Keep one rollback tag derived from original release

This commit is contained in:
渡口浪人
2026-10-11 17:15:56 +08:00
parent b41163965e
commit df1eba74e8
3 changed files with 41 additions and 6 deletions
+5 -1
View File
@@ -367,7 +367,7 @@ complete audit succeeded.
### Production image retention ### Production image retention
After a deployment passes verification and its success journal is saved, Frame Flow After a deployment passes verification and its success journal is saved, Frame Flow
removes obsolete `release-<commit>-<owner>` and `rollback-<owner>` images from the removes obsolete release and rollback images from the
configured production image repository. It retains the running version and the configured production image repository. It retains the running version and the
previous deployment baseline for one-step rollback. Images referenced by any previous deployment baseline for one-step rollback. Images referenced by any
container (including stopped containers), images with unrelated tags, and CI 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 never rolls back a successfully verified deployment. Historical deployment
journals remain available for audit, but only the latest rollback image is journals remain available for audit, but only the latest rollback image is
retained locally; older releases require rebuilding before use. retained locally; older releases require rebuilding before use.
The rollback image keeps one tag: its original `release-<commit>-<owner>`
tag with `-rollback` appended. Duplicate release/legacy rollback tags are removed
only after the rollback alias exists, and container-referenced tags remain protected.
+29 -2
View File
@@ -37,6 +37,7 @@ class Deploy:
self.lock_id = None self.lock_id = None
self.state = None self.state = None
self.old_image = None self.old_image = None
self.old_image_tag = ""
self.secret_values = [ self.secret_values = [
self.env[key] self.env[key]
for key in ( for key in (
@@ -196,6 +197,7 @@ class Deploy:
): ):
raise ValueError("Production user/restart policy differs from repository") raise ValueError("Production user/restart policy differs from repository")
self.old_image = old["Image"] self.old_image = old["Image"]
self.old_image_tag = old["Config"].get("Image", "")
volume = self.run( volume = self.run(
"volume", "inspect", self.env["PROD_STATE_VOLUME"], check=False "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.""" """Retain container references and the verified deployment's rollback baseline."""
try: try:
protected = {self.state["previous_image"]} protected = {self.state["previous_image"]}
referenced_tags = set()
container_ids = self.run("ps", "-aq").stdout.decode().split() container_ids = self.run("ps", "-aq").stdout.decode().split()
if container_ids: if container_ids:
containers = json.loads(self.run("inspect", *container_ids).stdout) containers = json.loads(self.run("inspect", *container_ids).stdout)
protected.update(container["Image"] for container in containers) 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"] repo = self.env["PROD_IMAGE_REPO"]
image_ids = ( image_ids = (
self.run("image", "ls", "--no-trunc", "-q", repo) self.run("image", "ls", "--no-trunc", "-q", repo)
@@ -746,17 +753,29 @@ class Deploy:
for image in images: for image in images:
tags = image.get("RepoTags") or [] tags = image.get("RepoTags") or []
# Shared images and unrelated tags are never cleanup targets. # Shared images and unrelated tags are never cleanup targets.
if image["Id"] in protected or not tags: if not tags:
continue continue
if not all( if not all(
re.fullmatch( re.fullmatch(
re.escape(repo) 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, tag,
) )
for tag in tags for tag in tags
): ):
continue 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) result = self.run("image", "rm", *tags, check=False)
if result.returncode: if result.returncode:
print("IMAGE RETENTION: referenced image retained", flush=True) print("IMAGE RETENTION: referenced image retained", flush=True)
@@ -776,6 +795,14 @@ class Deploy:
# Refresh baseline under the daemon-level lock. # Refresh baseline under the daemon-level lock.
previous = self.preflight() previous = self.preflight()
version = self.verify(previous) version = self.verify(previous)
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 pin = self.env["PROD_IMAGE_REPO"] + ":rollback-" + self.owner
self.run("image", "tag", self.old_image, pin) self.run("image", "tag", self.old_image, pin)
candidate = self.build(json.loads(json.dumps(previous))) candidate = self.build(json.loads(json.dumps(previous)))
+6 -2
View File
@@ -98,6 +98,7 @@ def test_preflight_rejects_wrong_host_resources(tool, monkeypatch, change):
def test_deploy_rolls_back_exact_baseline_without_deleting_data( def test_deploy_rolls_back_exact_baseline_without_deleting_data(
tool, monkeypatch, failure tool, monkeypatch, failure
): ):
tool.old_image_tag = "pigway-frame-flow:release-" + "a" * 40 + "-" + "b" * 8
old = {"services": {"pigway-frame-flow": {"image": "sha256:previous"}}} old = {"services": {"pigway-frame-flow": {"image": "sha256:previous"}}}
new = {"services": {"pigway-frame-flow": {"image": "candidate"}}} new = {"services": {"pigway-frame-flow": {"image": "candidate"}}}
activated, calls, saved = [], [], [] activated, calls, saved = [], [], []
@@ -138,6 +139,7 @@ def test_deploy_rolls_back_exact_baseline_without_deleting_data(
assert cleanup == ["success"] assert cleanup == ["success"]
assert activated == [new] assert activated == [new]
assert saved[-1]["status"] == "success" assert saved[-1]["status"] == "success"
assert saved[-1]["rollback_tag"] == tool.old_image_tag + "-rollback"
assert not any( assert not any(
isinstance(c, tuple) and ("down" in c or "prune" in c) for c in calls 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"} tool.state = {"previous_image": "previous"}
release = "pigway-frame-flow:release-" + "a" * 40 + "-" + "b" * 8 release = "pigway-frame-flow:release-" + "a" * 40 + "-" + "b" * 8
rollback = "pigway-frame-flow:rollback-" + "c" * 32 rollback = "pigway-frame-flow:rollback-" + "c" * 32
tool.state["rollback_tag"] = release + "-rollback"
images = [ images = [
{"Id": "current", "RepoTags": [release]}, {"Id": "current", "RepoTags": [release]},
{"Id": "previous", "RepoTags": [rollback]}, {"Id": "previous", "RepoTags": [release + "-rollback", release, rollback]},
{"Id": "stopped", "RepoTags": [release]}, {"Id": "stopped", "RepoTags": [release]},
{"Id": "obsolete", "RepoTags": [release, rollback]}, {"Id": "obsolete", "RepoTags": [release, rollback]},
{"Id": "shared", "RepoTags": [release, "other:latest"]}, {"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) monkeypatch.setattr(tool, "run", run)
tool.cleanup_images() tool.cleanup_images()
assert [c for c in calls if c[:2] == ("image", "rm")] == [ assert [c for c in calls if c[:2] == ("image", "rm")] == [
("image", "rm", release, rollback) ("image", "rm", release, rollback),
("image", "rm", release, rollback),
] ]