diff --git a/.env.model b/.env.model index c54ac59..e6fa27c 100644 --- a/.env.model +++ b/.env.model @@ -3,13 +3,20 @@ DATABASE_URL=postgresql://encryption_runtime:changeme@localhost:5432/encryption? VAULT_URL=https://data.encryption.numerique.gouv.fr UI_URL=https://encryption.numerique.gouv.fr ALLOWED_FRAME_ANCESTORS=https://product-a.numerique.gouv.fr,https://product-b.numerique.gouv.fr -OIDC_JWKS_URL=https://proconnect.gouv.fr/.well-known/jwks.json -OIDC_SERVER_CLIENT_ID=encryption -OIDC_ISSUER=https://proconnect.gouv.fr +OIDC_ISSUER=https://auth.example.org OIDC_CLIENT_ID=encryption +OIDC_CLIENT_SECRET=changeme +OIDC_TOKEN_ENDPOINT_AUTH_METHOD=client_secret_post OIDC_REDIRECT_URI=https://encryption.numerique.gouv.fr/auth/callback OIDC_ACCEPT_UNVERIFIED_EMAIL=false OIDC_FALLBACK_TO_EMAIL_FOR_IDENTIFICATION=false +OIDC_SCOPES=openid +OIDC_CLAIMS_FIRST_NAME=given_name +OIDC_CLAIMS_LAST_NAME=usual_name,family_name +OIDC_CLAIMS_FULL_NAME=name +OIDC_STEP_UP_ACR_VALUES=eidas0-mfa,eidas1-mfa,eidas2,eidas3 +OIDC_STEP_UP_AMR_VALUES=mfa,otp,totp,hwk,sc,pop +OIDC_STEP_UP_MAX_AGE_SECONDS=300 MAILER_SMTP_HOST=127.0.0.1 MAILER_SMTP_PORT=7208 MAILER_SMTP_USER= @@ -22,7 +29,7 @@ MAILER_FALLBACK_SMTP_USER= MAILER_FALLBACK_SMTP_PASSWORD= MAILER_FALLBACK_SMTP_SECURE=false MAILER_FALLBACK_SMTP_REQUIRE_TLS=true -MAILER_DEFAULT_DOMAIN=numerique.gouv.fr +MAILER_SENDER_ADDRESS=noreply@numerique.gouv.fr EMAIL_PRODUCT_URL=http://localhost:7201 EMAIL_PALETTE_PATH= BRAND_FONT= @@ -30,3 +37,5 @@ SENTRY_DSN= SENTRY_ENVIRONMENT= SENTRY_RELEASE= SECURITY_CONTACT_URL= +VAULT_PURGE_AFTER_DAYS=730 +VAULT_DEVICE_INACTIVITY_DAYS=365 diff --git a/.env.test b/.env.test index 9a44679..67b2b7e 100644 --- a/.env.test +++ b/.env.test @@ -1,19 +1,18 @@ PORT=7200 -DATABASE_URL=postgresql://postgres:changeme@localhost:7205/postgres?schema=encryption +DATABASE_URL=postgresql://postgres:changeme@localhost:7205/postgres VAULT_URL=http://data.encryption.localhost:7200 UI_URL=http://encryption.localhost:7200 ALLOWED_FRAME_ANCESTORS=http://localhost:7201,http://localhost:7202,http://a.product.localhost:7201,http://a.product.localhost:7202,http://b.product.localhost:7201,http://b.product.localhost:7202 -OIDC_JWKS_URL=http://localhost:7203/realms/encryption/protocol/openid-connect/certs -OIDC_SERVER_CLIENT_ID=encryption -# OIDC configuration for the encryption interface. -# Injected by the server into the HTML at runtime (no build-time variables). OIDC_ISSUER=http://localhost:7203/realms/encryption OIDC_CLIENT_ID=encryption +OIDC_CLIENT_SECRET=encryption-interface-dev-secret OIDC_REDIRECT_URI=http://encryption.localhost:7200/auth/callback +OIDC_STEP_UP_ACR_VALUES= +OIDC_STEP_UP_AMR_VALUES= MAILER_SMTP_HOST=127.0.0.1 MAILER_SMTP_PORT=7208 MAILER_SMTP_REQUIRE_TLS=false -MAILER_DEFAULT_DOMAIN=encryption.localhost +MAILER_SENDER_ADDRESS=noreply@encryption.localhost EMAIL_PRODUCT_URL=http://localhost:7201 SENTRY_DSN=http://11111111111111111111111111111111@localhost:7209/1 SENTRY_ENVIRONMENT=development diff --git a/.env.test.product b/.env.test.product index 3114128..34156a5 100644 --- a/.env.test.product +++ b/.env.test.product @@ -3,15 +3,15 @@ DATABASE_URL=postgresql://postgres:changeme@localhost:7205/postgres VAULT_URL=http://data.encryption.localhost:7200 UI_URL=http://encryption.localhost:7200 ALLOWED_FRAME_ANCESTORS=http://localhost:3000,http://127.0.0.1.nip.io:3000 -# Use usual LaSuite's Keycloak, adjust realm name depending on products ("docs", "meet"...) -OIDC_JWKS_URL=http://localhost:8083/realms/impress/protocol/openid-connect/certs -OIDC_SERVER_CLIENT_ID=encryption OIDC_ISSUER=http://localhost:8083/realms/impress OIDC_CLIENT_ID=encryption +OIDC_CLIENT_SECRET=encryption-interface-dev-secret OIDC_REDIRECT_URI=http://encryption.localhost:7200/auth/callback OIDC_ACCEPT_UNVERIFIED_EMAIL=true +OIDC_STEP_UP_ACR_VALUES= +OIDC_STEP_UP_AMR_VALUES= MAILER_SMTP_HOST=127.0.0.1 MAILER_SMTP_PORT=7208 MAILER_SMTP_REQUIRE_TLS=false -MAILER_DEFAULT_DOMAIN=encryption.localhost +MAILER_SENDER_ADDRESS=noreply@encryption.localhost EMAIL_PRODUCT_URL=http://localhost:3000 diff --git a/.github/actions/build-image/action.yml b/.github/actions/build-image/action.yml new file mode 100644 index 0000000..2f2e4e3 --- /dev/null +++ b/.github/actions/build-image/action.yml @@ -0,0 +1,69 @@ +name: Build the image +description: >- + Build the application image with its provenance and SBOM, load it in a containerd image + store, and scan it. Nothing is pushed: the caller logs in and pushes what passed, so a + release and a preview build the same way and differ only in where the image goes. + +inputs: + tags: + description: Comma-separated image references to build (registry/repository:tag) + required: true + primary-tag: + description: The reference among `tags` that is scanned and whose digest the caller reads + required: true + source-commit: + description: The commit the image is built from, recorded in the image + required: true +runs: + using: composite + steps: + - name: Set up Docker + if: ${{ !github.event.act }} # reconfigures the daemon, which act reaches through a mounted socket + uses: docker/setup-docker-action@77e84dbf09b47d1e29270283c22f16145aa85ca1 # v5.4.0 + with: + version: v29.7.2 # pinned like the rest, this job is the one holding a credential + # containerd store needed to let the build be scanned (provenance and SBOM) and then pushed + daemon-config: | + { + "features": { + "containerd-snapshotter": true + } + } + + - name: Make sure the containerd image store is active # needed to ensure `load: true` because otherwise attestation and provenance are dropped silently + if: ${{ !github.event.act }} # asserts the runner daemon, not whatever a developer happens to run + shell: bash + run: | + docker info --format '{{json .DriverStatus}}' | grep -q 'io.containerd.snapshotter' \ + || (echo "::error::The containerd image store is not active, attestations would be dropped." && exit 1) + + - name: Set up Docker buildx + uses: docker/setup-buildx-action@8d2750c68a42422c14e847fe6c8ac0403b4cbd6f # v3.12.0 + + - name: Build the image + uses: docker/build-push-action@10e90e3645eae34f1e60eeb005ba3a3d33f178e8 # v6.19.2 + with: + context: . + load: true + push: false + tags: ${{ inputs.tags }} + build-args: | + SOURCE_COMMIT=${{ inputs.source-commit }} + provenance: mode=max # attach repository info to the image + sbom: true # store attestation manifest inside the image index (including info from application `.cdx.json` files) + cache-from: type=gha + cache-to: type=gha,mode=max + + - name: Scan the image + # It will scan the Docker attached SBOM, but also the npm ones copied inside as `.cdx.json` + # "act" cannot support this since trivy action is doing `actions/checkout`, which needs a token act has none of + if: ${{ !github.event.act }} + uses: aquasecurity/trivy-action@ed142fd0673e97e23eac54620cfb913e5ce36c25 # v0.36.0 + env: + TRIVY_VEX: security/vex.openvex.json # allow bypass some if documented (opt-in file for external scanners, so fine if manipulated) + with: + image-ref: ${{ inputs.primary-tag }} + severity: HIGH,CRITICAL + ignore-unfixed: true + exit-code: '1' + format: table diff --git a/.github/actions/setup-helm/action.yml b/.github/actions/setup-helm/action.yml new file mode 100644 index 0000000..8db52c0 --- /dev/null +++ b/.github/actions/setup-helm/action.yml @@ -0,0 +1,36 @@ +name: Setup Helm +description: Helm and kubeconform for the chart tests and the chart release, helmfile for the test that renders deploy/helmfile/preview. + +runs: + using: composite + steps: + - name: Install Helm, kubeconform and helmfile + shell: bash + env: + HELM_VERSION: v3.19.0 + HELM_SHA256: a7f81ce08007091b86d8bd696eb4d86b8d0f2e1b9f6c714be62f82f96a594496 + KUBECONFORM_VERSION: v0.7.0 + KUBECONFORM_SHA256: c31518ddd122663b3f3aa874cfe8178cb0988de944f29c74a0b9260920d115d3 + HELMFILE_VERSION: 1.1.7 + HELMFILE_SHA256: e9d870f4e502b9f0850d7e0546cab8b418ff7f44ff4df0ed54cd3df8dfda189c + run: | # zizmor: ignore[github-env] the path written is a constant, never derived from an input + set -euo pipefail + mkdir -p "${HOME}/.local/bin" + + curl -sSfL "https://get.helm.sh/helm-${HELM_VERSION}-linux-amd64.tar.gz" -o /tmp/helm.tgz + echo "${HELM_SHA256} /tmp/helm.tgz" | sha256sum -c - + tar -xzf /tmp/helm.tgz -C /tmp linux-amd64/helm + mv /tmp/linux-amd64/helm "${HOME}/.local/bin/helm" + + curl -sSfL "https://github.com/yannh/kubeconform/releases/download/${KUBECONFORM_VERSION}/kubeconform-linux-amd64.tar.gz" -o /tmp/kubeconform.tgz + echo "${KUBECONFORM_SHA256} /tmp/kubeconform.tgz" | sha256sum -c - + tar -xzf /tmp/kubeconform.tgz -C "${HOME}/.local/bin" kubeconform + + curl -sSfL "https://github.com/helmfile/helmfile/releases/download/v${HELMFILE_VERSION}/helmfile_${HELMFILE_VERSION}_linux_amd64.tar.gz" -o /tmp/helmfile.tgz + echo "${HELMFILE_SHA256} /tmp/helmfile.tgz" | sha256sum -c - + tar -xzf /tmp/helmfile.tgz -C "${HOME}/.local/bin" helmfile + + echo "${HOME}/.local/bin" >> "${GITHUB_PATH}" + "${HOME}/.local/bin/helm" version --short + "${HOME}/.local/bin/kubeconform" -v + "${HOME}/.local/bin/helmfile" --version diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 04a4afb..c48e60f 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -5,7 +5,8 @@ on: branches: - main tags: - - 'v*' + - 'v*' # the application: image build, scan, push, attestation + - 'chart/v*' # the Helm chart alone: package, push, attestation (never mixed with the application jobs, see each job's `if`) pull_request: permissions: # Default permissions @@ -19,6 +20,7 @@ concurrency: env: CONTAINER_REGISTRY: docker.io CONTAINER_IMAGE: lasuite/encryption + CHART_IMAGE: lasuite/encryption-chart # its own repository: chart versions must not collide with image tags NODE_OPTIONS: --max-old-space-size=4096 NODE_VERSION: 24.19.0 NPM_VERSION: 12.0.2 @@ -27,6 +29,7 @@ jobs: # TODO: add egress filter when GitHub releases it end of 2026, and maybe scopes on events that trigger the pipeline supply-chain: name: Supply chain + if: ${{ !startsWith(github.ref, 'refs/tags/chart/') }} # a chart tag runs only the chart jobs runs-on: ubuntu-latest timeout-minutes: 30 permissions: @@ -61,6 +64,7 @@ jobs: verify: name: Verify + if: ${{ !startsWith(github.ref, 'refs/tags/chart/') }} # a chart tag runs only the chart jobs runs-on: ubuntu-latest timeout-minutes: 30 permissions: @@ -104,6 +108,7 @@ jobs: test: name: Unit tests + if: ${{ !startsWith(github.ref, 'refs/tags/chart/') }} # a chart tag runs only the chart jobs runs-on: ubuntu-latest timeout-minutes: 30 permissions: @@ -128,6 +133,7 @@ jobs: e2e: name: Story tests + if: ${{ !startsWith(github.ref, 'refs/tags/chart/') }} # a chart tag runs only the chart jobs runs-on: ubuntu-latest timeout-minutes: 30 permissions: @@ -170,10 +176,51 @@ jobs: - name: Test end-to-end run: npm run test:e2e:headless -- --max-workers $(nproc) + chromatic: + name: Publish Storybook + # Pull requests to review visual changes, and the CI branch whose builds are the baselines + # and the public permalink. A tag's commit was already published from its branch. + if: ${{ !startsWith(github.ref, 'refs/tags/') }} + runs-on: ubuntu-latest + timeout-minutes: 20 + permissions: + contents: read + steps: + - name: Checkout + uses: actions/checkout@fbc6f3992d24b796d5a048ff273f7fcc4a7b6c09 # v5.1.0 + with: + persist-credentials: false + fetch-depth: 0 # TurboSnap walks the history to find the baseline build and the files changed since + ref: ${{ github.event.pull_request.head.sha || github.sha }} # the pull request's own commit, not the synthetic merge commit Chromatic cannot match to a branch + + - name: Setup + uses: ./.github/actions/setup + with: + node-version: ${{ env.NODE_VERSION }} + npm-version: ${{ env.NPM_VERSION }} + + - name: Publish to Chromatic + # No token on a fork's pull request, a Dependabot one, or "act": skipped, not failed. + # Changes never fail the job (`exitZeroOnChanges`), they are reviewed in Chromatic. A push accepts + # them since it is what was merged, so the next pull requests are compared with it. + env: + CHROMATIC_PROJECT_TOKEN: ${{ secrets.CHROMATIC_PROJECT_TOKEN }} + EVENT_NAME: ${{ github.event_name }} + run: | + if [ -z "${CHROMATIC_PROJECT_TOKEN}" ]; then + echo "::notice::No CHROMATIC_PROJECT_TOKEN available, Storybook is not published" + exit 0 + fi + if [ "${EVENT_NAME}" = "push" ]; then + npm run chromatic -- --auto-accept-changes + else + npm run chromatic + fi + build: name: Build # when releasing the build will be done with docker, so only target pull requests here - if: ${{ github.event.act || github.event_name == 'pull_request' }} + if: ${{ github.event.act || (github.event_name == 'pull_request' && !startsWith(github.ref, 'refs/tags/chart/')) }} runs-on: ubuntu-latest timeout-minutes: 30 permissions: @@ -196,10 +243,79 @@ jobs: - name: Build run: npm run build + helm: + name: Helm chart + if: ${{ !startsWith(github.ref, 'refs/tags/v') }} # an application tag never touches the chart + runs-on: ubuntu-latest + timeout-minutes: 15 + permissions: + contents: read + steps: + - name: Checkout + uses: actions/checkout@fbc6f3992d24b796d5a048ff273f7fcc4a7b6c09 # v5.1.0 + with: + persist-credentials: false + + - name: Setup + uses: ./.github/actions/setup + with: + node-version: ${{ env.NODE_VERSION }} + npm-version: ${{ env.NPM_VERSION }} + + - name: Install Helm and kubeconform + uses: ./.github/actions/setup-helm + + - name: Test the chart # lint, values schema, Kubernetes API schemas, rendered objects, snapshot + run: npm run test:helm + + changes: + name: Changes since the last successful run + # A branch build republishes `main` (image) and `0.0.0-main` (chart) only when their part changed + # since the last run of this workflow that succeeded on the branch. Not since the previous push: + # a run whose release failed or was cancelled is not a base, so the next push publishes again. + # `.github` counts for both, a workflow change can change what gets published. + # A tag, "act", a pull request, no successful run yet, or its commit gone (force-push) publish anyway. + runs-on: ubuntu-latest + timeout-minutes: 5 + permissions: + contents: read + actions: read # to find the last successful run + outputs: + app: ${{ steps.diff.outputs.app }} + chart: ${{ steps.diff.outputs.chart }} + steps: + - name: Checkout + uses: actions/checkout@fbc6f3992d24b796d5a048ff273f7fcc4a7b6c09 # v5.1.0 + with: + persist-credentials: false + fetch-depth: 0 # the last successful run's commit has to be there to diff against it + + - name: Compare with the last successful run + id: diff + env: + GH_TOKEN: ${{ github.token }} + run: | + BASE="" + if [[ "${GITHUB_EVENT_NAME}" == "push" && "${GITHUB_REF_TYPE}" == "branch" ]]; then + WORKFLOW_FILE="$(basename "${GITHUB_WORKFLOW_REF%@*}")" + BASE="$(gh api "repos/${GITHUB_REPOSITORY}/actions/workflows/${WORKFLOW_FILE}/runs?branch=${GITHUB_REF_NAME}&event=push&status=success&per_page=1" \ + --jq '.workflow_runs[0].head_sha // empty')" || BASE="" + fi + if [[ -z "${BASE}" ]] || ! git cat-file -e "${BASE}^{commit}" 2> /dev/null; then + echo "No usable successful run to compare with, publishing everything" + echo "app=true" >> "$GITHUB_OUTPUT" + echo "chart=true" >> "$GITHUB_OUTPUT" + exit 0 + fi + echo "Comparing with ${BASE}, the last successful run" + changed() { git diff --quiet "${BASE}" "${GITHUB_SHA}" -- "$@" && echo false || echo true; } + echo "app=$(changed . ':(exclude)deploy/helm')" >> "$GITHUB_OUTPUT" + echo "chart=$(changed deploy/helm/encryption .github)" >> "$GITHUB_OUTPUT" + release: name: Release - needs: [supply-chain, verify, test, e2e] - if: ${{ github.event.act || github.ref_name == 'main' || github.ref_type == 'tag' }} + needs: [supply-chain, verify, test, e2e, changes] + if: ${{ (github.event.act || github.ref_name == 'main' || startsWith(github.ref, 'refs/tags/v')) && needs.changes.outputs.app == 'true' }} runs-on: ubuntu-latest timeout-minutes: 30 environment: release @@ -213,28 +329,6 @@ jobs: with: persist-credentials: false - - name: Set up Docker - if: ${{ !github.event.act }} # reconfigures the daemon, which act reaches through a mounted socket - uses: docker/setup-docker-action@77e84dbf09b47d1e29270283c22f16145aa85ca1 # v5.4.0 - with: - version: v29.7.2 # pinned like the rest, this job is the one holding a credential - # containerd store needed to let the build be scanned (provenance and SBOM) and then pushed - daemon-config: | - { - "features": { - "containerd-snapshotter": true - } - } - - - name: Make sure the containerd image store is active # needed to ensure `load: true` because otherwise attestation and provenance are dropped silently - if: ${{ !github.event.act }} # asserts the runner daemon, not whatever a developer happens to run - run: | - docker info --format '{{json .DriverStatus}}' | grep -q 'io.containerd.snapshotter' \ - || (echo "::error::The containerd image store is not active, attestations would be dropped." && exit 1) - - - name: Set up Docker buildx - uses: docker/setup-buildx-action@8d2750c68a42422c14e847fe6c8ac0403b4cbd6f # v3.12.0 - - name: Compute the image tags id: docker-meta run: | @@ -250,33 +344,12 @@ jobs: echo "tags=${TAGS}" >> $GITHUB_OUTPUT echo "primary-tag=${PRIMARY}" >> $GITHUB_OUTPUT - - name: Build the image - uses: docker/build-push-action@10e90e3645eae34f1e60eeb005ba3a3d33f178e8 # v6.19.2 + - name: Build and scan the image + uses: ./.github/actions/build-image with: - context: . - load: true - push: false tags: ${{ steps.docker-meta.outputs.tags }} - build-args: | - SOURCE_COMMIT=${{ github.sha }} - provenance: mode=max # attach repository info to the image - sbom: true # store attestation manifest inside the image index (including info from application `.cdx.json` files) - cache-from: type=gha - cache-to: type=gha,mode=max - - - name: Scan the image - # It will scan the Docker attached SBOM, but also the npm ones copied inside as `.cdx.json` - # "act" cannot support this since trivy action is doing `actions/checkout`, which needs a token act has none of - if: ${{ !github.event.act }} - uses: aquasecurity/trivy-action@ed142fd0673e97e23eac54620cfb913e5ce36c25 # v0.36.0 - env: - TRIVY_VEX: security/vex.openvex.json # allow bypass some if documented (opt-in file for external scanners, so fine if manipulated) - with: - image-ref: ${{ steps.docker-meta.outputs.primary-tag }} - severity: HIGH,CRITICAL - ignore-unfixed: true - exit-code: '1' - format: table + primary-tag: ${{ steps.docker-meta.outputs.primary-tag }} + source-commit: ${{ github.sha }} - name: Log in to the container registry if: ${{ !github.event.act }} @@ -313,3 +386,80 @@ jobs: # # Deployment must be handled outside this repository, either by using GitHub hooks, regular checks, or by manually deploying # + + release-chart: + name: Release the chart + needs: [helm, changes] + # Either having the chart release tag, or the main branch to have the same behavior than Docker images when tests are needed + if: ${{ (github.event.act || github.ref_name == 'testci' || startsWith(github.ref, 'refs/tags/chart/v')) && needs.changes.outputs.chart == 'true' }} + runs-on: ubuntu-latest + timeout-minutes: 15 + environment: release + permissions: + contents: read + id-token: write + attestations: write + steps: + - name: Checkout + uses: actions/checkout@fbc6f3992d24b796d5a048ff273f7fcc4a7b6c09 # v5.1.0 + with: + persist-credentials: false + + - name: Install Helm + uses: ./.github/actions/setup-helm + + - name: Compute the chart version # `chart/v1.4.0` -> `1.4.0`, a branch build -> `0.0.0-main` + id: chart-meta + env: + REF_NAME: ${{ github.ref_name }} + REF_TYPE: ${{ github.ref_type }} + # A branch build publishes one fixed version that the next build overwrites, the chart's + # counterpart of the image's `main` tag. Not `main` itself: a chart version must be + # SemVer and Helm refuses it. A pre-release is also skipped when Helm resolves the + # latest version, so an install that does not name this version never gets it. + run: | + if [[ "${REF_TYPE}" == "tag" ]]; then + VERSION="${REF_NAME#chart/v}" + [[ "${VERSION}" =~ ^[0-9]+\.[0-9]+\.[0-9]+(-[0-9A-Za-z.-]+)?$ ]] || { echo "::error::${REF_NAME} is not chart/vX.Y.Z"; exit 1; } + else + VERSION="0.0.0-main" + fi + echo "version=${VERSION}" >> "$GITHUB_OUTPUT" + + - name: Package the chart + env: + VERSION: ${{ steps.chart-meta.outputs.version }} + run: helm package deploy/helm/encryption --version "${VERSION}" --destination /tmp/chart + + - name: Log in to the container registry + # Docker's credential file, not `helm registry login`: the attestation push reads only that file, and Helm falls back to it + if: ${{ !github.event.act }} + uses: docker/login-action@c94ce9fb468520275223c153574b00df6fe4bcc9 # v3.7.0 + with: + registry: ${{ env.CONTAINER_REGISTRY }} + username: ${{ secrets.DOCKER_HUB_USER }} + password: ${{ secrets.DOCKER_HUB_PASSWORD }} + + - name: Push the chart + id: chart-push + if: ${{ !github.event.act }} + env: + VERSION: ${{ steps.chart-meta.outputs.version }} + # Through `tee`, not `$(...)`: under `bash -e` a failed substitution exits before its + # output is printed, which hides Helm's error. `pipefail` keeps that failure fatal. + run: | + set -o pipefail + helm push "/tmp/chart/encryption-chart-${VERSION}.tgz" oci://registry-1.docker.io/lasuite 2>&1 | tee "${RUNNER_TEMP}/helm-push.log" + DIGEST="$(sed -n 's/^Digest: //p' "${RUNNER_TEMP}/helm-push.log")" + [ -n "${DIGEST}" ] || { echo "::error::helm push printed no digest"; exit 1; } + echo "digest=${DIGEST}" >> "$GITHUB_OUTPUT" + echo "Pin it as \`${CHART_IMAGE}:${VERSION}@${DIGEST}\`" >> "$GITHUB_STEP_SUMMARY" + + - name: Publish the build provenance + # Same Sigstore attestation as the image; `cosign verify-attestation` on the chart digest reads it (see deploy/helm/encryption/README.md) + if: ${{ !github.event.act }} + uses: actions/attest-build-provenance@977bb373ede98d70efdf65b84cb5f73e068dcc2a # v3.0.0 + with: + subject-name: ${{ env.CONTAINER_REGISTRY }}/${{ env.CHART_IMAGE }} + subject-digest: ${{ steps.chart-push.outputs.digest }} + push-to-registry: true diff --git a/.github/workflows/preview.yml b/.github/workflows/preview.yml new file mode 100644 index 0000000..bca2594 --- /dev/null +++ b/.github/workflows/preview.yml @@ -0,0 +1,271 @@ +name: Preview environment + +# One ephemeral environment per pull request carrying the `preview` label. The environment +# itself is not created here: an Argo CD ApplicationSet in the deployment repository +# (deploy/argocd/applicationset-preview.example.yaml) lists the labelled pull requests +# and renders deploy/helmfile/preview at each one's own commit, then removes it when the +# pull request closes or loses the label. This repository's part is the image of the +# pull request's own code, a comment saying where the environment is, and a nudge so Argo +# CD looks now rather than at its next poll. +# +# The image goes where the releases go, `lasuite/encryption` on Docker Hub, so there is +# one registry to know about. Tags: `pr-`, moving with the pull request (what the +# preview helmfile derives from the pull request number by itself), `sha-<8 chars>` for +# the exact commit (for an ApplicationSet that wants a rollout on every push), and the +# branch name for a manual run ("Run workflow" on a `feat/` branch, for a build to +# point a stack at without a pull request: the prefix says the build is a feature in +# progress, and any other branch is refused with a message). Releases (`vX.Y.Z`, +# `latest`) and `main` are never touched: +# the repository's immutable-tag rule protects the versions, and this workflow only writes +# the three shapes above. It also cleans after itself: a pull request's tag goes when it +# closes or loses the label, everything else of those shapes after thirty days. +# +# The push and the cleanup use the Docker Hub token of the `release` environment, the one +# the CI releases with. Its deployment branch rules must admit these runs: a pull request +# runs on GitHub's merge ref, admitted by a branch rule `refs/pull/*/merge`, and a manual +# run on its `feat/*` branch. A token of its own, limited to read, write and +# delete on the repository, in an environment of its own, would keep the release +# credential with the release job alone: the two `environment:` lines below are all that +# changes then. +# +# The product images the environment runs come from labels of the pull request, +# `docs:` and `drive:` (`pr-` tags of a labelled upstream pull request, +# or a build pushed by hand, see deploy/helm/suite-stack/README.md, "Images"), which the +# ApplicationSet passes to the helmfile; without one, `main`. A label is an object of the +# repository, not of the pull request, so this workflow deletes a product label once no +# open pull request carries it any more: the list of labels never accumulates versions. + +on: + pull_request: + types: [labeled, unlabeled, opened, synchronize, closed] + workflow_dispatch: + schedule: + - cron: '17 4 * * 1' # Monday 04:17 UTC, the thirty-day purge + +permissions: + contents: read + +concurrency: + # A new push supersedes the build in progress of the same pull request or branch + group: preview-${{ github.event.pull_request.number || github.ref }} + cancel-in-progress: true + +env: + CONTAINER_REGISTRY: docker.io + CONTAINER_IMAGE: lasuite/encryption + +jobs: + image: + name: Publish the image of this code + # A labelled pull request of this repository (a fork's run gets no secret to push with), + # or a manual run on a feature branch + if: >- + (github.event_name == 'workflow_dispatch' && startsWith(github.ref, 'refs/heads/feat/')) || + (github.event_name == 'pull_request' && github.event.action != 'closed' && github.event.action != 'unlabeled' && + contains(github.event.pull_request.labels.*.name, 'preview') && + github.event.pull_request.head.repo.full_name == github.repository) + runs-on: ubuntu-latest + timeout-minutes: 30 + environment: release + permissions: + contents: read + outputs: + tag: ${{ steps.docker-meta.outputs.tag }} + steps: + - name: Checkout + uses: actions/checkout@fbc6f3992d24b796d5a048ff273f7fcc4a7b6c09 # v5.1.0 + with: + # The pull request's own commit, the one the ApplicationSet deploys, not the merge + # commit GitHub makes for the event; a manual run is on the branch head already + ref: ${{ github.event.pull_request.head.sha || github.sha }} + persist-credentials: false + + - name: Compute the image tags + id: docker-meta + env: + HEAD_SHA: ${{ github.event.pull_request.head.sha || github.sha }} + PR_NUMBER: ${{ github.event.pull_request.number }} + BRANCH: ${{ github.ref_name }} + run: | + PRIMARY="${CONTAINER_IMAGE}:sha-${HEAD_SHA:0:8}" + if [[ -n "${PR_NUMBER}" ]]; then + MOVING="pr-${PR_NUMBER}" + else + # A branch name as a tag: lowercase, nothing outside [a-z0-9._-], and never one + # of the shapes the CI owns (a branch called `main`, `latest` or `v1.2.3`) + MOVING="$(echo "${BRANCH}" | tr '[:upper:]' '[:lower:]' | sed 's/[^a-z0-9._-]/-/g' | cut -c1-120)" + if [[ "${MOVING}" == "main" || "${MOVING}" == "latest" || "${MOVING}" =~ ^v[0-9] ]]; then + MOVING="branch-${MOVING}" + fi + fi + echo "tags=${PRIMARY},${CONTAINER_IMAGE}:${MOVING}" >> "$GITHUB_OUTPUT" + echo "primary-tag=${PRIMARY}" >> "$GITHUB_OUTPUT" + echo "tag=sha-${HEAD_SHA:0:8}" >> "$GITHUB_OUTPUT" + echo "moving-tag=${MOVING}" >> "$GITHUB_OUTPUT" + + - name: Build and scan the image # the same steps as a release, see .github/actions/build-image + uses: ./.github/actions/build-image + with: + tags: ${{ steps.docker-meta.outputs.tags }} + primary-tag: ${{ steps.docker-meta.outputs.primary-tag }} + source-commit: ${{ github.event.pull_request.head.sha || github.sha }} + + - name: Log in to the container registry + uses: docker/login-action@c94ce9fb468520275223c153574b00df6fe4bcc9 # v3.7.0 + with: + registry: ${{ env.CONTAINER_REGISTRY }} + username: ${{ secrets.DOCKER_HUB_USER }} + password: ${{ secrets.DOCKER_HUB_PASSWORD }} + + - name: Push the scanned image + env: + TAGS: ${{ steps.docker-meta.outputs.tags }} + TAG: ${{ steps.docker-meta.outputs.tag }} + MOVING_TAG: ${{ steps.docker-meta.outputs.moving-tag }} + run: | + IFS=',' read -ra TAG_LIST <<< "${TAGS}" + for tag in "${TAG_LIST[@]}"; do + docker push "${tag}" + done + # The tags only: the repository name would be masked as the registry user's value + echo "Pushed the tags \`${TAG}\` and \`${MOVING_TAG}\`" >> "$GITHUB_STEP_SUMMARY" + + refused: + name: Refuse a manual run outside feat/ + # A skipped job says nothing; this one makes the run red with the reason + if: github.event_name == 'workflow_dispatch' && !startsWith(github.ref, 'refs/heads/feat/') + runs-on: ubuntu-latest + timeout-minutes: 1 + steps: + - run: | + echo "::error::A manual run publishes a feature branch only, named feat/: '${GITHUB_REF_NAME}' is not one. Open a pull request with the preview label instead." + exit 1 + + comment: + name: Say where it is + needs: [image] + if: github.event_name == 'pull_request' + runs-on: ubuntu-latest + timeout-minutes: 5 + permissions: + pull-requests: write + steps: + - uses: thollander/actions-comment-pull-request@24bffb9b452ba05a4f3f77933840a6a841d1b32b # v3.0.1 + with: + comment-tag: preview-url + message: | + :rocket: **Preview of this pull request, full stack** + + - Docs: https://${{ github.event.pull_request.number }}-docs.${{ vars.PREVIEW_DOMAIN }}/ + - Drive: https://${{ github.event.pull_request.number }}-drive.${{ vars.PREVIEW_DOMAIN }}/ + - Mailbox: https://${{ github.event.pull_request.number }}-mail.${{ vars.PREVIEW_DOMAIN }}/ + + **Accounts**: `alice.martin` / `alice.martin`, `bob.dupont` / `bob.dupont` (see `bootstrap.keycloak.users`), or register your own on the sign-in page. + + **Image**: `${{ env.CONTAINER_IMAGE }}:${{ needs.image.outputs.tag }}`, also tagged `pr-${{ github.event.pull_request.number }}`, which the environment runs. Every push republishes it; restart the `stack-encryption` Deployment in Argo CD to pick it up, unless the ApplicationSet follows the commit tag. + + Everything in this environment is public: put nothing real in it. It is destroyed when this pull request is closed or loses the `preview` label. + + notify-argocd: + name: Notify Argo CD + needs: [image] + # After the image exists, so the sync it triggers finds it. Skipped until the + # deployment repository side exists: the variable is set with the secrets when it does. + if: github.event_name == 'pull_request' && vars.ARGOCD_PREPROD_WEBHOOK_URL != '' + runs-on: ubuntu-latest + timeout-minutes: 5 + steps: + - uses: numerique-gouv/action-argocd-webhook-notification@cac2ee67896eb13e84e804f60c4271370424eaa8 # main + with: + deployment_repo_path: ${{ secrets.DEPLOYMENT_REPO_URL }} + argocd_webhook_secret: ${{ secrets.ARGOCD_PREPROD_WEBHOOK_SECRET }} + argocd_url: ${{ vars.ARGOCD_PREPROD_WEBHOOK_URL }} + + labels: + name: Delete the product labels nobody uses + # `docs:` and `drive:` are created for one pull request and would outlive it + # in the repository's label list: gone when the last open pull request carrying one + # closes or drops it. + if: >- + (github.event_name == 'pull_request' && github.event.action == 'closed') || + (github.event_name == 'pull_request' && github.event.action == 'unlabeled' && (startsWith(github.event.label.name, 'docs:') || startsWith(github.event.label.name, 'drive:'))) + runs-on: ubuntu-latest + timeout-minutes: 5 + permissions: + issues: write # labels are issue objects + pull-requests: read + env: + GH_TOKEN: ${{ github.token }} + # On a close, every product label of the pull request; on an unlabel, the one removed + CANDIDATES: ${{ github.event.action == 'unlabeled' && github.event.label.name || join(github.event.pull_request.labels.*.name, ',') }} + steps: + - run: | + IN_USE="$(gh api --paginate "repos/${GITHUB_REPOSITORY}/pulls?state=open&per_page=100" --jq '.[].labels[].name' || true)" + IFS=',' read -ra LABELS <<< "${CANDIDATES}" + for label in "${LABELS[@]}"; do + [[ "${label}" == docs:* || "${label}" == drive:* ]] || continue + if grep -qxF "${label}" <<< "${IN_USE}"; then + echo "Keeping ${label}, an open pull request carries it" + continue + fi + echo "Deleting the label ${label}" + gh label delete "${label}" --yes + done + + clean: + name: Clean the registry + # The pull request's tag when it closes or loses the label (the environment goes with + # it), and once a week every `pr-`, `sha-` or branch tag older than thirty days whose + # pull request is not open and labelled any more. Docker Hub has no expiry of its own, + # and its immutable-tag rule on the repository refuses the deletion of a version anyway. + if: >- + github.event_name == 'schedule' || + (github.event_name == 'pull_request' && github.event.action == 'closed' && contains(github.event.pull_request.labels.*.name, 'preview')) || + (github.event_name == 'pull_request' && github.event.action == 'unlabeled' && github.event.label.name == 'preview') + runs-on: ubuntu-latest + timeout-minutes: 10 + environment: release + permissions: + contents: read + pull-requests: read # the open labelled pull requests, whose tags the purge keeps + env: + GH_TOKEN: ${{ github.token }} + PR_NUMBER: ${{ github.event.pull_request.number }} + steps: + - name: Delete the tags + env: + DOCKER_HUB_USER: ${{ secrets.DOCKER_HUB_USER }} + DOCKER_HUB_PASSWORD: ${{ secrets.DOCKER_HUB_PASSWORD }} + run: | + TOKEN="$(curl -sSf -X POST https://hub.docker.com/v2/users/login/ -H 'Content-Type: application/json' \ + -d "$(jq -n --arg u "${DOCKER_HUB_USER}" --arg p "${DOCKER_HUB_PASSWORD}" '{username: $u, password: $p}')" | jq -r .token)" + delete_tag() { + echo "Deleting ${CONTAINER_IMAGE}:$1" + curl -sSf -X DELETE -H "Authorization: JWT ${TOKEN}" "https://hub.docker.com/v2/repositories/${CONTAINER_IMAGE}/tags/$1/" + } + + if [[ -n "${PR_NUMBER}" ]]; then + delete_tag "pr-${PR_NUMBER}" + exit 0 + fi + + # The weekly purge. Tags this workflow writes (`pr-`, `sha-<8>`, a branch name, + # that is anything but `main`, `latest` and versions), last pushed more than thirty + # days ago, except the `pr-` of a pull request still open and labelled. + KEEP="$(gh api --paginate "repos/${GITHUB_REPOSITORY}/pulls?state=open&per_page=100" \ + --jq '.[] | select(any(.labels[]; .name == "preview")) | "pr-\(.number)"' | paste -sd, - || true)" + CUTOFF="$(date -u -d '30 days ago' +%Y-%m-%dT%H:%M:%SZ)" + URL="https://hub.docker.com/v2/repositories/${CONTAINER_IMAGE}/tags/?page_size=100" + while [[ -n "${URL}" && "${URL}" != "null" ]]; do + PAGE="$(curl -sSf -H "Authorization: JWT ${TOKEN}" "${URL}")" + while IFS= read -r tag; do + [[ -z "${tag}" ]] && continue + delete_tag "${tag}" + done < <(echo "${PAGE}" | jq -r --arg cutoff "${CUTOFF}" --arg keep ",${KEEP}," ' + .results[] + | select(.name != "main" and .name != "latest" and (.name | test("^v[0-9]") | not)) + | select(.last_updated < $cutoff) + | .name as $name | select(($keep | index("," + $name + ",")) == null) + | $name') + URL="$(echo "${PAGE}" | jq -r .next)" + done diff --git a/.gitignore b/.gitignore index da5292f..9057390 100644 --- a/.gitignore +++ b/.gitignore @@ -19,6 +19,8 @@ node_modules !/.storybook/**/* /storybook-static/ /build-storybook.log +/chromatic.log +/chromatic-diagnostics.json # misc .DS_Store @@ -32,3 +34,7 @@ npm-debug.log* .env.development.local .env.test.local .env.production.local + +# stack chart dependencies (allow moving version of encryption chart) +deploy/helm/suite-stack/charts/ +deploy/helm/suite-stack/Chart.lock diff --git a/.prettierignore b/.prettierignore index edf9680..fbd8346 100644 --- a/.prettierignore +++ b/.prettierignore @@ -1,6 +1,8 @@ /.cache/ /.github/CODEOWNERS /build/ +/deploy/helm/encryption/templates/ +/deploy/helm/suite-stack/templates/ /dist/ /node_modules/ /src/generated/ diff --git a/.storybook/WithDocumentRenderer.tsx b/.storybook/WithDocumentRenderer.tsx deleted file mode 100644 index 518b2ac..0000000 --- a/.storybook/WithDocumentRenderer.tsx +++ /dev/null @@ -1,34 +0,0 @@ -import { type DocumentProps, usePDF } from '@react-pdf/renderer'; -import type { Decorator } from '@storybook/react'; -import { type ReactElement } from 'react'; - -/** - * Renders a story that IS a `@react-pdf/renderer` into a real PDF and - * shows it in the browser's native PDF reader (an