Protect digest-pinned packages and refuse partial-inventory pruning
All checks were successful
CI Smoke / host-smoke (push) Successful in 0s
CI Smoke / container-smoke (push) Successful in 2s

Assistant: codex
Assistant-Model: gpt-6-astra
Assistant-Session: 01a0e241-8285-7a63-8c0c-51c9cb824dc3
This commit is contained in:
codex 2026-09-27 16:00:51 +02:00
parent 3f834b0556
commit 743def17be
5 changed files with 174 additions and 11 deletions

View file

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

View file

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

View file

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

View file

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

View file

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