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.
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
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
SQLite online backup under `/data/deploy-backups/<owner>.db`. No data/config
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).
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
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.
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
@@ -366,17 +366,15 @@ 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.
Production uses `pigway-frame-flow:latest`. Builds first create a unique release
image, then tag it as latest before activation. The old image is pinned during
an update so a failed deployment can restore it. Only after the new version
passes verification are old release/rollback images and duplicate tags removed.
After successful deployment, only the current production image remains; there
is no retained historical image for manual rollback. Rebuild a historical
revision before manually reverting a successful deployment.
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.
Images referenced by other containers (including stopped containers), images
with unrelated tags, and CI resources remain protected. Cleanup never prunes
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):
self.activate(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)
def secret_config(self):
@@ -392,7 +398,9 @@ class Deploy:
command += ["--build-arg", key + "=" + value]
self.run(*command, str(ROOT))
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()
service["environment"].update(proposed)
for key in ("SYNOLOGY_PHOTOS_URL", "SYNOLOGY_PHOTOS_CERT_SHA256"):
@@ -666,6 +674,7 @@ class Deploy:
self.state["status"] = "success"
self.state_io(self.state)
print("BOOTSTRAP VERIFIED", flush=True)
self.cleanup_images()
except BaseException:
print("BOOTSTRAP FAILED", flush=True)
self.diagnostics()
@@ -726,9 +735,9 @@ class Deploy:
print("Scoped container logs (redacted):", text, flush=True)
def cleanup_images(self):
"""Retain container references and the verified deployment's rollback baseline."""
"""Keep only latest after verification; protect unrelated container images."""
try:
protected = {self.state["previous_image"]}
protected = set()
referenced_tags = set()
container_ids = self.run("ps", "-aq").stdout.decode().split()
if container_ids:
@@ -758,22 +767,23 @@ class Deploy:
if not all(
re.fullmatch(
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,
)
for tag in tags
):
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:
latest = repo + ":latest"
if image["Id"] in protected:
# Current production uses latest. Other containers retain
# their referenced tags and images, even when stopped.
if latest not in tags:
continue
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:
continue
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()
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"}
release = "pigway-frame-flow:release-" + "a" * 40 + "-" + "b" * 8
rollback = "pigway-frame-flow:rollback-" + "c" * 32
tool.state["rollback_tag"] = release + "-rollback"
images = [
{"Id": "current", "RepoTags": [release]},
{"Id": "current", "RepoTags": ["pigway-frame-flow:latest", release]},
{"Id": "previous", "RepoTags": [release + "-rollback", release, rollback]},
{"Id": "stopped", "RepoTags": [release]},
{"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"):
data = "container1 container2"
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"):
data = "current previous stopped obsolete shared ci"
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)
tool.cleanup_images()
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),
]