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.
This commit is contained in:
parent
f0a6d6c042
commit
738dd1c95b
42 changed files with 6854 additions and 6680 deletions
|
|
@ -18,7 +18,7 @@ holds the hive directory, the agent roster and the job graph") is the
|
|||
**swarm-wide** one: `GET /api/agents` on swarm-controller reads
|
||||
straight from the swarm's authelia identity store over
|
||||
`swarm-authelia-bridge` (`swarm-controller/src/auth.rs::list_agent_identities`)
|
||||
— "every agent the swarm holds an identity for" (`swarm-controller/src/main.rs`'s
|
||||
— "every agent the swarm holds an identity for" (`swarm-controller/src/routes/agents.rs`'s
|
||||
`get_agents` doc comment). It's authoritative: an agent exists in the
|
||||
swarm if and only if it has an identity there, and `swarmctl agent
|
||||
create` (or the swarm UI, via `POST /api/agents`) writes that identity
|
||||
|
|
|
|||
|
|
@ -3,7 +3,7 @@
|
|||
// queue) pointed at swarm-controller's own GET /api/jobq/graph instead
|
||||
// of hive-c0re's — same wire shape (Vec<hive_jobq_wire::GraphNode>),
|
||||
// different endpoint, no component fork. swarm-controller's graph has
|
||||
// no real node kinds wired in yet (see swarm-controller/src/main.rs's
|
||||
// no real node kinds wired in yet (see swarm-controller/src/jobq.rs's
|
||||
// SwarmNodeKind/SwarmResourceKind), so this renders an empty tree today
|
||||
// — the page exists so the wiring is in place before the first real
|
||||
// swarm-level job (e.g. CreateAgent) lands.
|
||||
|
|
|
|||
|
|
@ -11,7 +11,7 @@
|
|||
// three separate fetches joined client-side by name; per operator
|
||||
// review feedback ("the view should be filled by a single backend
|
||||
// call") the join moved server-side instead — see
|
||||
// `swarm-controller/src/main.rs`'s `get_agents_status` handler. No more
|
||||
// `swarm-controller/src/routes/agents.rs`'s `get_agents_status` handler. No more
|
||||
// per-page joining left to do here: the wire row *is* the row this page renders.
|
||||
//
|
||||
// Owns the "+ agent" trigger too — the roster this populates is the
|
||||
|
|
|
|||
|
|
@ -754,7 +754,7 @@ in
|
|||
# `AF_UNIX` for the socket this daemon serves on, plus what its
|
||||
# outbound clients need: it mints authelia tokens and calls the forge
|
||||
# over HTTPS (`auth.rs`, `forge.rs`) and reaches the queue over NATS
|
||||
# (`main.rs`, `status.rs`). `AF_NETLINK` because glibc's
|
||||
# (`startup.rs`, `status.rs`). `AF_NETLINK` because glibc's
|
||||
# `getaddrinfo` opens a netlink socket to enumerate local addresses
|
||||
# before it will return one.
|
||||
#
|
||||
|
|
@ -789,7 +789,7 @@ in
|
|||
# The swarm's hive directory, JSON-encoded — the full directory
|
||||
# (this daemon has no "self" hive to exclude) rather than
|
||||
# peers-minus-self. Consumed by `GET /api/hives`
|
||||
# (swarm-controller/src/main.rs::load_hives).
|
||||
# (swarm-controller/src/startup.rs::load_hives).
|
||||
# The one blacklist of names an agent may not take, shared verbatim
|
||||
# with hive-c0re and with the collector's owner assertion — see
|
||||
# `nix/reserved-names.nix`. Whitespace-separated rather than JSON
|
||||
|
|
@ -806,7 +806,7 @@ in
|
|||
);
|
||||
# The merged links list — see `links`' description above for who
|
||||
# contributes to it. Consumed by `GET /api/links`
|
||||
# (swarm-controller/src/main.rs::load_links).
|
||||
# (swarm-controller/src/startup.rs::load_links).
|
||||
SWARM_CONTROLLER_LINKS = builtins.toJSON cfg.links;
|
||||
# Staleness threshold for `GET /api/hives/status` — see
|
||||
# `staleAfterSeconds`' description. Set unconditionally rather
|
||||
|
|
|
|||
|
|
@ -1555,7 +1555,7 @@ in
|
|||
for = "15m";
|
||||
summary = "swarm-controller has failed the agent credential renewal pass on consecutive ticks";
|
||||
})
|
||||
# `MINT_IDENTITY_FAILED` in swarm-controller's main.rs,
|
||||
# `MINT_IDENTITY_FAILED` in swarm-controller's jobq.rs,
|
||||
# logged once per failed `MintAgentIdentity` job node, at
|
||||
# agent creation or on a certificate renewal; the window
|
||||
# is how long the rule stays firing after it. Host
|
||||
|
|
|
|||
|
|
@ -9,8 +9,8 @@ use problem_details::ProblemDetails;
|
|||
use swarm_queue_client::agent_archetype::{self, ArchetypeRef};
|
||||
use swarm_queue_client::archetypes::SourceRecord;
|
||||
|
||||
use crate::routes::{AppState, error_problem};
|
||||
use crate::schedules::{queue, read_or_default, write_revision};
|
||||
use crate::{AppState, error_problem};
|
||||
|
||||
/// How many times a losing writer re-reads and re-applies, as in
|
||||
/// `wanted::MAX_ATTEMPTS`.
|
||||
|
|
|
|||
|
|
@ -22,7 +22,7 @@ use serde::Serialize;
|
|||
use swarm_queue_client::{AgentControl, AgentControlReply};
|
||||
use utoipa::ToSchema;
|
||||
|
||||
use crate::{AppState, error_problem};
|
||||
use crate::routes::{AppState, error_problem};
|
||||
|
||||
/// What became of one command.
|
||||
#[derive(Debug, PartialEq, Eq)]
|
||||
|
|
|
|||
|
|
@ -31,7 +31,7 @@ use serde::{Deserialize, Serialize};
|
|||
use swarm_queue_client::AgentInput;
|
||||
use utoipa::ToSchema;
|
||||
|
||||
use crate::{AppState, error_problem};
|
||||
use crate::routes::{AppState, error_problem};
|
||||
|
||||
/// The header the gateway names the session's user in.
|
||||
const SENDER_HEADER: &str = "remote-user";
|
||||
|
|
|
|||
|
|
@ -19,8 +19,8 @@ use serde::{Deserialize, Serialize};
|
|||
use swarm_queue_client::agent_limits::{self, AgentCaps};
|
||||
use utoipa::ToSchema;
|
||||
|
||||
use crate::routes::{AppState, error_problem, wanted_error_status};
|
||||
use crate::schedules::{live, queue, read_or_default, refused, write_revision};
|
||||
use crate::{AppState, error_problem, wanted_error_status};
|
||||
|
||||
/// How many times a losing writer re-reads and re-applies, as in
|
||||
/// `wanted::MAX_ATTEMPTS`.
|
||||
|
|
@ -478,9 +478,9 @@ pub(crate) mod tests {
|
|||
}
|
||||
|
||||
/// One hive, `pr1ma`, on `client`.
|
||||
pub(crate) fn state(client: &async_nats::Client) -> crate::AppState {
|
||||
crate::AppState {
|
||||
hives: Arc::new(vec![crate::HiveEntry {
|
||||
pub(crate) fn state(client: &async_nats::Client) -> crate::routes::AppState {
|
||||
crate::routes::AppState {
|
||||
hives: Arc::new(vec![crate::routes::hives::HiveEntry {
|
||||
name: "pr1ma".to_owned(),
|
||||
domain: "pr1ma.example".to_owned(),
|
||||
dashboard: "pr1ma.hives.example".to_owned(),
|
||||
|
|
@ -516,7 +516,7 @@ pub(crate) mod tests {
|
|||
}
|
||||
|
||||
/// A state on a real server whose `pr1ma` declares `atlas`.
|
||||
pub(crate) async fn declared(client: &async_nats::Client) -> crate::AppState {
|
||||
pub(crate) async fn declared(client: &async_nats::Client) -> crate::routes::AppState {
|
||||
WantedWriter::new(client.clone())
|
||||
.set("pr1ma", "atlas", AgentState::Up)
|
||||
.await
|
||||
|
|
@ -532,7 +532,7 @@ pub(crate) mod tests {
|
|||
}
|
||||
|
||||
async fn put(
|
||||
state: &crate::AppState,
|
||||
state: &crate::routes::AppState,
|
||||
agent: &str,
|
||||
value: Caps,
|
||||
) -> Result<axum::Json<Caps>, problem_details::ProblemDetails> {
|
||||
|
|
@ -544,7 +544,7 @@ pub(crate) mod tests {
|
|||
.await
|
||||
}
|
||||
|
||||
async fn get(state: &crate::AppState, agent: &str) -> Caps {
|
||||
async fn get(state: &crate::routes::AppState, agent: &str) -> Caps {
|
||||
get_agent_limits(State(state.clone()), Path(agent.to_owned()))
|
||||
.await
|
||||
.expect("caps are read")
|
||||
|
|
@ -662,7 +662,7 @@ pub(crate) mod tests {
|
|||
let _ = put(&state, "atlas", caps(Some("400%"), Some("8G")))
|
||||
.await
|
||||
.expect("cap atlas");
|
||||
crate::build_app(state)
|
||||
crate::startup::build_app(state)
|
||||
}
|
||||
|
||||
async fn put_raw(app: &axum::Router, body: &'static str) -> StatusCode {
|
||||
|
|
|
|||
|
|
@ -23,9 +23,9 @@ use serde::{Deserialize, Serialize};
|
|||
use swarm_queue_client::agent_permissions::{self, AgentPermissions, Import, ImportReply};
|
||||
use utoipa::ToSchema;
|
||||
|
||||
use crate::routes::{AppState, error_problem, wanted_error_status};
|
||||
use crate::schedules::{live, queue, read_or_default, refused, write, write_revision};
|
||||
use crate::wanted::WantedWriter;
|
||||
use crate::{AppState, error_problem, wanted_error_status};
|
||||
|
||||
/// How many times a losing writer re-reads and re-applies, as in
|
||||
/// `wanted::MAX_ATTEMPTS`.
|
||||
|
|
@ -549,7 +549,7 @@ mod tests {
|
|||
}
|
||||
|
||||
async fn put(
|
||||
state: &crate::AppState,
|
||||
state: &crate::routes::AppState,
|
||||
agent: &str,
|
||||
value: Permissions,
|
||||
) -> Result<axum::Json<Permissions>, problem_details::ProblemDetails> {
|
||||
|
|
@ -561,7 +561,7 @@ mod tests {
|
|||
.await
|
||||
}
|
||||
|
||||
async fn get(state: &crate::AppState, agent: &str) -> Permissions {
|
||||
async fn get(state: &crate::routes::AppState, agent: &str) -> Permissions {
|
||||
get_agent_permissions(State(state.clone()), Path(agent.to_owned()))
|
||||
.await
|
||||
.expect("read")
|
||||
|
|
@ -641,7 +641,7 @@ mod tests {
|
|||
let client = nats.client().await;
|
||||
let state = crate::agent_limits::tests::declared(&client).await;
|
||||
let _ = put(&state, "atlas", granted()).await.expect("set");
|
||||
let app = crate::build_app(state);
|
||||
let app = crate::startup::build_app(state);
|
||||
|
||||
for (body, status) in [
|
||||
(
|
||||
|
|
@ -803,9 +803,9 @@ mod tests {
|
|||
let client = nats.client().await;
|
||||
let state = crate::agent_limits::tests::declared(&client).await;
|
||||
let create = |name: &str, permissions| {
|
||||
crate::create_agent(
|
||||
crate::routes::agents::create_agent(
|
||||
State(state.clone()),
|
||||
axum::Json(crate::CreateAgentRequest {
|
||||
axum::Json(crate::routes::agents::CreateAgentRequest {
|
||||
name: name.to_owned(),
|
||||
hive: "pr1ma".to_owned(),
|
||||
limits: None,
|
||||
|
|
@ -855,18 +855,18 @@ mod tests {
|
|||
let state = crate::agent_limits::tests::declared(&client).await;
|
||||
let _ = put(&state, "atlas", granted()).await.expect("set");
|
||||
|
||||
let _ = crate::set_agent_state(
|
||||
let _ = crate::routes::agents::set_agent_state(
|
||||
State(state.clone()),
|
||||
Path(("pr1ma".to_owned(), "atlas".to_owned())),
|
||||
axum::Json(crate::SetAgentStateRequest {
|
||||
axum::Json(crate::routes::agents::SetAgentStateRequest {
|
||||
state: swarm_queue_client::wanted::AgentState::Destroyed,
|
||||
}),
|
||||
)
|
||||
.await
|
||||
.expect("destroyed");
|
||||
let _ = crate::create_agent(
|
||||
let _ = crate::routes::agents::create_agent(
|
||||
State(state.clone()),
|
||||
axum::Json(crate::CreateAgentRequest {
|
||||
axum::Json(crate::routes::agents::CreateAgentRequest {
|
||||
name: "atlas".to_owned(),
|
||||
hive: "pr1ma".to_owned(),
|
||||
limits: None,
|
||||
|
|
|
|||
|
|
@ -29,7 +29,7 @@ use axum::extract::{Path, State};
|
|||
use axum::response::sse::{Event, KeepAlive, Sse};
|
||||
use futures_util::{Stream, StreamExt as _};
|
||||
|
||||
use crate::AppState;
|
||||
use crate::routes::AppState;
|
||||
|
||||
#[utoipa::path(
|
||||
get,
|
||||
|
|
@ -60,19 +60,21 @@ pub(crate) async fn stream_agent_state(
|
|||
Path(agent): Path<String>,
|
||||
) -> Result<Sse<impl Stream<Item = Result<Event, Infallible>>>, problem_details::ProblemDetails> {
|
||||
let Some(status) = state.status.as_ref() else {
|
||||
return Err(crate::error_problem(
|
||||
return Err(crate::routes::error_problem(
|
||||
axum::http::StatusCode::SERVICE_UNAVAILABLE,
|
||||
"no swarm queue is configured on this host",
|
||||
));
|
||||
};
|
||||
let Some(agent_status) = state.agent_status.as_ref() else {
|
||||
return Err(crate::error_problem(
|
||||
return Err(crate::routes::error_problem(
|
||||
axum::http::StatusCode::SERVICE_UNAVAILABLE,
|
||||
"no agent-status reader is configured on this host",
|
||||
));
|
||||
};
|
||||
let agent = hive_types::Ident::parse(&agent)
|
||||
.map_err(|reason| crate::error_problem(axum::http::StatusCode::BAD_REQUEST, reason))?
|
||||
.map_err(|reason| {
|
||||
crate::routes::error_problem(axum::http::StatusCode::BAD_REQUEST, reason)
|
||||
})?
|
||||
.into_string();
|
||||
|
||||
// One-agent "roster": reuses `AgentStatusReader::view`'s already-tested
|
||||
|
|
@ -84,12 +86,12 @@ pub(crate) async fn stream_agent_state(
|
|||
.map_err(|e| {
|
||||
let detail = format!("{e:#}");
|
||||
tracing::warn!(%agent, error = %detail, "state stream: reading agent status failed");
|
||||
crate::error_problem(axum::http::StatusCode::INTERNAL_SERVER_ERROR, &detail)
|
||||
crate::routes::error_problem(axum::http::StatusCode::INTERNAL_SERVER_ERROR, &detail)
|
||||
})?
|
||||
.into_iter()
|
||||
.find_map(|row| row.hive)
|
||||
.ok_or_else(|| {
|
||||
crate::error_problem(
|
||||
crate::routes::error_problem(
|
||||
axum::http::StatusCode::NOT_FOUND,
|
||||
&format!("{agent:?} has never reported to the swarm"),
|
||||
)
|
||||
|
|
@ -101,7 +103,7 @@ pub(crate) async fn stream_agent_state(
|
|||
// against a queue that is not there — the honest answer is 503, the
|
||||
// same shape every other queue-backed route here already uses.
|
||||
swarm_queue_client::ensure_connected(&client).map_err(|e| {
|
||||
crate::error_problem(
|
||||
crate::routes::error_problem(
|
||||
axum::http::StatusCode::SERVICE_UNAVAILABLE,
|
||||
&swarm_queue_client::chain(&e),
|
||||
)
|
||||
|
|
|
|||
|
|
@ -20,7 +20,7 @@ use swarm_queue_client::AgentControl;
|
|||
use swarm_queue_client::agents_md::{Apply, ApplyReply, Mirror};
|
||||
|
||||
use crate::agent_control::{ControlSink, Outcome};
|
||||
use crate::{AppState, error_problem, wanted_error_status};
|
||||
use crate::routes::{AppState, error_problem, wanted_error_status};
|
||||
|
||||
/// Reads the `agents-md` bucket, the way [`crate::agent_icon::AgentIconReader`]
|
||||
/// reads the icon bucket, and creates it: the agents writing it cannot.
|
||||
|
|
|
|||
|
|
@ -23,7 +23,7 @@ use problem_details::ProblemDetails;
|
|||
use serde::{Deserialize, Serialize};
|
||||
use utoipa::ToSchema;
|
||||
|
||||
use crate::{AppState, error_problem};
|
||||
use crate::routes::{AppState, error_problem};
|
||||
|
||||
/// The env var `swarm-controller.nix` sets.
|
||||
const URL_ENV: &str = "SWARM_CONTROLLER_GRAFANA_URL";
|
||||
|
|
@ -282,12 +282,12 @@ mod tests {
|
|||
}
|
||||
|
||||
async fn get_alerts_with(grafana: Option<Grafana>) -> (StatusCode, serde_json::Value) {
|
||||
let (mut state, _) = crate::tests::state_with_roster();
|
||||
let (mut state, _) = crate::routes::tests::state_with_roster();
|
||||
state.alerts = grafana.map(Arc::new);
|
||||
let req = Request::get("/api/alerts")
|
||||
.body(Body::empty())
|
||||
.expect("a request");
|
||||
let res = crate::build_app(state)
|
||||
let res = crate::startup::build_app(state)
|
||||
.oneshot(req)
|
||||
.await
|
||||
.expect("infallible");
|
||||
|
|
|
|||
|
|
@ -21,8 +21,9 @@ use swarm_wire::builder::{
|
|||
};
|
||||
use utoipa::ToSchema;
|
||||
|
||||
use crate::routes::{AppState, error_problem};
|
||||
use crate::schedules::{queue, read_or_default, refused, write};
|
||||
use crate::{AppState, agent_limits, agent_permissions, error_problem, inference};
|
||||
use crate::{agent_limits, agent_permissions, inference};
|
||||
|
||||
/// How often the builder is asked. Its source only changes when its host is
|
||||
/// redeployed, so this bounds how long a redeploy takes to reach the create
|
||||
|
|
|
|||
|
|
@ -18,8 +18,8 @@ use problem_details::ProblemDetails;
|
|||
use serde::{Deserialize, Serialize};
|
||||
use utoipa::ToSchema;
|
||||
|
||||
use crate::routes::{AppState, error_problem};
|
||||
use crate::schedules::{read_with, write};
|
||||
use crate::{AppState, error_problem};
|
||||
|
||||
/// The bucket holding the builds. Agents hold no grant on it.
|
||||
pub const BUCKET: &str = "builder-runs";
|
||||
|
|
@ -449,9 +449,9 @@ pub(crate) mod tests {
|
|||
push(&*doc, &record("b")).await.expect("stored");
|
||||
|
||||
let get_at = |builds: Builds, path: &'static str| async move {
|
||||
let (mut state, _) = crate::tests::state_with_roster();
|
||||
let (mut state, _) = crate::routes::tests::state_with_roster();
|
||||
state.builds = builds;
|
||||
let res = crate::build_app(state)
|
||||
let res = crate::startup::build_app(state)
|
||||
.oneshot(Request::get(path).body(Body::empty()).expect("a request"))
|
||||
.await
|
||||
.expect("infallible");
|
||||
|
|
|
|||
|
|
@ -24,7 +24,7 @@ use serde::{Deserialize, Serialize};
|
|||
use swarm_wire::claude_login::{REPLY_DEADLINE, Reply, Request, SUBJECT};
|
||||
use utoipa::ToSchema;
|
||||
|
||||
use crate::{AppState, error_problem};
|
||||
use crate::routes::{AppState, error_problem};
|
||||
|
||||
/// The header the gateway names the session's user in.
|
||||
const OPERATOR_HEADER: &str = "remote-user";
|
||||
|
|
|
|||
|
|
@ -13,7 +13,8 @@ use swarm_secret_client::forge;
|
|||
use utoipa::ToSchema;
|
||||
|
||||
use super::linked_accounts::link;
|
||||
use super::{AppState, error_problem, swarm_hive};
|
||||
use crate::routes::agents::swarm_hive;
|
||||
use crate::routes::{AppState, error_problem};
|
||||
|
||||
/// The account to store for one agent's external forge.
|
||||
///
|
||||
|
|
@ -141,9 +142,9 @@ mod tests {
|
|||
}
|
||||
|
||||
/// Bare-minimum `AppState`, as `matrix_account`'s tests build it.
|
||||
fn state(store: &Arc<FakeStore>) -> super::super::AppState {
|
||||
super::super::AppState {
|
||||
hives: std::sync::Arc::new(vec![super::super::HiveEntry {
|
||||
fn state(store: &Arc<FakeStore>) -> crate::routes::AppState {
|
||||
crate::routes::AppState {
|
||||
hives: std::sync::Arc::new(vec![crate::routes::hives::HiveEntry {
|
||||
name: "pr1ma".to_owned(),
|
||||
domain: "pr1ma.example".to_owned(),
|
||||
dashboard: "pr1ma.hives.example".to_owned(),
|
||||
|
|
|
|||
|
|
@ -13,7 +13,8 @@ use swarm_secret_client::github;
|
|||
use utoipa::ToSchema;
|
||||
|
||||
use super::linked_accounts::link;
|
||||
use super::{AppState, error_problem, swarm_hive};
|
||||
use crate::routes::agents::swarm_hive;
|
||||
use crate::routes::{AppState, error_problem};
|
||||
|
||||
/// The token to store for one agent.
|
||||
///
|
||||
|
|
@ -111,9 +112,9 @@ mod tests {
|
|||
}
|
||||
|
||||
/// Bare-minimum `AppState`, as `forge_account`'s tests build it.
|
||||
fn state(store: &Arc<FakeStore>) -> super::super::AppState {
|
||||
super::super::AppState {
|
||||
hives: std::sync::Arc::new(vec![super::super::HiveEntry {
|
||||
fn state(store: &Arc<FakeStore>) -> crate::routes::AppState {
|
||||
crate::routes::AppState {
|
||||
hives: std::sync::Arc::new(vec![crate::routes::hives::HiveEntry {
|
||||
name: "pr1ma".to_owned(),
|
||||
domain: "pr1ma.example".to_owned(),
|
||||
dashboard: "pr1ma.hives.example".to_owned(),
|
||||
|
|
|
|||
|
|
@ -26,8 +26,8 @@ use serde::{Deserialize, Serialize};
|
|||
use swarm_queue_client::agent_inference::{self, AgentInference, Effective, Effort, Source};
|
||||
use utoipa::ToSchema;
|
||||
|
||||
use crate::routes::{AppState, error_problem, wanted_error_status};
|
||||
use crate::schedules::{live, queue, read_or_default, refused, write_revision};
|
||||
use crate::{AppState, error_problem, wanted_error_status};
|
||||
|
||||
/// How many times a losing writer re-reads and re-applies, as in
|
||||
/// `wanted::MAX_ATTEMPTS`.
|
||||
|
|
|
|||
|
|
@ -12,8 +12,8 @@ use axum::{
|
|||
extract::{Path, State},
|
||||
};
|
||||
|
||||
use super::{AppState, StatusUnavailable, error_problem};
|
||||
use crate::forge;
|
||||
use crate::routes::{AppState, StatusUnavailable, error_problem};
|
||||
|
||||
/// The 503 detail when the forge did not answer within
|
||||
/// [`forge::FORGE_DEADLINE`].
|
||||
|
|
@ -21,7 +21,7 @@ const DEADLINE_DETAIL: &str = "the forge did not answer within the deadline";
|
|||
|
||||
/// Repos with at least one open issue, as `owner/name` full names — the
|
||||
/// data source for swarm-ui's repo-filter dropdown. Not scoped to
|
||||
/// `forge::CONFIG_ORG` the way `main.rs`'s `get_config_prs` is: this
|
||||
/// `forge::CONFIG_ORG` the way `routes/config_prs.rs`'s `get_config_prs` is: this
|
||||
/// report deliberately covers the whole forge instance, not one org, since
|
||||
/// the report itself is a general-purpose browsing tool rather than
|
||||
/// something agent-config-specific.
|
||||
|
|
@ -156,7 +156,7 @@ pub async fn get_issue_report(
|
|||
///
|
||||
/// `Client::issue_report` propagates through `anyhow`, so the concrete
|
||||
/// [`forgejo_api::ForgejoError`] survives underneath but is not the type in
|
||||
/// hand — downcasting back to it, same pattern `main.rs`'s
|
||||
/// hand — downcasting back to it, same pattern `routes`'s
|
||||
/// `wanted_error_status` uses for `swarm_queue_client::Error`. A 404 there
|
||||
/// (`forge::is_repo_not_found`) means the repo itself doesn't exist;
|
||||
/// anything else — including a 404 on some *other* call this function
|
||||
|
|
@ -257,7 +257,7 @@ mod tests {
|
|||
"/api/issue-report",
|
||||
"/api/repos/o/r/issue-report",
|
||||
] {
|
||||
let (state, _) = crate::tests::state_with_roster();
|
||||
let (state, _) = crate::routes::tests::state_with_roster();
|
||||
assert!(
|
||||
state
|
||||
.forge
|
||||
|
|
@ -268,7 +268,7 @@ mod tests {
|
|||
);
|
||||
let start = tokio::time::Instant::now();
|
||||
let req = Request::get(uri).body(Body::empty()).expect("a request");
|
||||
let res = crate::build_app(state)
|
||||
let res = crate::startup::build_app(state)
|
||||
.oneshot(req)
|
||||
.await
|
||||
.expect("infallible");
|
||||
|
|
|
|||
2398
swarm-controller/src/jobq.rs
Normal file
2398
swarm-controller/src/jobq.rs
Normal file
File diff suppressed because it is too large
Load diff
|
|
@ -29,7 +29,8 @@ use serde::{Deserialize, Serialize};
|
|||
use swarm_secret_client::{Error, SecretStore, forge, github, matrix};
|
||||
use utoipa::{IntoParams, ToSchema};
|
||||
|
||||
use super::{AppState, error_problem, swarm_hive};
|
||||
use crate::routes::agents::swarm_hive;
|
||||
use crate::routes::{AppState, error_problem};
|
||||
|
||||
/// The host shown for a GitHub token. The store keeps none: a token is only
|
||||
/// ever used against github.com.
|
||||
|
|
@ -950,9 +951,9 @@ pub(crate) mod tests {
|
|||
}
|
||||
|
||||
/// Bare-minimum `AppState`, as `matrix_account`'s tests build it.
|
||||
fn state(store: &Arc<FakeStore>) -> super::super::AppState {
|
||||
super::super::AppState {
|
||||
hives: std::sync::Arc::new(vec![super::super::HiveEntry {
|
||||
fn state(store: &Arc<FakeStore>) -> crate::routes::AppState {
|
||||
crate::routes::AppState {
|
||||
hives: std::sync::Arc::new(vec![crate::routes::hives::HiveEntry {
|
||||
name: "pr1ma".to_owned(),
|
||||
domain: "pr1ma.example".to_owned(),
|
||||
dashboard: "pr1ma.hives.example".to_owned(),
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load diff
|
|
@ -34,7 +34,8 @@ use swarm_secret_client::matrix;
|
|||
use utoipa::ToSchema;
|
||||
|
||||
use super::linked_accounts::{AccountStore, Linking, link, refuse_linked};
|
||||
use super::{AppState, error_problem, swarm_hive};
|
||||
use crate::routes::agents::swarm_hive;
|
||||
use crate::routes::{AppState, error_problem};
|
||||
|
||||
pub mod agent_token;
|
||||
pub mod hive_sender;
|
||||
|
|
@ -445,9 +446,9 @@ mod tests {
|
|||
/// Bare-minimum `AppState` for a handler test: one hive, nothing
|
||||
/// queue-backed, an empty in-memory job graph the endpoint under test
|
||||
/// never touches, and `store` as the account store.
|
||||
fn state(store: &Arc<FakeStore>) -> super::super::AppState {
|
||||
super::super::AppState {
|
||||
hives: std::sync::Arc::new(vec![super::super::HiveEntry {
|
||||
fn state(store: &Arc<FakeStore>) -> crate::routes::AppState {
|
||||
crate::routes::AppState {
|
||||
hives: std::sync::Arc::new(vec![crate::routes::hives::HiveEntry {
|
||||
name: "pr1ma".to_owned(),
|
||||
domain: "pr1ma.example".to_owned(),
|
||||
dashboard: "pr1ma.hives.example".to_owned(),
|
||||
|
|
|
|||
|
|
@ -15,7 +15,7 @@ use utoipa::IntoParams;
|
|||
use crate::forge::{self, OpenPull};
|
||||
use crate::issue_report::issue_report_error_status;
|
||||
use crate::review_classifier::split_repo;
|
||||
use crate::{AppState, error_problem};
|
||||
use crate::routes::{AppState, error_problem};
|
||||
|
||||
/// Whose open pull requests to list.
|
||||
#[derive(Debug, Deserialize, IntoParams)]
|
||||
|
|
@ -117,9 +117,9 @@ mod tests {
|
|||
use tower::ServiceExt as _;
|
||||
|
||||
async fn get(uri: &str) -> (StatusCode, serde_json::Value) {
|
||||
let (state, _) = crate::tests::state_with_roster();
|
||||
let (state, _) = crate::routes::tests::state_with_roster();
|
||||
let req = Request::get(uri).body(Body::empty()).expect("a request");
|
||||
let res = crate::build_app(state)
|
||||
let res = crate::startup::build_app(state)
|
||||
.oneshot(req)
|
||||
.await
|
||||
.expect("infallible");
|
||||
|
|
|
|||
|
|
@ -38,7 +38,7 @@ use utoipa::{IntoParams, ToSchema};
|
|||
|
||||
use self::settings::{Settings, SettingsSource};
|
||||
use crate::forge::{self, DefaultBranchFiles, PullForReview};
|
||||
use crate::{AppState, error_problem};
|
||||
use crate::routes::{AppState, error_problem};
|
||||
|
||||
pub(crate) mod open_pr;
|
||||
pub(crate) mod review;
|
||||
|
|
@ -715,7 +715,7 @@ async fn start(
|
|||
fn settings_problem(e: &anyhow::Error) -> ProblemDetails {
|
||||
let detail = format!("{e:#}");
|
||||
tracing::warn!(error = %detail, "reading the review classifier's settings for a try-out failed");
|
||||
error_problem(crate::wanted_error_status(e), &detail)
|
||||
error_problem(crate::routes::wanted_error_status(e), &detail)
|
||||
}
|
||||
|
||||
/// 404 when the forge knows no such repo or PR, 503 for anything else.
|
||||
|
|
@ -915,9 +915,9 @@ pub(crate) mod tests {
|
|||
}
|
||||
|
||||
fn app_with_files(reply: Reply, files: Vec<(&'static str, String)>) -> axum::Router {
|
||||
let (mut state, _) = crate::tests::state_with_roster();
|
||||
let (mut state, _) = crate::routes::tests::state_with_roster();
|
||||
state.try_outs.fake = Some(Arc::new(Fake::new(reply, files)));
|
||||
crate::build_app(state)
|
||||
crate::startup::build_app(state)
|
||||
}
|
||||
|
||||
fn body(repo: &str, pr: u64) -> String {
|
||||
|
|
@ -1001,7 +1001,7 @@ pub(crate) mod tests {
|
|||
cache_write: None,
|
||||
},
|
||||
};
|
||||
let (mut state, _) = crate::tests::state_with_roster();
|
||||
let (mut state, _) = crate::routes::tests::state_with_roster();
|
||||
let fake = Arc::new(Fake {
|
||||
settings: Settings {
|
||||
model: "opus".to_owned(),
|
||||
|
|
@ -1013,7 +1013,7 @@ pub(crate) mod tests {
|
|||
..Fake::new(done(), Vec::new())
|
||||
});
|
||||
state.try_outs.fake = Some(Arc::clone(&fake));
|
||||
let app = crate::build_app(state);
|
||||
let app = crate::startup::build_app(state);
|
||||
let (status, accepted) = post(&app, body("o/r", 7)).await;
|
||||
assert_eq!(status, StatusCode::ACCEPTED, "{accepted}");
|
||||
settled(&app, accepted["id"].as_str().expect("an id")).await;
|
||||
|
|
@ -1051,10 +1051,10 @@ pub(crate) mod tests {
|
|||
(failed, runs::RunOutcome::Failed, Some("no API key")),
|
||||
] {
|
||||
let doc = Arc::new(runs::tests::FakeDoc::default());
|
||||
let (mut state, _) = crate::tests::state_with_roster();
|
||||
let (mut state, _) = crate::routes::tests::state_with_roster();
|
||||
state.try_outs.fake = Some(Arc::new(Fake::new(reply.clone(), Vec::new())));
|
||||
state.runs = runs::Runs::Fake(Arc::clone(&doc));
|
||||
let app = crate::build_app(state);
|
||||
let app = crate::startup::build_app(state);
|
||||
let (_, accepted) = post(&app, body("o/r", 7)).await;
|
||||
let id = accepted["id"].as_str().expect("an id");
|
||||
settled(&app, id).await;
|
||||
|
|
@ -1481,8 +1481,8 @@ classifications:
|
|||
|
||||
#[tokio::test]
|
||||
async fn rules_without_forge_access_on_this_host_are_503() {
|
||||
let (state, _) = crate::tests::state_with_roster();
|
||||
let (status, problem) = get_rules(&crate::build_app(state), "?repo=o/r").await;
|
||||
let (state, _) = crate::routes::tests::state_with_roster();
|
||||
let (status, problem) = get_rules(&crate::startup::build_app(state), "?repo=o/r").await;
|
||||
assert_eq!(status, StatusCode::SERVICE_UNAVAILABLE, "{problem}");
|
||||
assert_eq!(problem["detail"], "no forge access on this host yet");
|
||||
}
|
||||
|
|
|
|||
|
|
@ -32,7 +32,7 @@ use utoipa::ToSchema;
|
|||
use super::{FileSource, RULES_PATHS, check_classifications, operator, parse_rules, split_repo};
|
||||
use crate::forge::classifier_account::{BranchCommit, CommitAuthor, LOGIN};
|
||||
use crate::forge::{self, BranchFile};
|
||||
use crate::{AppState, error_problem};
|
||||
use crate::routes::{AppState, error_problem};
|
||||
|
||||
/// Where a branch's name starts, so the forge's branch list groups them.
|
||||
const BRANCH_PREFIX: &str = "review-classifier/";
|
||||
|
|
@ -676,9 +676,9 @@ classifications:
|
|||
}
|
||||
|
||||
fn app(fake: Fake) -> axum::Router {
|
||||
let (mut state, _) = crate::tests::state_with_roster();
|
||||
let (mut state, _) = crate::routes::tests::state_with_roster();
|
||||
state.try_outs.fake = Some(Arc::new(fake));
|
||||
crate::build_app(state)
|
||||
crate::startup::build_app(state)
|
||||
}
|
||||
|
||||
fn fake() -> Fake {
|
||||
|
|
|
|||
|
|
@ -33,10 +33,10 @@ use tokio::task::JoinHandle;
|
|||
use super::runs::{self, RunOutcome, RunRecord, Runs, Trigger};
|
||||
use super::settings::{Settings, SettingsSource};
|
||||
use super::{FileSource, PullSource, RunSink, split_repo};
|
||||
use crate::AppState;
|
||||
use crate::agent_renewal::unix_now;
|
||||
use crate::forge::pull_diff::CONTEXT_LINES;
|
||||
use crate::forge::{self, classifier_account::LOGIN, classifier_account::Review};
|
||||
use crate::routes::AppState;
|
||||
|
||||
/// The most review runs waiting for a slot.
|
||||
const MAX_WAITING_REVIEWS: usize = 8;
|
||||
|
|
|
|||
|
|
@ -23,8 +23,8 @@ use serde::{Deserialize, Serialize};
|
|||
use swarm_queue_client::review_classifier::{Overall, Reply, Row};
|
||||
use utoipa::ToSchema;
|
||||
|
||||
use crate::routes::{AppState, error_problem};
|
||||
use crate::schedules::{read_with, write};
|
||||
use crate::{AppState, error_problem};
|
||||
|
||||
/// The bucket holding the runs. Agents hold no grant on it.
|
||||
pub const BUCKET: &str = "review-classifier-runs";
|
||||
|
|
@ -593,9 +593,9 @@ pub(crate) mod tests {
|
|||
push(&*doc, &reviewed).await.expect("stored");
|
||||
|
||||
let get = |runs: Runs| async move {
|
||||
let (mut state, _) = crate::tests::state_with_roster();
|
||||
let (mut state, _) = crate::routes::tests::state_with_roster();
|
||||
state.runs = runs;
|
||||
let res = crate::build_app(state)
|
||||
let res = crate::startup::build_app(state)
|
||||
.oneshot(
|
||||
Request::get("/api/review-classifier/runs")
|
||||
.body(Body::empty())
|
||||
|
|
|
|||
|
|
@ -24,8 +24,8 @@ use swarm_queue_client::agent_inference::Effort;
|
|||
use swarm_queue_client::review_classifier::{Acp, Runtime};
|
||||
use utoipa::ToSchema;
|
||||
|
||||
use crate::routes::{AppState, error_problem, wanted_error_status};
|
||||
use crate::schedules::{queue, read_with, refused, write};
|
||||
use crate::{AppState, error_problem, wanted_error_status};
|
||||
|
||||
/// The bucket holding the settings. No other service holds a grant on it.
|
||||
pub const BUCKET: &str = "review-classifier-settings";
|
||||
|
|
@ -598,12 +598,12 @@ mod tests {
|
|||
|
||||
/// An app whose queue is `nats`.
|
||||
async fn app(nats: &TestNats) -> (axum::Router, AppState) {
|
||||
let (mut state, _) = crate::tests::state_with_roster();
|
||||
let (mut state, _) = crate::routes::tests::state_with_roster();
|
||||
state.status = Some(std::sync::Arc::new(crate::status::StatusReader::new(
|
||||
nats.client().await,
|
||||
std::time::Duration::from_mins(1),
|
||||
)));
|
||||
(crate::build_app(state.clone()), state)
|
||||
(crate::startup::build_app(state.clone()), state)
|
||||
}
|
||||
|
||||
async fn send(
|
||||
|
|
@ -777,21 +777,21 @@ mod tests {
|
|||
|
||||
#[tokio::test]
|
||||
async fn a_put_without_an_operator_is_401() {
|
||||
let (state, _) = crate::tests::state_with_roster();
|
||||
let (state, _) = crate::routes::tests::state_with_roster();
|
||||
let req = Request::put("/api/review-classifier/settings")
|
||||
.header("content-type", "application/json")
|
||||
.body(Body::from(
|
||||
serde_json::to_string(&Settings::default()).unwrap(),
|
||||
))
|
||||
.expect("a request");
|
||||
let (status, _, _) = send(&crate::build_app(state), req).await;
|
||||
let (status, _, _) = send(&crate::startup::build_app(state), req).await;
|
||||
assert_eq!(status, StatusCode::UNAUTHORIZED);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn without_a_queue_both_verbs_are_503() {
|
||||
let (state, _) = crate::tests::state_with_roster();
|
||||
let app = crate::build_app(state);
|
||||
let (state, _) = crate::routes::tests::state_with_roster();
|
||||
let app = crate::startup::build_app(state);
|
||||
let (status, _, _) = send(&app, get()).await;
|
||||
assert_eq!(status, StatusCode::SERVICE_UNAVAILABLE);
|
||||
let body = serde_json::to_string(&Settings::default()).unwrap();
|
||||
|
|
|
|||
2404
swarm-controller/src/routes/agents.rs
Normal file
2404
swarm-controller/src/routes/agents.rs
Normal file
File diff suppressed because it is too large
Load diff
81
swarm-controller/src/routes/config_prs.rs
Normal file
81
swarm-controller/src/routes/config_prs.rs
Normal file
|
|
@ -0,0 +1,81 @@
|
|||
//! Agents' open config PRs.
|
||||
|
||||
use anyhow::Result;
|
||||
use axum::{
|
||||
Json,
|
||||
extract::{Path, State},
|
||||
};
|
||||
|
||||
use crate::forge;
|
||||
use crate::routes::{AppState, StatusUnavailable};
|
||||
|
||||
/// `agent`'s open config PR, from [`ConfigPrCache`](crate::config_pr::ConfigPrCache) — kept
|
||||
/// current by a webhook nudge (near-real-time) backstopped by a periodic
|
||||
/// scan (worst case: up to one `config_pr::POLL_INTERVAL` stale if a
|
||||
/// delivery was ever missed). Not a live forge read either way, so this
|
||||
/// answers even when the forge itself is momentarily unreachable.
|
||||
///
|
||||
/// `200` with a `null` body means "no open PR" (or "no scan has completed
|
||||
/// yet") — not distinguished, for the same reason
|
||||
/// [`ConfigPrCache::get`](crate::config_pr::ConfigPrCache::get) doesn't: both read as "nothing to show"
|
||||
/// to the swarm-ui config-PR panel this feeds, and a cache is a best-effort
|
||||
/// read, not a source of truth callers should expect to disambiguate
|
||||
/// against.
|
||||
#[utoipa::path(
|
||||
get,
|
||||
path = "/api/agents/{name}/config-pr",
|
||||
params(("name" = String, Path, description = "agent name")),
|
||||
responses(
|
||||
(status = 200, description = "the agent's open config PR, or null if none / not scanned yet", body = Option<forge::ConfigPrStatus>),
|
||||
(status = 503, description = "no forge is configured on this host", body = String),
|
||||
),
|
||||
tag = "agents"
|
||||
)]
|
||||
pub(crate) async fn get_agent_config_pr(
|
||||
State(state): State<AppState>,
|
||||
Path(name): Path<String>,
|
||||
) -> Result<Json<Option<forge::ConfigPrStatus>>, StatusUnavailable> {
|
||||
let Some(cache) = state.config_prs.get() else {
|
||||
return Err(StatusUnavailable(
|
||||
"no forge is configured on this host".to_owned(),
|
||||
));
|
||||
};
|
||||
Ok(Json(cache.get(&name)))
|
||||
}
|
||||
|
||||
/// Every agent with an open config PR, in one response — the bulk
|
||||
/// counterpart to [`get_agent_config_pr`]. swarm-ui's config-PR table needs
|
||||
/// every agent's status to render, and fetching them one at a time doesn't
|
||||
/// scale and isn't how the rest of swarm-ui's single-fetch pages (jobq,
|
||||
/// hives status) work.
|
||||
///
|
||||
/// Deliberately **not** `/api/agents/config-prs`: agent names are
|
||||
/// user-specified (any string `hive_types::Ident` accepts), so a literal
|
||||
/// path segment sitting where a `{name}` capture could plausibly also want
|
||||
/// to live is a real, not theoretical, clash risk the moment someone names
|
||||
/// an agent `config-prs` — per mara's review call, closed off by
|
||||
/// construction rather than relying on axum's static-route-priority
|
||||
/// tiebreak to paper over it.
|
||||
///
|
||||
/// Only agents with a currently-open PR are present — same "absence is the
|
||||
/// answer" shape [`get_agent_config_pr`]'s `null` uses, just at map-entry
|
||||
/// granularity instead of per-request.
|
||||
#[utoipa::path(
|
||||
get,
|
||||
path = "/api/config-prs",
|
||||
responses(
|
||||
(status = 200, description = "agent name -> open config PR, only agents with one present", body = std::collections::HashMap<String, forge::ConfigPrStatus>),
|
||||
(status = 503, description = "no forge is configured on this host", body = String),
|
||||
),
|
||||
tag = "agents"
|
||||
)]
|
||||
pub(crate) async fn get_config_prs(
|
||||
State(state): State<AppState>,
|
||||
) -> Result<Json<std::collections::HashMap<String, forge::ConfigPrStatus>>, StatusUnavailable> {
|
||||
let Some(cache) = state.config_prs.get() else {
|
||||
return Err(StatusUnavailable(
|
||||
"no forge is configured on this host".to_owned(),
|
||||
));
|
||||
};
|
||||
Ok(Json(cache.snapshot()))
|
||||
}
|
||||
236
swarm-controller/src/routes/forge_admin.rs
Normal file
236
swarm-controller/src/routes/forge_admin.rs
Normal file
|
|
@ -0,0 +1,236 @@
|
|||
//! Forge site admins and an agent's forge token, on request.
|
||||
|
||||
use anyhow::Result;
|
||||
use axum::{
|
||||
Json,
|
||||
extract::{Path, State},
|
||||
};
|
||||
use swarm_controller_api::agents::MintAgentForgeTokenResponse;
|
||||
use swarm_controller_api::forge::{ForgeAdminChange, MakeForgeAdminResponse};
|
||||
|
||||
use crate::forge;
|
||||
use crate::jobq::queue_forge_token_mints;
|
||||
use crate::routes::agents::in_roster;
|
||||
use crate::routes::{AppState, error_problem};
|
||||
|
||||
/// 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
|
||||
/// minutes for every agent with a store identity; this is for an operator who
|
||||
/// does not want to wait, or for an agent the pass skipped. Queues the same
|
||||
/// node agent creation does. Idempotent: a current token is left alone.
|
||||
#[utoipa::path(
|
||||
post,
|
||||
path = "/api/agents/{name}/forge-token",
|
||||
params(("name" = String, Path, description = "agent name")),
|
||||
responses(
|
||||
(status = 200, description = "mint queued", body = MintAgentForgeTokenResponse),
|
||||
(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),
|
||||
),
|
||||
tag = "agents"
|
||||
)]
|
||||
pub(crate) async fn mint_agent_forge_token(
|
||||
State(state): State<AppState>,
|
||||
Path(name): Path<String>,
|
||||
) -> Result<Json<MintAgentForgeTokenResponse>, problem_details::ProblemDetails> {
|
||||
let agent = hive_types::Ident::parse(&name)
|
||||
.map_err(|reason| error_problem(axum::http::StatusCode::BAD_REQUEST, reason))?
|
||||
.into_string();
|
||||
let ids = queue_forge_token_mints(&state.jobq, vec![agent]).map_err(|e| {
|
||||
error_problem(
|
||||
axum::http::StatusCode::INTERNAL_SERVER_ERROR,
|
||||
&e.to_string(),
|
||||
)
|
||||
})?;
|
||||
let [id] = ids[..] else {
|
||||
unreachable!("exactly one handle was asked for");
|
||||
};
|
||||
Ok(Json(MintAgentForgeTokenResponse { node_id: id.get() }))
|
||||
}
|
||||
|
||||
/// Make an existing human forge account a site admin. Idempotent.
|
||||
///
|
||||
/// Never creates the account: a human's is made by their first SSO login to
|
||||
/// the forge, and until then this answers 404. Refuses an agent's name: a
|
||||
/// site admin can create repos whatever its `max_repo_creation`, and an
|
||||
/// agent's account is locked to 0 so its token cannot create a repo and
|
||||
/// self-merge in it.
|
||||
#[utoipa::path(
|
||||
post,
|
||||
path = "/api/forge/users/{name}/admin",
|
||||
params(("name" = String, Path, description = "forge username")),
|
||||
responses(
|
||||
(status = 200, description = "the account is a site admin", body = MakeForgeAdminResponse),
|
||||
(status = 400, description = "`name` is not a valid identifier (problem+json)", body = String),
|
||||
(status = 404, description = "no such account: the user has not logged in via SSO yet (problem+json)", body = String),
|
||||
(status = 409, description = "`name` is an agent (problem+json)", body = String),
|
||||
(status = 500, description = "the forge refused the read or the edit (problem+json)", body = String),
|
||||
(status = 503, description = "no forge configured, or the agent roster could not be read (problem+json)", body = String),
|
||||
),
|
||||
tag = "forge"
|
||||
)]
|
||||
pub(crate) async fn make_forge_admin(
|
||||
State(state): State<AppState>,
|
||||
Path(name): Path<String>,
|
||||
) -> Result<Json<MakeForgeAdminResponse>, problem_details::ProblemDetails> {
|
||||
let name = hive_types::Ident::parse(&name)
|
||||
.map_err(|reason| error_problem(axum::http::StatusCode::BAD_REQUEST, reason))?
|
||||
.into_string();
|
||||
let Some(forge) = state.forge.get().cloned() else {
|
||||
return Err(error_problem(
|
||||
axum::http::StatusCode::SERVICE_UNAVAILABLE,
|
||||
"no forge is configured on this host",
|
||||
));
|
||||
};
|
||||
let is_agent = in_roster(state.auth.as_deref(), &name).await;
|
||||
admin_unless_agent(&name, is_agent, || async {
|
||||
forge.make_site_admin(&name).await
|
||||
})
|
||||
.await
|
||||
}
|
||||
|
||||
/// Run `promote` only if `name` is known not to be an agent, and answer with
|
||||
/// what it found.
|
||||
///
|
||||
/// An unreadable roster refuses too: a site admin is the one thing an agent's
|
||||
/// account must never become, so "could not tell" is not "no".
|
||||
pub(crate) async fn admin_unless_agent<F, Fut>(
|
||||
name: &str,
|
||||
is_agent: Result<bool, String>,
|
||||
promote: F,
|
||||
) -> Result<Json<MakeForgeAdminResponse>, problem_details::ProblemDetails>
|
||||
where
|
||||
F: FnOnce() -> Fut,
|
||||
Fut: std::future::Future<Output = Result<forge::site_admin::Plan>>,
|
||||
{
|
||||
match is_agent {
|
||||
Ok(false) => {}
|
||||
Ok(true) => {
|
||||
return Err(error_problem(
|
||||
axum::http::StatusCode::CONFLICT,
|
||||
&format!("{name:?} is an agent; an agent's forge account is never a site admin"),
|
||||
));
|
||||
}
|
||||
Err(e) => {
|
||||
return Err(error_problem(
|
||||
axum::http::StatusCode::SERVICE_UNAVAILABLE,
|
||||
&format!("cannot tell whether {name:?} is an agent, so refusing: {e}"),
|
||||
));
|
||||
}
|
||||
}
|
||||
let plan = promote().await.map_err(|e| {
|
||||
error_problem(
|
||||
axum::http::StatusCode::INTERNAL_SERVER_ERROR,
|
||||
&format!("{e:#}"),
|
||||
)
|
||||
})?;
|
||||
let change = match plan {
|
||||
forge::site_admin::Plan::NoSuchUser => {
|
||||
return Err(error_problem(
|
||||
axum::http::StatusCode::NOT_FOUND,
|
||||
&format!(
|
||||
"the forge has no user {name:?}: the user has not logged in via SSO yet. \
|
||||
The forge creates the account on their first authelia login; run this \
|
||||
again after it"
|
||||
),
|
||||
));
|
||||
}
|
||||
forge::site_admin::Plan::AlreadyAdmin => ForgeAdminChange::AlreadyAdmin,
|
||||
forge::site_admin::Plan::Promote => ForgeAdminChange::Promoted,
|
||||
};
|
||||
Ok(Json(MakeForgeAdminResponse { change }))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
/// `admin_unless_agent` with a `promote` that records whether it ran and
|
||||
/// answers `plan`.
|
||||
async fn forge_admin_route(
|
||||
is_agent: Result<bool, String>,
|
||||
plan: crate::forge::site_admin::Plan,
|
||||
) -> (
|
||||
Result<axum::Json<super::MakeForgeAdminResponse>, problem_details::ProblemDetails>,
|
||||
bool,
|
||||
) {
|
||||
let ran = std::sync::atomic::AtomicBool::new(false);
|
||||
let result = super::admin_unless_agent("atlas", is_agent, || async {
|
||||
ran.store(true, std::sync::atomic::Ordering::SeqCst);
|
||||
Ok(plan)
|
||||
})
|
||||
.await;
|
||||
(result, ran.into_inner())
|
||||
}
|
||||
|
||||
/// An agent's name never reaches the forge.
|
||||
#[tokio::test]
|
||||
async fn the_forge_admin_route_refuses_an_agent() {
|
||||
let (result, ran) =
|
||||
forge_admin_route(Ok(true), crate::forge::site_admin::Plan::Promote).await;
|
||||
let Err(problem) = result else {
|
||||
panic!("an agent's name must be refused");
|
||||
};
|
||||
assert!(!ran, "the account must not be touched");
|
||||
assert_eq!(problem.status, Some(axum::http::StatusCode::CONFLICT));
|
||||
}
|
||||
|
||||
/// A roster that can't be read can't vouch for the name, so the route
|
||||
/// refuses rather than guessing "not an agent".
|
||||
#[tokio::test]
|
||||
async fn the_forge_admin_route_refuses_on_an_unreadable_roster() {
|
||||
let (result, ran) = forge_admin_route(
|
||||
Err("bridge down".to_owned()),
|
||||
crate::forge::site_admin::Plan::Promote,
|
||||
)
|
||||
.await;
|
||||
let Err(problem) = result else {
|
||||
panic!("an unreadable roster must refuse");
|
||||
};
|
||||
assert!(!ran, "the account must not be touched");
|
||||
assert_eq!(
|
||||
problem.status,
|
||||
Some(axum::http::StatusCode::SERVICE_UNAVAILABLE)
|
||||
);
|
||||
}
|
||||
|
||||
/// A missing account is a 404 that tells the operator what to wait for.
|
||||
#[tokio::test]
|
||||
async fn the_forge_admin_route_names_the_missing_sso_login() {
|
||||
let (result, ran) =
|
||||
forge_admin_route(Ok(false), crate::forge::site_admin::Plan::NoSuchUser).await;
|
||||
let Err(problem) = result else {
|
||||
panic!("a missing account must not report success");
|
||||
};
|
||||
assert!(ran);
|
||||
assert_eq!(problem.status, Some(axum::http::StatusCode::NOT_FOUND));
|
||||
let rendered = format!("{problem:?}");
|
||||
assert!(
|
||||
rendered.contains("has not logged in via SSO yet"),
|
||||
"{rendered}"
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn the_forge_admin_route_promotes_a_non_agent() {
|
||||
let (result, ran) =
|
||||
forge_admin_route(Ok(false), crate::forge::site_admin::Plan::Promote).await;
|
||||
let Ok(axum::Json(resp)) = result else {
|
||||
panic!("a non-agent must be promoted");
|
||||
};
|
||||
assert!(ran);
|
||||
assert_eq!(resp.change, super::ForgeAdminChange::Promoted);
|
||||
}
|
||||
|
||||
/// Idempotent: a second run succeeds and says nothing changed.
|
||||
#[tokio::test]
|
||||
async fn the_forge_admin_route_succeeds_for_an_admin() {
|
||||
let (result, _) =
|
||||
forge_admin_route(Ok(false), crate::forge::site_admin::Plan::AlreadyAdmin).await;
|
||||
let Ok(axum::Json(resp)) = result else {
|
||||
panic!("an existing admin must succeed");
|
||||
};
|
||||
assert_eq!(resp.change, super::ForgeAdminChange::AlreadyAdmin);
|
||||
}
|
||||
}
|
||||
126
swarm-controller/src/routes/hives.rs
Normal file
126
swarm-controller/src/routes/hives.rs
Normal file
|
|
@ -0,0 +1,126 @@
|
|||
//! The swarm's hive directory, service links and display name.
|
||||
|
||||
use anyhow::Result;
|
||||
use axum::{Json, extract::State};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use utoipa::ToSchema;
|
||||
|
||||
use crate::routes::{AppState, StatusUnavailable};
|
||||
use crate::status;
|
||||
|
||||
/// One hive in the swarm's directory — the same `name`/`domain` pair
|
||||
/// `services.hyperhive.swarm.hives` (nix/host-modules/swarm.nix) declares,
|
||||
/// plus that hive's `swarm.dashboardDomains` entry, carried across
|
||||
/// unchanged rather than reshaped, so this stays a direct mirror of the
|
||||
/// nix source of truth instead of a second vocabulary for the same fields.
|
||||
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, ToSchema)]
|
||||
pub(crate) struct HiveEntry {
|
||||
pub(crate) name: String,
|
||||
pub(crate) domain: String,
|
||||
/// The host the hive's dashboard answers on.
|
||||
pub(crate) dashboard: String,
|
||||
}
|
||||
|
||||
/// The swarm's hive directory — every hive, including whichever one this
|
||||
/// controller instance happens to run on (there is no "self" to exclude
|
||||
/// at the swarm level, unlike `hive-c0re`'s peer list).
|
||||
#[utoipa::path(
|
||||
get,
|
||||
path = "/api/hives",
|
||||
responses((status = 200, description = "every hive in the swarm", body = Vec<HiveEntry>)),
|
||||
tag = "hives"
|
||||
)]
|
||||
pub(crate) async fn get_hives(State(state): State<AppState>) -> Json<Vec<HiveEntry>> {
|
||||
Json((*state.hives).clone())
|
||||
}
|
||||
|
||||
/// One quick link to a swarm-wide service (authelia, matrix, forge, this
|
||||
/// daemon's own swagger UI, …). Deliberately generic rather than named
|
||||
/// fields per service: each service's own nix module contributes its own
|
||||
/// entry to `services.hyperhive.swarm.controller.links` (same list-merge
|
||||
/// idiom `services.hyperhive.gateway.localNames` already uses), so adding
|
||||
/// a new one is a nix-only change — no new field here, no swarm-ui change.
|
||||
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, ToSchema)]
|
||||
pub(crate) struct ServiceLink {
|
||||
pub(crate) label: String,
|
||||
/// Emoji or short glyph. Empty string, not `Option`, when a
|
||||
/// contributing module has none — one fewer null-vs-absent case for
|
||||
/// the frontend to handle, and every real contributor sets one today.
|
||||
pub(crate) icon: String,
|
||||
pub(crate) url: String,
|
||||
}
|
||||
|
||||
/// Quick links to swarm-wide services (authelia, matrix, forge, this
|
||||
/// daemon's own swagger UI, …), as contributed by each service's own nix
|
||||
/// module. Empty when nothing was configured to contribute — a caller
|
||||
/// renders 0 links the same way it renders any other count, no special
|
||||
/// "not configured" case.
|
||||
#[utoipa::path(
|
||||
get,
|
||||
path = "/api/links",
|
||||
responses((status = 200, description = "swarm service quick links", body = Vec<ServiceLink>)),
|
||||
tag = "links"
|
||||
)]
|
||||
pub(crate) async fn get_links(State(state): State<AppState>) -> Json<Vec<ServiceLink>> {
|
||||
Json((*state.links).clone())
|
||||
}
|
||||
|
||||
/// Body of `GET /api/swarm`.
|
||||
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, ToSchema)]
|
||||
pub(crate) struct SwarmInfo {
|
||||
/// `None` when the operator never set `services.hyperhive.swarm.name`
|
||||
/// — a caller (swarm-ui's chrome) falls back to a generic label rather
|
||||
/// than treating this as an error.
|
||||
name: Option<String>,
|
||||
}
|
||||
|
||||
/// The swarm's own display name, for UI chrome (page title, nav) that
|
||||
/// wants to say which swarm it's showing — distinct from [`get_hives`],
|
||||
/// which lists the *hives inside* the swarm, not the swarm itself.
|
||||
#[utoipa::path(
|
||||
get,
|
||||
path = "/api/swarm",
|
||||
responses((status = 200, description = "the swarm's display name, if the operator set one", body = SwarmInfo)),
|
||||
tag = "hives"
|
||||
)]
|
||||
pub(crate) async fn get_swarm_info(State(state): State<AppState>) -> Json<SwarmInfo> {
|
||||
Json(SwarmInfo {
|
||||
name: state.swarm_name.as_deref().map(str::to_owned),
|
||||
})
|
||||
}
|
||||
|
||||
/// What each hive last said about itself, read from the swarm queue at
|
||||
/// request time.
|
||||
///
|
||||
/// Every hive in the roster gets a row whether or not it has ever
|
||||
/// reported — see the `status` module for why absence, not presence, is
|
||||
/// the case this is built around.
|
||||
#[utoipa::path(
|
||||
get,
|
||||
path = "/api/hives/status",
|
||||
responses(
|
||||
(status = 200, description = "a row per hive, freshness derived now", body = Vec<status::HiveStatus>),
|
||||
(status = 503, description = "no swarm queue is configured here, or its store could not be read", body = String),
|
||||
),
|
||||
tag = "hives"
|
||||
)]
|
||||
pub(crate) async fn get_hives_status(
|
||||
State(state): State<AppState>,
|
||||
) -> Result<Json<Vec<status::HiveStatus>>, StatusUnavailable> {
|
||||
let Some(reader) = state.status.as_ref() else {
|
||||
return Err(StatusUnavailable(
|
||||
"no swarm queue is configured on this host".to_owned(),
|
||||
));
|
||||
};
|
||||
match reader
|
||||
.view(&state.hives, std::time::SystemTime::now())
|
||||
.await
|
||||
{
|
||||
Ok(rows) => Ok(Json(rows)),
|
||||
Err(e) => {
|
||||
let detail = format!("{e:#}");
|
||||
tracing::warn!(error = %detail, "reading the swarm status bucket failed");
|
||||
Err(StatusUnavailable(detail))
|
||||
}
|
||||
}
|
||||
}
|
||||
65
swarm-controller/src/routes/jobq_graph.rs
Normal file
65
swarm-controller/src/routes/jobq_graph.rs
Normal file
|
|
@ -0,0 +1,65 @@
|
|||
//! Read-only views of the swarm-level job graph.
|
||||
|
||||
use axum::{Json, extract::State};
|
||||
use hive_jobq_wire::GraphWire as _;
|
||||
use serde::Deserialize;
|
||||
|
||||
use crate::routes::AppState;
|
||||
|
||||
/// Query params for `GET /api/jobq/graph` — `?states=` narrows to root
|
||||
/// groups in the named states, same shape `hive_jobq_wire::parse_states`
|
||||
/// parses.
|
||||
#[derive(Deserialize, utoipa::IntoParams)]
|
||||
pub(crate) struct JobqGraphQuery {
|
||||
states: Option<String>,
|
||||
}
|
||||
|
||||
/// Every node of every root group in the swarm-level job graph. Nothing
|
||||
/// filters "done and old" here — with zero nodes ever submitted there is
|
||||
/// nothing to bound yet, so `graph.roots()` (everything) is the whole
|
||||
/// roster passed to [`hive_jobq_wire::GraphWire::wire_snapshot`].
|
||||
#[utoipa::path(
|
||||
get,
|
||||
path = "/api/jobq/graph",
|
||||
params(JobqGraphQuery),
|
||||
responses((status = 200, description = "every node of every root group, as generic \
|
||||
`hive_jobq` graph nodes. `?states=` narrows to root groups in the named states.",
|
||||
body = Vec<hive_jobq_wire::GraphNode>)),
|
||||
tag = "jobq"
|
||||
)]
|
||||
pub(crate) async fn get_jobq_graph(
|
||||
State(state): State<AppState>,
|
||||
axum::extract::Query(q): axum::extract::Query<JobqGraphQuery>,
|
||||
) -> Json<Vec<hive_jobq_wire::GraphNode>> {
|
||||
let states = hive_jobq_wire::parse_states(q.states.as_deref());
|
||||
let sched = state
|
||||
.jobq
|
||||
.lock()
|
||||
.unwrap_or_else(std::sync::PoisonError::into_inner);
|
||||
let graph = sched.graph();
|
||||
let roots: Vec<hive_jobq::NodeId> = graph.roots().map(|n| n.id).collect();
|
||||
let nodes = graph.wire_snapshot(roots);
|
||||
Json(hive_jobq_wire::filter_nodes_by_state(
|
||||
nodes,
|
||||
states.as_deref(),
|
||||
))
|
||||
}
|
||||
|
||||
/// Counts by lifecycle state over the same groups `/api/jobq/graph` serves.
|
||||
#[utoipa::path(
|
||||
get,
|
||||
path = "/api/jobq/rollup",
|
||||
responses((status = 200, description = "counts by lifecycle state", body = Vec<hive_jobq_wire::StateCount>)),
|
||||
tag = "jobq"
|
||||
)]
|
||||
pub(crate) async fn get_jobq_rollup(
|
||||
State(state): State<AppState>,
|
||||
) -> Json<Vec<hive_jobq_wire::StateCount>> {
|
||||
let sched = state
|
||||
.jobq
|
||||
.lock()
|
||||
.unwrap_or_else(std::sync::PoisonError::into_inner);
|
||||
let graph = sched.graph();
|
||||
let roots: Vec<hive_jobq::NodeId> = graph.roots().map(|n| n.id).collect();
|
||||
Json(hive_jobq_wire::state_rollup(graph, roots))
|
||||
}
|
||||
365
swarm-controller/src/routes/mod.rs
Normal file
365
swarm-controller/src/routes/mod.rs
Normal file
|
|
@ -0,0 +1,365 @@
|
|||
//! 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
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -33,8 +33,8 @@ use swarm_controller_api::schedules::{
|
|||
use swarm_queue_client::agent_schedules::{self, AgentSchedules, Schedule};
|
||||
|
||||
use crate::agent_renewal::unix_now;
|
||||
use crate::routes::{AppState, error_problem};
|
||||
use crate::wanted::WantedWriter;
|
||||
use crate::{AppState, error_problem};
|
||||
|
||||
/// The bucket holding every schedule definition. Agents hold no grant on it.
|
||||
pub const DEFINITIONS_BUCKET: &str = "schedule-definitions";
|
||||
|
|
@ -384,7 +384,7 @@ fn problem(failure: Failure) -> ProblemDetails {
|
|||
}
|
||||
Failure::Queue(e) => {
|
||||
tracing::warn!(error = %format!("{e:#}"), "schedules: queue failure");
|
||||
error_problem(crate::wanted_error_status(&e), &format!("{e:#}"))
|
||||
error_problem(crate::routes::wanted_error_status(&e), &format!("{e:#}"))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
910
swarm-controller/src/startup.rs
Normal file
910
swarm-controller/src/startup.rs
Normal file
|
|
@ -0,0 +1,910 @@
|
|||
//! Process startup: the config this daemon loads once from env vars, the
|
||||
//! background passes it starts, and the router it serves.
|
||||
//!
|
||||
//! `OpenAPI` spec generation mirrors `hive-c0re/src/dashboard/mod.rs`
|
||||
//! exactly: `#[utoipa::path(...)]` per handler, an `ApiDoc` root, and a
|
||||
//! raw JSON route at `/api/openapi.json`. Swagger UI itself is
|
||||
//! nginx-hosted from the nix store, same shape as the per-hive
|
||||
//! dashboard's (`nix/host-modules/hive-c0re/vhosts.nix`'s
|
||||
//! `swaggerUiLocations` — a swagger-ui-theme dist under `/api/docs/`, no
|
||||
//! fallback to this daemon). Only annotated routes appear in the spec;
|
||||
//! an unannotated one just doesn't show up, nothing breaks.
|
||||
|
||||
use std::path::PathBuf;
|
||||
use std::sync::{Arc, Mutex};
|
||||
|
||||
use anyhow::Result;
|
||||
use axum::{Json, routing::get};
|
||||
use backon::{ExponentialBuilder, Retryable as _};
|
||||
use utoipa::OpenApi;
|
||||
use utoipa_axum::{router::OpenApiRouter, routes};
|
||||
|
||||
use crate::jobq::{
|
||||
Late, SwarmNodeKind, SwarmResourceKind, queue_agent_renewals, queue_builder_forge_token_mint,
|
||||
queue_ci_metrics_forge_token_mint, queue_classifier_forge_token_mint, queue_docs_grants,
|
||||
queue_forge_token_mints, queue_hive_forge_token_mints, queue_hive_sender_mints,
|
||||
queue_matrix_account_mints,
|
||||
};
|
||||
use crate::routes::hives::{HiveEntry, ServiceLink};
|
||||
use crate::routes::{self, AppState, agents, config_prs, forge_admin, hives, jobq_graph};
|
||||
use crate::{
|
||||
agent_control, agent_icon, agent_input, agent_limits, agent_login, agent_permissions,
|
||||
agent_renewal, agent_state_stream, agent_status, agents_md, alerts, archetypes, auth, builder,
|
||||
claude_login, config_pr, forge, forge_account, github_account, inference, issue_report,
|
||||
linked_accounts, matrix_account, pulls, queue_identity, review_classifier, schedules, status,
|
||||
subagent_term, term_stream, wanted, webhook,
|
||||
};
|
||||
|
||||
/// Where the daemon binds, overridable via `SWARM_CONTROLLER_SOCKET`.
|
||||
///
|
||||
/// A compiled-in default is legitimate here and is *not* the mistake that
|
||||
/// a hardcoded remote address would be: this is a path this process
|
||||
/// **creates**, not an address it hopes to find something at. systemd's
|
||||
/// `RuntimeDirectory=swarm-controller` makes the parent exist before
|
||||
/// `ExecStart`, so the default names a directory the unit just produced.
|
||||
///
|
||||
/// The directory is its own — deliberately not shared with hive-c0re's
|
||||
/// `/run/hyperhive`. The socket is `0666`, so its directory is the only
|
||||
/// access control it has; co-locating it with c0re's admin socket would
|
||||
/// put both within reach of whatever can reach either. nginx runs on the
|
||||
/// host, so nothing narrows its reach for you.
|
||||
const DEFAULT_SOCKET: &str = "/run/swarm-controller/controller.sock";
|
||||
|
||||
pub(crate) fn socket_path() -> PathBuf {
|
||||
std::env::var_os("SWARM_CONTROLLER_SOCKET")
|
||||
.map_or_else(|| PathBuf::from(DEFAULT_SOCKET), PathBuf::from)
|
||||
}
|
||||
|
||||
/// Root of the auto-generated `OpenAPI` spec, served raw at
|
||||
/// `/api/openapi.json` — see the module doc comment above. Tag list
|
||||
/// grows alongside the swarm-level surfaces this daemon picks up, same
|
||||
/// as `hive-c0re::dashboard::ApiDoc`'s tag list did.
|
||||
#[derive(OpenApi)]
|
||||
#[openapi(
|
||||
info(
|
||||
title = "hyperhive swarm-controller API",
|
||||
description = "swarm-controller's HTTP surface, served over its unix \
|
||||
socket behind the gateway's swarm-UI vhost."
|
||||
),
|
||||
tags(
|
||||
(name = "health", description = "liveness probe"),
|
||||
(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"),
|
||||
(name = "webhook", description = "swarm-wide forge webhook receipt"),
|
||||
(name = "repos", description = "forge repo browsing (swarm-ui's issue-report page)"),
|
||||
(name = "forge", description = "forge site admins (`swarmctl forge make-admin`)"),
|
||||
(name = "schedules", description = "scheduled prompts (`swarmctl schedule`)"),
|
||||
(name = "review-classifier", description = "trying review classifications against a PR (swarm-ui's try-out page), and the classifier's swarm-wide settings"),
|
||||
(name = "pulls", description = "open pull requests, for the try-out page's dropdowns"),
|
||||
(name = "alerts", description = "the swarm Grafana's current alerts (swarm-ui's alerts page)"),
|
||||
(name = "builder", description = "the swarm builder's finished builds of agent config PRs, which their commit statuses link to"),
|
||||
)
|
||||
)]
|
||||
pub(crate) struct ApiDoc;
|
||||
|
||||
/// Env var the controller's NixOS module sets from
|
||||
/// `services.hyperhive.swarm.hives`, JSON-encoded — the full directory
|
||||
/// (this daemon has no "self" to exclude) rather than peers-minus-self.
|
||||
const HIVES_ENV: &str = "SWARM_CONTROLLER_HIVES";
|
||||
|
||||
/// Parses [`HIVES_ENV`] into the swarm's hive directory. Unset or
|
||||
/// unparseable both fall back to an empty list with a warning rather than
|
||||
/// failing startup — a swarm-controller that can't yet see its own config
|
||||
/// (dev run, a module not wired up yet) should still serve `/health`.
|
||||
pub(crate) fn load_hives() -> Vec<HiveEntry> {
|
||||
let Some(raw) = std::env::var_os(HIVES_ENV) else {
|
||||
return Vec::new();
|
||||
};
|
||||
match serde_json::from_str(&raw.to_string_lossy()) {
|
||||
Ok(hives) => hives,
|
||||
Err(e) => {
|
||||
tracing::warn!(error = %e, env = HIVES_ENV, "failed to parse hive directory, serving an empty list");
|
||||
Vec::new()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Env var the controller's NixOS module sets from the merged
|
||||
/// `services.hyperhive.swarm.controller.links` list, JSON-encoded — same
|
||||
/// shape/rationale as [`HIVES_ENV`]. Consumed by `GET /api/links`.
|
||||
const LINKS_ENV: &str = "SWARM_CONTROLLER_LINKS";
|
||||
|
||||
/// Parses [`LINKS_ENV`] into the swarm's service-link list. Same
|
||||
/// fall-back-to-empty rationale as `load_hives`: a daemon that can't yet
|
||||
/// see this config should still serve `/health` rather than fail startup.
|
||||
pub(crate) fn load_links() -> Vec<ServiceLink> {
|
||||
let Some(raw) = std::env::var_os(LINKS_ENV) else {
|
||||
return Vec::new();
|
||||
};
|
||||
match serde_json::from_str(&raw.to_string_lossy()) {
|
||||
Ok(links) => links,
|
||||
Err(e) => {
|
||||
tracing::warn!(error = %e, env = LINKS_ENV, "failed to parse service links, serving an empty list");
|
||||
Vec::new()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Env var the controller's NixOS module sets from
|
||||
/// `services.hyperhive.swarm.name` — unset (rather than an empty string)
|
||||
/// when the operator never configured it. Consumed by `GET /api/swarm`.
|
||||
const NAME_ENV: &str = "SWARM_CONTROLLER_NAME";
|
||||
|
||||
/// Reads [`NAME_ENV`]. `None` on absence — no fallback-and-warn shape like
|
||||
/// `load_hives`/`load_links` because there is nothing to parse and fail:
|
||||
/// an unset env var and an operator who never named the swarm are the same
|
||||
/// state, not an error.
|
||||
pub(crate) fn load_swarm_name() -> Option<String> {
|
||||
std::env::var(NAME_ENV).ok()
|
||||
}
|
||||
|
||||
/// The declaration writer, sharing the status reader's connection.
|
||||
///
|
||||
/// The controller holds exactly one queue connection by design — see
|
||||
/// `StatusReader::queue_client`. A second connect would double the
|
||||
/// auth-callout traffic and give the two paths independent reconnect state,
|
||||
/// so one could be serving while the other was still down.
|
||||
pub(crate) fn wanted_writer(
|
||||
status: Option<&Arc<status::StatusReader>>,
|
||||
) -> Option<Arc<wanted::WantedWriter>> {
|
||||
status.map(|s| Arc::new(wanted::WantedWriter::new(s.queue_client())))
|
||||
}
|
||||
|
||||
/// The per-agent status reader, sharing the status reader's connection and
|
||||
/// staleness threshold — same rationale as [`wanted_writer`], and the same
|
||||
/// threshold on purpose: both buckets are fed by the same publisher cadence
|
||||
/// (`hive-c0re`'s `PUBLISH_INTERVAL`), so two separately-configured
|
||||
/// thresholds would just be two ways to get out of sync with one cadence.
|
||||
pub(crate) fn agent_status_reader(
|
||||
status: Option<&Arc<status::StatusReader>>,
|
||||
) -> Option<Arc<agent_status::AgentStatusReader>> {
|
||||
status.map(|s| {
|
||||
Arc::new(agent_status::AgentStatusReader::new(
|
||||
s.queue_client(),
|
||||
status::StatusReader::stale_after_from_env(),
|
||||
))
|
||||
})
|
||||
}
|
||||
|
||||
/// The per-agent icon reader, sharing the status reader's connection —
|
||||
/// same rationale as [`wanted_writer`]. No staleness threshold, unlike
|
||||
/// [`agent_status_reader`]: an icon is a property of the agent rather than
|
||||
/// a report about it, so there is no cadence it could be late against.
|
||||
///
|
||||
/// Also starts creating the bucket, which the agents writing it cannot do.
|
||||
pub(crate) fn agent_icon_reader(
|
||||
status: Option<&Arc<status::StatusReader>>,
|
||||
) -> Option<Arc<agent_icon::AgentIconReader>> {
|
||||
let reader = Arc::new(agent_icon::AgentIconReader::new(status?.queue_client()));
|
||||
let creating = Arc::clone(&reader);
|
||||
tokio::spawn(async move { creating.create_when_connected().await });
|
||||
Some(reader)
|
||||
}
|
||||
|
||||
/// The `AGENTS.md` reader, sharing the status reader's connection, and
|
||||
/// creating the bucket, as [`agent_icon_reader`] does for icons.
|
||||
pub(crate) fn agents_md_reader(
|
||||
status: Option<&Arc<status::StatusReader>>,
|
||||
) -> Option<Arc<agents_md::AgentsMdReader>> {
|
||||
let reader = Arc::new(agents_md::AgentsMdReader::new(status?.queue_client()));
|
||||
let creating = Arc::clone(&reader);
|
||||
tokio::spawn(async move { creating.create_when_connected().await });
|
||||
Some(reader)
|
||||
}
|
||||
|
||||
/// What each agent last reported running on, read on the status reader's
|
||||
/// connection — same rationale as [`wanted_writer`].
|
||||
pub(crate) fn reported_inference(
|
||||
status: Option<&Arc<status::StatusReader>>,
|
||||
) -> Option<Arc<inference::Reported>> {
|
||||
Some(inference::Reported::spawn(status?.queue_client()))
|
||||
}
|
||||
|
||||
/// Whether each agent last said it needs a login, read on the status
|
||||
/// reader's connection — same rationale as [`wanted_writer`].
|
||||
pub(crate) fn agent_login(
|
||||
status: Option<&Arc<status::StatusReader>>,
|
||||
) -> Option<Arc<agent_login::Reported>> {
|
||||
Some(agent_login::Reported::spawn(status?.queue_client()))
|
||||
}
|
||||
|
||||
/// Same "log and carry on" shape as the queue/bridge/forge wiring in `main`.
|
||||
/// A controller that cannot hold a webhook secret still serves every other
|
||||
/// route; the webhook endpoint answers 503, which is the honest answer
|
||||
/// rather than a silent accept. Lifted out of `main` for
|
||||
/// `clippy::too_many_lines`.
|
||||
pub(crate) fn load_webhook_secret() -> Option<Arc<str>> {
|
||||
match webhook::load_or_generate_secret() {
|
||||
Ok(secret) => Some(Arc::from(secret)),
|
||||
Err(e) => {
|
||||
tracing::warn!(
|
||||
error = %format!("{e:#}"),
|
||||
"webhook secret unavailable; swarm-wide forge webhooks are off"
|
||||
);
|
||||
None
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 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.
|
||||
pub(crate) fn configured_matrix_homeserver() -> Option<Arc<str>> {
|
||||
matrix_account::configured_default_homeserver()
|
||||
.filter(|v| !v.is_empty())
|
||||
.map(Arc::from)
|
||||
}
|
||||
|
||||
/// Start the recovery passphrase pass, which needs only the store; the
|
||||
/// matrix-account backfill and the hive sender-token pass when a homeserver is
|
||||
/// configured; and the operator admin pass when the bridge is too. Lifted out
|
||||
/// of `main` for `clippy::too_many_lines`.
|
||||
pub(crate) fn spawn_matrix_account_backfill(
|
||||
jobq: &Arc<Mutex<hive_jobq::scheduler::Scheduler<SwarmNodeKind, SwarmResourceKind>>>,
|
||||
homeserver: Option<Arc<str>>,
|
||||
hives: &[HiveEntry],
|
||||
auth: Option<Arc<auth::AuthBridge>>,
|
||||
) {
|
||||
matrix_account::recovery::spawn();
|
||||
let Some(base) = homeserver else {
|
||||
return;
|
||||
};
|
||||
if let Some(bridge) = auth {
|
||||
matrix_account::operator_admin::spawn(bridge, Arc::clone(&base));
|
||||
}
|
||||
let sched = Arc::clone(jobq);
|
||||
matrix_account::agent_token::spawn(Arc::clone(&base), move |agents| {
|
||||
if let Err(e) = queue_matrix_account_mints(&sched, agents) {
|
||||
tracing::warn!(error = %format!("{e:#}"), "agent matrix accounts: queueing failed");
|
||||
}
|
||||
});
|
||||
let sched = Arc::clone(jobq);
|
||||
let hive_names = hives.iter().map(|h| h.name.clone()).collect();
|
||||
matrix_account::hive_sender::spawn(base, hive_names, move |hives| {
|
||||
if let Err(e) = queue_hive_sender_mints(&sched, hives) {
|
||||
tracing::warn!(error = %format!("{e:#}"), "hive sender tokens: queueing failed");
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/// Start the forge's periodic passes — the swarm-wide objects, the runner
|
||||
/// registration token, hives' and the builder's read tokens, agents'
|
||||
/// `internal/docs` grants, and
|
||||
/// agents' tokens (which also
|
||||
/// sweeps legacy tokens; see `forge::legacy_tokens`) —
|
||||
/// once there is a forge client, and the operators' profiles when the bridge
|
||||
/// and the queue are wired up too. Lifted out of `main` for
|
||||
/// `clippy::too_many_lines`.
|
||||
fn spawn_forge_workers(
|
||||
jobq: &Arc<Mutex<hive_jobq::scheduler::Scheduler<SwarmNodeKind, SwarmResourceKind>>>,
|
||||
client: Arc<forge::Client>,
|
||||
hives: &[HiveEntry],
|
||||
auth: Option<Arc<auth::AuthBridge>>,
|
||||
queue: Option<async_nats::Client>,
|
||||
) {
|
||||
forge::objects::spawn(Arc::clone(&client));
|
||||
if let (Some(bridge), Some(queue)) = (auth, queue) {
|
||||
forge::operator_profiles::spawn(Arc::clone(&client), bridge, queue);
|
||||
}
|
||||
forge::runner_token::spawn(Arc::clone(&client));
|
||||
let sched = Arc::clone(jobq);
|
||||
let hive_names = hives.iter().map(|h| h.name.clone()).collect();
|
||||
forge::hive_token::spawn(Arc::clone(&client), hive_names, move |hives| {
|
||||
if let Err(e) = queue_hive_forge_token_mints(&sched, hives) {
|
||||
tracing::warn!(error = %format!("{e:#}"), "hive forge tokens: queueing failed");
|
||||
}
|
||||
});
|
||||
let sched = Arc::clone(jobq);
|
||||
forge::classifier_account::spawn(Arc::clone(&client), move || {
|
||||
if let Err(e) = queue_classifier_forge_token_mint(&sched) {
|
||||
tracing::warn!(error = %format!("{e:#}"), "review classifier forge token: queueing failed");
|
||||
}
|
||||
});
|
||||
let sched = Arc::clone(jobq);
|
||||
forge::builder_account::spawn(Arc::clone(&client), move || {
|
||||
if let Err(e) = queue_builder_forge_token_mint(&sched) {
|
||||
tracing::warn!(error = %format!("{e:#}"), "builder forge token: queueing failed");
|
||||
}
|
||||
});
|
||||
let sched = Arc::clone(jobq);
|
||||
forge::ci_metrics_account::spawn(Arc::clone(&client), move || {
|
||||
if let Err(e) = queue_ci_metrics_forge_token_mint(&sched) {
|
||||
tracing::warn!(error = %format!("{e:#}"), "ci metrics forge token: queueing failed");
|
||||
}
|
||||
});
|
||||
let sched = Arc::clone(jobq);
|
||||
forge::docs_grant::spawn(Arc::clone(&client), move |agents| {
|
||||
if let Err(e) = queue_docs_grants(&sched, agents) {
|
||||
tracing::warn!(error = %format!("{e:#}"), "docs grants: queueing failed");
|
||||
}
|
||||
});
|
||||
let sched = Arc::clone(jobq);
|
||||
forge::agent_token::spawn(client, move |agents| {
|
||||
if let Err(e) = queue_forge_token_mints(&sched, agents) {
|
||||
tracing::warn!(error = %format!("{e:#}"), "agent forge tokens: queueing failed");
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/// Start the agent credential renewal pass when a swarm queue is wired up:
|
||||
/// the pass reads the wanted-state declarations to tell live agents from
|
||||
/// destroyed ones, and without a queue it has none. Lifted out of `main` for
|
||||
/// `clippy::too_many_lines`.
|
||||
pub(crate) fn spawn_agent_renewal(
|
||||
jobq: &Arc<Mutex<hive_jobq::scheduler::Scheduler<SwarmNodeKind, SwarmResourceKind>>>,
|
||||
wanted: Option<Arc<wanted::WantedWriter>>,
|
||||
hives: &[HiveEntry],
|
||||
) {
|
||||
let Some(wanted) = wanted else {
|
||||
return;
|
||||
};
|
||||
let hives = hives.iter().map(|h| h.name.clone()).collect();
|
||||
let sched = Arc::clone(jobq);
|
||||
agent_renewal::spawn(wanted, hives, move |renewals| {
|
||||
if let Err(e) = queue_agent_renewals(&sched, renewals) {
|
||||
tracing::warn!(error = %format!("{e:#}"), "agent credential renewal: queueing failed");
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/// Start creating every live agent's subagent and terminal streams when a swarm
|
||||
/// queue is wired up. Lifted out of `main` for `clippy::too_many_lines`.
|
||||
fn spawn_subagent_streams(status: Option<&Arc<status::StatusReader>>, hives: &[HiveEntry]) {
|
||||
let (Some(status), Some(wanted)) = (status, wanted_writer(status)) else {
|
||||
return;
|
||||
};
|
||||
let hives = hives.iter().map(|h| h.name.clone()).collect();
|
||||
subagent_term::spawn(status.queue_client(), wanted, hives);
|
||||
}
|
||||
|
||||
/// The per-agent background passes: subagent and terminal streams, and
|
||||
/// schedule keys, and the permissions bucket and its import, and the
|
||||
/// archetypes agents are created from.
|
||||
/// Lifted out of `main` for `clippy::too_many_lines`.
|
||||
pub(crate) fn spawn_per_agent_upkeep(
|
||||
status: Option<&Arc<status::StatusReader>>,
|
||||
hives: &[HiveEntry],
|
||||
) {
|
||||
spawn_subagent_streams(status, hives);
|
||||
spawn_schedule_reconcile(status, hives);
|
||||
spawn_agent_permissions(status, hives);
|
||||
if let Some(status) = status {
|
||||
archetypes::spawn(status.queue_client());
|
||||
}
|
||||
}
|
||||
|
||||
/// Create the agent-permissions bucket and answer hives' imports when a swarm
|
||||
/// queue is wired up. Same shape as [`spawn_subagent_streams`].
|
||||
fn spawn_agent_permissions(status: Option<&Arc<status::StatusReader>>, hives: &[HiveEntry]) {
|
||||
let (Some(status), Some(wanted)) = (status, wanted_writer(status)) else {
|
||||
return;
|
||||
};
|
||||
let hives = hives.iter().map(|h| h.name.clone()).collect();
|
||||
agent_permissions::spawn(status.queue_client(), wanted, hives);
|
||||
}
|
||||
|
||||
/// Start rewriting every agent's schedule key from the definitions when a
|
||||
/// swarm queue is wired up. Same shape as [`spawn_subagent_streams`].
|
||||
fn spawn_schedule_reconcile(status: Option<&Arc<status::StatusReader>>, hives: &[HiveEntry]) {
|
||||
let (Some(status), Some(wanted)) = (status, wanted_writer(status)) else {
|
||||
return;
|
||||
};
|
||||
let hives = hives.iter().map(|h| h.name.clone()).collect();
|
||||
schedules::spawn_reconcile(status.queue_client(), wanted, hives);
|
||||
}
|
||||
|
||||
/// The swarm's own public base URL, as the forge must address it.
|
||||
///
|
||||
/// Set by `swarm-controller.nix` **only when this host actually serves the
|
||||
/// swarm vhost** that carries the `/webhook/forge/` location. Absent means
|
||||
/// "the endpoint is not reachable from outside", and the right response to
|
||||
/// that is to register nothing: a hook pointing at a URL nothing answers is
|
||||
/// worse than no hook, because forgejo records failed deliveries against a
|
||||
/// registration that looks configured.
|
||||
pub(crate) const PUBLIC_URL_ENV: &str = "SWARM_CONTROLLER_PUBLIC_URL";
|
||||
|
||||
/// Register the swarm-wide forge hooks against this controller, in the
|
||||
/// background.
|
||||
///
|
||||
/// Detached rather than awaited, and never fatal: the forge may be slow or
|
||||
/// briefly down at boot, and none of the daemon's other routes depend on a
|
||||
/// hook existing.
|
||||
///
|
||||
/// Retried on a backoff rather than left to the next process start. Losing
|
||||
/// that race is the *common* case, not an edge one — the controller and the
|
||||
/// forge come up together on a rebuild, and a deploy was measured where both
|
||||
/// consecutive starts got `502 Bad Gateway` from the gateway because forgejo
|
||||
/// was not serving yet. Nothing schedules another start, so "the next restart
|
||||
/// retries" can leave a swarm with no hooks registered for as long as the
|
||||
/// daemon keeps running — and the failure is silent at both ends, because the
|
||||
/// forge has nothing to report about a call that never arrived.
|
||||
///
|
||||
/// Runs once there is a forge client, and silently does nothing when either
|
||||
/// other precondition is missing — each is a legitimate deployment shape (no
|
||||
/// state directory to hold a secret, no public vhost), and each is already
|
||||
/// logged where it is discovered.
|
||||
fn register_swarm_webhooks(forge: Arc<forge::Client>, secret: Option<Arc<str>>) {
|
||||
let Some(secret) = secret else { return };
|
||||
let Ok(public_url) = std::env::var(PUBLIC_URL_ENV) else {
|
||||
tracing::info!(
|
||||
"{PUBLIC_URL_ENV} unset; not registering swarm-wide forge webhooks (the \
|
||||
endpoint is not published on this host)"
|
||||
);
|
||||
return;
|
||||
};
|
||||
tokio::spawn(async move {
|
||||
// A bounded schedule, not a poll loop: 15s doubling to 240s, five
|
||||
// retries. It exists to outlast a forge that is slow to start, not to
|
||||
// re-register periodically. Registration is idempotent, so a repeat
|
||||
// costs one API call and changes nothing.
|
||||
const RETRIES: usize = 5;
|
||||
|
||||
let mut failed = 0;
|
||||
let registered = (|| forge.ensure_swarm_webhooks(&public_url, &secret))
|
||||
.retry(
|
||||
ExponentialBuilder::default()
|
||||
.with_min_delay(std::time::Duration::from_secs(15))
|
||||
.with_max_delay(std::time::Duration::from_mins(4))
|
||||
.with_max_times(RETRIES),
|
||||
)
|
||||
.notify(|e, wait| {
|
||||
failed += 1;
|
||||
tracing::info!(
|
||||
error = %format!("{e:#}"),
|
||||
attempt = failed,
|
||||
retry_in = ?wait,
|
||||
"registering swarm-wide forge webhooks failed; retrying"
|
||||
);
|
||||
})
|
||||
.await;
|
||||
if let Err(e) = registered {
|
||||
tracing::warn!(
|
||||
error = %format!("{e:#}"),
|
||||
attempts = RETRIES + 1,
|
||||
"registering swarm-wide forge webhooks failed; giving up until the next start"
|
||||
);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/// Connect to the swarm queue when this deployment wired one up, extracted
|
||||
/// out of `main` purely to keep that function under clippy's line-count
|
||||
/// lint — no behavior split, every comment below is unchanged from where
|
||||
/// it used to sit inline.
|
||||
///
|
||||
/// Deliberately NOT fatal on failure: the controller's HTTP surface is
|
||||
/// useful without the queue, and a hive that cannot be read from renders
|
||||
/// as `unknown` rather than as an outage of this daemon. What IS fatal is
|
||||
/// an identity that could not be resolved, which `queue_identity::init`
|
||||
/// reports in `main` before this runs: silently behaving like an
|
||||
/// unconfigured host is how every hive ends up reading `never_reported`
|
||||
/// with nothing to point at.
|
||||
pub(crate) async fn connect_status_reader() -> Result<Option<Arc<status::StatusReader>>> {
|
||||
let Some(cfg) = queue_identity::get().cloned() else {
|
||||
tracing::info!("no swarm queue configured; status aggregation is off");
|
||||
return Ok(None);
|
||||
};
|
||||
match swarm_queue_client::connect(cfg).await {
|
||||
Ok(client) => {
|
||||
// NOT "connected": `retry_on_initial_connect` returns a client
|
||||
// before any connection has been established, so claiming a
|
||||
// connection here would put "connected to the swarm queue" in
|
||||
// the journal moments before every request 503s with "not
|
||||
// connected" — and a reader would rightly distrust the second
|
||||
// line rather than the first. The connection's real state is
|
||||
// reported by the status endpoint, which checks it per request.
|
||||
tracing::info!("swarm queue configured; connecting in the background");
|
||||
Ok(Some(Arc::new(status::StatusReader::new(
|
||||
client,
|
||||
status::StatusReader::stale_after_from_env(),
|
||||
))))
|
||||
}
|
||||
Err(e) => {
|
||||
// `chain`, not `{:#}`: this is the queue client's own
|
||||
// error type, and thiserror's Display ignores the
|
||||
// alternate flag — the source would be dropped silently.
|
||||
tracing::warn!(
|
||||
error = swarm_queue_client::chain(&e),
|
||||
"swarm queue unreachable"
|
||||
);
|
||||
Ok(None)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Fill the forge slots and start everything that runs off the forge client —
|
||||
/// its periodic passes, the config-PR poll and the webhook registration — now
|
||||
/// if the token is in the store, otherwise when `forge::enable`'s background
|
||||
/// re-read finds it. Nothing to start when no forge URL is configured. Lifted
|
||||
/// out of `main` for `clippy::too_many_lines`.
|
||||
pub(crate) async fn start_forge(
|
||||
forge_client: Late<Arc<forge::Client>>,
|
||||
config_prs: Late<Arc<config_pr::ConfigPrCache>>,
|
||||
jobq: Arc<Mutex<hive_jobq::scheduler::Scheduler<SwarmNodeKind, SwarmResourceKind>>>,
|
||||
hives: Vec<HiveEntry>,
|
||||
webhook_secret: Option<Arc<str>>,
|
||||
auth: Option<Arc<auth::AuthBridge>>,
|
||||
queue: Option<async_nats::Client>,
|
||||
) {
|
||||
let url = match forge::url_from_env() {
|
||||
Ok(Some(url)) => url,
|
||||
Ok(None) => return,
|
||||
Err(e) => {
|
||||
tracing::warn!(error = %format!("{e:#}"), "forge misconfigured; repo provisioning is off");
|
||||
return;
|
||||
}
|
||||
};
|
||||
forge::enable(url, forge_client, move |client| {
|
||||
spawn_forge_workers(&jobq, Arc::clone(&client), &hives, auth, queue);
|
||||
config_prs.get_or_init(|| config_pr::spawn(Arc::clone(&client)));
|
||||
register_swarm_webhooks(client, webhook_secret);
|
||||
})
|
||||
.await;
|
||||
}
|
||||
|
||||
/// Every route this daemon serves, wired to its state.
|
||||
///
|
||||
/// Split out of `main` because the route list is the part that grows, and
|
||||
/// `main` sat exactly on the `too_many_lines` limit — adding an endpoint
|
||||
/// tripped a lint about the startup sequence, which is not where the change
|
||||
/// was. A new route now costs one line here and none there.
|
||||
pub(crate) fn build_app(state: AppState) -> axum::Router {
|
||||
let (router, api) = OpenApiRouter::<AppState>::with_openapi(ApiDoc::openapi())
|
||||
.routes(routes!(routes::health))
|
||||
.routes(routes!(alerts::get_alerts))
|
||||
.routes(routes!(hives::get_hives))
|
||||
.routes(routes!(hives::get_hives_status))
|
||||
.routes(routes!(hives::get_links))
|
||||
.routes(routes!(hives::get_swarm_info))
|
||||
.routes(routes!(jobq_graph::get_jobq_graph))
|
||||
.routes(routes!(jobq_graph::get_jobq_rollup))
|
||||
.routes(routes!(config_prs::get_agent_config_pr))
|
||||
.routes(routes!(agents::get_agent_icon))
|
||||
.routes(routes!(agents_md::get_agents_md, agents_md::put_agents_md))
|
||||
.routes(routes!(
|
||||
inference::get_agent_inference,
|
||||
inference::put_agent_inference,
|
||||
inference::delete_agent_inference
|
||||
))
|
||||
.routes(routes!(
|
||||
agent_limits::get_agent_limits,
|
||||
agent_limits::put_agent_limits,
|
||||
agent_limits::delete_agent_limits
|
||||
))
|
||||
.routes(routes!(
|
||||
agent_permissions::get_agent_permissions,
|
||||
agent_permissions::put_agent_permissions,
|
||||
agent_permissions::delete_agent_permissions
|
||||
))
|
||||
.routes(routes!(agent_permissions::get_permission_catalogue))
|
||||
.routes(routes!(archetypes::get_archetypes))
|
||||
.routes(routes!(config_prs::get_config_prs))
|
||||
.routes(routes!(agents::create_agent))
|
||||
.routes(routes!(forge_admin::mint_agent_forge_token))
|
||||
.routes(routes!(forge_admin::make_forge_admin))
|
||||
.routes(routes!(agents::get_agents))
|
||||
.routes(routes!(agents::get_agents_status))
|
||||
.routes(routes!(agents::set_agent_state))
|
||||
.routes(routes!(
|
||||
matrix_account::put_matrix_account,
|
||||
linked_accounts::delete_matrix_account
|
||||
))
|
||||
.routes(routes!(
|
||||
forge_account::put_forge_account,
|
||||
linked_accounts::delete_forge_account
|
||||
))
|
||||
.routes(routes!(
|
||||
github_account::put_github_account,
|
||||
linked_accounts::delete_github_account
|
||||
))
|
||||
.routes(routes!(linked_accounts::get_linked_accounts))
|
||||
.routes(routes!(agents::get_hive_wanted))
|
||||
.routes(routes!(term_stream::stream_agent_term))
|
||||
.routes(routes!(subagent_term::list_subagents))
|
||||
.routes(routes!(subagent_term::stream_subagent_term))
|
||||
.routes(routes!(agent_state_stream::stream_agent_state))
|
||||
.routes(routes!(agent_input::post_agent_input))
|
||||
.routes(routes!(agent_input::post_agents_broadcast))
|
||||
.routes(routes!(agent_control::post_agent_cancel))
|
||||
.routes(routes!(
|
||||
claude_login::post_claude_login,
|
||||
claude_login::delete_claude_login
|
||||
))
|
||||
.routes(routes!(claude_login::post_claude_login_code))
|
||||
.routes(routes!(schedules::list_schedules, schedules::add_schedule))
|
||||
.routes(routes!(
|
||||
schedules::edit_schedule,
|
||||
schedules::remove_schedule
|
||||
))
|
||||
.routes(routes!(issue_report::get_repos))
|
||||
.routes(routes!(issue_report::get_issue_report_all))
|
||||
.routes(routes!(issue_report::get_issue_report))
|
||||
.routes(routes!(pulls::get_pull_repos))
|
||||
.routes(routes!(pulls::get_pulls))
|
||||
.routes(routes!(review_classifier::post_try_out))
|
||||
.routes(routes!(review_classifier::get_try_out))
|
||||
.routes(routes!(review_classifier::get_rules))
|
||||
.routes(routes!(review_classifier::open_pr::post_open_pr))
|
||||
.routes(routes!(review_classifier::runs::get_runs))
|
||||
.routes(routes!(builder::runs::get_runs))
|
||||
.routes(routes!(builder::runs::get_run))
|
||||
.routes(routes!(
|
||||
review_classifier::settings::get_settings,
|
||||
review_classifier::settings::put_settings
|
||||
))
|
||||
.routes(routes!(webhook::post_webhook_forge))
|
||||
.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
|
||||
// `Clone`; each request gets its own owned copy for `Json` to
|
||||
// serialize, same as `hive-c0re::dashboard::serve`.
|
||||
router
|
||||
.route(
|
||||
"/api/openapi.json",
|
||||
get(move || async move { Json(api.clone()) }),
|
||||
)
|
||||
.with_state(state)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use std::path::Path;
|
||||
|
||||
use super::*;
|
||||
use crate::routes::tests::state_with_roster;
|
||||
|
||||
/// The socket must not share a directory with anything else, because
|
||||
/// the socket is `0666` and the directory is therefore the only access
|
||||
/// control it has. `/run/hyperhive` in particular holds hive-c0re's
|
||||
/// **admin** socket, and nginx — a host service — is bounded by
|
||||
/// nothing but the directory itself.
|
||||
///
|
||||
/// A test rather than a comment: the failure this guards against is a
|
||||
/// one-word edit that looks tidier and reads fine in review.
|
||||
#[test]
|
||||
fn socket_lives_in_its_own_runtime_dir() {
|
||||
let parent = Path::new(DEFAULT_SOCKET)
|
||||
.parent()
|
||||
.expect("socket path has a parent directory");
|
||||
assert_eq!(
|
||||
parent,
|
||||
Path::new("/run/swarm-controller"),
|
||||
"the socket's directory is its access control — moving it under a shared \
|
||||
directory (notably /run/hyperhive, which holds the host admin socket) \
|
||||
exposes everything else in that directory to the gateway's nginx"
|
||||
);
|
||||
}
|
||||
|
||||
/// One test, not three, deliberately: `HIVES_ENV` is a real process
|
||||
/// env var, and `cargo test`'s default parallel runner would race
|
||||
/// separate missing/malformed/valid tests against each other. Driving
|
||||
/// all three states sequentially inside one test needs no mutex and
|
||||
/// still proves each branch of `load_hives`.
|
||||
///
|
||||
/// SAFETY: single-threaded mutation of a process env var no other
|
||||
/// test in this crate reads; restored (removed) before returning.
|
||||
#[test]
|
||||
fn load_hives_covers_missing_malformed_and_valid() {
|
||||
unsafe {
|
||||
std::env::remove_var(HIVES_ENV);
|
||||
}
|
||||
assert_eq!(
|
||||
load_hives(),
|
||||
Vec::<HiveEntry>::new(),
|
||||
"unset env var is an empty directory, not a startup failure"
|
||||
);
|
||||
|
||||
unsafe {
|
||||
std::env::set_var(HIVES_ENV, "not json");
|
||||
}
|
||||
assert_eq!(
|
||||
load_hives(),
|
||||
Vec::<HiveEntry>::new(),
|
||||
"unparseable env var falls back to empty rather than panicking — \
|
||||
/health must still answer even if this daemon's own config is wrong"
|
||||
);
|
||||
|
||||
unsafe {
|
||||
std::env::set_var(
|
||||
HIVES_ENV,
|
||||
r#"[{"name":"pr1ma","domain":"pr1ma.example.com","dashboard":"pr1ma.hives.example"},{"name":"umbra","domain":"umbra.example.com","dashboard":"umbra.hives.example"}]"#,
|
||||
);
|
||||
}
|
||||
assert_eq!(
|
||||
load_hives(),
|
||||
vec![
|
||||
HiveEntry {
|
||||
name: "pr1ma".to_string(),
|
||||
domain: "pr1ma.example.com".to_string(),
|
||||
dashboard: "pr1ma.hives.example".to_string(),
|
||||
},
|
||||
HiveEntry {
|
||||
name: "umbra".to_string(),
|
||||
domain: "umbra.example.com".to_string(),
|
||||
dashboard: "umbra.hives.example".to_string(),
|
||||
},
|
||||
]
|
||||
);
|
||||
|
||||
unsafe {
|
||||
std::env::remove_var(HIVES_ENV);
|
||||
}
|
||||
}
|
||||
|
||||
/// Same three-state coverage as `load_hives_covers_missing_malformed_and_valid`,
|
||||
/// same reason for one test rather than three (a shared process env var).
|
||||
///
|
||||
/// SAFETY: single-threaded mutation of a process env var no other test
|
||||
/// in this crate reads; restored (removed) before returning.
|
||||
#[test]
|
||||
fn load_links_covers_missing_malformed_and_valid() {
|
||||
unsafe {
|
||||
std::env::remove_var(LINKS_ENV);
|
||||
}
|
||||
assert_eq!(
|
||||
load_links(),
|
||||
Vec::<ServiceLink>::new(),
|
||||
"unset env var is an empty list, not a startup failure"
|
||||
);
|
||||
|
||||
unsafe {
|
||||
std::env::set_var(LINKS_ENV, "not json");
|
||||
}
|
||||
assert_eq!(
|
||||
load_links(),
|
||||
Vec::<ServiceLink>::new(),
|
||||
"unparseable env var falls back to empty rather than panicking"
|
||||
);
|
||||
|
||||
unsafe {
|
||||
std::env::set_var(
|
||||
LINKS_ENV,
|
||||
r#"[{"label":"Authelia","icon":"🔑","url":"https://auth.example.com/"}]"#,
|
||||
);
|
||||
}
|
||||
assert_eq!(
|
||||
load_links(),
|
||||
vec![ServiceLink {
|
||||
label: "Authelia".to_string(),
|
||||
icon: "🔑".to_string(),
|
||||
url: "https://auth.example.com/".to_string(),
|
||||
}]
|
||||
);
|
||||
|
||||
unsafe {
|
||||
std::env::remove_var(LINKS_ENV);
|
||||
}
|
||||
}
|
||||
|
||||
/// Two states, not three like `load_hives`/`load_links` above: there is
|
||||
/// nothing to parse here, so no malformed-input branch exists to cover.
|
||||
///
|
||||
/// SAFETY: single-threaded mutation of a process env var no other test
|
||||
/// in this crate reads; restored (removed) before returning.
|
||||
#[test]
|
||||
fn load_swarm_name_covers_missing_and_set() {
|
||||
unsafe {
|
||||
std::env::remove_var(NAME_ENV);
|
||||
}
|
||||
assert_eq!(
|
||||
load_swarm_name(),
|
||||
None,
|
||||
"unset env var is an unnamed swarm, not a startup failure"
|
||||
);
|
||||
|
||||
unsafe {
|
||||
std::env::set_var(NAME_ENV, "constellat1on");
|
||||
}
|
||||
assert_eq!(load_swarm_name(), Some("constellat1on".to_string()));
|
||||
|
||||
unsafe {
|
||||
std::env::remove_var(NAME_ENV);
|
||||
}
|
||||
}
|
||||
|
||||
// ── NotConnected maps to 503, everything else stays 500 ─────────────
|
||||
|
||||
/// Every inference verb refuses a name that is not an identifier with a
|
||||
/// 400 and answers 503 without a queue; a PUT whose body is refused is a
|
||||
/// 400 before the queue is asked.
|
||||
#[tokio::test]
|
||||
async fn the_inference_routes_check_the_name_and_body_before_the_queue() {
|
||||
use crate::inference::{delete_agent_inference, get_agent_inference, put_agent_inference};
|
||||
use axum::extract::{Path, State};
|
||||
use axum::http::StatusCode;
|
||||
|
||||
fn status<T>(r: Result<T, problem_details::ProblemDetails>) -> Option<StatusCode> {
|
||||
r.map(|_| ()).expect_err("must not succeed").status
|
||||
}
|
||||
let (state, _sched) = state_with_roster();
|
||||
let body = |json: &str| axum::Json(serde_json::from_str(json).expect("decodes"));
|
||||
for name in ["iris", "Not An Ident"] {
|
||||
let want = Some(if name == "iris" {
|
||||
StatusCode::SERVICE_UNAVAILABLE
|
||||
} else {
|
||||
StatusCode::BAD_REQUEST
|
||||
});
|
||||
let path = || Path(name.to_owned());
|
||||
assert_eq!(
|
||||
status(get_agent_inference(State(state.clone()), path()).await),
|
||||
want,
|
||||
"GET {name}"
|
||||
);
|
||||
assert_eq!(
|
||||
status(
|
||||
put_agent_inference(State(state.clone()), path(), body(r#"{"model":"opus"}"#))
|
||||
.await
|
||||
),
|
||||
want,
|
||||
"PUT {name}"
|
||||
);
|
||||
assert_eq!(
|
||||
status(delete_agent_inference(State(state.clone()), path()).await),
|
||||
want,
|
||||
"DELETE {name}"
|
||||
);
|
||||
}
|
||||
for bad in [r#"{"model":"opus 4"}"#, r#"{"model":"--print"}"#] {
|
||||
assert_eq!(
|
||||
status(
|
||||
put_agent_inference(State(state.clone()), Path("iris".to_owned()), body(bad))
|
||||
.await
|
||||
),
|
||||
Some(StatusCode::BAD_REQUEST),
|
||||
"{bad}"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn listing_subagents_needs_a_queue_and_an_identifier() {
|
||||
let (state, _sched) = state_with_roster();
|
||||
let no_queue = crate::subagent_term::list_subagents(
|
||||
axum::extract::State(state.clone()),
|
||||
axum::extract::Path("iris".to_owned()),
|
||||
)
|
||||
.await
|
||||
.expect_err("no queue is configured");
|
||||
assert_eq!(
|
||||
no_queue.status,
|
||||
Some(axum::http::StatusCode::SERVICE_UNAVAILABLE),
|
||||
"{no_queue:?}"
|
||||
);
|
||||
let bad_name = crate::subagent_term::list_subagents(
|
||||
axum::extract::State(state),
|
||||
axum::extract::Path("iris.sub".to_owned()),
|
||||
)
|
||||
.await
|
||||
.expect_err("a dot would widen the subject");
|
||||
assert_eq!(
|
||||
bad_name.status,
|
||||
Some(axum::http::StatusCode::BAD_REQUEST),
|
||||
"{bad_name:?}"
|
||||
);
|
||||
}
|
||||
|
||||
/// A subagent name is a subject token, so a wildcard or a dot in it is
|
||||
/// refused before anything subscribes.
|
||||
#[tokio::test]
|
||||
async fn a_subagent_stream_refuses_a_name_that_is_not_one_token() {
|
||||
let (state, _sched) = state_with_roster();
|
||||
for subagent in ["*", ">", "a.b"] {
|
||||
let Err(problem) = crate::subagent_term::stream_subagent_term(
|
||||
axum::extract::State(state.clone()),
|
||||
axum::extract::Path(("iris".to_owned(), subagent.to_owned())),
|
||||
axum::http::HeaderMap::new(),
|
||||
)
|
||||
.await
|
||||
else {
|
||||
panic!("{subagent:?} must be refused");
|
||||
};
|
||||
assert_eq!(
|
||||
problem.status,
|
||||
Some(axum::http::StatusCode::BAD_REQUEST),
|
||||
"{subagent:?}: {problem:?}"
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -36,7 +36,7 @@ use futures_util::TryStreamExt as _;
|
|||
use serde::{Deserialize, Serialize};
|
||||
use utoipa::ToSchema;
|
||||
|
||||
use crate::HiveEntry;
|
||||
use crate::routes::hives::HiveEntry;
|
||||
|
||||
/// Default age past which a snapshot is reported stale.
|
||||
///
|
||||
|
|
@ -355,7 +355,7 @@ pub(crate) fn row(
|
|||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::{DEFAULT_STALE_AFTER, Freshness, Offered, STALE_AFTER_ENV, StatusReader, render};
|
||||
use crate::HiveEntry;
|
||||
use crate::routes::hives::HiveEntry;
|
||||
use std::collections::BTreeMap;
|
||||
use std::time::{Duration, SystemTime};
|
||||
|
||||
|
|
|
|||
|
|
@ -37,7 +37,7 @@ use serde::Serialize;
|
|||
use swarm_queue_client::{agent_term, subagent_term};
|
||||
use utoipa::ToSchema;
|
||||
|
||||
use crate::AppState;
|
||||
use crate::routes::AppState;
|
||||
use crate::wanted::WantedWriter;
|
||||
|
||||
/// How often [`spawn`] checks every live agent's stream. Also how long a newly
|
||||
|
|
@ -264,11 +264,11 @@ fn ident(name: &str) -> Result<String, &'static str> {
|
|||
}
|
||||
|
||||
fn bad_request(reason: &str) -> problem_details::ProblemDetails {
|
||||
crate::error_problem(StatusCode::BAD_REQUEST, reason)
|
||||
crate::routes::error_problem(StatusCode::BAD_REQUEST, reason)
|
||||
}
|
||||
|
||||
fn unavailable(detail: &str) -> problem_details::ProblemDetails {
|
||||
crate::error_problem(StatusCode::SERVICE_UNAVAILABLE, detail)
|
||||
crate::routes::error_problem(StatusCode::SERVICE_UNAVAILABLE, detail)
|
||||
}
|
||||
|
||||
fn is_stream_not_found(e: &async_nats::jetstream::context::GetStreamError) -> bool {
|
||||
|
|
@ -282,7 +282,7 @@ fn is_stream_not_found(e: &async_nats::jetstream::context::GetStreamError) -> bo
|
|||
fn read_failed(agent: &str, e: &dyn std::error::Error) -> problem_details::ProblemDetails {
|
||||
let detail = swarm_queue_client::chain(e);
|
||||
tracing::warn!(%agent, error = %detail, "listing subagents: reading the stream failed");
|
||||
crate::error_problem(StatusCode::INTERNAL_SERVER_ERROR, &detail)
|
||||
crate::routes::error_problem(StatusCode::INTERNAL_SERVER_ERROR, &detail)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
|
|
|
|||
|
|
@ -37,7 +37,7 @@ use futures_util::stream::BoxStream;
|
|||
use futures_util::{Stream, StreamExt as _};
|
||||
use swarm_queue_client::agent_term;
|
||||
|
||||
use crate::AppState;
|
||||
use crate::routes::AppState;
|
||||
|
||||
#[utoipa::path(
|
||||
get,
|
||||
|
|
@ -68,19 +68,21 @@ pub(crate) async fn stream_agent_term(
|
|||
headers: HeaderMap,
|
||||
) -> Result<Sse<impl Stream<Item = Result<Event, Infallible>>>, problem_details::ProblemDetails> {
|
||||
let Some(status) = state.status.as_ref() else {
|
||||
return Err(crate::error_problem(
|
||||
return Err(crate::routes::error_problem(
|
||||
axum::http::StatusCode::SERVICE_UNAVAILABLE,
|
||||
"no swarm queue is configured on this host",
|
||||
));
|
||||
};
|
||||
let Some(agent_status) = state.agent_status.as_ref() else {
|
||||
return Err(crate::error_problem(
|
||||
return Err(crate::routes::error_problem(
|
||||
axum::http::StatusCode::SERVICE_UNAVAILABLE,
|
||||
"no agent-status reader is configured on this host",
|
||||
));
|
||||
};
|
||||
let agent = hive_types::Ident::parse(&agent)
|
||||
.map_err(|reason| crate::error_problem(axum::http::StatusCode::BAD_REQUEST, reason))?
|
||||
.map_err(|reason| {
|
||||
crate::routes::error_problem(axum::http::StatusCode::BAD_REQUEST, reason)
|
||||
})?
|
||||
.into_string();
|
||||
|
||||
// One-agent "roster": reuses `AgentStatusReader::view`'s already-tested
|
||||
|
|
@ -92,12 +94,12 @@ pub(crate) async fn stream_agent_term(
|
|||
.map_err(|e| {
|
||||
let detail = format!("{e:#}");
|
||||
tracing::warn!(%agent, error = %detail, "term stream: reading agent status failed");
|
||||
crate::error_problem(axum::http::StatusCode::INTERNAL_SERVER_ERROR, &detail)
|
||||
crate::routes::error_problem(axum::http::StatusCode::INTERNAL_SERVER_ERROR, &detail)
|
||||
})?
|
||||
.into_iter()
|
||||
.find_map(|row| row.hive)
|
||||
.ok_or_else(|| {
|
||||
crate::error_problem(
|
||||
crate::routes::error_problem(
|
||||
axum::http::StatusCode::NOT_FOUND,
|
||||
&format!("{agent:?} has never reported to the swarm"),
|
||||
)
|
||||
|
|
@ -110,7 +112,7 @@ pub(crate) async fn stream_agent_term(
|
|||
// answer is 503, the same shape every other queue-backed route
|
||||
// here already uses.
|
||||
swarm_queue_client::ensure_connected(&client).map_err(|e| {
|
||||
crate::error_problem(
|
||||
crate::routes::error_problem(
|
||||
axum::http::StatusCode::SERVICE_UNAVAILABLE,
|
||||
&swarm_queue_client::chain(&e),
|
||||
)
|
||||
|
|
@ -277,7 +279,7 @@ pub(crate) async fn subscribe(
|
|||
) -> Result<async_nats::Subscriber, problem_details::ProblemDetails> {
|
||||
let subscriber = client.subscribe(subject.clone()).await.map_err(|e| {
|
||||
tracing::warn!(%subject, error = %e, "{what} stream: subscribe failed");
|
||||
crate::error_problem(
|
||||
crate::routes::error_problem(
|
||||
axum::http::StatusCode::INTERNAL_SERVER_ERROR,
|
||||
&format!("subscribing to {subject} failed: {e}"),
|
||||
)
|
||||
|
|
|
|||
|
|
@ -21,7 +21,7 @@ use axum::{
|
|||
use hmac::{Hmac, KeyInit as _, Mac as _};
|
||||
use sha2::Sha256;
|
||||
|
||||
use super::AppState;
|
||||
use crate::routes::AppState;
|
||||
|
||||
/// Where the HMAC secret is kept, relative to the daemon's state directory.
|
||||
///
|
||||
|
|
@ -416,10 +416,10 @@ pub(super) async fn post_webhook_forge(
|
|||
cache.apply_webhook_delivery(&body);
|
||||
}
|
||||
if let Some(merged) = crate::config_pr::merged(&body) {
|
||||
super::queue_merged_config_deploy(&state, merged).await;
|
||||
crate::jobq::queue_merged_config_deploy(&state, merged).await;
|
||||
}
|
||||
if let Some(changed) = crate::config_pr::changed_head(&body) {
|
||||
super::queue_config_build(&state, changed);
|
||||
crate::jobq::queue_config_build(&state, changed);
|
||||
}
|
||||
}
|
||||
DeliveryKind::VcsActivity => {
|
||||
|
|
|
|||
Loading…
Reference in a new issue