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-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-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-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 |
@ -52,7 +52,7 @@
| 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-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-T02 | todo | — | 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-T04 | wait | — | 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 | done | — | 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 | 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
### 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
Forgejo **personal access token** with package scopes — same convention as
`railiance-apps` (`FORGEJO_ADMIN_TOKEN`, `/tmp/forgejo-tegwick-api-token`).
The steady-state credential path is `CCR-2026-0006`: OpenBao lane
`platform/workloads/forgejo/forgejo-admin`, field `API_TOKEN`.
1. Log in to https://forgejo.coulomb.social as `tegwick` (or another user with
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):
1. Confirm the sanctioned route first:
```bash
# Option A — token file (matches forgejo-operator-bootstrap, npm smoke, webhooks)
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>'
warden plan "forgejo admin api token" --json
```
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
cd ~/railiance-platform
@ -64,4 +87,5 @@ counts (`deleted_count`, `candidate_count`, `skipped_protected_count`, `errors`)
## Related
- `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_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,
@ -323,25 +326,97 @@ def build_delete_plans(
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_file = os.environ.get(
"FORGEJO_TOKEN_FILE",
os.environ.get("FORGEJO_ADMIN_TOKEN_FILE", "/tmp/forgejo-tegwick-api-token"),
)
path = Path(token_file).expanduser()
if path.is_file():
return path.read_text(encoding="utf-8").strip()
raise SystemExit(
"ERROR: Forgejo API token required (read:package + write:package).\n"
" export FORGEJO_TOKEN=<pat> # or FORGEJO_ADMIN_TOKEN\n"
" # or save PAT to /tmp/forgejo-tegwick-api-token (chmod 600)\n"
" Generate: https://forgejo.coulomb.social/user/settings/applications\n"
" See: railiance-platform/docs/forgejo-package-prune.md"
)
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(
@ -519,4 +594,4 @@ def main(argv: list[str] | None = None) -> int:
if __name__ == "__main__":
raise SystemExit(main())
raise SystemExit(main())

View file

@ -2,6 +2,7 @@ from __future__ import annotations
import importlib.util
import sys
import tempfile
import unittest
from pathlib import Path
from unittest import mock
@ -63,7 +64,6 @@ image:
self.assertEqual(deletable, {("vergabe-teilnahme", "v1")})
self.assertEqual(protected_plans, {("vergabe-teilnahme", "old-prod")})
def test_collect_live_cluster_versions_protects_forgejo_tags(self) -> None:
images = "\n".join([
"forgejo.coulomb.social/coulomb/state-hub:f2e042a",
@ -93,6 +93,70 @@ image:
self.assertEqual(protected, set())
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__":
unittest.main()

View file

@ -4,11 +4,11 @@ type: workplan
title: "Forgejo admin PAT OpenBao consumer cutover"
domain: financials
repo: railiance-platform
status: active
status: finished
owner: codex
topic_slug: railiance
created: "2026-07-25"
updated: "2026-07-25"
updated: "2026-07-26"
related_repos:
- activity-core
- ops-warden
@ -45,7 +45,7 @@ It must not:
```task
id: RAILIANCE-WP-0013-T01
status: todo
status: done
priority: medium
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.
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
```task
id: RAILIANCE-WP-0013-T02
status: todo
status: done
priority: high
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
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
```task
id: RAILIANCE-WP-0013-T03
status: todo
status: done
priority: medium
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
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
```task
id: RAILIANCE-WP-0013-T04
status: wait
status: done
priority: medium
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
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