swarm-bao: create the KV mount the controller writes credentials through

The bootstrap unit writes a policy granting `secret/data/swarm/agents/*` and
nothing creates that engine. A fresh OpenBao has no `secret/` — only a dev-mode
one does — so `swarm-controller`'s first credential write answers `no handler
for route "secret/data/swarm/agents/<agent>/matrix/<name>". route entry not
found.` Measured on the live host at 21:27:27Z; #4171.

`git grep` for `secrets enable`, `kv-v2`, `kv_v2` and `sys/mounts` returned zero
across the whole tree. Control, so the zero means something: `auth enable` in
this same file returns 2 — the same defect was already found and fixed once, for
the cert auth mount, with a comment that states the principle. This is the other
half of it.

The mount name is now bound once and interpolated into both the policy text and
the new step, because a grant and a mount that disagree is exactly the failure
being fixed.

Placed outside the client-CA block: the controller writes *through* this mount
regardless of whether anything can log in by certificate. `module-eval` asserts
that, since one indentation level decides it.

Grants, measured against a real openbao 2.6.2 rather than derived:
`-output-policy` asks for `sys/mounts/secret` create+update, and a token holding
exactly `sys/mounts` read + `sys/mounts/<path>` create/update enabled the engine
— **no `sudo`**, unlike `sys/auth/cert`. Negative control: the same token on an
ungranted path got 403, so the grant is what made it work. `setup.md`'s
documented policy gains those two.

Also from that session, each deciding how this is written: re-enabling an
existing path errors (exit 2), so this asks first like the auth mount does;
`secrets list -format=json` keys look like `"secret/"`, so the `case` idiom
ports over; and `kv put -mount=<p>` reports `<p>/data/...`, confirming v2 — the
prefix the policy grants and the client writes.

setup.md also drops a check that cannot work: it told the operator to confirm
with `bao read auth/cert/…`, which 403s because the host wrapper carries no
token. `systemctl status swarm-bao-controller-policy` needs no credential and
names the three success lines. The first-attempt-after-rebuild race is now
written down too — the store is still coming up, and the 30s retry is what
lands.

Refs #4171.
This commit is contained in:
atlas 2026-09-11 00:16:46 +02:00
commit 48e6a0b88f
3 changed files with 96 additions and 16 deletions

View file

@ -75,7 +75,7 @@ authenticate until some role exists — this token is what breaks that cycle,
and it's the only step that needs the root token.
```bash
# Exactly the four grants the bootstrap unit needs, and nothing else. Each was
# Exactly the six grants the bootstrap unit needs, and nothing else. Each was
# derived with `bao <cmd> -output-policy`, which prints what a command requires
# without running it.
bao policy write swarm-bootstrap - <<'EOF'
@ -96,6 +96,17 @@ path "sys/auth/cert" {
path "auth/cert/certs/swarm-controller" {
capabilities = ["create", "update"]
}
# The KV engine the controller writes agent credentials through — also absent
# on a fresh store, and checked the same way. No `sudo` here, unlike the auth
# mount above: enabling a secrets engine does not ask for it.
path "sys/mounts" {
capabilities = ["read"]
}
path "sys/mounts/secret" {
capabilities = ["create", "update"]
}
EOF
# A token holding it. `-orphan` so it outlives the session that made it.
@ -105,23 +116,47 @@ bao token create -policy=swarm-bootstrap -ttl=24h -orphan -display-name=swarm-bo
Put the token's value at `services.hyperhive.deploy.bao.bootstrapTokenFile`
(all-local names that path for you), then rebuild. A one-shot unit **on the
host** reads it, writes the `swarm-controller` policy, enables the cert auth
method, and creates the `swarm-controller` role that attaches the two. It runs
there because every API listener demands a client certificate, and the host is
the side that has one.
method, mounts the KV engine the controller stores credentials in, and creates
the `swarm-controller` role that attaches policy to certificate. It runs there
because every API listener demands a client certificate, and the host is the
side that has one.
⚠️ **The grants above are derived, not proven.** They come from
`-output-policy`, not from a swarm that came up on them. The unit has run twice
against a live store and failed both times for reasons of its own — it could
not reach the store from where it then ran — so neither run exercised these
grants at all.
⏱️ **Expect the first attempt to fail if you rebuilt into this.** A rebuild
restarts the store, and the unit races it — the store answers `local node not
active` until it finishes coming up. It retries every 30s and the second
attempt is the one that usually lands. Nothing to do.
**Delete the file only once `bao read auth/cert/certs/swarm-controller` returns
the role.** The unit skips when the token is absent, so a host that has finished
bootstrapping stops carrying the credential — but deleting it before the role
**Confirm with `systemctl status swarm-bao-controller-policy`**, which wants no
token — a successful run logs `Uploaded policy`, `Enabled cert auth method` and
`Data written to: auth/cert/certs/swarm-controller`. ⚠️ Do _not_ reach for `bao
read auth/cert/…` to check: the host's `bao` wrapper carries an address, a CA
and a client certificate but deliberately **no token**, so that read answers
`403` whether or not the role exists.
**Delete the token file only once that unit has succeeded.** It skips when the
token is absent, so a host that has finished bootstrapping stops carrying the
credential — but deleting it before the role
exists leaves the unit skipping forever with nothing to show for it, and looks
exactly like a store that was never bootstrapped. The TTL above means a
forgotten one expires rather than lingering.
<details><summary>Already bootstrapped before the KV mount existed?</summary>
A store bootstrapped by an earlier version has the policy, the auth method and
the role, but no `secret/` engine — the controller's first credential write
answers `no handler for route "secret/data/…"`. The bootstrap token cannot fix
it either: the policy it was minted from names nothing under `sys/mounts`. Mount
it once with the root token from `init`:
```bash
sudo bash -c 'BAO_TOKEN="<root token>" bao secrets enable -path=secret kv-v2'
```
No rebuild needed — the unit's own check finds the mount on its next run and
leaves it alone.
</details>
Whether anything more is needed depends on
`services.hyperhive.deploy.bao.seal`: