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
1 changed files with 1222 additions and 138 deletions
|
|
@ -98,9 +98,20 @@ fn last_eight(token: &str) -> Option<&str> {
|
|||
/// 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 {
|
||||
classify_named(AGENT_TOKEN_NAME, AGENT_TOKEN_SCOPES, stored, listed)
|
||||
}
|
||||
|
||||
/// [`classify`] for the token named `name` that should carry `scopes`, so a
|
||||
/// second kind of token this controller mints decides the same way.
|
||||
pub(super) fn classify_named(
|
||||
name: &str,
|
||||
scopes: &str,
|
||||
stored: Option<&forge::Credential>,
|
||||
listed: &[AccessToken],
|
||||
) -> Decision {
|
||||
let ours: Vec<&AccessToken> = listed
|
||||
.iter()
|
||||
.filter(|t| t.name.as_deref() == Some(AGENT_TOKEN_NAME))
|
||||
.filter(|t| t.name.as_deref() == Some(name))
|
||||
.collect();
|
||||
let token = match ours[..] {
|
||||
[] => return Decision::Mint(MintReason::NotOnForge),
|
||||
|
|
@ -114,7 +125,7 @@ pub fn classify(stored: Option<&forge::Credential>, listed: &[AccessToken]) -> D
|
|||
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(',')) {
|
||||
if listed_scopes != normalized_scopes(scopes.split(',')) {
|
||||
return Decision::Mint(MintReason::ScopeMismatch);
|
||||
}
|
||||
Decision::Keep
|
||||
|
|
@ -216,46 +227,16 @@ impl Client {
|
|||
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(
|
||||
let credential = self
|
||||
.replace_token(
|
||||
&store,
|
||||
agent,
|
||||
CreateAccessTokenOption {
|
||||
name: AGENT_TOKEN_NAME.to_owned(),
|
||||
repositories: None,
|
||||
scopes: Some(AGENT_TOKEN_SCOPES.split(',').map(str::to_owned).collect()),
|
||||
},
|
||||
AGENT_TOKEN_NAME,
|
||||
AGENT_TOKEN_SCOPES,
|
||||
&listed,
|
||||
&path,
|
||||
)
|
||||
.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}"))?;
|
||||
.await?;
|
||||
|
||||
let as_agent = Forgejo::new(Auth::Token(&credential.value), self.url.clone())
|
||||
.context("building a forge client with the new token")?;
|
||||
|
|
@ -270,6 +251,57 @@ impl Client {
|
|||
Ok(())
|
||||
}
|
||||
|
||||
/// Delete `user`'s token named `name` if `listed` has one, create a new
|
||||
/// one carrying `scopes`, and store it at `path`. A crash between any two
|
||||
/// steps leaves a state [`classify_named`] reads as a mint again.
|
||||
///
|
||||
/// # Errors
|
||||
/// When the forge refuses the delete or the create, answers the create
|
||||
/// with no value, or the store refuses the write.
|
||||
pub(super) async fn replace_token(
|
||||
&self,
|
||||
store: &swarm_secret_client::SecretStore,
|
||||
user: &str,
|
||||
name: &str,
|
||||
scopes: &str,
|
||||
listed: &[AccessToken],
|
||||
path: &str,
|
||||
) -> Result<forge::Credential> {
|
||||
if listed.iter().any(|t| t.name.as_deref() == Some(name)) {
|
||||
match self.api.admin_delete_user_access_token(user, name).await {
|
||||
Ok(()) => {}
|
||||
Err(e) if is_not_found(&e) => {}
|
||||
Err(e) => {
|
||||
return Err(e).with_context(|| format!("deleting {user}'s {name} token"));
|
||||
}
|
||||
}
|
||||
}
|
||||
let created = self
|
||||
.api
|
||||
.admin_create_user_access_token(
|
||||
user,
|
||||
CreateAccessTokenOption {
|
||||
name: name.to_owned(),
|
||||
repositories: None,
|
||||
scopes: Some(scopes.split(',').map(str::to_owned).collect()),
|
||||
},
|
||||
)
|
||||
.await
|
||||
.with_context(|| format!("creating {user}'s {name} token"))?;
|
||||
let Some(value) = created.sha1.filter(|v| !v.is_empty()) else {
|
||||
bail!("the forge created {user}'s token but returned no value for it");
|
||||
};
|
||||
let credential = forge::Credential {
|
||||
value,
|
||||
name: name.to_owned(),
|
||||
};
|
||||
store
|
||||
.write(path, &credential)
|
||||
.await
|
||||
.with_context(|| format!("storing {user}'s forge token at {path}"))?;
|
||||
Ok(credential)
|
||||
}
|
||||
|
||||
/// What the forge and the store say about `agent`'s token.
|
||||
pub(super) async fn observe_agent(
|
||||
&self,
|
||||
|
|
|
|||
Loading…
Reference in a new issue