swarmctl: re-mint an existing agent's store identity
Agent creation at swarm level is event-driven and nothing sweeps for
agents missing a credential, so an agent created before a credential
joined the mint never receives one -- nothing comes back around to it.
Without a way to re-run the mint by hand, the only route to giving an
existing agent its queue credential would be to delete and recreate the
agent.
POST /api/agents/{name}/identity enqueues the same MintAgentIdentity
node POST /api/agents declares, rather than writing inline: a second
code path that mints an identity is a second place for the four strings
that have to agree to disagree. swarmctl agent mint-identity is the
operator end, the same POST-and-print-the-node-id shape agent create
already has.
--hive is required on both ends. Neither the CLI nor the controller
keeps a roster of which agent runs where, and the credentials this mints
name a hive, so a default would be a guess that hands an agent subjects
on a hive it does not run on.
Documents the backfill as a runbook step, and fills in the renewal cell
the credential matrix requires for the new row.
This commit is contained in:
parent
ffd5018b18
commit
1442168715
6 changed files with 332 additions and 37 deletions
|
|
@ -1,11 +1,11 @@
|
|||
//! `swarmctl agent create` — queue the swarm-controller's agent-creation
|
||||
//! job graph.
|
||||
//! `swarmctl agent create` and `swarmctl agent mint-identity` — queue work
|
||||
//! on the swarm-controller's job graph.
|
||||
//!
|
||||
//! `POST /api/agents` inserts a DAG and returns as soon as it is
|
||||
//! *inserted*. This verb prints the queued node id and stops: the graph's
|
||||
//! last node only *publishes* a deploy, after which the hive converges on
|
||||
//! its own clock, so even a settled graph would not mean the agent is up
|
||||
//! and there is nothing here that could be waited on honestly.
|
||||
//! Each POSTs and returns as soon as the work is *inserted*. Both verbs
|
||||
//! print the queued node id and stop: creation's last node only *publishes*
|
||||
//! a deploy, after which the hive converges on its own clock, so even a
|
||||
//! settled graph would not mean the agent is up and there is nothing here
|
||||
//! that could be waited on honestly.
|
||||
//!
|
||||
//! Transport is a bare `hyper` HTTP/1.1 client handshaked onto a
|
||||
//! [`tokio::net::UnixStream`] via [`hyper_util::rt::TokioIo`] — the
|
||||
|
|
@ -51,6 +51,19 @@ struct CreateAgentResponse {
|
|||
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`.
|
||||
#[derive(Deserialize)]
|
||||
struct MintIdentityResponse {
|
||||
node_id: u64,
|
||||
}
|
||||
|
||||
/// Run `swarmctl agent create`.
|
||||
///
|
||||
/// Synchronous on purpose: every other verb in this crate is, and this is
|
||||
|
|
@ -94,10 +107,60 @@ fn parse_ident(value: &str, what: &str) -> Result<String> {
|
|||
.map_err(|reason| anyhow::anyhow!("invalid {what} {value:?}: {reason}"))
|
||||
}
|
||||
|
||||
/// Run `swarmctl agent mint-identity`.
|
||||
///
|
||||
/// Synchronous for the same reason [`create`] is, and built on the same
|
||||
/// round trip.
|
||||
pub(crate) fn mint_identity(socket: &Path, name: &str, hive: &str) -> Result<()> {
|
||||
// Client-side first, so a typo is a local error rather than a 400 the
|
||||
// operator waits for. The controller validates both again.
|
||||
let name = parse_ident(name, "agent name")?;
|
||||
let hive = parse_ident(hive, "hive")?;
|
||||
|
||||
let rt = tokio::runtime::Builder::new_current_thread()
|
||||
.enable_io()
|
||||
.build()
|
||||
.context("starting a tokio runtime for the controller request")?;
|
||||
let resp: MintIdentityResponse = rt.block_on(post(
|
||||
socket,
|
||||
&format!("/api/agents/{name}/identity"),
|
||||
&MintIdentityRequest { hive: &hive },
|
||||
"mint-identity",
|
||||
))?;
|
||||
|
||||
println!("queued: job node {}", resp.node_id);
|
||||
println!(
|
||||
"agent {name:?} will have its identity re-minted on hive {hive:?} once the job graph \
|
||||
runs; `swarmctl` does not wait for it. An existing queue secret is kept as it is; the \
|
||||
store certificate is re-minted and the agent picks the new one up on its next boot"
|
||||
);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// One `POST /api/agents` round trip over the controller's unix socket.
|
||||
async fn post_create(socket: &Path, name: &str, hive: &str) -> Result<CreateAgentResponse> {
|
||||
let body = serde_json::to_vec(&CreateAgentRequest { name, hive })
|
||||
.context("serialising the create-agent request")?;
|
||||
post(
|
||||
socket,
|
||||
"/api/agents",
|
||||
&CreateAgentRequest { name, hive },
|
||||
"create-agent",
|
||||
)
|
||||
.await
|
||||
}
|
||||
|
||||
/// One JSON `POST` to `uri` over the controller's unix socket.
|
||||
///
|
||||
/// `what` names the request in error messages — the operator needs to know
|
||||
/// which call failed, and every other part of this function is identical
|
||||
/// between the two verbs.
|
||||
async fn post<Req: Serialize, Resp: serde::de::DeserializeOwned>(
|
||||
socket: &Path,
|
||||
uri: &str,
|
||||
request: &Req,
|
||||
what: &str,
|
||||
) -> Result<Resp> {
|
||||
let body =
|
||||
serde_json::to_vec(request).with_context(|| format!("serialising the {what} request"))?;
|
||||
|
||||
let stream = UnixStream::connect(socket)
|
||||
.await
|
||||
|
|
@ -112,18 +175,18 @@ async fn post_create(socket: &Path, name: &str, hive: &str) -> Result<CreateAgen
|
|||
|
||||
let req = hyper::Request::builder()
|
||||
.method(hyper::Method::POST)
|
||||
.uri("/api/agents")
|
||||
.uri(uri)
|
||||
// A unix socket has no authority of its own, but HTTP/1.1 requires
|
||||
// the header; the controller routes on the path alone.
|
||||
.header(hyper::header::HOST, "localhost")
|
||||
.header(hyper::header::CONTENT_TYPE, "application/json")
|
||||
.body(Full::new(bytes::Bytes::from(body)))
|
||||
.context("building the create-agent request")?;
|
||||
.with_context(|| format!("building the {what} request"))?;
|
||||
|
||||
let resp = sender
|
||||
.send_request(req)
|
||||
.await
|
||||
.context("sending the create-agent request to swarm-controller")?;
|
||||
.with_context(|| format!("sending the {what} request to swarm-controller"))?;
|
||||
let status = resp.status();
|
||||
let body = resp
|
||||
.into_body()
|
||||
|
|
@ -137,7 +200,7 @@ async fn post_create(socket: &Path, name: &str, hive: &str) -> Result<CreateAgen
|
|||
}
|
||||
serde_json::from_slice(&body).with_context(|| {
|
||||
format!(
|
||||
"swarm-controller answered {status} but its body is not a create-agent response: {}",
|
||||
"swarm-controller answered {status} but its body is not a {what} response: {}",
|
||||
String::from_utf8_lossy(&body).trim()
|
||||
)
|
||||
})
|
||||
|
|
|
|||
|
|
@ -168,6 +168,23 @@ enum AgentVerb {
|
|||
/// No approval gate guards this: running this binary already means
|
||||
/// being root on the controller's host.
|
||||
Create(AgentCreateArgs),
|
||||
/// Queue a re-mint of an existing agent's identity at the swarm's
|
||||
/// secret store.
|
||||
///
|
||||
/// **The backfill verb.** Agent creation is event-driven and nothing at
|
||||
/// swarm level sweeps for agents that are missing a credential, so an
|
||||
/// agent created before a credential joined the mint never receives one.
|
||||
/// This re-runs the mint for one agent that already exists.
|
||||
///
|
||||
/// ⚠️ **It re-mints the agent's store certificate**, which that agent
|
||||
/// picks up the next time its container boots. The agent's queue secret
|
||||
/// is left exactly as it is if it already has one, so running this
|
||||
/// against an already-migrated agent does not disturb its queue
|
||||
/// connection.
|
||||
///
|
||||
/// Queues and returns, the same way `agent create` does — watch the
|
||||
/// swarm UI's job view for the outcome.
|
||||
MintIdentity(AgentMintIdentityArgs),
|
||||
}
|
||||
|
||||
#[derive(Args)]
|
||||
|
|
@ -194,6 +211,29 @@ struct AgentCreateArgs {
|
|||
controller_socket: Option<PathBuf>,
|
||||
}
|
||||
|
||||
#[derive(Args)]
|
||||
struct AgentMintIdentityArgs {
|
||||
/// Name of an agent that already exists.
|
||||
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 does not 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.
|
||||
///
|
||||
/// Supplied by the nix module that installs this binary, from the same
|
||||
/// `socketPath` option the daemon binds; falls back to
|
||||
/// `SWARM_CONTROLLER_SOCKET`.
|
||||
#[arg(long, value_name = "PATH")]
|
||||
controller_socket: Option<PathBuf>,
|
||||
}
|
||||
|
||||
#[derive(Subcommand)]
|
||||
enum UserVerb {
|
||||
/// Add a user, generating a password for them.
|
||||
|
|
@ -262,6 +302,13 @@ fn main() -> Result<()> {
|
|||
let socket = path_from(args.controller_socket, "SWARM_CONTROLLER_SOCKET")?;
|
||||
agent::create(&socket, &args.name, &args.hive)
|
||||
}
|
||||
// Same socket-resolution reasoning as `Create` above.
|
||||
Verb::Agent {
|
||||
command: AgentVerb::MintIdentity(args),
|
||||
} => {
|
||||
let socket = path_from(args.controller_socket, "SWARM_CONTROLLER_SOCKET")?;
|
||||
agent::mint_identity(&socket, &args.name, &args.hive)
|
||||
}
|
||||
// Resolved lazily, inside the one arm that actually touches the
|
||||
// deployment env vars — see the `MarkdownDocs` doc comment above
|
||||
// for why an unconditional resolve up front would be wrong.
|
||||
|
|
@ -621,6 +668,36 @@ mod tests {
|
|||
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]
|
||||
fn the_backfill_verb_takes_an_agent_and_a_hive() {
|
||||
let cli = Cli::try_parse_from([
|
||||
"swarmctl",
|
||||
"agent",
|
||||
"mint-identity",
|
||||
"scribe",
|
||||
"--hive",
|
||||
"alpha",
|
||||
])
|
||||
.expect("the minimal form parses");
|
||||
let Verb::Agent {
|
||||
command: AgentVerb::MintIdentity(args),
|
||||
} = cli.command
|
||||
else {
|
||||
panic!("expected `agent mint-identity`");
|
||||
};
|
||||
assert_eq!(args.name, "scribe");
|
||||
assert_eq!(args.hive, "alpha");
|
||||
assert!(args.controller_socket.is_none());
|
||||
|
||||
assert!(
|
||||
Cli::try_parse_from(["swarmctl", "agent", "mint-identity", "scribe"]).is_err(),
|
||||
"an omitted hive must not be defaulted"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parses_authelia_hash_output() {
|
||||
let out = "Random Password: hunter2\nDigest: $argon2id$v=19$m=65536$abc\n";
|
||||
|
|
|
|||
Loading…
Reference in a new issue