swarm-controller grants internal/docs read to agents; hives stop doing it
Agent creation gains a GrantDocsRead node (after CreateForgeUser) that adds the agent as a read collaborator on internal/docs, and forge::docs_grant runs a pass over the store's hive-agent-* roster at start and every five minutes that queues the same job for any agent that is not one. The pass only adds; an unreadable roster or forge read changes nothing. hive-c0re drops shared_docs_access, its call in sync_agent, and the SHARED_ORG / SHARED_DOCS_REPO constants only it used.
This commit is contained in:
parent
4e7142ae53
commit
1ca758daa7
7 changed files with 643 additions and 59 deletions
|
|
@ -96,6 +96,11 @@ enum SwarmNodeKind {
|
|||
/// Add `agent` as a write collaborator on its own repo. See
|
||||
/// `forge::Client::add_repo_member`.
|
||||
AddRepoMember { agent: String },
|
||||
/// Add `agent` as a read collaborator on `internal/docs`. See
|
||||
/// `forge::docs_grant`.
|
||||
///
|
||||
/// Carries no hive: the grant is on a swarm-wide repo.
|
||||
GrantDocsRead { agent: String },
|
||||
/// Seed the agent's repo with `agent.nix` + `flake.nix`. See
|
||||
/// `forge::Client::seed_agent_config`.
|
||||
///
|
||||
|
|
@ -171,6 +176,7 @@ impl hive_jobq_wire::WireNode for SwarmNodeKind {
|
|||
SwarmNodeKind::CreateRepo { .. } => "create_repo".to_owned(),
|
||||
SwarmNodeKind::CreateForgeUser { .. } => "create_forge_user".to_owned(),
|
||||
SwarmNodeKind::AddRepoMember { .. } => "add_repo_member".to_owned(),
|
||||
SwarmNodeKind::GrantDocsRead { .. } => "grant_docs_read".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(),
|
||||
|
|
@ -197,6 +203,7 @@ impl hive_jobq_wire::WireNode for SwarmNodeKind {
|
|||
| SwarmNodeKind::CreateRepo { agent }
|
||||
| SwarmNodeKind::CreateForgeUser { agent }
|
||||
| SwarmNodeKind::AddRepoMember { agent }
|
||||
| SwarmNodeKind::GrantDocsRead { agent }
|
||||
| SwarmNodeKind::InitAgentConfigRepo { agent }
|
||||
| SwarmNodeKind::MintAgentIdentity { agent }
|
||||
| SwarmNodeKind::MintAgentForgeToken { agent }
|
||||
|
|
@ -340,6 +347,7 @@ async fn run_swarm_node(
|
|||
Err(e) => Outcome::Failed(format!("{e:#}")),
|
||||
},
|
||||
},
|
||||
SwarmNodeKind::GrantDocsRead { agent } => grant_docs_read(deps.forge, &agent).await,
|
||||
SwarmNodeKind::InitAgentConfigRepo { agent } => match deps.forge {
|
||||
None => Outcome::Failed(
|
||||
"no forge configured on this host (SWARM_CONTROLLER_FORGE_URL / \
|
||||
|
|
@ -414,6 +422,26 @@ async fn mint_forge_token(
|
|||
}
|
||||
}
|
||||
|
||||
/// The `GrantDocsRead` arm, lifted out for the same reason.
|
||||
async fn grant_docs_read(
|
||||
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 docs grant can be made"
|
||||
.to_owned(),
|
||||
);
|
||||
};
|
||||
match client.grant_docs_read(agent).await {
|
||||
Ok(()) => Outcome::Done,
|
||||
Err(e) => Outcome::Failed(format!("{e:#}")),
|
||||
}
|
||||
}
|
||||
|
||||
/// The `RenewAgentQueueCredential` arm, lifted out for the same reason.
|
||||
async fn renew_queue_credential(agent: &str) -> hive_jobq::scheduler::Outcome {
|
||||
use hive_jobq::scheduler::Outcome;
|
||||
|
|
@ -1980,6 +2008,14 @@ fn declare_agent_job(
|
|||
})
|
||||
.after_ok(create_repo)
|
||||
.after_ok(create_forge_user);
|
||||
// Read on `internal/docs`, a repo that exists whatever this agent is, so
|
||||
// it needs only the user. Not a parent of the deploy: the agent reads docs
|
||||
// once running, and a failed grant is retried by `forge::docs_grant::spawn`.
|
||||
let _grant_docs = b
|
||||
.node(SwarmNodeKind::GrantDocsRead {
|
||||
agent: agent.to_owned(),
|
||||
})
|
||||
.after_ok(create_forge_user);
|
||||
let init_config = b
|
||||
.node(SwarmNodeKind::InitAgentConfigRepo {
|
||||
agent: agent.to_owned(),
|
||||
|
|
@ -2266,6 +2302,37 @@ fn queue_forge_token_mints(
|
|||
Ok(ids)
|
||||
}
|
||||
|
||||
/// Insert one job per agent: `CreateForgeUser`, then `GrantDocsRead` `after_ok`
|
||||
/// it — the same edge [`declare_agent_job`] uses. Returns the grant nodes' ids.
|
||||
/// `forge::docs_grant::spawn`'s periodic pass comes through here, so a
|
||||
/// backfilled grant is the same node a new agent gets; the user node is a
|
||||
/// no-op for an agent that has an account.
|
||||
fn queue_docs_grants(
|
||||
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::GrantDocsRead { agent })
|
||||
.after_ok(create_forge_user)
|
||||
.guid(),
|
||||
]
|
||||
})
|
||||
.map_err(|e| anyhow::anyhow!("{e}"))?;
|
||||
ids.extend(queued);
|
||||
}
|
||||
Ok(ids)
|
||||
}
|
||||
|
||||
/// Insert one `MintAgentMatrixAccount` job per agent and return the nodes'
|
||||
/// ids. `matrix_account::agent_token::spawn`'s periodic pass comes through
|
||||
/// here, so a backfilled mint is the same node a new agent gets.
|
||||
|
|
@ -2373,7 +2440,8 @@ fn spawn_matrix_account_backfill(
|
|||
}
|
||||
|
||||
/// Start the forge's periodic passes — the swarm-wide objects, the runner
|
||||
/// registration token, hives' read tokens, and agents' tokens (which also
|
||||
/// registration token, hives' read tokens, agents' `internal/docs` grants, and
|
||||
/// agents' tokens (which also
|
||||
/// sweeps legacy tokens; see `forge::legacy_tokens`) —
|
||||
/// when a forge is configured. Lifted out of `main` for
|
||||
/// `clippy::too_many_lines`.
|
||||
|
|
@ -2395,6 +2463,12 @@ fn spawn_forge_workers(
|
|||
}
|
||||
});
|
||||
let sched = Arc::clone(jobq);
|
||||
forge::docs_grant::spawn(Arc::clone(&client), move |agents| {
|
||||
if let Err(e) = queue_docs_grants(&sched, agents) {
|
||||
tracing::warn!(error = %format!("{e:#}"), "docs grants: queueing failed");
|
||||
}
|
||||
});
|
||||
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");
|
||||
|
|
@ -4529,6 +4603,103 @@ mod tests {
|
|||
);
|
||||
}
|
||||
|
||||
/// Agent creation grants `internal/docs` read once the forge user exists,
|
||||
/// and a failed grant does not hold up the deploy (the pass retries it).
|
||||
#[tokio::test]
|
||||
async fn the_docs_grant_follows_the_user_and_is_not_a_deploy_parent() {
|
||||
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 grant = graph.node(id_of("grant_docs_read")).expect("just found");
|
||||
assert_eq!(grant.payload.data(grant.id.get())["agent"], "atlas");
|
||||
let when = grant
|
||||
.deps
|
||||
.iter()
|
||||
.find_map(|d| match d {
|
||||
hive_jobq::Dep::Node { id, when } if *id == id_of("create_forge_user") => {
|
||||
Some(*when)
|
||||
}
|
||||
_ => None,
|
||||
})
|
||||
.expect("the grant waits for the forge user");
|
||||
assert!(
|
||||
!when.accepts(hive_jobq::TerminalState::Failed),
|
||||
"adding a user that was never created as a collaborator is a forge error; \
|
||||
this edge has to be `after_ok`"
|
||||
);
|
||||
let deploy = graph.node(id_of("trigger_deploy")).expect("just found");
|
||||
assert!(
|
||||
!deploy.deps.iter().any(|d| matches!(
|
||||
d,
|
||||
hive_jobq::Dep::Node { id, .. } if *id == grant.id
|
||||
)),
|
||||
"the deploy must not wait on the docs grant"
|
||||
);
|
||||
}
|
||||
|
||||
/// The periodic pass queues the same pair of nodes agent creation does.
|
||||
#[test]
|
||||
fn a_queued_docs_grant_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_docs_grants(&sched, vec!["a".to_owned(), "b".to_owned()])
|
||||
.expect("two jobs insert");
|
||||
assert_eq!(ids.len(), 2, "one returned id per agent: its grant node");
|
||||
let guard = sched
|
||||
.lock()
|
||||
.unwrap_or_else(std::sync::PoisonError::into_inner);
|
||||
let graph = guard.graph();
|
||||
let mut labels: Vec<String> = graph.nodes().map(|n| n.payload.label()).collect();
|
||||
labels.sort();
|
||||
assert_eq!(
|
||||
labels,
|
||||
[
|
||||
"create_forge_user",
|
||||
"create_forge_user",
|
||||
"grant_docs_read",
|
||||
"grant_docs_read"
|
||||
]
|
||||
);
|
||||
for grant in graph
|
||||
.nodes()
|
||||
.filter(|n| n.payload.label() == "grant_docs_read")
|
||||
{
|
||||
assert!(ids.contains(&grant.id));
|
||||
assert!(
|
||||
grant
|
||||
.deps
|
||||
.iter()
|
||||
.any(|d| matches!(d, hive_jobq::Dep::Node { when, .. } if !when.accepts(hive_jobq::TerminalState::Failed))),
|
||||
"each grant waits `after_ok` on its user"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_forge_token_node_renders_the_agent() {
|
||||
use hive_jobq_wire::WireNode as _;
|
||||
|
|
|
|||
Loading…
Reference in a new issue