swarm-controller: mint each agent's matrix account with the swarm's token
A `MintAgentMatrixAccount` node creates the agent's account on the swarm's homeserver with the swarm appservice token, stores its token at `swarm/agents/<agent>/matrix/main`, and reads it back with whoami before reporting success. It is a root of agent creation, `after_any` into the deploy, and a five-minute backfill over every agent with a store identity queues the same node — the shape of the forge-token mint. The decision reads the stored token back rather than only checking that one is stored: the swarm and a hive both pin the device `hyperhive-<agent>`, so each login replaces the other's token. A failed read plans nothing, so an outage never rotates every agent's token. `matrixHomeserverUrl` now defaults to the swarm's `chat.` vhost, since the mint is what consults it.
This commit is contained in:
parent
9308752a09
commit
2776e121e5
7 changed files with 626 additions and 36 deletions
1
Cargo.lock
generated
1
Cargo.lock
generated
|
|
@ -4913,6 +4913,7 @@ dependencies = [
|
||||||
"sha2 0.11.0",
|
"sha2 0.11.0",
|
||||||
"strum",
|
"strum",
|
||||||
"swarm-authelia-bridge-sock",
|
"swarm-authelia-bridge-sock",
|
||||||
|
"swarm-matrix-client",
|
||||||
"swarm-queue-client",
|
"swarm-queue-client",
|
||||||
"swarm-secret-client",
|
"swarm-secret-client",
|
||||||
"time",
|
"time",
|
||||||
|
|
|
||||||
|
|
@ -227,11 +227,9 @@ let
|
||||||
SWARM_CONTROLLER_AUTH_BRIDGE_URL = deployCfg.swarm-controller.authBridgeUrl;
|
SWARM_CONTROLLER_AUTH_BRIDGE_URL = deployCfg.swarm-controller.authBridgeUrl;
|
||||||
};
|
};
|
||||||
|
|
||||||
# Swarm-wide default for `PUT .../matrix-accounts/{account}`'s own
|
# The swarm's homeserver: where `MintAgentMatrixAccount` creates each
|
||||||
# `homeserver` field, for a request that omits one. Same shape as
|
# agent's own account. Gated on the option resolving: a swarm with no
|
||||||
# `authBridgeEnv` above: genuinely optional, gated on the option
|
# domain has no homeserver URL, and the mint node then fails by name.
|
||||||
# resolving rather than assumed. Not read by anything yet — see the
|
|
||||||
# option's own description.
|
|
||||||
matrixHomeserverEnv = lib.optionalAttrs (deployCfg.swarm-controller.matrixHomeserverUrl != null) {
|
matrixHomeserverEnv = lib.optionalAttrs (deployCfg.swarm-controller.matrixHomeserverUrl != null) {
|
||||||
SWARM_CONTROLLER_MATRIX_HOMESERVER_URL = deployCfg.swarm-controller.matrixHomeserverUrl;
|
SWARM_CONTROLLER_MATRIX_HOMESERVER_URL = deployCfg.swarm-controller.matrixHomeserverUrl;
|
||||||
};
|
};
|
||||||
|
|
@ -561,19 +559,24 @@ in
|
||||||
|
|
||||||
matrixHomeserverUrl = lib.mkOption {
|
matrixHomeserverUrl = lib.mkOption {
|
||||||
type = lib.types.nullOr lib.types.str;
|
type = lib.types.nullOr lib.types.str;
|
||||||
default = null;
|
# The same `chat.<swarm domain>` ./hive-matrix.nix serves its vhost on
|
||||||
|
# (`gatewayHost`): a swarm runs one homeserver.
|
||||||
|
default = if swarmDomain == null then null else "https://chat.${swarmDomain}";
|
||||||
|
defaultText = lib.literalExpression ''"https://chat.''${services.hyperhive.swarm.domain}"'';
|
||||||
example = "https://matrix.example.org";
|
example = "https://matrix.example.org";
|
||||||
description = ''
|
description = ''
|
||||||
Swarm-wide default homeserver for `PUT
|
Client-server API base of the swarm's homeserver. The controller
|
||||||
|
creates each agent's own matrix account there, with the swarm's
|
||||||
|
appservice token, and stores its token where the agent reads it.
|
||||||
|
`null` leaves agents with no matrix account: the mint node fails,
|
||||||
|
naming this option's variable, and the agent is deployed anyway.
|
||||||
|
|
||||||
|
Also the swarm-wide default homeserver for `PUT
|
||||||
.../matrix-accounts/{account}` requests that omit their own
|
.../matrix-accounts/{account}` requests that omit their own
|
||||||
`homeserver` — see that route's own doc comment
|
`homeserver` — see that route's own doc comment
|
||||||
(`swarm-controller/src/matrix_account.rs`) for why the field is
|
(`swarm-controller/src/matrix_account.rs`) for why the field is
|
||||||
optional in token mode and what omitting it currently resolves to.
|
optional in token mode and what omitting it currently resolves to.
|
||||||
|
That route does not consult it yet.
|
||||||
`null` (the default) leaves that per-request resolution exactly as
|
|
||||||
it is today. **Not yet consulted by the route at all**: this option
|
|
||||||
only exists to carry the value in, ahead of the route being taught
|
|
||||||
to fall back to it.
|
|
||||||
'';
|
'';
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -59,6 +59,15 @@ let
|
||||||
|
|
||||||
baoWrapperCmd = if baoWrapper == null then "" else (baoWrapper.buildCommand or "");
|
baoWrapperCmd = if baoWrapper == null then "" else (baoWrapper.buildCommand or "");
|
||||||
cases = [
|
cases = [
|
||||||
|
{
|
||||||
|
# No hive mints an agent's matrix account any more, so a controller that
|
||||||
|
# did not know the homeserver would create every agent without one. The
|
||||||
|
# default is the swarm's own `chat.` vhost, with no operator setting.
|
||||||
|
name = "the controller is told the swarm's homeserver by default";
|
||||||
|
ok =
|
||||||
|
controllerNoStore.systemd.services.swarm-controller.environment.SWARM_CONTROLLER_MATRIX_HOMESERVER_URL
|
||||||
|
or null == "https://chat.t.local";
|
||||||
|
}
|
||||||
{
|
{
|
||||||
# Nothing asserted the PKI script before this, so a third leaf could be
|
# Nothing asserted the PKI script before this, so a third leaf could be
|
||||||
# added to it and every case still passed — measured, not assumed: the
|
# added to it and every case still passed — measured, not assumed: the
|
||||||
|
|
|
||||||
|
|
@ -96,6 +96,9 @@ swarm-queue-client = { workspace = true, features = ["kv"] }
|
||||||
# name and the object's shape are agreements between the two ends, and a
|
# name and the object's shape are agreements between the two ends, and a
|
||||||
# second spelling here would be a store this hive could not read.
|
# second spelling here would be a store this hive could not read.
|
||||||
swarm-secret-client.workspace = true
|
swarm-secret-client.workspace = true
|
||||||
|
# The appservice calls `matrix_account::agent_token` mints agents' accounts
|
||||||
|
# with — shared with `swarm-matrix-ctl`, which pins the same device id.
|
||||||
|
swarm-matrix-client.workspace = true
|
||||||
# `agent_identity.rs` signs the per-agent client leaf the store authenticates
|
# `agent_identity.rs` signs the per-agent client leaf the store authenticates
|
||||||
# an agent container by. The one runtime signer in this tree — every other CA
|
# an agent container by. The one runtime signer in this tree — every other CA
|
||||||
# here is a deploy-time `openssl` oneshot — because this one issues per agent
|
# here is a deploy-time `openssl` oneshot — because this one issues per agent
|
||||||
|
|
|
||||||
|
|
@ -113,6 +113,14 @@ enum SwarmNodeKind {
|
||||||
/// Carries no hive: the token's store path has no hive segment, and the
|
/// Carries no hive: the token's store path has no hive segment, and the
|
||||||
/// agent pulls it from wherever it runs.
|
/// agent pulls it from wherever it runs.
|
||||||
MintAgentForgeToken { agent: String },
|
MintAgentForgeToken { agent: String },
|
||||||
|
/// Make sure `agent` holds a live token for its own account on the swarm's
|
||||||
|
/// homeserver, creating the account with the swarm's appservice token
|
||||||
|
/// when it does not. See `matrix_account::agent_token` — including why a
|
||||||
|
/// stored token is read back before anything is minted.
|
||||||
|
///
|
||||||
|
/// Carries no hive: the path it writes has no hive segment, so an agent
|
||||||
|
/// that moves between hives keeps one matrix identity.
|
||||||
|
MintAgentMatrixAccount { agent: String },
|
||||||
/// Declare `agent` on `hive` as `Paused` in the swarm's wanted-state
|
/// Declare `agent` on `hive` as `Paused` in the swarm's wanted-state
|
||||||
/// store, so a freshly created agent does not start driving turns the
|
/// 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`.
|
/// moment it's deployed — the operator has to explicitly flip it to `Up`.
|
||||||
|
|
@ -139,6 +147,7 @@ impl hive_jobq_wire::WireNode for SwarmNodeKind {
|
||||||
SwarmNodeKind::InitAgentConfigRepo { .. } => "init_agent_config_repo".to_owned(),
|
SwarmNodeKind::InitAgentConfigRepo { .. } => "init_agent_config_repo".to_owned(),
|
||||||
SwarmNodeKind::MintAgentIdentity { .. } => "mint_agent_identity".to_owned(),
|
SwarmNodeKind::MintAgentIdentity { .. } => "mint_agent_identity".to_owned(),
|
||||||
SwarmNodeKind::MintAgentForgeToken { .. } => "mint_agent_forge_token".to_owned(),
|
SwarmNodeKind::MintAgentForgeToken { .. } => "mint_agent_forge_token".to_owned(),
|
||||||
|
SwarmNodeKind::MintAgentMatrixAccount { .. } => "mint_agent_matrix_account".to_owned(),
|
||||||
SwarmNodeKind::SetAgentWanted { .. } => "set_agent_wanted".to_owned(),
|
SwarmNodeKind::SetAgentWanted { .. } => "set_agent_wanted".to_owned(),
|
||||||
SwarmNodeKind::TriggerDeploy { .. } => "trigger_deploy".to_owned(),
|
SwarmNodeKind::TriggerDeploy { .. } => "trigger_deploy".to_owned(),
|
||||||
}
|
}
|
||||||
|
|
@ -157,7 +166,8 @@ impl hive_jobq_wire::WireNode for SwarmNodeKind {
|
||||||
| SwarmNodeKind::CreateForgeUser { agent }
|
| SwarmNodeKind::CreateForgeUser { agent }
|
||||||
| SwarmNodeKind::AddRepoMember { agent }
|
| SwarmNodeKind::AddRepoMember { agent }
|
||||||
| SwarmNodeKind::InitAgentConfigRepo { agent }
|
| SwarmNodeKind::InitAgentConfigRepo { agent }
|
||||||
| SwarmNodeKind::MintAgentForgeToken { agent } => {
|
| SwarmNodeKind::MintAgentForgeToken { agent }
|
||||||
|
| SwarmNodeKind::MintAgentMatrixAccount { agent } => {
|
||||||
serde_json::json!({ "agent": agent })
|
serde_json::json!({ "agent": agent })
|
||||||
}
|
}
|
||||||
SwarmNodeKind::TriggerDeploy { hive, agent }
|
SwarmNodeKind::TriggerDeploy { hive, agent }
|
||||||
|
|
@ -209,6 +219,11 @@ struct WorkerDeps {
|
||||||
/// see `wanted_writer`. `None` exactly when no swarm queue is configured
|
/// see `wanted_writer`. `None` exactly when no swarm queue is configured
|
||||||
/// on this host, same as `queue`.
|
/// on this host, same as `queue`.
|
||||||
wanted: Option<std::sync::Arc<wanted::WantedWriter>>,
|
wanted: Option<std::sync::Arc<wanted::WantedWriter>>,
|
||||||
|
/// Client-server API base of the swarm's homeserver, for the node that
|
||||||
|
/// mints agents' accounts on it. `None` when
|
||||||
|
/// `matrix_account::DEFAULT_HOMESERVER_ENV` is unset, which that node
|
||||||
|
/// **fails** on rather than skips, same as every other field here.
|
||||||
|
matrix_homeserver: Option<std::sync::Arc<str>>,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// What [`SwarmNodeKind::SetAgentWanted`] declares a brand-new agent to be.
|
/// What [`SwarmNodeKind::SetAgentWanted`] declares a brand-new agent to be.
|
||||||
|
|
@ -304,21 +319,13 @@ async fn run_swarm_node(
|
||||||
Err(e) => Outcome::Failed(format!("{e:#}")),
|
Err(e) => Outcome::Failed(format!("{e:#}")),
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
SwarmNodeKind::MintAgentIdentity { hive, agent } => match deps.agent_ca {
|
SwarmNodeKind::MintAgentIdentity { hive, agent } => {
|
||||||
None => Outcome::Failed(
|
mint_identity(deps.agent_ca.as_deref(), &agent, &hive).await
|
||||||
"no agent certificate authority is configured on this host \
|
}
|
||||||
(SWARM_CONTROLLER_AGENT_CA_FILE / SWARM_CONTROLLER_AGENT_CA_KEY_FILE unset), \
|
|
||||||
so this agent has no identity at the swarm secret store"
|
|
||||||
.to_owned(),
|
|
||||||
),
|
|
||||||
Some(authority) => {
|
|
||||||
match agent_identity::mint_and_verify(&authority, &agent, &hive).await {
|
|
||||||
Ok(()) => Outcome::Done,
|
|
||||||
Err(e) => Outcome::Failed(format!("{e:#}")),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
SwarmNodeKind::MintAgentForgeToken { agent } => mint_forge_token(deps.forge, &agent).await,
|
SwarmNodeKind::MintAgentForgeToken { agent } => mint_forge_token(deps.forge, &agent).await,
|
||||||
|
SwarmNodeKind::MintAgentMatrixAccount { agent } => {
|
||||||
|
mint_matrix_account(deps.matrix_homeserver.as_deref(), &agent).await
|
||||||
|
}
|
||||||
SwarmNodeKind::SetAgentWanted { hive, agent } => match deps.wanted {
|
SwarmNodeKind::SetAgentWanted { hive, agent } => match deps.wanted {
|
||||||
None => Outcome::Failed(
|
None => Outcome::Failed(
|
||||||
"no swarm queue is configured on this host, so no wanted-state \
|
"no swarm queue is configured on this host, so no wanted-state \
|
||||||
|
|
@ -338,6 +345,29 @@ async fn run_swarm_node(
|
||||||
(builder, outcome)
|
(builder, outcome)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// The `MintAgentIdentity` arm, lifted out so `run_swarm_node` stays under
|
||||||
|
/// `clippy::too_many_lines`.
|
||||||
|
async fn mint_identity(
|
||||||
|
authority: Option<&agent_identity::Authority>,
|
||||||
|
agent: &str,
|
||||||
|
hive: &str,
|
||||||
|
) -> hive_jobq::scheduler::Outcome {
|
||||||
|
use hive_jobq::scheduler::Outcome;
|
||||||
|
|
||||||
|
let Some(authority) = authority else {
|
||||||
|
return Outcome::Failed(
|
||||||
|
"no agent certificate authority is configured on this host \
|
||||||
|
(SWARM_CONTROLLER_AGENT_CA_FILE / SWARM_CONTROLLER_AGENT_CA_KEY_FILE unset), \
|
||||||
|
so this agent has no identity at the swarm secret store"
|
||||||
|
.to_owned(),
|
||||||
|
);
|
||||||
|
};
|
||||||
|
match agent_identity::mint_and_verify(authority, agent, hive).await {
|
||||||
|
Ok(()) => Outcome::Done,
|
||||||
|
Err(e) => Outcome::Failed(format!("{e:#}")),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// The `MintAgentForgeToken` arm, lifted out so `run_swarm_node` stays under
|
/// The `MintAgentForgeToken` arm, lifted out so `run_swarm_node` stays under
|
||||||
/// `clippy::too_many_lines`.
|
/// `clippy::too_many_lines`.
|
||||||
async fn mint_forge_token(
|
async fn mint_forge_token(
|
||||||
|
|
@ -359,6 +389,28 @@ async fn mint_forge_token(
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// The `MintAgentMatrixAccount` arm, lifted out for the same reason.
|
||||||
|
async fn mint_matrix_account(
|
||||||
|
homeserver: Option<&str>,
|
||||||
|
agent: &str,
|
||||||
|
) -> hive_jobq::scheduler::Outcome {
|
||||||
|
use hive_jobq::scheduler::Outcome;
|
||||||
|
|
||||||
|
// Loud on purpose: an agent quietly created without a matrix account is
|
||||||
|
// an agent nothing can talk to, and no hive mints one any more.
|
||||||
|
let Some(base) = homeserver else {
|
||||||
|
return Outcome::Failed(format!(
|
||||||
|
"no matrix homeserver configured on this host ({} unset), so no agent \
|
||||||
|
matrix account can be minted",
|
||||||
|
matrix_account::DEFAULT_HOMESERVER_ENV
|
||||||
|
));
|
||||||
|
};
|
||||||
|
match matrix_account::agent_token::ensure_agent_matrix_account(base, agent).await {
|
||||||
|
Ok(()) => Outcome::Done,
|
||||||
|
Err(e) => Outcome::Failed(format!("{e:#}")),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// Declare a brand-new agent at [`NEW_AGENT_WANTED_STATE`] — unless it turns
|
/// 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
|
/// 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
|
/// `Destroyed`, which this treats as reusable) is left alone, so a retried
|
||||||
|
|
@ -1513,6 +1565,13 @@ fn declare_agent_job(
|
||||||
agent: agent.to_owned(),
|
agent: agent.to_owned(),
|
||||||
})
|
})
|
||||||
.after_ok(create_forge_user);
|
.after_ok(create_forge_user);
|
||||||
|
// The agent's account on the swarm's homeserver. A root of its own:
|
||||||
|
// creating it needs neither an authelia subject nor a forge user nor a
|
||||||
|
// repo, and chaining it behind one of those would make an unrelated
|
||||||
|
// failure look like a matrix failure.
|
||||||
|
let mint_matrix = b.node(SwarmNodeKind::MintAgentMatrixAccount {
|
||||||
|
agent: agent.to_owned(),
|
||||||
|
});
|
||||||
// Declared before the deploy trigger so the pause is visible in the
|
// Declared before the deploy trigger so the pause is visible in the
|
||||||
// wanted-state store before the hive brings the container up — see 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
|
// node's own doc comment for why "before", not just "eventually". Needs
|
||||||
|
|
@ -1553,6 +1612,11 @@ fn declare_agent_job(
|
||||||
.after_ok(init_config)
|
.after_ok(init_config)
|
||||||
.after_any(mint_identity)
|
.after_any(mint_identity)
|
||||||
.after_any(mint_forge_token)
|
.after_any(mint_forge_token)
|
||||||
|
// `after_any` for the same reason: the container reads this token
|
||||||
|
// rather than producing it, so the deploy must not overtake the mint —
|
||||||
|
// but a host with no homeserver configured must still create agents,
|
||||||
|
// and only this node fails, by name.
|
||||||
|
.after_any(mint_matrix)
|
||||||
.after_ok(set_wanted);
|
.after_ok(set_wanted);
|
||||||
vec![create_identity.guid()]
|
vec![create_identity.guid()]
|
||||||
}
|
}
|
||||||
|
|
@ -1795,6 +1859,57 @@ fn queue_forge_token_mints(
|
||||||
Ok(ids)
|
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.
|
||||||
|
fn queue_matrix_account_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| {
|
||||||
|
vec![
|
||||||
|
b.node(SwarmNodeKind::MintAgentMatrixAccount { agent })
|
||||||
|
.guid(),
|
||||||
|
]
|
||||||
|
})
|
||||||
|
.map_err(|e| anyhow::anyhow!("{e}"))?;
|
||||||
|
ids.extend(queued);
|
||||||
|
}
|
||||||
|
Ok(ids)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The homeserver agents' own matrix accounts are minted on. Unset is this
|
||||||
|
/// controller creating agents with no matrix account, which the mint node
|
||||||
|
/// reports by name rather than `main` refusing to start.
|
||||||
|
fn configured_matrix_homeserver() -> Option<Arc<str>> {
|
||||||
|
matrix_account::configured_default_homeserver()
|
||||||
|
.filter(|v| !v.is_empty())
|
||||||
|
.map(Arc::from)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Start the matrix-account backfill when a homeserver is configured. Lifted
|
||||||
|
/// out of `main` for `clippy::too_many_lines`.
|
||||||
|
fn spawn_matrix_account_backfill(
|
||||||
|
jobq: &Arc<Mutex<hive_jobq::scheduler::Scheduler<SwarmNodeKind, SwarmResourceKind>>>,
|
||||||
|
homeserver: Option<Arc<str>>,
|
||||||
|
) {
|
||||||
|
let Some(base) = homeserver else {
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
let sched = Arc::clone(jobq);
|
||||||
|
matrix_account::agent_token::spawn(base, move |agents| {
|
||||||
|
if let Err(e) = queue_matrix_account_mints(&sched, agents) {
|
||||||
|
tracing::warn!(error = %format!("{e:#}"), "agent matrix accounts: queueing failed");
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
/// Check an agent's forge token now, and mint one if it is missing or stale.
|
/// 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
|
/// The periodic pass (`forge::agent_token::spawn`) does the same every five
|
||||||
|
|
@ -2167,12 +2282,14 @@ async fn main() -> Result<()> {
|
||||||
queue: status.as_ref().map(|s| s.queue_client()),
|
queue: status.as_ref().map(|s| s.queue_client()),
|
||||||
agent_ca: load_agent_authority(),
|
agent_ca: load_agent_authority(),
|
||||||
wanted: wanted_writer(status.as_ref()),
|
wanted: wanted_writer(status.as_ref()),
|
||||||
|
matrix_homeserver: configured_matrix_homeserver(),
|
||||||
};
|
};
|
||||||
|
|
||||||
let jobq = Arc::new(Mutex::new(hive_jobq::scheduler::Scheduler::new(
|
let jobq = Arc::new(Mutex::new(hive_jobq::scheduler::Scheduler::new(
|
||||||
hive_jobq::Graph::new(),
|
hive_jobq::Graph::new(),
|
||||||
hive_jobq::resources::ResourceTable::new(),
|
hive_jobq::resources::ResourceTable::new(),
|
||||||
)));
|
)));
|
||||||
|
spawn_matrix_account_backfill(&jobq, deps.matrix_homeserver.clone());
|
||||||
spawn_jobq_worker(Arc::clone(&jobq), deps);
|
spawn_jobq_worker(Arc::clone(&jobq), deps);
|
||||||
// Bound to a named variable, not `_` — dropping the provider stops its
|
// Bound to a named variable, not `_` — dropping the provider stops its
|
||||||
// `PeriodicReader`, so it must live as long as `main` does (which it
|
// `PeriodicReader`, so it must live as long as `main` does (which it
|
||||||
|
|
@ -2844,6 +2961,7 @@ mod tests {
|
||||||
queue: None,
|
queue: None,
|
||||||
agent_ca: None,
|
agent_ca: None,
|
||||||
wanted: None,
|
wanted: None,
|
||||||
|
matrix_homeserver: None,
|
||||||
};
|
};
|
||||||
let runner =
|
let runner =
|
||||||
hive_jobq::scheduler::Scheduler::claim_next(&sched, move |id, kind, builder| {
|
hive_jobq::scheduler::Scheduler::claim_next(&sched, move |id, kind, builder| {
|
||||||
|
|
@ -2898,6 +3016,7 @@ mod tests {
|
||||||
queue: None,
|
queue: None,
|
||||||
agent_ca: None,
|
agent_ca: None,
|
||||||
wanted: None,
|
wanted: None,
|
||||||
|
matrix_homeserver: None,
|
||||||
};
|
};
|
||||||
let runner =
|
let runner =
|
||||||
hive_jobq::scheduler::Scheduler::claim_next(&sched, move |id, kind, builder| {
|
hive_jobq::scheduler::Scheduler::claim_next(&sched, move |id, kind, builder| {
|
||||||
|
|
@ -2952,6 +3071,7 @@ mod tests {
|
||||||
queue: None,
|
queue: None,
|
||||||
agent_ca: None,
|
agent_ca: None,
|
||||||
wanted: None,
|
wanted: None,
|
||||||
|
matrix_homeserver: None,
|
||||||
};
|
};
|
||||||
let runner =
|
let runner =
|
||||||
hive_jobq::scheduler::Scheduler::claim_next(&sched, move |id, kind, builder| {
|
hive_jobq::scheduler::Scheduler::claim_next(&sched, move |id, kind, builder| {
|
||||||
|
|
@ -3004,6 +3124,7 @@ mod tests {
|
||||||
queue: None,
|
queue: None,
|
||||||
agent_ca: None,
|
agent_ca: None,
|
||||||
wanted: None,
|
wanted: None,
|
||||||
|
matrix_homeserver: None,
|
||||||
};
|
};
|
||||||
let runner =
|
let runner =
|
||||||
hive_jobq::scheduler::Scheduler::claim_next(&sched, move |id, kind, builder| {
|
hive_jobq::scheduler::Scheduler::claim_next(&sched, move |id, kind, builder| {
|
||||||
|
|
@ -3242,6 +3363,92 @@ mod tests {
|
||||||
assert_eq!(kind.data(1)["agent"], "atlas");
|
assert_eq!(kind.data(1)["agent"], "atlas");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_matrix_account_node_renders_the_agent() {
|
||||||
|
use hive_jobq_wire::WireNode as _;
|
||||||
|
|
||||||
|
let kind = SwarmNodeKind::MintAgentMatrixAccount {
|
||||||
|
agent: "atlas".to_owned(),
|
||||||
|
};
|
||||||
|
assert_eq!(kind.label(), "mint_agent_matrix_account");
|
||||||
|
assert_eq!(kind.data(1)["agent"], "atlas");
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Agent creation mints the matrix account as a root of its own, and the
|
||||||
|
/// deploy waits for it without being cancelled by it: a host with no
|
||||||
|
/// homeserver configured must still deploy the agent.
|
||||||
|
#[tokio::test]
|
||||||
|
async fn the_matrix_account_mint_is_a_root_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 find = |label: &str| {
|
||||||
|
graph
|
||||||
|
.nodes()
|
||||||
|
.find(|n| n.payload.label() == label)
|
||||||
|
.unwrap_or_else(|| panic!("the graph holds a {label} node"))
|
||||||
|
};
|
||||||
|
let mint = find("mint_agent_matrix_account");
|
||||||
|
assert!(mint.deps.is_empty(), "the mint waits for nothing");
|
||||||
|
let when = find("trigger_deploy")
|
||||||
|
.deps
|
||||||
|
.iter()
|
||||||
|
.find_map(|d| match d {
|
||||||
|
hive_jobq::Dep::Node { id, when } if *id == mint.id => Some(*when),
|
||||||
|
_ => None,
|
||||||
|
})
|
||||||
|
.expect("the deploy waits for the matrix mint");
|
||||||
|
assert!(
|
||||||
|
when.accepts(hive_jobq::TerminalState::Failed),
|
||||||
|
"a host with no homeserver must not cancel the deploy; this edge \
|
||||||
|
has to be `after_any`, not `after_ok`"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The backfill's queueing: one mint node per agent, nothing else.
|
||||||
|
#[test]
|
||||||
|
fn a_queued_matrix_mint_is_one_node_per_agent() {
|
||||||
|
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_matrix_account_mints(&sched, vec!["a".to_owned(), "b".to_owned()])
|
||||||
|
.expect("two jobs insert");
|
||||||
|
assert_eq!(ids.len(), 2);
|
||||||
|
let guard = sched
|
||||||
|
.lock()
|
||||||
|
.unwrap_or_else(std::sync::PoisonError::into_inner);
|
||||||
|
let mut agents: Vec<String> = guard
|
||||||
|
.graph()
|
||||||
|
.nodes()
|
||||||
|
.map(|n| {
|
||||||
|
assert_eq!(n.payload.label(), "mint_agent_matrix_account");
|
||||||
|
n.payload.data(n.id.get())["agent"]
|
||||||
|
.as_str()
|
||||||
|
.expect("agent is a string")
|
||||||
|
.to_owned()
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
agents.sort();
|
||||||
|
assert_eq!(agents, ["a", "b"]);
|
||||||
|
}
|
||||||
|
|
||||||
/// The manual route and the periodic pass both go through
|
/// The manual route and the periodic pass both go through
|
||||||
/// `queue_forge_token_mints`, so this is the assertion that a backfilled
|
/// `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,
|
/// mint is the same pair of nodes agent creation inserts: the forge user,
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,13 @@
|
||||||
//! Give one agent an external matrix account: put the credential in the
|
//! An agent's matrix accounts, from this daemon's two sides of them.
|
||||||
//! swarm's secret store, then tell that agent's hive it is there.
|
//!
|
||||||
|
//! **The internal one** — [`agent_token`] — is the agent's own `main` account on
|
||||||
|
//! the swarm's homeserver, minted with the swarm's appservice token and stored
|
||||||
|
//! where the agent's daemon reads it. See docs/swarm/credentials.md for why
|
||||||
|
//! `main` is the one name [`put_matrix_account`] refuses to write.
|
||||||
|
//!
|
||||||
|
//! **The external one** — [`put_matrix_account`] and everything under it — is
|
||||||
|
//! an account somewhere else that an operator hands us a credential for: put it
|
||||||
|
//! in the swarm's secret store, then tell that agent's hive it is there.
|
||||||
//!
|
//!
|
||||||
//! The hive end is `hive-c0re/src/workers/credential.rs`, which reads the
|
//! The hive end is `hive-c0re/src/workers/credential.rs`, which reads the
|
||||||
//! value under its own identity and writes it into the agent's state dir. The
|
//! value under its own identity and writes it into the agent's state dir. The
|
||||||
|
|
@ -31,6 +39,8 @@ use utoipa::ToSchema;
|
||||||
|
|
||||||
use super::{AppState, error_problem, swarm_hive};
|
use super::{AppState, error_problem, swarm_hive};
|
||||||
|
|
||||||
|
pub mod agent_token;
|
||||||
|
|
||||||
fn default_mode() -> String {
|
fn default_mode() -> String {
|
||||||
"token".to_owned()
|
"token".to_owned()
|
||||||
}
|
}
|
||||||
|
|
@ -41,9 +51,9 @@ fn default_mode() -> String {
|
||||||
/// when a caller omits one. Read via [`configured_default_homeserver`], not
|
/// when a caller omits one. Read via [`configured_default_homeserver`], not
|
||||||
/// directly — see that fn's doc.
|
/// directly — see that fn's doc.
|
||||||
///
|
///
|
||||||
/// Not yet consulted by [`put_matrix_account`]: `homeserver_or_configured_default`
|
/// Also the homeserver [`agent_token`] mints agents' own accounts on. Not yet
|
||||||
/// below exists for a later slice of this homeserver-default rollout to
|
/// consulted by [`put_matrix_account`]: `homeserver_or_configured_default`
|
||||||
/// call; this one only wires the config through.
|
/// below exists for a later slice of this homeserver-default rollout to call.
|
||||||
pub(crate) const DEFAULT_HOMESERVER_ENV: &str = "SWARM_CONTROLLER_MATRIX_HOMESERVER_URL";
|
pub(crate) const DEFAULT_HOMESERVER_ENV: &str = "SWARM_CONTROLLER_MATRIX_HOMESERVER_URL";
|
||||||
|
|
||||||
/// `caller`'s own homeserver, or `default` when the caller left it unset.
|
/// `caller`'s own homeserver, or `default` when the caller left it unset.
|
||||||
|
|
@ -250,11 +260,11 @@ pub async fn put_matrix_account(
|
||||||
Ok(Json(PutMatrixAccountResponse { user_id }))
|
Ok(Json(PutMatrixAccountResponse { user_id }))
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Whether `account` is the hive-internal name every hive declares per
|
/// Whether `account` is the agent's own account, which [`agent_token`] mints
|
||||||
/// agent (`nix/agent-modules/matrix.nix`) — see the call site's own comment
|
/// and `nix/agent-modules/matrix.nix` declares per agent — see the call site's
|
||||||
/// for why this route must never write one.
|
/// own comment for why this route must never write one.
|
||||||
fn is_reserved_account(account: &str) -> bool {
|
fn is_reserved_account(account: &str) -> bool {
|
||||||
account == "main"
|
account == agent_token::ACCOUNT
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Token-mode's only requirement: a token was actually given. Split out of
|
/// Token-mode's only requirement: a token was actually given. Split out of
|
||||||
|
|
|
||||||
357
swarm-controller/src/matrix_account/agent_token.rs
Normal file
357
swarm-controller/src/matrix_account/agent_token.rs
Normal file
|
|
@ -0,0 +1,357 @@
|
||||||
|
//! Each agent's own account on the swarm's homeserver: created here with the
|
||||||
|
//! **swarm's** appservice token, stored at `swarm/agents/<agent>/matrix/main`,
|
||||||
|
//! and pulled from there by the agent's matrix daemon itself.
|
||||||
|
//!
|
||||||
|
//! The appservice token is minted inside the matrix container and published
|
||||||
|
//! to `swarm_secret_client::matrix::swarm_appservice_token_path`, which only
|
||||||
|
//! matrix-ctl and this daemon may read. No hive holds it, and no hive mints an
|
||||||
|
//! agent's account any more.
|
||||||
|
//!
|
||||||
|
//! ⚠️ **Every mint replaces the agent's live token.** Both calls pin the
|
||||||
|
//! device id `hyperhive-<agent>`, so a login for an existing account replaces
|
||||||
|
//! that device's token: the one in the store before, or the file token a hive
|
||||||
|
//! minted. That is why the decision reads the stored token back with `whoami`
|
||||||
|
//! ([`classify`]) instead of only checking that the store holds something, and
|
||||||
|
//! why a failed read never mints ([`Observed::Unknown`]).
|
||||||
|
//!
|
||||||
|
//! Two callers insert the same `MintAgentMatrixAccount` job node: agent
|
||||||
|
//! creation, and [`spawn`]'s pass at start and every [`RECONCILE_INTERVAL`]
|
||||||
|
//! over every agent that holds a store identity — the same roster and shape as
|
||||||
|
//! `crate::forge::agent_token`.
|
||||||
|
|
||||||
|
use std::sync::Arc;
|
||||||
|
|
||||||
|
use anyhow::{Context, Result, bail};
|
||||||
|
use swarm_matrix_client::{self as homeserver, Whoami};
|
||||||
|
use swarm_secret_client::{SecretStore, client::DEFAULT_CERT_MOUNT, matrix, policy};
|
||||||
|
|
||||||
|
/// The account name every agent's own matrix credential is stored under.
|
||||||
|
///
|
||||||
|
/// `main` is what `nix/agent-modules/matrix.nix` declares per agent and what
|
||||||
|
/// the agent's daemon reads first. ⚠️ `PUT .../matrix-accounts/{account}`
|
||||||
|
/// refuses this exact name (`super::is_reserved_account`): that route stores
|
||||||
|
/// an **external** account an operator supplies, and one called `main` would
|
||||||
|
/// overwrite this. Two writers, one reserved name, and the reservation is what
|
||||||
|
/// keeps them apart.
|
||||||
|
pub const ACCOUNT: &str = "main";
|
||||||
|
|
||||||
|
/// How often [`spawn`] re-checks every agent's account.
|
||||||
|
const RECONCILE_INTERVAL: std::time::Duration = std::time::Duration::from_mins(5);
|
||||||
|
|
||||||
|
/// Why an account's token has to be (re)minted.
|
||||||
|
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||||
|
pub enum MintReason {
|
||||||
|
/// The store holds nothing for this agent: a new agent, or one whose
|
||||||
|
/// account a hive created before the swarm did this.
|
||||||
|
NotStored,
|
||||||
|
/// The homeserver does not know the stored token: another login on the
|
||||||
|
/// same device replaced it.
|
||||||
|
Revoked,
|
||||||
|
/// The stored token is live but belongs to another account.
|
||||||
|
OtherUser,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// What one look at an agent's stored token found.
|
||||||
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||||
|
pub enum Probe {
|
||||||
|
/// Nothing at the agent's path.
|
||||||
|
NotStored,
|
||||||
|
/// The homeserver's verdict on the stored token.
|
||||||
|
Whoami(Whoami),
|
||||||
|
}
|
||||||
|
|
||||||
|
/// What to do about one agent's account.
|
||||||
|
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||||
|
pub enum Decision {
|
||||||
|
/// The stored token is a live token for this agent's own account.
|
||||||
|
Keep,
|
||||||
|
/// Mint and store a new one.
|
||||||
|
Mint(MintReason),
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 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),
|
||||||
|
/// A read failed. Nothing is known, so nothing is done.
|
||||||
|
Unknown,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The localpart of a full user id, `@<localpart>:<server>`.
|
||||||
|
fn localpart(user_id: &str) -> Option<&str> {
|
||||||
|
user_id.strip_prefix('@')?.split_once(':').map(|(l, _)| l)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Decide what to do about `agent`'s account from what its stored token is.
|
||||||
|
pub fn classify(agent: &str, probe: &Probe) -> Decision {
|
||||||
|
match probe {
|
||||||
|
Probe::NotStored => Decision::Mint(MintReason::NotStored),
|
||||||
|
Probe::Whoami(Whoami::UnknownToken) => Decision::Mint(MintReason::Revoked),
|
||||||
|
Probe::Whoami(Whoami::User(user)) if localpart(user) == Some(agent) => Decision::Keep,
|
||||||
|
Probe::Whoami(Whoami::User(_)) => Decision::Mint(MintReason::OtherUser),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The agents a pass mints for.
|
||||||
|
///
|
||||||
|
/// [`Observed::Unknown`] never mints: a homeserver or store outage must not
|
||||||
|
/// turn into a new token for every agent, each of which kills the one the
|
||||||
|
/// agent is running on.
|
||||||
|
pub fn plan(observed: &[(String, Observed)]) -> Vec<String> {
|
||||||
|
observed
|
||||||
|
.iter()
|
||||||
|
.filter(|(_, o)| matches!(o, Observed::Decided(Decision::Mint(_))))
|
||||||
|
.map(|(agent, _)| agent.clone())
|
||||||
|
.collect()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The swarm appservice token, or an error naming why there is none.
|
||||||
|
async fn appservice_token(store: &SecretStore) -> Result<String> {
|
||||||
|
let path = matrix::swarm_appservice_token_path()?;
|
||||||
|
let stored: Option<matrix::Credential> = store
|
||||||
|
.read_optional(&path)
|
||||||
|
.await
|
||||||
|
.with_context(|| format!("reading {path}"))?;
|
||||||
|
match stored {
|
||||||
|
Some(c) if !c.value.trim().is_empty() => Ok(c.value.trim().to_owned()),
|
||||||
|
_ => bail!(
|
||||||
|
"nothing at {path}: the matrix container has not published the swarm \
|
||||||
|
appservice token (swarm-matrix-appservice-publish)"
|
||||||
|
),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// What the store and the homeserver say about `agent`'s stored token.
|
||||||
|
async fn probe(
|
||||||
|
store: &SecretStore,
|
||||||
|
http: &reqwest::Client,
|
||||||
|
base: &str,
|
||||||
|
agent: &str,
|
||||||
|
) -> Result<Probe> {
|
||||||
|
let path = matrix::account_path(agent, ACCOUNT)?;
|
||||||
|
let stored: Option<matrix::Credential> = store
|
||||||
|
.read_optional(&path)
|
||||||
|
.await
|
||||||
|
.with_context(|| format!("reading {path}"))?;
|
||||||
|
let Some(stored) = stored else {
|
||||||
|
return Ok(Probe::NotStored);
|
||||||
|
};
|
||||||
|
let verdict = homeserver::whoami(http, base, stored.value.trim())
|
||||||
|
.await
|
||||||
|
.with_context(|| format!("asking the homeserver about the token at {path}"))?;
|
||||||
|
Ok(Probe::Whoami(verdict))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Make sure `agent` holds a live token for its own account on the swarm's
|
||||||
|
/// homeserver at `base`, creating the account or logging in to it when it
|
||||||
|
/// does not. The whole job of the `MintAgentMatrixAccount` node.
|
||||||
|
///
|
||||||
|
/// Reads the new token back with `whoami` before reporting success, so the
|
||||||
|
/// node does not report success on a write nobody has read. A crash between
|
||||||
|
/// the login and the write leaves a dead token in the store, which the next
|
||||||
|
/// pass classifies as [`MintReason::Revoked`].
|
||||||
|
///
|
||||||
|
/// # Errors
|
||||||
|
/// When the store or the homeserver refuses a step, the swarm appservice
|
||||||
|
/// token has not been published, or the new token authenticates as someone
|
||||||
|
/// else.
|
||||||
|
pub async fn ensure_agent_matrix_account(base: &str, agent: &str) -> Result<()> {
|
||||||
|
let store = crate::store::connect()
|
||||||
|
.await
|
||||||
|
.context("logging in to the swarm secret store")?;
|
||||||
|
let as_token = appservice_token(&store).await?;
|
||||||
|
let http = homeserver::client()?;
|
||||||
|
let reason = match classify(agent, &probe(&store, &http, base, agent).await?) {
|
||||||
|
Decision::Keep => {
|
||||||
|
tracing::debug!(agent, "agent matrix account is current; left as it is");
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
Decision::Mint(reason) => reason,
|
||||||
|
};
|
||||||
|
|
||||||
|
let token = match homeserver::register(&http, base, agent, &as_token).await? {
|
||||||
|
homeserver::Registered::Token(token) => token,
|
||||||
|
// An agent minted before, or one a hive created back when hives did
|
||||||
|
// this: log in as the appservice on the same device instead.
|
||||||
|
homeserver::Registered::AlreadyExists => {
|
||||||
|
homeserver::appservice_login(&http, base, agent, &as_token).await?
|
||||||
|
}
|
||||||
|
};
|
||||||
|
let path = matrix::account_path(agent, ACCOUNT)?;
|
||||||
|
store
|
||||||
|
.write(
|
||||||
|
&path,
|
||||||
|
&matrix::Credential {
|
||||||
|
value: token.clone(),
|
||||||
|
homeserver: Some(base.to_owned()),
|
||||||
|
},
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.with_context(|| format!("storing {agent}'s matrix token at {path}"))?;
|
||||||
|
|
||||||
|
match homeserver::whoami(&http, base, &token)
|
||||||
|
.await
|
||||||
|
.with_context(|| format!("using {agent}'s new matrix token"))?
|
||||||
|
{
|
||||||
|
Whoami::User(user) if localpart(&user) == Some(agent) => {}
|
||||||
|
_ => bail!("{agent}'s new matrix token does not authenticate as {agent}"),
|
||||||
|
}
|
||||||
|
tracing::info!(agent, ?reason, %path, "agent matrix account token minted and stored");
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// One pass: every agent holding a store identity, observed.
|
||||||
|
///
|
||||||
|
/// Fails as a whole, rather than per agent, when the swarm appservice token is
|
||||||
|
/// not published: every mint would fail on it, and one message says so.
|
||||||
|
async fn observe_all(base: &str) -> Result<Vec<(String, Observed)>> {
|
||||||
|
let store = crate::store::connect()
|
||||||
|
.await
|
||||||
|
.context("logging in to the swarm secret store")?;
|
||||||
|
appservice_token(&store).await?;
|
||||||
|
let http = homeserver::client()?;
|
||||||
|
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 = match probe(&store, &http, base, &agent).await {
|
||||||
|
Ok(p) => Observed::Decided(classify(&agent, &p)),
|
||||||
|
Err(e) => {
|
||||||
|
tracing::warn!(agent, error = %format!("{e:#}"), "agent matrix account: read failed");
|
||||||
|
Observed::Unknown
|
||||||
|
}
|
||||||
|
};
|
||||||
|
observed.push((agent, o));
|
||||||
|
}
|
||||||
|
Ok(observed)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Check every agent's account now and every [`RECONCILE_INTERVAL`] after, and
|
||||||
|
/// hand the agents that need a token to `enqueue`, which inserts a
|
||||||
|
/// `MintAgentMatrixAccount` node for each.
|
||||||
|
///
|
||||||
|
/// The roster is the store's `hive-agent-*` cert-auth roles: exactly the
|
||||||
|
/// agents that can read what the node writes. A pass that fails is logged and
|
||||||
|
/// retried on the next tick; it never stops the daemon.
|
||||||
|
pub fn spawn(base: Arc<str>, enqueue: impl Fn(Vec<String>) + Send + 'static) {
|
||||||
|
tokio::spawn(async move {
|
||||||
|
let mut ticker = tokio::time::interval(RECONCILE_INTERVAL);
|
||||||
|
loop {
|
||||||
|
ticker.tick().await;
|
||||||
|
match observe_all(&base).await {
|
||||||
|
Ok(observed) => {
|
||||||
|
let agents = plan(&observed);
|
||||||
|
if agents.is_empty() {
|
||||||
|
tracing::debug!(
|
||||||
|
checked = observed.len(),
|
||||||
|
"agent matrix accounts: all current"
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
tracing::info!(
|
||||||
|
checked = observed.len(),
|
||||||
|
minting = agents.len(),
|
||||||
|
"agent matrix accounts: queueing mints"
|
||||||
|
);
|
||||||
|
enqueue(agents);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Err(e) => tracing::warn!(
|
||||||
|
error = %format!("{e:#}"),
|
||||||
|
retry_in_s = RECONCILE_INTERVAL.as_secs(),
|
||||||
|
"agent matrix accounts: pass failed; retrying next tick"
|
||||||
|
),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
fn user(id: &str) -> Probe {
|
||||||
|
Probe::Whoami(Whoami::User(id.to_owned()))
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_live_token_for_the_agent_is_kept() {
|
||||||
|
assert_eq!(classify("atlas", &user("@atlas:t.local")), Decision::Keep);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn nothing_stored_mints() {
|
||||||
|
assert_eq!(
|
||||||
|
classify("atlas", &Probe::NotStored),
|
||||||
|
Decision::Mint(MintReason::NotStored)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_replaced_token_mints() {
|
||||||
|
// The shape a hive re-login on the same device leaves behind.
|
||||||
|
assert_eq!(
|
||||||
|
classify("atlas", &Probe::Whoami(Whoami::UnknownToken)),
|
||||||
|
Decision::Mint(MintReason::Revoked)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_token_for_another_account_mints() {
|
||||||
|
// Localpart compared whole, so a prefix of the agent's name is not it.
|
||||||
|
for other in ["@argus:t.local", "@atlas2:t.local", "@atla:t.local"] {
|
||||||
|
assert_eq!(
|
||||||
|
classify("atlas", &user(other)),
|
||||||
|
Decision::Mint(MintReason::OtherUser),
|
||||||
|
"{other}"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_pass_mints_only_what_it_knows_needs_one() {
|
||||||
|
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::Decided(Decision::Mint(MintReason::Revoked)),
|
||||||
|
),
|
||||||
|
];
|
||||||
|
assert_eq!(plan(&observed), ["b", "d"]);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn an_outage_plans_nothing() {
|
||||||
|
// Each mint replaces the token an agent is running on, so a pass that
|
||||||
|
// could not read must not mint for everyone.
|
||||||
|
let observed = [
|
||||||
|
("a".to_owned(), Observed::Unknown),
|
||||||
|
("b".to_owned(), Observed::Unknown),
|
||||||
|
];
|
||||||
|
assert!(plan(&observed).is_empty());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn the_published_path_is_the_hiveless_one_the_agent_reads() {
|
||||||
|
// The literal is the point, and the absence of a hive segment is the
|
||||||
|
// half of it that was decided: an agent's account follows the agent.
|
||||||
|
assert_eq!(
|
||||||
|
matrix::account_path("atlas", ACCOUNT).expect("a plain name is legal"),
|
||||||
|
"swarm/agents/atlas/matrix/main"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn the_account_name_is_the_one_the_agent_module_declares() {
|
||||||
|
// `nix/agent-modules/matrix.nix` renders this as a `matrixAccounts`
|
||||||
|
// key and nothing wires an override across.
|
||||||
|
assert_eq!(ACCOUNT, "main");
|
||||||
|
}
|
||||||
|
}
|
||||||
Loading…
Reference in a new issue