From df1eba74e806b5b8d96ac126a675752423155227 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E6=B8=A1=E5=8F=A3=E6=B5=AA=E4=BA=BA?= Date: Sun, 11 Oct 2026 17:15:56 +0800 Subject: [PATCH] Keep one rollback tag derived from original release --- docs/deployment/GITEA_CD.md | 6 +++++- scripts/production-deploy.py | 33 ++++++++++++++++++++++++++++++--- tests/test_production_deploy.py | 8 ++++++-- 3 files changed, 41 insertions(+), 6 deletions(-) diff --git a/docs/deployment/GITEA_CD.md b/docs/deployment/GITEA_CD.md index ddfbd45..0d01e45 100644 --- a/docs/deployment/GITEA_CD.md +++ b/docs/deployment/GITEA_CD.md @@ -367,7 +367,7 @@ complete audit succeeded. ### Production image retention After a deployment passes verification and its success journal is saved, Frame Flow -removes obsolete `release--` and `rollback-` images from the +removes obsolete release and rollback images from the configured production image repository. It retains the running version and the previous deployment baseline for one-step rollback. Images referenced by any container (including stopped containers), images with unrelated tags, and CI @@ -376,3 +376,7 @@ Failed deployments do not run retention; cleanup failure produces a warning and never rolls back a successfully verified deployment. Historical deployment journals remain available for audit, but only the latest rollback image is retained locally; older releases require rebuilding before use. + +The rollback image keeps one tag: its original `release--` +tag with `-rollback` appended. Duplicate release/legacy rollback tags are removed +only after the rollback alias exists, and container-referenced tags remain protected. diff --git a/scripts/production-deploy.py b/scripts/production-deploy.py index c2cfb65..01f28cd 100644 --- a/scripts/production-deploy.py +++ b/scripts/production-deploy.py @@ -37,6 +37,7 @@ class Deploy: self.lock_id = None self.state = None self.old_image = None + self.old_image_tag = "" self.secret_values = [ self.env[key] for key in ( @@ -196,6 +197,7 @@ class Deploy: ): raise ValueError("Production user/restart policy differs from repository") self.old_image = old["Image"] + self.old_image_tag = old["Config"].get("Image", "") volume = self.run( "volume", "inspect", self.env["PROD_STATE_VOLUME"], check=False ) @@ -727,10 +729,15 @@ class Deploy: """Retain container references and the verified deployment's rollback baseline.""" try: protected = {self.state["previous_image"]} + referenced_tags = set() container_ids = self.run("ps", "-aq").stdout.decode().split() if container_ids: containers = json.loads(self.run("inspect", *container_ids).stdout) protected.update(container["Image"] for container in containers) + referenced_tags.update( + container.get("Config", {}).get("Image", "") + for container in containers + ) repo = self.env["PROD_IMAGE_REPO"] image_ids = ( self.run("image", "ls", "--no-trunc", "-q", repo) @@ -746,17 +753,29 @@ class Deploy: for image in images: tags = image.get("RepoTags") or [] # Shared images and unrelated tags are never cleanup targets. - if image["Id"] in protected or not tags: + if not tags: continue if not all( re.fullmatch( re.escape(repo) - + r":(?:release-[0-9a-f]{40}-[0-9a-f]{8}|rollback-[0-9a-f]{32})", + + r":(?:release-[0-9a-f]{40}-[0-9a-f]{8}(?:-rollback)?|rollback-[0-9a-f]{32})", tag, ) for tag in tags ): continue + if image["Id"] == self.state["previous_image"]: + pin = self.state.get("rollback_tag") + # Untag duplicates only after the rollback alias is present. + if pin not in tags: + continue + tags = [ + tag for tag in tags if tag != pin and tag not in referenced_tags + ] + elif image["Id"] in protected: + continue + if not tags: + continue result = self.run("image", "rm", *tags, check=False) if result.returncode: print("IMAGE RETENTION: referenced image retained", flush=True) @@ -776,7 +795,15 @@ class Deploy: # Refresh baseline under the daemon-level lock. previous = self.preflight() version = self.verify(previous) - pin = self.env["PROD_IMAGE_REPO"] + ":rollback-" + self.owner + release = self.old_image_tag.removesuffix("-rollback") + if re.fullmatch( + re.escape(self.env["PROD_IMAGE_REPO"]) + + r":release-[0-9a-f]{40}-[0-9a-f]{8}", + release, + ): + pin = release + "-rollback" + else: + pin = self.env["PROD_IMAGE_REPO"] + ":rollback-" + self.owner self.run("image", "tag", self.old_image, pin) candidate = self.build(json.loads(json.dumps(previous))) self.state = { diff --git a/tests/test_production_deploy.py b/tests/test_production_deploy.py index 0f7fe38..af5c548 100644 --- a/tests/test_production_deploy.py +++ b/tests/test_production_deploy.py @@ -98,6 +98,7 @@ def test_preflight_rejects_wrong_host_resources(tool, monkeypatch, change): def test_deploy_rolls_back_exact_baseline_without_deleting_data( tool, monkeypatch, failure ): + tool.old_image_tag = "pigway-frame-flow:release-" + "a" * 40 + "-" + "b" * 8 old = {"services": {"pigway-frame-flow": {"image": "sha256:previous"}}} new = {"services": {"pigway-frame-flow": {"image": "candidate"}}} activated, calls, saved = [], [], [] @@ -138,6 +139,7 @@ def test_deploy_rolls_back_exact_baseline_without_deleting_data( assert cleanup == ["success"] assert activated == [new] assert saved[-1]["status"] == "success" + assert saved[-1]["rollback_tag"] == tool.old_image_tag + "-rollback" assert not any( isinstance(c, tuple) and ("down" in c or "prune" in c) for c in calls ) @@ -255,9 +257,10 @@ def test_image_retention_protects_containers_rollback_and_other_tags(tool, monke tool.state = {"previous_image": "previous"} release = "pigway-frame-flow:release-" + "a" * 40 + "-" + "b" * 8 rollback = "pigway-frame-flow:rollback-" + "c" * 32 + tool.state["rollback_tag"] = release + "-rollback" images = [ {"Id": "current", "RepoTags": [release]}, - {"Id": "previous", "RepoTags": [rollback]}, + {"Id": "previous", "RepoTags": [release + "-rollback", release, rollback]}, {"Id": "stopped", "RepoTags": [release]}, {"Id": "obsolete", "RepoTags": [release, rollback]}, {"Id": "shared", "RepoTags": [release, "other:latest"]}, @@ -282,7 +285,8 @@ def test_image_retention_protects_containers_rollback_and_other_tags(tool, monke monkeypatch.setattr(tool, "run", run) tool.cleanup_images() assert [c for c in calls if c[:2] == ("image", "rm")] == [ - ("image", "rm", release, rollback) + ("image", "rm", release, rollback), + ("image", "rm", release, rollback), ]