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`
|
- `railiance-apps/docs/forgejo-package-registry.md`
|
||||||
- `docs/forgejo-backup.md`
|
- `docs/forgejo-backup.md`
|
||||||
- `docs/workload-kv-access-lanes.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)
|
@dataclass(frozen=True)
|
||||||
class VersionRef:
|
class VersionRef:
|
||||||
package_type: str
|
package_type: str
|
||||||
|
|
@ -142,9 +166,9 @@ def collect_live_images_from_files(
|
||||||
if not image or image.startswith("#"):
|
if not image or image.startswith("#"):
|
||||||
continue
|
continue
|
||||||
has_images = True
|
has_images = True
|
||||||
match = FORGEJO_IMAGE_RE.match(image)
|
error = protect_image(image, protected)
|
||||||
if match and match.group("tag"):
|
if error:
|
||||||
protected.add(("container", match.group("name"), match.group("tag")))
|
notes.append(f"{error} in live-images file: {path}")
|
||||||
if not has_images:
|
if not has_images:
|
||||||
notes.append(f"live-images file empty: {path}")
|
notes.append(f"live-images file empty: {path}")
|
||||||
return protected, notes
|
return protected, notes
|
||||||
|
|
@ -181,14 +205,17 @@ def collect_live_cluster_versions(
|
||||||
)
|
)
|
||||||
except Exception as exc: # noqa: BLE001
|
except Exception as exc: # noqa: BLE001
|
||||||
return protected, [f"live-tag protection skipped: kubectl query failed ({exc})"]
|
return protected, [f"live-tag protection skipped: kubectl query failed ({exc})"]
|
||||||
|
notes: list[str] = []
|
||||||
for line in result.stdout.splitlines():
|
for line in result.stdout.splitlines():
|
||||||
image = line.strip()
|
image = line.strip()
|
||||||
if not image:
|
if not image:
|
||||||
continue
|
continue
|
||||||
match = FORGEJO_IMAGE_RE.match(image)
|
error = protect_image(image, protected)
|
||||||
if match and match.group("tag"):
|
if error:
|
||||||
protected.add(("container", match.group("name"), match.group("tag")))
|
notes.append(error)
|
||||||
return protected, []
|
if not result.stdout.strip():
|
||||||
|
notes.append("live cluster image inventory empty")
|
||||||
|
return protected, notes
|
||||||
|
|
||||||
|
|
||||||
def _api_request(
|
def _api_request(
|
||||||
|
|
@ -242,7 +269,9 @@ def list_packages(
|
||||||
)
|
)
|
||||||
url = f"{base_url.rstrip('/')}/api/v1/packages/{owner_q}?{query}"
|
url = f"{base_url.rstrip('/')}/api/v1/packages/{owner_q}?{query}"
|
||||||
payload = _api_request("GET", url, token)
|
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:
|
if not batch:
|
||||||
break
|
break
|
||||||
items.extend(batch)
|
items.extend(batch)
|
||||||
|
|
@ -321,7 +350,8 @@ def build_delete_plans(
|
||||||
if not version or version in keep:
|
if not version or version in keep:
|
||||||
continue
|
continue
|
||||||
key = (package_type, name, version)
|
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(
|
plans.append(
|
||||||
DeletePlan(
|
DeletePlan(
|
||||||
package_type=package_type,
|
package_type=package_type,
|
||||||
|
|
@ -329,7 +359,9 @@ def build_delete_plans(
|
||||||
version=version,
|
version=version,
|
||||||
created_at=str(item.get("created_at") or ""),
|
created_at=str(item.get("created_at") or ""),
|
||||||
protected=is_protected,
|
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
|
return plans, errors
|
||||||
|
|
@ -552,6 +584,11 @@ def main(argv: list[str] | None = None) -> int:
|
||||||
protected=protected,
|
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] = []
|
deleted: list[DeletePlan] = []
|
||||||
for plan in plans:
|
for plan in plans:
|
||||||
if plan.protected:
|
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
|
```task
|
||||||
id: RPF-WP-0048-T03
|
id: RPF-WP-0048-T03
|
||||||
status: todo
|
status: progress
|
||||||
priority: high
|
priority: high
|
||||||
state_hub_task_id: "6c2650a2-5da8-5999-a6e7-b22eb7c655f4"
|
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
|
healthy observation. T02 stays waiting for this window and authenticated narrowly
|
||||||
bound release-identity/rollback proof. Automation and pruning remain disabled.
|
bound release-identity/rollback proof. Automation and pruning remain disabled.
|
||||||
Authorization decision: 78a4b859-dd00-4623-b95b-121b0e1c915d.
|
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