ACR image retention — keep newest 3, prune older on deploy/upgrade¶
Motivation¶
Every control-plane deploy pushes a fresh image tag per sidecar repository
(elb-api, elb-frontend, elb-terminal). Both the in-app self-upgrade
(Settings → Update) and scripts/dev/quick-deploy.sh therefore accumulate one
manifest per run in the platform ACR forever, growing registry storage
unbounded. There was no automatic cleanup — old, unreferenced images piled up.
User-facing change¶
Old images are now pruned automatically, keeping only the newest 3 manifests per control-plane repository (configurable). The prune runs at the two moments the user named:
- In-app upgrade — after an upgrade reaches
succeeded, the reconciler runs a best-effort retention sweep. The now-running images and the rollback target are protected so a subsequent rollback is never starved of its snapshot. Tunable viaUPGRADE_ACR_KEEP_IMAGES(default 3). quick-deploy.sh— after a successful build, the script deletes older manifests for the repositories it just deployed, while the ACR firewall is still open (steady-state ACR ispublicNetworkAccess: Disabled, so the prune runs betweenacr_ensure_build_accessandacr_restore_build_access). Tunable viaELB_ACR_KEEP_IMAGES(default 3); skip with--no-pruneorELB_SKIP_ACR_PRUNE=1. Skipped automatically on--no-build(no fresh image was pushed that run).
Both paths are best-effort: a missing AcrDelete/Contributor permission,
a transient registry error, or a repository with ≤ keep manifests is a no-op
and never fails the deploy/upgrade. The newest keep manifests (by
last-update time) are never deleted, so the just-pushed image and the
previously-running image always survive.
API / IaC diff summary¶
- New service module
api/services/upgrade/acr_retention.py—prune_repository,prune_control_plane_images,keep_count. Data-plane prune viaazure-containerregistry(list_manifest_properties+delete_manifest); reusesacr_inventoryfor client construction + ref parsing. api/tasks/upgrade/reconciler.py— new_prune_acr_after_success(row)helper called on both the single-mode and blue/greensucceededtransitions. Fully best-effort; never raises.scripts/dev/quick-deploy.sh— newacr_prune_repo_keep_recent/acr_prune_targetshelpers,--no-pruneflag,ELB_ACR_KEEP_IMAGES/ELB_SKIP_ACR_PRUNEenv knobs; invoked before the final "Done" in thealland single-sidecar deploy paths.- No Bicep / RBAC change. The shared user-assigned MI already holds
Contributoron the registry (infra/modules/acr.bicep). Caveat: the data-planedelete_manifestcontent-delete action maps to theAcrDeleterole; if a deployment's MI lacks it the in-app prune logsforbidden (MI needs AcrDelete)per repo and the upgrade still succeeds (the registry simply keeps accumulating untilAcrDeleteis granted as an additive phase-1 role assignment per charter §12a Rule 1).
Validation evidence¶
uv run pytest -q api/tests/test_upgrade_acr_retention.py→ 13 passed (keep-newest-N, protected tag/digest outside window, env override, forbidden delete best-effort, list-not-found no-op, control-plane orchestration).uv run pytest -q api/tests/test_upgrade_task.py api/tests/test_upgrade_chaos.py→ 62 passed (success hook does not break the state machine).uv run pytest -q api/tests/test_upgrade_routes.py api/tests/test_upgrade_bluegreen.py api/tests/test_upgrade_acr_inventory.py→ 73 passed (blue/greenconfirming → succeededprune hook safe).uv run ruff checkclean on the changed Python files.bash -n scripts/dev/quick-deploy.sh→ syntax OK.