Project pinned digest-safe retention tool through existing GitOps resources
Assistant: codex Assistant-Model: gpt-6-astra Assistant-Session: 01a0e241-8285-7a63-8c0c-51c9cb824dc3
This commit is contained in:
parent
942059a6fe
commit
a12f1169f9
6 changed files with 988 additions and 2 deletions
|
|
@ -1079,6 +1079,655 @@ data:
|
|||
evidence: []
|
||||
gaps:
|
||||
- "Add explicit ops inventory probes and evidence events."
|
||||
forgejo_package_prune.py: |
|
||||
#!/usr/bin/env python3
|
||||
"""Forgejo package retention prune — keep newest N versions per package."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
import shutil
|
||||
import subprocess
|
||||
import sys
|
||||
import time
|
||||
import urllib.error
|
||||
import urllib.parse
|
||||
import urllib.request
|
||||
from dataclasses import dataclass
|
||||
from datetime import datetime
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
DEFAULT_BASE = "https://forgejo.coulomb.social"
|
||||
DEFAULT_OWNER = "coulomb"
|
||||
DEFAULT_TYPES = ("container", "pypi", "npm", "generic")
|
||||
DEFAULT_MAX_VERSIONS = 3
|
||||
DEFAULT_APPS_ROOT = Path.home() / "railiance-apps"
|
||||
DEFAULT_FORGEJO_ADMIN_BAO_PATH = "platform/workloads/forgejo/forgejo-admin"
|
||||
DEFAULT_FORGEJO_ADMIN_BAO_FIELD = "API_TOKEN"
|
||||
LEGACY_FORGEJO_TOKEN_FILE = Path("/tmp/forgejo-tegwick-api-token")
|
||||
FORGEJO_IMAGE_RE = re.compile(
|
||||
r"^forgejo\.coulomb\.social/(?:coulomb/)?(?P<name>[^:/]+)(?::(?P<tag>[^/\s]+))?$",
|
||||
re.IGNORECASE,
|
||||
)
|
||||
|
||||
|
||||
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
|
||||
name: str
|
||||
version: str
|
||||
|
||||
def key(self) -> tuple[str, str, str]:
|
||||
return (self.package_type, self.name, self.version)
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class DeletePlan:
|
||||
package_type: str
|
||||
name: str
|
||||
version: str
|
||||
created_at: str
|
||||
protected: bool
|
||||
reason: str
|
||||
|
||||
|
||||
def _parse_created_at(value: str | None) -> datetime:
|
||||
if not value:
|
||||
return datetime.min
|
||||
try:
|
||||
return datetime.fromisoformat(value.replace("Z", "+00:00"))
|
||||
except ValueError:
|
||||
return datetime.min
|
||||
|
||||
|
||||
def collect_protected_versions(apps_root: Path) -> set[tuple[str, str, str]]:
|
||||
protected: set[tuple[str, str, str]] = set()
|
||||
if not apps_root.is_dir():
|
||||
return protected
|
||||
|
||||
patterns = [
|
||||
apps_root / "helm" / "*-values.yaml",
|
||||
apps_root / "charts" / "*" / "values.yaml",
|
||||
]
|
||||
paths: list[Path] = []
|
||||
for pattern in patterns:
|
||||
paths.extend(sorted(pattern.parent.glob(pattern.name)))
|
||||
|
||||
try:
|
||||
import yaml # type: ignore
|
||||
except ImportError:
|
||||
yaml = None
|
||||
|
||||
for path in paths:
|
||||
text = path.read_text(encoding="utf-8")
|
||||
if yaml is not None:
|
||||
try:
|
||||
data = yaml.safe_load(text) or {}
|
||||
except Exception:
|
||||
data = {}
|
||||
image = data.get("image") if isinstance(data, dict) else None
|
||||
if isinstance(image, dict):
|
||||
repo = str(image.get("repository") or "").strip()
|
||||
tag = str(image.get("tag") or "").strip()
|
||||
if repo and tag:
|
||||
match = FORGEJO_IMAGE_RE.match(repo) or FORGEJO_IMAGE_RE.match(
|
||||
f"{repo}:{tag}"
|
||||
)
|
||||
if match:
|
||||
name = match.group("name")
|
||||
protected.add(("container", name, tag))
|
||||
continue
|
||||
|
||||
repo_match = re.search(
|
||||
r"repository:\s*forgejo\.coulomb\.social/coulomb/([^\s]+)",
|
||||
text,
|
||||
re.IGNORECASE,
|
||||
)
|
||||
tag_match = re.search(r'^\s*tag:\s*"?([^"\s#]+)"?\s*$', text, re.MULTILINE)
|
||||
if repo_match and tag_match:
|
||||
protected.add(("container", repo_match.group(1), tag_match.group(1)))
|
||||
|
||||
return protected
|
||||
|
||||
|
||||
def collect_live_images_from_files(
|
||||
paths: list[Path],
|
||||
) -> tuple[set[tuple[str, str, str]], list[str]]:
|
||||
"""Protect image tags listed in exported live-image files.
|
||||
|
||||
Each file holds one image ref per line (`kubectl get pods ... jsonpath`
|
||||
output from another cluster). This closes the multi-cluster gap
|
||||
(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
|
||||
file that is merged here. Unavailable or empty exports produce notes;
|
||||
main refuses apply when any requested export cannot provide coverage.
|
||||
"""
|
||||
protected: set[tuple[str, str, str]] = set()
|
||||
notes: list[str] = []
|
||||
for raw_path in paths:
|
||||
path = raw_path.expanduser()
|
||||
if not path.is_file():
|
||||
notes.append(f"live-images file missing: {path}")
|
||||
continue
|
||||
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()
|
||||
if not image or image.startswith("#"):
|
||||
continue
|
||||
has_images = True
|
||||
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
|
||||
|
||||
|
||||
def collect_live_cluster_versions(
|
||||
*, kubectl: str = "kubectl", timeout: float = 60.0
|
||||
) -> tuple[set[tuple[str, str, str]], list[str]]:
|
||||
"""Protect image tags currently running in the cluster (best-effort).
|
||||
|
||||
Enumerates all pod container images across namespaces via kubectl and
|
||||
protects any `forgejo.coulomb.social/coulomb/<name>:<tag>`. This closes the
|
||||
gap where a live deployment pins a tag not declared in Helm values (e.g.
|
||||
CI-deployed apps). Failures (no kubectl, no cluster access) return an empty
|
||||
set with a note — pruning a reachable registry must not hard-depend on
|
||||
cluster access, but the note surfaces reduced protection coverage.
|
||||
"""
|
||||
protected: set[tuple[str, str, str]] = set()
|
||||
if shutil.which(kubectl) is None:
|
||||
return protected, ["live-tag protection skipped: kubectl not found"]
|
||||
jsonpath = (
|
||||
"{range .items[*]}"
|
||||
"{range .spec.containers[*]}{.image}{'\\n'}{end}"
|
||||
"{range .spec.initContainers[*]}{.image}{'\\n'}{end}"
|
||||
"{end}"
|
||||
)
|
||||
try:
|
||||
result = subprocess.run(
|
||||
[kubectl, "get", "pods", "--all-namespaces", "-o", f"jsonpath={jsonpath}"],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=timeout,
|
||||
check=True,
|
||||
)
|
||||
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
|
||||
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(
|
||||
method: str,
|
||||
url: str,
|
||||
token: str,
|
||||
*,
|
||||
timeout: float = 60.0,
|
||||
retries: int = 2,
|
||||
) -> Any:
|
||||
req = urllib.request.Request(
|
||||
url,
|
||||
method=method,
|
||||
headers={
|
||||
"Authorization": f"token {token}",
|
||||
"Accept": "application/json",
|
||||
},
|
||||
)
|
||||
last_exc: Exception | None = None
|
||||
for attempt in range(retries + 1):
|
||||
try:
|
||||
with urllib.request.urlopen(req, timeout=timeout) as resp:
|
||||
body = resp.read().decode("utf-8")
|
||||
return json.loads(body) if body else None
|
||||
except urllib.error.HTTPError:
|
||||
raise # 4xx/5xx are real responses — surface them, do not retry
|
||||
except (urllib.error.URLError, TimeoutError, OSError) as exc:
|
||||
# Transient: connection reset, read timeout, TLS handshake timeout.
|
||||
last_exc = exc
|
||||
if attempt < retries:
|
||||
time.sleep(2 * (attempt + 1))
|
||||
continue
|
||||
raise
|
||||
if last_exc: # pragma: no cover - defensive
|
||||
raise last_exc
|
||||
|
||||
|
||||
def list_packages(
|
||||
base_url: str,
|
||||
token: str,
|
||||
owner: str,
|
||||
package_type: str,
|
||||
) -> list[dict[str, Any]]:
|
||||
owner_q = urllib.parse.quote(owner)
|
||||
items: list[dict[str, Any]] = []
|
||||
page = 1
|
||||
page_size = 50
|
||||
while True:
|
||||
query = urllib.parse.urlencode(
|
||||
{"limit": page_size, "page": page, "type": package_type}
|
||||
)
|
||||
url = f"{base_url.rstrip('/')}/api/v1/packages/{owner_q}?{query}"
|
||||
payload = _api_request("GET", url, token)
|
||||
if not isinstance(payload, list):
|
||||
raise ValueError("invalid package inventory response")
|
||||
batch = payload
|
||||
if not batch:
|
||||
break
|
||||
items.extend(batch)
|
||||
if len(batch) < page_size:
|
||||
break
|
||||
page += 1
|
||||
return items
|
||||
|
||||
|
||||
def delete_version(
|
||||
base_url: str,
|
||||
token: str,
|
||||
owner: str,
|
||||
package_type: str,
|
||||
name: str,
|
||||
version: str,
|
||||
*,
|
||||
dry_run: bool,
|
||||
) -> None:
|
||||
owner_q = urllib.parse.quote(owner)
|
||||
type_q = urllib.parse.quote(package_type)
|
||||
name_q = urllib.parse.quote(name, safe="")
|
||||
version_q = urllib.parse.quote(version, safe="")
|
||||
path = f"/api/v1/packages/{owner_q}/{type_q}/{name_q}/{version_q}"
|
||||
if dry_run:
|
||||
return
|
||||
url = f"{base_url.rstrip('/')}{path}"
|
||||
_api_request("DELETE", url, token)
|
||||
|
||||
|
||||
def build_delete_plans(
|
||||
*,
|
||||
base_url: str,
|
||||
token: str,
|
||||
owner: str,
|
||||
package_types: list[str],
|
||||
max_versions: int,
|
||||
protected: set[tuple[str, str, str]],
|
||||
) -> tuple[list[DeletePlan], list[str]]:
|
||||
plans: list[DeletePlan] = []
|
||||
errors: list[str] = []
|
||||
|
||||
for package_type in package_types:
|
||||
try:
|
||||
packages = list_packages(base_url, token, owner, package_type)
|
||||
except urllib.error.HTTPError as exc:
|
||||
errors.append(f"list {package_type}: HTTP {exc.code}")
|
||||
continue
|
||||
except Exception as exc: # noqa: BLE001
|
||||
errors.append(f"list {package_type}: {exc}")
|
||||
continue
|
||||
|
||||
# Forgejo's package list endpoint returns one entry per (name, version).
|
||||
# Group by package name; each group is that package's version set — there
|
||||
# is no separate per-package "/versions" endpoint.
|
||||
by_name: dict[str, list[dict[str, Any]]] = {}
|
||||
for package in packages:
|
||||
name = str(package.get("name") or package.get("package_name") or "")
|
||||
if not name:
|
||||
continue
|
||||
by_name.setdefault(name, []).append(package)
|
||||
|
||||
for name, versions in by_name.items():
|
||||
sorted_versions = sorted(
|
||||
versions,
|
||||
key=lambda item: _parse_created_at(str(item.get("created_at") or "")),
|
||||
reverse=True,
|
||||
)
|
||||
keep = {
|
||||
str(item.get("version") or "")
|
||||
for item in sorted_versions[:max_versions]
|
||||
if str(item.get("version") or "")
|
||||
}
|
||||
for item in sorted_versions[max_versions:]:
|
||||
version = str(item.get("version") or "")
|
||||
if not version or version in keep:
|
||||
continue
|
||||
key = (package_type, name, version)
|
||||
digest_protected = (package_type, name, "*") in protected
|
||||
is_protected = key in protected or digest_protected
|
||||
plans.append(
|
||||
DeletePlan(
|
||||
package_type=package_type,
|
||||
name=name,
|
||||
version=version,
|
||||
created_at=str(item.get("created_at") or ""),
|
||||
protected=is_protected,
|
||||
reason=("protected_digest_package" if digest_protected else
|
||||
"protected_production_tag" if is_protected else
|
||||
"beyond_retention_depth"),
|
||||
)
|
||||
)
|
||||
return plans, errors
|
||||
|
||||
|
||||
def _read_token_file(path: Path) -> str:
|
||||
return path.read_text(encoding="utf-8").strip()
|
||||
|
||||
|
||||
def _truthy_env(name: str) -> bool:
|
||||
return os.environ.get(name, "").strip().lower() in {"1", "true", "yes", "on"}
|
||||
|
||||
|
||||
def _load_token_from_file_env() -> str | None:
|
||||
for env_name in ("FORGEJO_TOKEN_FILE", "FORGEJO_ADMIN_TOKEN_FILE"):
|
||||
raw_path = os.environ.get(env_name, "").strip()
|
||||
if not raw_path:
|
||||
continue
|
||||
path = Path(raw_path).expanduser()
|
||||
if not path.is_file():
|
||||
raise SystemExit(f"ERROR: {env_name} points to a missing file: {path}")
|
||||
token = _read_token_file(path)
|
||||
if token:
|
||||
return token
|
||||
raise SystemExit(f"ERROR: {env_name} points to an empty file: {path}")
|
||||
return None
|
||||
|
||||
|
||||
def _load_token_from_openbao() -> tuple[str | None, str | None]:
|
||||
bao_bin = os.environ.get("FORGEJO_ADMIN_BAO_CLI", "bao").strip() or "bao"
|
||||
bao_path = (
|
||||
os.environ.get("FORGEJO_ADMIN_BAO_PATH", DEFAULT_FORGEJO_ADMIN_BAO_PATH).strip()
|
||||
or DEFAULT_FORGEJO_ADMIN_BAO_PATH
|
||||
)
|
||||
bao_field = (
|
||||
os.environ.get("FORGEJO_ADMIN_BAO_FIELD", DEFAULT_FORGEJO_ADMIN_BAO_FIELD).strip()
|
||||
or DEFAULT_FORGEJO_ADMIN_BAO_FIELD
|
||||
)
|
||||
if shutil.which(bao_bin) is None:
|
||||
return None, f"{bao_bin} CLI not found"
|
||||
try:
|
||||
result = subprocess.run(
|
||||
[bao_bin, "kv", "get", f"-field={bao_field}", bao_path],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
check=True,
|
||||
)
|
||||
except subprocess.CalledProcessError as exc:
|
||||
detail = exc.stderr.strip() or exc.stdout.strip() or f"exit {exc.returncode}"
|
||||
return None, f"{bao_bin} kv get failed: {detail}"
|
||||
except OSError as exc:
|
||||
return None, f"{bao_bin} invocation failed: {exc}"
|
||||
token = result.stdout.strip()
|
||||
if not token:
|
||||
return None, f"{bao_bin} kv get returned an empty {bao_field} field"
|
||||
return token, None
|
||||
|
||||
|
||||
def _token_help_message(bao_error: str | None) -> str:
|
||||
lines = [
|
||||
"ERROR: Forgejo API token required (read:package + write:package).",
|
||||
" Primary: bao login -method=oidc -path=netkingdom role=forgejo-admin-workload-kv-read",
|
||||
f" Default lane: {DEFAULT_FORGEJO_ADMIN_BAO_PATH} field {DEFAULT_FORGEJO_ADMIN_BAO_FIELD}",
|
||||
" Override lane via FORGEJO_ADMIN_BAO_PATH / FORGEJO_ADMIN_BAO_FIELD if needed.",
|
||||
" Break-glass: set FORGEJO_TOKEN / FORGEJO_ADMIN_TOKEN, or set FORGEJO_TOKEN_FILE / FORGEJO_ADMIN_TOKEN_FILE.",
|
||||
" Legacy /tmp fallback stays opt-in only via FORGEJO_ALLOW_LEGACY_FILE_FALLBACK=1.",
|
||||
" See: railiance-platform/docs/forgejo-package-prune.md",
|
||||
]
|
||||
if bao_error:
|
||||
lines.insert(3, f" OpenBao lookup failed: {bao_error}")
|
||||
return "\n".join(lines)
|
||||
|
||||
|
||||
def load_token() -> str:
|
||||
for env_name in ("FORGEJO_TOKEN", "FORGEJO_ADMIN_TOKEN"):
|
||||
token = os.environ.get(env_name, "").strip()
|
||||
if token:
|
||||
return token
|
||||
token = _load_token_from_file_env()
|
||||
if token:
|
||||
return token
|
||||
token, bao_error = _load_token_from_openbao()
|
||||
if token:
|
||||
return token
|
||||
|
||||
if _truthy_env("FORGEJO_ALLOW_LEGACY_FILE_FALLBACK"):
|
||||
if LEGACY_FORGEJO_TOKEN_FILE.is_file():
|
||||
token = _read_token_file(LEGACY_FORGEJO_TOKEN_FILE)
|
||||
if token:
|
||||
return token
|
||||
suffix = f"legacy file {LEGACY_FORGEJO_TOKEN_FILE} is empty"
|
||||
else:
|
||||
suffix = f"legacy file {LEGACY_FORGEJO_TOKEN_FILE} is missing"
|
||||
bao_error = f"{bao_error}; {suffix}" if bao_error else suffix
|
||||
|
||||
raise SystemExit(_token_help_message(bao_error))
|
||||
|
||||
|
||||
def emit_summary(
|
||||
*,
|
||||
owner: str,
|
||||
max_versions: int,
|
||||
package_types: list[str],
|
||||
protected: set[tuple[str, str, str]],
|
||||
plans: list[DeletePlan],
|
||||
errors: list[str],
|
||||
apply: bool,
|
||||
deleted: list[DeletePlan],
|
||||
) -> dict[str, Any]:
|
||||
would_delete = [p for p in plans if not p.protected]
|
||||
skipped_protected = [p for p in plans if p.protected]
|
||||
return {
|
||||
"kind": "forgejo_package_prune",
|
||||
"owner": owner,
|
||||
"max_versions": max_versions,
|
||||
"package_types": package_types,
|
||||
"protected_count": len(protected),
|
||||
"candidate_count": len(would_delete),
|
||||
"skipped_protected_count": len(skipped_protected),
|
||||
"deleted_count": len(deleted),
|
||||
"apply": apply,
|
||||
"would_delete": [
|
||||
{
|
||||
"type": p.package_type,
|
||||
"name": p.name,
|
||||
"version": p.version,
|
||||
"created_at": p.created_at,
|
||||
}
|
||||
for p in would_delete
|
||||
],
|
||||
"skipped_protected": [
|
||||
{
|
||||
"type": p.package_type,
|
||||
"name": p.name,
|
||||
"version": p.version,
|
||||
"reason": p.reason,
|
||||
}
|
||||
for p in skipped_protected
|
||||
],
|
||||
"deleted": [
|
||||
{
|
||||
"type": p.package_type,
|
||||
"name": p.name,
|
||||
"version": p.version,
|
||||
}
|
||||
for p in deleted
|
||||
],
|
||||
"errors": errors,
|
||||
}
|
||||
|
||||
|
||||
def main(argv: list[str] | None = None) -> int:
|
||||
parser = argparse.ArgumentParser(description="Prune old Forgejo package versions")
|
||||
parser.add_argument("--owner", default=DEFAULT_OWNER)
|
||||
parser.add_argument("--base-url", default=os.environ.get("FORGEJO_BASE_URL", DEFAULT_BASE))
|
||||
parser.add_argument("--max-versions", type=int, default=DEFAULT_MAX_VERSIONS)
|
||||
parser.add_argument(
|
||||
"--types",
|
||||
default=",".join(DEFAULT_TYPES),
|
||||
help="Comma-separated package types",
|
||||
)
|
||||
parser.add_argument("--apps-root", type=Path, default=DEFAULT_APPS_ROOT)
|
||||
parser.add_argument("--apply", action="store_true")
|
||||
parser.add_argument("--json", action="store_true", help="Emit JSON summary on stdout")
|
||||
parser.add_argument(
|
||||
"--no-protect-live",
|
||||
dest="protect_live",
|
||||
action="store_false",
|
||||
help="Skip protecting image tags currently running in the cluster (kubectl)",
|
||||
)
|
||||
parser.set_defaults(protect_live=True)
|
||||
parser.add_argument(
|
||||
"--live-images-file",
|
||||
dest="live_images_files",
|
||||
type=Path,
|
||||
action="append",
|
||||
default=[],
|
||||
help=(
|
||||
"File with one image ref per line, exported from another "
|
||||
"production cluster; repeatable. Tags matching the Forgejo "
|
||||
"registry are protected in addition to the local kubectl scan."
|
||||
),
|
||||
)
|
||||
args = parser.parse_args(argv)
|
||||
|
||||
apply = bool(args.apply)
|
||||
dry_run = not apply
|
||||
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()
|
||||
protected = collect_protected_versions(args.apps_root.expanduser())
|
||||
protect_notes: list[str] = []
|
||||
if args.protect_live:
|
||||
live, protect_notes = collect_live_cluster_versions()
|
||||
protected |= live
|
||||
print(f"Protected live cluster tags: {len(live)}", file=sys.stderr)
|
||||
for note in protect_notes:
|
||||
print(f" WARN: {note}", file=sys.stderr)
|
||||
if args.live_images_files:
|
||||
protected |= file_live
|
||||
protect_notes.extend(file_notes)
|
||||
print(f"Protected exported live tags: {len(file_live)}", file=sys.stderr)
|
||||
for note in file_notes:
|
||||
print(f" WARN: {note}", file=sys.stderr)
|
||||
|
||||
print(f"Forgejo package prune — owner={args.owner} keep={args.max_versions}", file=sys.stderr)
|
||||
print(f"Protected production tags: {len(protected)}", file=sys.stderr)
|
||||
|
||||
plans, errors = build_delete_plans(
|
||||
base_url=args.base_url,
|
||||
token=token,
|
||||
owner=args.owner,
|
||||
package_types=package_types,
|
||||
max_versions=max(1, args.max_versions),
|
||||
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:
|
||||
print(
|
||||
f" skip protected {plan.package_type}/{plan.name}:{plan.version}",
|
||||
file=sys.stderr,
|
||||
)
|
||||
continue
|
||||
if dry_run:
|
||||
print(
|
||||
f" would delete {plan.package_type}/{plan.name}:{plan.version}",
|
||||
file=sys.stderr,
|
||||
)
|
||||
continue
|
||||
try:
|
||||
delete_version(
|
||||
args.base_url,
|
||||
token,
|
||||
args.owner,
|
||||
plan.package_type,
|
||||
plan.name,
|
||||
plan.version,
|
||||
dry_run=False,
|
||||
)
|
||||
deleted.append(plan)
|
||||
print(
|
||||
f" deleted {plan.package_type}/{plan.name}:{plan.version}",
|
||||
file=sys.stderr,
|
||||
)
|
||||
except urllib.error.HTTPError as exc:
|
||||
errors.append(f"delete {plan.package_type}/{plan.name}:{plan.version}: HTTP {exc.code}")
|
||||
except Exception as exc: # noqa: BLE001
|
||||
errors.append(f"delete {plan.package_type}/{plan.name}:{plan.version}: {exc}")
|
||||
|
||||
summary = emit_summary(
|
||||
owner=args.owner,
|
||||
max_versions=args.max_versions,
|
||||
package_types=package_types,
|
||||
protected=protected,
|
||||
plans=plans,
|
||||
errors=errors,
|
||||
apply=apply,
|
||||
deleted=deleted,
|
||||
)
|
||||
summary["live_protection"] = args.protect_live
|
||||
summary["protection_notes"] = protect_notes
|
||||
|
||||
if args.json or not sys.stdout.isatty():
|
||||
print(json.dumps(summary, indent=2))
|
||||
else:
|
||||
print(json.dumps(summary, indent=2))
|
||||
|
||||
return 1 if errors else 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
kind: ConfigMap
|
||||
metadata:
|
||||
name: actcore-ops-service-inventory
|
||||
|
|
@ -1528,6 +2177,7 @@ spec:
|
|||
template:
|
||||
metadata:
|
||||
annotations:
|
||||
activity-core/retention-sha256: fa3d4cc8cdcc14ecce509cf6865c6e4726d722f84814b97d40686d68d6ee5bb1
|
||||
kubectl.kubernetes.io/restartedAt: '2026-09-05T20:48:57+02:00'
|
||||
labels:
|
||||
app.kubernetes.io/name: actcore-worker
|
||||
|
|
@ -1582,6 +2232,10 @@ spec:
|
|||
- mountPath: /var/custodian/runtime/prompts
|
||||
name: custodian-runtime
|
||||
readOnly: true
|
||||
- mountPath: /opt/railiance-platform/scripts/forgejo_package_prune.py
|
||||
name: ops-service-inventory
|
||||
subPath: forgejo_package_prune.py
|
||||
readOnly: true
|
||||
- mountPath: /opt/railiance-platform
|
||||
name: railiance-platform
|
||||
readOnly: true
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue