Retain only the verified production latest image

This commit is contained in:
渡口浪人
2026-10-12 20:51:08 +08:00
parent df1eba74e8
commit b46fbea4c9
3 changed files with 47 additions and 30 deletions
+13 -15
View File
@@ -107,7 +107,7 @@ been performed during implementation; those are first-run acceptance steps.
an immutable rollback tag so normal dangling-image cleanup cannot remove it. an immutable rollback tag so normal dangling-image cleanup cannot remove it.
4. Refuse a stale checked-out `main` revision that no longer equals remote main. 4. Refuse a stale checked-out `main` revision that no longer equals remote main.
Build an immutable `release-<commit>-<owner>` tag locally, without a registry Build an immutable `release-<commit>-<owner>` tag locally, without a registry
and without changing `latest` or CI image tags. then point `latest` at the candidate; CI image tags are never changed.
5. Persist a `prepared` journal with old/new runtime configs. Create an additive 5. Persist a `prepared` journal with old/new runtime configs. Create an additive
SQLite online backup under `/data/deploy-backups/<owner>.db`. No data/config SQLite online backup under `/data/deploy-backups/<owner>.db`. No data/config
directory or file is removed, replaced or recursively chowned. directory or file is removed, replaced or recursively chowned.
@@ -266,7 +266,7 @@ as an Actions artifact. First bootstrap has no prior healthy version to roll bac
`192.0.2.10:18787` binding (not loopback). `192.0.2.10:18787` binding (not loopback).
4. Open `http://192.0.2.10:18787/api/v1/version`; its `commit` must equal the full 4. Open `http://192.0.2.10:18787/api/v1/version`; its `commit` must equal the full
workflow checkout commit. Check `/ready` returns HTTP 200. The immutable image tag workflow checkout commit. Check `/ready` returns HTTP 200. The immutable image tag
is `pigway-frame-flow:release-<commit>-<transaction>`; compare its image ID in is `pigway-frame-flow:latest`; compare its image ID in
Container Manager with the running container. Container Manager with the running container.
5. Keep `PROD_DEPLOY_ENABLED=true`. Subsequent main pushes Verify then deploy using 5. Keep `PROD_DEPLOY_ENABLED=true`. Subsequent main pushes Verify then deploy using
this same runtime and journal mechanism. The first update records bootstrap as this same runtime and journal mechanism. The first update records bootstrap as
@@ -366,17 +366,15 @@ complete audit succeeded.
### Production image retention ### Production image retention
After a deployment passes verification and its success journal is saved, Frame Flow Production uses `pigway-frame-flow:latest`. Builds first create a unique release
removes obsolete release and rollback images from the image, then tag it as latest before activation. The old image is pinned during
configured production image repository. It retains the running version and the an update so a failed deployment can restore it. Only after the new version
previous deployment baseline for one-step rollback. Images referenced by any passes verification are old release/rollback images and duplicate tags removed.
container (including stopped containers), images with unrelated tags, and CI After successful deployment, only the current production image remains; there
images are protected. Retention does not prune build cache, volumes, or data. is no retained historical image for manual rollback. Rebuild a historical
Failed deployments do not run retention; cleanup failure produces a warning and revision before manually reverting a successful deployment.
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-<commit>-<owner>` Images referenced by other containers (including stopped containers), images
tag with `-rollback` appended. Duplicate release/legacy rollback tags are removed with unrelated tags, and CI resources remain protected. Cleanup never prunes
only after the rollback alias exists, and container-referenced tags remain protected. volumes, data, or shared build cache. Cleanup failure is a warning and does not
roll back a verified deployment. Failed deployments do not run cleanup.
+21 -11
View File
@@ -337,6 +337,12 @@ class Deploy:
def rollback(self, previous): def rollback(self, previous):
self.activate(previous) self.activate(previous)
self.verify(previous) self.verify(previous)
self.run(
"image",
"tag",
previous["services"]["pigway-frame-flow"]["image"],
self.env["PROD_IMAGE_REPO"] + ":latest",
)
print("ROLLBACK VERIFIED", flush=True) print("ROLLBACK VERIFIED", flush=True)
def secret_config(self): def secret_config(self):
@@ -392,7 +398,9 @@ class Deploy:
command += ["--build-arg", key + "=" + value] command += ["--build-arg", key + "=" + value]
self.run(*command, str(ROOT)) self.run(*command, str(ROOT))
service = config["services"]["pigway-frame-flow"] service = config["services"]["pigway-frame-flow"]
service["image"] = image latest = self.env["PROD_IMAGE_REPO"] + ":latest"
self.run("image", "tag", image, latest)
service["image"] = latest
proposed = self.secret_config() proposed = self.secret_config()
service["environment"].update(proposed) service["environment"].update(proposed)
for key in ("SYNOLOGY_PHOTOS_URL", "SYNOLOGY_PHOTOS_CERT_SHA256"): for key in ("SYNOLOGY_PHOTOS_URL", "SYNOLOGY_PHOTOS_CERT_SHA256"):
@@ -666,6 +674,7 @@ class Deploy:
self.state["status"] = "success" self.state["status"] = "success"
self.state_io(self.state) self.state_io(self.state)
print("BOOTSTRAP VERIFIED", flush=True) print("BOOTSTRAP VERIFIED", flush=True)
self.cleanup_images()
except BaseException: except BaseException:
print("BOOTSTRAP FAILED", flush=True) print("BOOTSTRAP FAILED", flush=True)
self.diagnostics() self.diagnostics()
@@ -726,9 +735,9 @@ class Deploy:
print("Scoped container logs (redacted):", text, flush=True) print("Scoped container logs (redacted):", text, flush=True)
def cleanup_images(self): def cleanup_images(self):
"""Retain container references and the verified deployment's rollback baseline.""" """Keep only latest after verification; protect unrelated container images."""
try: try:
protected = {self.state["previous_image"]} protected = set()
referenced_tags = set() 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:
@@ -758,22 +767,23 @@ class Deploy:
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)?|rollback-[0-9a-f]{32})", + r":(?:latest|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"]: latest = repo + ":latest"
pin = self.state.get("rollback_tag") if image["Id"] in protected:
# Untag duplicates only after the rollback alias is present. # Current production uses latest. Other containers retain
if pin not in tags: # their referenced tags and images, even when stopped.
if latest not in tags:
continue continue
tags = [ tags = [
tag for tag in tags if tag != pin and tag not in referenced_tags tag
for tag in tags
if tag != latest and tag not in referenced_tags
] ]
elif image["Id"] in protected:
continue
if not tags: if not tags:
continue continue
result = self.run("image", "rm", *tags, check=False) result = self.run("image", "rm", *tags, check=False)
+13 -4
View File
@@ -253,13 +253,13 @@ def test_lock_conflict_never_activates_production(tool, monkeypatch):
tool.deploy() tool.deploy()
def test_image_retention_protects_containers_rollback_and_other_tags(tool, monkeypatch): def test_image_retention_keeps_latest_and_protects_other_containers(tool, monkeypatch):
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" tool.state["rollback_tag"] = release + "-rollback"
images = [ images = [
{"Id": "current", "RepoTags": [release]}, {"Id": "current", "RepoTags": ["pigway-frame-flow:latest", release]},
{"Id": "previous", "RepoTags": [release + "-rollback", release, 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]},
@@ -273,7 +273,15 @@ def test_image_retention_protects_containers_rollback_and_other_tags(tool, monke
if args == ("ps", "-aq"): if args == ("ps", "-aq"):
data = "container1 container2" data = "container1 container2"
elif args[0] == "inspect": elif args[0] == "inspect":
data = json.dumps([{"Image": "current"}, {"Image": "stopped"}]) data = json.dumps(
[
{
"Image": "current",
"Config": {"Image": "pigway-frame-flow:latest"},
},
{"Image": "stopped"},
]
)
elif args[:2] == ("image", "ls"): elif args[:2] == ("image", "ls"):
data = "current previous stopped obsolete shared ci" data = "current previous stopped obsolete shared ci"
elif args[:2] == ("image", "inspect"): elif args[:2] == ("image", "inspect"):
@@ -285,7 +293,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),
("image", "rm", release + "-rollback", release, rollback),
("image", "rm", release, rollback), ("image", "rm", release, rollback),
] ]