hives read the forge through their own swarm-minted read account
swarm-controller now gives every hive in its directory a forge account, `hive-<hive>`, in a new `hives` team of `agent-configs` (read on `repo.code` + `repo.pulls`, every repo including later ones), and mints one `read:repository` token named `swarm-hive` into the store at `swarm/hives/<hive>/forge/token`. A five-minute pass queues a `MintHiveForgeToken` node for a hive whose token is missing or no longer matches the forge, whose account is missing, or whose team membership or team settings drifted. A hive whose store or forge read failed is never queued, so an outage cannot rotate every hive's token. The token decision reuses `agent_token`'s classifier and replace step, now parameterised by name and scopes. An agent may be called `hive-<x>`. The hive account is created with `hive-<hive>@hives.hyperhive.local` and the job refuses an account by that name with any other email before it locks it down, adds it to the team or mints for it; agent creation already refuses the hive's account by the same email check. The controller's bao policy gains the `swarm/hives/+/forge/token` leaf (create/read/update), pinned by the bao-grants module-eval assertion. The hive's own policy already reads `swarm/hives/<hive>/*`. hive-c0re reads that token through `forge::reader_token()`, falling back to the core token file when the store holds none, and uses it for: fetching a merged config rev (swarm deploy, `hivectl forge reconcile-config`), reseeding a purged `applied`, the meta-update cascade's fast-forward, seeding `proposed` on first deploy, and finding the PR that merged a refused rev. Those reads no longer require a local `hive-forge` container. The `/knowledge` clone moves from the forge host's boot sweep into the knowledge pull, so every hive clones it. Posting the deploy-failure comment on that PR stays on the core token. Refs #4851
This commit is contained in:
parent
c8472966e9
commit
21e3f626f4
17 changed files with 1222 additions and 138 deletions
|
|
@ -34,6 +34,7 @@ use utoipa::ToSchema;
|
|||
use crate::webhook::DeliveryKind;
|
||||
|
||||
pub mod agent_token;
|
||||
pub mod hive_token;
|
||||
pub mod legacy_tokens;
|
||||
pub mod objects;
|
||||
pub mod runner_token;
|
||||
|
|
@ -455,13 +456,20 @@ impl Client {
|
|||
/// Create `agent`'s account. Idempotent: an existing user (409/422) is
|
||||
/// folded into success, same as [`Self::ensure_org_repo`].
|
||||
async fn create_agent_user(&self, agent: &str) -> Result<()> {
|
||||
self.create_user(agent, &agent_email(agent)).await
|
||||
}
|
||||
|
||||
/// Create the account `login` with `email` and a throwaway password.
|
||||
/// Idempotent like [`Self::create_agent_user`], and like it proves only
|
||||
/// that *some* account named `login` exists: the caller checks the email.
|
||||
async fn create_user(&self, login: &str, email: &str) -> Result<()> {
|
||||
let password = crate::webhook::generate_hex_secret()
|
||||
.context("generating a throwaway password for the agent's forge account")?;
|
||||
.context("generating a throwaway password for a forge account")?;
|
||||
let res = self
|
||||
.api
|
||||
.admin_create_user(CreateUserOption {
|
||||
created_at: None,
|
||||
email: agent_email(agent),
|
||||
email: email.to_owned(),
|
||||
full_name: None,
|
||||
login_name: None,
|
||||
must_change_password: Some(false),
|
||||
|
|
@ -469,24 +477,24 @@ impl Client {
|
|||
restricted: None,
|
||||
send_notify: None,
|
||||
source_id: None,
|
||||
username: agent.to_owned(),
|
||||
username: login.to_owned(),
|
||||
visibility: None,
|
||||
})
|
||||
.await;
|
||||
match res {
|
||||
Ok(_) => {
|
||||
tracing::info!(%agent, "swarm forge: created agent forge user");
|
||||
tracing::info!(%login, "swarm forge: created forge user");
|
||||
Ok(())
|
||||
}
|
||||
Err(e) => {
|
||||
let existing =
|
||||
is_ambiguous_validation_failure(&e) && self.api.user_get(agent).await.is_ok();
|
||||
is_ambiguous_validation_failure(&e) && self.api.user_get(login).await.is_ok();
|
||||
if folds_into_success(&e, existing) {
|
||||
tracing::debug!(%agent, "swarm forge: agent forge user already exists");
|
||||
tracing::debug!(%login, "swarm forge: forge user already exists");
|
||||
Ok(())
|
||||
} else {
|
||||
tracing::error!(%agent, error = %e, "swarm forge: forge refused to create agent user");
|
||||
Err(e).with_context(|| format!("create forge user {agent}"))
|
||||
tracing::error!(%login, error = %e, "swarm forge: forge refused to create user");
|
||||
Err(e).with_context(|| format!("create forge user {login}"))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -1426,13 +1434,14 @@ mod tests {
|
|||
}
|
||||
|
||||
/// What [`stub_forge`] saw: method, path and JSON body of each request.
|
||||
type Seen = std::sync::Arc<std::sync::Mutex<Vec<(String, String, serde_json::Value)>>>;
|
||||
pub(super) type Seen =
|
||||
std::sync::Arc<std::sync::Mutex<Vec<(String, String, serde_json::Value)>>>;
|
||||
|
||||
/// A stand-in forge on a loopback port — never the real one — answering
|
||||
/// the admin user create (`POST`) with `create`, the pre-lockdown
|
||||
/// `user_get` (`GET`) with `get`, and the admin user edit (`PATCH`) with
|
||||
/// `edit` (status, JSON body each), and recording every request.
|
||||
async fn stub_forge(
|
||||
pub(super) async fn stub_forge(
|
||||
create: (u16, &'static str),
|
||||
get: (u16, &'static str),
|
||||
edit: (u16, &'static str),
|
||||
|
|
@ -1470,7 +1479,7 @@ mod tests {
|
|||
}
|
||||
|
||||
/// The `PATCH` [`Client::ensure_agent_user`] sent, if any.
|
||||
fn lockdown_patch(seen: &Seen, agent: &str) -> Option<serde_json::Value> {
|
||||
pub(super) fn lockdown_patch(seen: &Seen, agent: &str) -> Option<serde_json::Value> {
|
||||
let path = format!("/api/v1/admin/users/{agent}");
|
||||
seen.lock()
|
||||
.unwrap()
|
||||
|
|
|
|||
Loading…
Reference in a new issue