From fc37d09e45d1ee966e7afdaff3751ca5cdbb1dcb Mon Sep 17 00:00:00 2001 From: Tore Martin Hagen Date: Wed, 11 Nov 2026 10:44:52 +0100 Subject: [PATCH 1/2] Show a plan per account before deploy-lambda.yml's approval A code swap has no Terraform plan, so a reviewer approved a fingerprint without seeing what it replaces. A plan job per account now writes a job summary naming the fingerprint and commit the account runs and will run, a compare link between the commits, and the new build's Kosli trail. The commit of the running code is looked up in the package's Kosli flow when the caller passes kosli_flow and the token. The gate moves from a separate approval job to the deploy jobs, which wait for the plan: one approval releases every job waiting on the environment, and each account's deploy is recorded against it. Part of kosli-dev/server#7913 Co-Authored-By: Claude Opus 5.5 --- .github/workflows/deploy-lambda.yml | 109 ++++++++++++++++-- ...li-reporting-for-lambda-functions-ci-ru.md | 17 ++- 2 files changed, 112 insertions(+), 14 deletions(-) diff --git a/.github/workflows/deploy-lambda.yml b/.github/workflows/deploy-lambda.yml index 9a723e2..4c4614f 100644 --- a/.github/workflows/deploy-lambda.yml +++ b/.github/workflows/deploy-lambda.yml @@ -29,6 +29,27 @@ on: package_bucket: default: "lambda-packages-dacef8339fbd41ce31c346f854a85d0c74f7c4e8" type: string + kosli_flow: + description: "Kosli flow of the package, from build-lambda.yml. With kosli_trail and the kosli_api_token secret, the plan names the commit each account runs." + default: "" + type: string + kosli_trail: + description: "Kosli trail of the package, from build-lambda.yml: the commit it was built from." + default: "" + type: string + kosli_host: + default: "https://app.kosli.com" + type: string + kosli_org: + default: "kosli" + type: string + kosli_cli_version: + default: "latest" + type: string + secrets: + kosli_api_token: + description: "Reads the flow, to find the commit of the code each account runs. Without it the plan shows fingerprints only." + required: false jobs: accounts: @@ -42,20 +63,90 @@ jobs: with: environments: ${{ inputs.environments }} - # One approval covers every account of the environments, rather than one per - # matrix job. - approval: - if: inputs.github_environment != '' + # What each account runs and what it will run, in the job summary, so a + # reviewer of the deploy sees it before approving. + plan: + name: Plan for ${{ matrix.name }} + needs: accounts runs-on: ubuntu-latest - timeout-minutes: 5 - environment: ${{ inputs.github_environment }} + timeout-minutes: 10 + permissions: + id-token: write + contents: read + strategy: + fail-fast: false + matrix: + include: ${{ fromJSON(needs.accounts.outputs.json) }} + env: + FUNCTION_NAME: ${{ inputs.function_name }} + FINGERPRINT: ${{ inputs.fingerprint }} + ACCOUNT: ${{ matrix.name }} + REGION: ${{ matrix.aws_region }} + KOSLI_ORG: ${{ inputs.kosli_org }} + KOSLI_HOST: ${{ inputs.kosli_host }} + KOSLI_API_TOKEN: ${{ secrets.kosli_api_token }} + KOSLI_FLOW: ${{ inputs.kosli_flow }} + NEW_COMMIT: ${{ inputs.kosli_trail }} + REPOSITORY_URL: ${{ github.server_url }}/${{ github.repository }} steps: - - run: echo "Approved deploying ${{ inputs.fingerprint }} to ${{ inputs.function_name }}" + - name: Configure AWS credentials + uses: aws-actions/configure-aws-credentials@ec61189d14ec14c8efccab744f656cffd0e33f37 # v6.1.0 + with: + role-to-assume: arn:aws:iam::${{ matrix.aws_account_id }}:role/${{ inputs.role_name }} + aws-region: ${{ matrix.aws_region }} + role-session-name: ${{ inputs.role_name }} + action-timeout-s: 45 + retry-max-attempts: 3 + + - uses: kosli-dev/setup-cli-action@060e7f9bf6ac246743d6a62c5df847dfe7050867 # v5.2.1 + if: env.KOSLI_API_TOKEN != '' && env.KOSLI_FLOW != '' + with: + version: ${{ inputs.kosli_cli_version }} + + # Lambda reports the code's SHA-256 in base64; Kosli fingerprints in hex. + - name: Compare the running code with the package + run: | + set -euo pipefail + running=$(aws lambda get-function --function-name "$FUNCTION_NAME" \ + --query Configuration.CodeSha256 --output text | base64 -d | od -An -tx1 | tr -d ' \n') + + old_commit="" + if [ -n "$KOSLI_API_TOKEN" ] && [ -n "$KOSLI_FLOW" ]; then + old_commit=$(kosli get artifact "$KOSLI_FLOW@$running" --output json 2>/dev/null \ + | jq -r '[.. | objects | .git_commit? | strings][0] // empty' || true) + fi + short() { printf '`%s`' "${1:0:12}"; } + trail_url="$KOSLI_HOST/$KOSLI_ORG/flows/$KOSLI_FLOW/trails/$NEW_COMMIT" + { + echo "### $FUNCTION_NAME in $ACCOUNT ($REGION)" + echo + echo "| | Fingerprint | Commit |" + echo "|---|---|---|" + echo "| Running | $(short "$running") | ${old_commit:+$(short "$old_commit")} |" + echo "| Will deploy | $(short "$FINGERPRINT") | ${NEW_COMMIT:+$(short "$NEW_COMMIT")} |" + echo + if [ "$running" = "$FINGERPRINT" ]; then + echo "**No change:** the account already runs this package." + elif [ -n "$old_commit" ] && [ -n "$NEW_COMMIT" ]; then + echo "**Change:** [${old_commit:0:12}...${NEW_COMMIT:0:12}]($REPOSITORY_URL/compare/$old_commit...$NEW_COMMIT)" + elif [ -n "$KOSLI_API_TOKEN" ] && [ -n "$KOSLI_FLOW" ]; then + echo "**Change:** the running code is not an artifact of \`$KOSLI_FLOW\`, so it has no build trail." + else + echo "**Change:** the code changes; without a Kosli flow and token the running code's commit is unknown." + fi + if [ -n "$KOSLI_FLOW" ] && [ -n "$NEW_COMMIT" ]; then + echo + echo "Build trail, with its control decisions: $trail_url" + fi + } >> "$GITHUB_STEP_SUMMARY" + + # With github_environment set, the deploys wait on its protection rules, and + # one approval releases every account's deploy. deploy: name: Deploy to ${{ matrix.name }} - needs: [accounts, approval] - if: ${{ !cancelled() && needs.accounts.result == 'success' && contains(fromJSON('["success", "skipped"]'), needs.approval.result) }} + needs: [accounts, plan] + environment: ${{ inputs.github_environment }} runs-on: ubuntu-latest timeout-minutes: 15 permissions: diff --git a/docs/handover/7913-improve-kosli-reporting-for-lambda-functions-ci-ru.md b/docs/handover/7913-improve-kosli-reporting-for-lambda-functions-ci-ru.md index 5c0f07a..e8f25cf 100644 --- a/docs/handover/7913-improve-kosli-reporting-for-lambda-functions-ci-ru.md +++ b/docs/handover/7913-improve-kosli-reporting-for-lambda-functions-ci-ru.md @@ -36,7 +36,7 @@ source, with the PR that reviewed it and a decision per control. (terraform-base-infra#1247) - [x] Golden pipeline: a reusable workflow in this repo that zips, tests, uploads, attests, evaluates the controls and deploys to staging, then prod after approval (#46) -- [ ] Pilot: `kosli-queue-sampler` (terraform-server#685 merged; first run pending) +- [x] Pilot: `kosli-queue-sampler` (terraform-server#685) - [ ] Remaining flavour A functions - [ ] Flavour D (notify-slack), built from the pinned module source - [ ] Flavour C (`upload-package` in terraform-aws-evidence-reporter attests its zip) @@ -58,7 +58,10 @@ source, with the PR that reviewed it and a decision per control. | sdlc-policies `7913-dependencies-zip-variant` | sdlc-policies#60 | merged | | terraform-base-infra `7913-lambda-deploy-role` | terraform-base-infra#1251 (prod: #1253) | merged | | terraform-server `7913-queue-sampler-pipeline` | terraform-server#685 | merged | -| `7913-lambda-params-fix` | — | in progress | +| `7913-lambda-params-fix` | #47 | merged | +| sdlc-policies `7913-unit-tests-only` | sdlc-policies#61 | merged | +| `7913-unit-tests-only-pin` | #48 | merged | +| `7913-deploy-lambda-plan` | — | in progress | --- @@ -82,6 +85,8 @@ source, with the PR that reviewed it and a decision per control. - A function whose code the pipeline deploys is a plain `aws_lambda_function` with its package in `ignore_changes`, not a `terraform-aws-modules/lambda` call: the module names its package after the content and the provider swaps the code when the name changes, so any apply after a source change would put back an unattested build. The module's `ignore_source_code_hash` leaves the name tracked; a fixed placeholder package was rejected because the first apply swaps it in and breaks the function; Terraform downloading the deployed package by a pointer was rejected for a drift diff after every deploy; moving each function's Terraform into its own repo was rejected for the extra applies a new environment would need. The module still builds the package a new environment's function starts with. - Each repo deploys with its own role, trusting only `deploy-lambda.yml` on main, rather than with its Terraform role, so the deploy can swap code and nothing else. - Production deploys wait on a GitHub environment named `production` with `tech-team` as required reviewer and `master` as the only deployment branch, the same name and gate as the proposal to replace the prod branch. +- SDLC-CTRL-0008 lets a consumer require the unit-test suite alone (sdlc-policies#61), because the Lambda functions have unit tests only; this lowers the control's floor for every consumer that asks for it. +- A deploy shows a plan before its approval, one job summary per account naming the fingerprint and commit it runs and will run, because a code swap has no Terraform plan to review; the gate sits on the deploy jobs themselves, since one approval releases every job waiting on the environment. --- @@ -94,8 +99,10 @@ source, with the PR that reviewed it and a decision per control. resources ignoring its package, the unqualified EventBridge permission only, and the `production` environment (terraform-server#685, terraform-base-infra#1251) - [x] `lambda-package-publisher`'s trust on `job_workflow_ref`: the pilot's first build uploaded -- [ ] The pilot's first run failed in the control decisions: the build passed empty params, because `jq` read the - empty stdin; fixed by `jq -n` in this branch. Re-run `queue-sampler.yml` once it is merged -- [ ] `terraform-server-lambda-deploy`'s trust on `job_workflow_ref` is verified only by the first deploy +- [x] The pilot deployed to staging, infra-dev and production with all four decisions passing, after `jq -n` + (#47) and the unit-tests-only floor (sdlc-policies#61, #48); `terraform-server-lambda-deploy`'s trust works +- [ ] The deploy plan: `queue-sampler.yml` passes `kosli_flow`, `kosli_trail` and the Kosli token; the first run + verifies the commit lookup, that an empty `github_environment` deploys without one, and that one approval + releases every production account - [ ] The remaining flavour A functions, with a shared function module once a second one shows the shape - [ ] README documentation of `build-lambda.yml` and `deploy-lambda.yml` From db312d560b7e74e039002b2398024287b8d4fbf2 Mon Sep 17 00:00:00 2001 From: Tore Martin Hagen Date: Wed, 11 Nov 2026 10:46:34 +0100 Subject: [PATCH 2/2] Quote the plan summary's code spans so shellcheck accepts them Part of kosli-dev/server#7913 Co-Authored-By: Claude Opus 5.5 --- .github/workflows/deploy-lambda.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/deploy-lambda.yml b/.github/workflows/deploy-lambda.yml index 4c4614f..aec576f 100644 --- a/.github/workflows/deploy-lambda.yml +++ b/.github/workflows/deploy-lambda.yml @@ -116,7 +116,7 @@ jobs: | jq -r '[.. | objects | .git_commit? | strings][0] // empty' || true) fi - short() { printf '`%s`' "${1:0:12}"; } + short() { printf "\`%s\`" "${1:0:12}"; } trail_url="$KOSLI_HOST/$KOSLI_ORG/flows/$KOSLI_FLOW/trails/$NEW_COMMIT" { echo "### $FUNCTION_NAME in $ACCOUNT ($REGION)"