diff --git a/docs/activity-core-release-admission.md b/docs/activity-core-release-admission.md new file mode 100644 index 0000000..c570965 --- /dev/null +++ b/docs/activity-core-release-admission.md @@ -0,0 +1,51 @@ +# Activity-core unattended release admission + +Owner: ACTIVITY-WP-0041-T03, with RPF-WP-0048-T02 for platform activation. +Status: proposed contract; no credential minted and no automatic sync enabled. +The user authorized implementation; these are enforcement requirements, not a +request to reapprove the existing adoption. + +## Required enforcement + +Use a dedicated non-admin service principal. Separate producer and reviewer +identities. Public source reads need no repository credential. A general repository +write PAT cannot enforce image-only changes by itself: the executor must validate +the exact before/after commits and authenticated receipts before writing, while +protected branches and required checks prevent bypass. Restrict its repository +membership to the release repository and its ArgoCD role to get/sync the single +`activity-core/activity-core` application. No root sync, application spec updates, +project updates, exec, prune, overrides, secrets or other applications. + +The present parent Application pins the child's source revision in the platform +repository. Therefore an app-only ArgoCD sync grant alone cannot perform durable +promotion. Implement a reviewed broker for the single child revision field or a +separately reviewed source-tracking design; do not grant the executor unrestricted +platform repository writes to work around this boundary. + +Keep credentials in OpenBao with the existing delivery subsystem; never in Git, +receipts or prompts. Publish the exact subject, repository/branch/path scope, +ArgoCD resource, TTL, revocation and negative-test evidence before admission. +The warden routing catalog currently has no ready release-specific lane. Do not +reuse source-read or package-admin credentials as deployment authority. + +## Receipt and failure requirements + +Authenticate CI completion against the expected Forgejo repository and full commit; +require the image-build and smoke checks. Bind the independently authenticated +reviewer result to that same commit and actual image digest. Fetch current ArgoCD +health from its authority, with bounded staleness; preserve observations proving +at least 24 healthy hours. Producer-provided booleans are insufficient. + +Before promotion retain the exact prior source revision and protect both live and +rollback images in the additive registry inventory. Serialize releases, compare +the current revision before writing, persist state, and recover idempotently after +restart. Reconcile through ArgoCD without pruning. Check deployment readiness, +report sink and schedule invariants. On timeout/failure revert the pinned revision +through the same Git path, reconcile, verify recovery, and stop further promotion +if recovery fails. Store sanitized receipts in the activity-core/State Hub run. + +Acceptance must include denied out-of-scope mutation, stale/mismatched/forged +receipts, concurrent release, restart, failed health and successful rollback. +Run destructive failure fixtures in an isolated environment; production must not +be intentionally broken to manufacture proof. Admit only after tests and the +observation gate pass. Access, budget or scope expansion stays a monthly decision. diff --git a/docs/forgejo-package-prune.md b/docs/forgejo-package-prune.md index a2f3179..21c4c2c 100644 --- a/docs/forgejo-package-prune.md +++ b/docs/forgejo-package-prune.md @@ -131,3 +131,19 @@ counts (`deleted_count`, `candidate_count`, `skipped_protected_count`, `errors`) - `railiance-apps/docs/forgejo-package-registry.md` - `docs/forgejo-backup.md` - `docs/workload-kv-access-lanes.md` + +## Digest-pinned packages + +Live/exported references with a valid sha256 digest (including tag@digest) protect +**every container version of that package**. The reason is +`protected_digest_package`. This conservative policy covers aliases and child +manifests without assuming the package API supplies a complete digest mapping. +Other packages retain normal depth-based cleanup. Additive inventory also retains +rollback references; removal requires the existing owner review. Storage use may +grow for digest-pinned packages until a reviewed registry-aware mapping replaces +this conservative protection. + +Malformed Forgejo references stop apply. Incomplete requested cluster inventories +or failed package listings stop apply before any deletion, even when other package +lists succeed. Dry-run remains available for inspection. No manual prune execution +is needed to verify the protection logic. diff --git a/scripts/forgejo_package_prune.py b/scripts/forgejo_package_prune.py index 226cf38..be5b96a 100644 --- a/scripts/forgejo_package_prune.py +++ b/scripts/forgejo_package_prune.py @@ -33,6 +33,30 @@ FORGEJO_IMAGE_RE = re.compile( ) +def protect_image(image: str, protected: set[tuple[str, str, str]]) -> str | None: + """Digest references conservatively protect all versions of their package. + + Package APIs do not prove which tags or child manifests share a live digest. + Retaining the whole package avoids deleting live/rollback content through an + alias. The additive inventory intentionally keeps this protection until an + owner explicitly retires the reference. + """ + ref, separator, digest = image.partition("@") + match = FORGEJO_IMAGE_RE.fullmatch(ref) + if not match: + if image.lower().startswith("forgejo.coulomb.social/"): + return "unrecognized Forgejo image reference" + return None + name = match.group("name") + if separator: + if not re.fullmatch(r"sha256:[0-9a-f]{64}", digest): + return "invalid Forgejo image digest" + protected.add(("container", name, "*")) + else: + protected.add(("container", name, match.group("tag") or "latest")) + return None + + @dataclass(frozen=True) class VersionRef: package_type: str @@ -142,9 +166,9 @@ def collect_live_images_from_files( 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"))) + error = protect_image(image, protected) + if error: + notes.append(f"{error} in live-images file: {path}") if not has_images: notes.append(f"live-images file empty: {path}") return protected, notes @@ -181,14 +205,17 @@ def collect_live_cluster_versions( ) except Exception as exc: # noqa: BLE001 return protected, [f"live-tag protection skipped: kubectl query failed ({exc})"] + notes: list[str] = [] for line in result.stdout.splitlines(): image = line.strip() if not image: continue - match = FORGEJO_IMAGE_RE.match(image) - if match and match.group("tag"): - protected.add(("container", match.group("name"), match.group("tag"))) - return protected, [] + error = protect_image(image, protected) + if error: + notes.append(error) + if not result.stdout.strip(): + notes.append("live cluster image inventory empty") + return protected, notes def _api_request( @@ -242,7 +269,9 @@ def list_packages( ) url = f"{base_url.rstrip('/')}/api/v1/packages/{owner_q}?{query}" payload = _api_request("GET", url, token) - batch = payload if isinstance(payload, list) else [] + if not isinstance(payload, list): + raise ValueError("invalid package inventory response") + batch = payload if not batch: break items.extend(batch) @@ -321,7 +350,8 @@ def build_delete_plans( if not version or version in keep: continue key = (package_type, name, version) - is_protected = key in protected + digest_protected = (package_type, name, "*") in protected + is_protected = key in protected or digest_protected plans.append( DeletePlan( package_type=package_type, @@ -329,7 +359,9 @@ def build_delete_plans( version=version, created_at=str(item.get("created_at") or ""), protected=is_protected, - reason="protected_production_tag" if is_protected else "beyond_retention_depth", + reason=("protected_digest_package" if digest_protected else + "protected_production_tag" if is_protected else + "beyond_retention_depth"), ) ) return plans, errors @@ -552,6 +584,11 @@ def main(argv: list[str] | None = None) -> int: protected=protected, ) + # Never partially prune after an incomplete package or requested cluster scan. + if apply and (errors or protect_notes): + print("Refusing apply: incomplete inventory/protection coverage", file=sys.stderr) + return 2 + deleted: list[DeletePlan] = [] for plan in plans: if plan.protected: diff --git a/tests/test_digest_retention.py b/tests/test_digest_retention.py new file mode 100644 index 0000000..54ea568 --- /dev/null +++ b/tests/test_digest_retention.py @@ -0,0 +1,45 @@ +from unittest.mock import patch +from scripts import forgejo_package_prune as p + +IMAGE = 'forgejo.coulomb.social/coulomb/activity-core' + +def test_digest_and_tag_digest_protect_whole_package(tmp_path): + for suffix in ['@sha256:' + 'a'*64, ':old@sha256:' + 'b'*64]: + f = tmp_path / 'images'; f.write_text(IMAGE + suffix + '\n') + protected, notes = p.collect_live_images_from_files([f]) + assert notes == [] + assert protected == {('container', 'activity-core', '*')} + versions = [{'name': name, 'version': version, 'created_at': date} + for name in ['activity-core', 'unrelated'] + for version, date in [('new', '2026-09-27'), ('old', '2025-01-01')]] + with patch.object(p, 'list_packages', return_value=versions): + plans, errors = p.build_delete_plans(base_url='', token='', owner='coulomb', + package_types=['container'], max_versions=1, protected=protected) + assert not errors + assert [(x.name, x.protected, x.reason) for x in plans] == [ + ('activity-core', True, 'protected_digest_package'), + ('unrelated', False, 'beyond_retention_depth')] + +def test_bad_digest_refuses_apply_before_credentials(tmp_path): + f = tmp_path / 'images'; f.write_text(IMAGE + '@sha256:bad\n') + with patch.object(p, 'load_token') as auth, patch.object(p, 'delete_version') as delete: + assert p.main(['--apply', '--live-images-file', str(f)]) == 2 + auth.assert_not_called(); delete.assert_not_called() + +def test_incomplete_inventory_never_deletes(): + for errors, notes in [(['list failed'], []), ([], ['cluster unavailable'])]: + with patch.object(p, 'load_token', return_value='fixture'), \ + patch.object(p, 'collect_protected_versions', return_value=set()), \ + patch.object(p, 'collect_live_cluster_versions', return_value=(set(), notes)), \ + patch.object(p, 'build_delete_plans', return_value=([p.DeletePlan('container','x','old','',False,'old')], errors)), \ + patch.object(p, 'delete_version') as delete: + assert p.main(['--apply']) == 2 + delete.assert_not_called() + +def test_cluster_digest_uses_same_protection(): + import subprocess + with patch.object(p.shutil, 'which', return_value='/bin/kubectl'), \ + patch.object(p.subprocess, 'run', return_value=subprocess.CompletedProcess([],0,IMAGE+'@sha256:'+'a'*64+'\n','')): + protected, notes = p.collect_live_cluster_versions() + assert not notes + assert ('container','activity-core','*') in protected diff --git a/workplans/RPF-WP-0048-activity-core-gitops-adoption.md b/workplans/RPF-WP-0048-activity-core-gitops-adoption.md index 99d432e..c64f008 100644 --- a/workplans/RPF-WP-0048-activity-core-gitops-adoption.md +++ b/workplans/RPF-WP-0048-activity-core-gitops-adoption.md @@ -56,7 +56,7 @@ activating bounded routine promotion. Destructive pruning remains disabled. ```task id: RPF-WP-0048-T03 -status: todo +status: progress priority: high state_hub_task_id: "6c2650a2-5da8-5999-a6e7-b22eb7c655f4" ``` @@ -86,3 +86,17 @@ starts 2026-09-27T13:38:21Z, earliest eligibility 2026-09-28T13:38:21Z subject t healthy observation. T02 stays waiting for this window and authenticated narrowly bound release-identity/rollback proof. Automation and pruning remain disabled. Authorization decision: 78a4b859-dd00-4623-b95b-121b0e1c915d. + +## Digest retention implementation — 2026-09-27 + +Implemented conservative package-wide protection for live/exported sha256 refs, +including tag@digest and retained rollback references. This avoids unsafe alias +mapping assumptions; storage retention increases for those packages. Malformed +references and incomplete cluster/package inventory refuse apply before deletion. +Sixteen focused tests pass. Live tool installation/readback remains required; +existing baseline aliases continue protecting production in the meantime. + +Credential routing inspection found no ready scoped unattended ArgoCD/Forgejo +release lane. ACTIVITY-WP-0041-T03 remains the authority/admission owner; no broad +operator token was copied or delegated. Its concrete executor contract is in +`docs/activity-core-release-admission.md`.