Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
3ee5a960b4 | ||
|
|
22f0acfd6d | ||
|
|
abc942cff3 | ||
|
|
6d0c30ade2 | ||
|
|
b5d07d4df2 | ||
|
|
dd32a395f7 | ||
|
|
52c8c0b0de |
43 changed files with 1902 additions and 329 deletions
|
|
@ -24,17 +24,30 @@ operator has to place, and where.
|
|||
|
||||
### 1 · Forge
|
||||
|
||||
```bash
|
||||
# Provision (or refresh) ruth's own forge account — do this first. Ruth's
|
||||
# bootstrap bypasses the normal spawn-approval flow ("Spawn sub-agents"
|
||||
# below), so unlike every other agent it does not get its forge account
|
||||
# auto-provisioned — this manual step is still load-bearing.
|
||||
hivectl forge create-user ruth
|
||||
hive-c0re no longer creates agent forge users or mints agent tokens.
|
||||
swarm-controller does, for every agent that holds a store identity: a pass
|
||||
at start and every five minutes creates the forge user if it's missing,
|
||||
then mints the token into the swarm secret store, where the agent fetches
|
||||
it under that identity. An agent created with `swarmctl agent create` gets
|
||||
its identity then. Ruth doesn't: hive-c0re creates her on its own at
|
||||
startup, so she needs her identity minted by hand, once.
|
||||
|
||||
# Sub-agents spawned later (via the approval flow in "Spawn sub-agents")
|
||||
# get their forge accounts auto-provisioned — nothing to run here for them.
|
||||
```bash
|
||||
# On the swarm-controller host: give ruth her store identity. <hive> is the
|
||||
# name of the hive she runs on.
|
||||
swarmctl agent mint-identity ruth --hive <hive>
|
||||
|
||||
# On ruth's hive: re-apply her container config, which is when hive-c0re
|
||||
# hands the new identity to the container.
|
||||
hivectl agent ruth rebuild
|
||||
```
|
||||
|
||||
You don't need to do anything else. The controller's next pass creates
|
||||
ruth's forge user and mints her token, and her container fetches it within
|
||||
about ten minutes. `swarmctl agent mint-forge-token ruth` skips the wait for
|
||||
the pass. A hive without a swarm secret store has no path to a forge token
|
||||
for ruth at all.
|
||||
|
||||
Swarm SSO creates the human operator's own forge account instead of
|
||||
a manual `hivectl` step — see _Swarm SSO_ below (`swarmctl user add`).
|
||||
|
||||
|
|
@ -267,7 +280,9 @@ See [`tools/hivectl.md`](../tools/hivectl.md) for every `hivectl` verb.
|
|||
<!-- vale write-good.Passive = NO -->
|
||||
|
||||
- **No forge admin token is stored in any agent state dir.** Agents
|
||||
hold a regular agent token in their `forge-token` file; sensitive
|
||||
hold a regular agent token, fetched from the swarm secret store into
|
||||
`/run/hive-agent-forge-token/token` (or, for an agent without a store
|
||||
identity, the `forge-token` file hive-c0re wrote before); sensitive
|
||||
creds (the core token) live on the host.
|
||||
- All config changes (forge PRs on `agent-configs/<name>`) go through
|
||||
operator approval — agents can't unilaterally rebuild containers, by design.
|
||||
|
|
|
|||
|
|
@ -17,7 +17,9 @@ each agent on relevant activity.
|
|||
|
||||
Two scope sets live in `hive-c0re::forge`:
|
||||
|
||||
**`TOKEN_SCOPES`** (per-agent tokens):
|
||||
**`TOKEN_SCOPES`** (per-agent tokens, and `hivectl forge create-user`
|
||||
accounts). swarm-controller mints agent tokens with a byte-identical copy,
|
||||
`forge::agent_token::AGENT_TOKEN_SCOPES`, pinned by a test:
|
||||
|
||||
| Scope | Why |
|
||||
| -------------------- | ------------------------------------------------------------------------------------------------------- |
|
||||
|
|
@ -46,13 +48,20 @@ hive-c0re to re-mint with the new scopes.
|
|||
|
||||
## Per-agent forge accounts
|
||||
|
||||
Each agent gets its own Forgejo user + access token, provisioned at
|
||||
boot by `hive-c0re::forge`. The provisioning flow is idempotent:
|
||||
`hive-c0re::forge` reuses existing accounts + tokens, so container destroy/recreate
|
||||
doesn't lose forge identity. It writes the token to
|
||||
`<state>/forge-token` (one line, no trailing newline) inside the
|
||||
agent container so `hive-forge` CLI + `forge_notify` poller can
|
||||
read it without touching c0re's host-side credential store.
|
||||
Each agent gets its own Forgejo user and access token. swarm-controller
|
||||
creates the user when it creates the agent, and mints one token named
|
||||
`swarm-agent` with the admin API into the swarm secret store at
|
||||
`swarm/agents/<agent>/forge-token`. A pass at start and every five minutes
|
||||
covers every agent with a store identity: it creates a missing forge user and
|
||||
re-mints any token that's missing or no longer matches the forge;
|
||||
a rotation deletes the old `swarm-agent` token first, so each agent holds at
|
||||
most one. The agent fetches the token under its own store certificate into
|
||||
`/run/hive-agent-forge-token/token` (`nix/agent-modules/forge-token.nix`),
|
||||
and `hive-forge`, the git credential helper, the `forge_notify` poller and
|
||||
the avatar sync read it from there, falling back to `<state>/forge-token`,
|
||||
the file hive-c0re wrote before. hive-c0re no longer creates agent users or
|
||||
mints agent tokens; the `hyperhive-<unix-seconds>` tokens it minted stay on
|
||||
the forge until removed.
|
||||
|
||||
Two things live in the `agent-configs` Forgejo organization:
|
||||
|
||||
|
|
@ -162,7 +171,7 @@ The poller starts disabled and stays that way for any of:
|
|||
|
||||
- `HIVE_FORGE_URL` not set (no forge configured for this hive), or
|
||||
not parseable as a URL.
|
||||
- `<state>/forge-token` missing or empty (agent has no forge
|
||||
- no token in `$HIVE_FORGE_TOKEN_FILE` or `<state>/forge-token` (agent has no forge
|
||||
account — pre-provisioning or destroy-without-purge race).
|
||||
- Initial client construction fails (the typed `forgejo-api` client
|
||||
for the API calls, or the plain reqwest client kept for the
|
||||
|
|
|
|||
|
|
@ -263,7 +263,7 @@ wiring is runtime:
|
|||
- the `hyperhive-isolated-dns` oneshot (`nix/agent-modules/network.nix`), gated on that
|
||||
marker, rewrites `/etc/resolv.conf` to `nameserver <gateway-ip>` at boot.
|
||||
It's ordered `before` the harness (`hive-ag3nt`), the matrix daemon, and
|
||||
`tea-login` so the resolver is correct before the first DNS lookup.
|
||||
the forge-token fetch so the resolver is correct before the first DNS lookup.
|
||||
|
||||
**Why isolation is safe**: hive-c0re's control-plane sockets are unix
|
||||
domain sockets bind-mounted into containers, not network listeners — see
|
||||
|
|
|
|||
|
|
@ -502,13 +502,9 @@ sparingly) or build just the suspect check, for example `nix build
|
|||
## Best-effort oneshot services
|
||||
|
||||
The harness ships a family of one-shot systemd services that
|
||||
configure agent-side surfaces from values hive-c0re writes into
|
||||
the state dir at provisioning time:
|
||||
configure agent-side surfaces from values delivered to the agent at
|
||||
provisioning time:
|
||||
|
||||
- `tea-login` — writes `~/.config/tea/config.yml` from the
|
||||
`forge-token` written by `hive-c0re::forge::ensure_user_for`,
|
||||
so `tea repos create` / `tea pulls create` work without
|
||||
interactive prompts.
|
||||
- `forge-avatar-sync` — uploads `services.hyperhive.agent.icon` SVG to the
|
||||
agent's Forgejo profile, so the icon shows up on commits / PRs /
|
||||
issue comments.
|
||||
|
|
@ -538,13 +534,10 @@ Shape contract — every one of these:
|
|||
by the `.path` watchers that re-fire on token appearance (see
|
||||
`docs/agent-lifecycle/persistence.md::Matrix per-agent daemon`).
|
||||
|
||||
The artefact lives under the agent user's home where applicable
|
||||
(`~/.config/tea/config.yml`) and is chown'd to that user, but the
|
||||
service itself stays root-owned so the bootstrap ordering doesn't
|
||||
need a user-existence check before each fire.
|
||||
The service stays root-owned so the bootstrap ordering doesn't need a
|
||||
user-existence check before each fire.
|
||||
|
||||
This pattern keeps the rebuild path resilient: any failure inside
|
||||
these services degrades the corresponding surface (no tea config,
|
||||
no avatar) but never blocks the container from coming up. The
|
||||
these services degrades the corresponding surface (no avatar) but never blocks the container from coming up. The
|
||||
operator notices through `journalctl -u <unit>` rather than a
|
||||
broken switch-to-configuration.
|
||||
|
|
|
|||
|
|
@ -448,7 +448,7 @@ connects to the compositor at `127.0.0.1:<vnc_port>`.
|
|||
must never block on weston signalling readiness. A misconfigured
|
||||
weston degrades to a `Restart=on-failure` loop visible in
|
||||
`journalctl`, it doesn't abort the `nixos-container update`.
|
||||
Same reasoning as the `tea-login` unit in `nix/agent-modules/forge.nix`.
|
||||
Same reasoning as the best-effort oneshots in `docs/process/conventions.md`.
|
||||
- **`[core] idle-time=0`**: disables weston's 300-second idle
|
||||
timeout. Without it the VNC desktop fades to black and
|
||||
desktop-shell shows its click-to-unlock screen — useless for an
|
||||
|
|
|
|||
|
|
@ -42,9 +42,9 @@ to the store under its own name, pulling what it needs when it needs it. No
|
|||
process reads a secret on another principal's behalf: the principal that
|
||||
needs a value is the principal that authenticates for it.
|
||||
|
||||
Two per-agent credential files — the forge token and the github token — sit
|
||||
outside this page: they're operator-supplied and never pass through the
|
||||
store, so the table below doesn't govern them.
|
||||
One per-agent credential file — the github token — sits outside this page:
|
||||
it's operator-supplied and never passes through the store, so the table
|
||||
below doesn't govern it.
|
||||
|
||||
**Per secret, the target specifies minter, reader, and renewal strategy.**
|
||||
Those three are the contract, and the reader is a process pulling a store
|
||||
|
|
@ -54,16 +54,17 @@ strategy for every credential, including the mTLS leaf.
|
|||
|
||||
<!-- vale write-good.Passive = NO -->
|
||||
|
||||
| store path | minter | reader — pulls at runtime, holds in memory | renewal |
|
||||
| -------------------------------------------- | ------------------------------------------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------ | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| `swarm/agents/<agent>/matrix/<account>` | `swarm-controller` | the agent container itself, under the certificate its hive passed in | must be stated |
|
||||
| `swarm/agents/<agent>/bao-mtls` | `swarm-controller`, at agent creation | `hive-c0re`, under the hive's own certificate, when it writes the agent's container config | must be stated |
|
||||
| `swarm/agents/<agent>/queue` | `swarm-controller`, at agent creation | the agent container itself, under its own certificate — the identity it presents to the swarm queue, naming that one agent rather than its hive | none: the secret is fixed for the life of the agent and is revoked by deleting the path. A rotation mechanism is tracked as separate work, because rotating this credential needs a reconnect path — a queue client holding a revoked secret doesn't find out until it reconnects |
|
||||
| `swarm/hives/<hive>/matrix/appservice-token` | one minter, on the authelia host | the hive process that presents the token to its homeserver, under the hive's own certificate | must be stated |
|
||||
| `swarm/hives/<hive>/matrix/sender-token` | `swarm-matrix-ctl`, in the `hive-matrix` container | `swarm-matrix-ctl` itself, under its own certificate, before it decides whether to mint, and hive-c0re's `stored_sender_token()`, under the hive's own certificate | must be stated |
|
||||
| `swarm/hives/<hive>/queue/agent` | authelia | the agent container presenting the OIDC client to the swarm queue, under its own certificate | must be stated |
|
||||
| `swarm/services/<clientId>/oidc/client` | authelia | the service process that presents the client secret, under the certificate of the host it runs on | must be stated |
|
||||
| _(not in the store)_ a hive's mTLS leaf | the store's own PKI, or an operator placing it by hand | its own client, off disk — the exception above, because it's what makes every other row's pull possible | must be stated |
|
||||
| store path | minter | reader — pulls at runtime, holds in memory | renewal |
|
||||
| -------------------------------------------- | ---------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------ | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| `swarm/agents/<agent>/matrix/<account>` | `swarm-controller` | the agent container itself, under the certificate its hive passed in | must be stated |
|
||||
| `swarm/agents/<agent>/bao-mtls` | `swarm-controller`, at agent creation | `hive-c0re`, under the hive's own certificate, when it writes the agent's container config | must be stated |
|
||||
| `swarm/agents/<agent>/queue` | `swarm-controller`, at agent creation | the agent container itself, under its own certificate — the identity it presents to the swarm queue, naming that one agent rather than its hive | none: the secret is fixed for the life of the agent and is revoked by deleting the path. A rotation mechanism is tracked as separate work, because rotating this credential needs a reconnect path — a queue client holding a revoked secret doesn't find out until it reconnects |
|
||||
| `swarm/agents/<agent>/forge-token` | `swarm-controller`, at agent creation and in a pass every 5 minutes over every agent with a store identity | the agent container itself, under its own certificate, fetched to `/run/hive-agent-forge-token/token` | the controller re-mints when the stored token is missing or no longer matches the forge (last eight characters and scopes); the agent re-fetches on a 10-minute timer |
|
||||
| `swarm/hives/<hive>/matrix/appservice-token` | one minter, on the authelia host | the hive process that presents the token to its homeserver, under the hive's own certificate | must be stated |
|
||||
| `swarm/hives/<hive>/matrix/sender-token` | `swarm-matrix-ctl`, in the `hive-matrix` container | `swarm-matrix-ctl` itself, under its own certificate, before it decides whether to mint, and hive-c0re's `stored_sender_token()`, under the hive's own certificate | must be stated |
|
||||
| `swarm/hives/<hive>/queue/agent` | authelia | the agent container presenting the OIDC client to the swarm queue, under its own certificate | must be stated |
|
||||
| `swarm/services/<clientId>/oidc/client` | authelia | the service process that presents the client secret, under the certificate of the host it runs on | must be stated |
|
||||
| _(not in the store)_ a hive's mTLS leaf | the store's own PKI, or an operator placing it by hand | its own client, off disk — the exception above, because it's what makes every other row's pull possible | must be stated |
|
||||
|
||||
<!-- vale write-good.Passive = YES -->
|
||||
|
||||
|
|
@ -95,6 +96,19 @@ value it holds, so running this against an already-migrated agent doesn't drop
|
|||
its queue connection. The certificate half isn't: the agent gets a fresh leaf
|
||||
and picks it up on its next boot.
|
||||
|
||||
The forge token needs no such step: `swarm-controller` checks every agent
|
||||
that has a store identity at start and every five minutes. For any whose
|
||||
stored token is missing or stale it creates the forge user if there isn't one,
|
||||
then mints the token. To check one agent now:
|
||||
|
||||
```sh
|
||||
swarmctl agent mint-forge-token <agent>
|
||||
```
|
||||
|
||||
An agent without a store identity gets no swarm token and keeps using the
|
||||
`forge-token` file in its state dir, if it has one; run `mint-identity` for it
|
||||
first.
|
||||
|
||||
⚠️ **Run this for every existing agent before deploying a hive-side change
|
||||
that makes a container require a credential it may not have.** A container
|
||||
whose credential is absent doesn't start — that's deliberate, and it's what
|
||||
|
|
|
|||
|
|
@ -14,7 +14,8 @@ markdown-docs > docs/tools/forge-cli.md`.
|
|||
|
||||
## Credentials and repo defaults
|
||||
|
||||
- Credentials: `$HYPERHIVE_STATE_DIR/forge-token`
|
||||
- Credentials: `$HIVE_FORGE_TOKEN_FILE` (the token the agent fetched from
|
||||
the swarm secret store), else `$HYPERHIVE_STATE_DIR/forge-token`
|
||||
- Active repo resolves, highest priority first: global `-r/--repo` flag
|
||||
> the `origin` remote of the cwd's git checkout > `$HIVE_FORGE_REPO`
|
||||
(last-resort override, unset by default) > a hard error.
|
||||
|
|
|
|||
|
|
@ -103,22 +103,22 @@ Manual entry point to the same idempotent provisioning c0re runs at boot — for
|
|||
|
||||
###### **Subcommands:**
|
||||
|
||||
* `create-user` — Create or refresh the Forgejo account + token for `<name>`
|
||||
* `create-user` — Create or refresh a non-agent Forgejo account + token for `<name>`
|
||||
* `reconcile-config` — Show + reconcile the divergence between an agent's local applied config checkout and its forge `agent-configs/<agent>` main
|
||||
|
||||
|
||||
|
||||
## `hivectl forge create-user`
|
||||
|
||||
Create or refresh the Forgejo account + token for `<name>`.
|
||||
Create or refresh a non-agent Forgejo account + token for `<name>`.
|
||||
|
||||
For an existing agent, persists the token to its state dir; for a human/other account, prints the token to stdout. Set a password to enable forge web-UI login (otherwise it uses a random throwaway).
|
||||
Prints the token to stdout. Set a password to enable forge web-UI login (otherwise it uses a random throwaway). Refused for an existing agent: swarm-controller mints an agent's token (`swarmctl agent mint-forge-token <agent>`).
|
||||
|
||||
**Usage:** `hivectl forge create-user [OPTIONS] <NAME>`
|
||||
|
||||
###### **Arguments:**
|
||||
|
||||
* `<NAME>` — Forgejo username. For agents: the container/agent name (`<n>` in `h-<n>`; manager uses the literal `manager`). For humans: any forgejo username — `mara`, `damocles`, etc
|
||||
* `<NAME>` — Forgejo username of a human/other account — `mara`, `damocles`, etc
|
||||
|
||||
###### **Options:**
|
||||
|
||||
|
|
|
|||
|
|
@ -29,7 +29,7 @@ at boot. Useful for recovery, ad-hoc re-provisioning, or fixing a single
|
|||
agent without bouncing the daemon.
|
||||
|
||||
```bash
|
||||
hivectl forge create-user iris # provision (or refresh) forge account for agent `iris`
|
||||
hivectl forge create-user iris # refused: `iris` is an agent; use `swarmctl agent mint-forge-token iris`
|
||||
hivectl forge create-user mara # create forge account for a human user; prints token to stdout
|
||||
hivectl forge create-user mara --password hunter2 # set a web-login password
|
||||
hivectl forge create-user mara --password-stdin # read password from stdin (safer for scripting)
|
||||
|
|
@ -40,8 +40,9 @@ hivectl forge reconcile-config iris --verbose # include the full diff, not
|
|||
```
|
||||
|
||||
- For **agents** (name has a state dir under `/var/lib/hyperhive/agents/`):
|
||||
`create-user` persists the token to `<state>/forge-token`. Re-running refreshes the
|
||||
token (idempotent — scope always matches current `TOKEN_SCOPES`).
|
||||
`create-user` refuses. swarm-controller mints an agent's token into
|
||||
the swarm secret store; `swarmctl agent mint-forge-token <agent>` checks
|
||||
and, if needed, re-mints it.
|
||||
- For **non-agents** (humans): creates the account and prints the token to
|
||||
stdout, creating no state dir. Re-running after account already exists
|
||||
re-mints the token and prints it again — safe for password resets.
|
||||
|
|
|
|||
|
|
@ -8,6 +8,7 @@ This document contains the help content for the `swarmctl` command-line program.
|
|||
* [`swarmctl agent`↴](#swarmctl-agent)
|
||||
* [`swarmctl agent create`↴](#swarmctl-agent-create)
|
||||
* [`swarmctl agent mint-identity`↴](#swarmctl-agent-mint-identity)
|
||||
* [`swarmctl agent mint-forge-token`↴](#swarmctl-agent-mint-forge-token)
|
||||
* [`swarmctl user`↴](#swarmctl-user)
|
||||
* [`swarmctl user add`↴](#swarmctl-user-add)
|
||||
* [`swarmctl user update`↴](#swarmctl-user-update)
|
||||
|
|
@ -45,6 +46,7 @@ Manage agents across the swarm
|
|||
|
||||
* `create` — Queue creation of a new agent on a hive in this swarm
|
||||
* `mint-identity` — Queue a re-mint of an existing agent's identity at the swarm's secret store
|
||||
* `mint-forge-token` — Check one agent's forge token, and mint it if it's missing or stale
|
||||
|
||||
|
||||
|
||||
|
|
@ -104,6 +106,28 @@ Queues and returns, the same way `agent create` does — watch the swarm UI's jo
|
|||
|
||||
|
||||
|
||||
## `swarmctl agent mint-forge-token`
|
||||
|
||||
Check one agent's forge token, and mint it if it's missing or stale.
|
||||
|
||||
swarm-controller does this for every agent with a store identity at start and every five minutes; this is for when waiting isn't an option. It leaves a current token alone.
|
||||
|
||||
Queues and returns, the same way `agent create` does — watch the swarm UI's job view for the outcome.
|
||||
|
||||
**Usage:** `swarmctl agent mint-forge-token [OPTIONS] <NAME>`
|
||||
|
||||
###### **Arguments:**
|
||||
|
||||
* `<NAME>` — Name of an agent that already exists
|
||||
|
||||
###### **Options:**
|
||||
|
||||
* `--controller-socket <PATH>` — swarm-controller's unix socket.
|
||||
|
||||
Supplied by the nix module that installs this binary, from the same `socketPath` option the daemon binds; falls back to `SWARM_CONTROLLER_SOCKET`.
|
||||
|
||||
|
||||
|
||||
## `swarmctl user`
|
||||
|
||||
Manage subjects in the swarm's SSO provider
|
||||
|
|
|
|||
|
|
@ -236,9 +236,12 @@ token policy bounds file reads; network isolation bounds network reach.
|
|||
this directory at a default mode is world-readable, which isn't
|
||||
hypothetical: `plugins/` and `.last-cleanup` already are.
|
||||
- `$HYPERHIVE_STATE_DIR/forge-token` (= `/agents/<name>/state/forge-token`)
|
||||
— written at mode `0600` and chowned to the per-agent uid:gid (see
|
||||
`hive-c0re/src/forge/mod.rs`'s module doc for exactly where). nixbld users
|
||||
can't read it.
|
||||
— the token hive-c0re wrote at mode `0600`, chowned to the per-agent
|
||||
uid:gid, before swarm-controller took over minting. Nothing writes it any
|
||||
more; it stays as the fallback for an agent without a store identity.
|
||||
nixbld users can't read it. The swarm-minted token lives under
|
||||
`/run/hive-agent-forge-token/` (`0700` directory, `0400` file), outside
|
||||
the state dir.
|
||||
|
||||
**Policy**: all credential files written to agent state directories MUST be mode
|
||||
`0600` or stricter. Don't create world-readable secret files in agent state dirs.
|
||||
|
|
@ -291,7 +294,7 @@ known operations; there is no arbitrary command pass-through:
|
|||
| `RemoveServiceDropin` | remove `container@<name>.service.d/` drop-in on destroy |
|
||||
| `DaemonReload` | `systemctl daemon-reload` |
|
||||
| `RunForgeAdmin` | `nixos-container run hive-forge -- runuser -u forgejo -- forgejo admin <args>` |
|
||||
| `WriteAgentForgeToken` / `WriteAgentMatrixToken` | write `0600` credential file into agent state dir |
|
||||
| `WriteAgentMatrixToken` | write `0600` credential file into agent state dir |
|
||||
| `RestartMatrixDaemon` | `systemctl --machine=h-<name> restart hive-matrix-daemon.service` |
|
||||
| `ControlInfraContainer` | `systemctl <action> container@<container>.service` — the `InfraContainer` enum is the allowlist, and serde rejects unknown names at the wire boundary (`hive-c0re` has no variant, so no request can name it) |
|
||||
| `SyncAgentTmpfiles` | write `/etc/tmpfiles.d/hyperhive-agents.conf` for the agent set, then `systemd-tmpfiles --create` |
|
||||
|
|
|
|||
|
|
@ -137,11 +137,9 @@ services.hyperhive.agent.forge.url = "http://forge.example:3000"; # default: nu
|
|||
services.hyperhive.agent.matrix.url = "https://matrix.example"; # default: null
|
||||
```
|
||||
|
||||
**`services.hyperhive.agent.forge.url`** — base URL of the Forgejo instance. Used by
|
||||
a one-shot boot unit (`tea-login`) that writes `~/.config/tea/config.yml`
|
||||
directly from the agent's `forge-token`, so `tea` and `hive-forge`
|
||||
work without an interactive auth step. The unit is a no-op when
|
||||
`forge-token` is absent. Override when the agent should connect to a
|
||||
**`services.hyperhive.agent.forge.url`** — base URL of the Forgejo instance. It scopes
|
||||
the git credential helper to this forge and is where `forge-avatar-sync`
|
||||
uploads the agent's icon. Override when the agent should connect to a
|
||||
Forgejo on a different host or port (for example a swarm peer's forge).
|
||||
Validated: must be an `http://` or `https://` URL, or `null`.
|
||||
|
||||
|
|
@ -149,7 +147,7 @@ Validated: must be an `http://` or `https://` URL, or `null`.
|
|||
loopback default would only ever be correct when the forge shares the
|
||||
agent's network namespace, and inside a container `localhost` is the
|
||||
agent itself, so the default was a value that built fine and then talked
|
||||
to the wrong machine. With `null` the `tea-login` and `forge-avatar-sync`
|
||||
to the wrong machine. With `null` the credential helper and `forge-avatar-sync`
|
||||
units aren't generated at all: an absent integration rather than a
|
||||
misdirected one. You don't normally set this — hive-c0re renders the
|
||||
host's real forge URL into every agent, and refuses to write a meta
|
||||
|
|
|
|||
|
|
@ -351,7 +351,12 @@ fn agent_links(label: &str, gui_enabled: bool) -> Vec<AgentLink> {
|
|||
});
|
||||
}
|
||||
|
||||
if crate::paths::state_dir().join("forge-token").is_file() {
|
||||
// Either copy of the forge token: the one fetched from the swarm secret
|
||||
// store (`HIVE_FORGE_TOKEN_FILE`), or the state-dir file the hive used
|
||||
// to write.
|
||||
let fetched_token = std::env::var_os("HIVE_FORGE_TOKEN_FILE")
|
||||
.is_some_and(|p| std::path::Path::new(&p).is_file());
|
||||
if fetched_token || crate::paths::state_dir().join("forge-token").is_file() {
|
||||
links.push(AgentLink {
|
||||
url: format!("/{label}"),
|
||||
icon: "🔨".to_owned(),
|
||||
|
|
|
|||
|
|
@ -527,7 +527,7 @@ pub(crate) async fn resolve_approval_dag(
|
|||
let mut terminal_tag = None;
|
||||
match approval.kind {
|
||||
ApprovalKind::Spawn => {
|
||||
// Post-spawn forge bookkeeping (user, config repo mirror, meta
|
||||
// Post-spawn forge bookkeeping (config repo mirror, meta
|
||||
// access) — warn-only, then the resolution events + a rescan so
|
||||
// the dashboard reflects the post-spawn state either way.
|
||||
if result.is_ok() {
|
||||
|
|
@ -605,13 +605,10 @@ fn fetch_approval_for_worker(
|
|||
}
|
||||
|
||||
/// Forge bookkeeping run once after the very first container spawn:
|
||||
/// create the per-agent forge user, mirror the applied repo, and grant
|
||||
/// read access to core/meta. Also rescans containers so the dashboard
|
||||
/// reflects the post-spawn state.
|
||||
/// mirror the applied repo and grant read access to core/meta. The
|
||||
/// agent's forge user and token are swarm-controller's, not this
|
||||
/// hive's. Also rescans containers so the dashboard reflects the post-spawn state.
|
||||
async fn forge_after_first_spawn(coord: &Arc<Coordinator>, agent: &str) {
|
||||
if let Err(e) = crate::forge::ensure_user_for(agent).await {
|
||||
tracing::warn!(%agent, error = ?e, "forge: ensure_user after first spawn failed");
|
||||
}
|
||||
if let Err(e) = crate::forge::ensure_config_repo(agent).await {
|
||||
tracing::warn!(%agent, error = ?e, "forge: ensure_config_repo after first spawn failed");
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
//! Optional Forgejo wiring — per-agent user + token provisioning,
|
||||
//! Optional Forgejo wiring — per-agent account alignment,
|
||||
//! config-repo mirroring, meta read-access grants. Also seeds
|
||||
//! `internal/docs` — a private repo every agent gets read-only
|
||||
//! collaborator access to for operator-curated shared content.
|
||||
|
|
@ -21,7 +21,7 @@ pub use repos::{
|
|||
ensure_meta_remote, ensure_repo, ensure_shared_docs_repo, fast_forward_applied_main,
|
||||
fetch_config_main_into_applied, meta_read_access, push_config, push_meta, shared_docs_access,
|
||||
};
|
||||
pub use users::{core_token, ensure_user_for, provision_user_token};
|
||||
pub use users::{core_token, provision_user_token};
|
||||
|
||||
use std::sync::OnceLock;
|
||||
use std::time::{Duration, Instant};
|
||||
|
|
@ -212,9 +212,9 @@ pub(crate) fn api(token: &str) -> Result<Forgejo> {
|
|||
Forgejo::new(Auth::Token(token), url).context("build forgejo api client")
|
||||
}
|
||||
|
||||
/// Per-agent forge sync: ensure the agent has a forgejo user + token,
|
||||
/// a mirrored config repo, read access to `core/meta`, and the `meta`
|
||||
/// remote in its proposed repo. All operations are idempotent; failures
|
||||
/// Per-agent forge sync: align the agent's forge account (email,
|
||||
/// repo-creation lockdown), mirror its config repo, grant read access
|
||||
/// to `core/meta`, and wire the `meta` remote in its proposed repo. All operations are idempotent; failures
|
||||
/// are logged as warnings but don't abort the caller.
|
||||
///
|
||||
/// `core_token` is `core_token()` — passed in so callers that already
|
||||
|
|
@ -228,11 +228,10 @@ pub(crate) fn api(token: &str) -> Result<Forgejo> {
|
|||
/// dashboard warning (see [`ensure_all`]); the rebuild path ignores it and
|
||||
/// relies on the journal `warn!` lines alone (a rebuild is its own retry).
|
||||
pub async fn sync_agent(name: &str, core_token: Option<&str>) -> bool {
|
||||
// The agent's forge user and token are swarm-controller's now
|
||||
// (`MintAgentForgeToken`); this hive no longer creates or re-mints
|
||||
// either.
|
||||
let mut ok = true;
|
||||
if let Err(e) = ensure_user_for(name).await {
|
||||
tracing::warn!(%name, error = ?e, "forge: ensure_user failed");
|
||||
ok = false;
|
||||
}
|
||||
// Align email to match the git user.email set by meta::render_flake
|
||||
// so commits link to the agent's Forgejo profile. Best-effort;
|
||||
// also patches up agents created before this fix (old @hive.local).
|
||||
|
|
|
|||
|
|
@ -313,8 +313,8 @@ pub(super) async fn ensure_repo_creation_disabled(name: &str) {
|
|||
/// a monotonic clock so re-issuing doesn't collide with an existing
|
||||
/// token of the same name in the DB. `scopes` is the scope string
|
||||
/// passed to `forgejo admin user generate-access-token --scopes`;
|
||||
/// use `TOKEN_SCOPES` for agents, `CORE_TOKEN_SCOPES` for the
|
||||
/// bootstrap `core` user.
|
||||
/// use `TOKEN_SCOPES` for `hivectl forge create-user` accounts and
|
||||
/// `CORE_TOKEN_SCOPES` for the bootstrap `core` user.
|
||||
async fn mint_token(name: &str, scopes: &str) -> Result<String> {
|
||||
let token_name = format!(
|
||||
"{TOKEN_NAME_PREFIX}-{}",
|
||||
|
|
@ -349,16 +349,6 @@ async fn mint_token(name: &str, scopes: &str) -> Result<String> {
|
|||
Ok(token)
|
||||
}
|
||||
|
||||
/// Mint a fresh Forgejo access token for an agent and write it to the
|
||||
/// agent's state dir via hive-priv. hive-c0re runs unprivileged and
|
||||
/// cannot write to agent-owned (0755) state directories directly.
|
||||
async fn mint_and_persist_agent_token(name: &str) -> Result<()> {
|
||||
let token = mint_token(name, TOKEN_SCOPES).await?;
|
||||
crate::priv_client::write_agent_forge_token(name, &token)
|
||||
.await
|
||||
.with_context(|| format!("write forge-token for {name} via hive-priv"))
|
||||
}
|
||||
|
||||
/// Mint a fresh Forgejo access token for the `core` admin user and
|
||||
/// write it directly to `path`. Unlike agent tokens this path is owned
|
||||
/// by hive-c0re itself (under `/var/lib/hyperhive/`), so a direct
|
||||
|
|
@ -376,24 +366,11 @@ async fn mint_and_persist_core_token(path: &Path) -> Result<()> {
|
|||
Ok(())
|
||||
}
|
||||
|
||||
/// Ensure `name` has a forgejo user + token file. Always re-mints the
|
||||
/// token so the on-disk file always reflects the current `TOKEN_SCOPES`.
|
||||
/// Safe to call on every spawn and on every hive-c0re startup.
|
||||
pub async fn ensure_user_for(name: &str) -> Result<()> {
|
||||
if !is_present().await {
|
||||
return Ok(());
|
||||
}
|
||||
ensure_user_exists(name, false, None).await?;
|
||||
ensure_user_email(name).await;
|
||||
mint_and_persist_agent_token(name).await
|
||||
}
|
||||
|
||||
/// Provision a forgejo user for `name` and return the freshly-minted
|
||||
/// token. Unlike [`ensure_user_for`], the token is **not** persisted to
|
||||
/// disk — the caller is responsible for storing it. Used by `hivectl
|
||||
/// forge create-user` for human (non-agent) accounts so we don't create
|
||||
/// stray `/var/lib/hyperhive/agents/<name>/` directories for users that
|
||||
/// aren't agents.
|
||||
/// token, which is **not** persisted to disk — the caller is responsible
|
||||
/// for storing it. Used by `hivectl forge create-user` for human
|
||||
/// (non-agent) accounts. Agent tokens are minted by swarm-controller
|
||||
/// (`swarm-controller/src/forge/agent_token.rs`), not here.
|
||||
///
|
||||
/// `password` picks the account password. `None` keeps the existing
|
||||
/// random-throwaway shape (caller doesn't need web UI access — token
|
||||
|
|
|
|||
|
|
@ -642,7 +642,7 @@ const FORWARDED_VARS: &[&str] = &[
|
|||
/// Map of forwarded env var -> the agent option carrying the same value.
|
||||
///
|
||||
/// Both exist because they're consumed at different times: the option is baked
|
||||
/// into scripts at build time (tea-login bakes `FORGE_URL` from it), the env
|
||||
/// into scripts at build time (forge-avatar-sync bakes `FORGE_URL` from it), the env
|
||||
/// var is read at runtime. Setting only one leaves the other on its default,
|
||||
/// which is how a hive ends up with two disagreeing answers for one value.
|
||||
///
|
||||
|
|
@ -1242,7 +1242,7 @@ where
|
|||
# resolve the agent's durable state dir without hard-coding it.
|
||||
# `environment.variables` only writes /etc/environment (login
|
||||
# shells); `systemd.globalEnvironment` is the analogue for
|
||||
# systemd units so tea-login / forge-avatar-sync /
|
||||
# systemd units so forge-avatar-sync /
|
||||
# hive-matrix-daemon etc. can read `$HYPERHIVE_STATE_DIR`
|
||||
# without each service having to redeclare it.
|
||||
environment.variables = {
|
||||
|
|
@ -1259,7 +1259,7 @@ where
|
|||
// Forwarded vars (HIVE_FORGE_URL etc.) also go into globalEnvironment,
|
||||
// not just the harness service env below, so EVERY service + shell in
|
||||
// the container inherits them — crucially the bash-task runner (where
|
||||
// `hive-forge` + `git` actually run), plus the matrix daemon, tea-login
|
||||
// `hive-forge` + `git` actually run), plus the matrix daemon, forge-avatar-sync
|
||||
// and interactive shells. Scoped to the harness service alone they were
|
||||
// invisible to bash tasks: harmless in shared netns (the localhost
|
||||
// default works) but broken under isolation, where the in-cluster
|
||||
|
|
@ -2081,7 +2081,7 @@ mod tests {
|
|||
fn render_flake_sets_service_url_options_from_forwarded_env() {
|
||||
// The forwarded env vars must ALSO become option assignments, because
|
||||
// the two are consumed at different times: the option is baked into
|
||||
// scripts at build time (tea-login's FORGE_URL), the env var is read at
|
||||
// scripts at build time (forge-avatar-sync's FORGE_URL), the env var is read at
|
||||
// runtime. Emitting only the env var leaves the option on its default,
|
||||
// which is how a hive ends up with two disagreeing answers for the same
|
||||
// URL.
|
||||
|
|
|
|||
|
|
@ -361,18 +361,6 @@ pub async fn set_agent_paused(agent_name: &str, paused: bool) -> Result<()> {
|
|||
.await?)
|
||||
}
|
||||
|
||||
/// Write the Forgejo access token for `agent_name` to
|
||||
/// `<agent_state_root>/<agent_name>/state/forge-token` via hive-priv
|
||||
/// (running as root). The file is written 0600 and chowned to the agent
|
||||
/// user so it is readable from inside the agent container.
|
||||
pub async fn write_agent_forge_token(agent_name: &str, token: &str) -> Result<()> {
|
||||
ok(call(&PrivRequest::WriteAgentForgeToken {
|
||||
agent_name: agent_name.to_owned(),
|
||||
token: token.to_owned(),
|
||||
})
|
||||
.await?)
|
||||
}
|
||||
|
||||
/// Write a Matrix access token for `agent_name` via hive-priv (running as
|
||||
/// root). `account: None` writes the hive-internal
|
||||
/// `<state>/matrix-token`; `account: Some(name)` writes
|
||||
|
|
|
|||
|
|
@ -600,36 +600,30 @@ async fn handle_forge_create_user(
|
|||
"hive-forge container not running — wait for hive-c0re to start it before provisioning forge users"
|
||||
);
|
||||
}
|
||||
let mut out = Vec::new();
|
||||
if agent_exists(name)? {
|
||||
if password.is_some() {
|
||||
// Agents authenticate by API token, never by password — refuse
|
||||
// rather than silently dropping a supplied one.
|
||||
anyhow::bail!(
|
||||
"forge create-user: a password is for non-agent (operator) accounts only; '{name}' is an agent which authenticates via API token"
|
||||
);
|
||||
}
|
||||
crate::forge::ensure_user_for(name.as_str())
|
||||
.await
|
||||
.with_context(|| format!("forge create-user {name}"))?;
|
||||
let path = Coordinator::agent_notes_dir(name).join("forge-token");
|
||||
out.push(format!("forge: provisioned agent user '{name}'"));
|
||||
out.push(format!("token persisted at: {}", path.display()));
|
||||
// An agent's forge user and token are swarm-controller's: it mints
|
||||
// the token into the swarm secret store and the agent fetches it
|
||||
// from there. A hive-minted token would be one the swarm neither
|
||||
// tracks nor rotates.
|
||||
anyhow::bail!(
|
||||
"forge create-user: '{name}' is an agent; its forge token is minted by \
|
||||
swarm-controller — run `swarmctl agent mint-forge-token {name}` on the \
|
||||
controller host"
|
||||
);
|
||||
}
|
||||
let token = crate::forge::provision_user_token(name.as_str(), password)
|
||||
.await
|
||||
.with_context(|| format!("forge create-user {name}"))?;
|
||||
let mut out = vec![
|
||||
format!("forge: provisioned user '{name}' (not an agent — token not persisted)"),
|
||||
format!("token: {token}"),
|
||||
];
|
||||
if password.is_some() {
|
||||
out.push("password: set as supplied — use it to log into the forge web UI".to_owned());
|
||||
} else {
|
||||
let token = crate::forge::provision_user_token(name.as_str(), password)
|
||||
.await
|
||||
.with_context(|| format!("forge create-user {name}"))?;
|
||||
out.push(format!(
|
||||
"forge: provisioned user '{name}' (not an agent — token not persisted)"
|
||||
));
|
||||
out.push(format!("token: {token}"));
|
||||
if password.is_some() {
|
||||
out.push("password: set as supplied — use it to log into the forge web UI".to_owned());
|
||||
} else {
|
||||
out.push(
|
||||
"password: random throwaway (not surfaced — pass --password or --password-stdin to set one you can use)".to_owned(),
|
||||
);
|
||||
}
|
||||
out.push(
|
||||
"password: random throwaway (not surfaced — pass --password or --password-stdin to set one you can use)".to_owned(),
|
||||
);
|
||||
}
|
||||
Ok(HostResponse::messages(out))
|
||||
}
|
||||
|
|
|
|||
|
|
@ -37,23 +37,17 @@ async fn forgejo_loop(state_dir: String, socket: std::path::PathBuf) {
|
|||
}
|
||||
};
|
||||
|
||||
let token_path = format!("{state_dir}/forge-token");
|
||||
// Retry reading the token to handle races where hive-priv provisions
|
||||
// it after the harness starts, or where a parent-container chown
|
||||
// briefly makes the file unreadable.
|
||||
let token = {
|
||||
let token_paths = hive_forge_notify::forge_token_paths(&state_dir);
|
||||
// Retry reading the token to handle races where the agent's fetch of
|
||||
// it from the swarm secret store lands after this unit starts, or where
|
||||
// a parent-container chown briefly makes the file unreadable.
|
||||
let mut token = {
|
||||
let mut attempts = 0u32;
|
||||
loop {
|
||||
match tokio::fs::read_to_string(&token_path).await {
|
||||
Ok(t) => {
|
||||
let t = t.trim().to_owned();
|
||||
if !t.is_empty() {
|
||||
break t;
|
||||
}
|
||||
debug!("forge_notify: empty forge token at {token_path}");
|
||||
}
|
||||
Err(e) => debug!("forge_notify: cannot read token at {token_path}: {e}"),
|
||||
if let Some(t) = hive_forge_notify::read_first_token(&token_paths) {
|
||||
break t;
|
||||
}
|
||||
debug!(?token_paths, "forge_notify: no forge token yet");
|
||||
attempts += 1;
|
||||
if attempts >= TOKEN_RETRY_MAX {
|
||||
debug!(
|
||||
|
|
@ -65,7 +59,7 @@ async fn forgejo_loop(state_dir: String, socket: std::path::PathBuf) {
|
|||
}
|
||||
};
|
||||
|
||||
let Some(source) = ForgejoSource::new(&forge_url, &token) else {
|
||||
let Some(mut source) = ForgejoSource::new(&forge_url, &token) else {
|
||||
return;
|
||||
};
|
||||
|
||||
|
|
@ -111,6 +105,18 @@ async fn forgejo_loop(state_dir: String, socket: std::path::PathBuf) {
|
|||
|
||||
loop {
|
||||
interval.tick().await;
|
||||
// The swarm rotates the token when it goes stale, and the agent's
|
||||
// fetch replaces the file; pick the new one up rather than polling
|
||||
// with a revoked token for the rest of this process's life.
|
||||
if let Some(t) = hive_forge_notify::read_first_token(&token_paths)
|
||||
&& t != token
|
||||
&& let Some(s) = ForgejoSource::new(&forge_url, &t)
|
||||
{
|
||||
info!("forge_notify: forge token changed on disk; using the new one");
|
||||
token = t;
|
||||
source = s;
|
||||
own_login.clear();
|
||||
}
|
||||
if own_login.is_empty() {
|
||||
own_login = resolve_own_login(&client, &source).await;
|
||||
if !own_login.is_empty() {
|
||||
|
|
|
|||
|
|
@ -70,3 +70,76 @@ pub fn init_tracing() {
|
|||
pub fn state_dir() -> String {
|
||||
std::env::var("HYPERHIVE_STATE_DIR").unwrap_or_default()
|
||||
}
|
||||
|
||||
/// Where this agent's forge token is read from, first match wins:
|
||||
/// `HIVE_FORGE_TOKEN_FILE` (the copy the agent fetched from the swarm secret
|
||||
/// store, set by `nix/agent-modules/forge-token.nix`), then
|
||||
/// `<state_dir>/forge-token` (the file the hive used to write, still the only
|
||||
/// copy on an agent without a store identity).
|
||||
#[must_use]
|
||||
pub fn forge_token_paths(state_dir: &str) -> Vec<std::path::PathBuf> {
|
||||
let mut paths = Vec::with_capacity(2);
|
||||
if let Ok(fetched) = std::env::var("HIVE_FORGE_TOKEN_FILE")
|
||||
&& !fetched.is_empty()
|
||||
{
|
||||
paths.push(std::path::PathBuf::from(fetched));
|
||||
}
|
||||
paths.push(std::path::Path::new(state_dir).join("forge-token"));
|
||||
paths
|
||||
}
|
||||
|
||||
/// The first non-empty token among `paths`, trimmed. `None` when none of
|
||||
/// them holds one.
|
||||
#[must_use]
|
||||
pub fn read_first_token(paths: &[std::path::PathBuf]) -> Option<String> {
|
||||
paths.iter().find_map(|p| {
|
||||
let t = std::fs::read_to_string(p).ok()?;
|
||||
let t = t.trim();
|
||||
(!t.is_empty()).then(|| t.to_owned())
|
||||
})
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod token_tests {
|
||||
use super::read_first_token;
|
||||
|
||||
fn scratch(tag: &str) -> std::path::PathBuf {
|
||||
let ts = std::time::SystemTime::now()
|
||||
.duration_since(std::time::UNIX_EPOCH)
|
||||
.map_or(0, |d| d.as_nanos());
|
||||
let dir = std::env::temp_dir().join(format!("hive-forge-notify-token-{tag}-{ts}"));
|
||||
std::fs::create_dir_all(&dir).expect("create scratch dir");
|
||||
dir
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_fetched_token_wins_over_the_state_file() {
|
||||
let dir = scratch("wins");
|
||||
let (fetched, state) = (dir.join("fetched"), dir.join("forge-token"));
|
||||
std::fs::write(&fetched, "new\n").expect("write");
|
||||
std::fs::write(&state, "old\n").expect("write");
|
||||
assert_eq!(read_first_token(&[fetched, state]).as_deref(), Some("new"));
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_missing_or_empty_fetched_token_falls_back_to_the_state_file() {
|
||||
let dir = scratch("fallback");
|
||||
let (fetched, state) = (dir.join("fetched"), dir.join("forge-token"));
|
||||
std::fs::write(&state, "old\n").expect("write");
|
||||
assert_eq!(
|
||||
read_first_token(&[fetched.clone(), state.clone()]).as_deref(),
|
||||
Some("old")
|
||||
);
|
||||
std::fs::write(&fetched, "\n").expect("write");
|
||||
assert_eq!(read_first_token(&[fetched, state]).as_deref(), Some("old"));
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn no_token_anywhere_is_none() {
|
||||
let dir = scratch("none");
|
||||
assert_eq!(read_first_token(&[dir.join("a"), dir.join("b")]), None);
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,5 +1,6 @@
|
|||
//! App-level Forgejo client wrapper. Identity is the per-agent token
|
||||
//! under `${HYPERHIVE_STATE_DIR}/forge-token`. REST calls go through
|
||||
//! App-level Forgejo client wrapper. Identity is the per-agent token at
|
||||
//! `$HIVE_FORGE_TOKEN_FILE`, else `${HYPERHIVE_STATE_DIR}/forge-token`
|
||||
//! (see `read_token`). REST calls go through
|
||||
//! the typed [`forgejo_api::sync::Forgejo`] client (exposed via
|
||||
//! [`Client::api`]); a minimal raw `reqwest` client remains for the
|
||||
//! few *web-router* routes Forgejo does not serve under `/api/v1/`
|
||||
|
|
@ -462,9 +463,20 @@ fn state_dir() -> PathBuf {
|
|||
}
|
||||
}
|
||||
|
||||
/// Locate and read the forge token. Falls back to `$PWD/forge-token`
|
||||
/// when `HYPERHIVE_STATE_DIR` isn't set, matching the bash helper.
|
||||
/// Locate and read the forge token: `HIVE_FORGE_TOKEN_FILE` first (the
|
||||
/// copy the agent fetched from the swarm secret store, set by
|
||||
/// `nix/agent-modules/forge-token.nix`) when it names a non-empty file,
|
||||
/// otherwise `<state dir>/forge-token`, the file the hive used to write.
|
||||
/// The state dir falls back to `$PWD` when `HYPERHIVE_STATE_DIR` isn't
|
||||
/// set, matching the bash helper.
|
||||
fn read_token() -> Result<String> {
|
||||
if let Ok(fetched) = std::env::var("HIVE_FORGE_TOKEN_FILE")
|
||||
&& !fetched.is_empty()
|
||||
&& let Ok(raw) = std::fs::read_to_string(&fetched)
|
||||
&& !raw.trim().is_empty()
|
||||
{
|
||||
return Ok(raw.trim().to_owned());
|
||||
}
|
||||
let path = state_dir().join("forge-token");
|
||||
let raw = std::fs::read_to_string(&path)
|
||||
.with_context(|| format!("hive-forge: no forge-token at {}", path.display()))?;
|
||||
|
|
|
|||
|
|
@ -29,7 +29,23 @@ fn scratch_state_dir(tag: &str) -> PathBuf {
|
|||
/// `state_dir`, feeding `request` as the git credential-protocol
|
||||
/// request body on stdin. Returns `(exit success, stdout, stderr)`.
|
||||
fn run_get(base_url: &str, state_dir: &Path, request: &str) -> (bool, String, String) {
|
||||
let mut child = Command::new(env!("CARGO_BIN_EXE_hive-forge"))
|
||||
run_get_with(base_url, state_dir, None, request)
|
||||
}
|
||||
|
||||
/// [`run_get`], with `HIVE_FORGE_TOKEN_FILE` set to `fetched` when given and
|
||||
/// removed otherwise, so the caller's own environment never leaks in.
|
||||
fn run_get_with(
|
||||
base_url: &str,
|
||||
state_dir: &Path,
|
||||
fetched: Option<&Path>,
|
||||
request: &str,
|
||||
) -> (bool, String, String) {
|
||||
let mut cmd = Command::new(env!("CARGO_BIN_EXE_hive-forge"));
|
||||
match fetched {
|
||||
Some(p) => cmd.env("HIVE_FORGE_TOKEN_FILE", p),
|
||||
None => cmd.env_remove("HIVE_FORGE_TOKEN_FILE"),
|
||||
};
|
||||
let mut child = cmd
|
||||
.arg("credential-helper")
|
||||
.arg("get")
|
||||
.env("HIVE_FORGE_URL", base_url)
|
||||
|
|
@ -111,3 +127,46 @@ fn credential_helper_get_still_works_with_no_host_line() {
|
|||
);
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
|
||||
/// The token the agent fetched from the swarm secret store wins over the
|
||||
/// state-dir file the hive used to write, so a rotated token takes effect
|
||||
/// even while the old file is still on disk.
|
||||
#[test]
|
||||
fn the_fetched_token_wins_over_the_state_file() {
|
||||
let dir = scratch_state_dir("fetched");
|
||||
let fetched = dir.join("fetched-token");
|
||||
std::fs::write(&fetched, "fetched-token-value\n").expect("seed fetched token");
|
||||
let (ok, stdout, stderr) = run_get_with(
|
||||
"http://forge.internal.example",
|
||||
&dir,
|
||||
Some(&fetched),
|
||||
"protocol=https\nhost=forge.internal.example\n",
|
||||
);
|
||||
assert!(ok, "stderr={stderr}");
|
||||
assert!(
|
||||
stdout.contains("password=fetched-token-value"),
|
||||
"stdout={stdout}"
|
||||
);
|
||||
assert!(!stdout.contains(TOKEN), "stdout={stdout}");
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
|
||||
/// An agent whose token was not minted yet has the variable set and no file
|
||||
/// behind it; it keeps working off the state-dir file.
|
||||
#[test]
|
||||
fn a_fetched_token_that_is_not_there_falls_back_to_the_state_file() {
|
||||
let dir = scratch_state_dir("fallback");
|
||||
let missing = dir.join("never-fetched");
|
||||
let (ok, stdout, stderr) = run_get_with(
|
||||
"http://forge.internal.example",
|
||||
&dir,
|
||||
Some(&missing),
|
||||
"protocol=https\nhost=forge.internal.example\n",
|
||||
);
|
||||
assert!(ok, "stderr={stderr}");
|
||||
assert!(
|
||||
stdout.contains(&format!("password={TOKEN}")),
|
||||
"stdout={stdout}"
|
||||
);
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -316,10 +316,10 @@ pub enum HostRequest {
|
|||
room: Option<String>,
|
||||
},
|
||||
/// Create or refresh a forge account + API token for `name`. Daemon-side
|
||||
/// equivalent of `hivectl forge create-user`: for an existing agent it
|
||||
/// provisions the account and persists the token to `<notes>/forge-token`;
|
||||
/// for a non-agent (operator/human) it mints a user and returns the token
|
||||
/// in [`HostResponse::messages`]. `password` is resolved client-side
|
||||
/// equivalent of `hivectl forge create-user`: for a non-agent
|
||||
/// (operator/human) it mints a user and returns the token in
|
||||
/// [`HostResponse::messages`]. An existing agent is refused: its token is
|
||||
/// swarm-controller's to mint. `password` is resolved client-side
|
||||
/// (inline flag or stdin) and only meaningful for non-agent accounts.
|
||||
ForgeCreateUser {
|
||||
name: Ident,
|
||||
|
|
|
|||
|
|
@ -468,19 +468,6 @@ pub enum PrivRequest {
|
|||
},
|
||||
|
||||
// --- Agent credential writes ---
|
||||
/// Write `forge-token` into `AGENT_STATE_ROOT/<agent_name>/state/forge-token`.
|
||||
///
|
||||
/// hive-priv validates `agent_name`, creates the state dir if absent,
|
||||
/// writes the file 0600, and chowns it to the state dir's owner so
|
||||
/// the agent process can read it. Required because hive-c0re runs
|
||||
/// unprivileged and cannot write to agent-owned state directories.
|
||||
WriteAgentForgeToken {
|
||||
/// Logical agent name (validated by `validate_agent_name`).
|
||||
agent_name: String,
|
||||
/// Token value. hive-priv appends a trailing newline before writing.
|
||||
token: String,
|
||||
},
|
||||
|
||||
/// Write a matrix access token into the agent's state dir. With
|
||||
/// `account: None` it targets the hive-internal `matrix-token`; with
|
||||
/// `account: Some(name)` it targets `matrix-token-<name>` for an extra
|
||||
|
|
@ -488,8 +475,10 @@ pub enum PrivRequest {
|
|||
/// `account` suffix as plain identifiers before building the path, so a
|
||||
/// crafted account name cannot traverse out of the state dir.
|
||||
///
|
||||
/// Same write semantics as `WriteAgentForgeToken` — validates names,
|
||||
/// creates dir, writes 0600, chowns to agent owner.
|
||||
/// hive-priv validates the names, creates the state dir if absent,
|
||||
/// writes the file 0600, and chowns it to the state dir's owner so the
|
||||
/// agent process can read it. Required because hive-c0re runs
|
||||
/// unprivileged and cannot write to agent-owned state directories.
|
||||
WriteAgentMatrixToken {
|
||||
/// Logical agent name (validated by `validate_agent_name`).
|
||||
agent_name: String,
|
||||
|
|
@ -512,7 +501,7 @@ pub enum PrivRequest {
|
|||
/// The operator-supplied GitHub personal access token (PAT) for the
|
||||
/// agent's GitHub integration (`services.hyperhive.agent.github.enable`). Same write
|
||||
/// semantics as
|
||||
/// `WriteAgentForgeToken` — validates `agent_name`, creates the state dir
|
||||
/// `WriteAgentMatrixToken` — validates `agent_name`, creates the state dir
|
||||
/// if absent, writes the file 0600, and chowns it to the agent so the
|
||||
/// `gh` wrapper / git credential helper can read it. No account suffix
|
||||
/// (single GitHub account per agent).
|
||||
|
|
@ -534,7 +523,7 @@ pub enum PrivRequest {
|
|||
/// `label` MUST be validated as a plain identifier (same rule as the
|
||||
/// matrix `account` suffix) before it goes into the filename — a
|
||||
/// crafted label could otherwise traverse out of the state dir. Same
|
||||
/// write semantics as `WriteAgentForgeToken` — validates `agent_name`,
|
||||
/// write semantics as `WriteAgentMatrixToken` — validates `agent_name`,
|
||||
/// creates the state dir if absent, writes both files 0600, chowns to
|
||||
/// the agent.
|
||||
WriteAgentExtraForgeAccount {
|
||||
|
|
|
|||
|
|
@ -403,11 +403,6 @@ async fn exec(
|
|||
paused,
|
||||
} => exec_set_agent_paused(agent_name, paused),
|
||||
|
||||
PrivRequest::WriteAgentForgeToken {
|
||||
ref agent_name,
|
||||
ref token,
|
||||
} => write_forge_token(agent_name, token),
|
||||
|
||||
PrivRequest::WriteAgentMatrixToken {
|
||||
ref agent_name,
|
||||
ref token,
|
||||
|
|
@ -619,12 +614,6 @@ fn exec_set_agent_paused(agent_name: &str, paused: bool) -> Result<(String, Stri
|
|||
set_agent_paused(agent_name, paused)
|
||||
}
|
||||
|
||||
/// `WriteAgentForgeToken`.
|
||||
fn write_forge_token(agent_name: &str, token: &str) -> Result<(String, String)> {
|
||||
validate_agent_name(agent_name)?;
|
||||
write_agent_state_file(agent_name, "forge-token", &format!("{token}\n"))
|
||||
}
|
||||
|
||||
/// `WriteAgentGithubToken`.
|
||||
fn write_github_token(agent_name: &str, token: &str) -> Result<(String, String)> {
|
||||
validate_agent_name(agent_name)?;
|
||||
|
|
@ -1441,7 +1430,7 @@ struct ForgeSidecar<'a> {
|
|||
base_url: &'a str,
|
||||
}
|
||||
|
||||
/// Shared helper for `WriteAgentForgeToken` and `WriteAgentMatrixToken`.
|
||||
/// Shared helper for the `WriteAgent*Token` requests.
|
||||
/// Writes `content` to `AGENT_STATE_ROOT/<agent_name>/state/<filename>`,
|
||||
/// chowns to the agent user (derived from the state dir's existing owner),
|
||||
/// and chmods 0600. Running as root (hive-priv), so this succeeds
|
||||
|
|
|
|||
|
|
@ -248,15 +248,15 @@ impl ScopeArgs {
|
|||
|
||||
#[derive(Subcommand)]
|
||||
pub enum ForgeCmd {
|
||||
/// Create or refresh the Forgejo account + token for `<name>`.
|
||||
/// Create or refresh a non-agent Forgejo account + token for `<name>`.
|
||||
///
|
||||
/// For an existing agent, persists the token to its state dir; for a
|
||||
/// human/other account, prints the token to stdout. Set a password to
|
||||
/// enable forge web-UI login (otherwise it uses a random throwaway).
|
||||
/// Prints the token to stdout. Set a password to enable forge web-UI
|
||||
/// login (otherwise it uses a random throwaway). Refused for an
|
||||
/// existing agent: swarm-controller mints an agent's token
|
||||
/// (`swarmctl agent mint-forge-token <agent>`).
|
||||
CreateUser {
|
||||
/// Forgejo username. For agents: the container/agent name
|
||||
/// (`<n>` in `h-<n>`; manager uses the literal `manager`).
|
||||
/// For humans: any forgejo username — `mara`, `damocles`, etc.
|
||||
/// Forgejo username of a human/other account — `mara`,
|
||||
/// `damocles`, etc.
|
||||
name: String,
|
||||
/// Set the account password to this string instead of a random
|
||||
/// throwaway. Use this for operator accounts that need to log
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
//! `hivectl forge create-user <name>` — provision a Forgejo account via the
|
||||
//! daemon (which owns the forge admin token + account-token persistence);
|
||||
//! daemon (which owns the forge admin token);
|
||||
//! hivectl just resolves the password client-side and relays the request.
|
||||
|
||||
use std::io::{self, Write};
|
||||
|
|
|
|||
|
|
@ -46,6 +46,7 @@ in
|
|||
./dashboard-links.nix
|
||||
./docs.nix
|
||||
./forge.nix
|
||||
./forge-token.nix
|
||||
./frontend.nix
|
||||
./github.nix
|
||||
./logs.nix
|
||||
|
|
|
|||
182
nix/agent-modules/forge-token.nix
Normal file
182
nix/agent-modules/forge-token.nix
Normal file
|
|
@ -0,0 +1,182 @@
|
|||
# This agent's own forge access token, fetched from the swarm secret store by
|
||||
# the agent itself.
|
||||
#
|
||||
# `swarm-controller` mints the token with the forge's admin API and writes it
|
||||
# to `swarm/agents/<agent>/forge-token`
|
||||
# (`swarm_secret_client::forge::agent_token_path`); no hive is in that chain.
|
||||
# This unit logs in to the store with the certificate ./bao.nix already proves
|
||||
# it can log in with, and reads its own path. The shape is ./queue-identity.nix's,
|
||||
# for the same reason: a hive handing the token over would be the hive reading
|
||||
# a secret on the agent's behalf.
|
||||
#
|
||||
# Unlike the queue credential this one rotates: the controller replaces the
|
||||
# token when the forge's copy stops matching the stored one. So the unit is
|
||||
# re-run by a timer, and it swaps the file in by rename, only when the value
|
||||
# changed, so a reader never sees half a token and a watcher on the file
|
||||
# (forge-avatar-sync.path) fires only on a real change.
|
||||
#
|
||||
# Consumers read `services.hyperhive.agent.forge.tokenFile` first and fall back
|
||||
# to `<state>/forge-token`, the file the hive wrote before this existed.
|
||||
{
|
||||
pkgs,
|
||||
lib,
|
||||
config,
|
||||
...
|
||||
}:
|
||||
let
|
||||
cfg = config.services.hyperhive.agent.bao;
|
||||
|
||||
# The name `swarm-controller` minted the token under — see ./queue-identity.nix.
|
||||
agentName = config.services.hyperhive.agent.user.name;
|
||||
|
||||
# The same three ids ./bao.nix and ./queue-identity.nix load.
|
||||
certCredential = "hive-agent-bao-cert";
|
||||
keyCredential = "hive-agent-bao-key";
|
||||
serverCaCredential = "hive-agent-bao-server-ca";
|
||||
|
||||
unitName = "hive-agent-forge-token";
|
||||
|
||||
# The nix half of `swarm_secret_client::forge::agent_token_path` plus
|
||||
# `path::MOUNT`.
|
||||
tokenPath = "secret/swarm/agents/${agentName}/forge-token";
|
||||
|
||||
runtimeDir = unitName;
|
||||
tokenFile = "/run/${runtimeDir}/token";
|
||||
# Beside the token, so the rename that replaces it stays in one directory.
|
||||
stagingFile = "/run/${runtimeDir}/token.new";
|
||||
|
||||
# The store's address is the whole switch, as in ./bao.nix and
|
||||
# ./queue-identity.nix.
|
||||
configured = cfg.addr != null;
|
||||
in
|
||||
{
|
||||
options.services.hyperhive.agent.forge.tokenFile = lib.mkOption {
|
||||
type = lib.types.str;
|
||||
readOnly = true;
|
||||
default = tokenFile;
|
||||
description = ''
|
||||
Path this agent's own forge token is fetched to. Read-only: it is a
|
||||
fact about where `${unitName}.service` writes, not a knob.
|
||||
|
||||
🩸 A PATH and never a value. The file is `0400` to the agent user.
|
||||
|
||||
The file exists only once the swarm has minted a token for this agent
|
||||
and the agent has a store identity to fetch it with. Until then every
|
||||
consumer falls back to `$HYPERHIVE_STATE_DIR/forge-token`.
|
||||
'';
|
||||
};
|
||||
|
||||
config = lib.mkIf configured {
|
||||
# Every unit and the bash-task runner (where `hive-forge` and `git` run)
|
||||
# resolve the token through this, the same way they find
|
||||
# `$HYPERHIVE_STATE_DIR`. A path, never the value.
|
||||
systemd.globalEnvironment.HIVE_FORGE_TOKEN_FILE = tokenFile;
|
||||
environment.variables.HIVE_FORGE_TOKEN_FILE = tokenFile;
|
||||
|
||||
systemd.services.${unitName} = {
|
||||
description = "fetch this agent's own forge token from the secret store";
|
||||
after = [
|
||||
"network.target"
|
||||
# Ordering only, for the reason ./queue-identity.nix gives.
|
||||
"hive-agent-bao-identity.service"
|
||||
];
|
||||
before = [ "hive-forge-notify.service" ];
|
||||
wantedBy = [ "multi-user.target" ];
|
||||
path = [
|
||||
pkgs.openbao
|
||||
pkgs.coreutils
|
||||
pkgs.diffutils
|
||||
];
|
||||
startLimitBurst = 4;
|
||||
startLimitIntervalSec = 300;
|
||||
serviceConfig = {
|
||||
Type = "oneshot";
|
||||
# Not `RemainAfterExit`, unlike the queue fetch: the timer below has to
|
||||
# be able to start this unit again, and an active unit cannot be
|
||||
# started. `RuntimeDirectoryPreserve` is what keeps the directory, and
|
||||
# the token in it, alive between runs instead.
|
||||
RemainAfterExit = false;
|
||||
TimeoutStartSec = 30;
|
||||
Restart = "on-failure";
|
||||
RestartSec = 15;
|
||||
User = agentName;
|
||||
Group = agentName;
|
||||
RuntimeDirectory = runtimeDir;
|
||||
RuntimeDirectoryMode = "0700";
|
||||
RuntimeDirectoryPreserve = "yes";
|
||||
UMask = "0377";
|
||||
LoadCredential = [
|
||||
certCredential
|
||||
keyCredential
|
||||
serverCaCredential
|
||||
];
|
||||
};
|
||||
environment = {
|
||||
BAO_ADDR = cfg.addr;
|
||||
BAO_CLIENT_CERT = "%d/${certCredential}";
|
||||
BAO_CLIENT_KEY = "%d/${keyCredential}";
|
||||
};
|
||||
script = ''
|
||||
set -euo pipefail
|
||||
|
||||
# No identity delivered: ./bao.nix's check reports that; saying it
|
||||
# twice adds nothing.
|
||||
for id in ${lib.escapeShellArg certCredential} ${lib.escapeShellArg keyCredential}; do
|
||||
if [ ! -s "$CREDENTIALS_DIRECTORY/$id" ]; then
|
||||
echo "this agent has no store identity, so it cannot fetch its own forge token." >&2
|
||||
exit 0
|
||||
fi
|
||||
done
|
||||
|
||||
if [ -s "$CREDENTIALS_DIRECTORY/${serverCaCredential}" ]; then
|
||||
export BAO_CACERT="$CREDENTIALS_DIRECTORY/${serverCaCredential}"
|
||||
fi
|
||||
|
||||
err="$(mktemp)"
|
||||
trap 'rm -f "$err" ${lib.escapeShellArg stagingFile}' EXIT
|
||||
|
||||
if ! BAO_TOKEN="$(bao login -method=cert -token-only 2>"$err")"; then
|
||||
echo "this agent's certificate was refused by the swarm secret store at $BAO_ADDR." >&2
|
||||
if [ -s "$err" ]; then cat "$err" >&2; fi
|
||||
exit 1
|
||||
fi
|
||||
export BAO_TOKEN
|
||||
|
||||
# 🩸 Degrades rather than fails, for the reason ./queue-identity.nix
|
||||
# gives: the policy stanza that let the login read `bao-mtls` covers
|
||||
# this path too, so a refusal here is a token not minted yet. The
|
||||
# file already in place, if any, is kept: a store that is briefly
|
||||
# unreachable must not take a working token away.
|
||||
#
|
||||
# ⚠️ Written by redirect, never echoed: the field is the secret. The
|
||||
# staging file is removed first so the redirect creates it — a file
|
||||
# `UMask=0377` left behind is `0400` and could not be reopened for
|
||||
# writing.
|
||||
rm -f ${lib.escapeShellArg stagingFile}
|
||||
if ! bao kv get -field=value ${lib.escapeShellArg tokenPath} > ${lib.escapeShellArg stagingFile} 2>"$err"; then
|
||||
echo "no forge token at ${tokenPath} yet; consumers keep using the state-dir token if there is one." >&2
|
||||
if [ -s "$err" ]; then cat "$err" >&2; fi
|
||||
exit 0
|
||||
fi
|
||||
|
||||
if cmp -s ${lib.escapeShellArg stagingFile} ${lib.escapeShellArg tokenFile}; then
|
||||
echo "this agent's forge token at ${tokenPath} is unchanged."
|
||||
exit 0
|
||||
fi
|
||||
mv -f ${lib.escapeShellArg stagingFile} ${lib.escapeShellArg tokenFile}
|
||||
echo "fetched this agent's forge token from ${tokenPath}."
|
||||
'';
|
||||
};
|
||||
|
||||
# The controller re-checks every agent's token every five minutes; this
|
||||
# picks up a rotation within about ten more.
|
||||
systemd.timers.${unitName} = {
|
||||
description = "re-fetch this agent's forge token from the secret store";
|
||||
wantedBy = [ "timers.target" ];
|
||||
timerConfig = {
|
||||
OnUnitInactiveSec = "10min";
|
||||
RandomizedDelaySec = "1min";
|
||||
};
|
||||
};
|
||||
};
|
||||
}
|
||||
|
|
@ -1,6 +1,6 @@
|
|||
# In-container forge (Forgejo) integration: the `tea` CLI login
|
||||
# oneshot, the `hive-forge` verb CLI on PATH, and the icon → forge
|
||||
# avatar sync.
|
||||
# In-container forge (Forgejo) integration: the `hive-forge` verb CLI on
|
||||
# PATH, the git credential helper, the notification poller, and the icon →
|
||||
# forge avatar sync. The token itself is fetched by ./forge-token.nix.
|
||||
{
|
||||
pkgs,
|
||||
lib,
|
||||
|
|
@ -9,7 +9,18 @@
|
|||
}:
|
||||
let
|
||||
userName = config.services.hyperhive.agent.user.name;
|
||||
homeDir = "/home/${userName}";
|
||||
# The token the agent fetched from the swarm secret store
|
||||
# (./forge-token.nix), then the state-dir file the hive used to write,
|
||||
# which is still the only copy for an agent without a store identity.
|
||||
# Both are PATHS; each reader below takes the first that holds a token.
|
||||
fetchedTokenFile = config.services.hyperhive.agent.forge.tokenFile;
|
||||
stateTokenFile = "/agents/${userName}/state/forge-token";
|
||||
pickTokenFile = ''
|
||||
TOKEN_FILE=
|
||||
for f in ${lib.escapeShellArg fetchedTokenFile} ${lib.escapeShellArg stateTokenFile}; do
|
||||
if [ -s "$f" ] && [ -r "$f" ]; then TOKEN_FILE="$f"; break; fi
|
||||
done
|
||||
'';
|
||||
# Same 512×512 rasterization of the agent icon the matrix avatar
|
||||
# sync uses (./matrix.nix — identical derivation, same store path).
|
||||
# Only forced when an icon is configured AND a forge is (the avatar-sync
|
||||
|
|
@ -20,7 +31,7 @@ let
|
|||
|
||||
# git credential helper for the hive forge --- the exact shape
|
||||
# `./github.nix` uses for github.com, for the same two reasons: the token
|
||||
# is read from the agent's state file AT INVOCATION (so a re-issued token
|
||||
# is read from its file AT INVOCATION (so a re-issued token
|
||||
# takes effect with no rebuild), and the token PATH is baked in at build
|
||||
# time rather than read from the environment, because claude's Bash tool
|
||||
# runs `bash -c` in a minimal env that never sources `/etc/set-environment`.
|
||||
|
|
@ -32,8 +43,8 @@ let
|
|||
gitCredHelper = pkgs.writeShellScriptBin "git-credential-hive-forge" ''
|
||||
# git credential-helper protocol: only `get` needs an answer.
|
||||
[ "''${1:-}" = "get" ] || exit 0
|
||||
TOKEN_FILE="/agents/${userName}/state/forge-token"
|
||||
[ -r "$TOKEN_FILE" ] || exit 0
|
||||
${pickTokenFile}
|
||||
[ -n "$TOKEN_FILE" ] || exit 0
|
||||
printf 'username=%s\n' ${lib.escapeShellArg userName}
|
||||
printf 'password=%s\n' "$(cat "$TOKEN_FILE")"
|
||||
'';
|
||||
|
|
@ -44,20 +55,16 @@ in
|
|||
default = null;
|
||||
example = "http://forge.internal:3000";
|
||||
description = ''
|
||||
Base URL of the hyperhive-managed Forgejo. Used at container
|
||||
boot by a oneshot systemd unit that calls
|
||||
`tea login add --url <this> --token "$(cat $HYPERHIVE_STATE_DIR/forge-token)"`
|
||||
(= `/agents/<name>/state/forge-token`) so the agent's claude can
|
||||
shell out to `tea` without an extra auth dance. No-op when the
|
||||
forge-token file is missing (i.e. hive-forge isn't running on
|
||||
the host).
|
||||
Base URL of the hyperhive-managed Forgejo. Scopes the git
|
||||
credential helper to this forge, and is where the avatar sync
|
||||
uploads the agent's icon.
|
||||
|
||||
**`null` means "no forge", not "guess one".** There is deliberately
|
||||
no loopback default: the forge may run on a different host from
|
||||
the agents, and inside an agent's network namespace `localhost`
|
||||
reaches the agent rather than the forge, so a default would be a
|
||||
value that builds fine and then talks to the wrong machine.
|
||||
When this is `null` the tea-login and avatar-sync units are not
|
||||
When this is `null` the credential helper and avatar-sync units are not
|
||||
generated at all --- an absent integration, never a misdirected
|
||||
one.
|
||||
|
||||
|
|
@ -88,10 +95,6 @@ in
|
|||
];
|
||||
|
||||
environment.systemPackages = [
|
||||
# tea: gitea/forgejo CLI client. Configured at boot by the
|
||||
# tea-login oneshot below if /state/forge-token is present, so
|
||||
# claude can `tea repos create`, `tea pulls create`, etc.
|
||||
pkgs.tea
|
||||
# hive-forge <verb>: CLI wrapping common Forgejo REST API operations
|
||||
# (view, pr, issue, comment, assign, close, labels, branches, etc.).
|
||||
# The per-bin split package — narrow closure, no hivectl/wireguard.
|
||||
|
|
@ -178,94 +181,22 @@ in
|
|||
};
|
||||
};
|
||||
|
||||
# One-shot: tea config.yml from the seeded forge token. Shape
|
||||
# contract (always exit 0, no set -e, skip-silently, re-runnable):
|
||||
# docs/process/conventions.md::Best-effort oneshot services.
|
||||
# Not generated at all when no forge is configured: an absent
|
||||
# integration rather than one pointed at a guessed address.
|
||||
systemd.services.tea-login = lib.mkIf (config.services.hyperhive.agent.forge.url != null) {
|
||||
description = "configure tea CLI from hive-forge token (best-effort)";
|
||||
wantedBy = [ "multi-user.target" ];
|
||||
after = [ "local-fs.target" ];
|
||||
serviceConfig = {
|
||||
Type = "oneshot";
|
||||
RemainAfterExit = true;
|
||||
# Pin the journal identity (else it's the `script` store-path wrapper).
|
||||
SyslogIdentifier = "tea-login";
|
||||
};
|
||||
path = [
|
||||
pkgs.curl
|
||||
pkgs.jq
|
||||
pkgs.coreutils
|
||||
];
|
||||
environment.HOME_DIR = homeDir;
|
||||
environment.AGENT_USER = userName;
|
||||
script = ''
|
||||
# No `set -e`: best-effort posture (see docs pointer above).
|
||||
FORGE_URL=${lib.escapeShellArg config.services.hyperhive.agent.forge.url}
|
||||
# $HYPERHIVE_STATE_DIR is system-wide via the meta flake.
|
||||
TOKEN_FILE="$HYPERHIVE_STATE_DIR/forge-token"
|
||||
if [ ! -f "$TOKEN_FILE" ]; then
|
||||
echo "tea-login: no forge-token at $TOKEN_FILE; skipping"
|
||||
exit 0
|
||||
fi
|
||||
TOKEN=$(cat "$TOKEN_FILE")
|
||||
# Resolve the agent username from the forge API.
|
||||
USER=$(curl -sf --max-time 5 \
|
||||
-H "Authorization: token $TOKEN" \
|
||||
"$FORGE_URL/api/v1/user" \
|
||||
| jq -r '.login // empty' 2>/dev/null || true)
|
||||
if [ -z "$USER" ]; then
|
||||
echo "tea-login: could not resolve username from forge API; skipping"
|
||||
exit 0
|
||||
fi
|
||||
# Config under the agent user's home, chown'd to them;
|
||||
# service stays root-owned (see docs pointer above).
|
||||
CONFIG="$HOME_DIR/.config/tea/config.yml"
|
||||
mkdir -p "$(dirname "$CONFIG")" || true
|
||||
cat > "$CONFIG" << EOF
|
||||
logins:
|
||||
- name: forge
|
||||
url: $FORGE_URL
|
||||
token: $TOKEN
|
||||
default: true
|
||||
ssh_host: ""
|
||||
ssh_key: ""
|
||||
insecure: false
|
||||
ssh_agent: false
|
||||
user: $USER
|
||||
preferences:
|
||||
editor: false
|
||||
flag_defaults:
|
||||
remote: ""
|
||||
EOF
|
||||
chown -R "$AGENT_USER:$AGENT_USER" "$HOME_DIR/.config" 2>/dev/null || true
|
||||
echo "tea-login: configured for $FORGE_URL as $USER (config at $CONFIG)"
|
||||
'';
|
||||
};
|
||||
|
||||
# Path-trigger sibling: re-fires forge-avatar-sync when
|
||||
# `<state>/forge-token` is written. On first agent deployment the
|
||||
# container boots before hive-c0re has provisioned the forge-token, so
|
||||
# the service fires too early and exits with "no forge-token found".
|
||||
# Without this path unit, RemainAfterExit=true would prevent systemd
|
||||
# from ever re-running the service. See
|
||||
# docs/agent-lifecycle/persistence.md::forge-avatar-sync.
|
||||
# Path-trigger sibling: re-fires forge-avatar-sync when the fetched
|
||||
# forge token (./forge-token.nix) appears or is replaced. On first
|
||||
# agent deployment the container can boot before the swarm has minted
|
||||
# the token, so the service fires too early and exits with "no
|
||||
# forge-token found". Without this path unit, nothing would ever
|
||||
# re-run it. See docs/agent-lifecycle/persistence.md::forge-avatar-sync.
|
||||
#
|
||||
# PathChanged=, not PathExists=: a PathExists= condition that already
|
||||
# holds re-activates the unit immediately every time the path unit
|
||||
# re-arms, and a oneshot re-arms it by deactivating — so it loops until
|
||||
# systemd's start limit stops it. PathChanged= does not fire on an
|
||||
# already-present path, and hive-priv writes this file in place
|
||||
# (write_state_file_nofollow: O_TRUNC, no rename), so close-after-write
|
||||
# still triggers it. Same directive and same reason as
|
||||
# swarm-controller.nix's queue-credential watcher.
|
||||
# already-present path, and does fire on the rename the fetch unit
|
||||
# swaps a new token in with. The fetch renames only when the value
|
||||
# changed, so its timer does not re-upload the avatar every tick.
|
||||
#
|
||||
# ⚠️ This agent's own token, not a glob over `/agents/*/`. Every agent's
|
||||
# state dir is visible from inside every container, so a wildcard here
|
||||
# watches paths this unit has no business reacting to.
|
||||
# The service reads `$HYPERHIVE_STATE_DIR/forge-token`; this is the same
|
||||
# file, spelled the way `tea-login` above already spells it.
|
||||
# ⚠️ This agent's own token, not a glob.
|
||||
#
|
||||
# ⚠️ Gated on the SAME condition as the service it triggers, not just on
|
||||
# the icon: a `.path` unit whose `Unit=` does not exist is a unit pulled
|
||||
|
|
@ -278,7 +209,7 @@ in
|
|||
{
|
||||
description = "trigger forge-avatar-sync when forge-token appears";
|
||||
wantedBy = [ "multi-user.target" ];
|
||||
pathConfig.PathChanged = "/agents/${userName}/state/forge-token";
|
||||
pathConfig.PathChanged = fetchedTokenFile;
|
||||
};
|
||||
|
||||
# One-shot: services.hyperhive.agent.icon → Forgejo profile avatar. Shape contract:
|
||||
|
|
@ -295,7 +226,7 @@ in
|
|||
{
|
||||
description = "sync agent icon to Forgejo user avatar (best-effort)";
|
||||
wantedBy = [ "multi-user.target" ];
|
||||
after = [ "tea-login.service" ];
|
||||
after = [ "hive-agent-forge-token.service" ];
|
||||
serviceConfig = {
|
||||
Type = "oneshot";
|
||||
RemainAfterExit = false;
|
||||
|
|
@ -309,10 +240,8 @@ in
|
|||
];
|
||||
script = ''
|
||||
FORGE_URL=${lib.escapeShellArg config.services.hyperhive.agent.forge.url}
|
||||
# $HYPERHIVE_STATE_DIR is set system-wide by the meta flake
|
||||
# (systemd.globalEnvironment) to `/agents/<name>/state`.
|
||||
TOKEN_FILE="$HYPERHIVE_STATE_DIR/forge-token"
|
||||
if [ ! -f "$TOKEN_FILE" ]; then
|
||||
${pickTokenFile}
|
||||
if [ -z "$TOKEN_FILE" ]; then
|
||||
echo "forge-avatar-sync: no forge-token found; skipping"
|
||||
exit 0
|
||||
fi
|
||||
|
|
|
|||
|
|
@ -45,7 +45,7 @@
|
|||
# `/etc/hyperhive-bridge-dns` (containing the gateway IP) since
|
||||
# isolation is always on; the oneshot reads it and rewrites
|
||||
# resolv.conf on every boot. Ordered before the first DNS consumer
|
||||
# (tea-login) and the network targets so name resolution works for
|
||||
# (the forge-token fetch) and the network targets so name resolution works for
|
||||
# the very first turn.
|
||||
systemd.services.hyperhive-isolated-dns = {
|
||||
description = "point resolv.conf at the hive bridge resolver (isolated containers)";
|
||||
|
|
@ -61,7 +61,7 @@
|
|||
# is a harmless no-op when matrix is disabled (the unit is absent).
|
||||
before = [
|
||||
"network-online.target"
|
||||
"tea-login.service"
|
||||
"hive-agent-forge-token.service"
|
||||
"hive-agent.service"
|
||||
"hive-matrix-daemon.service"
|
||||
];
|
||||
|
|
|
|||
|
|
@ -108,6 +108,10 @@ in
|
|||
inherit pkgs self nixosSystem;
|
||||
inherit (pkgs) lib;
|
||||
};
|
||||
module-eval-agent-forge-bao = import ./module-eval/agent-forge-bao.nix {
|
||||
inherit pkgs self nixosSystem;
|
||||
inherit (pkgs) lib;
|
||||
};
|
||||
module-eval-agent-memory = import ./module-eval/agent-memory.nix {
|
||||
inherit pkgs self nixosSystem;
|
||||
inherit (pkgs) lib;
|
||||
|
|
|
|||
169
nix/module-eval/agent-forge-bao.nix
Normal file
169
nix/module-eval/agent-forge-bao.nix
Normal file
|
|
@ -0,0 +1,169 @@
|
|||
# `checks.module-eval-agent-forge-bao` — see ./lib.nix for the shared
|
||||
# rationale (why this suite exists, naming convention, "evaluates
|
||||
# not executes").
|
||||
#
|
||||
# The agent side of the swarm-minted forge token: ../agent-modules/forge-token.nix
|
||||
# fetches it, and ../agent-modules/forge.nix's readers find it.
|
||||
{
|
||||
pkgs,
|
||||
lib,
|
||||
self,
|
||||
nixosSystem,
|
||||
}:
|
||||
let
|
||||
inherit
|
||||
(import ./lib.nix {
|
||||
inherit
|
||||
pkgs
|
||||
lib
|
||||
self
|
||||
nixosSystem
|
||||
;
|
||||
})
|
||||
agentWith
|
||||
runGroup
|
||||
;
|
||||
|
||||
forgeUrl = "http://forge.t.local";
|
||||
baoAddr = "https://bao.t.local:8200";
|
||||
|
||||
# A forge and a store: the fetch exists and every reader points at it.
|
||||
agentForgeBao = agentWith {
|
||||
services.hyperhive.agent.bao.addr = baoAddr;
|
||||
services.hyperhive.agent.forge.url = forgeUrl;
|
||||
services.hyperhive.agent.icon = pkgs.emptyFile;
|
||||
};
|
||||
|
||||
# A forge and no store: the absence arm, and what makes the cases above able
|
||||
# to fail.
|
||||
agentForgeNoBao = agentWith {
|
||||
services.hyperhive.agent.forge.url = forgeUrl;
|
||||
services.hyperhive.agent.icon = pkgs.emptyFile;
|
||||
};
|
||||
|
||||
fetchUnit = machine: machine.systemd.services.hive-agent-forge-token;
|
||||
tokenFile = machine: machine.services.hyperhive.agent.forge.tokenFile;
|
||||
in
|
||||
let
|
||||
cases = [
|
||||
{
|
||||
# The whole switch is the store address, as for the queue credential.
|
||||
name = "an agent with a store address fetches its forge token";
|
||||
ok =
|
||||
agentForgeBao.systemd.services ? hive-agent-forge-token
|
||||
&& agentForgeBao.systemd.timers ? hive-agent-forge-token;
|
||||
}
|
||||
{
|
||||
name = "an agent told no store address fetches no forge token";
|
||||
ok =
|
||||
!(agentForgeNoBao.systemd.services ? hive-agent-forge-token)
|
||||
&& !(agentForgeNoBao.systemd.timers ? hive-agent-forge-token)
|
||||
&& !(agentForgeNoBao.systemd.globalEnvironment ? HIVE_FORGE_TOKEN_FILE);
|
||||
}
|
||||
{
|
||||
# The nix half of `swarm_secret_client::forge::agent_token_path` plus the
|
||||
# mount. Spelled out: the Rust test pins `swarm/agents/atlas/forge-token`,
|
||||
# and a drift between the two is a fetch that 404s forever and says
|
||||
# "not minted yet".
|
||||
name = "the fetch reads the agent's own forge-token path";
|
||||
ok =
|
||||
let
|
||||
name = agentForgeBao.services.hyperhive.agent.user.name;
|
||||
in
|
||||
lib.hasInfix "secret/swarm/agents/${name}/forge-token" (fetchUnit agentForgeBao).script;
|
||||
}
|
||||
{
|
||||
# One store identity per agent: the fetch presents the same certificate
|
||||
# the identity check proves. Every `BAO_*` value is an address or a
|
||||
# `%d/` path, never a value.
|
||||
name = "the fetch presents the agent's own store identity, by path";
|
||||
ok =
|
||||
let
|
||||
u = fetchUnit agentForgeBao;
|
||||
e = u.environment;
|
||||
in
|
||||
builtins.elem "hive-agent-bao-cert" u.serviceConfig.LoadCredential
|
||||
&& builtins.elem "hive-agent-bao-key" u.serviceConfig.LoadCredential
|
||||
&& e.BAO_ADDR == baoAddr
|
||||
&& e.BAO_CLIENT_CERT == "%d/hive-agent-bao-cert"
|
||||
&& e.BAO_CLIENT_KEY == "%d/hive-agent-bao-key";
|
||||
}
|
||||
{
|
||||
# The field is the secret. It goes to a file by redirect and is never
|
||||
# echoed.
|
||||
name = "the fetch writes the token by redirect and never echoes it";
|
||||
ok =
|
||||
let
|
||||
s = (fetchUnit agentForgeBao).script;
|
||||
in
|
||||
lib.hasInfix "-field=value" s
|
||||
&& lib.hasInfix "> ${lib.escapeShellArg "/run/hive-agent-forge-token/token.new"}" s
|
||||
&& !(lib.hasInfix "echo \"$(bao" s)
|
||||
&& !(lib.hasInfix "echo $(bao" s);
|
||||
}
|
||||
{
|
||||
# A timer can only start an inactive unit, so the fetch must not stay
|
||||
# active; the directory, and the token in it, has to outlive each run.
|
||||
name = "the fetch can be re-run by its timer without losing the token";
|
||||
ok =
|
||||
let
|
||||
c = (fetchUnit agentForgeBao).serviceConfig;
|
||||
in
|
||||
!c.RemainAfterExit
|
||||
&& c.RuntimeDirectory == "hive-agent-forge-token"
|
||||
&& c.RuntimeDirectoryPreserve == "yes"
|
||||
&& c.UMask == "0377";
|
||||
}
|
||||
{
|
||||
# Rename in only on a change: a reader never sees half a token, and the
|
||||
# avatar watcher does not fire on every timer tick.
|
||||
name = "the fetch swaps the token in by rename, only when it changed";
|
||||
ok =
|
||||
let
|
||||
s = (fetchUnit agentForgeBao).script;
|
||||
in
|
||||
lib.hasInfix "cmp -s" s && lib.hasInfix "mv -f" s;
|
||||
}
|
||||
{
|
||||
# Every unit and the bash-task runner find the token through this. A
|
||||
# path, never the value.
|
||||
name = "the fetched token's path is published to every unit";
|
||||
ok =
|
||||
agentForgeBao.systemd.globalEnvironment.HIVE_FORGE_TOKEN_FILE == tokenFile agentForgeBao
|
||||
&& tokenFile agentForgeBao == "/run/hive-agent-forge-token/token";
|
||||
}
|
||||
{
|
||||
# The avatar sync has to re-fire when the swarm's token lands, which is
|
||||
# the file the fetch writes, not the state-dir file nothing writes any
|
||||
# more.
|
||||
name = "the avatar watcher follows the fetched token";
|
||||
ok =
|
||||
agentForgeBao.systemd.paths.forge-avatar-sync.pathConfig.PathChanged == tokenFile agentForgeBao
|
||||
&& builtins.elem "hive-agent-forge-token.service" agentForgeBao.systemd.services.forge-avatar-sync.after;
|
||||
}
|
||||
{
|
||||
# Both readers take the fetched token first and fall back to the state
|
||||
# file, which is still the only copy for an agent with no store identity.
|
||||
name = "the avatar sync reads the fetched token before the state file";
|
||||
ok =
|
||||
let
|
||||
s = agentForgeBao.systemd.services.forge-avatar-sync.script;
|
||||
name = agentForgeBao.services.hyperhive.agent.user.name;
|
||||
fetched = lib.escapeShellArg (tokenFile agentForgeBao);
|
||||
state = lib.escapeShellArg "/agents/${name}/state/forge-token";
|
||||
in
|
||||
lib.hasInfix "for f in ${fetched} ${state}; do" s;
|
||||
}
|
||||
{
|
||||
# tea-login copied the token into ~/.config/tea/config.yml, which
|
||||
# docs/swarm/credentials.md forbids for a store secret. Gone, with the
|
||||
# package it configured.
|
||||
name = "no tea-login unit and no tea package";
|
||||
ok =
|
||||
!(agentForgeBao.systemd.services ? tea-login)
|
||||
&& !(agentForgeNoBao.systemd.services ? tea-login)
|
||||
&& !(builtins.elem pkgs.tea agentForgeBao.environment.systemPackages);
|
||||
}
|
||||
];
|
||||
in
|
||||
runGroup "agent-forge-bao" cases
|
||||
|
|
@ -33,6 +33,8 @@ use utoipa::ToSchema;
|
|||
|
||||
use crate::webhook::DeliveryKind;
|
||||
|
||||
pub mod agent_token;
|
||||
|
||||
/// An agent's open config-PR, as [`Client::list_open_config_prs`] reports it
|
||||
/// and `GET /api/agents/{name}/config-pr` serves it.
|
||||
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, ToSchema)]
|
||||
|
|
@ -135,6 +137,10 @@ const TOKEN_FILE_ENV: &str = "SWARM_CONTROLLER_FORGE_TOKEN_FILE";
|
|||
/// Typed forgejo client, built once at startup from env.
|
||||
pub struct Client {
|
||||
api: Forgejo,
|
||||
/// The forge's base URL, kept so a client authenticated as someone
|
||||
/// else can be built against the same forge (see
|
||||
/// [`agent_token`]'s read-back).
|
||||
url: url::Url,
|
||||
}
|
||||
|
||||
impl Client {
|
||||
|
|
@ -168,8 +174,12 @@ impl Client {
|
|||
}
|
||||
let parsed_url =
|
||||
url::Url::parse(&url).with_context(|| format!("parse {URL_ENV} ({url}) as a URL"))?;
|
||||
let api = Forgejo::new(Auth::Token(token), parsed_url).context("build forgejo client")?;
|
||||
Ok(Some(Self { api }))
|
||||
let api =
|
||||
Forgejo::new(Auth::Token(token), parsed_url.clone()).context("build forgejo client")?;
|
||||
Ok(Some(Self {
|
||||
api,
|
||||
url: parsed_url,
|
||||
}))
|
||||
}
|
||||
|
||||
/// Creation options for an empty repo defaulting to `main`.
|
||||
|
|
|
|||
563
swarm-controller/src/forge/agent_token.rs
Normal file
563
swarm-controller/src/forge/agent_token.rs
Normal file
|
|
@ -0,0 +1,563 @@
|
|||
//! Each agent's own forge access token: minted here with the admin API, stored
|
||||
//! at `swarm/agents/<agent>/forge-token`, and pulled from there by the agent
|
||||
//! container itself (`nix/agent-modules/forge-token.nix`).
|
||||
//!
|
||||
//! One token per agent, under the fixed name [`AGENT_TOKEN_NAME`]. Forgejo
|
||||
//! refuses a second token with a name the user already has, so a rotation is a
|
||||
//! delete then a create, and there is never more than one of these per agent.
|
||||
//! The `hyperhive-<unix-seconds>` tokens `hive-c0re` used to mint are never
|
||||
//! touched here: the name cannot match them.
|
||||
//!
|
||||
//! Two callers insert the same `MintAgentForgeToken` job node: agent creation,
|
||||
//! and [`spawn`]'s pass at start and every [`RECONCILE_INTERVAL`] over every
|
||||
//! agent that holds a store identity. The decision is pure ([`classify`] and
|
||||
//! [`plan`]), so the tests pin it; the IO on either side only reads or acts.
|
||||
|
||||
use std::collections::BTreeSet;
|
||||
use std::sync::Arc;
|
||||
|
||||
use anyhow::{Context, Result, bail};
|
||||
use forgejo_api::structs::{AccessToken, CreateAccessTokenOption};
|
||||
use forgejo_api::{ApiErrorKind, Auth, Forgejo, ForgejoError};
|
||||
use reqwest::StatusCode;
|
||||
use swarm_secret_client::{client::DEFAULT_CERT_MOUNT, forge, policy};
|
||||
|
||||
use super::Client;
|
||||
|
||||
/// The forge's name for every agent token this module mints.
|
||||
///
|
||||
/// Deliberately not `hyperhive-…`: that prefix is what `hive-c0re` named each
|
||||
/// of its re-mints, and a later sweep of those must never match this one.
|
||||
pub const AGENT_TOKEN_NAME: &str = "swarm-agent";
|
||||
|
||||
/// The scopes an agent token carries. Byte-identical to `hive-c0re`'s
|
||||
/// `forge::users::TOKEN_SCOPES`, so the move from hive to swarm changes
|
||||
/// nothing an agent can do. Narrowing it is a separate decision.
|
||||
///
|
||||
/// ⚠️ Duplicated across the crate boundary until the last `hive-c0re` caller
|
||||
/// of that constant goes. A test here pins the literal.
|
||||
pub const AGENT_TOKEN_SCOPES: &str = "read:user,write:user,read:notification,write:notification,write:repository,write:issue,write:organization,write:misc";
|
||||
|
||||
/// How often [`spawn`] re-checks every agent's token.
|
||||
const RECONCILE_INTERVAL: std::time::Duration = std::time::Duration::from_mins(5);
|
||||
|
||||
/// Why a token has to be (re)minted.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum MintReason {
|
||||
/// The store holds nothing for this agent.
|
||||
NotStored,
|
||||
/// The forge lists no [`AGENT_TOKEN_NAME`] token for this agent.
|
||||
NotOnForge,
|
||||
/// The forge's token is not the one the store holds.
|
||||
LastEightMismatch,
|
||||
/// The forge's token carries other scopes than [`AGENT_TOKEN_SCOPES`].
|
||||
ScopeMismatch,
|
||||
}
|
||||
|
||||
/// What to do about one agent's token.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum Decision {
|
||||
/// The stored token is the forge's token, with the right scopes.
|
||||
Keep,
|
||||
/// Delete any [`AGENT_TOKEN_NAME`] token, create a new one, store it.
|
||||
Mint(MintReason),
|
||||
/// The forge lists more than one [`AGENT_TOKEN_NAME`] token. Forgejo
|
||||
/// refuses a duplicate name, so this is a forge in a state nothing here
|
||||
/// can reason about; a delete by name would 422 on it anyway.
|
||||
Conflict,
|
||||
}
|
||||
|
||||
/// A scope list reduced to what Forgejo would store for it.
|
||||
///
|
||||
/// Forgejo normalises a new token's scopes before storing them
|
||||
/// (`models/auth/access_token_scope.go`, `Normalize` → `toScope`): a `read:X`
|
||||
/// beside `write:X` is dropped, because the write bit implies the read one.
|
||||
/// The admin list then returns the stored form. Comparing
|
||||
/// [`AGENT_TOKEN_SCOPES`] to that list as written would never match, and every
|
||||
/// pass would rotate every agent's token.
|
||||
fn normalized_scopes<'a>(scopes: impl IntoIterator<Item = &'a str>) -> BTreeSet<String> {
|
||||
let all: BTreeSet<&str> = scopes.into_iter().map(str::trim).collect();
|
||||
all.iter()
|
||||
.filter(|s| {
|
||||
s.strip_prefix("read:")
|
||||
.is_none_or(|area| !all.contains(format!("write:{area}").as_str()))
|
||||
})
|
||||
.map(|s| (*s).to_owned())
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// The last eight characters of `token`, as Forgejo reports them for a token
|
||||
/// it holds (`token_last_eight`). `None` for a token shorter than that, which
|
||||
/// no Forgejo token is.
|
||||
fn last_eight(token: &str) -> Option<&str> {
|
||||
token.len().checked_sub(8).and_then(|at| token.get(at..))
|
||||
}
|
||||
|
||||
/// Decide what to do about an agent's token, from what the store holds and
|
||||
/// what the forge lists for the agent.
|
||||
///
|
||||
/// Every token not named [`AGENT_TOKEN_NAME`] is ignored, so the
|
||||
/// `hyperhive-*` tokens `hive-c0re` left behind never affect the decision.
|
||||
pub fn classify(stored: Option<&forge::Credential>, listed: &[AccessToken]) -> Decision {
|
||||
let ours: Vec<&AccessToken> = listed
|
||||
.iter()
|
||||
.filter(|t| t.name.as_deref() == Some(AGENT_TOKEN_NAME))
|
||||
.collect();
|
||||
let token = match ours[..] {
|
||||
[] => return Decision::Mint(MintReason::NotOnForge),
|
||||
[token] => token,
|
||||
_ => return Decision::Conflict,
|
||||
};
|
||||
let Some(stored) = stored else {
|
||||
return Decision::Mint(MintReason::NotStored);
|
||||
};
|
||||
if token.token_last_eight.as_deref() != last_eight(&stored.value) {
|
||||
return Decision::Mint(MintReason::LastEightMismatch);
|
||||
}
|
||||
let listed_scopes = normalized_scopes(token.scopes.iter().flatten().map(String::as_str));
|
||||
if listed_scopes != normalized_scopes(AGENT_TOKEN_SCOPES.split(',')) {
|
||||
return Decision::Mint(MintReason::ScopeMismatch);
|
||||
}
|
||||
Decision::Keep
|
||||
}
|
||||
|
||||
/// What one pass found for one agent.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum Observed {
|
||||
/// Both reads worked, and this is what [`classify`] made of them.
|
||||
Decided(Decision),
|
||||
/// The forge has no user by this agent's name. Planned like a mint: the
|
||||
/// queued job creates the user first (`CreateForgeUser`, idempotent), so
|
||||
/// an agent that holds a store identity but was never given a forge
|
||||
/// account still ends up with both.
|
||||
NoForgeUser,
|
||||
/// A read failed. Nothing is known, so nothing is done.
|
||||
Unknown,
|
||||
}
|
||||
|
||||
/// The agents a pass mints for: a [`Decision::Mint`], or an agent with no
|
||||
/// forge user yet ([`Observed::NoForgeUser`]), whose job creates the user
|
||||
/// before it mints.
|
||||
///
|
||||
/// [`Observed::Unknown`] in particular never mints: a forge or store outage
|
||||
/// must not turn into a rotation of every agent's token.
|
||||
pub fn plan(observed: &[(String, Observed)]) -> Vec<String> {
|
||||
observed
|
||||
.iter()
|
||||
.filter(|(_, o)| {
|
||||
matches!(
|
||||
o,
|
||||
Observed::Decided(Decision::Mint(_)) | Observed::NoForgeUser
|
||||
)
|
||||
})
|
||||
.map(|(agent, _)| agent.clone())
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Whether a forge error is a 404, whichever of its two shapes it came in.
|
||||
fn is_not_found(e: &ForgejoError) -> bool {
|
||||
match e {
|
||||
ForgejoError::ApiError(api) => {
|
||||
matches!(api.error_kind(), ApiErrorKind::NotFound { .. })
|
||||
|| matches!(api.error_kind(), ApiErrorKind::Other(s) if *s == StatusCode::NOT_FOUND)
|
||||
}
|
||||
ForgejoError::UnexpectedStatusCode(s) => *s == StatusCode::NOT_FOUND,
|
||||
_ => false,
|
||||
}
|
||||
}
|
||||
|
||||
impl Client {
|
||||
/// Every access token the forge lists for `agent`, or `None` when the
|
||||
/// forge has no such user.
|
||||
async fn list_agent_tokens(&self, agent: &str) -> Result<Option<Vec<AccessToken>>> {
|
||||
match self.api.admin_list_user_access_tokens(agent).all().await {
|
||||
Ok(tokens) => Ok(Some(tokens)),
|
||||
Err(e) if is_not_found(&e) => Ok(None),
|
||||
Err(e) => Err(e).with_context(|| format!("listing {agent}'s forge tokens")),
|
||||
}
|
||||
}
|
||||
|
||||
/// Make sure `agent`'s stored token is a live [`AGENT_TOKEN_NAME`] token
|
||||
/// with [`AGENT_TOKEN_SCOPES`], and mint one if it is not. The whole job
|
||||
/// of the `MintAgentForgeToken` node.
|
||||
///
|
||||
/// A rotation deletes the old token first, then creates, stores, and uses
|
||||
/// the new one: it logs in with it and asks the forge who it is, so the
|
||||
/// node does not report success on a write nobody has read. A crash
|
||||
/// between any two steps leaves a state the next run classifies as
|
||||
/// [`Decision::Mint`] again.
|
||||
///
|
||||
/// # Errors
|
||||
/// When the store or the forge refuses a step, when the agent has no forge
|
||||
/// user, or when the new token authenticates as someone else.
|
||||
pub async fn ensure_agent_forge_token(&self, agent: &str) -> Result<()> {
|
||||
let path = forge::agent_token_path(agent)?;
|
||||
let store = crate::store::connect()
|
||||
.await
|
||||
.context("logging in to the swarm secret store")?;
|
||||
let stored: Option<forge::Credential> = store
|
||||
.read_optional(&path)
|
||||
.await
|
||||
.with_context(|| format!("reading {path}"))?;
|
||||
let Some(listed) = self.list_agent_tokens(agent).await? else {
|
||||
bail!(
|
||||
"the forge has no user {agent:?} even after this job's create_forge_user \
|
||||
node, so there is nothing to mint a token for"
|
||||
);
|
||||
};
|
||||
let reason = match classify(stored.as_ref(), &listed) {
|
||||
Decision::Keep => {
|
||||
tracing::debug!(agent, "agent forge token is current; left as it is");
|
||||
return Ok(());
|
||||
}
|
||||
Decision::Conflict => bail!(
|
||||
"the forge lists more than one {AGENT_TOKEN_NAME:?} token for {agent:?}; \
|
||||
delete them by id and re-run"
|
||||
),
|
||||
Decision::Mint(reason) => reason,
|
||||
};
|
||||
|
||||
if listed
|
||||
.iter()
|
||||
.any(|t| t.name.as_deref() == Some(AGENT_TOKEN_NAME))
|
||||
{
|
||||
match self
|
||||
.api
|
||||
.admin_delete_user_access_token(agent, AGENT_TOKEN_NAME)
|
||||
.await
|
||||
{
|
||||
Ok(()) => {}
|
||||
Err(e) if is_not_found(&e) => {}
|
||||
Err(e) => {
|
||||
return Err(e)
|
||||
.with_context(|| format!("deleting {agent}'s {AGENT_TOKEN_NAME} token"));
|
||||
}
|
||||
}
|
||||
}
|
||||
let created = self
|
||||
.api
|
||||
.admin_create_user_access_token(
|
||||
agent,
|
||||
CreateAccessTokenOption {
|
||||
name: AGENT_TOKEN_NAME.to_owned(),
|
||||
repositories: None,
|
||||
scopes: Some(AGENT_TOKEN_SCOPES.split(',').map(str::to_owned).collect()),
|
||||
},
|
||||
)
|
||||
.await
|
||||
.with_context(|| format!("creating {agent}'s {AGENT_TOKEN_NAME} token"))?;
|
||||
let Some(value) = created.sha1.filter(|v| !v.is_empty()) else {
|
||||
bail!("the forge created {agent}'s token but returned no value for it");
|
||||
};
|
||||
let credential = forge::Credential {
|
||||
value,
|
||||
name: AGENT_TOKEN_NAME.to_owned(),
|
||||
};
|
||||
store
|
||||
.write(&path, &credential)
|
||||
.await
|
||||
.with_context(|| format!("storing {agent}'s forge token at {path}"))?;
|
||||
|
||||
let as_agent = Forgejo::new(Auth::Token(&credential.value), self.url.clone())
|
||||
.context("building a forge client with the new token")?;
|
||||
let me = as_agent
|
||||
.user_get_current()
|
||||
.await
|
||||
.with_context(|| format!("using {agent}'s new forge token"))?;
|
||||
if me.login.as_deref() != Some(agent) {
|
||||
bail!("{agent}'s new forge token authenticates as someone else");
|
||||
}
|
||||
tracing::info!(agent, ?reason, %path, "agent forge token minted and stored");
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// What the forge and the store say about `agent`'s token.
|
||||
async fn observe_agent(
|
||||
&self,
|
||||
store: &swarm_secret_client::SecretStore,
|
||||
agent: &str,
|
||||
) -> Observed {
|
||||
let stored = match forge::agent_token_path(agent) {
|
||||
Ok(path) => store.read_optional::<forge::Credential>(&path).await,
|
||||
Err(e) => Err(e),
|
||||
};
|
||||
let stored = match stored {
|
||||
Ok(stored) => stored,
|
||||
Err(e) => {
|
||||
tracing::warn!(agent, error = %e, "agent forge token: store read failed");
|
||||
return Observed::Unknown;
|
||||
}
|
||||
};
|
||||
match self.list_agent_tokens(agent).await {
|
||||
Ok(Some(listed)) => Observed::Decided(classify(stored.as_ref(), &listed)),
|
||||
Ok(None) => Observed::NoForgeUser,
|
||||
Err(e) => {
|
||||
tracing::warn!(agent, error = %format!("{e:#}"), "agent forge token: forge read failed");
|
||||
Observed::Unknown
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// One pass: every agent holding a store identity, observed.
|
||||
async fn observe_all(&self) -> Result<Vec<(String, Observed)>> {
|
||||
let store = crate::store::connect()
|
||||
.await
|
||||
.context("logging in to the swarm secret store")?;
|
||||
let roles = store
|
||||
.list_cert_roles(DEFAULT_CERT_MOUNT)
|
||||
.await
|
||||
.context("listing the store's cert-auth roles")?;
|
||||
let mut observed = Vec::new();
|
||||
for agent in policy::agents_from_role_names(&roles) {
|
||||
let o = self.observe_agent(&store, &agent).await;
|
||||
if o == Observed::NoForgeUser {
|
||||
tracing::info!(
|
||||
agent,
|
||||
"agent forge token: no forge user; creating one first"
|
||||
);
|
||||
}
|
||||
observed.push((agent, o));
|
||||
}
|
||||
Ok(observed)
|
||||
}
|
||||
}
|
||||
|
||||
/// Check every agent's token now and every [`RECONCILE_INTERVAL`] after, and
|
||||
/// hand the agents that need one to `enqueue`, which inserts a
|
||||
/// `MintAgentForgeToken` node for each.
|
||||
///
|
||||
/// The roster is the store's `hive-agent-*` cert-auth roles: exactly the
|
||||
/// agents that can read what the node writes. The first tick fires
|
||||
/// immediately (`tokio::time::interval`'s default). A pass that fails is
|
||||
/// logged and retried on the next tick; it never stops the daemon.
|
||||
pub fn spawn(client: Arc<Client>, enqueue: impl Fn(Vec<String>) + Send + 'static) {
|
||||
tokio::spawn(async move {
|
||||
let mut ticker = tokio::time::interval(RECONCILE_INTERVAL);
|
||||
loop {
|
||||
ticker.tick().await;
|
||||
match client.observe_all().await {
|
||||
Ok(observed) => {
|
||||
let agents = plan(&observed);
|
||||
if agents.is_empty() {
|
||||
tracing::debug!(
|
||||
checked = observed.len(),
|
||||
"agent forge tokens: all current"
|
||||
);
|
||||
} else {
|
||||
tracing::info!(
|
||||
checked = observed.len(),
|
||||
minting = agents.len(),
|
||||
"agent forge tokens: queueing mints"
|
||||
);
|
||||
enqueue(agents);
|
||||
}
|
||||
}
|
||||
Err(e) => tracing::warn!(
|
||||
error = %format!("{e:#}"),
|
||||
retry_in_s = RECONCILE_INTERVAL.as_secs(),
|
||||
"agent forge tokens: pass failed; retrying next tick"
|
||||
),
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
const VALUE: &str = "0123456789abcdef0123456789abcdef01234567";
|
||||
|
||||
fn stored() -> forge::Credential {
|
||||
forge::Credential {
|
||||
value: VALUE.to_owned(),
|
||||
name: AGENT_TOKEN_NAME.to_owned(),
|
||||
}
|
||||
}
|
||||
|
||||
fn token(name: &str, last_eight: &str, scopes: &[&str]) -> AccessToken {
|
||||
AccessToken {
|
||||
created_at: None,
|
||||
id: Some(1),
|
||||
name: Some(name.to_owned()),
|
||||
repositories: None,
|
||||
scopes: Some(scopes.iter().map(|s| (*s).to_owned()).collect()),
|
||||
sha1: None,
|
||||
token_last_eight: Some(last_eight.to_owned()),
|
||||
}
|
||||
}
|
||||
|
||||
/// What Forgejo v16 stores and lists for [`AGENT_TOKEN_SCOPES`]: the
|
||||
/// `toScope` order, with each `read:X` folded into its `write:X`.
|
||||
const LISTED_SCOPES: [&str; 6] = [
|
||||
"write:misc",
|
||||
"write:notification",
|
||||
"write:organization",
|
||||
"write:issue",
|
||||
"write:repository",
|
||||
"write:user",
|
||||
];
|
||||
|
||||
fn ours() -> AccessToken {
|
||||
token(AGENT_TOKEN_NAME, "01234567", &LISTED_SCOPES)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_scopes_are_hive_c0res_byte_for_byte() {
|
||||
assert_eq!(
|
||||
AGENT_TOKEN_SCOPES,
|
||||
"read:user,write:user,read:notification,write:notification,write:repository,write:issue,write:organization,write:misc"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_current_token_is_kept() {
|
||||
assert_eq!(classify(Some(&stored()), &[ours()]), Decision::Keep);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn scopes_as_written_are_kept_too() {
|
||||
// A forge that listed the scopes un-normalised must not rotate either.
|
||||
let t = token(
|
||||
AGENT_TOKEN_NAME,
|
||||
"01234567",
|
||||
&AGENT_TOKEN_SCOPES.split(',').collect::<Vec<_>>(),
|
||||
);
|
||||
assert_eq!(classify(Some(&stored()), &[t]), Decision::Keep);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn nothing_stored_mints() {
|
||||
assert_eq!(
|
||||
classify(None, &[ours()]),
|
||||
Decision::Mint(MintReason::NotStored)
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn nothing_on_the_forge_mints() {
|
||||
assert_eq!(
|
||||
classify(Some(&stored()), &[]),
|
||||
Decision::Mint(MintReason::NotOnForge)
|
||||
);
|
||||
assert_eq!(classify(None, &[]), Decision::Mint(MintReason::NotOnForge));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_different_last_eight_mints() {
|
||||
let t = token(AGENT_TOKEN_NAME, "ffffffff", &LISTED_SCOPES);
|
||||
assert_eq!(
|
||||
classify(Some(&stored()), &[t]),
|
||||
Decision::Mint(MintReason::LastEightMismatch)
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_missing_scope_mints() {
|
||||
let t = token(AGENT_TOKEN_NAME, "01234567", &LISTED_SCOPES[1..]);
|
||||
assert_eq!(
|
||||
classify(Some(&stored()), &[t]),
|
||||
Decision::Mint(MintReason::ScopeMismatch)
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_extra_scope_mints() {
|
||||
let mut scopes = LISTED_SCOPES.to_vec();
|
||||
scopes.push("write:admin");
|
||||
let t = token(AGENT_TOKEN_NAME, "01234567", &scopes);
|
||||
assert_eq!(
|
||||
classify(Some(&stored()), &[t]),
|
||||
Decision::Mint(MintReason::ScopeMismatch)
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn scope_order_does_not_matter() {
|
||||
let mut scopes = LISTED_SCOPES.to_vec();
|
||||
scopes.reverse();
|
||||
let t = token(AGENT_TOKEN_NAME, "01234567", &scopes);
|
||||
assert_eq!(classify(Some(&stored()), &[t]), Decision::Keep);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn legacy_tokens_are_ignored() {
|
||||
// A pile of `hyperhive-*` tokens, one of them even matching the
|
||||
// stored value's last eight, is still "no swarm token".
|
||||
let legacy = [
|
||||
token("hyperhive-1700000000", "01234567", &LISTED_SCOPES),
|
||||
token("hyperhive-1700000001", "aaaaaaaa", &LISTED_SCOPES),
|
||||
];
|
||||
assert_eq!(
|
||||
classify(Some(&stored()), &legacy),
|
||||
Decision::Mint(MintReason::NotOnForge)
|
||||
);
|
||||
let mut with_ours = legacy.to_vec();
|
||||
with_ours.push(ours());
|
||||
assert_eq!(classify(Some(&stored()), &with_ours), Decision::Keep);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn two_swarm_tokens_are_a_conflict_not_a_mint() {
|
||||
assert_eq!(
|
||||
classify(Some(&stored()), &[ours(), ours()]),
|
||||
Decision::Conflict
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn last_eight_is_the_tail() {
|
||||
assert_eq!(last_eight(VALUE), Some("01234567"));
|
||||
assert_eq!(last_eight("short"), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_mint_or_a_missing_user_is_planned() {
|
||||
let observed = [
|
||||
("a".to_owned(), Observed::Decided(Decision::Keep)),
|
||||
(
|
||||
"b".to_owned(),
|
||||
Observed::Decided(Decision::Mint(MintReason::NotStored)),
|
||||
),
|
||||
("c".to_owned(), Observed::Unknown),
|
||||
("d".to_owned(), Observed::NoForgeUser),
|
||||
("e".to_owned(), Observed::Decided(Decision::Conflict)),
|
||||
(
|
||||
"f".to_owned(),
|
||||
Observed::Decided(Decision::Mint(MintReason::ScopeMismatch)),
|
||||
),
|
||||
];
|
||||
assert_eq!(plan(&observed), ["b", "d", "f"]);
|
||||
}
|
||||
|
||||
/// An agent with a store identity and no forge user used to be skipped
|
||||
/// on every pass, forever. It is planned now; the job it gets creates
|
||||
/// the user before it mints (see `queue_forge_token_mints`).
|
||||
#[test]
|
||||
fn a_missing_forge_user_is_planned() {
|
||||
let observed = [("ruth".to_owned(), Observed::NoForgeUser)];
|
||||
assert_eq!(plan(&observed), ["ruth"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_outage_plans_nothing() {
|
||||
let observed = [
|
||||
("a".to_owned(), Observed::Unknown),
|
||||
("b".to_owned(), Observed::Unknown),
|
||||
];
|
||||
assert!(plan(&observed).is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn both_404_shapes_are_not_found() {
|
||||
assert!(is_not_found(&ForgejoError::UnexpectedStatusCode(
|
||||
StatusCode::NOT_FOUND
|
||||
)));
|
||||
assert!(is_not_found(&ForgejoError::ApiError(
|
||||
ApiErrorKind::NotFound { errors: None }.into()
|
||||
)));
|
||||
assert!(!is_not_found(&ForgejoError::UnexpectedStatusCode(
|
||||
StatusCode::FORBIDDEN
|
||||
)));
|
||||
}
|
||||
}
|
||||
|
|
@ -105,6 +105,14 @@ enum SwarmNodeKind {
|
|||
/// its hive's shared queue credential, so the document cannot be rendered
|
||||
/// without knowing which hive the agent belongs to.
|
||||
MintAgentIdentity { hive: String, agent: String },
|
||||
/// Make sure `agent` holds a live forge access token in the swarm secret
|
||||
/// store, minting one with the forge's admin API when it does not. See
|
||||
/// `forge::agent_token` — including why a rotation is a delete then a
|
||||
/// create.
|
||||
///
|
||||
/// Carries no hive: the token's store path has no hive segment, and the
|
||||
/// agent pulls it from wherever it runs.
|
||||
MintAgentForgeToken { agent: String },
|
||||
/// Declare `agent` on `hive` as `Paused` in the swarm's wanted-state
|
||||
/// store, so a freshly created agent does not start driving turns the
|
||||
/// moment it's deployed — the operator has to explicitly flip it to `Up`.
|
||||
|
|
@ -130,6 +138,7 @@ impl hive_jobq_wire::WireNode for SwarmNodeKind {
|
|||
SwarmNodeKind::AddRepoMember { .. } => "add_repo_member".to_owned(),
|
||||
SwarmNodeKind::InitAgentConfigRepo { .. } => "init_agent_config_repo".to_owned(),
|
||||
SwarmNodeKind::MintAgentIdentity { .. } => "mint_agent_identity".to_owned(),
|
||||
SwarmNodeKind::MintAgentForgeToken { .. } => "mint_agent_forge_token".to_owned(),
|
||||
SwarmNodeKind::SetAgentWanted { .. } => "set_agent_wanted".to_owned(),
|
||||
SwarmNodeKind::TriggerDeploy { .. } => "trigger_deploy".to_owned(),
|
||||
}
|
||||
|
|
@ -147,7 +156,8 @@ impl hive_jobq_wire::WireNode for SwarmNodeKind {
|
|||
| SwarmNodeKind::CreateRepo { agent }
|
||||
| SwarmNodeKind::CreateForgeUser { agent }
|
||||
| SwarmNodeKind::AddRepoMember { agent }
|
||||
| SwarmNodeKind::InitAgentConfigRepo { agent } => {
|
||||
| SwarmNodeKind::InitAgentConfigRepo { agent }
|
||||
| SwarmNodeKind::MintAgentForgeToken { agent } => {
|
||||
serde_json::json!({ "agent": agent })
|
||||
}
|
||||
SwarmNodeKind::TriggerDeploy { hive, agent }
|
||||
|
|
@ -308,6 +318,7 @@ async fn run_swarm_node(
|
|||
}
|
||||
}
|
||||
},
|
||||
SwarmNodeKind::MintAgentForgeToken { agent } => mint_forge_token(deps.forge, &agent).await,
|
||||
SwarmNodeKind::SetAgentWanted { hive, agent } => match deps.wanted {
|
||||
None => Outcome::Failed(
|
||||
"no swarm queue is configured on this host, so no wanted-state \
|
||||
|
|
@ -327,6 +338,27 @@ async fn run_swarm_node(
|
|||
(builder, outcome)
|
||||
}
|
||||
|
||||
/// The `MintAgentForgeToken` arm, lifted out so `run_swarm_node` stays under
|
||||
/// `clippy::too_many_lines`.
|
||||
async fn mint_forge_token(
|
||||
forge: Option<Arc<forge::Client>>,
|
||||
agent: &str,
|
||||
) -> hive_jobq::scheduler::Outcome {
|
||||
use hive_jobq::scheduler::Outcome;
|
||||
|
||||
let Some(client) = forge else {
|
||||
return Outcome::Failed(
|
||||
"no forge configured on this host (SWARM_CONTROLLER_FORGE_URL / \
|
||||
SWARM_CONTROLLER_FORGE_TOKEN_FILE unset), so no agent forge token can be minted"
|
||||
.to_owned(),
|
||||
);
|
||||
};
|
||||
match client.ensure_agent_forge_token(agent).await {
|
||||
Ok(()) => Outcome::Done,
|
||||
Err(e) => Outcome::Failed(format!("{e:#}")),
|
||||
}
|
||||
}
|
||||
|
||||
/// Declare a brand-new agent at [`NEW_AGENT_WANTED_STATE`] — unless it turns
|
||||
/// out not to be new: an agent that already has a declaration (other than
|
||||
/// `Destroyed`, which this treats as reusable) is left alone, so a retried
|
||||
|
|
@ -1470,6 +1502,14 @@ fn declare_agent_job(
|
|||
agent: agent.to_owned(),
|
||||
})
|
||||
.after_ok(create_identity);
|
||||
// The agent's own forge token, in the store before the hive deploys
|
||||
// the container that pulls it. After the forge user, because the token
|
||||
// is minted for that user.
|
||||
let mint_forge_token = b
|
||||
.node(SwarmNodeKind::MintAgentForgeToken {
|
||||
agent: agent.to_owned(),
|
||||
})
|
||||
.after_ok(create_forge_user);
|
||||
// Declared before the deploy trigger so the pause is visible in the
|
||||
// wanted-state store before the hive brings the container up — see the
|
||||
// node's own doc comment for why "before", not just "eventually". Needs
|
||||
|
|
@ -1491,6 +1531,11 @@ fn declare_agent_job(
|
|||
// create agents exactly as it does today. `after_ok` there would turn an
|
||||
// unconfigured option into an agent nobody runs.
|
||||
//
|
||||
// The forge-token mint gets `after_any` for the same reason: a host with
|
||||
// no forge or no store must still create agents; only that node fails,
|
||||
// by name, and the agent's container fetches the token on its next
|
||||
// timer tick once one is minted.
|
||||
//
|
||||
// `after_ok` on the declaration, though: an agent deployed without its
|
||||
// pause landing first is the race this node exists to close, so a
|
||||
// declaration that did not land must not be deployed past. A host with no
|
||||
|
|
@ -1504,6 +1549,7 @@ fn declare_agent_job(
|
|||
})
|
||||
.after_ok(init_config)
|
||||
.after_any(mint_identity)
|
||||
.after_any(mint_forge_token)
|
||||
.after_ok(set_wanted);
|
||||
vec![create_identity.guid()]
|
||||
}
|
||||
|
|
@ -1703,6 +1749,85 @@ async fn mint_agent_identity(
|
|||
Ok(Json(MintAgentIdentityResponse { node_id: id.get() }))
|
||||
}
|
||||
|
||||
/// Success body of `POST /api/agents/{name}/forge-token`.
|
||||
#[derive(Clone, Debug, Serialize, ToSchema)]
|
||||
struct MintAgentForgeTokenResponse {
|
||||
/// The queued node, so a caller can follow it in the job view.
|
||||
node_id: u64,
|
||||
}
|
||||
|
||||
/// Insert one job per agent: `CreateForgeUser`, then `MintAgentForgeToken`
|
||||
/// `after_ok` it — the same edge [`declare_agent_job`] uses. Returns the mint
|
||||
/// nodes' ids.
|
||||
///
|
||||
/// The one place that node is queued outside agent creation: both the manual
|
||||
/// route below and `forge::agent_token::spawn`'s periodic pass come through
|
||||
/// here, so a backfilled mint is the same node a new agent gets. The user
|
||||
/// node is what lets the pass reach an agent that has a store identity but
|
||||
/// no forge account; for one that has an account it is a no-op, because
|
||||
/// `forge::Client::ensure_agent_user` folds "already exists" into success.
|
||||
fn queue_forge_token_mints(
|
||||
sched: &Mutex<hive_jobq::scheduler::Scheduler<SwarmNodeKind, SwarmResourceKind>>,
|
||||
agents: Vec<String>,
|
||||
) -> Result<Vec<hive_jobq::NodeId>> {
|
||||
let mut sched = sched
|
||||
.lock()
|
||||
.unwrap_or_else(std::sync::PoisonError::into_inner);
|
||||
let mut ids = Vec::with_capacity(agents.len());
|
||||
for agent in agents {
|
||||
let queued = sched
|
||||
.insert_job(None, |b| {
|
||||
let create_forge_user = b.node(SwarmNodeKind::CreateForgeUser {
|
||||
agent: agent.clone(),
|
||||
});
|
||||
vec![
|
||||
b.node(SwarmNodeKind::MintAgentForgeToken { agent })
|
||||
.after_ok(create_forge_user)
|
||||
.guid(),
|
||||
]
|
||||
})
|
||||
.map_err(|e| anyhow::anyhow!("{e}"))?;
|
||||
ids.extend(queued);
|
||||
}
|
||||
Ok(ids)
|
||||
}
|
||||
|
||||
/// Check an agent's forge token now, and mint one if it is missing or stale.
|
||||
///
|
||||
/// The periodic pass (`forge::agent_token::spawn`) does the same every five
|
||||
/// minutes for every agent with a store identity; this is for an operator who
|
||||
/// does not want to wait, or for an agent the pass skipped. Queues the same
|
||||
/// node agent creation does. Idempotent: a current token is left alone.
|
||||
#[utoipa::path(
|
||||
post,
|
||||
path = "/api/agents/{name}/forge-token",
|
||||
params(("name" = String, Path, description = "agent name")),
|
||||
responses(
|
||||
(status = 200, description = "mint queued", body = MintAgentForgeTokenResponse),
|
||||
(status = 400, description = "`name` is not a valid identifier (problem+json)", body = String),
|
||||
(status = 500, description = "the job could not be queued (problem+json)", body = String),
|
||||
),
|
||||
tag = "agents"
|
||||
)]
|
||||
async fn mint_agent_forge_token(
|
||||
State(state): State<AppState>,
|
||||
Path(name): Path<String>,
|
||||
) -> Result<Json<MintAgentForgeTokenResponse>, problem_details::ProblemDetails> {
|
||||
let agent = hive_types::Ident::parse(&name)
|
||||
.map_err(|reason| error_problem(axum::http::StatusCode::BAD_REQUEST, reason))?
|
||||
.into_string();
|
||||
let ids = queue_forge_token_mints(&state.jobq, vec![agent]).map_err(|e| {
|
||||
error_problem(
|
||||
axum::http::StatusCode::INTERNAL_SERVER_ERROR,
|
||||
&e.to_string(),
|
||||
)
|
||||
})?;
|
||||
let [id] = ids[..] else {
|
||||
unreachable!("exactly one handle was asked for");
|
||||
};
|
||||
Ok(Json(MintAgentForgeTokenResponse { node_id: id.get() }))
|
||||
}
|
||||
|
||||
/// Every agent with an open config PR, in one response — the bulk
|
||||
/// counterpart to [`get_agent_config_pr`]. swarm-ui's config-PR table needs
|
||||
/// every agent's status to render, and fetching them one at a time doesn't
|
||||
|
|
@ -1990,6 +2115,14 @@ async fn main() -> Result<()> {
|
|||
);
|
||||
}
|
||||
|
||||
if let Some(client) = forge_client.clone() {
|
||||
let sched = Arc::clone(&jobq);
|
||||
forge::agent_token::spawn(client, move |agents| {
|
||||
if let Err(e) = queue_forge_token_mints(&sched, agents) {
|
||||
tracing::warn!(error = %format!("{e:#}"), "agent forge tokens: queueing failed");
|
||||
}
|
||||
});
|
||||
}
|
||||
let config_prs = forge_client.clone().map(config_pr::spawn);
|
||||
let state_forge = keep_forge_for_state(forge_client, webhook_secret.clone());
|
||||
|
||||
|
|
@ -2041,6 +2174,7 @@ fn build_app(state: AppState) -> axum::Router {
|
|||
.routes(routes!(get_config_prs))
|
||||
.routes(routes!(create_agent))
|
||||
.routes(routes!(mint_agent_identity))
|
||||
.routes(routes!(mint_agent_forge_token))
|
||||
.routes(routes!(get_agents))
|
||||
.routes(routes!(get_agents_status))
|
||||
.routes(routes!(set_agent_state))
|
||||
|
|
@ -2842,6 +2976,151 @@ mod tests {
|
|||
);
|
||||
}
|
||||
|
||||
/// The forge-token mint sits between the forge user and the deploy: it
|
||||
/// needs the user to exist, and the deploy must not overtake it — but a
|
||||
/// host with no forge or no store must still deploy the agent.
|
||||
#[tokio::test]
|
||||
async fn the_forge_token_mint_follows_the_user_and_does_not_block_the_deploy() {
|
||||
use hive_jobq_wire::WireNode as _;
|
||||
|
||||
let (state, sched) = state_with_roster();
|
||||
let _queued = super::create_agent(
|
||||
axum::extract::State(state),
|
||||
axum::Json(super::CreateAgentRequest {
|
||||
name: "atlas".to_owned(),
|
||||
hive: "pr1ma".to_owned(),
|
||||
}),
|
||||
)
|
||||
.await
|
||||
.expect("a hive in the roster must be accepted");
|
||||
|
||||
let guard = sched
|
||||
.lock()
|
||||
.unwrap_or_else(std::sync::PoisonError::into_inner);
|
||||
let graph = guard.graph();
|
||||
let id_of = |label: &str| {
|
||||
graph
|
||||
.nodes()
|
||||
.find(|n| n.payload.label() == label)
|
||||
.unwrap_or_else(|| panic!("the graph holds a {label} node"))
|
||||
.id
|
||||
};
|
||||
let edge = |from: hive_jobq::NodeId, to: &str| {
|
||||
graph
|
||||
.node(id_of(to))
|
||||
.expect("just found")
|
||||
.deps
|
||||
.iter()
|
||||
.find_map(|d| match d {
|
||||
hive_jobq::Dep::Node { id, when } if *id == from => Some(*when),
|
||||
_ => None,
|
||||
})
|
||||
.unwrap_or_else(|| panic!("{to} waits for node {}", from.get()))
|
||||
};
|
||||
|
||||
let user_to_mint = edge(id_of("create_forge_user"), "mint_agent_forge_token");
|
||||
assert!(
|
||||
!user_to_mint.accepts(hive_jobq::TerminalState::Failed),
|
||||
"a token for a user that was never created cannot be minted; this \
|
||||
edge has to be `after_ok`"
|
||||
);
|
||||
let mint_to_deploy = edge(id_of("mint_agent_forge_token"), "trigger_deploy");
|
||||
assert!(
|
||||
mint_to_deploy.accepts(hive_jobq::TerminalState::Failed),
|
||||
"a host with no forge or store must not cancel the deploy; this edge \
|
||||
has to be `after_any`, not `after_ok`"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_forge_token_node_renders_the_agent() {
|
||||
use hive_jobq_wire::WireNode as _;
|
||||
|
||||
let kind = SwarmNodeKind::MintAgentForgeToken {
|
||||
agent: "atlas".to_owned(),
|
||||
};
|
||||
assert_eq!(kind.label(), "mint_agent_forge_token");
|
||||
assert_eq!(kind.data(1)["agent"], "atlas");
|
||||
}
|
||||
|
||||
/// The manual route and the periodic pass both go through
|
||||
/// `queue_forge_token_mints`, so this is the assertion that a backfilled
|
||||
/// mint is the same pair of nodes agent creation inserts: the forge user,
|
||||
/// then the mint `after_ok` it, per agent.
|
||||
#[test]
|
||||
fn a_queued_mint_creates_the_forge_user_first() {
|
||||
use hive_jobq_wire::WireNode as _;
|
||||
|
||||
let sched = std::sync::Mutex::new(hive_jobq::scheduler::Scheduler::new(
|
||||
hive_jobq::Graph::new(),
|
||||
hive_jobq::resources::ResourceTable::new(),
|
||||
));
|
||||
let ids = super::queue_forge_token_mints(&sched, vec!["a".to_owned(), "b".to_owned()])
|
||||
.expect("two jobs insert");
|
||||
assert_eq!(ids.len(), 2, "one returned id per agent: its mint node");
|
||||
let guard = sched
|
||||
.lock()
|
||||
.unwrap_or_else(std::sync::PoisonError::into_inner);
|
||||
let graph = guard.graph();
|
||||
let agent_of = |n: &hive_jobq::Node<SwarmNodeKind, super::SwarmResourceKind>| {
|
||||
n.payload.data(n.id.get())["agent"]
|
||||
.as_str()
|
||||
.expect("agent is a string")
|
||||
.to_owned()
|
||||
};
|
||||
for agent in ["a", "b"] {
|
||||
let find = |label: &str| {
|
||||
graph
|
||||
.nodes()
|
||||
.find(|n| n.payload.label() == label && agent_of(n) == agent)
|
||||
.unwrap_or_else(|| panic!("{agent} has a {label} node"))
|
||||
};
|
||||
let user = find("create_forge_user").id;
|
||||
let mint = find("mint_agent_forge_token");
|
||||
assert!(ids.contains(&mint.id), "the returned id is {agent}'s mint");
|
||||
let when = mint
|
||||
.deps
|
||||
.iter()
|
||||
.find_map(|d| match d {
|
||||
hive_jobq::Dep::Node { id, when } if *id == user => Some(*when),
|
||||
_ => None,
|
||||
})
|
||||
.unwrap_or_else(|| panic!("{agent}'s mint waits for its own forge user"));
|
||||
assert!(
|
||||
!when.accepts(hive_jobq::TerminalState::Failed),
|
||||
"a token cannot be minted for a user that was never created; this \
|
||||
edge has to be `after_ok`"
|
||||
);
|
||||
}
|
||||
assert_eq!(
|
||||
graph.nodes().count(),
|
||||
4,
|
||||
"two nodes per agent, nothing else"
|
||||
);
|
||||
}
|
||||
|
||||
/// The backfill end to end, minus the IO: an agent the pass observed with
|
||||
/// no forge user is planned, and the job it gets creates that user before
|
||||
/// it mints.
|
||||
#[test]
|
||||
fn an_agent_with_no_forge_user_gets_a_user_and_a_mint() {
|
||||
use crate::forge::agent_token::{Observed, plan};
|
||||
use hive_jobq_wire::WireNode as _;
|
||||
|
||||
let agents = plan(&[("ruth".to_owned(), Observed::NoForgeUser)]);
|
||||
let sched = std::sync::Mutex::new(hive_jobq::scheduler::Scheduler::new(
|
||||
hive_jobq::Graph::new(),
|
||||
hive_jobq::resources::ResourceTable::new(),
|
||||
));
|
||||
super::queue_forge_token_mints(&sched, agents).expect("one job inserts");
|
||||
let guard = sched
|
||||
.lock()
|
||||
.unwrap_or_else(std::sync::PoisonError::into_inner);
|
||||
let mut labels: Vec<String> = guard.graph().nodes().map(|n| n.payload.label()).collect();
|
||||
labels.sort();
|
||||
assert_eq!(labels, ["create_forge_user", "mint_agent_forge_token"]);
|
||||
}
|
||||
|
||||
/// The socket must not share a directory with anything else, because
|
||||
/// the socket is `0666` and the directory is therefore the only access
|
||||
/// control it has. `/run/hyperhive` in particular holds hive-c0re's
|
||||
|
|
|
|||
|
|
@ -260,6 +260,25 @@ impl SecretStore {
|
|||
.await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// The names of every cert-auth role under `mount`, or none when the
|
||||
/// mount has no roles at all.
|
||||
///
|
||||
/// **Only a 404 is "none".** The store answers a `LIST` on an empty
|
||||
/// directory with a 404; a denial is a 403 and stays an error, so a caller
|
||||
/// iterating the result never mistakes "not allowed to look" for "nothing
|
||||
/// there".
|
||||
///
|
||||
/// # Errors
|
||||
/// [`Error::Vault`] for anything that is not a 404: a denial, or an
|
||||
/// unreachable store.
|
||||
pub async fn list_cert_roles(&self, mount: &str) -> Result<Vec<String>, Error> {
|
||||
match vaultrs::auth::cert::ca_cert_role::list(&self.inner, mount).await {
|
||||
Ok(listed) => Ok(listed.keys),
|
||||
Err(vaultrs::error::ClientError::APIError { code: 404, .. }) => Ok(Vec::new()),
|
||||
Err(e) => Err(e.into()),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Write an ACL policy, spelled out here because [`vaultrs`] does not have it:
|
||||
|
|
|
|||
137
swarm-secret-client/src/forge.rs
Normal file
137
swarm-secret-client/src/forge.rs
Normal file
|
|
@ -0,0 +1,137 @@
|
|||
//! The forge agreement: where an agent's own forge token lives in the store,
|
||||
//! and what the object at that path holds.
|
||||
//!
|
||||
//! `swarm-controller` mints the token with the forge's admin API and writes it
|
||||
//! here; the agent container reads it back under its own certificate
|
||||
//! (`nix/agent-modules/forge-token.nix`). Neither end is senior, so both halves
|
||||
//! are stated once, here, beside [`crate::matrix`].
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
use crate::{
|
||||
Error,
|
||||
path::{Kind, principal_prefix},
|
||||
};
|
||||
|
||||
/// The path holding `agent`'s own forge access token.
|
||||
///
|
||||
/// A flat leaf under the agent's prefix, like its controller-minted siblings
|
||||
/// [`crate::queue::agent_queue_path`] and [`crate::mtls::identity_path`], so
|
||||
/// the agent's own read stanza ([`crate::policy::render_agent_with_queue`])
|
||||
/// already covers it.
|
||||
///
|
||||
/// # Errors
|
||||
/// [`Error::PathSegment`] when `agent` contains anything but `[A-Za-z0-9_-]`,
|
||||
/// which is what keeps one agent's name from addressing another agent's secret.
|
||||
pub fn agent_token_path(agent: &str) -> Result<String, Error> {
|
||||
let prefix = principal_prefix(Kind::Agent, agent)?;
|
||||
Ok(format!("{prefix}/forge-token"))
|
||||
}
|
||||
|
||||
/// What an agent's forge-token path holds.
|
||||
///
|
||||
/// No `Debug` derive: `value` is a live forge credential, and a derived
|
||||
/// `Debug` is one `{:?}` away from a log line.
|
||||
#[derive(Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct Credential {
|
||||
/// The token itself. `forge-token.nix` reads the store with
|
||||
/// `bao kv get -field=value`, so this name is load-bearing for a reader
|
||||
/// this crate does not control, the same as [`crate::matrix::Credential`]'s.
|
||||
pub value: String,
|
||||
|
||||
/// The forge's name for the token — not secret. Stored beside the value
|
||||
/// so whoever rotates it can name the token it replaces.
|
||||
pub name: String,
|
||||
}
|
||||
|
||||
impl std::fmt::Debug for Credential {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
f.debug_struct("Credential")
|
||||
.field("value", &"<redacted>")
|
||||
.field("name", &self.name)
|
||||
.finish()
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn the_token_lands_under_the_agent_prefix() {
|
||||
// Spelled out rather than rebuilt from the pieces the code uses: the
|
||||
// nix reader spells the same string, and a rename here that moved it
|
||||
// would leave that reader fetching nothing.
|
||||
assert_eq!(
|
||||
agent_token_path("atlas").expect("a plain name is legal"),
|
||||
"swarm/agents/atlas/forge-token"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_traversal_in_the_agent_name_is_refused() {
|
||||
for bad in ["../argus", "a/b", "atlas/../argus", "a.b", ""] {
|
||||
let e = agent_token_path(bad).expect_err("a traversal is not legal");
|
||||
assert!(matches!(e, Error::PathSegment { kind: "agent", .. }), "{e}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_legal_charset_is_actually_reachable() {
|
||||
// The control for the test above: a guard that refused everything
|
||||
// would pass it for the wrong reason.
|
||||
assert!(agent_token_path("a-b_C9").is_ok());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_agents_own_read_grant_covers_the_path() {
|
||||
// The agent reads this path under `hive-agent-<agent>`, and that
|
||||
// policy is rendered elsewhere. If the path ever moved out from under
|
||||
// it, the agent's fetch would 403 at boot, naming neither.
|
||||
let path = agent_token_path("atlas").expect("legal");
|
||||
let policy = crate::policy::render_agent_with_queue("atlas", "pr1ma").expect("legal");
|
||||
let covered = policy.lines().any(|line| {
|
||||
line.strip_prefix("path \"")
|
||||
.and_then(|rest| rest.split_once("\" {"))
|
||||
.and_then(|(p, _)| p.strip_suffix('*'))
|
||||
.is_some_and(|prefix| format!("secret/data/{path}").starts_with(prefix))
|
||||
});
|
||||
assert!(covered, "no stanza in\n{policy}\ncovers {path}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn another_agents_read_grant_does_not_cover_the_path() {
|
||||
// Control for the test above: the prefix match must actually be
|
||||
// discriminating, or it proves nothing.
|
||||
let path = agent_token_path("atlas").expect("legal");
|
||||
let policy = crate::policy::render_agent_with_queue("argus", "pr1ma").expect("legal");
|
||||
let covered = policy.lines().any(|line| {
|
||||
line.strip_prefix("path \"")
|
||||
.and_then(|rest| rest.split_once("\" {"))
|
||||
.and_then(|(p, _)| p.strip_suffix('*'))
|
||||
.is_some_and(|prefix| format!("secret/data/{path}").starts_with(prefix))
|
||||
});
|
||||
assert!(!covered, "argus's policy must not reach atlas's token");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_value_field_matches_what_the_nix_reader_asks_for() {
|
||||
let json = serde_json::to_string(&Credential {
|
||||
value: "t".to_owned(),
|
||||
name: "swarm-agent".to_owned(),
|
||||
})
|
||||
.expect("two Strings serialise");
|
||||
assert_eq!(json, r#"{"value":"t","name":"swarm-agent"}"#);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn debug_never_prints_the_value() {
|
||||
let c = Credential {
|
||||
value: "0123456789abcdef".to_owned(),
|
||||
name: "swarm-agent".to_owned(),
|
||||
};
|
||||
let shown = format!("{c:?}");
|
||||
assert!(!shown.contains("0123456789abcdef"), "{shown}");
|
||||
assert!(shown.contains("swarm-agent"), "{shown}");
|
||||
}
|
||||
}
|
||||
|
|
@ -5,7 +5,7 @@
|
|||
//! the rules every path obeys ([`path`]), the translation from this
|
||||
//! deployment's environment into a logged-in client ([`client`]), and, per kind
|
||||
//! of secret, the path it lives at together with the fields it holds
|
||||
//! ([`matrix`], [`queue`], [`mtls`]). Each of those is a thing the controller
|
||||
//! ([`matrix`], [`queue`], [`mtls`], [`forge`]). Each of those is a thing the controller
|
||||
//! and a hive must say identically, so it is said once here.
|
||||
//!
|
||||
//! [`policy`] is the same kind of agreement seen from the other side: which of
|
||||
|
|
@ -23,6 +23,7 @@
|
|||
//! value inside it, and its doc explains why that is not circular.
|
||||
|
||||
pub mod client;
|
||||
pub mod forge;
|
||||
pub mod matrix;
|
||||
pub mod mtls;
|
||||
pub mod path;
|
||||
|
|
|
|||
|
|
@ -96,6 +96,23 @@ pub fn agent_object_name(agent: &str) -> Result<String, Error> {
|
|||
Ok(format!("{AGENT_PREFIX}{agent}"))
|
||||
}
|
||||
|
||||
/// The agents among `names`, a listing of cert-auth roles: the inverse of
|
||||
/// [`agent_object_name`], for a caller that needs the set of agents holding a
|
||||
/// store identity.
|
||||
///
|
||||
/// A name that carries the prefix but whose suffix is not a legal segment is
|
||||
/// dropped rather than returned: [`agent_object_name`] could not have produced
|
||||
/// it, so it is not an agent this crate minted.
|
||||
#[must_use]
|
||||
pub fn agents_from_role_names(names: &[String]) -> Vec<String> {
|
||||
names
|
||||
.iter()
|
||||
.filter_map(|name| name.strip_prefix(AGENT_PREFIX))
|
||||
.filter(|agent| checked_segment("agent", agent).is_ok())
|
||||
.map(str::to_owned)
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// One read stanza. The only shape this module emits, so "read-only" is a
|
||||
/// property of the renderer rather than of each call site.
|
||||
fn read_stanza(path: &str) -> String {
|
||||
|
|
@ -595,4 +612,33 @@ mod tests {
|
|||
let expected_queue_path = crate::queue::agent_client_path("pr1ma").expect("legal");
|
||||
assert!(p.contains(&expected_queue_path));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn only_agent_roles_come_back_and_without_their_prefix() {
|
||||
let roles: Vec<String> = [
|
||||
"hive-agent-atlas",
|
||||
"hive-pr1ma",
|
||||
"swarm-controller",
|
||||
"hive-agent-argus",
|
||||
]
|
||||
.map(str::to_owned)
|
||||
.to_vec();
|
||||
assert_eq!(agents_from_role_names(&roles), ["atlas", "argus"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_role_agent_object_name_could_not_have_produced_is_dropped() {
|
||||
// An empty suffix and one carrying a dot are both names the prefix
|
||||
// matches and no agent can have.
|
||||
let roles: Vec<String> = ["hive-agent-", "hive-agent-a.b", "hive-agent-ok"]
|
||||
.map(str::to_owned)
|
||||
.to_vec();
|
||||
assert_eq!(agents_from_role_names(&roles), ["ok"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_listing_inverts_agent_object_name() {
|
||||
let name = agent_object_name("atlas").expect("legal");
|
||||
assert_eq!(agents_from_role_names(&[name]), ["atlas"]);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
//! `swarmctl agent create` and `swarmctl agent mint-identity` — queue work
|
||||
//! on the swarm-controller's job graph.
|
||||
//! `swarmctl agent create`, `swarmctl agent mint-identity` and `swarmctl agent
|
||||
//! mint-forge-token` — queue work on the swarm-controller's job graph.
|
||||
//!
|
||||
//! Each POSTs and returns as soon as the work is *inserted*. Both verbs
|
||||
//! print the queued node id and stop: creation's last node only *publishes*
|
||||
|
|
@ -64,6 +64,12 @@ struct MintIdentityResponse {
|
|||
node_id: u64,
|
||||
}
|
||||
|
||||
/// Success body of `POST /api/agents/{name}/forge-token`, which takes no body.
|
||||
#[derive(Deserialize)]
|
||||
struct MintForgeTokenResponse {
|
||||
node_id: u64,
|
||||
}
|
||||
|
||||
/// Run `swarmctl agent create`.
|
||||
///
|
||||
/// Synchronous on purpose: every other verb in this crate is, and this is
|
||||
|
|
@ -137,6 +143,33 @@ pub(crate) fn mint_identity(socket: &Path, name: &str, hive: &str) -> Result<()>
|
|||
Ok(())
|
||||
}
|
||||
|
||||
/// Run `swarmctl agent mint-forge-token`.
|
||||
///
|
||||
/// Synchronous for the same reason [`create`] is, and built on the same
|
||||
/// round trip. No `--hive`: the token's store path has no hive in it.
|
||||
pub(crate) fn mint_forge_token(socket: &Path, name: &str) -> Result<()> {
|
||||
let name = parse_ident(name, "agent name")?;
|
||||
|
||||
let rt = tokio::runtime::Builder::new_current_thread()
|
||||
.enable_io()
|
||||
.build()
|
||||
.context("starting a tokio runtime for the controller request")?;
|
||||
let resp: MintForgeTokenResponse = rt.block_on(post(
|
||||
socket,
|
||||
&format!("/api/agents/{name}/forge-token"),
|
||||
&serde_json::json!({}),
|
||||
"mint-forge-token",
|
||||
))?;
|
||||
|
||||
println!("queued: job node {}", resp.node_id);
|
||||
println!(
|
||||
"agent {name:?}'s forge token will be checked once the job graph runs, and minted \
|
||||
only if it is missing or stale; `swarmctl` does not wait for it. The agent picks a \
|
||||
new token up on its next fetch"
|
||||
);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// One `POST /api/agents` round trip over the controller's unix socket.
|
||||
async fn post_create(socket: &Path, name: &str, hive: &str) -> Result<CreateAgentResponse> {
|
||||
post(
|
||||
|
|
|
|||
|
|
@ -184,6 +184,15 @@ enum AgentVerb {
|
|||
/// Queues and returns, the same way `agent create` does — watch the
|
||||
/// swarm UI's job view for the outcome.
|
||||
MintIdentity(AgentMintIdentityArgs),
|
||||
/// Check one agent's forge token, and mint it if it's missing or stale.
|
||||
///
|
||||
/// swarm-controller does this for every agent with a store identity at
|
||||
/// start and every five minutes; this is for when waiting isn't an
|
||||
/// option. It leaves a current token alone.
|
||||
///
|
||||
/// Queues and returns, the same way `agent create` does — watch the
|
||||
/// swarm UI's job view for the outcome.
|
||||
MintForgeToken(AgentMintForgeTokenArgs),
|
||||
}
|
||||
|
||||
#[derive(Args)]
|
||||
|
|
@ -210,6 +219,19 @@ struct AgentCreateArgs {
|
|||
controller_socket: Option<PathBuf>,
|
||||
}
|
||||
|
||||
#[derive(Args)]
|
||||
struct AgentMintForgeTokenArgs {
|
||||
/// Name of an agent that already exists.
|
||||
name: String,
|
||||
/// swarm-controller's unix socket.
|
||||
///
|
||||
/// Supplied by the nix module that installs this binary, from the same
|
||||
/// `socketPath` option the daemon binds; falls back to
|
||||
/// `SWARM_CONTROLLER_SOCKET`.
|
||||
#[arg(long, value_name = "PATH")]
|
||||
controller_socket: Option<PathBuf>,
|
||||
}
|
||||
|
||||
#[derive(Args)]
|
||||
struct AgentMintIdentityArgs {
|
||||
/// Name of an agent that already exists.
|
||||
|
|
@ -308,6 +330,13 @@ fn main() -> Result<()> {
|
|||
let socket = path_from(args.controller_socket, "SWARM_CONTROLLER_SOCKET")?;
|
||||
agent::mint_identity(&socket, &args.name, &args.hive)
|
||||
}
|
||||
// Same socket-resolution reasoning as `Create` above.
|
||||
Verb::Agent {
|
||||
command: AgentVerb::MintForgeToken(args),
|
||||
} => {
|
||||
let socket = path_from(args.controller_socket, "SWARM_CONTROLLER_SOCKET")?;
|
||||
agent::mint_forge_token(&socket, &args.name)
|
||||
}
|
||||
// Resolved lazily, inside the one arm that actually touches the
|
||||
// deployment env vars — see the `MarkdownDocs` doc comment above
|
||||
// for why an unconditional resolve up front would be wrong.
|
||||
|
|
@ -697,6 +726,31 @@ mod tests {
|
|||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_forge_token_verb_takes_an_agent_and_no_hive() {
|
||||
let cli = Cli::try_parse_from(["swarmctl", "agent", "mint-forge-token", "scribe"])
|
||||
.expect("the minimal form parses");
|
||||
let Verb::Agent {
|
||||
command: AgentVerb::MintForgeToken(args),
|
||||
} = cli.command
|
||||
else {
|
||||
panic!("expected `agent mint-forge-token`");
|
||||
};
|
||||
assert_eq!(args.name, "scribe");
|
||||
assert!(
|
||||
Cli::try_parse_from([
|
||||
"swarmctl",
|
||||
"agent",
|
||||
"mint-forge-token",
|
||||
"scribe",
|
||||
"--hive",
|
||||
"a"
|
||||
])
|
||||
.is_err(),
|
||||
"the token has no hive, so the verb must not take one"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parses_authelia_hash_output() {
|
||||
let out = "Random Password: hunter2\nDigest: $argon2id$v=19$m=65536$abc\n";
|
||||
|
|
|
|||
Loading…
Reference in a new issue