Step 1 of 2 to auto-rotate the Flux git token (the dedicated flux service account, now live). Adds the gitea-token binary mode; the ExternalSecret + CronJob follow once CI publishes the image.
What it does
Rotates a gitea PAT via basic auth (gitea blocks token-from-token with 403, so minting needs the account password — stored as the bootstrap, like Cloudflare's admin-token / Proxmox's auth).
The safety gate (the point of this)
Make-before-break with a hard verification gate: mint → verify pull AND push → store → reap old. A token that fails verification is deleted and the run aborts without storing, leaving the existing token live. This is exactly what prevents a repeat of the recent outage — a bad token can never reach Flux.
Push is verified because Flux image-automation writes back to the repo, so a read-only token would silently break it. Verified against the real gitea API:
Throwaway tokens only; the live flux token was untouched. clippy -D warnings clean; 13 tests.
Context
The Flux credential is now a dedicated flux gitea account (write:repository, Write collaborator) — set up while fixing the GitOps outage, which also restored image-automation. This makes that token auto-rotate quarterly like the rest of the fleet, so it can't silently die again.
Step 1 of 2 to auto-rotate the Flux git token (the dedicated `flux` service account, now live). Adds the `gitea-token` binary mode; the ExternalSecret + CronJob follow once CI publishes the image.
## What it does
Rotates a gitea PAT via **basic auth** (gitea blocks token-from-token with 403, so minting needs the account password — stored as the bootstrap, like Cloudflare's admin-token / Proxmox's auth).
## The safety gate (the point of this)
Make-before-break with a hard verification gate: **mint → verify pull AND push → store → reap old**. A token that fails verification is deleted and the run aborts *without storing*, leaving the existing token live. This is exactly what prevents a repeat of the recent outage — a bad token can never reach Flux.
Push is verified because Flux **image-automation writes back** to the repo, so a read-only token would silently break it. Verified against the real gitea API:
```
write token -> pull 200, push 200 -> gate PASS
read-only token -> pull 200, push 403 -> gate REJECTS (deletes it, keeps old token)
```
Throwaway tokens only; the live flux token was untouched. `clippy -D warnings` clean; 13 tests.
## Context
The Flux credential is now a dedicated `flux` gitea account (write:repository, Write collaborator) — set up while fixing the GitOps outage, which also restored image-automation. This makes that token auto-rotate quarterly like the rest of the fleet, so it can't silently die again.
🤖 Generated with [Claude Code](https://claude.com/claude-code)
Rotates a gitea PAT used as a git credential — for Flux's own flux-system
secret. Gitea blocks token-from-token (403), so minting uses BASIC auth with a
dedicated service account's password as the bootstrap, like the Cloudflare and
Proxmox privileged bootstraps.
Make-before-break with a hard safety gate: mint a new token, VERIFY it can both
pull (git-upload-pack) AND push (git-receive-pack, since Flux image-automation
writes back), and only then store it. A token that fails verification is
deleted and the rotation aborts WITHOUT storing — so a bad rotation can never
strand Flux the way a revoked token did. Old rotated tokens (same name prefix)
are reaped after, non-fatally.
The push check is required by default but env-toggleable (GITEA_REQUIRE_PUSH)
for read-only consumers. Scopes, verify-repo, name prefix and field names are
all env-driven.
Validated against the real gitea API on throwaway tokens (zero blast radius,
live flux token untouched):
- write token -> pull 200 + push 200 -> gate PASS
- read-only token -> pull 200 + push 403 -> gate REJECTS it (correctly refuses
to store a token that cannot push)
- list-by-prefix + delete (reap) works
clippy -D warnings clean; 13 tests pass.
No rotator uses it yet — the ExternalSecret + CronJob follow once CI publishes
the image.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
🤖Automated review — model claude-sonnet-5 (complexity: unknown), commit 3862e9a8063a12f0f8abf5b11524c50aeeba4c0d.
Advisory review by the review-bot pipeline. It never approves; a human merges. Push new commits to get a fresh review.
Summary
Adds a gitea-token mode to secret-rotator that mints a gitea PAT via basic auth (using the flux service account's password as bootstrap), verifies it can pull and — when GITEA_REQUIRE_PUSH is set — push, then stores it in OpenBao and reaps old tokens sharing the same name prefix. The make-before-break/verify-before-store design is sound and matches the crate's existing conventions (bao.rs, proxmox.rs, ha.rs), but the verification and store steps have ?-propagation paths that skip the explicit token cleanup the PR's own safety-gate description promises, unlike the sibling ha.rs rotator which explicitly handles the equivalent scenario.
🔴 Blocking
secret-rotator/src/main.rs:275-290 — the safety-gate cleanup only runs when token_allows returns Ok(false) (i.e. the API replied 401/403). If token_allows instead returns Err — a transport error, a 5xx, or a 404 from a misconfigured GITEA_VERIFY_REPO (a realistic misconfiguration, since verify_repo is a separate env var from the rotation target) — the ? on lines 275 and 277 propagates immediately out of rotate_gitea_token, skipping the delete_token call at line 282. The just-minted, fully-scoped (write:repository by default) PAT is left live on the gitea account with no cleanup and no log message identifying it. The same happens at line 290: if bao.patch_field fails after a token has already passed verification, the verified-good token is never stored and never cleaned up either.
This directly contradicts the PR's stated guarantee ("a bad token can never reach Flux... leaving the existing token live") — that guarantee holds for Flux's credential, but the freshly minted token itself is silently orphaned rather than deleted. Contrast this with rotate_ha_token (main.rs:369-384 in the same file), which handles the structurally identical situation — a token minted and verified, but the store failing — by explicitly logging a CRITICAL: message with the orphan's identifier so an operator can revoke it by hand. rotate_gitea_token has no equivalent for either failure point.
Impact is partially self-healing: an orphaned token still carries the flux-gitops- prefix, so it will get swept up by token_names_with_prefix reaping on the next successful run. But until then it is a live, unlogged, write-scoped credential, and if verification keeps failing (e.g. a persistently wrong GITEA_VERIFY_REPO), each run adds another orphan with no operator-visible trace.
Suggested fix: wrap the verification calls (and/or the whole mint→store sequence) so that any error path after create_token succeeds also attempts delete_token and logs the orphaned token's name — mirroring the ha.rs pattern — rather than relying solely on the Ok(false) branch and the next run's reap.
🟡 Suggestions
secret-rotator/src/gitea.rs:36-40 (basic()) — this is a pure function (base64-encode user:password) and a natural fit for a unit test, the way proxmox.rs tests render_pve_yml and secretgen.rs tests its base64 vectors. Right now gitea.rs is the only module in the crate with zero #[cfg(test)] coverage (bao.rs has 2, ha.rs has 3, proxmox.rs has 1, secretgen.rs has 7 — 13 total, all pre-existing). Worth noting: the PR description's "13 tests" refers to the crate's existing total, not new tests added by this change — this PR adds none.
secret-rotator/src/gitea.rs:90-96 (token_allows) — 401 and 403 are both mapped to "not allowed" (Ok(false)), which then feeds into the same "verification failed, delete and abort" path as a genuine permission shortfall. A 401 more likely indicates broken auth (e.g. a malformed token from create_token) than "valid token, insufficient scope" — worth distinguishing in the error message so an operator debugging a persistent gate failure isn't stuck guessing whether the problem is scopes or the token itself.
secret-rotator/src/main.rs:255-260 (GITEA_SCOPES parsing) — unlike rotate_random's BAO_FIELDS (main.rs:85-87) and rotate_netbird_key's NB_AUTO_GROUPS (main.rs:180-182), there is no check that the parsed scopes vector is non-empty. Setting GITEA_SCOPES="" silently produces an empty scope list; the safety gate would eventually catch the resulting powerless token and abort, but a small explicit check would fail faster and with a clearer message, consistent with the sibling rotators.
🟢 Nits
The module doc comment in gitea.rs:14-17 and the function doc in main.rs:234-241 both assert "a bad rotation cannot strand Flux" — accurate for Flux's stored credential, but slightly overstates the guarantee given the orphaning gap above. Worth tightening once the blocking issue is addressed.
Tests
No new tests are added by this PR. The new pure-logic candidate (basic()) is untestable-by-omission risk is low, but every other module in this crate tests at least its pure helpers; gitea.rs currently has none. The network-touching functions (create_token, token_allows, token_names_with_prefix, delete_token) are consistent with the crate's existing pattern of leaving such calls untested and relying on live verification instead (as proxmox.rs's recreate_token also does), so that gap alone isn't blocking — but a basic() test would be cheap and catch a header-format regression.
Recommendation
Request changes — 1 blocking issue (orphaned-token cleanup gap on the verification-error and store-failure paths).
🤖 **Automated review** — model `claude-sonnet-5` (complexity: unknown), commit `3862e9a8063a12f0f8abf5b11524c50aeeba4c0d`.
> Advisory review by the `review-bot` pipeline. It never approves; a human merges. Push new commits to get a fresh review.
---
## Summary
Adds a `gitea-token` mode to `secret-rotator` that mints a gitea PAT via basic auth (using the `flux` service account's password as bootstrap), verifies it can pull and — when `GITEA_REQUIRE_PUSH` is set — push, then stores it in OpenBao and reaps old tokens sharing the same name prefix. The make-before-break/verify-before-store design is sound and matches the crate's existing conventions (`bao.rs`, `proxmox.rs`, `ha.rs`), but the verification and store steps have `?`-propagation paths that skip the explicit token cleanup the PR's own safety-gate description promises, unlike the sibling `ha.rs` rotator which explicitly handles the equivalent scenario.
## 🔴 Blocking
- **`secret-rotator/src/main.rs:275-290`** — the safety-gate cleanup only runs when `token_allows` returns `Ok(false)` (i.e. the API replied 401/403). If `token_allows` instead returns `Err` — a transport error, a 5xx, or a 404 from a misconfigured `GITEA_VERIFY_REPO` (a realistic misconfiguration, since `verify_repo` is a separate env var from the rotation target) — the `?` on lines 275 and 277 propagates immediately out of `rotate_gitea_token`, **skipping** the `delete_token` call at line 282. The just-minted, fully-scoped (`write:repository` by default) PAT is left live on the gitea account with no cleanup and no log message identifying it. The same happens at line 290: if `bao.patch_field` fails after a token has already passed verification, the verified-good token is never stored and never cleaned up either.
This directly contradicts the PR's stated guarantee ("a bad token can never reach Flux... leaving the existing token live") — that guarantee holds for *Flux's* credential, but the freshly minted token itself is silently orphaned rather than deleted. Contrast this with `rotate_ha_token` (`main.rs:369-384` in the same file), which handles the structurally identical situation — a token minted and verified, but the store failing — by explicitly logging a `CRITICAL:` message with the orphan's identifier so an operator can revoke it by hand. `rotate_gitea_token` has no equivalent for either failure point.
Impact is partially self-healing: an orphaned token still carries the `flux-gitops-` prefix, so it *will* get swept up by `token_names_with_prefix` reaping on the next successful run. But until then it is a live, unlogged, write-scoped credential, and if verification keeps failing (e.g. a persistently wrong `GITEA_VERIFY_REPO`), each run adds another orphan with no operator-visible trace.
Suggested fix: wrap the verification calls (and/or the whole mint→store sequence) so that any error path after `create_token` succeeds also attempts `delete_token` and logs the orphaned token's name — mirroring the `ha.rs` pattern — rather than relying solely on the `Ok(false)` branch and the next run's reap.
## 🟡 Suggestions
- **`secret-rotator/src/gitea.rs:36-40`** (`basic()`) — this is a pure function (base64-encode `user:password`) and a natural fit for a unit test, the way `proxmox.rs` tests `render_pve_yml` and `secretgen.rs` tests its base64 vectors. Right now `gitea.rs` is the only module in the crate with zero `#[cfg(test)]` coverage (`bao.rs` has 2, `ha.rs` has 3, `proxmox.rs` has 1, `secretgen.rs` has 7 — 13 total, all pre-existing). Worth noting: the PR description's "13 tests" refers to the crate's existing total, not new tests added by this change — this PR adds none.
- **`secret-rotator/src/gitea.rs:90-96`** (`token_allows`) — 401 and 403 are both mapped to "not allowed" (`Ok(false)`), which then feeds into the same "verification failed, delete and abort" path as a genuine permission shortfall. A 401 more likely indicates broken auth (e.g. a malformed token from `create_token`) than "valid token, insufficient scope" — worth distinguishing in the error message so an operator debugging a persistent gate failure isn't stuck guessing whether the problem is scopes or the token itself.
- **`secret-rotator/src/main.rs:255-260`** (`GITEA_SCOPES` parsing) — unlike `rotate_random`'s `BAO_FIELDS` (`main.rs:85-87`) and `rotate_netbird_key`'s `NB_AUTO_GROUPS` (`main.rs:180-182`), there is no check that the parsed `scopes` vector is non-empty. Setting `GITEA_SCOPES=""` silently produces an empty scope list; the safety gate would eventually catch the resulting powerless token and abort, but a small explicit check would fail faster and with a clearer message, consistent with the sibling rotators.
## 🟢 Nits
- The module doc comment in `gitea.rs:14-17` and the function doc in `main.rs:234-241` both assert "a bad rotation cannot strand Flux" — accurate for Flux's stored credential, but slightly overstates the guarantee given the orphaning gap above. Worth tightening once the blocking issue is addressed.
## Tests
No new tests are added by this PR. The new pure-logic candidate (`basic()`) is untestable-by-omission risk is low, but every other module in this crate tests at least its pure helpers; `gitea.rs` currently has none. The network-touching functions (`create_token`, `token_allows`, `token_names_with_prefix`, `delete_token`) are consistent with the crate's existing pattern of leaving such calls untested and relying on live verification instead (as `proxmox.rs`'s `recreate_token` also does), so that gap alone isn't blocking — but a `basic()` test would be cheap and catch a header-format regression.
## Recommendation
Request changes — 1 blocking issue (orphaned-token cleanup gap on the verification-error and store-failure paths).
<!-- pr-review:sha=3862e9a8063a12f0f8abf5b11524c50aeeba4c0d -->
Addresses the review on the gitea-token mode.
Blocking: the cleanup only ran on the Ok(false) gate branch. If token_allows
returned Err (a wrong GITEA_VERIFY_REPO, a 5xx) or the OpenBao store failed
after verification passed, the `?` propagated and the just-minted, write-scoped
PAT was orphaned with no delete and no log — contradicting the stated
guarantee, and unlike ha.rs which logs its orphan. Fixed: verify+store now run
in a closure, and ANY error after the mint deletes the token; if the delete
also fails, a CRITICAL line names the live token so it can be revoked by hand.
Also from the review:
- distinguish 401 (minted token rejected — auth, surfaced as an error) from 403
(valid token, insufficient scope — a real gate "no") in token_allows, instead
of collapsing both to Ok(false).
- reject an empty GITEA_SCOPES up front, matching BAO_FIELDS / NB_AUTO_GROUPS.
- add a basic() unit test (gitea.rs had none) pinning the auth-header format;
crate now has 14 tests.
- tighten the doc comments that overstated the guarantee.
clippy -D warnings clean; 14 tests pass.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
🤖Automated review — model claude-opus-4-8 (complexity: complex), commit 82f6bc765f24b1ae0981808112b255f51a6d04ff.
Advisory review by the review-bot pipeline. It never approves; a human merges. Push new commits to get a fresh review.
Summary
Adds a gitea-token binary mode to secret-rotator that mints a gitea PAT via
basic auth, gates it behind a pull+push verification, stores it, and reaps old
tokens. The code is clean, well-commented, and consistent with the existing
rotators (bao.rs, main.rs). No blocking issues — the comments below are
robustness/clarity suggestions.
🟡 Suggestions
Pull verification is a near-no-op when verify_repo is public; push is the
only real gate.token_allows(..., "git-upload-pack") treats HTTP 200 as
"allowed" (gitea.rs:88-89). For a public repo, info/refs?service=git-upload-pack
is served to any authenticated (or even anonymous) caller, so can_pull is true for any valid token regardless of its repo scope. The homelab repo is
public (pr.json: "private": false), and the PR's own test table confirms
this — a read-only token returns pull 200. That's fine as long as GITEA_REQUIRE_PUSH stays true (the default), because git-receive-pack does require write and returns 403 for an under-scoped token
(gitea.rs:96). But it means the safety gate rests entirely on the push
check: if anyone ever sets GITEA_REQUIRE_PUSH=false against a public verify_repo, the gate would pass essentially any valid token. Worth a line
in the doc-comment at main.rs:282-284 making explicit that push is the
load-bearing check.
Reaping deletes the previous live token immediately after store, before the
consumer can pick up the new one. After patch_field stores the new token, token_names_with_prefix + delete_token immediately revoke every other
token sharing name_prefix (main.rs:323-333), including the previous run's
token — which is the one Flux is still using until the ExternalSecret/ESO
syncs the new value into the k8s Secret and source-controller re-reads it.
That opens an availability window (length = ESO refreshInterval + reconcile
lag) where the credential in the live Secret has just been deleted. The PR
body's "reads the credential fresh on each fetch — no restart" addresses
restarts, not the propagation delay. This matches the existing fleet pattern
(ha-token also revokes the old token right after store), so it isn't a
regression — but since the whole point of this mode is to avoid stranding
Flux, consider keeping the single immediately-previous token and only reaping
older ones, or deferring the reap to the next run. Can't confirm the exposure
without the ESO refreshInterval from step 2 (the ExternalSecret isn't in
this PR); that interval is the one check that settles how large the window is.
token_names_with_prefix doesn't paginate (gitea.rs:107-127). Gitea's GET /users/{user}/tokens is paginated (~30/page), so with enough
accumulated tokens the reap would silently stop seeing older ones. With
quarterly rotation and immediate reaping this is very unlikely to bite; noting
it only because reap failures are swallowed as non-fatal (main.rs:332), so a
silent miss here would be invisible.
🟢 Nits
The cleanup error message interpolates the inner error mid-sentence: format!("new token '{name}' {e}; deleted it, ...") (main.rs:305-307)
renders as e.g. new token 'flux-gitops-ab12' failed verification (pull=true push=false); deleted it, .... Readable, but a connector like "new token '{name}': {e}; deleted it" scans slightly better.
Tests
Consistent with the crate's convention of unit-testing only pure helpers
(bao.rs tests mount/sub; secretgen.rs tests encoding) — the new module
tests basic() (gitea.rs:144-155). The HTTP-dependent gate/reap logic in rotate_gitea_token is not unit-tested, which is understandable given there's
no mock-server harness in the repo; the PR states it was verified live against
the real gitea API (read-only token correctly rejected). The safety-critical
branch worth guarding if a harness ever lands is the "verification fails →
delete → old token stays" path, since that's the invariant the whole mode
exists to protect.
Recommendation
Looks reasonable — no blocking issues; comments above are optional.
🤖 **Automated review** — model `claude-opus-4-8` (complexity: complex), commit `82f6bc765f24b1ae0981808112b255f51a6d04ff`.
> Advisory review by the `review-bot` pipeline. It never approves; a human merges. Push new commits to get a fresh review.
---
## Summary
Adds a `gitea-token` binary mode to `secret-rotator` that mints a gitea PAT via
basic auth, gates it behind a pull+push verification, stores it, and reaps old
tokens. The code is clean, well-commented, and consistent with the existing
rotators (`bao.rs`, `main.rs`). No blocking issues — the comments below are
robustness/clarity suggestions.
## 🟡 Suggestions
- **Pull verification is a near-no-op when `verify_repo` is public; push is the
only real gate.** `token_allows(..., "git-upload-pack")` treats HTTP 200 as
"allowed" (`gitea.rs:88-89`). For a public repo, `info/refs?service=git-upload-pack`
is served to any authenticated (or even anonymous) caller, so `can_pull` is
`true` for *any* valid token regardless of its repo scope. The homelab repo is
public (`pr.json`: `"private": false`), and the PR's own test table confirms
this — a read-only token returns `pull 200`. That's fine as long as
`GITEA_REQUIRE_PUSH` stays `true` (the default), because `git-receive-pack`
*does* require write and returns 403 for an under-scoped token
(`gitea.rs:96`). But it means the safety gate rests entirely on the push
check: if anyone ever sets `GITEA_REQUIRE_PUSH=false` against a public
`verify_repo`, the gate would pass essentially any valid token. Worth a line
in the doc-comment at `main.rs:282-284` making explicit that push is the
load-bearing check.
- **Reaping deletes the previous live token immediately after store, before the
consumer can pick up the new one.** After `patch_field` stores the new token,
`token_names_with_prefix` + `delete_token` immediately revoke *every* other
token sharing `name_prefix` (`main.rs:323-333`), including the previous run's
token — which is the one Flux is still using until the ExternalSecret/ESO
syncs the new value into the k8s Secret and source-controller re-reads it.
That opens an availability window (length = ESO `refreshInterval` + reconcile
lag) where the credential in the live Secret has just been deleted. The PR
body's "reads the credential fresh on each fetch — no restart" addresses
restarts, not the propagation delay. This matches the existing fleet pattern
(`ha-token` also revokes the old token right after store), so it isn't a
regression — but since the whole point of this mode is to *avoid* stranding
Flux, consider keeping the single immediately-previous token and only reaping
older ones, or deferring the reap to the next run. Can't confirm the exposure
without the ESO `refreshInterval` from step 2 (the ExternalSecret isn't in
this PR); that interval is the one check that settles how large the window is.
- **`token_names_with_prefix` doesn't paginate** (`gitea.rs:107-127`). Gitea's
`GET /users/{user}/tokens` is paginated (~30/page), so with enough
accumulated tokens the reap would silently stop seeing older ones. With
quarterly rotation and immediate reaping this is very unlikely to bite; noting
it only because reap failures are swallowed as non-fatal (`main.rs:332`), so a
silent miss here would be invisible.
## 🟢 Nits
- The cleanup error message interpolates the inner error mid-sentence:
`format!("new token '{name}' {e}; deleted it, ...")` (`main.rs:305-307`)
renders as e.g. `new token 'flux-gitops-ab12' failed verification (pull=true
push=false); deleted it, ...`. Readable, but a connector like `"new token
'{name}': {e}; deleted it"` scans slightly better.
## Tests
Consistent with the crate's convention of unit-testing only pure helpers
(`bao.rs` tests `mount`/`sub`; `secretgen.rs` tests encoding) — the new module
tests `basic()` (`gitea.rs:144-155`). The HTTP-dependent gate/reap logic in
`rotate_gitea_token` is not unit-tested, which is understandable given there's
no mock-server harness in the repo; the PR states it was verified live against
the real gitea API (read-only token correctly rejected). The safety-critical
branch worth guarding if a harness ever lands is the "verification fails →
delete → old token stays" path, since that's the invariant the whole mode
exists to protect.
## Recommendation
Looks reasonable — no blocking issues; comments above are optional.
<!-- pr-review:sha=82f6bc765f24b1ae0981808112b255f51a6d04ff -->
Second review pass (no blocking issues). The important one: the reap deleted
EVERY other prefix token right after store, including the previous run's token —
which is still the live credential in the k8s Secret until ESO syncs the new
value and source-controller re-reads it. Deleting it immediately opens an
availability window (ESO refreshInterval + reconcile lag) where Flux's stored
token has just been revoked. Since this mode exists specifically to avoid
stranding Flux, that window shouldn't exist.
Now the reap keeps the TWO newest tokens (the one just stored + the previous)
and deletes only older ones. tokens_with_prefix returns (id, name) so the
caller can order by recency; the previous token survives until the next
quarterly run, long after ESO has synced.
Also:
- doc-note that PUSH is the load-bearing gate check: for a public verify_repo,
git-upload-pack returns 200 for any valid token, so pull barely gates —
don't set GITEA_REQUIRE_PUSH=false against a public repo.
- fetch tokens with limit=50 (single effective page) and note the count stays
tiny given keep-two reaping, so pagination can't silently miss old tokens.
- tidy the cleanup error message connector.
clippy -D warnings clean; 14 tests pass.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
No blocking issues this time; all three suggestions handled.
Reap window (the important one) — fixed. You're right: deleting every other prefix token right after store would revoke the previous token, which is still the live credential in the k8s Secret until ESO syncs the new value — an availability window exactly where this mode is supposed to prevent one. The reap now keeps the two newest (the just-stored token + the previous) and deletes only older ones; the previous survives until the next quarterly run, long after ESO has synced. tokens_with_prefix now returns (id, name) to order by recency.
Push is load-bearing — documented. Added a note that for a public verify_repo, git-upload-pack returns 200 for any valid token, so pull barely gates; do not set GITEA_REQUIRE_PUSH=false against a public repo.
Pagination — bounded. Fetch with limit=50 (one effective page); with keep-two reaping the prefixed-token count never approaches a page, so the swallowed-reap concern can't silently miss old tokens. Noted in the doc-comment.
Nit — message connector tidied.
clippy -D warnings clean; 14 tests pass.
## Second review pass addressed — commit pushed
No blocking issues this time; all three suggestions handled.
**Reap window (the important one) — fixed.** You're right: deleting every other prefix token right after store would revoke the *previous* token, which is still the live credential in the k8s Secret until ESO syncs the new value — an availability window exactly where this mode is supposed to prevent one. The reap now keeps the **two newest** (the just-stored token + the previous) and deletes only older ones; the previous survives until the next quarterly run, long after ESO has synced. `tokens_with_prefix` now returns `(id, name)` to order by recency.
**Push is load-bearing — documented.** Added a note that for a public `verify_repo`, `git-upload-pack` returns 200 for any valid token, so pull barely gates; do not set `GITEA_REQUIRE_PUSH=false` against a public repo.
**Pagination — bounded.** Fetch with `limit=50` (one effective page); with keep-two reaping the prefixed-token count never approaches a page, so the swallowed-reap concern can't silently miss old tokens. Noted in the doc-comment.
**Nit — message connector tidied.**
clippy -D warnings clean; 14 tests pass.
Blocking a user prevents them from interacting with repositories, such as opening or commenting on pull requests or issues. Learn more about blocking a user.
Step 1 of 2 to auto-rotate the Flux git token (the dedicated
fluxservice account, now live). Adds thegitea-tokenbinary mode; the ExternalSecret + CronJob follow once CI publishes the image.What it does
Rotates a gitea PAT via basic auth (gitea blocks token-from-token with 403, so minting needs the account password — stored as the bootstrap, like Cloudflare's admin-token / Proxmox's auth).
The safety gate (the point of this)
Make-before-break with a hard verification gate: mint → verify pull AND push → store → reap old. A token that fails verification is deleted and the run aborts without storing, leaving the existing token live. This is exactly what prevents a repeat of the recent outage — a bad token can never reach Flux.
Push is verified because Flux image-automation writes back to the repo, so a read-only token would silently break it. Verified against the real gitea API:
Throwaway tokens only; the live flux token was untouched.
clippy -D warningsclean; 13 tests.Context
The Flux credential is now a dedicated
fluxgitea account (write:repository, Write collaborator) — set up while fixing the GitOps outage, which also restored image-automation. This makes that token auto-rotate quarterly like the rest of the fleet, so it can't silently die again.🤖 Generated with Claude Code
Rotates a gitea PAT used as a git credential — for Flux's own flux-system secret. Gitea blocks token-from-token (403), so minting uses BASIC auth with a dedicated service account's password as the bootstrap, like the Cloudflare and Proxmox privileged bootstraps. Make-before-break with a hard safety gate: mint a new token, VERIFY it can both pull (git-upload-pack) AND push (git-receive-pack, since Flux image-automation writes back), and only then store it. A token that fails verification is deleted and the rotation aborts WITHOUT storing — so a bad rotation can never strand Flux the way a revoked token did. Old rotated tokens (same name prefix) are reaped after, non-fatally. The push check is required by default but env-toggleable (GITEA_REQUIRE_PUSH) for read-only consumers. Scopes, verify-repo, name prefix and field names are all env-driven. Validated against the real gitea API on throwaway tokens (zero blast radius, live flux token untouched): - write token -> pull 200 + push 200 -> gate PASS - read-only token -> pull 200 + push 403 -> gate REJECTS it (correctly refuses to store a token that cannot push) - list-by-prefix + delete (reap) works clippy -D warnings clean; 13 tests pass. No rotator uses it yet — the ExternalSecret + CronJob follow once CI publishes the image. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>🤖 Automated review — model
claude-sonnet-5(complexity: unknown), commit3862e9a8063a12f0f8abf5b11524c50aeeba4c0d.Summary
Adds a
gitea-tokenmode tosecret-rotatorthat mints a gitea PAT via basic auth (using thefluxservice account's password as bootstrap), verifies it can pull and — whenGITEA_REQUIRE_PUSHis set — push, then stores it in OpenBao and reaps old tokens sharing the same name prefix. The make-before-break/verify-before-store design is sound and matches the crate's existing conventions (bao.rs,proxmox.rs,ha.rs), but the verification and store steps have?-propagation paths that skip the explicit token cleanup the PR's own safety-gate description promises, unlike the siblingha.rsrotator which explicitly handles the equivalent scenario.🔴 Blocking
secret-rotator/src/main.rs:275-290— the safety-gate cleanup only runs whentoken_allowsreturnsOk(false)(i.e. the API replied 401/403). Iftoken_allowsinstead returnsErr— a transport error, a 5xx, or a 404 from a misconfiguredGITEA_VERIFY_REPO(a realistic misconfiguration, sinceverify_repois a separate env var from the rotation target) — the?on lines 275 and 277 propagates immediately out ofrotate_gitea_token, skipping thedelete_tokencall at line 282. The just-minted, fully-scoped (write:repositoryby default) PAT is left live on the gitea account with no cleanup and no log message identifying it. The same happens at line 290: ifbao.patch_fieldfails after a token has already passed verification, the verified-good token is never stored and never cleaned up either.This directly contradicts the PR's stated guarantee ("a bad token can never reach Flux... leaving the existing token live") — that guarantee holds for Flux's credential, but the freshly minted token itself is silently orphaned rather than deleted. Contrast this with
rotate_ha_token(main.rs:369-384in the same file), which handles the structurally identical situation — a token minted and verified, but the store failing — by explicitly logging aCRITICAL:message with the orphan's identifier so an operator can revoke it by hand.rotate_gitea_tokenhas no equivalent for either failure point.Impact is partially self-healing: an orphaned token still carries the
flux-gitops-prefix, so it will get swept up bytoken_names_with_prefixreaping on the next successful run. But until then it is a live, unlogged, write-scoped credential, and if verification keeps failing (e.g. a persistently wrongGITEA_VERIFY_REPO), each run adds another orphan with no operator-visible trace.Suggested fix: wrap the verification calls (and/or the whole mint→store sequence) so that any error path after
create_tokensucceeds also attemptsdelete_tokenand logs the orphaned token's name — mirroring theha.rspattern — rather than relying solely on theOk(false)branch and the next run's reap.🟡 Suggestions
secret-rotator/src/gitea.rs:36-40(basic()) — this is a pure function (base64-encodeuser:password) and a natural fit for a unit test, the wayproxmox.rstestsrender_pve_ymlandsecretgen.rstests its base64 vectors. Right nowgitea.rsis the only module in the crate with zero#[cfg(test)]coverage (bao.rshas 2,ha.rshas 3,proxmox.rshas 1,secretgen.rshas 7 — 13 total, all pre-existing). Worth noting: the PR description's "13 tests" refers to the crate's existing total, not new tests added by this change — this PR adds none.secret-rotator/src/gitea.rs:90-96(token_allows) — 401 and 403 are both mapped to "not allowed" (Ok(false)), which then feeds into the same "verification failed, delete and abort" path as a genuine permission shortfall. A 401 more likely indicates broken auth (e.g. a malformed token fromcreate_token) than "valid token, insufficient scope" — worth distinguishing in the error message so an operator debugging a persistent gate failure isn't stuck guessing whether the problem is scopes or the token itself.secret-rotator/src/main.rs:255-260(GITEA_SCOPESparsing) — unlikerotate_random'sBAO_FIELDS(main.rs:85-87) androtate_netbird_key'sNB_AUTO_GROUPS(main.rs:180-182), there is no check that the parsedscopesvector is non-empty. SettingGITEA_SCOPES=""silently produces an empty scope list; the safety gate would eventually catch the resulting powerless token and abort, but a small explicit check would fail faster and with a clearer message, consistent with the sibling rotators.🟢 Nits
gitea.rs:14-17and the function doc inmain.rs:234-241both assert "a bad rotation cannot strand Flux" — accurate for Flux's stored credential, but slightly overstates the guarantee given the orphaning gap above. Worth tightening once the blocking issue is addressed.Tests
No new tests are added by this PR. The new pure-logic candidate (
basic()) is untestable-by-omission risk is low, but every other module in this crate tests at least its pure helpers;gitea.rscurrently has none. The network-touching functions (create_token,token_allows,token_names_with_prefix,delete_token) are consistent with the crate's existing pattern of leaving such calls untested and relying on live verification instead (asproxmox.rs'srecreate_tokenalso does), so that gap alone isn't blocking — but abasic()test would be cheap and catch a header-format regression.Recommendation
Request changes — 1 blocking issue (orphaned-token cleanup gap on the verification-error and store-failure paths).
🤖 Automated review — model
claude-opus-4-8(complexity: complex), commit82f6bc765f24b1ae0981808112b255f51a6d04ff.Summary
Adds a
gitea-tokenbinary mode tosecret-rotatorthat mints a gitea PAT viabasic auth, gates it behind a pull+push verification, stores it, and reaps old
tokens. The code is clean, well-commented, and consistent with the existing
rotators (
bao.rs,main.rs). No blocking issues — the comments below arerobustness/clarity suggestions.
🟡 Suggestions
Pull verification is a near-no-op when
verify_repois public; push is theonly real gate.
token_allows(..., "git-upload-pack")treats HTTP 200 as"allowed" (
gitea.rs:88-89). For a public repo,info/refs?service=git-upload-packis served to any authenticated (or even anonymous) caller, so
can_pullistruefor any valid token regardless of its repo scope. The homelab repo ispublic (
pr.json:"private": false), and the PR's own test table confirmsthis — a read-only token returns
pull 200. That's fine as long asGITEA_REQUIRE_PUSHstaystrue(the default), becausegit-receive-packdoes require write and returns 403 for an under-scoped token
(
gitea.rs:96). But it means the safety gate rests entirely on the pushcheck: if anyone ever sets
GITEA_REQUIRE_PUSH=falseagainst a publicverify_repo, the gate would pass essentially any valid token. Worth a linein the doc-comment at
main.rs:282-284making explicit that push is theload-bearing check.
Reaping deletes the previous live token immediately after store, before the
consumer can pick up the new one. After
patch_fieldstores the new token,token_names_with_prefix+delete_tokenimmediately revoke every othertoken sharing
name_prefix(main.rs:323-333), including the previous run'stoken — which is the one Flux is still using until the ExternalSecret/ESO
syncs the new value into the k8s Secret and source-controller re-reads it.
That opens an availability window (length = ESO
refreshInterval+ reconcilelag) where the credential in the live Secret has just been deleted. The PR
body's "reads the credential fresh on each fetch — no restart" addresses
restarts, not the propagation delay. This matches the existing fleet pattern
(
ha-tokenalso revokes the old token right after store), so it isn't aregression — but since the whole point of this mode is to avoid stranding
Flux, consider keeping the single immediately-previous token and only reaping
older ones, or deferring the reap to the next run. Can't confirm the exposure
without the ESO
refreshIntervalfrom step 2 (the ExternalSecret isn't inthis PR); that interval is the one check that settles how large the window is.
token_names_with_prefixdoesn't paginate (gitea.rs:107-127). Gitea'sGET /users/{user}/tokensis paginated (~30/page), so with enoughaccumulated tokens the reap would silently stop seeing older ones. With
quarterly rotation and immediate reaping this is very unlikely to bite; noting
it only because reap failures are swallowed as non-fatal (
main.rs:332), so asilent miss here would be invisible.
🟢 Nits
format!("new token '{name}' {e}; deleted it, ...")(main.rs:305-307)renders as e.g.
new token 'flux-gitops-ab12' failed verification (pull=true push=false); deleted it, .... Readable, but a connector like"new token '{name}': {e}; deleted it"scans slightly better.Tests
Consistent with the crate's convention of unit-testing only pure helpers
(
bao.rstestsmount/sub;secretgen.rstests encoding) — the new moduletests
basic()(gitea.rs:144-155). The HTTP-dependent gate/reap logic inrotate_gitea_tokenis not unit-tested, which is understandable given there'sno mock-server harness in the repo; the PR states it was verified live against
the real gitea API (read-only token correctly rejected). The safety-critical
branch worth guarding if a harness ever lands is the "verification fails →
delete → old token stays" path, since that's the invariant the whole mode
exists to protect.
Recommendation
Looks reasonable — no blocking issues; comments above are optional.
Second review pass addressed — commit pushed
No blocking issues this time; all three suggestions handled.
Reap window (the important one) — fixed. You're right: deleting every other prefix token right after store would revoke the previous token, which is still the live credential in the k8s Secret until ESO syncs the new value — an availability window exactly where this mode is supposed to prevent one. The reap now keeps the two newest (the just-stored token + the previous) and deletes only older ones; the previous survives until the next quarterly run, long after ESO has synced.
tokens_with_prefixnow returns(id, name)to order by recency.Push is load-bearing — documented. Added a note that for a public
verify_repo,git-upload-packreturns 200 for any valid token, so pull barely gates; do not setGITEA_REQUIRE_PUSH=falseagainst a public repo.Pagination — bounded. Fetch with
limit=50(one effective page); with keep-two reaping the prefixed-token count never approaches a page, so the swallowed-reap concern can't silently miss old tokens. Noted in the doc-comment.Nit — message connector tidied.
clippy -D warnings clean; 14 tests pass.