From 327fa53d7bf304fbf243a8ed609aa1182c572ec6 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 09:14:23 +0800 Subject: [PATCH] Retain current production image and one rollback baseline --- docs/deployment/GITEA_CD.md | 13 ++++++++ scripts/production-deploy.py | 45 ++++++++++++++++++++++++++ tests/test_production_deploy.py | 56 +++++++++++++++++++++++++++++++++ 3 files changed, 114 insertions(+) diff --git a/docs/deployment/GITEA_CD.md b/docs/deployment/GITEA_CD.md index 205d432..ddfbd45 100644 --- a/docs/deployment/GITEA_CD.md +++ b/docs/deployment/GITEA_CD.md @@ -363,3 +363,16 @@ the audit client's normal HTTP freshness rules. It is not a permanent offline snapshot or a cross-job cache. Raw network exceptions are omitted because proxy URLs may contain credentials. Logs identify the advisory host and whether the 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 +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 +images are protected. Retention does not prune build cache, volumes, or data. +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. diff --git a/scripts/production-deploy.py b/scripts/production-deploy.py index 5876dfa..c2cfb65 100644 --- a/scripts/production-deploy.py +++ b/scripts/production-deploy.py @@ -723,6 +723,50 @@ class Deploy: text = re.sub(r"https?://[^\s\"'<>]+", "[REDACTED-URL]", text) print("Scoped container logs (redacted):", text, flush=True) + def cleanup_images(self): + """Retain container references and the verified deployment's rollback baseline.""" + try: + protected = {self.state["previous_image"]} + 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) + repo = self.env["PROD_IMAGE_REPO"] + image_ids = ( + self.run("image", "ls", "--no-trunc", "-q", repo) + .stdout.decode() + .split() + ) + if not image_ids: + return + images = json.loads( + self.run("image", "inspect", *sorted(set(image_ids))).stdout + ) + removed = 0 + 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: + continue + if not all( + re.fullmatch( + re.escape(repo) + + r":(?:release-[0-9a-f]{40}-[0-9a-f]{8}|rollback-[0-9a-f]{32})", + tag, + ) + for tag in tags + ): + continue + result = self.run("image", "rm", *tags, check=False) + if result.returncode: + print("IMAGE RETENTION: referenced image retained", flush=True) + else: + removed += 1 + print(f"IMAGE RETENTION: removed {removed} obsolete images", flush=True) + except Exception: + # Cleanup failure must never roll back an already verified deployment. + print("IMAGE RETENTION WARNING: cleanup deferred", flush=True) + def deploy(self): previous = self.preflight() self.lock() @@ -769,6 +813,7 @@ class Deploy: self.state["status"] = "success" self.state_io(self.state) print("DEPLOY VERIFIED", flush=True) + self.cleanup_images() except BaseException as exc: print("DEPLOY FAILED:", type(exc).__name__, flush=True) self.diagnostics() diff --git a/tests/test_production_deploy.py b/tests/test_production_deploy.py index ed473ac..0f7fe38 100644 --- a/tests/test_production_deploy.py +++ b/tests/test_production_deploy.py @@ -111,6 +111,10 @@ def test_deploy_rolls_back_exact_baseline_without_deleting_data( monkeypatch.setattr( tool, "state_io", lambda state: saved.append(json.loads(json.dumps(state))) ) + cleanup = [] + monkeypatch.setattr( + tool, "cleanup_images", lambda: cleanup.append(saved[-1]["status"]) + ) verifies = [] def verify(config): @@ -125,11 +129,13 @@ def test_deploy_rolls_back_exact_baseline_without_deleting_data( if failure: with pytest.raises(RuntimeError): tool.deploy() + assert cleanup == [] assert activated == [new, old] assert saved[0]["previous"] == old assert ("unlock" in calls) is (failure != "rollback") else: tool.deploy() + assert cleanup == ["success"] assert activated == [new] assert saved[-1]["status"] == "success" assert not any( @@ -243,3 +249,53 @@ def test_lock_conflict_never_activates_production(tool, monkeypatch): monkeypatch.setattr(tool, "run", fail) with pytest.raises(RuntimeError): tool.deploy() + + +def test_image_retention_protects_containers_rollback_and_other_tags(tool, monkeypatch): + tool.state = {"previous_image": "previous"} + release = "pigway-frame-flow:release-" + "a" * 40 + "-" + "b" * 8 + rollback = "pigway-frame-flow:rollback-" + "c" * 32 + images = [ + {"Id": "current", "RepoTags": [release]}, + {"Id": "previous", "RepoTags": [rollback]}, + {"Id": "stopped", "RepoTags": [release]}, + {"Id": "obsolete", "RepoTags": [release, rollback]}, + {"Id": "shared", "RepoTags": [release, "other:latest"]}, + {"Id": "ci", "RepoTags": ["ffci-fixture:latest"]}, + ] + calls = [] + + def run(*args, **kwargs): + calls.append(args) + if args == ("ps", "-aq"): + data = "container1 container2" + elif args[0] == "inspect": + data = json.dumps([{"Image": "current"}, {"Image": "stopped"}]) + elif args[:2] == ("image", "ls"): + data = "current previous stopped obsolete shared ci" + elif args[:2] == ("image", "inspect"): + data = json.dumps(images) + else: + data = "" + return SimpleNamespace(stdout=data.encode(), returncode=0) + + monkeypatch.setattr(tool, "run", run) + tool.cleanup_images() + assert [c for c in calls if c[:2] == ("image", "rm")] == [ + ("image", "rm", release, rollback) + ] + + +def test_image_retention_failure_does_not_fail_verified_deploy( + tool, monkeypatch, capsys +): + tool.state = {"previous_image": "previous"} + + def unavailable(*args, **kwargs): + raise RuntimeError("private diagnostic") + + monkeypatch.setattr(tool, "run", unavailable) + tool.cleanup_images() + output = capsys.readouterr().out + assert "cleanup deferred" in output + assert "private diagnostic" not in output