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.
|
then atomically publishes sorted image references and a count/hash receipt.
|
||||||
Set `LIVE_IMAGES_OUTPUT` when publishing for a different host user.
|
Set `LIVE_IMAGES_OUTPUT` when publishing for a different host user.
|
||||||
Missing, empty, or malformed input fails without replacing the previous file.
|
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
|
Refresh only adds protection: removal of obsolete tags requires a separate
|
||||||
review of every production cluster and rollback requirement.
|
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
|
output from another cluster). This closes the multi-cluster gap
|
||||||
(ACTIVITY-WP-0020-T07): the prune host's kubectl only sees its own
|
(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
|
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 —
|
file that is merged here. Unavailable or empty exports produce notes;
|
||||||
but it means reduced protection coverage, so the note must surface.
|
main refuses apply when any requested export cannot provide coverage.
|
||||||
"""
|
"""
|
||||||
protected: set[tuple[str, str, str]] = set()
|
protected: set[tuple[str, str, str]] = set()
|
||||||
notes: list[str] = []
|
notes: list[str] = []
|
||||||
|
|
@ -131,13 +131,22 @@ def collect_live_images_from_files(
|
||||||
if not path.is_file():
|
if not path.is_file():
|
||||||
notes.append(f"live-images file missing: {path}")
|
notes.append(f"live-images file missing: {path}")
|
||||||
continue
|
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()
|
image = line.strip()
|
||||||
if not image or image.startswith("#"):
|
if not image or image.startswith("#"):
|
||||||
continue
|
continue
|
||||||
|
has_images = True
|
||||||
match = FORGEJO_IMAGE_RE.match(image)
|
match = FORGEJO_IMAGE_RE.match(image)
|
||||||
if match and match.group("tag"):
|
if match and match.group("tag"):
|
||||||
protected.add(("container", match.group("name"), 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
|
return protected, notes
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -509,6 +518,12 @@ def main(argv: list[str] | None = None) -> int:
|
||||||
apply = bool(args.apply)
|
apply = bool(args.apply)
|
||||||
dry_run = not apply
|
dry_run = not apply
|
||||||
package_types = [part.strip() for part in args.types.split(",") if part.strip()]
|
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()
|
token = load_token()
|
||||||
protected = collect_protected_versions(args.apps_root.expanduser())
|
protected = collect_protected_versions(args.apps_root.expanduser())
|
||||||
protect_notes: list[str] = []
|
protect_notes: list[str] = []
|
||||||
|
|
@ -519,7 +534,6 @@ def main(argv: list[str] | None = None) -> int:
|
||||||
for note in protect_notes:
|
for note in protect_notes:
|
||||||
print(f" WARN: {note}", file=sys.stderr)
|
print(f" WARN: {note}", file=sys.stderr)
|
||||||
if args.live_images_files:
|
if args.live_images_files:
|
||||||
file_live, file_notes = collect_live_images_from_files(args.live_images_files)
|
|
||||||
protected |= file_live
|
protected |= file_live
|
||||||
protect_notes.extend(file_notes)
|
protect_notes.extend(file_notes)
|
||||||
print(f"Protected exported live tags: {len(file_live)}", file=sys.stderr)
|
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):
|
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:
|
def test_collect_protected_versions_from_helm_values(self) -> None:
|
||||||
import tempfile
|
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.
|
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
|
## Install production projection and rollout refresh
|
||||||
|
|
||||||
```task
|
```task
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue