Retain current production image and one rollback baseline

This commit is contained in:
渡口浪人
2026-10-11 09:14:23 +08:00
parent 80c6bf3b7d
commit 327fa53d7b
3 changed files with 114 additions and 0 deletions
+13
View File
@@ -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 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 URLs may contain credentials. Logs identify the advisory host and whether the
complete audit succeeded. complete audit succeeded.
### Production image retention
After a deployment passes verification and its success journal is saved, Frame Flow
removes obsolete `release-<commit>-<owner>` and `rollback-<owner>` 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.
+45
View File
@@ -723,6 +723,50 @@ class Deploy:
text = re.sub(r"https?://[^\s\"'<>]+", "[REDACTED-URL]", text) text = re.sub(r"https?://[^\s\"'<>]+", "[REDACTED-URL]", text)
print("Scoped container logs (redacted):", text, flush=True) 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): def deploy(self):
previous = self.preflight() previous = self.preflight()
self.lock() self.lock()
@@ -769,6 +813,7 @@ class Deploy:
self.state["status"] = "success" self.state["status"] = "success"
self.state_io(self.state) self.state_io(self.state)
print("DEPLOY VERIFIED", flush=True) print("DEPLOY VERIFIED", flush=True)
self.cleanup_images()
except BaseException as exc: except BaseException as exc:
print("DEPLOY FAILED:", type(exc).__name__, flush=True) print("DEPLOY FAILED:", type(exc).__name__, flush=True)
self.diagnostics() self.diagnostics()
+56
View File
@@ -111,6 +111,10 @@ def test_deploy_rolls_back_exact_baseline_without_deleting_data(
monkeypatch.setattr( monkeypatch.setattr(
tool, "state_io", lambda state: saved.append(json.loads(json.dumps(state))) 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 = [] verifies = []
def verify(config): def verify(config):
@@ -125,11 +129,13 @@ def test_deploy_rolls_back_exact_baseline_without_deleting_data(
if failure: if failure:
with pytest.raises(RuntimeError): with pytest.raises(RuntimeError):
tool.deploy() tool.deploy()
assert cleanup == []
assert activated == [new, old] assert activated == [new, old]
assert saved[0]["previous"] == old assert saved[0]["previous"] == old
assert ("unlock" in calls) is (failure != "rollback") assert ("unlock" in calls) is (failure != "rollback")
else: else:
tool.deploy() tool.deploy()
assert cleanup == ["success"]
assert activated == [new] assert activated == [new]
assert saved[-1]["status"] == "success" assert saved[-1]["status"] == "success"
assert not any( assert not any(
@@ -243,3 +249,53 @@ def test_lock_conflict_never_activates_production(tool, monkeypatch):
monkeypatch.setattr(tool, "run", fail) monkeypatch.setattr(tool, "run", fail)
with pytest.raises(RuntimeError): with pytest.raises(RuntimeError):
tool.deploy() 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