swarm-controller: CreateIdentity node, auth-bridge client, POST /api/agents
This commit is contained in:
parent
c1eb6b9834
commit
d30f149338
5 changed files with 289 additions and 55 deletions
|
|
@ -32,31 +32,38 @@ use serde::{Deserialize, Serialize};
|
|||
use utoipa::{OpenApi, ToSchema};
|
||||
use utoipa_axum::{router::OpenApiRouter, routes};
|
||||
|
||||
mod auth;
|
||||
mod status;
|
||||
|
||||
/// Placeholder node payload for the swarm-level job graph — uninhabited on
|
||||
/// purpose, and named `Swarm*` rather than the bare `NodeKind`/`Resource`
|
||||
/// `hive-c0re::job_queue::model` already uses, so a grep for either doesn't
|
||||
/// land on both crates. The *scheduler loop* below is real and running
|
||||
/// (`spawn_jobq_worker`, mirroring `hive-c0re/src/job_queue/scheduler.rs`'s
|
||||
/// `run_worker`) — what's still missing is a real job to give it: no
|
||||
/// variant exists yet, so nothing is ever inserted into the graph and
|
||||
/// `claim_next` always returns `None`. Giving this real variants (starting
|
||||
/// with `CreateRepo`) is the next slice, landing together with
|
||||
/// `swarm-controller::forge`, the client those nodes will call. `WireNode`
|
||||
/// is trivially satisfiable on an empty enum (`match *self {}`), so the
|
||||
/// wire machinery below is real and typechecked today, with nothing yet to
|
||||
/// put in it.
|
||||
/// Node payload for the swarm-level job graph. Named `Swarm*` rather than
|
||||
/// the bare `NodeKind`/`Resource` `hive-c0re::job_queue::model` already
|
||||
/// uses, so a grep for either doesn't land on both crates.
|
||||
///
|
||||
/// `CreateIdentity` is the first real variant — "the minimal shape for
|
||||
/// agent creation is creating the identity and wiring that in" (the design
|
||||
/// thread's own framing for why this landed before the forge-node work a
|
||||
/// standalone `CreateRepo` variant would have started with). Its only
|
||||
/// effect is calling `swarm-controller::auth`, which calls
|
||||
/// `swarm-authelia-bridge`; nothing forge- or deploy-shaped happens yet.
|
||||
#[derive(Clone, Debug)]
|
||||
enum SwarmNodeKind {}
|
||||
enum SwarmNodeKind {
|
||||
/// Ensure `agent` exists as an authelia subject at the swarm level.
|
||||
CreateIdentity { agent: String },
|
||||
}
|
||||
|
||||
impl hive_jobq_wire::WireNode for SwarmNodeKind {
|
||||
fn label(&self) -> String {
|
||||
match *self {}
|
||||
match self {
|
||||
SwarmNodeKind::CreateIdentity { .. } => "create_identity".to_owned(),
|
||||
}
|
||||
}
|
||||
|
||||
fn data(&self, _id: hive_jobq_wire::WireId) -> serde_json::Value {
|
||||
match *self {}
|
||||
match self {
|
||||
SwarmNodeKind::CreateIdentity { agent } => {
|
||||
serde_json::json!({ "agent": agent })
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -73,20 +80,33 @@ impl hive_jobq_wire::WireResource for SwarmResourceKind {
|
|||
|
||||
/// Run a claimed node's actual work. Mirrors `hive-c0re/src/job_queue/
|
||||
/// exec.rs::run_node`'s role exactly — the one place a `SwarmNodeKind`
|
||||
/// variant turns into a real effect. Trivially exhaustive today
|
||||
/// (`match kind {}`) because the enum has no variants yet; the first
|
||||
/// real arm (`CreateRepo`, calling `swarm-controller::forge`) lands
|
||||
/// alongside that variant, not before.
|
||||
/// variant turns into a real effect.
|
||||
///
|
||||
/// `auth` is `None` on a host that runs a controller split from
|
||||
/// `swarm-authelia` (no bridge configured there) — `CreateIdentity` fails
|
||||
/// explicitly in that case rather than this fn papering over a
|
||||
/// misconfigured deployment.
|
||||
async fn run_swarm_node(
|
||||
_id: hive_jobq::NodeId,
|
||||
kind: SwarmNodeKind,
|
||||
builder: hive_jobq::builder::JobBuilder<SwarmNodeKind, SwarmResourceKind>,
|
||||
auth: Option<std::sync::Arc<auth::AuthBridge>>,
|
||||
) -> (
|
||||
hive_jobq::builder::JobBuilder<SwarmNodeKind, SwarmResourceKind>,
|
||||
hive_jobq::scheduler::Outcome,
|
||||
) {
|
||||
let _ = builder;
|
||||
match kind {}
|
||||
let outcome = match kind {
|
||||
SwarmNodeKind::CreateIdentity { agent } => match auth {
|
||||
None => hive_jobq::scheduler::Outcome::Failed(
|
||||
"no swarm-authelia-bridge is configured on this host".to_owned(),
|
||||
),
|
||||
Some(bridge) => match bridge.ensure_agent_identity(&agent).await {
|
||||
Ok(_) => hive_jobq::scheduler::Outcome::Done,
|
||||
Err(e) => hive_jobq::scheduler::Outcome::Failed(format!("{e:#}")),
|
||||
},
|
||||
},
|
||||
};
|
||||
(builder, outcome)
|
||||
}
|
||||
|
||||
/// Spawn the swarm-level job-graph scheduler loop. Mirrors `hive-c0re/src/
|
||||
|
|
@ -102,14 +122,23 @@ async fn run_swarm_node(
|
|||
/// every in-flight HTTP request does.
|
||||
///
|
||||
/// Cheap to run with an empty graph: `claim_next` on a graph nothing was
|
||||
/// ever inserted into just returns `None` every poll, so this is a
|
||||
/// harmless idle loop until the first real node kind exists.
|
||||
/// ever inserted into just returns `None` every poll.
|
||||
///
|
||||
/// `auth` is cloned per iteration (an `Arc` clone, not a reconnect) and
|
||||
/// moved into the closure `claim_next` takes ownership of — `run_swarm_node`
|
||||
/// needs its own owned copy since the claimed future may outlive this loop
|
||||
/// iteration.
|
||||
fn spawn_jobq_worker(
|
||||
sched: Arc<Mutex<hive_jobq::scheduler::Scheduler<SwarmNodeKind, SwarmResourceKind>>>,
|
||||
auth: Option<Arc<auth::AuthBridge>>,
|
||||
) {
|
||||
tokio::spawn(async move {
|
||||
loop {
|
||||
let runner = hive_jobq::scheduler::Scheduler::claim_next(&sched, run_swarm_node);
|
||||
let auth = auth.clone();
|
||||
let runner =
|
||||
hive_jobq::scheduler::Scheduler::claim_next(&sched, move |id, kind, builder| {
|
||||
run_swarm_node(id, kind, builder, auth)
|
||||
});
|
||||
match runner {
|
||||
Some(runner) => {
|
||||
tokio::spawn(async move {
|
||||
|
|
@ -174,6 +203,7 @@ fn socket_path() -> PathBuf {
|
|||
(name = "hives", description = "the swarm's hive directory"),
|
||||
(name = "links", description = "swarm service quick links"),
|
||||
(name = "jobq", description = "the swarm-level job graph"),
|
||||
(name = "agents", description = "creating agent identities at swarm level"),
|
||||
)
|
||||
)]
|
||||
struct ApiDoc;
|
||||
|
|
@ -222,6 +252,13 @@ struct AppState {
|
|||
/// is synchronous (no `.await` while held). Always present, never
|
||||
/// gated on the swarm queue: this is process state, not something
|
||||
/// read over the network.
|
||||
/// **Not** consulted by `POST /api/agents` — that endpoint only ever
|
||||
/// queues the job (see [`create_agent`]); whether a bridge is
|
||||
/// configured is `run_swarm_node`'s concern (it holds its own clone,
|
||||
/// handed to it by `spawn_jobq_worker`), not this handler's. A request
|
||||
/// still queues cleanly on a bridge-less host, then fails loud once
|
||||
/// claimed — same "queue now, fail per-job" shape as an unreachable
|
||||
/// swarm queue.
|
||||
jobq: Arc<Mutex<hive_jobq::scheduler::Scheduler<SwarmNodeKind, SwarmResourceKind>>>,
|
||||
}
|
||||
|
||||
|
|
@ -366,6 +403,62 @@ async fn get_hives_status(
|
|||
}
|
||||
}
|
||||
|
||||
/// Body of `POST /api/agents` — the agent name to create a swarm-level
|
||||
/// identity for. No other fields: this endpoint is deliberately narrow —
|
||||
/// "identity in authelia only, no forge or deploy yet" — so it asks for
|
||||
/// nothing a `CreateIdentity` node doesn't use.
|
||||
#[derive(Clone, Debug, Deserialize, ToSchema)]
|
||||
struct CreateAgentRequest {
|
||||
name: String,
|
||||
}
|
||||
|
||||
/// Where the queued job landed — a caller polls `/api/jobq/graph` (or
|
||||
/// `?states=`) with this id to watch it settle, same as every other job
|
||||
/// kind this daemon will ever queue.
|
||||
#[derive(Clone, Debug, Serialize, ToSchema)]
|
||||
struct CreateAgentResponse {
|
||||
node_id: u64,
|
||||
}
|
||||
|
||||
/// Queue a `CreateIdentity` job for `name`. Returns as soon as the node is
|
||||
/// inserted — **not** once the identity exists; `run_swarm_node` does that
|
||||
/// work asynchronously off the scheduler loop already running
|
||||
/// (`spawn_jobq_worker`), same as every other node kind. This is also the
|
||||
/// first genuine non-test caller `SwarmNodeKind::CreateIdentity` has: the
|
||||
/// node kind's `dead_code` bound was the whole reason this endpoint had to
|
||||
/// land in the same change as the variant, not as a follow-up.
|
||||
#[utoipa::path(
|
||||
post,
|
||||
path = "/api/agents",
|
||||
request_body = CreateAgentRequest,
|
||||
responses(
|
||||
(status = 200, description = "job queued", body = CreateAgentResponse),
|
||||
(status = 500, description = "the job could not be queued", body = String),
|
||||
),
|
||||
tag = "agents"
|
||||
)]
|
||||
async fn create_agent(
|
||||
State(state): State<AppState>,
|
||||
Json(req): Json<CreateAgentRequest>,
|
||||
) -> Result<Json<CreateAgentResponse>, (axum::http::StatusCode, String)> {
|
||||
let mut sched = state
|
||||
.jobq
|
||||
.lock()
|
||||
.unwrap_or_else(std::sync::PoisonError::into_inner);
|
||||
let ids = sched
|
||||
.insert_job(None, |b| {
|
||||
vec![
|
||||
b.node(SwarmNodeKind::CreateIdentity { agent: req.name })
|
||||
.guid(),
|
||||
]
|
||||
})
|
||||
.map_err(|e| (axum::http::StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
|
||||
let [id] = ids[..] else {
|
||||
unreachable!("exactly one handle was asked for");
|
||||
};
|
||||
Ok(Json(CreateAgentResponse { node_id: id.get() }))
|
||||
}
|
||||
|
||||
/// Query params for `GET /api/jobq/graph` — `?states=` narrows to root
|
||||
/// groups in the named states, same shape `hive_jobq_wire::parse_states`
|
||||
/// parses.
|
||||
|
|
@ -501,11 +594,23 @@ async fn main() -> Result<()> {
|
|||
},
|
||||
};
|
||||
|
||||
// Same "not fatal, log and carry on" shape as the queue connect above:
|
||||
// a controller with no bridge wired up still serves everything else,
|
||||
// and `run_swarm_node` gives an honest per-job failure instead of this
|
||||
// fn refusing to start.
|
||||
let auth = match auth::AuthBridge::from_env() {
|
||||
Ok(auth) => auth.map(Arc::new),
|
||||
Err(e) => {
|
||||
tracing::warn!(error = %format!("{e:#}"), "swarm-authelia-bridge misconfigured; agent identity creation is off");
|
||||
None
|
||||
}
|
||||
};
|
||||
|
||||
let jobq = Arc::new(Mutex::new(hive_jobq::scheduler::Scheduler::new(
|
||||
hive_jobq::Graph::new(),
|
||||
hive_jobq::resources::ResourceTable::new(),
|
||||
)));
|
||||
spawn_jobq_worker(Arc::clone(&jobq));
|
||||
spawn_jobq_worker(Arc::clone(&jobq), auth.clone());
|
||||
|
||||
let state = AppState {
|
||||
hives: Arc::new(load_hives()),
|
||||
|
|
@ -521,6 +626,7 @@ async fn main() -> Result<()> {
|
|||
.routes(routes!(get_links))
|
||||
.routes(routes!(get_jobq_graph))
|
||||
.routes(routes!(get_jobq_rollup))
|
||||
.routes(routes!(create_agent))
|
||||
.split_for_parts();
|
||||
// Just the JSON, not the UI — Swagger UI itself is nginx-hosted from
|
||||
// the nix store (see the module doc comment above). `api` is
|
||||
|
|
|
|||
Loading…
Reference in a new issue