Protect digest-pinned packages and refuse partial-inventory pruning
Assistant: codex Assistant-Model: gpt-6-astra Assistant-Session: 01a0e241-8285-7a63-8c0c-51c9cb824dc3
This commit is contained in:
parent
3f834b0556
commit
743def17be
5 changed files with 174 additions and 11 deletions
51
docs/activity-core-release-admission.md
Normal file
51
docs/activity-core-release-admission.md
Normal 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.
|
||||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
45
tests/test_digest_retention.py
Normal file
45
tests/test_digest_retention.py
Normal 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
|
||||
|
|
@ -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`.
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue