Skip to content

ci: fix the deploy-beta SSM parameter collision and retry tool downloads - #843

Merged
bnusunny merged 3 commits into
mainfrom
ci/layer-param-and-nextest-retry
Sep 14, 2026
Merged

bnusunny merged 3 commits into
mainfrom
ci/layer-param-and-nextest-retry

Conversation

@bnusunny

Copy link
Copy Markdown
Contributor

Fixes the two failures seen on the last merge to main: a permanent deploy-beta wedge, and a flaky tool download that took e2e-test-zip with it.

1. deploy-beta has been wedged since the v1.1.0 revert

Every merge to main has failed since 2026-07-10 (run 29062863936, run 33441923283) with:

UPDATE_FAILED  AWS::SSM::Parameter  LambdaAdapterLayerArm64VersionParameter
  Resource of type 'AWS::SSM::Parameter' with identifier
  '/lambda-web-adapter/layer/x86_64/1.0.1' already exists.  (HandlerErrorCode: AlreadyExists)
→ stack lambda-adapter-beta-x86 → UPDATE_ROLLBACK_COMPLETE → exit 1

The parameter's Name embedded ${CargoPkgVersion}, so changing the version forces CloudFormation to replace the resource, and UpdateReplacePolicy: Retain left the old name behind un-managed:

  1. Releasing 1.1.0 (87f0c87) created /x86_64/1.1.0 and orphaned /x86_64/1.0.1.
  2. Reverting to 1.0.1 (3fc665d) tried to create that exact name → collided with the orphan.
  3. Rollback restores the 1.1.0 name, so every later deploy repeats step 2. Deterministic, not flaky.

e2e-test-zip and e2e-test-oci are needs: [deploy-beta], so main had no e2e coverage for two months.

Why removal rather than a different policy

  • Dropping UpdateReplacePolicy: Retain would fix the collision, but then only the current version is ever tracked — and that parameter holds the identical value to the /latest parameter beside it. It earns nothing.
  • Writing it with aws ssm put-parameter --overwrite from the workflow needs ssm:PutParameter on the PipelineExecutionRole in every beta/gamma/prod/China account. Today that role grants only ssm:GetParameters on /lambda-web-adapter/e2e/*, so the step would fail closed on first run.
  • Nothing reads a per-version parameter. The only consumer of either is tests/e2e_tests/fixtures/go-httpbin-zip/template.yaml, which reads /lambda-web-adapter/layer/x86_64/latest. That name is static, so it updates in place and can never collide.

No parameter is deleted by merging this. DeletionPolicy: Retain applies when a resource is removed from the template, so the currently tracked names are kept as historical pointers. The layer Description still records the version, so aws lambda list-layer-versions maps a version to its layer ARN.

2. Tool downloads had no retry, and hid their own failure

On the first green deploy-beta in two months, e2e-test-zip died at step 6 of 12 — before setup-sam, before assuming any role, before deploying or testing anything:

Run curl -LsSf https://get.nexte.st/latest/linux | tar zxf - -C ${CARGO_HOME:-~/.cargo}/bin
curl: (35) Recv failure: Connection reset by peer
tar: Error is not recoverable: exiting now
##[error]Process completed with exit code 2

Two problems: no retry, so a single reset from get.nexte.st fails the job; and piping into tar discards curl's exit status, because the default shell for run is bash -e without pipefail — so a network failure was reported as a tar error.

Both downloads now retry and land in a file before extraction: cargo-nextest in all five install sites (merge test, merge e2e-test-zip, merge e2e-test-oci, pr, release) and mdBook in docs.yaml, which had the same pattern and would have been next to flake.

Remediation already applied

The two orphaned parameters were deleted by hand in the beta account, which unwedged deploy-beta on run 34767327920 (e2e-test-zip passed on re-run). Current beta state: all four lambda-adapter-beta-* stacks UPDATE_COMPLETE, /x86_64/1.0.1 re-created under stack management at layer :273, /latest advanced to :273 so it no longer advertises the backed-out v1.1.0 build.

Gamma/prod/China accounts still hold the same class of orphan from earlier releases. They are harmless while version numbers are never reused, and this change stops new ones accruing — but a re-release of an existing version there would need the same one-time cleanup.

Validation

  • All four workflow files parse as YAML.
  • sam validate --lint clean on both templates.
  • The new install snippet run locally yields cargo-nextest 0.9.144.
  • CargoPkgVersion is still referenced (the layer Description), so the parameter is not orphaned in the template.

Every merge to main has failed at deploy-beta since the v1.1.0 revert
(2026-07-10) with:

  Resource of type 'AWS::SSM::Parameter' with identifier
  '/lambda-web-adapter/layer/x86_64/1.0.1' already exists. (AlreadyExists)

The parameter's Name embedded ${CargoPkgVersion}, so a version change forces
CloudFormation to replace the resource, and UpdateReplacePolicy: Retain left
the old name behind un-managed. Releasing 1.1.0 orphaned /x86_64/1.0.1;
reverting to 1.0.1 then tried to create that exact name and hit the orphan.
Rollback restores the 1.1.0 name, so the next deploy repeats it — a permanent
wedge, not a flake. It also took e2e-test-zip and e2e-test-oci down with it
(both `needs: deploy-beta`), so main had no e2e coverage for two months.

Removing the resource is the fix rather than dropping UpdateReplacePolicy:
with only the current version ever tracked, the per-version parameter holds
the same value as the /latest parameter beside it, so it earns nothing. The
alternative — writing it with `aws ssm put-parameter --overwrite` from the
workflow — needs ssm:PutParameter added to the PipelineExecutionRole in every
beta, gamma, prod, and China account, which today grants only ssm:GetParameters
on /lambda-web-adapter/e2e/*.

Nothing reads a per-version parameter: the only consumer of either parameter is
tests/e2e_tests/fixtures/go-httpbin-zip/template.yaml, which reads
/lambda-web-adapter/layer/x86_64/latest. That one keeps its static name, so it
updates in place and can never collide.

No parameter is deleted by this change. DeletionPolicy: Retain applies when a
resource is removed from the template, so the currently-tracked names are kept
as the historical pointers, and the layer Description still records the version
for `aws lambda list-layer-versions`.
e2e-test-zip failed on the first green deploy-beta in two months, six steps
before it ran anything:

  curl -LsSf https://get.nexte.st/latest/linux | tar zxf - -C ...
  curl: (35) Recv failure: Connection reset by peer
  tar: Error is not recoverable: exiting now

Two problems. There was no retry, so one reset from get.nexte.st failed the
job. And piping into tar discarded curl's exit status — the default shell for
`run` is `bash -e`, without pipefail — so the diagnosis surfaced as a tar
error rather than the network failure it was.

Both downloads now retry and land in a file before extraction: cargo-nextest
in all five places it is installed (merge test, merge e2e-test-zip, merge
e2e-test-oci, pr, release) and mdBook in the docs workflow, which has the
same pattern and would have been the next one to flake.
@bnusunny
bnusunny requested a review from a team as a code owner September 13, 2026 16:29

@aws-sam-tooling-bot aws-sam-tooling-bot Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Code Review Results

Reviewed: 4c38d8f..7e16ee9
Files: 6
Comments: 1

Comment thread .github/workflows/docs.yaml Outdated
The mdBook step kept `-sSL` while the cargo-nextest steps use `-LsSf`, so it
still had the failure mode this branch set out to remove. Without `--fail`, curl
treats an HTTP error as a successful transfer: it writes the error page to
/tmp/mdbook.tar.gz and exits 0, and `tar` then fails with "not in gzip format"
instead of reporting the 403 or 404 that actually happened. `--retry` cannot help
either, because curl never classifies the response as an error to retry.

Reproduced against a non-existent release asset: without -f, curl exits 0 having
written a 9-byte "Not Found" body and tar exits 2 with no usable message; with
-f, curl exits 22 and says "The requested URL returned error: 404". The real
asset still downloads and extracts (mdbook v0.4.40).

Reported by aws-sam-tooling-bot on #843.

@aws-sam-tooling-bot aws-sam-tooling-bot Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Code Review Results

Reviewed: 4c38d8f..5633f75
Files: 6
Comments: 1

Comment thread .github/workflows/merge.yaml
@bnusunny
bnusunny merged commit bcd79a4 into main Sep 14, 2026
7 checks passed
@bnusunny
bnusunny deleted the ci/layer-param-and-nextest-retry branch September 14, 2026 20:23
bnusunny added a commit that referenced this pull request Sep 18, 2026
* ci: publish the version-named layer parameter from the pipeline

The second half of #843. That change removed the version-named SSM pointer from the
layer stacks, because a parameter whose Name contains the package version is replaced
when the version changes and — retained — collides with its own orphan when a version
is re-used, which wedged every merge to main for two months. The pointer itself is
still wanted, so it is now written after deployment with
`aws ssm put-parameter --overwrite`, which is idempotent and cannot collide.

Two scripts:

publish-layer-parameter.sh reads the layer ARN from the stack's own output and writes
/lambda-web-adapter/layer/<arch>/<version>. It refuses a version containing a slash
before making any AWS call, since such a name would fall outside the granted path and
could address a `latest` pointer.

configure-layer-parameter-iam.sh grants the PipelineExecutionRole ssm:PutParameter on
that path. Run once per role, with administrator credentials in the role's account.
This is a prerequisite: SAM-managed pipeline roles do not have the permission, and
until the bootstrap is run the publish steps fail with AccessDenied. 69 roles are in
scope — 32 gamma, 32 prod, 2 China gamma, 2 China prod, 1 beta, all distinct.

Three things about the grant are deliberate:

  * Region-agnostic. `put-role-policy` replaces the whole named policy, so a
    per-region document meant a second invocation for another region silently revoked
    the first. The Resource wildcards the region instead, so one run per role covers
    every region and re-running is idempotent.
  * No region argument. With the region wildcarded there is nothing for the partition
    parsed from the role ARN to disagree with; an `aws` role paired with `cn-north-1`
    previously produced a policy matching nothing while reporting success.
  * An explicit Deny on `/latest`. The Allow has to wildcard the version segment, which
    also covers the `latest` pointer CloudFormation owns — and which the e2e fixture
    resolves at deploy time. Without the Deny, a mistyped version could overwrite
    stack-managed state out of band, where nobody would look for drift.

In the workflows, both architectures deploy before either pointer is written. Ordered
the other way — publish x86, deploy arm64 — a missed bootstrap left the region with a
new x86 layer and no arm64 one, failed deploy-prod, skipped publish-to-public-ecr, and
stopped the release after prod had been partially mutated. Ordered this way the same
failure leaves both layers published and the pointers stale.

Each stack name is now defined once per job and consumed by both the deploy and the
publish step, rather than written twice in ten places with nothing asserting the two
agree.

Exercised: the publish script's argument, architecture and slash-in-version guards; the
IAM script's ARN validation against a non-ARN, a short account id, a user ARN and a
nested role path; the rendered policy document; and both workflows parse.

* ci: take the SSM grant from the infrastructure package, not a bootstrap script

The PipelineExecutionRole is managed in LambdaWebAdapterInfrastructureCDK, so the
grant belongs there rather than in an imperative script run by hand against 69 roles.
lib/infra-template.yaml now carries it in PipelineExecutionRolePermissions, allowing
ssm:PutParameter on parameter/lambda-web-adapter/layer/{x86_64,arm64}/* and denying
.../latest, which CloudFormation owns.

Deletes .github/scripts/configure-layer-parameter-iam.sh. Keeping it would have been
worse than redundant: it wrote a separate inline policy under its own name, so the two
would have coexisted on every role with no indication which was authoritative, and its
region-wildcarded Resource was broader than the per-region scope the infra template can
express — one pipeline-resources stack exists per region and its role only deploys in
that region.

The workflow comments and the publish script's header now say where the permission
comes from, so the AccessDenied case points at a region whose pipeline-resources stack
has not been updated rather than at a script nobody ran.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants