state-hub/workplans/STATE-WP-0084-forge-read-for-private-repositories.md
tegwick 470ece82ed
All checks were successful
CI Smoke / host-smoke (push) Successful in 0s
CI Smoke / container-smoke (push) Successful in 1s
Build and Publish Multi-Context Image / build-and-push (push) Successful in 27s
feat(forge): resolve an optional forge read credential (STATE-WP-0084-T02/T03)
Nine repositories are invisible to derivation because central may not read
them. This adds the consuming half of the credential lane MASON-WP-0003 built.

The cluster has no agent injector and no secrets-store CSI driver, so the pod
authenticates to OpenBao with a projected ServiceAccount token (audience
`openbao`, not the API server) and reads the KV path itself. `forgeRead.*`
carries coordinates only; no credential is a chart value, an image layer, or a
Kubernetes Secret.

The credential reaches git through GIT_CONFIG_* setting http.extraHeader, not
through `-c` and not through userinfo in the clone URL — both of those put the
token in the process listing. It is redacted from ForgeDeriveError, which is
logged, stored in reset outcomes, and returned over the API.

Absent stays a supported state: with no credential, or with OpenBao
unreachable, resolution returns None and public derivation runs unchanged.
Raising would turn "nine repositories are unreadable" into "the pass failed",
which is what T01 exists to prevent.

Chart default is disabled. 717 pass.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

Assistant: claude-code
Assistant-Model: opus
Assistant-Process: 2583210@bnt-lap001
Assistant-Session: f2bff2d5-e9b2-4338-92ca-10282a927006
2026-08-27 23:12:57 +02:00

237 lines
10 KiB
Markdown

---
id: STATE-WP-0084
type: workplan
title: "Derive private repositories, and report unreadable as unreadable"
domain: infotech
repo: state-hub
status: active
owner: codex
topic_slug: infotech
created: "2026-08-26"
updated: "2026-08-26"
related:
- CUST-ADR-012
- STATE-WP-0083
- MASON-WP-0003
state_hub_workstream_id: "9c4b8c79-edba-5d2d-ad37-a45707e89304"
---
# Derive private repositories, and report unreadable as unreadable
## Goal
Consume the forge read credential that `ops-mason/MASON-WP-0003` builds, so
derivation covers private repositories — and make a repository central cannot
read report as *unreadable* rather than as an error or, worse, as a repository
whose records no longer derive.
## Why this exists
`STATE-WP-0083-T04` reset 121 repositories on 2026-08-26. Nine failed
identically:
```text
fatal: could not read Username for 'https://forgejo.coulomb.social'
```
`rapp-core-hub`, `rapp-issue-core`, `rapp-openbao`, `rapp-policy-nexus` and five
others are invisible to derivation entirely. `ADR-012`'s premise — the forge is
the projection source — holds only for repositories central can read, and
nothing in the current design says so.
## Split from MASON-WP-0003
The credential lane itself (OpenBao policy, AppRole, KV path, and the
`warden route find` catalog entry) is `ops-mason`'s to build: that repository
exists to build "AppRoles, policies, and KV secret paths so ops-warden always
has something real to route to". Deployment plumbing and derivation code are
this repository's.
T01 does **not** depend on the credential and should land first. T02 and T03
wait on `MASON-WP-0003-T02`.
## Distinguish "cannot read" from "does not exist"
```task
id: STATE-WP-0084-T01
status: done
priority: high
state_hub_task_id: "e955067d-2152-59f7-b672-12755ceb26b0"
```
A repository the hub is not permitted to read must be reported as unreadable —
a named condition — not as a generic clone error and never as an empty
derivation.
Today the reset treats a failed clone as an error, which is correct only by
accident: if a clone ever returned empty instead of failing, every record in
that repository would be proposed for retirement. That is the failure that
nearly retired `vergabe-teilnahme`, and it must be impossible here by
construction rather than by luck.
An empty clone result and an unreadable repository must both be incapable of
producing retirement work. Retirement proposals require a *successful* read
that positively lacks the record.
This also carries the decision in `MASON-WP-0003-T01`: a per-repository grant
is only safe to choose if a missing grant surfaces as a named condition. Until
this lands, the narrow option fails silently.
Acceptance: an unreadable repository is reported as unreadable; a clone that
succeeds but returns nothing produces no retirement proposals; both cases are
covered by tests that fail if the retirement path is reachable from either.
**Done 2026-08-26.** `api/services/forge_projection.py`:
- `ForgeUnreadableError` subclasses `ForgeDeriveError`, so existing callers
keep catching it while new ones can tell the cases apart. A permission-shaped
git failure is classified into it; a genuine fault stays a plain error.
Forgejo's 404 for an unauthenticated private repository classifies as
unreadable, because the two are indistinguishable at this layer and the safe
reading of an ambiguous answer is the one that cannot destroy a record.
- `_run_git` runs with `GIT_TERMINAL_PROMPT=0`. Without it an unattended pass
blocks on a username prompt instead of failing, and an unreadable repository
is only observable if it fails.
- `DerivedProjection.records_source_present` / `.retirement_eligible` separate
"no records found" from "no records exist". A checkout with no `workplans/`
directory can no longer evidence an absence.
- `diff_against_hub` withholds `stale` rather than computing and filtering it,
and says so in `stale_withheld`. `would_remove` is 0 for such a source.
- `reset_repository_projection` returns `status="unreadable"` for a repository
it may not read, and refuses retirement from an ineligible source **even when
`acknowledge_retirements=True`** — consenting to a conclusion is not the same
as the evidence for it existing.
- `reset_fleet_projection` keeps unreadable repositories out of `errors`, in
their own bucket with its own count. Nine repositories in an error bucket
read as nine broken repositories; they were nine we were not allowed to read.
13 tests in `TestUnreadableIsNotMissing` (`tests/test_forge_projection.py`);
58 pass across the forge/projection/backfill suites.
## Deliver the credential to the pod
```task
id: STATE-WP-0084-T02
status: progress
priority: medium
state_hub_task_id: "d8d41a89-1ebc-5118-bb73-cf8d8c114e16"
```
Blocked on `MASON-WP-0003-T02`.
Make the credential available to the `state-hub` deployment as a Kubernetes
Secret, mounted or injected, referenced by the chart the same way
`state-hub-env` already is.
It must not be baked into the image and must not be committed to the chart.
Rotation must not require a chart change or a redeploy.
Acceptance: the pod can read the credential; nothing in the repository contains
it; rotating the token does not require a redeploy.
**Chart landed 2026-08-27; awaits the minted token.** The cluster has no OpenBao
agent injector and no secrets-store CSI driver — checked, not assumed — so
there is nothing to inject with. The pod authenticates to OpenBao itself, which
is what `MASON-WP-0003-T02`'s Kubernetes auth role was built for.
- `templates/serviceaccount.yaml` creates ServiceAccount `state-hub`, and the
Deployment now sets `serviceAccountName`. The auth role binds to this name and
deliberately not to `default`, so until this ships the pod cannot authenticate
at all. **This changes the identity the running pod uses** — expect a pod
restart on upgrade.
- The OpenBao token is a *projected* ServiceAccount token with audience
`openbao`, not the legacy auto-mounted one. The auto-mounted token's audience
is the API server, so a copy of it is a credential for the cluster; this one
is only accepted by OpenBao, and the kubelet rotates it in place.
- `forgeRead.*` carries coordinates only — address, role, KV path, key. No
credential is a chart value, an image layer, or a Kubernetes Secret in this
release. Rotating the token in OpenBao needs no chart change and no redeploy:
the value is re-read every 5 minutes.
- Default `forgeRead.enabled: false`. A hub without the credential still derives
every public repository, so this is added capability, not a prerequisite.
Remaining before this is `done`: the minted read-only Forgejo token in the KV
path (`paste_once_provision`, outside both repositories), and a deploy with
`forgeRead.enabled=true` plus the real address and path in
`deploy/railiance/apps/helm/state-hub-values.yaml`.
## Teach the derivation to use it
```task
id: STATE-WP-0084-T03
status: progress
priority: medium
state_hub_task_id: "b22b24d9-7ed3-533c-bda7-3130693cf4d2"
```
Blocked on T02.
`derive_from_forge()` clones anonymously. It should use the credential when
present and continue working without it — a hub with no token must still derive
public repositories rather than failing wholesale.
Credentials must never reach the clone URL in a way that lands in logs or
process listings: use a credential helper or an HTTP header, not an embedded
userinfo URL.
Acceptance: private repositories derive; a hub without the credential still
derives public ones; no credential appears in logs, process listings, or
recorded clone URLs.
**Code landed 2026-08-27**, ahead of T02 — writing the consumer does not need
the secret to exist. `api/services/forge_projection.py`:
- `forge_read_token()` reads `FORGE_READ_TOKEN_FILE` in preference to
`FORGE_READ_TOKEN`. A mounted file is what lets T02 satisfy "rotation must
not require a redeploy"; the environment cannot be rotated in place. An
unreadable token *file* returns `None` rather than falling back to the
environment — falling back would let a broken mount look like success while
quietly using a stale value.
- Absent is a supported state, not a degraded one: with no credential the git
environment is untouched and public derivation runs exactly as before.
- The credential is passed as `GIT_CONFIG_COUNT`/`GIT_CONFIG_KEY_0`/
`GIT_CONFIG_VALUE_0` setting `http.extraHeader`, not as `-c` on the command
line and not as userinfo in the URL. Both alternatives put the token in the
process listing, where anything that can run `ps` reads it.
- `_run_git` redacts the token from `ForgeDeriveError` before raising, because
that message is logged, stored in a reset outcome, and returned over the API.
- `api/services/forge_credential.py` resolves the credential from a mounted
file, an environment variable, or OpenBao via Kubernetes auth, in that order.
Production uses the third; the first two make the code runnable and testable
outside the cluster. Configuring a file means *that* file and nothing else —
no fall-through, so a broken mount cannot silently resolve to a stale value.
- Every failure resolves to `None`, never an exception: no configuration, no
network, OpenBao down, permission denied. Raising would convert "nine
repositories are unreadable" into "the whole pass failed", which is the
outcome T01 exists to prevent.
- The resolved value is cached for 5 minutes — a fleet reset of 121
repositories must not authenticate 121 times, and a rotated token must still
be picked up without a redeploy.
Nine tests in `TestForgeCredential` cover each of those, including the two that
are silent when wrong: token-in-argv and token-in-exception. 43 pass in
`tests/test_forge_projection.py`; 717 pass across the suite.
Remains `progress` because the acceptance clause "private repositories derive"
cannot be observed until T02 supplies a credential; T04 confirms it.
## Confirm the nine
```task
id: STATE-WP-0084-T04
status: wait
priority: medium
state_hub_task_id: "33c09a78-5974-57c6-bb7f-ad73082046bb"
```
Blocked on T03.
Re-run the fleet reset and confirm the nine repositories derive rather than
error. Expect a real diff for each — quite possibly a large one, since none has
ever been reconciled against its files.
Treat the first diff for each as review material, not as work to apply. Every
repository examined this way for the first time has produced findings.
Acceptance: fleet errors fall from nine to zero; each newly readable repository
has its first diff reviewed before anything is applied.