From dbc3ad8c6943056f2adb148b183ab33055871f8d Mon Sep 17 00:00:00 2001 From: Codex Bot Date: Thu, 29 Oct 2026 18:43:57 +0800 Subject: [PATCH] Retain verified wiki releases and expire unused CI images --- .gitea/workflows/waterfall-wiki.yml | 24 +- .../workflows/wiki-image-retention-checks.yml | 26 +++ Dockerfile | 2 + docs/wiki-image-retention.md | 59 +++++ scripts/deploy.sh | 6 +- scripts/wiki-image-lifecycle.py | 210 ++++++++++++++++++ tests/test_wiki_image_lifecycle.py | 168 ++++++++++++++ 7 files changed, 479 insertions(+), 16 deletions(-) create mode 100644 .gitea/workflows/wiki-image-retention-checks.yml create mode 100644 docs/wiki-image-retention.md create mode 100644 scripts/wiki-image-lifecycle.py create mode 100644 tests/test_wiki_image_lifecycle.py diff --git a/.gitea/workflows/waterfall-wiki.yml b/.gitea/workflows/waterfall-wiki.yml index b2cb01b..945aae9 100644 --- a/.gitea/workflows/waterfall-wiki.yml +++ b/.gitea/workflows/waterfall-wiki.yml @@ -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 diff --git a/.gitea/workflows/wiki-image-retention-checks.yml b/.gitea/workflows/wiki-image-retention-checks.yml new file mode 100644 index 0000000..a4ce0cd --- /dev/null +++ b/.gitea/workflows/wiki-image-retention-checks.yml @@ -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 diff --git a/Dockerfile b/Dockerfile index 43501a2..43a60ba 100644 --- a/Dockerfile +++ b/Dockerfile @@ -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 diff --git a/docs/wiki-image-retention.md b/docs/wiki-image-retention.md new file mode 100644 index 0000000..8d27f4f --- /dev/null +++ b/docs/wiki-image-retention.md @@ -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 ` 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. diff --git a/scripts/deploy.sh b/scripts/deploy.sh index 732bfc9..023e0c9 100644 --- a/scripts/deploy.sh +++ b/scripts/deploy.sh @@ -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 diff --git a/scripts/wiki-image-lifecycle.py b/scripts/wiki-image-lifecycle.py new file mode 100644 index 0000000..f181e95 --- /dev/null +++ b/scripts/wiki-image-lifecycle.py @@ -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) diff --git a/tests/test_wiki_image_lifecycle.py b/tests/test_wiki_image_lifecycle.py new file mode 100644 index 0000000..8f760bb --- /dev/null +++ b/tests/test_wiki_image_lifecycle.py @@ -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()