Retain only the verified production latest image
This commit is contained in:
+13
-15
@@ -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.
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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),
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user