swarm-queue-client: one agent-token spelling, and no hive in AgentCredential
`swarm_queue_client::agent_token::format_agent_token` / `parse_agent_token` are the spelling an agent presents its own queue secret in, `swarm-agent.<agent>.<secret>`, and the one the auth-callout responder reads back. The prefix is what separates it from an OIDC access token, which may itself contain `.`. Parsing distinguishes "not an agent token" (no prefix) from "a malformed one"; the error names the problem and never the value. The module is store-free, so the agent formats its token without linking the secret-store client. `swarm_secret_client::queue::AgentCredential` loses `hive`: an agent's identity is not tied to a hive, and nothing reads the field. Objects already in the store carry it and still decode, since unknown fields are ignored; a test parses one. The controller stops writing it. With the credential no longer naming a hive, and the agent's policy naming none since #4762, nothing in the mint consumes one. `hive` goes from `mint_and_verify`, from the `MintAgentIdentity` node, and from `POST /api/agents/{name}/identity`, which now takes no body and no longer checks a hive against the roster; a caller that still sends one is not refused, the body is ignored. `swarmctl agent mint-identity` loses `--hive`, so passing it is now a usage error.
This commit is contained in:
parent
6170e74a31
commit
fc97c237dc
10 changed files with 234 additions and 201 deletions
|
|
@ -33,9 +33,8 @@ its identity then. Ruth doesn't: hive-c0re creates her on its own at
|
||||||
startup, so she needs her identity minted by hand, once.
|
startup, so she needs her identity minted by hand, once.
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
# On the swarm-controller host: give ruth her store identity. <hive> is the
|
# On the swarm-controller host: give ruth her store identity.
|
||||||
# name of the hive she runs on.
|
swarmctl agent mint-identity ruth
|
||||||
swarmctl agent mint-identity ruth --hive <hive>
|
|
||||||
|
|
||||||
# On ruth's hive: re-apply her container config, which is when hive-c0re
|
# On ruth's hive: re-apply her container config, which is when hive-c0re
|
||||||
# hands the new identity to the container.
|
# hands the new identity to the container.
|
||||||
|
|
|
||||||
|
|
@ -101,12 +101,10 @@ mint therefore never receives one, and nothing will ever come back around to
|
||||||
it. Re-run the mint for one agent with:
|
it. Re-run the mint for one agent with:
|
||||||
|
|
||||||
```sh
|
```sh
|
||||||
swarmctl agent mint-identity <agent> --hive <hive>
|
swarmctl agent mint-identity <agent>
|
||||||
```
|
```
|
||||||
|
|
||||||
`--hive` has no default: neither the CLI nor the controller keeps a roster of
|
The queue secret half is idempotent — an agent that already has one keeps exactly the
|
||||||
which agent runs where, and the credentials this mints name a hive. The queue
|
|
||||||
secret half is idempotent — an agent that already has one keeps exactly the
|
|
||||||
value it holds, so running this against an already-migrated agent doesn't drop
|
value it holds, so running this against an already-migrated agent doesn't drop
|
||||||
its queue connection. The certificate half isn't: the agent gets a fresh leaf
|
its queue connection. The certificate half isn't: the agent gets a fresh leaf
|
||||||
and picks it up on its next boot.
|
and picks it up on its next boot.
|
||||||
|
|
|
||||||
|
|
@ -92,7 +92,7 @@ Queue a re-mint of an existing agent's identity at the swarm's secret store.
|
||||||
|
|
||||||
Queues and returns, the same way `agent create` does — watch the swarm UI's job view for the outcome.
|
Queues and returns, the same way `agent create` does — watch the swarm UI's job view for the outcome.
|
||||||
|
|
||||||
**Usage:** `swarmctl agent mint-identity [OPTIONS] --hive <HIVE> <NAME>`
|
**Usage:** `swarmctl agent mint-identity [OPTIONS] <NAME>`
|
||||||
|
|
||||||
###### **Arguments:**
|
###### **Arguments:**
|
||||||
|
|
||||||
|
|
@ -100,9 +100,6 @@ Queues and returns, the same way `agent create` does — watch the swarm UI's jo
|
||||||
|
|
||||||
###### **Options:**
|
###### **Options:**
|
||||||
|
|
||||||
* `--hive <HIVE>` — The hive that agent runs on.
|
|
||||||
|
|
||||||
Required, and deliberately not defaulted: the credentials this mints name a hive, and neither this CLI nor the controller keeps a roster of which agent is on which hive. Naming the wrong one gives the agent an identity scoped to a hive it doesn't run on. The controller checks the value against the swarm's hive roster and names the known hives if it misses.
|
|
||||||
* `--controller-socket <PATH>` — swarm-controller's unix socket.
|
* `--controller-socket <PATH>` — swarm-controller's unix socket.
|
||||||
|
|
||||||
Supplied by the nix module that installs this binary, from the same `socketPath` option the daemon binds; falls back to `SWARM_CONTROLLER_SOCKET`.
|
Supplied by the nix module that installs this binary, from the same `socketPath` option the daemon binds; falls back to `SWARM_CONTROLLER_SOCKET`.
|
||||||
|
|
|
||||||
|
|
@ -136,7 +136,7 @@ fn generate_queue_secret() -> Result<String> {
|
||||||
/// Anything that stops one of those five steps, with the step named. A
|
/// Anything that stops one of those five steps, with the step named. A
|
||||||
/// failure here fails the job node and nothing else — the agent is still
|
/// failure here fails the job node and nothing else — the agent is still
|
||||||
/// created, without a store identity.
|
/// created, without a store identity.
|
||||||
pub async fn mint_and_verify(agent: &str, hive: &str) -> Result<()> {
|
pub async fn mint_and_verify(agent: &str) -> Result<()> {
|
||||||
let (mount, pki_role) = agent_pki(|k| std::env::var(k).ok())?;
|
let (mount, pki_role) = agent_pki(|k| std::env::var(k).ok())?;
|
||||||
let name = policy::agent_object_name(agent)?;
|
let name = policy::agent_object_name(agent)?;
|
||||||
let path = mtls::identity_path(agent)?;
|
let path = mtls::identity_path(agent)?;
|
||||||
|
|
@ -161,18 +161,15 @@ pub async fn mint_and_verify(agent: &str, hive: &str) -> Result<()> {
|
||||||
.read_optional(&queue_path)
|
.read_optional(&queue_path)
|
||||||
.await
|
.await
|
||||||
.with_context(|| format!("checking whether {queue_path} already holds a credential"))?;
|
.with_context(|| format!("checking whether {queue_path} already holds a credential"))?;
|
||||||
// The secret survives a re-run; the principal it names does not get to.
|
// The secret survives a re-run. An object naming a different agent is
|
||||||
// An object whose `hive` disagrees with the hive this node was invoked
|
// corrected by rewriting the name around the *same* `value`, which no live
|
||||||
// with would grant its holder subjects on the wrong hive, so it is
|
// connection notices.
|
||||||
// corrected — but by rewriting the two name fields around the *same*
|
|
||||||
// `value`, which is a correction no live connection notices.
|
|
||||||
let wanted = queue::AgentCredential {
|
let wanted = queue::AgentCredential {
|
||||||
value: match &existing {
|
value: match &existing {
|
||||||
Some(existing) => existing.value.clone(),
|
Some(existing) => existing.value.clone(),
|
||||||
None => generate_queue_secret()?,
|
None => generate_queue_secret()?,
|
||||||
},
|
},
|
||||||
agent: agent.to_owned(),
|
agent: agent.to_owned(),
|
||||||
hive: hive.to_owned(),
|
|
||||||
};
|
};
|
||||||
if existing.as_ref() == Some(&wanted) {
|
if existing.as_ref() == Some(&wanted) {
|
||||||
tracing::info!(
|
tracing::info!(
|
||||||
|
|
@ -187,9 +184,8 @@ pub async fn mint_and_verify(agent: &str, hive: &str) -> Result<()> {
|
||||||
.with_context(|| format!("publishing the agent queue credential at {queue_path}"))?;
|
.with_context(|| format!("publishing the agent queue credential at {queue_path}"))?;
|
||||||
tracing::info!(
|
tracing::info!(
|
||||||
agent,
|
agent,
|
||||||
hive,
|
|
||||||
%queue_path,
|
%queue_path,
|
||||||
// Never "rotated": the secret is the same one, only the names
|
// Never "rotated": the secret is the same one, only the name
|
||||||
// around it moved.
|
// around it moved.
|
||||||
corrected = existing.is_some(),
|
corrected = existing.is_some(),
|
||||||
"agent queue credential published"
|
"agent queue credential published"
|
||||||
|
|
@ -280,9 +276,9 @@ async fn read_back_as_agent(
|
||||||
.read(queue_path)
|
.read(queue_path)
|
||||||
.await
|
.await
|
||||||
.with_context(|| format!("reading {queue_path} back under {role}'s own token"))?;
|
.with_context(|| format!("reading {queue_path} back under {role}'s own token"))?;
|
||||||
// The two name fields are compared as well as the secret: they are what
|
// The agent is compared as well as the secret: the verifying end refuses
|
||||||
// the verifying end will grant subjects from, so a mismatch here is the
|
// an object naming a different agent than the path, so a mismatch here is
|
||||||
// same class of fault as an unreadable path.
|
// the same class of fault as an unreadable path.
|
||||||
if read_back != *queue_credential {
|
if read_back != *queue_credential {
|
||||||
bail!("the store returned a different object at {queue_path} than the one just published");
|
bail!("the store returned a different object at {queue_path} than the one just published");
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -100,11 +100,10 @@ enum SwarmNodeKind {
|
||||||
/// `agent_identity::mint_and_verify` — including why this node does not
|
/// `agent_identity::mint_and_verify` — including why this node does not
|
||||||
/// report success on a write.
|
/// report success on a write.
|
||||||
///
|
///
|
||||||
/// Carries the hive for a different reason than `TriggerDeploy` does:
|
/// Carries no hive: neither the certificate, the queue credential nor
|
||||||
/// not as an address, but because the agent's queue credential names the
|
/// the agent's policy names one, so an agent keeps one store identity
|
||||||
/// hive it may take subjects on, so it cannot be written without knowing
|
/// whichever hive it runs on.
|
||||||
/// which hive the agent belongs to.
|
MintAgentIdentity { agent: String },
|
||||||
MintAgentIdentity { hive: String, agent: String },
|
|
||||||
/// Make sure `agent` holds a live forge access token in the swarm secret
|
/// Make sure `agent` holds a live forge access token in the swarm secret
|
||||||
/// store, minting one with the forge's admin API when it does not. See
|
/// store, minting one with the forge's admin API when it does not. See
|
||||||
/// `forge::agent_token` — including why a rotation is a delete then a
|
/// `forge::agent_token` — including why a rotation is a delete then a
|
||||||
|
|
@ -124,8 +123,7 @@ enum SwarmNodeKind {
|
||||||
/// 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`.
|
||||||
/// Carries the hive for the same reason `TriggerDeploy`/`MintAgentIdentity`
|
/// Carries the hive because the wanted-state bucket is keyed per hive.
|
||||||
/// do: the wanted-state bucket is keyed per hive.
|
|
||||||
SetAgentWanted { hive: String, agent: String },
|
SetAgentWanted { hive: String, agent: String },
|
||||||
/// Tell `hive` to rebuild `agent`, by publishing on the swarm's deploy
|
/// Tell `hive` to rebuild `agent`, by publishing on the swarm's deploy
|
||||||
/// subject. The one node kind whose effect leaves this host.
|
/// subject. The one node kind whose effect leaves this host.
|
||||||
|
|
@ -166,12 +164,12 @@ 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::MintAgentIdentity { agent }
|
||||||
| SwarmNodeKind::MintAgentForgeToken { agent }
|
| SwarmNodeKind::MintAgentForgeToken { agent }
|
||||||
| SwarmNodeKind::MintAgentMatrixAccount { agent } => {
|
| SwarmNodeKind::MintAgentMatrixAccount { agent } => {
|
||||||
serde_json::json!({ "agent": agent })
|
serde_json::json!({ "agent": agent })
|
||||||
}
|
}
|
||||||
SwarmNodeKind::TriggerDeploy { hive, agent }
|
SwarmNodeKind::TriggerDeploy { hive, agent }
|
||||||
| SwarmNodeKind::MintAgentIdentity { hive, agent }
|
|
||||||
| SwarmNodeKind::SetAgentWanted { hive, agent } => {
|
| SwarmNodeKind::SetAgentWanted { hive, agent } => {
|
||||||
serde_json::json!({ "agent": agent, "hive": hive })
|
serde_json::json!({ "agent": agent, "hive": hive })
|
||||||
}
|
}
|
||||||
|
|
@ -314,7 +312,7 @@ async fn run_swarm_node(
|
||||||
Err(e) => Outcome::Failed(format!("{e:#}")),
|
Err(e) => Outcome::Failed(format!("{e:#}")),
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
SwarmNodeKind::MintAgentIdentity { hive, agent } => mint_identity(&agent, &hive).await,
|
SwarmNodeKind::MintAgentIdentity { agent } => mint_identity(&agent).await,
|
||||||
SwarmNodeKind::MintAgentForgeToken { agent } => mint_forge_token(deps.forge, &agent).await,
|
SwarmNodeKind::MintAgentForgeToken { agent } => mint_forge_token(deps.forge, &agent).await,
|
||||||
SwarmNodeKind::MintAgentMatrixAccount { agent } => {
|
SwarmNodeKind::MintAgentMatrixAccount { agent } => {
|
||||||
mint_matrix_account(deps.matrix_homeserver.as_deref(), &agent).await
|
mint_matrix_account(deps.matrix_homeserver.as_deref(), &agent).await
|
||||||
|
|
@ -340,10 +338,10 @@ async fn run_swarm_node(
|
||||||
|
|
||||||
/// The `MintAgentIdentity` arm, lifted out so `run_swarm_node` stays under
|
/// The `MintAgentIdentity` arm, lifted out so `run_swarm_node` stays under
|
||||||
/// `clippy::too_many_lines`.
|
/// `clippy::too_many_lines`.
|
||||||
async fn mint_identity(agent: &str, hive: &str) -> hive_jobq::scheduler::Outcome {
|
async fn mint_identity(agent: &str) -> hive_jobq::scheduler::Outcome {
|
||||||
use hive_jobq::scheduler::Outcome;
|
use hive_jobq::scheduler::Outcome;
|
||||||
|
|
||||||
match agent_identity::mint_and_verify(agent, hive).await {
|
match agent_identity::mint_and_verify(agent).await {
|
||||||
Ok(()) => Outcome::Done,
|
Ok(()) => Outcome::Done,
|
||||||
Err(e) => Outcome::Failed(format!("{e:#}")),
|
Err(e) => Outcome::Failed(format!("{e:#}")),
|
||||||
}
|
}
|
||||||
|
|
@ -1512,7 +1510,6 @@ fn declare_agent_job(
|
||||||
// authelia.
|
// authelia.
|
||||||
let mint_identity = b
|
let mint_identity = b
|
||||||
.node(SwarmNodeKind::MintAgentIdentity {
|
.node(SwarmNodeKind::MintAgentIdentity {
|
||||||
hive: hive.to_owned(),
|
|
||||||
agent: agent.to_owned(),
|
agent: agent.to_owned(),
|
||||||
})
|
})
|
||||||
.after_ok(create_identity);
|
.after_ok(create_identity);
|
||||||
|
|
@ -1670,20 +1667,6 @@ async fn get_agent_config_pr(
|
||||||
Ok(Json(cache.get(&name)))
|
Ok(Json(cache.get(&name)))
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Body of `POST /api/agents/{name}/identity`.
|
|
||||||
#[derive(Deserialize, ToSchema)]
|
|
||||||
struct MintAgentIdentityRequest {
|
|
||||||
/// The hive this agent belongs to.
|
|
||||||
///
|
|
||||||
/// Required, for the same reason [`CreateAgentRequest`]'s is: the
|
|
||||||
/// credentials this mints name a hive, and the controller has nowhere to
|
|
||||||
/// look one up — agents are created on hives at runtime and this daemon
|
|
||||||
/// keeps no roster of which agent is where. An operator naming the wrong
|
|
||||||
/// one would hand the agent subjects on a hive it does not run on, so it
|
|
||||||
/// is asked for rather than guessed at.
|
|
||||||
hive: String,
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Success body of `POST /api/agents/{name}/identity`.
|
/// Success body of `POST /api/agents/{name}/identity`.
|
||||||
#[derive(Clone, Debug, Serialize, ToSchema)]
|
#[derive(Clone, Debug, Serialize, ToSchema)]
|
||||||
struct MintAgentIdentityResponse {
|
struct MintAgentIdentityResponse {
|
||||||
|
|
@ -1712,10 +1695,9 @@ struct MintAgentIdentityResponse {
|
||||||
post,
|
post,
|
||||||
path = "/api/agents/{name}/identity",
|
path = "/api/agents/{name}/identity",
|
||||||
params(("name" = String, Path, description = "agent name")),
|
params(("name" = String, Path, description = "agent name")),
|
||||||
request_body = MintAgentIdentityRequest,
|
|
||||||
responses(
|
responses(
|
||||||
(status = 200, description = "mint queued", body = MintAgentIdentityResponse),
|
(status = 200, description = "mint queued", body = MintAgentIdentityResponse),
|
||||||
(status = 400, description = "`name` or `hive` is not a valid identifier, or `hive` is not in this swarm (problem+json)", body = String),
|
(status = 400, description = "`name` is not a valid identifier (problem+json)", body = String),
|
||||||
(status = 500, description = "the job could not be queued (problem+json)", body = String),
|
(status = 500, description = "the job could not be queued (problem+json)", body = String),
|
||||||
),
|
),
|
||||||
tag = "agents"
|
tag = "agents"
|
||||||
|
|
@ -1723,28 +1705,12 @@ struct MintAgentIdentityResponse {
|
||||||
async fn mint_agent_identity(
|
async fn mint_agent_identity(
|
||||||
State(state): State<AppState>,
|
State(state): State<AppState>,
|
||||||
Path(name): Path<String>,
|
Path(name): Path<String>,
|
||||||
Json(req): Json<MintAgentIdentityRequest>,
|
|
||||||
) -> Result<Json<MintAgentIdentityResponse>, problem_details::ProblemDetails> {
|
) -> Result<Json<MintAgentIdentityResponse>, problem_details::ProblemDetails> {
|
||||||
// Both names are interpolated into store paths and policy documents
|
// The name is interpolated into store paths and policy documents
|
||||||
// downstream, so both are validated here as well as there.
|
// downstream, so it is validated here as well as there.
|
||||||
let agent = hive_types::Ident::parse(&name)
|
let agent = hive_types::Ident::parse(&name)
|
||||||
.map_err(|reason| error_problem(axum::http::StatusCode::BAD_REQUEST, reason))?
|
.map_err(|reason| error_problem(axum::http::StatusCode::BAD_REQUEST, reason))?
|
||||||
.into_string();
|
.into_string();
|
||||||
let hive = hive_types::Ident::parse(&req.hive)
|
|
||||||
.map_err(|reason| error_problem(axum::http::StatusCode::BAD_REQUEST, reason))?
|
|
||||||
.into_string();
|
|
||||||
if !state.hives.iter().any(|h| h.name == hive) {
|
|
||||||
let known: Vec<&str> = state.hives.iter().map(|h| h.name.as_str()).collect();
|
|
||||||
let known = if known.is_empty() {
|
|
||||||
"(none configured)".to_owned()
|
|
||||||
} else {
|
|
||||||
known.join(", ")
|
|
||||||
};
|
|
||||||
return Err(error_problem(
|
|
||||||
axum::http::StatusCode::BAD_REQUEST,
|
|
||||||
&format!("hive {hive:?} is not in this swarm — known hives: {known}"),
|
|
||||||
));
|
|
||||||
}
|
|
||||||
|
|
||||||
// No reserved-name or collision warnings here, unlike `create_agent`:
|
// No reserved-name or collision warnings here, unlike `create_agent`:
|
||||||
// those answer "is this name available", and this route is only ever
|
// those answer "is this name available", and this route is only ever
|
||||||
|
|
@ -1757,7 +1723,6 @@ async fn mint_agent_identity(
|
||||||
.insert_job(None, |b| {
|
.insert_job(None, |b| {
|
||||||
vec![
|
vec![
|
||||||
b.node(SwarmNodeKind::MintAgentIdentity {
|
b.node(SwarmNodeKind::MintAgentIdentity {
|
||||||
hive: hive.clone(),
|
|
||||||
agent: agent.clone(),
|
agent: agent.clone(),
|
||||||
})
|
})
|
||||||
.guid(),
|
.guid(),
|
||||||
|
|
@ -2803,39 +2768,6 @@ mod tests {
|
||||||
assert_eq!(resp.change, super::ForgeAdminChange::AlreadyAdmin);
|
assert_eq!(resp.change, super::ForgeAdminChange::AlreadyAdmin);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// The backfill route's roster check, asserted by effect for the same
|
|
||||||
/// reason its sibling above is: a refusal that queued first would still
|
|
||||||
/// re-mint the agent's certificate, which every running agent on the
|
|
||||||
/// named hive picks up on its next boot.
|
|
||||||
#[tokio::test]
|
|
||||||
async fn a_backfill_for_a_hive_outside_the_roster_queues_nothing() {
|
|
||||||
let (state, sched) = state_with_roster();
|
|
||||||
|
|
||||||
let err = super::mint_agent_identity(
|
|
||||||
axum::extract::State(state),
|
|
||||||
axum::extract::Path("atlas".to_owned()),
|
|
||||||
axum::Json(super::MintAgentIdentityRequest {
|
|
||||||
hive: "pr1maa".to_owned(),
|
|
||||||
}),
|
|
||||||
)
|
|
||||||
.await
|
|
||||||
.expect_err("a hive outside the roster must be refused");
|
|
||||||
|
|
||||||
let rendered = format!("{err:?}");
|
|
||||||
assert!(
|
|
||||||
rendered.contains("pr1ma"),
|
|
||||||
"the refusal should name the known hives, got: {rendered}"
|
|
||||||
);
|
|
||||||
|
|
||||||
let queued = sched
|
|
||||||
.lock()
|
|
||||||
.unwrap_or_else(std::sync::PoisonError::into_inner)
|
|
||||||
.graph()
|
|
||||||
.nodes()
|
|
||||||
.count();
|
|
||||||
assert_eq!(queued, 0, "a refused backfill must queue no work");
|
|
||||||
}
|
|
||||||
|
|
||||||
/// A name that is not an identifier is refused before it can reach a
|
/// A name that is not an identifier is refused before it can reach a
|
||||||
/// store path. `create_agent` gets this for free from the roster check
|
/// store path. `create_agent` gets this for free from the roster check
|
||||||
/// on the hive; the agent name has no roster to check against, so this
|
/// on the hive; the agent name has no roster to check against, so this
|
||||||
|
|
@ -2848,9 +2780,6 @@ mod tests {
|
||||||
super::mint_agent_identity(
|
super::mint_agent_identity(
|
||||||
axum::extract::State(state),
|
axum::extract::State(state),
|
||||||
axum::extract::Path("../beta".to_owned()),
|
axum::extract::Path("../beta".to_owned()),
|
||||||
axum::Json(super::MintAgentIdentityRequest {
|
|
||||||
hive: "pr1ma".to_owned(),
|
|
||||||
}),
|
|
||||||
)
|
)
|
||||||
.await
|
.await
|
||||||
.expect_err("a traversal in the agent name must be refused");
|
.expect_err("a traversal in the agent name must be refused");
|
||||||
|
|
@ -2876,12 +2805,9 @@ mod tests {
|
||||||
let queued = super::mint_agent_identity(
|
let queued = super::mint_agent_identity(
|
||||||
axum::extract::State(state),
|
axum::extract::State(state),
|
||||||
axum::extract::Path("atlas".to_owned()),
|
axum::extract::Path("atlas".to_owned()),
|
||||||
axum::Json(super::MintAgentIdentityRequest {
|
|
||||||
hive: "pr1ma".to_owned(),
|
|
||||||
}),
|
|
||||||
)
|
)
|
||||||
.await
|
.await
|
||||||
.expect("a hive in the roster must be accepted");
|
.expect("a valid agent name must be accepted");
|
||||||
|
|
||||||
let guard = sched
|
let guard = sched
|
||||||
.lock()
|
.lock()
|
||||||
|
|
@ -2893,10 +2819,9 @@ mod tests {
|
||||||
assert!(
|
assert!(
|
||||||
matches!(
|
matches!(
|
||||||
&node.payload,
|
&node.payload,
|
||||||
SwarmNodeKind::MintAgentIdentity { hive, agent }
|
SwarmNodeKind::MintAgentIdentity { agent } if agent == "atlas"
|
||||||
if hive == "pr1ma" && agent == "atlas"
|
|
||||||
),
|
),
|
||||||
"the one node must be the mint, carrying both names: {:?}",
|
"the one node must be the mint, for the named agent: {:?}",
|
||||||
node.payload
|
node.payload
|
||||||
);
|
);
|
||||||
// The id the operator is told to watch has to be the node that was
|
// The id the operator is told to watch has to be the node that was
|
||||||
|
|
@ -3025,7 +2950,6 @@ mod tests {
|
||||||
let id = sched
|
let id = sched
|
||||||
.append(
|
.append(
|
||||||
SwarmNodeKind::MintAgentIdentity {
|
SwarmNodeKind::MintAgentIdentity {
|
||||||
hive: "pr1ma".to_owned(),
|
|
||||||
agent: "atlas".to_owned(),
|
agent: "atlas".to_owned(),
|
||||||
},
|
},
|
||||||
Vec::new(),
|
Vec::new(),
|
||||||
|
|
@ -3192,25 +3116,6 @@ mod tests {
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// The node carries the hive, and the viewer has to see it. The `data`
|
|
||||||
/// match is an or-pattern on purpose (see its own comment), and this is
|
|
||||||
/// the assertion that the new variant joined the two-field arm rather
|
|
||||||
/// than the agent-only one — a viewer silently missing the hive is the
|
|
||||||
/// failure that comment describes having already happened once.
|
|
||||||
#[test]
|
|
||||||
fn a_mint_node_renders_both_the_agent_and_the_hive() {
|
|
||||||
use hive_jobq_wire::WireNode as _;
|
|
||||||
|
|
||||||
let kind = SwarmNodeKind::MintAgentIdentity {
|
|
||||||
hive: "pr1ma".to_owned(),
|
|
||||||
agent: "atlas".to_owned(),
|
|
||||||
};
|
|
||||||
assert_eq!(kind.label(), "mint_agent_identity");
|
|
||||||
let data = kind.data(1);
|
|
||||||
assert_eq!(data["agent"], "atlas");
|
|
||||||
assert_eq!(data["hive"], "pr1ma");
|
|
||||||
}
|
|
||||||
|
|
||||||
/// The ordering the operator's ruling requires: a hive cannot pass down
|
/// The ordering the operator's ruling requires: a hive cannot pass down
|
||||||
/// a certificate the swarm has not published, so the deploy message must
|
/// a certificate the swarm has not published, so the deploy message must
|
||||||
/// not leave before the mint is terminal.
|
/// not leave before the mint is terminal.
|
||||||
|
|
|
||||||
155
swarm-queue-client/src/agent_token.rs
Normal file
155
swarm-queue-client/src/agent_token.rs
Normal file
|
|
@ -0,0 +1,155 @@
|
||||||
|
//! The one spelling of the token an agent presents its own queue secret in,
|
||||||
|
//! shared by the agent that formats it and the auth-callout responder that
|
||||||
|
//! parses it back:
|
||||||
|
//! [`AGENT_TOKEN_PREFIX`](crate::agent_token::AGENT_TOKEN_PREFIX), the agent's
|
||||||
|
//! name, `.`, the secret.
|
||||||
|
//!
|
||||||
|
//! The secret itself lives at `swarm/agents/<agent>/queue` in the swarm's
|
||||||
|
//! secret store (`swarm_secret_client::queue`). This module knows nothing of
|
||||||
|
//! the store, so an agent formats its token without linking a store client.
|
||||||
|
|
||||||
|
/// Marks an `auth_token` as an agent's own credential.
|
||||||
|
///
|
||||||
|
/// An OIDC access token may itself contain `.`, so the `<agent>.<secret>`
|
||||||
|
/// shape alone does not tell the two apart. Authelia's access tokens start
|
||||||
|
/// `authelia_at_`.
|
||||||
|
pub const AGENT_TOKEN_PREFIX: &str = "swarm-agent.";
|
||||||
|
|
||||||
|
/// An agent's own credential as presented at the queue.
|
||||||
|
///
|
||||||
|
/// No `Debug`: `secret` is the credential itself.
|
||||||
|
pub struct AgentToken<'a> {
|
||||||
|
/// The agent the presenter claims to be. Unproven until `secret` is
|
||||||
|
/// checked against that agent's stored credential.
|
||||||
|
pub agent: &'a str,
|
||||||
|
/// The presented secret.
|
||||||
|
pub secret: &'a str,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A token that is not `<agent>.<secret>` after its prefix, or parts that
|
||||||
|
/// would not make one. Carries the reason only: a token holds a secret.
|
||||||
|
#[derive(Debug, thiserror::Error)]
|
||||||
|
#[error("malformed agent token: {0}")]
|
||||||
|
pub struct Malformed(pub &'static str);
|
||||||
|
|
||||||
|
/// Spell `agent`'s credential as the token it presents at the queue.
|
||||||
|
///
|
||||||
|
/// # Errors
|
||||||
|
/// [`Malformed`] when `agent` or `secret` is empty or holds anything outside
|
||||||
|
/// `[A-Za-z0-9_-]`. Either would make [`parse_agent_token`] split the token
|
||||||
|
/// differently than it was joined.
|
||||||
|
pub fn format_agent_token(agent: &str, secret: &str) -> Result<String, Malformed> {
|
||||||
|
check_agent(agent)?;
|
||||||
|
check_secret(secret)?;
|
||||||
|
Ok(format!("{AGENT_TOKEN_PREFIX}{agent}.{secret}"))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Read a presented token back into an [`AgentToken`].
|
||||||
|
///
|
||||||
|
/// `None` when `token` does not start with [`AGENT_TOKEN_PREFIX`]: it is some
|
||||||
|
/// other kind of token, not a malformed one of these. `Some(Err(_))` when it
|
||||||
|
/// does and is not exactly `<agent>.<secret>` after it.
|
||||||
|
#[must_use]
|
||||||
|
pub fn parse_agent_token(token: &str) -> Option<Result<AgentToken<'_>, Malformed>> {
|
||||||
|
let rest = token.strip_prefix(AGENT_TOKEN_PREFIX)?;
|
||||||
|
Some(
|
||||||
|
rest.split_once('.')
|
||||||
|
.ok_or(Malformed("no `.` between the agent and the secret"))
|
||||||
|
.and_then(|(agent, secret)| {
|
||||||
|
check_agent(agent)?;
|
||||||
|
check_secret(secret)?;
|
||||||
|
Ok(AgentToken { agent, secret })
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn is_segment(s: &str) -> bool {
|
||||||
|
!s.is_empty()
|
||||||
|
&& s.bytes()
|
||||||
|
.all(|b| b.is_ascii_alphanumeric() || b == b'-' || b == b'_')
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The alphabet a store path segment allows, so the name cannot widen a
|
||||||
|
/// subject with `.`, `*` or `>`, or address another agent's path.
|
||||||
|
fn check_agent(agent: &str) -> Result<(), Malformed> {
|
||||||
|
if is_segment(agent) {
|
||||||
|
Ok(())
|
||||||
|
} else {
|
||||||
|
Err(Malformed("the agent is not a single [A-Za-z0-9_-] segment"))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The alphabet the controller mints secrets in, base64url without padding.
|
||||||
|
/// The error never carries the value.
|
||||||
|
fn check_secret(secret: &str) -> Result<(), Malformed> {
|
||||||
|
if is_segment(secret) {
|
||||||
|
Ok(())
|
||||||
|
} else {
|
||||||
|
Err(Malformed(
|
||||||
|
"the secret is empty or holds a byte outside [A-Za-z0-9_-]",
|
||||||
|
))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn an_agent_token_round_trips() {
|
||||||
|
let token = format_agent_token("atlas", "Ab9_-z").expect("legal");
|
||||||
|
assert_eq!(token, "swarm-agent.atlas.Ab9_-z");
|
||||||
|
let parsed = parse_agent_token(&token)
|
||||||
|
.expect("carries the prefix")
|
||||||
|
.expect("well-formed");
|
||||||
|
assert_eq!(parsed.agent, "atlas");
|
||||||
|
assert_eq!(parsed.secret, "Ab9_-z");
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Anything without the prefix belongs to the OIDC path, including a
|
||||||
|
/// token that happens to look like `<agent>.<secret>`.
|
||||||
|
#[test]
|
||||||
|
fn a_token_without_the_prefix_is_not_an_agent_token() {
|
||||||
|
for token in ["authelia_at_abc.def", "atlas.s3cr3t", "", "swarm-agent"] {
|
||||||
|
assert!(parse_agent_token(token).is_none(), "{token:?}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_malformed_agent_token_is_refused() {
|
||||||
|
for token in [
|
||||||
|
"swarm-agent.",
|
||||||
|
"swarm-agent.atlas",
|
||||||
|
"swarm-agent.atlas.",
|
||||||
|
"swarm-agent..s3cr3t",
|
||||||
|
"swarm-agent.atlas.s3.cr3t",
|
||||||
|
"swarm-agent.at*las.s3cr3t",
|
||||||
|
"swarm-agent.at>las.s3cr3t",
|
||||||
|
"swarm-agent.at/las.s3cr3t",
|
||||||
|
"swarm-agent.atlas.s3cr3t=",
|
||||||
|
"swarm-agent.atlas.s3 cr3t",
|
||||||
|
] {
|
||||||
|
assert!(
|
||||||
|
matches!(parse_agent_token(token), Some(Err(_))),
|
||||||
|
"{token:?} must be refused"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// What formats always parses back into the same two parts.
|
||||||
|
#[test]
|
||||||
|
fn formatting_refuses_what_parsing_would_split_differently() {
|
||||||
|
assert!(format_agent_token("at.las", "s3cr3t").is_err());
|
||||||
|
assert!(format_agent_token("atlas", "s3.cr3t").is_err());
|
||||||
|
assert!(format_agent_token("atlas", "").is_err());
|
||||||
|
assert!(format_agent_token("", "s3cr3t").is_err());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_malformed_secret_is_not_echoed_in_the_error() {
|
||||||
|
let Some(Err(e)) = parse_agent_token("swarm-agent.atlas.hunter2!") else {
|
||||||
|
panic!("must be refused");
|
||||||
|
};
|
||||||
|
assert!(!e.to_string().contains("hunter2"), "{e}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -178,6 +178,10 @@ pub mod wanted;
|
||||||
/// inside it. See the module doc for why the two must not merge.
|
/// inside it. See the module doc for why the two must not merge.
|
||||||
pub mod agent_status;
|
pub mod agent_status;
|
||||||
|
|
||||||
|
/// The token an agent presents its own queue secret in. Store-free, so an agent
|
||||||
|
/// formats it without linking the secret-store client.
|
||||||
|
pub mod agent_token;
|
||||||
|
|
||||||
/// The subject the swarm controller publishes on when the hive-wide knowledge
|
/// The subject the swarm controller publishes on when the hive-wide knowledge
|
||||||
/// repository has changed. One writer, many readers — every hive subscribes.
|
/// repository has changed. One writer, many readers — every hive subscribes.
|
||||||
///
|
///
|
||||||
|
|
|
||||||
|
|
@ -22,6 +22,9 @@
|
||||||
//! reaching the store and nothing else; deriving a queue identity from it would
|
//! reaching the store and nothing else; deriving a queue identity from it would
|
||||||
//! couple the two credentials' lifetimes, so that renewing one would mean
|
//! couple the two credentials' lifetimes, so that renewing one would mean
|
||||||
//! renewing the other.
|
//! renewing the other.
|
||||||
|
//!
|
||||||
|
//! The token an agent presents that secret in at the queue is spelled by
|
||||||
|
//! `swarm_queue_client::agent_token`, which needs no store client.
|
||||||
|
|
||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
|
|
||||||
|
|
@ -57,14 +60,11 @@ pub fn agent_queue_path(agent: &str) -> Result<String, Error> {
|
||||||
Ok(format!("{prefix}/queue"))
|
Ok(format!("{prefix}/queue"))
|
||||||
}
|
}
|
||||||
|
|
||||||
/// What [`agent_queue_path`] holds: the secret, and the principal it proves.
|
/// What [`agent_queue_path`] holds: the secret, and the agent it proves.
|
||||||
///
|
///
|
||||||
/// Both names ride **in the object** rather than being parsed back out of a
|
/// No hive: an agent's identity is not tied to one, and the subjects the
|
||||||
/// composite principal string. Hive and agent names draw from the same
|
/// verifier grants are keyed on the agent alone. Objects written with a `hive`
|
||||||
/// alphabet (`hive_types::Ident`, `[a-z0-9-]`), so a principal spelled
|
/// field still decode, because unknown fields are ignored.
|
||||||
/// `hive-<hive>-agent-<agent>` parses two ways for a name containing `-agent-`
|
|
||||||
/// — and an ambiguous principal parse in an authorisation path is a caller that
|
|
||||||
/// authenticates fine and is handed somebody else's grant.
|
|
||||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||||
pub struct AgentCredential {
|
pub struct AgentCredential {
|
||||||
/// The secret itself. Named to match [`Credential::value`] and
|
/// The secret itself. Named to match [`Credential::value`] and
|
||||||
|
|
@ -74,13 +74,6 @@ pub struct AgentCredential {
|
||||||
|
|
||||||
/// The agent this secret authenticates.
|
/// The agent this secret authenticates.
|
||||||
pub agent: String,
|
pub agent: String,
|
||||||
|
|
||||||
/// The hive that agent belongs to.
|
|
||||||
///
|
|
||||||
/// Here because the verifying end has no roster to look it up in, and
|
|
||||||
/// because the subjects an agent is granted are hive-templated — without
|
|
||||||
/// this field the verifier would know *who* is connecting and not *where*.
|
|
||||||
pub hive: String,
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// What the path holds: the client secret, plus the client id it belongs to.
|
/// What the path holds: the client secret, plus the client id it belongs to.
|
||||||
|
|
@ -198,7 +191,6 @@ mod tests {
|
||||||
let c = AgentCredential {
|
let c = AgentCredential {
|
||||||
value: "s3cr3t".to_owned(),
|
value: "s3cr3t".to_owned(),
|
||||||
agent: "atlas".to_owned(),
|
agent: "atlas".to_owned(),
|
||||||
hive: "alpha".to_owned(),
|
|
||||||
};
|
};
|
||||||
let json = serde_json::to_string(&c).expect("serialises");
|
let json = serde_json::to_string(&c).expect("serialises");
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
|
|
@ -212,29 +204,39 @@ mod tests {
|
||||||
let json = serde_json::to_value(AgentCredential {
|
let json = serde_json::to_value(AgentCredential {
|
||||||
value: "s3cr3t".to_owned(),
|
value: "s3cr3t".to_owned(),
|
||||||
agent: "atlas".to_owned(),
|
agent: "atlas".to_owned(),
|
||||||
hive: "alpha".to_owned(),
|
|
||||||
})
|
})
|
||||||
.expect("serialises");
|
.expect("serialises");
|
||||||
assert_eq!(json["value"], "s3cr3t");
|
assert_eq!(json["value"], "s3cr3t");
|
||||||
assert_eq!(json["agent"], "atlas");
|
assert_eq!(json["agent"], "atlas");
|
||||||
assert_eq!(json["hive"], "alpha");
|
assert!(json.get("hive").is_none(), "{json}");
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Neither name is optional. An object missing one is not a usable
|
/// A stored object may carry a `hive` field. It is ignored, and the object
|
||||||
/// credential — a verifier holding `None` for the hive can only guess at
|
/// decodes.
|
||||||
/// the subjects to grant, and guessing is the failure this shape exists to
|
|
||||||
/// prevent.
|
|
||||||
#[test]
|
#[test]
|
||||||
fn an_agent_object_missing_a_principal_does_not_decode() {
|
fn a_stored_agent_object_that_still_names_a_hive_decodes() {
|
||||||
assert!(
|
let c: AgentCredential =
|
||||||
serde_json::from_str::<AgentCredential>(r#"{"value":"s","agent":"atlas"}"#).is_err()
|
serde_json::from_str(r#"{"value":"s3cr3t","agent":"atlas","hive":"alpha"}"#)
|
||||||
|
.expect("a stored object carrying `hive` decodes");
|
||||||
|
assert_eq!(
|
||||||
|
c,
|
||||||
|
AgentCredential {
|
||||||
|
value: "s3cr3t".to_owned(),
|
||||||
|
agent: "atlas".to_owned(),
|
||||||
|
}
|
||||||
);
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The agent is required: it is what the verifier checks the presented
|
||||||
|
/// name against.
|
||||||
|
#[test]
|
||||||
|
fn an_agent_object_missing_its_agent_does_not_decode() {
|
||||||
|
assert!(serde_json::from_str::<AgentCredential>(r#"{"value":"s"}"#).is_err());
|
||||||
assert!(
|
assert!(
|
||||||
serde_json::from_str::<AgentCredential>(r#"{"value":"s","hive":"alpha"}"#).is_err()
|
serde_json::from_str::<AgentCredential>(r#"{"value":"s","hive":"alpha"}"#).is_err()
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// The two kinds are different objects at different paths, and neither
|
|
||||||
/// decodes as the other — the property that keeps a reader from picking up
|
/// decodes as the other — the property that keeps a reader from picking up
|
||||||
/// a hive-shared credential where a per-agent one was meant.
|
/// a hive-shared credential where a per-agent one was meant.
|
||||||
#[test]
|
#[test]
|
||||||
|
|
@ -249,7 +251,6 @@ mod tests {
|
||||||
let agent_json = serde_json::to_string(&AgentCredential {
|
let agent_json = serde_json::to_string(&AgentCredential {
|
||||||
value: "s".to_owned(),
|
value: "s".to_owned(),
|
||||||
agent: "atlas".to_owned(),
|
agent: "atlas".to_owned(),
|
||||||
hive: "alpha".to_owned(),
|
|
||||||
})
|
})
|
||||||
.expect("serialises");
|
.expect("serialises");
|
||||||
assert!(serde_json::from_str::<Credential>(&agent_json).is_err());
|
assert!(serde_json::from_str::<Credential>(&agent_json).is_err());
|
||||||
|
|
|
||||||
|
|
@ -51,13 +51,6 @@ struct CreateAgentResponse {
|
||||||
warnings: Vec<String>,
|
warnings: Vec<String>,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Body of `POST /api/agents/{name}/identity`. Mirrors the controller's own
|
|
||||||
/// `MintAgentIdentityRequest` — see this module's doc comment.
|
|
||||||
#[derive(Serialize)]
|
|
||||||
struct MintIdentityRequest<'a> {
|
|
||||||
hive: &'a str,
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Success body of `POST /api/agents/{name}/identity`.
|
/// Success body of `POST /api/agents/{name}/identity`.
|
||||||
#[derive(Deserialize)]
|
#[derive(Deserialize)]
|
||||||
struct MintIdentityResponse {
|
struct MintIdentityResponse {
|
||||||
|
|
@ -117,11 +110,10 @@ pub(crate) fn parse_ident(value: &str, what: &str) -> Result<String> {
|
||||||
///
|
///
|
||||||
/// Synchronous for the same reason [`create`] is, and built on the same
|
/// Synchronous for the same reason [`create`] is, and built on the same
|
||||||
/// round trip.
|
/// round trip.
|
||||||
pub(crate) fn mint_identity(socket: &Path, name: &str, hive: &str) -> Result<()> {
|
pub(crate) fn mint_identity(socket: &Path, name: &str) -> Result<()> {
|
||||||
// Client-side first, so a typo is a local error rather than a 400 the
|
// Client-side first, so a typo is a local error rather than a 400 the
|
||||||
// operator waits for. The controller validates both again.
|
// operator waits for. The controller validates it again.
|
||||||
let name = parse_ident(name, "agent name")?;
|
let name = parse_ident(name, "agent name")?;
|
||||||
let hive = parse_ident(hive, "hive")?;
|
|
||||||
|
|
||||||
let rt = tokio::runtime::Builder::new_current_thread()
|
let rt = tokio::runtime::Builder::new_current_thread()
|
||||||
.enable_io()
|
.enable_io()
|
||||||
|
|
@ -130,15 +122,15 @@ pub(crate) fn mint_identity(socket: &Path, name: &str, hive: &str) -> Result<()>
|
||||||
let resp: MintIdentityResponse = rt.block_on(post(
|
let resp: MintIdentityResponse = rt.block_on(post(
|
||||||
socket,
|
socket,
|
||||||
&format!("/api/agents/{name}/identity"),
|
&format!("/api/agents/{name}/identity"),
|
||||||
&MintIdentityRequest { hive: &hive },
|
&serde_json::json!({}),
|
||||||
"mint-identity",
|
"mint-identity",
|
||||||
))?;
|
))?;
|
||||||
|
|
||||||
println!("queued: job node {}", resp.node_id);
|
println!("queued: job node {}", resp.node_id);
|
||||||
println!(
|
println!(
|
||||||
"agent {name:?} will have its identity re-minted on hive {hive:?} once the job graph \
|
"agent {name:?} will have its identity re-minted once the job graph runs; `swarmctl` \
|
||||||
runs; `swarmctl` does not wait for it. An existing queue secret is kept as it is; the \
|
does not wait for it. An existing queue secret is kept as it is; the store \
|
||||||
store certificate is re-minted and the agent picks the new one up on its next boot"
|
certificate is re-minted and the agent picks the new one up on its next boot"
|
||||||
);
|
);
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -242,16 +242,6 @@ struct AgentMintForgeTokenArgs {
|
||||||
struct AgentMintIdentityArgs {
|
struct AgentMintIdentityArgs {
|
||||||
/// Name of an agent that already exists.
|
/// Name of an agent that already exists.
|
||||||
name: String,
|
name: String,
|
||||||
/// The hive that agent runs on.
|
|
||||||
///
|
|
||||||
/// Required, and deliberately not defaulted: the credentials this mints
|
|
||||||
/// name a hive, and neither this CLI nor the controller keeps a roster of
|
|
||||||
/// which agent is on which hive. Naming the wrong one gives the agent an
|
|
||||||
/// identity scoped to a hive it doesn't run on. The controller checks
|
|
||||||
/// the value against the swarm's hive roster and names the known hives if
|
|
||||||
/// it misses.
|
|
||||||
#[arg(long, value_name = "HIVE")]
|
|
||||||
hive: String,
|
|
||||||
/// swarm-controller's unix socket.
|
/// swarm-controller's unix socket.
|
||||||
///
|
///
|
||||||
/// Supplied by the nix module that installs this binary, from the same
|
/// Supplied by the nix module that installs this binary, from the same
|
||||||
|
|
@ -357,7 +347,7 @@ fn main() -> Result<()> {
|
||||||
command: AgentVerb::MintIdentity(args),
|
command: AgentVerb::MintIdentity(args),
|
||||||
} => {
|
} => {
|
||||||
let socket = path_from(args.controller_socket, "SWARM_CONTROLLER_SOCKET")?;
|
let socket = path_from(args.controller_socket, "SWARM_CONTROLLER_SOCKET")?;
|
||||||
agent::mint_identity(&socket, &args.name, &args.hive)
|
agent::mint_identity(&socket, &args.name)
|
||||||
}
|
}
|
||||||
// Same socket-resolution reasoning as `Create` above.
|
// Same socket-resolution reasoning as `Create` above.
|
||||||
Verb::Agent {
|
Verb::Agent {
|
||||||
|
|
@ -732,20 +722,10 @@ mod tests {
|
||||||
assert!(args.controller_socket.is_none());
|
assert!(args.controller_socket.is_none());
|
||||||
}
|
}
|
||||||
|
|
||||||
/// The backfill verb takes the same two names as `create`, and `--hive`
|
|
||||||
/// is required on it for the same reason: it is an address nobody can
|
|
||||||
/// infer.
|
|
||||||
#[test]
|
#[test]
|
||||||
fn the_backfill_verb_takes_an_agent_and_a_hive() {
|
fn the_backfill_verb_takes_an_agent_and_no_hive() {
|
||||||
let cli = Cli::try_parse_from([
|
let cli = Cli::try_parse_from(["swarmctl", "agent", "mint-identity", "scribe"])
|
||||||
"swarmctl",
|
.expect("the minimal form parses");
|
||||||
"agent",
|
|
||||||
"mint-identity",
|
|
||||||
"scribe",
|
|
||||||
"--hive",
|
|
||||||
"alpha",
|
|
||||||
])
|
|
||||||
.expect("the minimal form parses");
|
|
||||||
let Verb::Agent {
|
let Verb::Agent {
|
||||||
command: AgentVerb::MintIdentity(args),
|
command: AgentVerb::MintIdentity(args),
|
||||||
} = cli.command
|
} = cli.command
|
||||||
|
|
@ -753,12 +733,18 @@ mod tests {
|
||||||
panic!("expected `agent mint-identity`");
|
panic!("expected `agent mint-identity`");
|
||||||
};
|
};
|
||||||
assert_eq!(args.name, "scribe");
|
assert_eq!(args.name, "scribe");
|
||||||
assert_eq!(args.hive, "alpha");
|
|
||||||
assert!(args.controller_socket.is_none());
|
assert!(args.controller_socket.is_none());
|
||||||
|
|
||||||
assert!(
|
assert!(
|
||||||
Cli::try_parse_from(["swarmctl", "agent", "mint-identity", "scribe"]).is_err(),
|
Cli::try_parse_from([
|
||||||
"an omitted hive must not be defaulted"
|
"swarmctl",
|
||||||
|
"agent",
|
||||||
|
"mint-identity",
|
||||||
|
"scribe",
|
||||||
|
"--hive",
|
||||||
|
"a"
|
||||||
|
])
|
||||||
|
.is_err(),
|
||||||
|
"the identity has no hive, so the verb must not take one"
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue