Retain current production image and one rollback baseline
This commit is contained in:
@@ -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.
|
||||||
|
|||||||
@@ -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()
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
Reference in New Issue
Block a user