diff --git a/docs/forgejo-package-prune.md b/docs/forgejo-package-prune.md index dccb73a..a2f3179 100644 --- a/docs/forgejo-package-prune.md +++ b/docs/forgejo-package-prune.md @@ -106,8 +106,14 @@ Migration and rollout sequence: other clusters' prior entries during outages. Run the hook on the durable host, or transfer the complete export there before invoking it. -The publisher is implemented here; host migration and the activity-core mount -and rollout-hook adoption are tracked in `RPF-WP-0028` until verified live. +`tools/cmd/refresh-live-images` captures regular, init and ephemeral container +images with a bounded kubectl call and publishes only after all requested +contexts succeed. `CONTEXTS` selects contexts; `EXTRA_LIVE_FILES` supplies saved +exports. Bare image names are accepted. Failed captures preserve the inventory. +The host installation exposes `~/.local/bin/railiance-live-images-refresh`; +invoke it after cluster image rollouts. Activity-core's `make refresh-live-images` +delegates to the same implementation. Live installation evidence is tracked in +`RPF-WP-0028`. ## Rollback procedure diff --git a/scripts/refresh_live_images.py b/scripts/refresh_live_images.py index 758804b..b25f4da 100644 --- a/scripts/refresh_live_images.py +++ b/scripts/refresh_live_images.py @@ -8,6 +8,7 @@ import hashlib import json import os from pathlib import Path +import re import tempfile @@ -17,7 +18,7 @@ def read_images(path: Path) -> set[str]: image = line.strip() if not image or image.startswith("#"): continue - if any(c.isspace() for c in image) or "/" not in image: + if not re.fullmatch(r"[A-Za-z0-9][A-Za-z0-9._:/@+-]*", image): raise ValueError("invalid image export") images.add(image) if not images: diff --git a/tests/test_live_images_inventory.py b/tests/test_live_images_inventory.py index 8be8518..7bd58de 100644 --- a/tests/test_live_images_inventory.py +++ b/tests/test_live_images_inventory.py @@ -1,5 +1,7 @@ import importlib.util +import os from pathlib import Path +import subprocess import tempfile import unittest @@ -11,6 +13,31 @@ SPEC.loader.exec_module(inventory) class InventoryTests(unittest.TestCase): + def test_bare_image_names_are_valid(self): + with tempfile.TemporaryDirectory() as tmp: + root = Path(tmp) + source = root / "source" + source.write_text("nginx:stable\n") + self.assertEqual(inventory.refresh(root / "all", [source])["images"], 1) + + def test_capture_failure_keeps_prior_inventory(self): + with tempfile.TemporaryDirectory() as tmp: + root = Path(tmp) + output = root / "all" + output.write_text("forgejo.example/org/app:old\n") + kubectl = root / "kubectl" + kubectl.write_text('#!/bin/sh\necho forgejo.example/org/app:new\nexit 1\n') + kubectl.chmod(0o755) + env = {**os.environ, "PATH": str(root) + ":" + os.environ["PATH"], "OUT": str(output)} + command = Path(__file__).resolve().parents[1] / "tools/cmd/refresh-live-images" + failed = subprocess.run(["bash", str(command)], env=env, capture_output=True) + self.assertNotEqual(failed.returncode, 0) + self.assertEqual(output.read_text(), "forgejo.example/org/app:old\n") + kubectl.write_text('#!/bin/sh\necho forgejo.example/org/app:new\n') + succeeded = subprocess.run(["bash", str(command)], env=env, capture_output=True) + self.assertEqual(succeeded.returncode, 0, succeeded.stderr) + self.assertEqual(len(output.read_text().splitlines()), 2) + def test_refresh_preserves_other_clusters_and_previous_tags(self): with tempfile.TemporaryDirectory() as tmp: root = Path(tmp) diff --git a/tools/cmd/refresh-live-images b/tools/cmd/refresh-live-images new file mode 100755 index 0000000..8910e2b --- /dev/null +++ b/tools/cmd/refresh-live-images @@ -0,0 +1,23 @@ +#!/usr/bin/env bash +# Capture complete cluster exports and atomically preserve the protection union. +set -euo pipefail +ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)" +OUT="${OUT:-${HOME}/.local/state/railiance-platform/live-images/all.txt}" +tmpdir=$(mktemp -d) +trap 'rm -rf "$tmpdir"' EXIT +sources=() +capture() { + local context="$1" file="$tmpdir/export-${#sources[@]}.txt" + local args=() + if [[ -n "$context" ]]; then args=(--context "$context"); fi + kubectl "${args[@]}" --request-timeout=30s get pods -A \ + -o jsonpath='{range .items[*]}{range .spec.containers[*]}{.image}{"\n"}{end}{range .spec.initContainers[*]}{.image}{"\n"}{end}{range .spec.ephemeralContainers[*]}{.image}{"\n"}{end}{end}' > "$file" + sources+=(--source "$file") +} +if [[ -z "${CONTEXTS:-}" ]]; then + capture "" +else + for context in $CONTEXTS; do capture "$context"; done +fi +for file in ${EXTRA_LIVE_FILES:-}; do sources+=(--source "$file"); done +python3 "$ROOT/scripts/refresh_live_images.py" --output "$OUT" "${sources[@]}"