Retain verified wiki releases and expire unused CI images
Wiki image retention checks / retention-policy (pull_request) Successful in 52s

This commit is contained in:
2026-10-29 18:43:57 +08:00
parent 1a7b8f5235
commit dbc3ad8c69
7 changed files with 479 additions and 16 deletions
+9 -15
View File
@@ -8,6 +8,7 @@ on:
- 'Dockerfile'
- 'compose.yml'
- 'scripts/deploy.sh'
- 'scripts/wiki-image-lifecycle.py'
- 'scripts/infra.sh'
- 'scripts/auth-admin.sh'
- 'scripts/integration.sh'
@@ -22,19 +23,12 @@ jobs:
- uses: actions/checkout@v4
with:
fetch-depth: 0
- name: Validate configuration
run: docker compose --env-file apps/waterfall-wiki/.env.example config --quiet
- name: Build and verify waterfall-wiki
run: docker build --target integration -t "waterfall-wiki-tests:${{ gitea.sha }}" .
- name: Verify retained character originals
run: docker run --rm --entrypoint node "waterfall-wiki-tests:${{ gitea.sha }}" --test tests/character-pack.test.mjs
- name: Build runtime
run: docker build --target runtime -t "waterfall-wiki:${{ gitea.sha }}" .
- name: Integration stack
- name: Build, verify, deploy and retain wiki images
env:
WATERFALL_WIKI_IMAGE: waterfall-wiki:${{ gitea.sha }}
WATERFALL_WIKI_TEST_IMAGE: waterfall-wiki-tests:${{ gitea.sha }}
run: bash scripts/integration.sh
- name: Deploy waterfall-wiki
if: gitea.ref == 'refs/heads/main' && gitea.event_name == 'push'
run: bash scripts/deploy.sh "${{ gitea.sha }}"
RELEASE_SHA: ${{ gitea.sha }}
run: |
if [ "$(id -u)" -eq 0 ]; then
python3 scripts/wiki-image-lifecycle.py ci --sha "$RELEASE_SHA"
else
sudo -n python3 scripts/wiki-image-lifecycle.py ci --sha "$RELEASE_SHA"
fi
@@ -0,0 +1,26 @@
name: Wiki image retention checks
on:
pull_request:
paths:
- 'scripts/wiki-image-lifecycle.py'
- 'scripts/deploy.sh'
- 'tests/test_wiki_image_lifecycle.py'
- '.gitea/workflows/wiki-image-retention-checks.yml'
- '.gitea/workflows/waterfall-wiki.yml'
- 'Dockerfile'
push:
branches: [main]
paths:
- 'scripts/wiki-image-lifecycle.py'
- 'tests/test_wiki_image_lifecycle.py'
- '.gitea/workflows/wiki-image-retention-checks.yml'
jobs:
retention-policy:
runs-on: ces-control
timeout-minutes: 5
steps:
- uses: actions/checkout@v4
- name: Check retention without building or accessing the Docker daemon
run: |
bash -n scripts/deploy.sh
PYTHONDONTWRITEBYTECODE=1 python3 -m unittest discover -s tests -p 'test_wiki_image_lifecycle.py' -v
+2
View File
@@ -1,4 +1,5 @@
FROM node:24-bookworm-slim AS base
LABEL io.entroia.project="waterfall-wiki"
ENV NEXT_TELEMETRY_DISABLED=1
WORKDIR /app
RUN apt-get update \
@@ -6,6 +7,7 @@ RUN apt-get update \
&& rm -rf /var/lib/apt/lists/*
FROM base AS dependencies
LABEL io.entroia.wiki.build="true"
RUN corepack enable && corepack prepare pnpm@11.25.0 --activate
COPY apps/waterfall-wiki/pnpm-lock.yaml ./
COPY pnpm-workspace.yaml /tmp/monorepo-policy.yaml
+59
View File
@@ -0,0 +1,59 @@
# Wiki image retention on ces-control
Wiki CI builds one runtime image and one integration-test image per revision. Web
and worker share the runtime image. Tags can share an image ID; displayed image
sizes include shared layers and must not be added to estimate reclaimed space.
## Policy
- Retain the current release and the two preceding distinct successful releases.
`scripts/deploy.sh` records success in `/opt/prompt/successful-releases` only after
all existing deployment checks pass. The ledger is replaced atomically.
- The first three successful deployments bootstrap this ledger. Until then,
retain all runtime images, since old release directories alone do not prove
successful deployment. No historical success is guessed from directory dates.
- Keep the test images corresponding to retained runtime releases: Studio verification
and publishing scripts use them as operational tools. Remove other successful
job test images unless a container still references them or their image ID has
another protected tag.
- Retain other runtime/test/build images for at least 72 hours for debugging.
Expired runtime images outside the three retained releases become eligible.
- Remove expired project/build-labelled exited containers only with no mounts.
Remove expired project-labelled untagged image intermediates where Docker allows.
- Always preserve images referenced by any running, created or stopped container.
This can deliberately keep more than three runtime versions.
- Preserve foreign tags/digests, unlabelled intermediates and legacy
`prompt:*` images. Legacy images require a separate reviewed migration list.
- Never use global prune, force image/container removal, or volume deletion.
Database, object storage, backups and release directories are outside this tool.
## Preview and apply
Run from the reviewed checkout on the confirmed ces-control host:
```sh
sudo python3 scripts/wiki-image-lifecycle.py preview
sudo python3 scripts/wiki-image-lifecycle.py apply
```
The default command is preview. Both print keep/delete reasons, image IDs and tags.
Preview reads Docker metadata and may create empty lock files, but never changes
Docker state. Both commands refuse to proceed if the current release metadata or
success ledger is invalid. They also refuse while another cooperating build or
deployment holds the locks.
The workflow invokes `ci --sha <full-sha>` once on main. A single exclusive
`/opt/prompt/.image-lifecycle.lock` spans builds, integration, deployment and
cleanup. Cleanup also takes the existing `.deploy.lock`, protecting manual
`deploy.sh` invocations. Use this CI entrypoint for manual builds too; raw Docker
commands and old workflow revisions do not participate in this lock. Allow old
wiki jobs to finish before first enabling apply. Other projects are out of scope.
Cleanup runs after both success and failure. Errors are reported without changing
the original build/deployment status or rolling back healthy services. Referenced
images and Docker refusal are reported and retained. Failed CI does not record a
successful release and does not immediately remove that job's test image.
PR checks run Python policy tests and shell syntax checks on ces-control without
building or touching Docker. The existing full build/integration/deployment runs
only after the user merges to main. No local builds are needed.
+5 -1
View File
@@ -17,7 +17,7 @@ test "$(git -c safe.directory="$PWD" rev-parse HEAD)" = "$RELEASE_SHA"
LATEST_MAIN=$(git -c safe.directory="$PWD" ls-remote origin refs/heads/main | cut -f1)
if [ "$LATEST_MAIN" != "$RELEASE_SHA" ]; then
git -c safe.directory="$PWD" fetch origin main
if ! git -c safe.directory="$PWD" diff --quiet "$RELEASE_SHA" origin/main -- apps/waterfall-wiki Dockerfile compose.yml scripts/deploy.sh scripts/infra.sh scripts/integration.sh scripts/auth-admin.sh .gitea/workflows/waterfall-wiki.yml pnpm-workspace.yaml; then
if ! git -c safe.directory="$PWD" diff --quiet "$RELEASE_SHA" origin/main -- apps/waterfall-wiki Dockerfile compose.yml scripts/deploy.sh scripts/wiki-image-lifecycle.py scripts/infra.sh scripts/integration.sh scripts/auth-admin.sh .gitea/workflows/waterfall-wiki.yml pnpm-workspace.yaml; then
echo 'Skipping deployment: Wiki has a newer commit'; exit 0
fi
fi
@@ -135,6 +135,10 @@ if command -v systemctl >/dev/null; then
if systemctl is-active --quiet prompt.service || systemctl is-active --quiet prompt-worker.service; then echo 'Legacy host service is unexpectedly running'; false; fi
fi
echo "Full Docker deployment verified: web+worker+postgres+objects port=$PORT release=$RELEASE_SHA"
# Register only after every deployment check succeeded; ledger failure retains old images.
if ! python3 scripts/wiki-image-lifecycle.py record-success --sha "$RELEASE_SHA"; then
echo "Warning: successful-release ledger update failed; runtime cleanup stays conservative" >&2
fi
# Read-only network diagnostics: a private address alone is not a public entrypoint.
echo "Host: $(hostname)"
hostname -I || true
+210
View File
@@ -0,0 +1,210 @@
#!/usr/bin/env python3
"""Wiki-only image retention. Preview is the default; never prune the daemon."""
import argparse
from contextlib import contextmanager
from datetime import datetime, timezone
import fcntl
import json
import os
from pathlib import Path
import re
import subprocess
import sys
SHA = re.compile(r"^[0-9a-f]{40}$")
PROJECT = "io.entroia.project"
ROOT = Path("/opt/prompt")
TTL = 3 * 86400
def run(args, **kwargs):
return subprocess.run(args, check=True, text=True, **kwargs)
def output(args):
return run(args, stdout=subprocess.PIPE).stdout
def timestamp(value):
return datetime.fromisoformat(value.replace("Z", "+00:00")).timestamp()
@contextmanager
def lock(path):
# Fail rather than wait behind a build and consume the workflow timeout.
with open(path, "a") as handle:
try:
fcntl.flock(handle, fcntl.LOCK_EX | fcntl.LOCK_NB)
except BlockingIOError:
raise RuntimeError(f"Busy: {path}; no cleanup performed")
yield
def history(root):
path = root / "successful-releases"
values = path.read_text().splitlines() if path.exists() else []
if any(not SHA.fullmatch(value) for value in values):
raise RuntimeError("Invalid successful-releases ledger; refusing cleanup")
return list(dict.fromkeys(values))
def current_sha(root):
path = root / "current" / "release.env"
if not path.is_file():
raise RuntimeError("Missing current/release.env; refusing cleanup")
values = [line.split("=", 1)[1] for line in path.read_text().splitlines()
if line.startswith("RELEASE_SHA=")]
if len(values) != 1 or not SHA.fullmatch(values[0]):
raise RuntimeError("Invalid current release SHA; refusing cleanup")
return values[0]
def record(root, sha):
if current_sha(root) != sha:
raise RuntimeError("Only the verified current release can be recorded")
values = [sha] + [item for item in history(root) if item != sha]
temp = root / ".successful-releases.next"
temp.write_text("\n".join(values) + "\n")
os.replace(temp, root / "successful-releases")
def decision(image, referenced, retained, ready, now, completed_test=None):
"""Return keep/delete with a reviewable reason; all tags must be in scope."""
tags = image.get("RepoTags") or []
labels = image.get("Config", {}).get("Labels") or {}
if image["Id"] in referenced:
return "keep", "container reference (including stopped containers)"
parsed = []
for tag in tags:
repo, _, sha = tag.rpartition(":")
if repo not in ("waterfall-wiki", "waterfall-wiki-tests") or not SHA.fullmatch(sha):
return "keep", "foreign or non-release tag"
parsed.append((repo, sha))
if any(digest.partition("@")[0] not in ("waterfall-wiki", "waterfall-wiki-tests")
for digest in image.get("RepoDigests") or []):
return "keep", "foreign registry digest retained conservatively"
if any(sha in retained for _, sha in parsed):
return "keep", "retained release or its operational test image"
if any(repo == "waterfall-wiki" for repo, _ in parsed) and not ready:
return "keep", "bootstrap: fewer than three successful releases recorded"
if parsed and all(repo == "waterfall-wiki-tests" and sha == completed_test
for repo, sha in parsed):
return "delete", "this job's tests completed successfully"
if now - timestamp(image["Created"]) < TTL:
return "keep", "three-day build/debug window"
if parsed:
return "delete", "expired project release/test image"
if labels.get(PROJECT) == "waterfall-wiki":
return "delete", "expired project-labelled intermediate image"
return "keep", "unlabelled image: ownership unknown"
def container_expired(container, now):
labels = container.get("Config", {}).get("Labels") or {}
return (labels.get(PROJECT) == "waterfall-wiki"
and labels.get("io.entroia.wiki.build") == "true"
and container["State"]["Status"] == "exited"
and not container.get("Mounts")
and now - timestamp(container["Created"]) >= TTL)
def containers():
ids = output(["docker", "ps", "-aq"]).split()
return json.loads(output(["docker", "inspect", *ids])) if ids else []
def image_ids():
# Include labelled build intermediates, but never enumerate foreign image metadata.
ids = set()
for name in ("waterfall-wiki", "waterfall-wiki-tests"):
ids.update(output(["docker", "image", "ls", "-aq", "--no-trunc", name]).split())
ids.update(output(["docker", "image", "ls", "-aq", "--no-trunc", "--filter",
f"label={PROJECT}=waterfall-wiki"]).split())
return sorted(ids)
def cleanup(root, apply=False, completed_test=None):
# CI holds the image lock throughout build, integration, deployment and cleanup.
# Taking the deployment lock also protects direct/manual deployment invocations.
with lock(root / ".deploy.lock"):
successes = history(root)
retained = set(successes[:3]) | {current_sha(root)}
ready = len(successes) >= 3
now = datetime.now(timezone.utc).timestamp()
print("APPLY" if apply else "PREVIEW", "retained releases:", sorted(retained), flush=True)
for container in containers():
if container_expired(container, now):
print("delete-container", container["Id"], "expired, project-labelled, no mounts", flush=True)
if apply:
fresh = json.loads(output(["docker", "inspect", container["Id"]]))[0]
if container_expired(fresh, now):
run(["docker", "container", "rm", container["Id"]])
referenced = {item["Image"] for item in containers()}
ids = image_ids()
images = json.loads(output(["docker", "image", "inspect", *ids])) if ids else []
for image in images:
image_id = image["Id"]
action, reason = decision(image, referenced, retained, ready, now, completed_test)
print(action, image_id, json.dumps(image.get("RepoTags") or []), reason, flush=True)
if apply and action == "delete":
# Recheck references and tags immediately before removal. No -f or volume operations.
fresh = json.loads(output(["docker", "image", "inspect", image_id]))[0]
refs = {item["Image"] for item in containers()}
if decision(fresh, refs, retained, ready, now, completed_test)[0] != "delete":
continue
for target in fresh.get("RepoTags") or [image_id]:
result = subprocess.run(["docker", "image", "rm", target], text=True)
if result.returncode:
print("skip", target, "Docker refused removal; retained", flush=True)
break
def ci(root, sha):
"""One lock spans the whole job; failed build images get a three-day grace."""
env = dict(os.environ, WATERFALL_WIKI_IMAGE=f"waterfall-wiki:{sha}",
WATERFALL_WIKI_TEST_IMAGE=f"waterfall-wiki-tests:{sha}")
code = 1
try:
run(["docker", "compose", "--env-file", "apps/waterfall-wiki/.env.example", "config", "--quiet"])
run(["docker", "build", "--target", "integration", "-t", env["WATERFALL_WIKI_TEST_IMAGE"], "."])
run(["docker", "run", "--rm", "--entrypoint", "node", env["WATERFALL_WIKI_TEST_IMAGE"],
"--test", "tests/character-pack.test.mjs"])
run(["docker", "build", "--target", "runtime", "-t", env["WATERFALL_WIKI_IMAGE"], "."])
run(["bash", "scripts/integration.sh"], env=env)
run(["bash", "scripts/deploy.sh", sha], env=env)
code = 0
except subprocess.CalledProcessError as error:
code = error.returncode
finally:
try:
cleanup(root, apply=True, completed_test=sha if code == 0 else None)
except Exception as error:
# Housekeeping must not mask the build/deployment result or roll back healthy services.
print(f"Cleanup failed; images retained for later retry: {error}", file=sys.stderr)
return code
def main():
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("command", nargs="?", choices=("preview", "apply", "record-success", "ci"), default="preview")
parser.add_argument("--sha")
args = parser.parse_args()
if args.command in ("ci", "record-success") and not SHA.fullmatch(args.sha or ""):
parser.error("--sha must be a full lowercase Git SHA")
if args.command == "record-success":
# Called at the end of deploy.sh while its deployment lock is still held.
record(ROOT, args.sha)
return 0
with lock(ROOT / ".image-lifecycle.lock"):
if args.command == "ci":
return ci(ROOT, args.sha)
cleanup(ROOT, apply=args.command == "apply")
return 0
if __name__ == "__main__":
try:
sys.exit(main())
except Exception as error:
print(str(error), file=sys.stderr)
sys.exit(1)
+168
View File
@@ -0,0 +1,168 @@
import importlib.util
import json
from pathlib import Path
import tempfile
import unittest
from unittest.mock import patch
spec = importlib.util.spec_from_file_location(
"lifecycle", Path(__file__).resolve().parents[1] / "scripts/wiki-image-lifecycle.py")
m = importlib.util.module_from_spec(spec)
spec.loader.exec_module(m)
NOW = m.timestamp("2026-10-29T12:00:00Z")
A, B, C, D = [letter * 40 for letter in "abcd"]
def image(tags=None, labels=None, created="2026-10-20T12:00:00Z"):
return {"Id": "sha256:example", "RepoTags": tags or [], "RepoDigests": [],
"Created": created, "Config": {"Labels": labels or {}}}
class RetentionTests(unittest.TestCase):
def decide(self, item, referenced=(), retained=(A, B, C), ready=True, completed=None):
return m.decision(item, set(referenced), set(retained), ready, NOW, completed)[0]
def test_current_and_two_previous_survive(self):
for sha in (A, B, C):
self.assertEqual(self.decide(image([f"waterfall-wiki:{sha}"])), "keep")
self.assertEqual(self.decide(image([f"waterfall-wiki:{D}"])), "delete")
def test_retained_release_test_images_remain_available_to_studio_tools(self):
for sha in (A, B, C):
self.assertEqual(self.decide(image([f"waterfall-wiki-tests:{sha}"]), completed=sha), "keep")
def test_stopped_container_reference_survives(self):
self.assertEqual(self.decide(image([f"waterfall-wiki:{D}"]),
referenced=("sha256:example",)), "keep")
def test_all_tags_of_shared_retained_image_survive(self):
self.assertEqual(self.decide(image([f"waterfall-wiki:{A}", f"waterfall-wiki:{D}"])), "keep")
def test_foreign_and_legacy_tags_survive(self):
for tag in (f"prompt:{D}", f"other-app:{D}", "waterfall-wiki:local"):
self.assertEqual(self.decide(image([tag, f"waterfall-wiki:{D}"])), "keep")
def test_bootstrap_never_deletes_runtime(self):
self.assertEqual(self.decide(image([f"waterfall-wiki:{D}"]), ready=False), "keep")
def test_failed_job_debug_window(self):
for repo in ("waterfall-wiki", "waterfall-wiki-tests"):
self.assertEqual(self.decide(image([f"{repo}:{D}"], created="2026-10-28T12:00:00Z")), "keep")
def test_successful_job_test_image_can_be_removed_immediately(self):
item = image([f"waterfall-wiki-tests:{D}"], created="2026-10-29T11:00:00Z")
self.assertEqual(self.decide(item, completed=D), "delete")
self.assertEqual(self.decide(item), "keep")
def test_expired_tests_dont_require_release_ledger(self):
self.assertEqual(self.decide(image([f"waterfall-wiki-tests:{D}"]), ready=False), "delete")
def test_only_owned_dangling_images_are_candidates(self):
self.assertEqual(self.decide(image()), "keep")
self.assertEqual(self.decide(image(labels={m.PROJECT: "other"})), "keep")
self.assertEqual(self.decide(image(labels={m.PROJECT: "waterfall-wiki"})), "delete")
def test_registry_digest_is_conservatively_retained(self):
item = image([f"waterfall-wiki:{D}"])
item["RepoDigests"] = ["registry.example/wiki@sha256:abc"]
self.assertEqual(self.decide(item), "keep")
def test_local_containerd_digest_does_not_disable_expiry(self):
item = image([f"waterfall-wiki-tests:{D}"])
item["RepoDigests"] = ["waterfall-wiki-tests@sha256:example"]
self.assertEqual(self.decide(item), "delete")
def test_failed_container_requires_ownership_age_and_no_mounts(self):
item = {"Config": {"Labels": {m.PROJECT: "waterfall-wiki", "io.entroia.wiki.build": "true"}},
"State": {"Status": "exited"}, "Mounts": [], "Created": "2026-10-20T12:00:00Z"}
self.assertTrue(m.container_expired(item, NOW))
for key, value in (("Mounts", [{"Type": "volume"}]),
("State", {"Status": "running"}),
("Created", "2026-10-29T11:00:00Z"),
("Config", {"Labels": {}})):
self.assertFalse(m.container_expired(dict(item, **{key: value}), NOW))
def test_failed_ci_preserves_original_exit_and_debug_images(self):
error = m.subprocess.CalledProcessError(7, ["docker", "build"])
with patch.object(m, "run", side_effect=error), patch.object(m, "cleanup") as cleanup:
self.assertEqual(m.ci(Path("/unused"), D), 7)
cleanup.assert_called_once_with(Path("/unused"), apply=True, completed_test=None)
def test_cleanup_error_does_not_turn_failed_ci_into_success(self):
error = m.subprocess.CalledProcessError(7, ["docker", "build"])
with patch.object(m, "run", side_effect=error), \
patch.object(m, "cleanup", side_effect=RuntimeError("busy")):
self.assertEqual(m.ci(Path("/unused"), D), 7)
def test_success_only_cleans_tests_after_deployment(self):
events = []
with patch.object(m, "run", side_effect=lambda args, **kw: events.append(args)), \
patch.object(m, "cleanup", side_effect=lambda *a, **kw: events.append(kw)):
self.assertEqual(m.ci(Path("/unused"), D), 0)
self.assertEqual(events[-2], ["bash", "scripts/deploy.sh", D])
self.assertEqual(events[-1], {"apply": True, "completed_test": D})
def test_busy_lock_refuses_work(self):
with tempfile.TemporaryDirectory() as directory:
path = Path(directory) / "lock"
with m.lock(path):
with self.assertRaisesRegex(RuntimeError, "Busy"):
with m.lock(path):
self.fail("Second holder entered lock")
def test_only_current_verified_sha_can_be_recorded_and_order_is_unique(self):
with tempfile.TemporaryDirectory() as directory:
root = Path(directory)
(root / "current").mkdir()
(root / "current/release.env").write_text(f"RELEASE_SHA={A}\n")
(root / "successful-releases").write_text(f"{B}\n{A}\n{C}\n")
m.record(root, A)
self.assertEqual(m.history(root), [A, B, C])
with self.assertRaises(RuntimeError):
m.record(root, D)
(root / "successful-releases").write_text("bad-ledger\n")
with self.assertRaises(RuntimeError):
m.history(root)
def test_preview_has_no_docker_mutations(self):
with tempfile.TemporaryDirectory() as directory:
root = Path(directory)
item = image([f"waterfall-wiki-tests:{D}"])
with patch.object(m, "history", return_value=[A, B, C]), \
patch.object(m, "current_sha", return_value=A), \
patch.object(m, "containers", return_value=[]), \
patch.object(m, "image_ids", return_value=[item["Id"]]), \
patch.object(m, "output", return_value=json.dumps([item])), \
patch.object(m, "run") as run, patch.object(m.subprocess, "run") as mutate:
m.cleanup(root)
run.assert_not_called()
mutate.assert_not_called()
def test_cleanup_rechecks_container_reference_before_removal(self):
with tempfile.TemporaryDirectory() as directory:
item = image([f"waterfall-wiki-tests:{D}"])
with patch.object(m, "history", return_value=[A, B, C]), \
patch.object(m, "current_sha", return_value=A), \
patch.object(m, "containers", side_effect=[[], [], [{"Image": item["Id"]}]]), \
patch.object(m, "image_ids", return_value=[item["Id"]]), \
patch.object(m, "output", return_value=json.dumps([item])), \
patch.object(m.subprocess, "run") as mutate:
m.cleanup(Path(directory), apply=True)
mutate.assert_not_called()
def test_cleanup_never_forces_or_removes_volumes(self):
with tempfile.TemporaryDirectory() as directory:
item = image([f"waterfall-wiki-tests:{D}"])
with patch.object(m, "history", return_value=[A, B, C]), \
patch.object(m, "current_sha", return_value=A), \
patch.object(m, "containers", return_value=[]), \
patch.object(m, "image_ids", return_value=[item["Id"]]), \
patch.object(m, "output", return_value=json.dumps([item])), \
patch.object(m.subprocess, "run") as mutate:
mutate.return_value.returncode = 0
m.cleanup(Path(directory), apply=True)
mutate.assert_called_once_with(["docker", "image", "rm", f"waterfall-wiki-tests:{D}"], text=True)
if __name__ == "__main__":
unittest.main()