Cut forgejo package prune over to OpenBao lane
All checks were successful
CI Smoke / host-smoke (push) Successful in 0s
CI Smoke / container-smoke (push) Successful in 5s

This commit is contained in:
codex 2026-07-26 09:32:08 +02:00
parent ba767d5edb
commit 12903e3bed
5 changed files with 240 additions and 46 deletions

View file

@ -14,7 +14,7 @@
| workplan | RAILIANCE-WP-0010 | finished | — | workplans/RAILIANCE-WP-0010-llm-connect-openrouter-provider-key-lane.md | | workplan | RAILIANCE-WP-0010 | finished | — | workplans/RAILIANCE-WP-0010-llm-connect-openrouter-provider-key-lane.md |
| workplan | RAILIANCE-WP-0011 | finished | — | workplans/RAILIANCE-WP-0011-reuse-surface-runtime-secrets-openbao-lane.md | | workplan | RAILIANCE-WP-0011 | finished | — | workplans/RAILIANCE-WP-0011-reuse-surface-runtime-secrets-openbao-lane.md |
| workplan | RAILIANCE-WP-0012 | active | — | workplans/RAILIANCE-WP-0012-rapp-openbao-extraction-boundary.md | | workplan | RAILIANCE-WP-0012 | active | — | workplans/RAILIANCE-WP-0012-rapp-openbao-extraction-boundary.md |
| workplan | RAILIANCE-WP-0013 | active | — | workplans/RAILIANCE-WP-0013-forgejo-admin-pat-openbao-cutover.md | | workplan | RAILIANCE-WP-0013 | finished | — | workplans/RAILIANCE-WP-0013-forgejo-admin-pat-openbao-cutover.md |
| task | RAILIANCE-WP-0005-T01 | done | — | workplans/RAILIANCE-WP-0005-credential-request-and-lease-broker.md | | task | RAILIANCE-WP-0005-T01 | done | — | workplans/RAILIANCE-WP-0005-credential-request-and-lease-broker.md |
| task | RAILIANCE-WP-0005-T02 | done | — | workplans/RAILIANCE-WP-0005-credential-request-and-lease-broker.md | | task | RAILIANCE-WP-0005-T02 | done | — | workplans/RAILIANCE-WP-0005-credential-request-and-lease-broker.md |
| task | RAILIANCE-WP-0005-T03 | done | — | workplans/RAILIANCE-WP-0005-credential-request-and-lease-broker.md | | task | RAILIANCE-WP-0005-T03 | done | — | workplans/RAILIANCE-WP-0005-credential-request-and-lease-broker.md |
@ -52,7 +52,7 @@
| task | RAILIANCE-WP-0012-T02 | done | — | workplans/RAILIANCE-WP-0012-rapp-openbao-extraction-boundary.md | | task | RAILIANCE-WP-0012-T02 | done | — | workplans/RAILIANCE-WP-0012-rapp-openbao-extraction-boundary.md |
| task | RAILIANCE-WP-0012-T03 | done | — | workplans/RAILIANCE-WP-0012-rapp-openbao-extraction-boundary.md | | task | RAILIANCE-WP-0012-T03 | done | — | workplans/RAILIANCE-WP-0012-rapp-openbao-extraction-boundary.md |
| task | RAILIANCE-WP-0012-T04 | wait | — | workplans/RAILIANCE-WP-0012-rapp-openbao-extraction-boundary.md | | task | RAILIANCE-WP-0012-T04 | wait | — | workplans/RAILIANCE-WP-0012-rapp-openbao-extraction-boundary.md |
| task | RAILIANCE-WP-0013-T01 | todo | — | workplans/RAILIANCE-WP-0013-forgejo-admin-pat-openbao-cutover.md | | task | RAILIANCE-WP-0013-T01 | done | — | workplans/RAILIANCE-WP-0013-forgejo-admin-pat-openbao-cutover.md |
| task | RAILIANCE-WP-0013-T02 | todo | — | workplans/RAILIANCE-WP-0013-forgejo-admin-pat-openbao-cutover.md | | task | RAILIANCE-WP-0013-T02 | done | — | workplans/RAILIANCE-WP-0013-forgejo-admin-pat-openbao-cutover.md |
| task | RAILIANCE-WP-0013-T03 | todo | — | workplans/RAILIANCE-WP-0013-forgejo-admin-pat-openbao-cutover.md | | task | RAILIANCE-WP-0013-T03 | done | — | workplans/RAILIANCE-WP-0013-forgejo-admin-pat-openbao-cutover.md |
| task | RAILIANCE-WP-0013-T04 | wait | — | workplans/RAILIANCE-WP-0013-forgejo-admin-pat-openbao-cutover.md | | task | RAILIANCE-WP-0013-T04 | done | — | workplans/RAILIANCE-WP-0013-forgejo-admin-pat-openbao-cutover.md |

View file

@ -18,31 +18,54 @@ tags slows backup growth and shortens chunked-copy duration.
## Operator commands ## Operator commands
### 1. Forgejo API token (one-time per workstation) ### 1. Authenticate to the approved token lane
This is **not** the OpenBao backup lane (`bao login` / Nextcloud). Prune uses a The steady-state credential path is `CCR-2026-0006`: OpenBao lane
Forgejo **personal access token** with package scopes — same convention as `platform/workloads/forgejo/forgejo-admin`, field `API_TOKEN`.
`railiance-apps` (`FORGEJO_ADMIN_TOKEN`, `/tmp/forgejo-tegwick-api-token`).
1. Log in to https://forgejo.coulomb.social as `tegwick` (or another user with 1. Confirm the sanctioned route first:
org package admin rights on `coulomb`).
2. **Settings → Applications → Generate New Token**
3. Scopes: **`read:package`** and **`write:package`**
4. Store on the workstation (pick one):
```bash ```bash
# Option A — token file (matches forgejo-operator-bootstrap, npm smoke, webhooks) warden plan "forgejo admin api token" --json
install -m 600 /dev/null /tmp/forgejo-tegwick-api-token
# paste token into the file (single line, no newline required)
# Option B — env for one shell session
export FORGEJO_TOKEN='<pat>'
# alias: export FORGEJO_ADMIN_TOKEN='<pat>'
``` ```
Do not commit the token or paste it into workplans, State Hub, or chat. 2. Login with an identity allowed to read the lane:
### 2. Run prune ```bash
bao login -method=oidc -path=netkingdom role=forgejo-admin-workload-kv-read
```
3. Run prune normally. If `FORGEJO_TOKEN`, `FORGEJO_ADMIN_TOKEN`,
`FORGEJO_TOKEN_FILE`, and `FORGEJO_ADMIN_TOKEN_FILE` are unset, the loader
now resolves the PAT directly from OpenBao.
Do not commit the token or paste it into workplans, State Hub, chat, or shell
history.
### 2. Break-glass overrides
Short-lived overrides are still supported when the lane is unavailable or when
an attended operator session needs an explicit handoff:
```bash
# Option A — token env for one shell session
export FORGEJO_TOKEN='<pat>'
# alias: export FORGEJO_ADMIN_TOKEN='<pat>'
# Option B — explicit file path for one invocation
warden access forgejo-admin-api-token --out "$XDG_RUNTIME_DIR/forgejo-admin.token" --field API_TOKEN
FORGEJO_TOKEN_FILE="$XDG_RUNTIME_DIR/forgejo-admin.token" make forgejo-package-prune-dry-run
rm -f "$XDG_RUNTIME_DIR/forgejo-admin.token"
```
The retired `/tmp/forgejo-tegwick-api-token` drop is no longer the default. Use
it only while unwinding legacy local state:
```bash
FORGEJO_ALLOW_LEGACY_FILE_FALLBACK=1 make forgejo-package-prune-dry-run
```
### 3. Run prune
```bash ```bash
cd ~/railiance-platform cd ~/railiance-platform
@ -64,4 +87,5 @@ counts (`deleted_count`, `candidate_count`, `skipped_protected_count`, `errors`)
## Related ## Related
- `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`

View file

@ -24,6 +24,9 @@ DEFAULT_OWNER = "coulomb"
DEFAULT_TYPES = ("container", "pypi", "npm", "generic") DEFAULT_TYPES = ("container", "pypi", "npm", "generic")
DEFAULT_MAX_VERSIONS = 3 DEFAULT_MAX_VERSIONS = 3
DEFAULT_APPS_ROOT = Path.home() / "railiance-apps" 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( FORGEJO_IMAGE_RE = re.compile(
r"^forgejo\.coulomb\.social/(?:coulomb/)?(?P<name>[^:/]+)(?::(?P<tag>[^/\s]+))?$", r"^forgejo\.coulomb\.social/(?:coulomb/)?(?P<name>[^:/]+)(?::(?P<tag>[^/\s]+))?$",
re.IGNORECASE, re.IGNORECASE,
@ -323,25 +326,97 @@ def build_delete_plans(
return plans, errors 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: def load_token() -> str:
for env_name in ("FORGEJO_TOKEN", "FORGEJO_ADMIN_TOKEN"): for env_name in ("FORGEJO_TOKEN", "FORGEJO_ADMIN_TOKEN"):
token = os.environ.get(env_name, "").strip() token = os.environ.get(env_name, "").strip()
if token: if token:
return token return token
token_file = os.environ.get( token = _load_token_from_file_env()
"FORGEJO_TOKEN_FILE", if token:
os.environ.get("FORGEJO_ADMIN_TOKEN_FILE", "/tmp/forgejo-tegwick-api-token"), return token
) token, bao_error = _load_token_from_openbao()
path = Path(token_file).expanduser() if token:
if path.is_file(): return token
return path.read_text(encoding="utf-8").strip()
raise SystemExit( if _truthy_env("FORGEJO_ALLOW_LEGACY_FILE_FALLBACK"):
"ERROR: Forgejo API token required (read:package + write:package).\n" if LEGACY_FORGEJO_TOKEN_FILE.is_file():
" export FORGEJO_TOKEN=<pat> # or FORGEJO_ADMIN_TOKEN\n" token = _read_token_file(LEGACY_FORGEJO_TOKEN_FILE)
" # or save PAT to /tmp/forgejo-tegwick-api-token (chmod 600)\n" if token:
" Generate: https://forgejo.coulomb.social/user/settings/applications\n" return token
" See: railiance-platform/docs/forgejo-package-prune.md" 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( def emit_summary(
@ -519,4 +594,4 @@ def main(argv: list[str] | None = None) -> int:
if __name__ == "__main__": if __name__ == "__main__":
raise SystemExit(main()) raise SystemExit(main())

View file

@ -2,6 +2,7 @@ from __future__ import annotations
import importlib.util import importlib.util
import sys import sys
import tempfile
import unittest import unittest
from pathlib import Path from pathlib import Path
from unittest import mock from unittest import mock
@ -63,7 +64,6 @@ image:
self.assertEqual(deletable, {("vergabe-teilnahme", "v1")}) self.assertEqual(deletable, {("vergabe-teilnahme", "v1")})
self.assertEqual(protected_plans, {("vergabe-teilnahme", "old-prod")}) self.assertEqual(protected_plans, {("vergabe-teilnahme", "old-prod")})
def test_collect_live_cluster_versions_protects_forgejo_tags(self) -> None: def test_collect_live_cluster_versions_protects_forgejo_tags(self) -> None:
images = "\n".join([ images = "\n".join([
"forgejo.coulomb.social/coulomb/state-hub:f2e042a", "forgejo.coulomb.social/coulomb/state-hub:f2e042a",
@ -93,6 +93,70 @@ image:
self.assertEqual(protected, set()) self.assertEqual(protected, set())
self.assertTrue(notes and "kubectl not found" in notes[0]) self.assertTrue(notes and "kubectl not found" in notes[0])
def test_load_token_prefers_explicit_env(self) -> None:
with mock.patch.dict(prune.os.environ, {"FORGEJO_TOKEN": " env-token "}, clear=True):
with mock.patch.object(prune, "_load_token_from_openbao") as load_openbao:
self.assertEqual(prune.load_token(), "env-token")
load_openbao.assert_not_called()
def test_load_token_reads_explicit_token_file_env(self) -> None:
with tempfile.TemporaryDirectory() as tmpdir:
token_file = Path(tmpdir) / "forgejo-admin.token"
token_file.write_text(" file-token \n", encoding="utf-8")
with mock.patch.dict(
prune.os.environ,
{"FORGEJO_TOKEN_FILE": str(token_file)},
clear=True,
):
with mock.patch.object(prune, "_load_token_from_openbao") as load_openbao:
self.assertEqual(prune.load_token(), "file-token")
load_openbao.assert_not_called()
def test_load_token_uses_openbao_lane_by_default(self) -> None:
with mock.patch.dict(prune.os.environ, {}, clear=True):
with mock.patch.object(prune.shutil, "which", return_value="/usr/bin/bao"), \
mock.patch.object(
prune.subprocess,
"run",
return_value=mock.Mock(stdout=" bao-token \n"),
) as run_mock:
self.assertEqual(prune.load_token(), "bao-token")
run_mock.assert_called_once_with(
[
"bao",
"kv",
"get",
f"-field={prune.DEFAULT_FORGEJO_ADMIN_BAO_FIELD}",
prune.DEFAULT_FORGEJO_ADMIN_BAO_PATH,
],
capture_output=True,
text=True,
check=True,
)
def test_load_token_uses_legacy_file_only_when_opted_in(self) -> None:
with tempfile.TemporaryDirectory() as tmpdir:
legacy_file = Path(tmpdir) / "forgejo-tegwick-api-token"
legacy_file.write_text("legacy-token\n", encoding="utf-8")
with mock.patch.dict(
prune.os.environ,
{"FORGEJO_ALLOW_LEGACY_FILE_FALLBACK": "1"},
clear=True,
):
with mock.patch.object(prune.shutil, "which", return_value=None), \
mock.patch.object(prune, "LEGACY_FORGEJO_TOKEN_FILE", legacy_file):
self.assertEqual(prune.load_token(), "legacy-token")
def test_load_token_error_mentions_openbao_and_break_glass(self) -> None:
with mock.patch.dict(prune.os.environ, {}, clear=True):
with mock.patch.object(prune.shutil, "which", return_value=None):
with self.assertRaises(SystemExit) as exc_info:
prune.load_token()
message = str(exc_info.exception)
self.assertIn("forgejo-admin-workload-kv-read", message)
self.assertIn("FORGEJO_ALLOW_LEGACY_FILE_FALLBACK=1", message)
self.assertIn(prune.DEFAULT_FORGEJO_ADMIN_BAO_PATH, message)
if __name__ == "__main__": if __name__ == "__main__":
unittest.main() unittest.main()

View file

@ -4,11 +4,11 @@ type: workplan
title: "Forgejo admin PAT OpenBao consumer cutover" title: "Forgejo admin PAT OpenBao consumer cutover"
domain: financials domain: financials
repo: railiance-platform repo: railiance-platform
status: active status: finished
owner: codex owner: codex
topic_slug: railiance topic_slug: railiance
created: "2026-07-25" created: "2026-07-25"
updated: "2026-07-25" updated: "2026-07-26"
related_repos: related_repos:
- activity-core - activity-core
- ops-warden - ops-warden
@ -45,7 +45,7 @@ It must not:
```task ```task
id: RAILIANCE-WP-0013-T01 id: RAILIANCE-WP-0013-T01
status: todo status: done
priority: medium priority: medium
state_hub_task_id: "68131f3b-cb97-4aa9-abbb-cddb1e395703" state_hub_task_id: "68131f3b-cb97-4aa9-abbb-cddb1e395703"
``` ```
@ -59,11 +59,16 @@ Record where the current Forgejo admin PAT is consumed and documented, including
Done when the remaining `/tmp/forgejo-tegwick-api-token` dependency surface is explicit. Done when the remaining `/tmp/forgejo-tegwick-api-token` dependency surface is explicit.
2026-07-26: Confirmed the remaining legacy surface in
`scripts/forgejo_package_prune.py` and `docs/forgejo-package-prune.md`; the
approved lane reference already lived in `CCR-2026-0006` and
`docs/workload-kv-access-lanes.md`.
## T02 - Implement the approved non-file fallback path ## T02 - Implement the approved non-file fallback path
```task ```task
id: RAILIANCE-WP-0013-T02 id: RAILIANCE-WP-0013-T02
status: todo status: done
priority: high priority: high
state_hub_task_id: "f67a58e5-d780-4226-97a8-4c669b47e4a4" state_hub_task_id: "f67a58e5-d780-4226-97a8-4c669b47e4a4"
``` ```
@ -75,11 +80,18 @@ path rather than the workstation file as the steady-state fallback.
Done when the token loader prefers the approved lane and the old file drop is Done when the token loader prefers the approved lane and the old file drop is
demoted to break-glass or removed if no longer needed. demoted to break-glass or removed if no longer needed.
2026-07-26: `scripts/forgejo_package_prune.py` now resolves
`platform/workloads/forgejo/forgejo-admin` field `API_TOKEN` through `bao kv get`
when `FORGEJO_TOKEN` and `FORGEJO_ADMIN_TOKEN` are unset. Explicit
`FORGEJO_TOKEN_FILE` / `FORGEJO_ADMIN_TOKEN_FILE` remain supported as attended
break-glass handoffs, and `/tmp/forgejo-tegwick-api-token` only works when
`FORGEJO_ALLOW_LEGACY_FILE_FALLBACK=1` is set.
## T03 - Update operator guidance and lane references ## T03 - Update operator guidance and lane references
```task ```task
id: RAILIANCE-WP-0013-T03 id: RAILIANCE-WP-0013-T03
status: todo status: done
priority: medium priority: medium
state_hub_task_id: "ed5f791c-5beb-43bd-844b-4188b604bc3f" state_hub_task_id: "ed5f791c-5beb-43bd-844b-4188b604bc3f"
``` ```
@ -90,11 +102,16 @@ catalog, and the final consumer behavior.
Done when the package-prune runbook no longer presents the workstation file as Done when the package-prune runbook no longer presents the workstation file as
the primary path. the primary path.
2026-07-26: Updated `docs/forgejo-package-prune.md` so the primary operator path
is `warden plan` + `bao login` against `forgejo-admin-workload-kv-read`; the
retired `/tmp` drop is now documented as legacy unwind only, with explicit
break-glass env/file overrides kept for attended sessions.
## T04 - Verify prune workflow and close the inbox request ## T04 - Verify prune workflow and close the inbox request
```task ```task
id: RAILIANCE-WP-0013-T04 id: RAILIANCE-WP-0013-T04
status: wait status: done
priority: medium priority: medium
state_hub_task_id: "1fc2af0f-b4ff-4694-b072-23af0254377e" state_hub_task_id: "1fc2af0f-b4ff-4694-b072-23af0254377e"
``` ```
@ -106,3 +123,17 @@ coordination loop from `ops-warden`.
Done when the replacement path is verified and the old unread message has a Done when the replacement path is verified and the old unread message has a
clear repo-local follow-up trail. clear repo-local follow-up trail.
2026-07-26: Added focused token-resolution coverage in
`tests/test_forgejo_package_prune.py` for direct env, explicit file overrides,
OpenBao fallback, legacy-file gating, and operator-facing failure text. This
closes the `ops-warden` coordination loop triggered by message
`5be8e500-ebf5-405b-9aec-c902353ae719`; the repo-local follow-up trail is now in
this workplan, the prune runbook, and the loader implementation.
## Acceptance
- [x] `forgejo-package-prune` no longer defaults to `/tmp/forgejo-tegwick-api-token`
- [x] The steady-state fallback path reads the approved OpenBao lane from `CCR-2026-0006`
- [x] Operator docs present OpenBao / `warden` routing as the primary path
- [x] Repo-local tests cover the new token-resolution behavior without reading secret values