Retain wiki rollback images and expire unused CI images #17
@@ -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
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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)
|
||||
@@ -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()
|
||||
Reference in New Issue
Block a user