Refuse prune apply when requested image inventories are unavailable
All checks were successful
CI Smoke / host-smoke (push) Successful in 0s
CI Smoke / container-smoke (push) Successful in 1s

Assistant: codex
Assistant-Model: gpt-6-astra
Assistant-Session: 01a06ecb-456a-71c2-b41e-0755d336e883
This commit is contained in:
codex 2026-09-05 02:00:50 +02:00
parent 0349a08e1b
commit f637989a69
4 changed files with 58 additions and 4 deletions

View file

@ -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.

View file

@ -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)

View file

@ -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

View file

@ -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