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
|
|
@ -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,
|
||||
|
|
|
|||
671
swarm-controller/src/forge/hive_token.rs
Normal file
671
swarm-controller/src/forge/hive_token.rs
Normal file
|
|
@ -0,0 +1,671 @@
|
|||
//! Each hive's read-only account on the swarm's forge, `hive-<hive>`: created
|
||||
//! here with the admin API, put in the [`HIVES_TEAM`] of `agent-configs` (read
|
||||
//! on every config repo, including ones created later), and given one token,
|
||||
//! stored at `swarm/hives/<hive>/forge/token`, where hive-c0re reads it under
|
||||
//! the hive's own store identity.
|
||||
//!
|
||||
//! The roster is the controller's hive directory, as for
|
||||
//! `matrix_account::hive_sender`. The token decision is
|
||||
//! [`super::agent_token`]'s, under this module's name and scopes, so a stored
|
||||
//! token the forge still lists with the same last eight characters and scopes
|
||||
//! is kept, and a pass whose reads fail queues nothing.
|
||||
//!
|
||||
//! ⚠️ The account name is not reserved: an agent may be called `hive-<x>`.
|
||||
//! Each side refuses an account carrying the other's email, so neither ever
|
||||
//! locks down, joins the team with, or mints a token for the other's account.
|
||||
|
||||
use std::sync::Arc;
|
||||
|
||||
use anyhow::{Context, Result, bail};
|
||||
use forgejo_api::structs::{
|
||||
AccessToken, CreateTeamOption, CreateTeamOptionPermission, EditTeamOption,
|
||||
EditTeamOptionPermission, RepoSearchQuery, Team, TeamPermission,
|
||||
};
|
||||
use forgejo_api::{Auth, Forgejo};
|
||||
use swarm_secret_client::forge;
|
||||
|
||||
use super::agent_token::{Decision, Observed, classify_named, is_not_found};
|
||||
use super::{CONFIG_ORG, Client, folds_into_success};
|
||||
|
||||
/// The forge's name for every hive token this module mints.
|
||||
pub const HIVE_TOKEN_NAME: &str = "swarm-hive";
|
||||
|
||||
/// What a hive token may do: read repositories. Which repositories is the
|
||||
/// account's business, and the account is only in [`HIVES_TEAM`].
|
||||
pub const HIVE_TOKEN_SCOPES: &str = "read:repository";
|
||||
|
||||
/// The team in `agent-configs` every hive account is a member of.
|
||||
const HIVES_TEAM: &str = "hives";
|
||||
|
||||
const HIVES_TEAM_DESCRIPTION: &str = "hyperhive hives — read on every agent config repo";
|
||||
|
||||
/// What a hive reads in a config repo: the git tree, and which PR merged a
|
||||
/// commit.
|
||||
const HIVES_TEAM_UNITS: [&str; 2] = ["repo.code", "repo.pulls"];
|
||||
|
||||
/// How often [`spawn`] re-checks every hive.
|
||||
const RECONCILE_INTERVAL: std::time::Duration = std::time::Duration::from_mins(5);
|
||||
|
||||
/// The email this module creates `hive`'s account with, and the one it
|
||||
/// requires before acting on an account by that name.
|
||||
///
|
||||
/// Under its own domain, so it can equal neither email an agent account
|
||||
/// carries (`super::agent_email`, `super::legacy_agent_email`) whatever the
|
||||
/// agent is called.
|
||||
fn hive_email(hive: &str) -> String {
|
||||
format!("{}@hives.hyperhive.local", forge::hive_login(hive))
|
||||
}
|
||||
|
||||
/// Whether the account named [`forge::hive_login`]`(hive)`, carrying `email`,
|
||||
/// is the one this module creates.
|
||||
fn is_hive_account(hive: &str, email: Option<&str>) -> bool {
|
||||
email == Some(hive_email(hive).as_str())
|
||||
}
|
||||
|
||||
/// Whether `t` has every setting [`Client::ensure_hives_team`] gives it.
|
||||
fn hives_team_matches(t: &Team) -> bool {
|
||||
let units_match = t.units.as_ref().is_some_and(|units| {
|
||||
let mut have: Vec<&str> = units.iter().map(String::as_str).collect();
|
||||
let mut want = HIVES_TEAM_UNITS.to_vec();
|
||||
have.sort_unstable();
|
||||
want.sort_unstable();
|
||||
have == want
|
||||
});
|
||||
units_match
|
||||
&& t.permission == Some(TeamPermission::Read)
|
||||
&& t.includes_all_repositories == Some(true)
|
||||
&& t.can_create_org_repo == Some(false)
|
||||
&& t.description.as_deref() == Some(HIVES_TEAM_DESCRIPTION)
|
||||
}
|
||||
|
||||
/// [`HIVES_TEAM`] as one pass found it.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum TeamSeen {
|
||||
/// Present with every setting [`hives_team_matches`] checks.
|
||||
Matches(i64),
|
||||
/// Present with some other setting, which may be a wider grant.
|
||||
Drifted(i64),
|
||||
Absent,
|
||||
/// The read failed.
|
||||
Unknown,
|
||||
}
|
||||
|
||||
/// What one pass found for one hive.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub struct HiveSeen {
|
||||
pub token: Observed,
|
||||
/// Whether the account is in [`HIVES_TEAM`]; `None` when that read failed
|
||||
/// or the team id is not known.
|
||||
pub member: Option<bool>,
|
||||
}
|
||||
|
||||
/// The hives a pass queues a `MintHiveForgeToken` job for.
|
||||
///
|
||||
/// A token that has to be minted, or an account that does not exist, always
|
||||
/// queues. A kept token queues only when the account or the team is not as
|
||||
/// the job leaves them; that job finds the token current again and does not
|
||||
/// rotate it.
|
||||
///
|
||||
/// [`Observed::Unknown`] never queues: a forge or store outage must not turn
|
||||
/// into a rotation of every hive's token. Neither does a
|
||||
/// [`Decision::Conflict`], which the job would refuse anyway.
|
||||
pub fn plan(team: TeamSeen, hives: &[(String, HiveSeen)]) -> Vec<String> {
|
||||
let team_needs_work = matches!(team, TeamSeen::Absent | TeamSeen::Drifted(_));
|
||||
hives
|
||||
.iter()
|
||||
.filter(|(_, seen)| match seen.token {
|
||||
Observed::Unknown | Observed::Decided(Decision::Conflict) => false,
|
||||
Observed::NoForgeUser | Observed::Decided(Decision::Mint(_)) => true,
|
||||
Observed::Decided(Decision::Keep) => team_needs_work || seen.member == Some(false),
|
||||
})
|
||||
.map(|(hive, _)| hive.clone())
|
||||
.collect()
|
||||
}
|
||||
|
||||
impl Client {
|
||||
/// Make sure `hive`'s account exists, is in [`HIVES_TEAM`], and holds a
|
||||
/// live [`HIVE_TOKEN_NAME`] token in the store, minting one when it does
|
||||
/// not. The whole job of the `MintHiveForgeToken` node.
|
||||
///
|
||||
/// A rotation is [`Client::replace_token`]'s delete then create. The new
|
||||
/// token is used once, and the forge's and the store's state is read back
|
||||
/// and classified again, so the node does not report success on a write
|
||||
/// nobody has read.
|
||||
///
|
||||
/// # Errors
|
||||
/// When the store or the forge refuses a step, when an account by the
|
||||
/// hive's name exists and is not the hive's, or when the read-back does
|
||||
/// not show the new token.
|
||||
pub async fn ensure_hive_forge_token(&self, hive: &str) -> Result<()> {
|
||||
let login = forge::hive_login(hive);
|
||||
let path = forge::hive_token_path(hive)?;
|
||||
self.ensure_hive_user(hive, &login).await?;
|
||||
let team = self.ensure_hives_team().await?;
|
||||
self.api
|
||||
.org_add_team_member(team, &login)
|
||||
.await
|
||||
.with_context(|| format!("adding {login} to {CONFIG_ORG}/{HIVES_TEAM}"))?;
|
||||
|
||||
let store = crate::store::connect()
|
||||
.await
|
||||
.context("logging in to the swarm secret store")?;
|
||||
let (stored, listed) = self.read_hive_token(&store, &login, &path).await?;
|
||||
let reason =
|
||||
match classify_named(HIVE_TOKEN_NAME, HIVE_TOKEN_SCOPES, stored.as_ref(), &listed) {
|
||||
Decision::Keep => {
|
||||
tracing::debug!(hive, "hive forge token is current; left as it is");
|
||||
return Ok(());
|
||||
}
|
||||
Decision::Conflict => bail!(
|
||||
"the forge lists more than one {HIVE_TOKEN_NAME:?} token for {login:?}; \
|
||||
delete them by id and re-run"
|
||||
),
|
||||
Decision::Mint(reason) => reason,
|
||||
};
|
||||
let credential = self
|
||||
.replace_token(
|
||||
&store,
|
||||
&login,
|
||||
HIVE_TOKEN_NAME,
|
||||
HIVE_TOKEN_SCOPES,
|
||||
&listed,
|
||||
&path,
|
||||
)
|
||||
.await?;
|
||||
|
||||
Forgejo::new(Auth::Token(&credential.value), self.url.clone())
|
||||
.context("building a forge client with the new token")?
|
||||
.repo_search(RepoSearchQuery::default())
|
||||
.await
|
||||
.with_context(|| format!("using {login}'s new forge token"))?;
|
||||
let (stored, listed) = self.read_hive_token(&store, &login, &path).await?;
|
||||
let now = classify_named(HIVE_TOKEN_NAME, HIVE_TOKEN_SCOPES, stored.as_ref(), &listed);
|
||||
if now != Decision::Keep {
|
||||
bail!("{login}'s new forge token does not read back as current: {now:?}");
|
||||
}
|
||||
tracing::info!(hive, ?reason, %path, "hive forge token minted and stored");
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// The stored token at `path` and every token the forge lists for
|
||||
/// `login`, which must exist.
|
||||
async fn read_hive_token(
|
||||
&self,
|
||||
store: &swarm_secret_client::SecretStore,
|
||||
login: &str,
|
||||
path: &str,
|
||||
) -> Result<(Option<forge::Credential>, Vec<AccessToken>)> {
|
||||
let stored = store
|
||||
.read_optional(path)
|
||||
.await
|
||||
.with_context(|| format!("reading {path}"))?;
|
||||
let Some(listed) = self.list_agent_tokens(login).await? else {
|
||||
bail!("the forge has no user {login:?} after this job created it");
|
||||
};
|
||||
Ok((stored, listed))
|
||||
}
|
||||
|
||||
/// Create `hive`'s account if there is none, refuse one that is not the
|
||||
/// hive's, and lock it out of creating repos.
|
||||
async fn ensure_hive_user(&self, hive: &str, login: &str) -> Result<()> {
|
||||
self.create_user(login, &hive_email(hive)).await?;
|
||||
let user = self
|
||||
.api
|
||||
.user_get(login)
|
||||
.await
|
||||
.with_context(|| format!("read forge user {login}"))?;
|
||||
if !is_hive_account(hive, user.email.as_deref()) {
|
||||
bail!(
|
||||
"forge account `{login}` exists but is not hive `{hive}`'s (email {:?}, expected \
|
||||
`{}`); refusing to give it the hive's access",
|
||||
user.email,
|
||||
hive_email(hive)
|
||||
);
|
||||
}
|
||||
self.api
|
||||
.admin_edit_user(login, super::repo_creation_lockdown(login))
|
||||
.await
|
||||
.with_context(|| format!("disable repo creation for forge user {login}"))?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// The id of [`HIVES_TEAM`] in `agent-configs`, creating it or
|
||||
/// correcting its settings first when needed. Its members are not
|
||||
/// touched here.
|
||||
async fn ensure_hives_team(&self) -> Result<i64> {
|
||||
match self.observe_hives_team().await {
|
||||
TeamSeen::Matches(id) => Ok(id),
|
||||
TeamSeen::Drifted(id) => {
|
||||
self.api
|
||||
.org_edit_team(
|
||||
id,
|
||||
EditTeamOption {
|
||||
can_create_org_repo: Some(false),
|
||||
description: Some(HIVES_TEAM_DESCRIPTION.to_owned()),
|
||||
includes_all_repositories: Some(true),
|
||||
name: HIVES_TEAM.to_owned(),
|
||||
permission: Some(EditTeamOptionPermission::Read),
|
||||
units: Some(HIVES_TEAM_UNITS.map(str::to_owned).to_vec()),
|
||||
units_map: None,
|
||||
},
|
||||
)
|
||||
.await
|
||||
.with_context(|| {
|
||||
format!("reconcile {CONFIG_ORG}/{HIVES_TEAM} team settings")
|
||||
})?;
|
||||
tracing::info!("swarm forge: reconciled {CONFIG_ORG}/{HIVES_TEAM} team settings");
|
||||
Ok(id)
|
||||
}
|
||||
TeamSeen::Absent => self.create_hives_team().await,
|
||||
TeamSeen::Unknown => bail!("could not read the teams of {CONFIG_ORG}"),
|
||||
}
|
||||
}
|
||||
|
||||
async fn create_hives_team(&self) -> Result<i64> {
|
||||
let team = CreateTeamOption {
|
||||
can_create_org_repo: Some(false),
|
||||
description: Some(HIVES_TEAM_DESCRIPTION.to_owned()),
|
||||
includes_all_repositories: Some(true),
|
||||
name: HIVES_TEAM.to_owned(),
|
||||
permission: Some(CreateTeamOptionPermission::Read),
|
||||
units: Some(HIVES_TEAM_UNITS.map(str::to_owned).to_vec()),
|
||||
units_map: None,
|
||||
};
|
||||
match self.api.org_create_team(CONFIG_ORG, team).await {
|
||||
Ok(created) => {
|
||||
tracing::info!("swarm forge: created {CONFIG_ORG}/{HIVES_TEAM} team");
|
||||
created
|
||||
.id
|
||||
.context("the forge created the hives team but returned no id")
|
||||
}
|
||||
// A duplicate is a 409 or a 422; either counts once a re-read
|
||||
// finds the team, which also supplies its id.
|
||||
Err(e) => match self.observe_hives_team().await {
|
||||
TeamSeen::Matches(id) | TeamSeen::Drifted(id) if folds_into_success(&e, true) => {
|
||||
Ok(id)
|
||||
}
|
||||
_ => Err(e).with_context(|| format!("create team {CONFIG_ORG}/{HIVES_TEAM}")),
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
async fn observe_hives_team(&self) -> TeamSeen {
|
||||
match self.api.org_list_teams(CONFIG_ORG).await {
|
||||
Ok((_, teams)) => teams
|
||||
.into_iter()
|
||||
.find(|t| t.name.as_deref() == Some(HIVES_TEAM))
|
||||
.map_or(TeamSeen::Absent, |t| match t.id {
|
||||
Some(id) if hives_team_matches(&t) => TeamSeen::Matches(id),
|
||||
Some(id) => TeamSeen::Drifted(id),
|
||||
None => TeamSeen::Unknown,
|
||||
}),
|
||||
Err(e) => {
|
||||
tracing::warn!(error = %e, "hive forge tokens: reading the teams of {CONFIG_ORG} failed");
|
||||
TeamSeen::Unknown
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// What the store, the forge and the team say about `hive`.
|
||||
async fn observe_hive(
|
||||
&self,
|
||||
store: &swarm_secret_client::SecretStore,
|
||||
team: TeamSeen,
|
||||
hive: &str,
|
||||
) -> HiveSeen {
|
||||
let login = forge::hive_login(hive);
|
||||
let stored = match forge::hive_token_path(hive) {
|
||||
Ok(path) => store.read_optional::<forge::Credential>(&path).await,
|
||||
Err(e) => Err(e),
|
||||
};
|
||||
let token = match stored {
|
||||
Err(e) => {
|
||||
tracing::warn!(hive, error = %e, "hive forge token: store read failed");
|
||||
Observed::Unknown
|
||||
}
|
||||
Ok(stored) => match self.list_agent_tokens(&login).await {
|
||||
Ok(Some(listed)) => Observed::Decided(classify_named(
|
||||
HIVE_TOKEN_NAME,
|
||||
HIVE_TOKEN_SCOPES,
|
||||
stored.as_ref(),
|
||||
&listed,
|
||||
)),
|
||||
Ok(None) => Observed::NoForgeUser,
|
||||
Err(e) => {
|
||||
tracing::warn!(hive, error = %format!("{e:#}"), "hive forge token: forge read failed");
|
||||
Observed::Unknown
|
||||
}
|
||||
},
|
||||
};
|
||||
let member = match team {
|
||||
TeamSeen::Matches(id) | TeamSeen::Drifted(id) => {
|
||||
match self.api.org_list_team_member(id, &login).await {
|
||||
Ok(_) => Some(true),
|
||||
Err(e) if is_not_found(&e) => Some(false),
|
||||
Err(e) => {
|
||||
tracing::warn!(hive, error = %e, "hive forge token: team membership read failed");
|
||||
None
|
||||
}
|
||||
}
|
||||
}
|
||||
TeamSeen::Absent => Some(false),
|
||||
TeamSeen::Unknown => None,
|
||||
};
|
||||
HiveSeen { token, member }
|
||||
}
|
||||
|
||||
/// One pass: the team, then every hive in `hives`.
|
||||
async fn observe_hives(&self, hives: &[String]) -> Result<(TeamSeen, Vec<(String, HiveSeen)>)> {
|
||||
let store = crate::store::connect()
|
||||
.await
|
||||
.context("logging in to the swarm secret store")?;
|
||||
let team = self.observe_hives_team().await;
|
||||
let mut observed = Vec::with_capacity(hives.len());
|
||||
for hive in hives {
|
||||
observed.push((hive.clone(), self.observe_hive(&store, team, hive).await));
|
||||
}
|
||||
Ok((team, observed))
|
||||
}
|
||||
}
|
||||
|
||||
/// Check every hive now and every [`RECONCILE_INTERVAL`] after, and hand the
|
||||
/// hives that need a job to `enqueue`, which inserts a `MintHiveForgeToken`
|
||||
/// node for each.
|
||||
///
|
||||
/// The roster is the controller's hive directory. A pass that fails is
|
||||
/// logged and retried on the next tick; it never stops the daemon.
|
||||
pub fn spawn(
|
||||
client: Arc<Client>,
|
||||
hives: Vec<String>,
|
||||
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_hives(&hives).await {
|
||||
Ok((team, observed)) => {
|
||||
let queued = plan(team, &observed);
|
||||
if queued.is_empty() {
|
||||
tracing::debug!(checked = observed.len(), "hive forge tokens: all current");
|
||||
} else {
|
||||
tracing::info!(
|
||||
checked = observed.len(),
|
||||
queued = queued.len(),
|
||||
"hive forge tokens: queueing jobs"
|
||||
);
|
||||
enqueue(queued);
|
||||
}
|
||||
}
|
||||
Err(e) => tracing::warn!(
|
||||
error = %format!("{e:#}"),
|
||||
retry_in_s = RECONCILE_INTERVAL.as_secs(),
|
||||
"hive forge tokens: pass failed; retrying next tick"
|
||||
),
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::super::agent_token::MintReason;
|
||||
use super::*;
|
||||
|
||||
const VALUE: &str = "0123456789abcdef0123456789abcdef01234567";
|
||||
|
||||
fn stored() -> forge::Credential {
|
||||
forge::Credential {
|
||||
value: VALUE.to_owned(),
|
||||
name: HIVE_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()),
|
||||
}
|
||||
}
|
||||
|
||||
fn decide(stored: Option<&forge::Credential>, listed: &[AccessToken]) -> Decision {
|
||||
classify_named(HIVE_TOKEN_NAME, HIVE_TOKEN_SCOPES, stored, listed)
|
||||
}
|
||||
|
||||
fn seen(token: Observed, member: Option<bool>) -> HiveSeen {
|
||||
HiveSeen { token, member }
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_token_reads_repositories_and_nothing_else() {
|
||||
assert_eq!(HIVE_TOKEN_SCOPES, "read:repository");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn nothing_on_the_forge_or_in_the_store_creates() {
|
||||
assert_eq!(decide(None, &[]), Decision::Mint(MintReason::NotOnForge));
|
||||
let ours = token(HIVE_TOKEN_NAME, "01234567", &["read:repository"]);
|
||||
assert_eq!(decide(None, &[ours]), Decision::Mint(MintReason::NotStored));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_current_token_is_kept() {
|
||||
let ours = token(HIVE_TOKEN_NAME, "01234567", &["read:repository"]);
|
||||
assert_eq!(decide(Some(&stored()), &[ours]), Decision::Keep);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn another_value_or_wider_scopes_rotate() {
|
||||
let other = token(HIVE_TOKEN_NAME, "ffffffff", &["read:repository"]);
|
||||
assert_eq!(
|
||||
decide(Some(&stored()), &[other]),
|
||||
Decision::Mint(MintReason::LastEightMismatch)
|
||||
);
|
||||
let wider = token(HIVE_TOKEN_NAME, "01234567", &["write:repository"]);
|
||||
assert_eq!(
|
||||
decide(Some(&stored()), &[wider]),
|
||||
Decision::Mint(MintReason::ScopeMismatch)
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_agent_token_on_the_account_is_not_the_hive_token() {
|
||||
// The two names must differ, or a hive account would be judged by the
|
||||
// agent token's scopes and the other way round.
|
||||
assert_ne!(HIVE_TOKEN_NAME, super::super::agent_token::AGENT_TOKEN_NAME);
|
||||
let agents = token(
|
||||
super::super::agent_token::AGENT_TOKEN_NAME,
|
||||
"01234567",
|
||||
&["read:repository"],
|
||||
);
|
||||
assert_eq!(
|
||||
decide(Some(&stored()), &[agents]),
|
||||
Decision::Mint(MintReason::NotOnForge)
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn create_and_rotate_are_queued_and_keep_is_not() {
|
||||
let hives = [
|
||||
(
|
||||
"keep".to_owned(),
|
||||
seen(Observed::Decided(Decision::Keep), Some(true)),
|
||||
),
|
||||
(
|
||||
"rotate".to_owned(),
|
||||
seen(
|
||||
Observed::Decided(Decision::Mint(MintReason::LastEightMismatch)),
|
||||
Some(true),
|
||||
),
|
||||
),
|
||||
(
|
||||
"create".to_owned(),
|
||||
seen(Observed::NoForgeUser, Some(false)),
|
||||
),
|
||||
(
|
||||
"conflict".to_owned(),
|
||||
seen(Observed::Decided(Decision::Conflict), Some(true)),
|
||||
),
|
||||
];
|
||||
assert_eq!(plan(TeamSeen::Matches(7), &hives), ["rotate", "create"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_unknown_hive_is_never_queued() {
|
||||
// Not even when the team is missing, which queues every hive whose
|
||||
// token is known.
|
||||
let hives = [
|
||||
("a".to_owned(), seen(Observed::Unknown, Some(false))),
|
||||
("b".to_owned(), seen(Observed::Unknown, None)),
|
||||
];
|
||||
for team in [
|
||||
TeamSeen::Matches(7),
|
||||
TeamSeen::Drifted(7),
|
||||
TeamSeen::Absent,
|
||||
TeamSeen::Unknown,
|
||||
] {
|
||||
assert!(plan(team, &hives).is_empty(), "{team:?}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_kept_token_outside_the_team_is_queued() {
|
||||
let keep = |member| seen(Observed::Decided(Decision::Keep), member);
|
||||
let hives = [
|
||||
("in".to_owned(), keep(Some(true))),
|
||||
("out".to_owned(), keep(Some(false))),
|
||||
("unread".to_owned(), keep(None)),
|
||||
];
|
||||
assert_eq!(plan(TeamSeen::Matches(7), &hives), ["out"]);
|
||||
assert_eq!(plan(TeamSeen::Unknown, &hives), ["out"]);
|
||||
// A missing or drifted team queues every known hive: the job fixes the
|
||||
// team, and finds each token current.
|
||||
assert_eq!(plan(TeamSeen::Absent, &hives), ["in", "out", "unread"]);
|
||||
assert_eq!(plan(TeamSeen::Drifted(7), &hives), ["in", "out", "unread"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn only_the_hives_own_email_is_its_account() {
|
||||
assert!(is_hive_account(
|
||||
"alpha",
|
||||
Some("hive-alpha@hives.hyperhive.local")
|
||||
));
|
||||
assert!(!is_hive_account("alpha", None));
|
||||
assert!(!is_hive_account(
|
||||
"beta",
|
||||
Some("hive-alpha@hives.hyperhive.local")
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_agent_called_hive_x_is_not_hive_xs_account() {
|
||||
// Both ways round: the hive job refuses the agent's account, and the
|
||||
// agent's own guard (`disable_repo_creation`) refuses the hive's,
|
||||
// because neither accepts the other's email.
|
||||
let login = forge::hive_login("alpha");
|
||||
for agents in [
|
||||
super::super::agent_email(&login),
|
||||
super::super::legacy_agent_email(&login),
|
||||
] {
|
||||
assert!(!is_hive_account("alpha", Some(&agents)), "{agents}");
|
||||
assert_ne!(hive_email("alpha"), agents);
|
||||
}
|
||||
}
|
||||
|
||||
/// The job-side guard, against a stand-in forge: an existing account
|
||||
/// named `hive-alpha` that carries an agent's email is refused before the
|
||||
/// lockdown, so nothing after it (team, token) runs either.
|
||||
#[tokio::test]
|
||||
async fn the_job_refuses_an_agent_account_named_like_the_hive() {
|
||||
use super::super::tests::{lockdown_patch, stub_forge};
|
||||
|
||||
let (client, seen) = stub_forge(
|
||||
(409, r#"{"message":"user already exists"}"#),
|
||||
(200, r#"{"email":"hive-alpha@hyperhive.local"}"#),
|
||||
(200, "{}"),
|
||||
)
|
||||
.await;
|
||||
let err = client
|
||||
.ensure_hive_user("alpha", "hive-alpha")
|
||||
.await
|
||||
.expect_err("an agent's account is not the hive's");
|
||||
let msg = format!("{err:#}");
|
||||
assert!(msg.contains("not hive `alpha`'s"), "{msg}");
|
||||
assert!(lockdown_patch(&seen, "hive-alpha").is_none());
|
||||
}
|
||||
|
||||
/// The other way round: creating an agent called `hive-alpha` refuses
|
||||
/// hive `alpha`'s account rather than lock it down as the agent's.
|
||||
#[tokio::test]
|
||||
async fn an_agent_create_refuses_the_hives_account() {
|
||||
use super::super::tests::{lockdown_patch, stub_forge};
|
||||
|
||||
let (client, seen) = stub_forge(
|
||||
(409, r#"{"message":"user already exists"}"#),
|
||||
(200, r#"{"email":"hive-alpha@hives.hyperhive.local"}"#),
|
||||
(200, "{}"),
|
||||
)
|
||||
.await;
|
||||
client
|
||||
.ensure_agent_user("hive-alpha")
|
||||
.await
|
||||
.expect_err("the hive's account is not the agent's");
|
||||
assert!(lockdown_patch(&seen, "hive-alpha").is_none());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn the_job_locks_down_the_hives_own_account() {
|
||||
use super::super::tests::{lockdown_patch, stub_forge};
|
||||
|
||||
let (client, seen) = stub_forge(
|
||||
(409, r#"{"message":"user already exists"}"#),
|
||||
(200, r#"{"email":"hive-alpha@hives.hyperhive.local"}"#),
|
||||
(200, "{}"),
|
||||
)
|
||||
.await;
|
||||
client
|
||||
.ensure_hive_user("alpha", "hive-alpha")
|
||||
.await
|
||||
.expect("the hive's own account");
|
||||
let body = lockdown_patch(&seen, "hive-alpha").expect("no lockdown PATCH sent");
|
||||
assert_eq!(body["max_repo_creation"], 0);
|
||||
}
|
||||
|
||||
fn team(permission: TeamPermission, units: &[&str]) -> Team {
|
||||
Team {
|
||||
can_create_org_repo: Some(false),
|
||||
description: Some(HIVES_TEAM_DESCRIPTION.to_owned()),
|
||||
id: Some(7),
|
||||
includes_all_repositories: Some(true),
|
||||
name: Some(HIVES_TEAM.to_owned()),
|
||||
organization: None,
|
||||
permission: Some(permission),
|
||||
units: Some(units.iter().map(|u| (*u).to_owned()).collect()),
|
||||
units_map: None,
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_team_matches_only_as_a_read_team_on_every_repo() {
|
||||
assert!(hives_team_matches(&team(
|
||||
TeamPermission::Read,
|
||||
&["repo.pulls", "repo.code"]
|
||||
)));
|
||||
assert!(!hives_team_matches(&team(
|
||||
TeamPermission::Write,
|
||||
&HIVES_TEAM_UNITS
|
||||
)));
|
||||
assert!(!hives_team_matches(&team(
|
||||
TeamPermission::Read,
|
||||
&["repo.code"]
|
||||
)));
|
||||
let mut some_repos = team(TeamPermission::Read, &HIVES_TEAM_UNITS);
|
||||
some_repos.includes_all_repositories = Some(false);
|
||||
assert!(!hives_team_matches(&some_repos));
|
||||
}
|
||||
}
|
||||
Loading…
Reference in a new issue