Refuse prune apply when requested image inventories are unavailable
Assistant: codex Assistant-Model: gpt-6-astra Assistant-Session: 01a06ecb-456a-71c2-b41e-0755d336e883
This commit is contained in:
parent
0349a08e1b
commit
f637989a69
4 changed files with 58 additions and 4 deletions
|
|
@ -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.
|
||||
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue