205 lines
8.6 KiB
Python
205 lines
8.6 KiB
Python
#!/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
|
|
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)
|