From 12903e3bed8e96b34b6e938985e7647df9854945 Mon Sep 17 00:00:00 2001 From: codex Date: Sun, 26 Jul 2026 09:32:08 +0200 Subject: [PATCH] Cut forgejo package prune over to OpenBao lane --- WORK-RECORDS.md | 10 +- docs/forgejo-package-prune.md | 62 +++++++---- scripts/forgejo_package_prune.py | 105 +++++++++++++++--- tests/test_forgejo_package_prune.py | 66 ++++++++++- ...-0013-forgejo-admin-pat-openbao-cutover.md | 43 ++++++- 5 files changed, 240 insertions(+), 46 deletions(-) diff --git a/WORK-RECORDS.md b/WORK-RECORDS.md index 10a5555..4ee1d05 100644 --- a/WORK-RECORDS.md +++ b/WORK-RECORDS.md @@ -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 | diff --git a/docs/forgejo-package-prune.md b/docs/forgejo-package-prune.md index 18dbdde..baf6668 100644 --- a/docs/forgejo-package-prune.md +++ b/docs/forgejo-package-prune.md @@ -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='' -# alias: export FORGEJO_ADMIN_TOKEN='' +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='' +# alias: export FORGEJO_ADMIN_TOKEN='' + +# 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` \ No newline at end of file +- `docs/forgejo-backup.md` +- `docs/workload-kv-access-lanes.md` diff --git a/scripts/forgejo_package_prune.py b/scripts/forgejo_package_prune.py index 1e8850b..faed37e 100644 --- a/scripts/forgejo_package_prune.py +++ b/scripts/forgejo_package_prune.py @@ -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[^:/]+)(?::(?P[^/\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= # 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()) \ No newline at end of file + raise SystemExit(main()) diff --git a/tests/test_forgejo_package_prune.py b/tests/test_forgejo_package_prune.py index 0da015e..07ea293 100644 --- a/tests/test_forgejo_package_prune.py +++ b/tests/test_forgejo_package_prune.py @@ -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() diff --git a/workplans/RAILIANCE-WP-0013-forgejo-admin-pat-openbao-cutover.md b/workplans/RAILIANCE-WP-0013-forgejo-admin-pat-openbao-cutover.md index aa3bb99..6982106 100644 --- a/workplans/RAILIANCE-WP-0013-forgejo-admin-pat-openbao-cutover.md +++ b/workplans/RAILIANCE-WP-0013-forgejo-admin-pat-openbao-cutover.md @@ -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