Watch
0
0
Fork
You've already forked hyperhive
0
hyperhive/swarm-controller/src/routes/mod.rs
atlas 738dd1c95b swarm-controller: split main.rs into jobq, routes and startup modules
no behaviour change: a pure move of items and tests into new modules, plus
visibility edits (pub(crate)) and path updates in sibling modules that named
crate-root items. same routes, same OpenAPI, 561 tests before and after.

why: #5470, new features stop landing in one 6.4k-line file by default.

files outside swarm-controller/src: docs/agent-lifecycle/agent-roster.md,
swarm-ui JobsPage.tsx and AgentsPage.tsx, nix/host-modules/swarm-controller.nix
and swarm-grafana.nix hold comments that named main.rs for items that moved.
2026-10-18 13:44:43 +02:00

365 lines
18 KiB
Rust

//! The HTTP state every handler shares, the error shapes they answer with,
//! and the handlers that live outside a resource module of their own.
pub(crate) mod agents;
pub(crate) mod config_prs;
pub(crate) mod forge_admin;
pub(crate) mod hives;
pub(crate) mod jobq_graph;
use std::sync::{Arc, Mutex};
use crate::jobq::{Late, SwarmNodeKind, SwarmResourceKind};
use crate::routes::hives::{HiveEntry, ServiceLink};
use crate::{
agent_icon, agent_login, agent_status, agents_md, alerts, auth, builder, config_pr, forge,
inference, linked_accounts, review_classifier, status, wanted,
};
/// Liveness probe. Returns the build's version so an operator can tell
/// *which* controller answered without shelling onto the host.
#[utoipa::path(
get,
path = "/health",
responses((status = 200, description = "process is up, body is \"swarm-controller <version>\"", body = String)),
tag = "health"
)]
pub(crate) async fn health() -> &'static str {
concat!("swarm-controller ", env!("CARGO_PKG_VERSION"), "\n")
}
#[derive(Clone)]
pub(crate) struct AppState {
/// Loaded once at startup (`load_hives`); never mutated, so an
/// `Arc` clone per request is the whole synchronization story.
pub(crate) hives: Arc<Vec<HiveEntry>>,
/// Loaded once at startup (`load_links`); same synchronization story
/// as `hives`.
pub(crate) links: Arc<Vec<ServiceLink>>,
/// `None` when this deployment wired up no swarm queue — the only
/// state in which `/api/hives/status` cannot answer at all. A queue
/// that is merely *unreachable* still yields a reader, because
/// `async-nats` reconnects underneath it.
pub(crate) status: Option<Arc<status::StatusReader>>,
/// Publishes the agent set this swarm declares for each hive.
///
/// `None` in exactly the state `status` is: no swarm queue was wired
/// up, so there is nowhere to publish a declaration to. Shares that
/// reader's connection rather than opening a second one.
pub(crate) wanted: Option<Arc<wanted::WantedWriter>>,
/// Per-agent status, sharing `status`'s connection — same "one queue
/// connection, several consumers" rationale as `wanted` above. `None`
/// in exactly the state `status` is.
pub(crate) agent_status: Option<Arc<agent_status::AgentStatusReader>>,
/// Per-agent icons, sharing `status`'s connection — same "one queue
/// connection, several consumers" rationale as `agent_status` above,
/// and `None` in exactly the state `status` is.
pub(crate) agent_icons: Option<Arc<agent_icon::AgentIconReader>>,
/// Each agent's published `AGENTS.md`, on `status`'s connection like
/// `agent_icons`, and `None` in exactly the state `status` is.
pub(crate) agents_md: Option<Arc<agents_md::AgentsMdReader>>,
/// What each agent last reported running on, from its turn-state
/// headers on `status`'s connection. `None` in exactly the state
/// `status` is.
pub(crate) reported_inference: Option<Arc<inference::Reported>>,
/// Whether each agent last said it needs a login, from its turn-state
/// headers on `status`'s connection. `None` in exactly the state
/// `status` is.
pub(crate) agent_login: Option<Arc<agent_login::Reported>>,
/// The swarm-level job graph, wrapped in its
/// [`hive_jobq::scheduler::Scheduler`] now that something drives it
/// (`spawn_jobq_worker`) — the graph alone was enough for the
/// read-only endpoints, the scheduler is what a `claim_next` loop
/// needs. `std::sync::Mutex`, not `tokio`'s — every lock scope below
/// is synchronous (no `.await` while held). Always present, never
/// gated on the swarm queue: this is process state, not something
/// read over the network.
pub(crate) jobq: Arc<Mutex<hive_jobq::scheduler::Scheduler<SwarmNodeKind, SwarmResourceKind>>>,
/// The identity store's only reader, for `GET /api/agents`.
///
/// `None` when no bridge is wired up, which is the one state in which
/// the roster cannot be answered at all — the same shape as `status`
/// above, and for the same reason: an empty roster and an unreadable
/// one are different facts.
///
/// ⚠️ The two `/api/agents` verbs treat this differently on purpose.
/// **POST does not need it**: creation only queues a job, and
/// whether a bridge exists is `run_swarm_node`'s concern (it holds its
/// own clone from `spawn_jobq_worker`), so a request still queues
/// cleanly on a bridge-less host and fails loud once claimed. POST reads
/// it only for a name that breaks a naming rule, to tell a new agent
/// from an existing one, and warns when it cannot (`name_verdict`). **GET
/// has nowhere to defer to** — there is no job, only an answer it
/// either has or does not. `POST /api/forge/users/{name}/admin` reads
/// it for every name and refuses when it cannot (`admin_unless_agent`).
pub(crate) auth: Option<Arc<auth::AuthBridge>>,
/// HMAC secret for swarm-wide forge webhooks, loaded once at startup.
/// `None` when it could not be read or created — the webhook endpoint
/// then refuses every delivery with 503 rather than admitting one it
/// cannot verify. Deliberately not fatal to startup: nothing is
/// registered against that endpoint yet, and the rest of this daemon's
/// surface is unaffected. See [`load_or_generate_secret`](crate::webhook::load_or_generate_secret).
///
/// `Arc<str>`, not `Arc<String>`: the value is never mutated after
/// startup, and this way `as_deref()` yields the `&str` the verifier
/// takes without a second hop through `String`.
pub(crate) webhook_secret: Option<Arc<str>>,
/// Last successful `agent-configs/*` scan, kept current by
/// [`config_pr::spawn`]. Empty while this daemon has no forge access —
/// same "absent means don't ask" shape as `status` and `forge`, not a
/// startup failure — and filled when the forge client is.
pub(crate) config_prs: Late<Arc<config_pr::ConfigPrCache>>,
/// The swarm's human display name (`services.hyperhive.swarm.name`),
/// loaded once at startup (`load_swarm_name`). `None` when the
/// operator never set it — a swarm without a display name is a
/// supported, if less friendly, state, not a startup failure. Same
/// `Arc<str>` rationale as `webhook_secret`: never mutated, so a
/// clone per request is just a refcount bump.
pub(crate) swarm_name: Option<Arc<str>>,
/// The forge client itself, for the read-only repo/issue-report
/// routes (`GET /api/repos`, `GET /api/repos/{org}/{repo}/issue-report`)
/// and `POST /api/forge/users/{name}/admin` — distinct from `config_prs`, which holds a *cache* built off this
/// same client rather than the client. Empty while this daemon has no
/// forge access, and filled the moment `forge::enable` builds the client.
pub(crate) forge: Late<Arc<forge::Client>>,
/// Held by `create_agent` from reading where a name is placed until its
/// graph is queued, and by `set_agent_state` from that read until its
/// declaration is written, so two placements of one name cannot both
/// find it unplaced. `tokio`'s mutex, unlike `jobq`'s: the read awaits
/// the queue.
pub(crate) create_gate: Arc<tokio::sync::Mutex<()>>,
/// The store the linked-account routes read and write.
pub(crate) accounts: linked_accounts::Accounts,
/// The review classifier try-outs started here, until their results
/// expire.
pub(crate) try_outs: review_classifier::TryOuts,
/// The requested reviews running or waiting.
pub(crate) reviews: Arc<review_classifier::review::Reviews>,
/// The review classifier's finished runs.
pub(crate) runs: review_classifier::runs::Runs,
/// The swarm builder's finished builds.
pub(crate) builds: builder::runs::Builds,
/// Grafana's alerts API, for `GET /api/alerts`. `None` when no Grafana URL
/// is configured.
pub(crate) alerts: Option<Arc<alerts::Grafana>>,
}
/// Why the status route answers 503 rather than an empty list.
///
/// "I cannot reach the store" and "every hive is silent" are different
/// answers, and rendering the second when the first is true is exactly
/// the smoothing this endpoint exists to avoid — a caller would draw a
/// swarm-wide outage out of a local one. The cause is carried in the
/// body because a bare 503 on an operator-facing diagnostic is how a
/// misconfiguration costs an afternoon; it is a queue/JetStream error
/// string, and this surface is already behind the swarm's SSO.
///
/// It travels in `detail` of an RFC 9457 `application/problem+json` body
/// rather than as a bare string, so the cause is an addressable field
/// instead of the whole payload — see `error_problem` below.
pub(crate) struct StatusUnavailable(pub(crate) String);
impl axum::response::IntoResponse for StatusUnavailable {
fn into_response(self) -> axum::response::Response {
error_problem(axum::http::StatusCode::SERVICE_UNAVAILABLE, &self.0).into_response()
}
}
/// Every error this daemon returns, in one shape.
///
/// RFC 9457 `application/problem+json` is the hive-wide contract for HTTP
/// error bodies (`docs/process/conventions.md`), and the operator UIs read `detail`
/// for display. A bare string forces the reader to treat the entire body as
/// the message, which is the difference between a UI that can offer "copy the
/// cause" and one that can only dump a response.
pub(crate) fn error_problem(
status: axum::http::StatusCode,
detail: &str,
) -> problem_details::ProblemDetails {
problem_details::ProblemDetails::from_status_code(status).with_detail(detail)
}
/// The status a [`wanted::WantedWriter`], [`agent_icon::AgentIconReader`] or
/// `schedules` queue failure should answer with.
///
/// `WantedWriter::view`/`set` call `ensure_connected` internally and
/// propagate through `anyhow`, so the concrete
/// [`swarm_queue_client::Error`] survives underneath but is not the type in
/// hand — `swarm_queue_client`'s own doc says a caller "gets a type it can
/// match on", which means downcasting back to it rather than string-matching
/// the rendered message. `NotConnected` is a retryable condition (the queue
/// is reconnecting, or hasn't yet) and every other queue-backed route here
/// already answers 503 for it; anything else is a genuine failure to publish
/// or read, which stays 500.
pub(crate) fn wanted_error_status(e: &anyhow::Error) -> axum::http::StatusCode {
match e.downcast_ref::<swarm_queue_client::Error>() {
Some(swarm_queue_client::Error::NotConnected(_)) => {
axum::http::StatusCode::SERVICE_UNAVAILABLE
}
_ => axum::http::StatusCode::INTERNAL_SERVER_ERROR,
}
}
#[cfg(test)]
pub(crate) mod tests {
use super::*;
/// The 503 this route returns is what the operator UIs' shared error
/// component renders, so assert the RENDERED response rather than the
/// `problem_details` crate: the contract a UI depends on is the content
/// type plus a `detail` it can address, and a handler that built the
/// value and returned it as a bare string would satisfy any test
/// written against the type alone.
#[tokio::test]
async fn status_unavailable_renders_problem_json_with_the_cause_in_detail() {
use axum::response::IntoResponse as _;
let cause = "listing status bucket keys: timed out";
let resp = StatusUnavailable(cause.to_owned()).into_response();
assert_eq!(resp.status(), axum::http::StatusCode::SERVICE_UNAVAILABLE);
let ct = resp
.headers()
.get(axum::http::header::CONTENT_TYPE)
.and_then(|v| v.to_str().ok())
.unwrap_or_default()
.to_owned();
assert!(
ct.starts_with("application/problem+json"),
"RFC 9457 media type, got {ct:?}"
);
let bytes = axum::body::to_bytes(resp.into_body(), 64 * 1024)
.await
.expect("body reads");
let v: serde_json::Value = serde_json::from_slice(&bytes).expect("problem+json parses");
assert_eq!(v["status"], 503);
// The cause is an addressable field, not the entire payload — that
// distinction is the point of the change, so it is what is asserted.
assert_eq!(v["detail"], cause);
}
/// Drives `SwarmNodeKind::CreateRepo` through the real
/// `hive_jobq::scheduler::Scheduler` claim → run → complete path,
/// rather than only through `create_agent`'s endpoint test (there
/// isn't one — the endpoint itself is thin, insert-and-return; the
/// interesting behavior is in `run_swarm_node`'s executor arm, which
/// this exercises directly).
///
/// Deliberately offline: with the forge URL unset,
/// `forge::Client::from_env` returns `Ok(None)` (see that module's doc
/// comment), so this exercises the whole claim → run → complete path
/// through `hive_jobq::scheduler::Scheduler` without a real forge
/// server — at the cost of only ever observing the
/// graceful-absence-is-failure branch here. The happy path needs an
/// actual forge instance and isn't something a unit test in this crate
/// can reach.
///
/// SAFETY: single-threaded mutation of the `forge` env var this
/// test itself owns, restored before returning — no other test in this
/// crate reads them.
/// One roster, one agent name, two hives — the accepted one and a
/// typo. Built per test so neither can see the other's queue.
/// The scheduler handle these tests hold onto so they can assert on
/// what the endpoint queued. Aliased because the full type is three
/// nested generics deep and reads worse inline than named.
pub(crate) type SharedSched = std::sync::Arc<
std::sync::Mutex<
hive_jobq::scheduler::Scheduler<
crate::jobq::SwarmNodeKind,
crate::jobq::SwarmResourceKind,
>,
>,
>;
pub(crate) fn state_with_roster() -> (super::AppState, SharedSched) {
let sched =
std::sync::Arc::new(std::sync::Mutex::new(hive_jobq::scheduler::Scheduler::new(
hive_jobq::Graph::new(),
hive_jobq::resources::ResourceTable::new(),
)));
let state = super::AppState {
hives: std::sync::Arc::new(vec![HiveEntry {
name: "pr1ma".to_owned(),
domain: "pr1ma.example".to_owned(),
dashboard: "pr1ma.hives.example".to_owned(),
}]),
links: std::sync::Arc::new(Vec::new()),
status: None,
// No queue, for the same reason as `status`: these tests build
// the creation graph and read its shape, and never run the node
// that would write a declaration.
wanted: None,
agent_status: None,
agent_icons: None,
agents_md: None,
reported_inference: None,
agent_login: None,
jobq: std::sync::Arc::clone(&sched),
webhook_secret: None,
config_prs: std::sync::Arc::default(),
swarm_name: None,
// No bridge: these tests drive agent *creation*, which queues a
// job and consults one only for a rule-breaking name. The roster
// read is the verb that needs it, and it has its own test below.
auth: None,
forge: std::sync::Arc::default(),
create_gate: std::sync::Arc::default(),
accounts: crate::linked_accounts::Accounts::Fake(std::sync::Arc::default()),
try_outs: crate::review_classifier::TryOuts::default(),
reviews: std::sync::Arc::default(),
runs: crate::review_classifier::runs::Runs::Live(None),
builds: crate::builder::runs::Builds::Live(None),
alerts: None,
};
(state, sched)
}
/// `wanted_error_status` as a pure function first: build the exact
/// `swarm_queue_client::Error::NotConnected` `ensure_connected` raises,
/// wrap it the same way `?` inside `WantedWriter::view`/`set` does, and
/// confirm it downcasts back to the status those routes are meant to
/// answer.
#[test]
fn not_connected_downcasts_to_503() {
let e: anyhow::Error =
swarm_queue_client::Error::NotConnected(async_nats::connection::State::Pending).into();
assert_eq!(
super::wanted_error_status(&e),
axum::http::StatusCode::SERVICE_UNAVAILABLE
);
}
/// Invert-proof for the test above: an `anyhow::Error` that does not
/// wrap a `swarm_queue_client::Error` at all (the shape a genuine write
/// or decode failure takes — `apply`'s "declaration is not decodable"
/// context, for instance) must NOT downcast to `NotConnected`, and must
/// stay 500. Without this, a `wanted_error_status` that answered 503
/// unconditionally would still pass the test above.
#[test]
fn an_unrelated_error_stays_500() {
let e = anyhow::anyhow!("the hive's current declaration is not decodable");
assert_eq!(
super::wanted_error_status(&e),
axum::http::StatusCode::INTERNAL_SERVER_ERROR
);
}
/// Second invert-proof, closer to the actual bug this closes: a
/// *different* `swarm_queue_client::Error` variant (a real connect
/// failure, not `NotConnected`) also stays 500 — proving the match
/// looks at the variant, not just at "was this crate's error type
/// involved at all".
#[test]
fn a_different_queue_client_error_variant_stays_500() {
let e: anyhow::Error = swarm_queue_client::Error::Connect {
url: "nats://queue.example:4222".to_owned(),
source: async_nats::ConnectErrorKind::TimedOut.into(),
}
.into();
assert_eq!(
super::wanted_error_status(&e),
axum::http::StatusCode::INTERNAL_SERVER_ERROR
);
}
}