diff --git a/docs/forgejo-package-prune.md b/docs/forgejo-package-prune.md index f79c3b1..dccb73a 100644 --- a/docs/forgejo-package-prune.md +++ b/docs/forgejo-package-prune.md @@ -82,6 +82,11 @@ merges complete, nonempty exports with the prior inventory under a writer lock, then atomically publishes sorted image references and a count/hash receipt. Set `LIVE_IMAGES_OUTPUT` when publishing for a different host user. Missing, empty, or malformed input fails without replacing the previous file. +The prune CLI also refuses `--apply` before credential retrieval if any +explicit `--live-images-file` is missing, unreadable, empty, or comment-only. +Dry-run keeps reporting those files as warnings. This guard supplements the +activity-core worker guard; it does not require exports for callers that have +not configured any. Refresh only adds protection: removal of obsolete tags requires a separate review of every production cluster and rollback requirement. diff --git a/scripts/forgejo_package_prune.py b/scripts/forgejo_package_prune.py index faed37e..226cf38 100644 --- a/scripts/forgejo_package_prune.py +++ b/scripts/forgejo_package_prune.py @@ -121,8 +121,8 @@ def collect_live_images_from_files( output from another cluster). This closes the multi-cluster gap (ACTIVITY-WP-0020-T07): the prune host's kubectl only sees its own cluster, so every other production cluster exports its live images to a - file that is merged here. A missing file is a WARN, not a failure — - but it means reduced protection coverage, so the note must surface. + file that is merged here. Unavailable or empty exports produce notes; + main refuses apply when any requested export cannot provide coverage. """ protected: set[tuple[str, str, str]] = set() notes: list[str] = [] @@ -131,13 +131,22 @@ def collect_live_images_from_files( if not path.is_file(): notes.append(f"live-images file missing: {path}") continue - for line in path.read_text(encoding="utf-8").splitlines(): + try: + lines = path.read_text(encoding="utf-8").splitlines() + except (OSError, UnicodeError): + notes.append(f"live-images file unreadable: {path}") + continue + has_images = False + for line in lines: image = line.strip() if not image or image.startswith("#"): continue + has_images = True match = FORGEJO_IMAGE_RE.match(image) if match and match.group("tag"): protected.add(("container", match.group("name"), match.group("tag"))) + if not has_images: + notes.append(f"live-images file empty: {path}") return protected, notes @@ -509,6 +518,12 @@ def main(argv: list[str] | None = None) -> int: apply = bool(args.apply) dry_run = not apply package_types = [part.strip() for part in args.types.split(",") if part.strip()] + file_live, file_notes = collect_live_images_from_files(args.live_images_files) + if apply and file_notes: + for note in file_notes: + print(f" ERROR: {note}", file=sys.stderr) + print("Refusing apply: requested live-image inventory is unavailable or empty", file=sys.stderr) + return 2 token = load_token() protected = collect_protected_versions(args.apps_root.expanduser()) protect_notes: list[str] = [] @@ -519,7 +534,6 @@ def main(argv: list[str] | None = None) -> int: for note in protect_notes: print(f" WARN: {note}", file=sys.stderr) if args.live_images_files: - file_live, file_notes = collect_live_images_from_files(args.live_images_files) protected |= file_live protect_notes.extend(file_notes) print(f"Protected exported live tags: {len(file_live)}", file=sys.stderr) diff --git a/tests/test_forgejo_package_prune.py b/tests/test_forgejo_package_prune.py index 07ea293..233ea89 100644 --- a/tests/test_forgejo_package_prune.py +++ b/tests/test_forgejo_package_prune.py @@ -18,6 +18,36 @@ SPEC.loader.exec_module(prune) class ForgejoPackagePruneTests(unittest.TestCase): + def test_apply_refuses_any_unavailable_export_before_authentication(self): + with tempfile.TemporaryDirectory() as tmp: + root = Path(tmp) + good = root / "good.txt" + good.write_text("forgejo.coulomb.social/coulomb/app:live\n") + bad = root / "bad.txt" + for content in [None, b"", b"# no inventory\n", b"\xff"]: + with self.subTest(content=content): + if content is not None: + bad.write_bytes(content) + with mock.patch.object(prune, "load_token") as auth, \ + mock.patch.object(prune, "build_delete_plans") as plans, \ + mock.patch.object(prune, "delete_version") as delete: + result = prune.main([ + "--apply", "--live-images-file", str(good), + "--live-images-file", str(bad), + ]) + self.assertEqual(result, 2) + auth.assert_not_called() + plans.assert_not_called() + delete.assert_not_called() + + def test_non_forgejo_image_export_is_not_empty(self): + with tempfile.TemporaryDirectory() as tmp: + export = Path(tmp) / "images.txt" + export.write_text("nginx:stable\n") + protected, notes = prune.collect_live_images_from_files([export]) + self.assertEqual(protected, set()) + self.assertEqual(notes, []) + def test_collect_protected_versions_from_helm_values(self) -> None: import tempfile diff --git a/workplans/RPF-WP-0028-durable-live-image-inventory.md b/workplans/RPF-WP-0028-durable-live-image-inventory.md index eb26605..500ff02 100644 --- a/workplans/RPF-WP-0028-durable-live-image-inventory.md +++ b/workplans/RPF-WP-0028-durable-live-image-inventory.md @@ -25,6 +25,11 @@ priority: high Implemented scripts/refresh_live_images.py and make live-images-refresh. Publication validates exports before mutation, locks concurrent writers, retains all previous cluster entries, and fsyncs an atomic replacement. Offline tests prove repeatability, multi-cluster retention, and preservation on missing, empty, or malformed input. +The local prune CLI was additionally hardened on 2026-09-05 to refuse apply +before credential lookup if any explicitly requested export is missing, +unreadable, empty, or comment-only. Regression coverage includes a valid file +alongside a bad one, proving partial coverage cannot authorize deletion. + ## Install production projection and rollout refresh ```task