feat: redeploy the hosted PDP fleet on release - #331
Conversation
Every hosted customer in pdp-deployer is pinned to image_tag = "latest", so the tag string never changes, terraform plans an empty diff, and refreshing the image means flipping force_new_deployment = true in each customer's tfvars, applying, and remembering to flip it back. The flip-back is what got missed on aen-gems-prod, which has been force-redeploying on every apply since PER-14649. Add a standalone workflow that does it instead. `update-service --force-new-deployment` re-runs the existing task definition, so no new revision is registered and no terraform-tracked attribute changes: this produces zero terraform drift, including on aen-gems-prod, the one customer with ignore_task_definition_changes = false. release.yml is deliberately untouched. The consequence is that this workflow listens to the same `release: published` event as the image build rather than running after it, so it cannot assume :latest has moved. The preflight job waits until permitio/pdp-v2:latest and the release tag resolve to the same manifest digest before anything is deployed, and fails loudly if the build never lands. That check also covers the overlapping-release case, where an older run would otherwise redeploy the fleet onto a newer release's :latest. All eleven services roll in one wave at max-parallel 3, fail-fast disabled so one customer cannot cancel the other ten. Per service: snapshot the running task ARNs and image digests, force the new deployment, poll to steady state, then assert none of the pre-deploy task ARNs survive. Compared as a set rather than by startedAt, which ECS returns with a +00:00 offset that jq's fromdateiso8601 will not parse. The steady-state loop replaces `aws ecs wait services-stable`, which is hardcoded to 40 polls x 15s = 10 minutes. These services need longer: task boot, plus healthy_threshold 2 x interval 30 on the ALB, plus the target groups' default 300s deregistration_delay while the old pair drains. It also treats a service as stable only once the old deployment has fully drained, and keys stall detection on failedTasks because rolloutState is only populated when the ECS deployment circuit breaker is enabled, which it is not on these services. Prereleases never push :latest, so they never roll the fleet. Requires PDP_CICD_AWS_ROLE to allow ecs:UpdateService in us-east-2 as well as us-east-1; it has only ever touched one us-east-1 service. The two Allegion services fail until that lands. Verify with a dry-run dispatch first. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
72053cc to
b5855fb
Compare
🔍 Vulnerabilities of
|
| digest | sha256:85c25de71ae174d5f951646ad15cd57645a5b8b4cdd41413c1749f6918532e04 |
| vulnerabilities | |
| platform | linux/amd64 |
| size | 133 MB |
| packages | 248 |
📦 Base Image alpine:3.23
| also known as |
|
| digest | sha256:1beb0dc0a51de7ff38e3b5274078a2e0b81113ba5c7535e1a03d5913a5edbda3 |
| vulnerabilities |
Description
Description
Description
Description
Description
| ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
Description
| ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
Description
|
There was a problem hiding this comment.
Pull request overview
Adds a new GitHub Actions workflow to redeploy all hosted/managed PDP ECS services after a published release by forcing a new ECS deployment once permitio/pdp-v2:latest matches the released tag’s digest.
Changes:
- Introduces a standalone
release: published+workflow_dispatchworkflow that gates rollout on Docker Hub:latestmatching the release tag digest. - Rolls a fixed matrix of 11 ECS services (max-parallel 3, fail-fast false), verifying steady state and full task replacement.
- Produces a per-service artifact and a consolidated job summary table with pass/fail status.
💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.
| set -uo pipefail | ||
|
|
||
| # `aws ecs wait services-stable` is hardcoded to 40 polls x 15s = 10 minutes. | ||
| # These services need longer: task boot, plus healthy_threshold 2 x interval 30 | ||
| # on the ALB, plus the target groups' default 300s deregistration_delay while | ||
| # the old pair drains. 15 minutes leaves real headroom. | ||
| DEADLINE=$(( $(date +%s) + 900 )) | ||
|
|
||
| while :; do | ||
| SVC=$(aws ecs describe-services --cluster "$CL" --services "$SV" --output json) | ||
|
|
||
| read -r RUNNING DESIRED PENDING FAILED DEPLOYMENTS <<<"$(jq -r --arg d "$DID" ' | ||
| .services[0] as $s | ||
| | ($s.deployments[] | select(.id == $d)) as $p | ||
| | [ $p.runningCount, $p.desiredCount, $p.pendingCount, | ||
| ($p.failedTasks // 0), ($s.deployments | length) ] | @tsv' <<<"$SVC")" | ||
|
|
||
| echo "$SV running=$RUNNING/$DESIRED pending=$PENDING failedTasks=$FAILED deployments=$DEPLOYMENTS" |
There was a problem hiding this comment.
Confirmed and fixed in 82a0c06. I reproduced it before changing anything: with an empty or unmatched response, read assigns empty strings to all five fields, [ "" -ge 3 ] fails as a non-numeric comparison without set -e to catch it, and the loop spins to the 15 minute deadline having printed nothing useful.
One correction to the diagnosis, in the workflow's favour: it does not produce a false "stable". [ "$RUNNING" = "$DESIRED" ] is true for two empty strings, but the [ "$DEPLOYMENTS" = "1" ] half of the condition fails, so it never exits 0 on garbage. The bug is a silent spin, not a wrong success.
The missing set -e is deliberate — a transient describe-services failure has to be retried rather than abort the roll — so the fix is to make every failure path explicit instead:
describe-servicesfailing is retried with the AWS error printed, and gives up after 10 consecutive failures rather than burning the whole deadline.- The service vanishing from the cluster fails immediately.
- The deployment id no longer being present fails immediately and prints the deployments actually on the service. That is your second point, and it has a real trigger: a concurrent
update-serviceor a terraform apply supersedes our deployment. It is caught withjq -e, which exits non-zero when the filter yields no output.
Every branch is driven by a mocked aws — transient error, service missing, deployment superseded, steady state, stalled rollout.
Addresses both findings from the Copilot review. Dry runs no longer list or describe tasks. The snapshot step exists only to give the post-deploy assertion a baseline, so it is now gated behind DRY_RUN != 'true'. In its place a dry run does a read-only describe-services that proves the role authenticates in the region and that the service exists under the name the matrix claims - which is the point of a dry run - without needing ecs:ListTasks or ecs:DescribeTasks. The zero-running-tasks hard failure is downgraded to a warning. A service at desiredCount 0, or one whose tasks are already down, is precisely a case where forcing a new deployment is the right move rather than a reason to abort. A wrong service name was never caught by that count anyway - it surfaces as ServiceNotFoundException from the call itself. The replacement assertion handles an empty baseline correctly: no pre-deploy ARN can survive when there were none. The steady-state loop no longer degrades into a silent spin. It deliberately runs without `set -e` so a transient describe-services failure is retried rather than aborting the roll, but that left three paths where an unreadable response produced empty fields and looped to the 15 minute deadline without ever printing why. Each is now an explicit branch: - describe-services failing is retried with the AWS error printed, and gives up after 10 consecutive failures instead of burning the full deadline. - The service vanishing from the cluster mid-deployment fails immediately. - The deployment id no longer being present - superseded by a concurrent update-service or terraform apply - fails immediately and prints the deployments that are actually on the service. This is caught with `jq -e`, which exits non-zero when the filter yields no output. Verified by driving every branch with a mocked aws: transient error, service missing, deployment superseded, steady state and stalled rollout. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
What
Adds a single new workflow,
.github/workflows/redeploy-pdp-fleet.yml, that forces every hosted (managed) PDP ECS service to re-pullpermitio/pdp-v2:latestafter an official release.release.ymlis not modified. This PR adds one file and nothing else.Why not the Terraform route
Every hosted customer in
pdp-deployeris pinned toimage_tag = "latest". The tag string never changes, so terraform plans an empty diff and nothing deploys. Refreshing the image today means flippingforce_new_deployment = truein each customer's tfvars, applying, then remembering to flip it back — see permitio/pdp-deployer#30, which needs a follow-up revert PR to be safe.That flip-back is what got missed on
aen-gems-prod: it has sat attrueonmainsince PER-14649, so every Digger apply touching it force-redeploys the service right now, intended or not.update-service --force-new-deploymentre-runs the existing task definition and Fargate pulls the image fresh. No new revision is registered and no terraform-tracked attribute changes, so this produces zero terraform drift — including onaen-gems-prod, the one customer withignore_task_definition_changes = false.How it is sequenced without touching release.yml
This is the one consequence of keeping
release.ymluntouched, and it is handled explicitly.Because nothing in
release.ymltriggers this workflow, it listens to the samerelease: publishedevent as the image build — which means it starts alongside the build, not after it. At that moment:latestis still the previous release.The
preflightjob therefore waits untilpermitio/pdp-v2:latestandpermitio/pdp-v2:<tag>resolve to the same manifest digest before any service is touched, with a 40-minute deadline covering the multi-arch build. If the build fails, this times out and fails loudly — "the fleet was NOT redeployed" — rather than rolling the fleet onto a stale image.That gate does double duty: it also covers the overlapping-release case, where an older run would otherwise redeploy the fleet onto a
:latestbelonging to a newer release.Rollout
All eleven services in one wave,
max-parallel: 3,fail-fast: falseso one customer cannot cancel the other ten.public-pdps-us-east-1public-pdps-us-east-2Per service: snapshot running task ARNs + image digests →
update-service --force-new-deployment→ poll to steady state → assert no pre-deploy task ARN survives → record a result thereportjob merges into a job-summary table.Design notes
The steady-state loop replaces
aws ecs wait services-stable, which is hardcoded to 40 polls × 15s = 10 minutes. These services need longer: task boot, plushealthy_threshold 2 × interval 30on the ALB, plus the target groups' default 300sderegistration_delaywhile the old pair drains. It also treats a service as stable only once the old deployment has fully drained, and keys stall detection onfailedTasksbecauserolloutStateis only populated when the ECS deployment circuit breaker is enabled — which it is not on these services.Task replacement is asserted by ARN set-intersection, not timestamps. ECS returns
startedAtwith a+00:00offset that jq'sfromdateiso8601will not parse. Digest comparison is advisory only: a rebuild producing an identical image legitimately keeps the same digest.workflow_dispatchis available withdry_rundefaulting to true, so a manual run enumerates targets instead of deploying unless you opt in.Prereleases and drafts never roll the fleet — they never push
:latesteither, so the gate matches the existing build condition.Never
cancel-in-progress— cancelling mid-roll abandons a service with its old tasks half drained.Verification
No actionlint/shellcheck in the local environment, so this was checked directly:
pdp-deployer/terraform/customers/envs/*.tfvars— 11 services, none missing, none extra, every cluster matching its region,aen-gems-prodcorrectly the one entry without aservice_name_suffix.run:block parsed withbash -n.runningCount == desiredCountcheck would have passed it.git diff origin/main -- .github/workflows/release.ymlis empty.PDP_CICD_AWS_ROLEis defined outside this repo and has only ever touched one service inus-east-1. It needsecs:UpdateService,ecs:DescribeServices,ecs:ListTasksandecs:DescribeTasksacross bothpublic-pdps-*clusters. Until that lands, the two Allegion services fail every run.iam:PassRoleis not required — force-new-deployment registers no task definition and passes no role.Once it lands, verify with a manual dispatch of Redeploy PDP fleet with
dry_run: true(the default) — it enumerates all 11 targets and confirms the role authenticates in both regions without touching anything.Known gaps, not addressed here
max-parallel: 3bounds concurrency but there is no canary gate.force_new_deploymentlines from the tfvars and adding a CI guard that diffs them against this matrix, so onboarding customer Opensource ws rpc #12 fails the build. Until that exists, the matrix here is verified-at-commit-time but not enforced. permitio/pdp-deployer#30 is left open and untouched.:latest, every task-definition revision resolves the same mutable tag. Recovery means re-tagging on Docker Hub, which also affects self-hosted customers. Immutable version tags are the real fix and the natural follow-up.🤖 Generated with Claude Code