hyperhive/swarm-controller/src/main.rs

1330 lines
56 KiB
Rust

//! Swarm-level controller daemon. Runs as the unprivileged
//! `swarm-controller` user on whichever host the operator flips
//! `services.hyperhive.swarm.controller.enable` on, and serves HTTP over a
//! unix socket that the hive-gateway's nginx proxies to.
//!
//! Configuration is read-only and loaded once at startup from env vars the
//! NixOS module sets (`services.hyperhive.swarm.controller`) — see
//! `load_hives`. A config change means a redeploy, same as every other
//! option this process reads.
//!
//! The one thing it does persist is `webhook-secret` under its
//! `StateDirectory` (see `webhook`), because that key is handed to Forgejo
//! at registration and so cannot be regenerated per boot.
//!
//! Distinct from `hive-c0re`, which is per-hive: c0re owns the agents on
//! one host, this owns what is true across hives.
//!
//! `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-gateway/vhosts.nix`'s
//! `swarmUiVhost` — 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::os::unix::fs::PermissionsExt as _;
use std::path::PathBuf;
use std::sync::{Arc, Mutex};
use anyhow::{Context, Result};
use axum::{Json, extract::State, routing::get};
use hive_jobq_wire::GraphWire as _;
use serde::{Deserialize, Serialize};
use utoipa::{OpenApi, ToSchema};
use utoipa_axum::{router::OpenApiRouter, routes};
mod auth;
mod forge;
mod jobq_metrics;
mod status;
mod webhook;
/// Node payload for the swarm-level job graph. Named `Swarm*` rather than
/// the bare `NodeKind`/`Resource` `hive-c0re::job_queue::model` already
/// uses, so a grep for either doesn't land on both crates.
///
/// `CreateIdentity` was the first real variant — "the minimal shape for
/// agent creation is creating the identity and wiring that in" (the design
/// thread's own framing for why that landed before the forge-node work
/// here). `CreateRepo`/`AddRepoMember`/`InitAgentConfigRepo` are three
/// separate nodes rather than one combined "create the repo" step so each
/// is independently retryable/observable in the job graph, same as every
/// other multi-step provisioning flow in this codebase (`hive-c0re`'s own
/// `NodeKind` never folds unrelated forge calls into one node either).
#[derive(Clone, Debug)]
enum SwarmNodeKind {
/// Ensure `agent` exists as an authelia subject at the swarm level.
CreateIdentity { agent: String },
/// Create the agent's repo in `forge::AGENTS_ORG` with the operator
/// merge gate on its default branch. See `forge::Client::create_repo`.
CreateRepo { agent: String },
/// Add `agent` as a write collaborator on its own repo. See
/// `forge::Client::add_repo_member`.
AddRepoMember { agent: String },
/// Seed the agent's repo with `agent.nix` + `flake.nix`. See
/// `forge::Client::seed_agent_config`.
///
/// Deliberately carries no hive: seeding a config repo is the same
/// work whichever hive the agent is bound for, and an agent's config
/// states nothing about where it runs. The hive is an address the
/// swarm routes a deploy message to — it belongs on the node that
/// sends that message, not on this one.
InitAgentConfigRepo { agent: String },
}
impl hive_jobq_wire::WireNode for SwarmNodeKind {
fn label(&self) -> String {
match self {
SwarmNodeKind::CreateIdentity { .. } => "create_identity".to_owned(),
SwarmNodeKind::CreateRepo { .. } => "create_repo".to_owned(),
SwarmNodeKind::AddRepoMember { .. } => "add_repo_member".to_owned(),
SwarmNodeKind::InitAgentConfigRepo { .. } => "init_agent_config_repo".to_owned(),
}
}
fn data(&self, _id: hive_jobq_wire::WireId) -> serde_json::Value {
// Every node in this graph is about exactly one agent, so they all
// render the same and `label()` is what distinguishes them. Spelled
// out as an or-pattern rather than a catch-all on purpose: a fifth
// variant then fails to compile here instead of silently rendering
// as an agent name.
match self {
SwarmNodeKind::CreateIdentity { agent }
| SwarmNodeKind::CreateRepo { agent }
| SwarmNodeKind::AddRepoMember { agent }
| SwarmNodeKind::InitAgentConfigRepo { agent } => {
serde_json::json!({ "agent": agent })
}
}
}
}
/// Placeholder resource name — same rationale and same "no variants until a
/// real node needs one" shape as [`SwarmNodeKind`].
#[derive(Clone, Debug, PartialEq, Eq, Hash)]
enum SwarmResourceKind {}
impl hive_jobq_wire::WireResource for SwarmResourceKind {
fn name(&self) -> String {
match *self {}
}
}
/// Everything a claimed node's executor arm might need to reach outside
/// this process — bundled into one `Clone` struct rather than growing
/// `run_swarm_node`'s parameter list per node kind (three forge-shaped
/// node kinds landed in one slice; a fourth parameter each would have made
/// the signature the least readable part of this file). Each field is
/// built once at startup (see `main`) and is `None` exactly when that
/// dependency isn't configured on this host — every arm below treats
/// absence as *this node's* failure, not a reason to skip silently.
#[derive(Clone)]
struct WorkerDeps {
auth: Option<std::sync::Arc<auth::AuthBridge>>,
forge: Option<std::sync::Arc<forge::Client>>,
}
/// Run a claimed node's actual work. Mirrors `hive-c0re/src/job_queue/
/// exec.rs::run_node`'s role exactly — the one place a `SwarmNodeKind`
/// variant turns into a real effect.
async fn run_swarm_node(
_id: hive_jobq::NodeId,
kind: SwarmNodeKind,
builder: hive_jobq::builder::JobBuilder<SwarmNodeKind, SwarmResourceKind>,
deps: WorkerDeps,
) -> (
hive_jobq::builder::JobBuilder<SwarmNodeKind, SwarmResourceKind>,
hive_jobq::scheduler::Outcome,
) {
use hive_jobq::scheduler::Outcome;
let outcome = match kind {
SwarmNodeKind::CreateIdentity { agent } => match deps.auth {
None => {
Outcome::Failed("no swarm-authelia-bridge is configured on this host".to_owned())
}
Some(bridge) => match bridge.ensure_agent_identity(&agent).await {
Ok(_) => Outcome::Done,
Err(e) => Outcome::Failed(format!("{e:#}")),
},
},
SwarmNodeKind::CreateRepo { agent } => match deps.forge {
None => Outcome::Failed(
"no forge configured on this host (SWARM_CONTROLLER_FORGE_URL / \
SWARM_CONTROLLER_FORGE_TOKEN_FILE unset)"
.to_owned(),
),
Some(client) => match client.create_repo(&agent).await {
Ok(full_name) => {
tracing::info!(%full_name, "swarm jobq: create_repo done");
Outcome::Done
}
Err(e) => Outcome::Failed(format!("{e:#}")),
},
},
SwarmNodeKind::AddRepoMember { agent } => match deps.forge {
None => Outcome::Failed(
"no forge configured on this host (SWARM_CONTROLLER_FORGE_URL / \
SWARM_CONTROLLER_FORGE_TOKEN_FILE unset)"
.to_owned(),
),
Some(client) => match client.add_repo_member(&agent, &agent).await {
Ok(()) => Outcome::Done,
Err(e) => Outcome::Failed(format!("{e:#}")),
},
},
SwarmNodeKind::InitAgentConfigRepo { agent } => match deps.forge {
None => Outcome::Failed(
"no forge configured on this host (SWARM_CONTROLLER_FORGE_URL / \
SWARM_CONTROLLER_FORGE_TOKEN_FILE unset)"
.to_owned(),
),
Some(client) => match client.seed_agent_config(&agent, &agent).await {
Ok(()) => Outcome::Done,
Err(e) => Outcome::Failed(format!("{e:#}")),
},
},
};
(builder, outcome)
}
/// Spawn the swarm-level job-graph scheduler loop. Mirrors `hive-c0re/src/
/// job_queue/scheduler.rs::run_worker`'s shape: claim one runnable node,
/// spawn the future that runs + completes it, loop again immediately if
/// something started (more may now be runnable), otherwise back off
/// briefly before re-polling.
///
/// No shutdown signal to wire in — unlike `hive-c0re`'s `coord.shutdown_rx()`,
/// this daemon has no graceful-shutdown machinery at all yet (`main`'s
/// `axum::serve` runs unconditionally to process exit), so this loop
/// matches that: it rides the runtime down with the process, same as
/// every in-flight HTTP request does.
///
/// Cheap to run with an empty graph: `claim_next` on a graph nothing was
/// ever inserted into just returns `None` every poll.
///
/// `deps` is cloned per iteration (its fields are `Arc` clones, not
/// reconnects) and moved into the closure `claim_next` takes ownership
/// of — `run_swarm_node` needs its own owned copy since the claimed
/// future may outlive this loop iteration.
fn spawn_jobq_worker(
sched: Arc<Mutex<hive_jobq::scheduler::Scheduler<SwarmNodeKind, SwarmResourceKind>>>,
deps: WorkerDeps,
) {
tokio::spawn(async move {
loop {
let deps = deps.clone();
let runner =
hive_jobq::scheduler::Scheduler::claim_next(&sched, move |id, kind, builder| {
run_swarm_node(id, kind, builder, deps)
});
match runner {
Some(runner) => {
tokio::spawn(async move {
let (id, grew) = runner.await;
if let Err(e) = grew {
tracing::warn!(
node = id.get(),
error = %e,
"swarm jobq: grown job rejected"
);
}
});
// Something just started — more may be runnable right
// now, so loop again immediately rather than sleeping.
}
None => {
// Nothing runnable. Bounded poll rather than an event
// wake (unlike hive-c0re's `notify.notify_one()`,
// there is no completion-signal channel here yet) —
// fine at this daemon's scale (one graph, no
// submitters yet); revisit if/when that stops holding.
tokio::time::sleep(std::time::Duration::from_millis(200)).await;
}
}
}
});
}
/// 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";
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"),
)
)]
struct ApiDoc;
/// 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"
)]
async fn health() -> &'static str {
concat!("swarm-controller ", env!("CARGO_PKG_VERSION"), "\n")
}
/// One hive in the swarm's directory — the same `name`/`domain` pair
/// `services.hyperhive.swarm.hives` (nix/host-modules/swarm.nix) declares,
/// 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 two fields.
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, ToSchema)]
struct HiveEntry {
name: String,
domain: String,
}
#[derive(Clone)]
struct AppState {
/// Loaded once at startup (`load_hives`); never mutated, so an
/// `Arc` clone per request is the whole synchronization story.
hives: Arc<Vec<HiveEntry>>,
/// Loaded once at startup (`load_links`); same synchronization story
/// as `hives`.
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.
status: Option<Arc<status::StatusReader>>,
/// 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.
/// **Not** consulted by `POST /api/agents` — that endpoint only ever
/// queues the job (see [`create_agent`]); whether a bridge is
/// configured is `run_swarm_node`'s concern (it holds its own clone,
/// handed to it by `spawn_jobq_worker`), not this handler's. A request
/// still queues cleanly on a bridge-less host, then fails loud once
/// claimed — same "queue now, fail per-job" shape as an unreachable
/// swarm queue.
jobq: Arc<Mutex<hive_jobq::scheduler::Scheduler<SwarmNodeKind, SwarmResourceKind>>>,
/// 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 [`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`.
webhook_secret: Option<Arc<str>>,
/// 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.
swarm_name: Option<Arc<str>>,
}
/// 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
/// (`services.hyperhive.swarm.peerHives`, which other consumers use).
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`.
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()
}
}
}
/// 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"
)]
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)]
struct ServiceLink {
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.
icon: String,
url: String,
}
/// 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.
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()
}
}
}
/// 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"
)]
async fn get_links(State(state): State<AppState>) -> Json<Vec<ServiceLink>> {
Json((*state.links).clone())
}
/// 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.
fn load_swarm_name() -> Option<String> {
std::env::var(NAME_ENV).ok()
}
/// Body of `GET /api/swarm`.
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, ToSchema)]
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"
)]
async fn get_swarm_info(State(state): State<AppState>) -> Json<SwarmInfo> {
Json(SwarmInfo {
name: state.swarm_name.as_deref().map(str::to_owned),
})
}
/// 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.
struct StatusUnavailable(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/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.
fn error_problem(status: axum::http::StatusCode, detail: &str) -> problem_details::ProblemDetails {
problem_details::ProblemDetails::from_status_code(status).with_detail(detail)
}
/// 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"
)]
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))
}
}
}
/// Body of `POST /api/agents` — the agent name to create, and the hive the
/// creation is aimed at. The repo name inside `forge::AGENTS_ORG` is the
/// same string as `name`: one repo per agent, named after it, same
/// convention `hive-c0re::forge` already uses for its own single-hive
/// `CreateRepo` path.
///
/// `hive` is an **address**, not an attribute of the agent: it is where a
/// deploy message goes over the queue. Nothing here writes it into the
/// agent's config — a config naming its own hive would be a second
/// statement of where the agent lives, free to drift from the queue that
/// actually delivers to it.
///
/// It is required rather than optional because it is only knowable here,
/// at creation, from the operator making the choice. An optional field
/// would have been the friendlier migration and would have left every
/// creation in the state this endpoint exists to avoid — one nothing can
/// address.
///
/// Validated here and carried no further: none of the nodes this endpoint
/// queues talks to a hive, so none of them needs the address. It reaches
/// its consumer when the node that *sends* a deploy message exists, and
/// that node takes it from this field. Settling the request shape now is
/// the point — it is the breaking half, and doing it once is cheaper for
/// every caller than doing it again later.
#[derive(Clone, Debug, Deserialize, ToSchema)]
struct CreateAgentRequest {
name: String,
hive: String,
}
/// Where the queued job landed — a caller polls `/api/jobq/graph` (or
/// `?states=`) with this id to watch it settle, same as every other job
/// kind this daemon will ever queue.
#[derive(Clone, Debug, Serialize, ToSchema)]
struct CreateAgentResponse {
node_id: u64,
}
/// Queue the whole agent-creation job graph for `name` — `CreateIdentity`
/// then, once that succeeds, `CreateRepo`; once THAT succeeds,
/// `AddRepoMember` and `InitAgentConfigRepo` both run off it — a fan-out,
/// not a chain, since adding a collaborator and seeding config files are
/// independent operations against the same already-created, already-
/// protected repo and have no ordering requirement on each other (mara,
/// design review: `InitAgentConfigRepo` does not depend on
/// `AddRepoMember` — it's a jobq graph, not a linear chain). Returns as
/// soon as the graph is inserted — **not** once any of it has run; `run_swarm_node` does that
/// work asynchronously off the scheduler loop already running
/// (`spawn_jobq_worker`), same as every other node kind. The response
/// reports `CreateIdentity`'s id, the graph's entry point — a caller
/// watches the whole thing settle via `/api/jobq/graph`, which serves
/// every root, not just this one.
///
/// This endpoint is also the first genuine non-test caller all four
/// `SwarmNodeKind` variants have: each node kind's `dead_code` bound is
/// the whole reason the executor arm and this endpoint had to land in the
/// same change as the variant, not as a follow-up.
///
/// `name` is validated with [`hive_types::Ident::parse`] before it becomes
/// `agent`/`repo` anywhere downstream — not the *only* gate (`CreateIdentity`
/// runs first and validates server-side too), but the upstream check is a
/// few hops removed from where an unvalidated name would do damage
/// (`forge::seed_agent_config` interpolates `agent` into a nix comment
/// line). Flagged in review as safe today but fragile if a second caller
/// of these nodes ever appears; validating here closes it locally.
#[utoipa::path(
post,
path = "/api/agents",
request_body = CreateAgentRequest,
responses(
(status = 200, description = "job chain queued", body = CreateAgentResponse),
(status = 400, description = "`name` or `hive` is not a valid identifier, or `hive` is not in this swarm (problem+json)", body = String),
(status = 500, description = "the job chain could not be queued (problem+json)", body = String),
),
tag = "agents"
)]
async fn create_agent(
State(state): State<AppState>,
Json(req): Json<CreateAgentRequest>,
) -> Result<Json<CreateAgentResponse>, problem_details::ProblemDetails> {
let agent = hive_types::Ident::parse(&req.name)
.map_err(|reason| error_problem(axum::http::StatusCode::BAD_REQUEST, reason))?
.into_string();
// Two checks, and the second is the one that makes the field worth
// having: `Ident::parse` says the string is *shaped* like a hive name,
// and the roster says it *is* one. Without the roster check a typo is
// accepted, the agent gets created, and nothing notices until a deploy
// message is addressed to a hive that does not exist — by which point
// the operator who could have corrected it in one keystroke is gone.
//
// `state.hives` is the swarm directory loaded from `SWARM_CONTROLLER_HIVES`
// at startup and never mutated, so this is a scan of a handful of
// entries against a value an operator just chose.
let hive = hive_types::Ident::parse(&req.hive)
.map_err(|reason| error_problem(axum::http::StatusCode::BAD_REQUEST, reason))?
.into_string();
if !state.hives.iter().any(|h| h.name == hive) {
let known: Vec<&str> = state.hives.iter().map(|h| h.name.as_str()).collect();
// Name the hives that *would* work: the caller is an operator who
// just picked one, and "not in this swarm" without the roster
// leaves them guessing at a typo they cannot see.
let known = if known.is_empty() {
"(none configured)".to_owned()
} else {
known.join(", ")
};
let detail = format!("hive {hive:?} is not in this swarm — known hives: {known}");
return Err(error_problem(axum::http::StatusCode::BAD_REQUEST, &detail));
}
let mut sched = state
.jobq
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner);
let ids = sched
.insert_job(None, |b| {
let create_identity = b.node(SwarmNodeKind::CreateIdentity {
agent: agent.clone(),
});
let create_repo = b
.node(SwarmNodeKind::CreateRepo {
agent: agent.clone(),
})
.after_ok(create_identity);
// Both fan out from `create_repo` directly — independent
// operations on the same repo, no ordering requirement on
// each other (see the doc comment above).
let _add_repo_member = b
.node(SwarmNodeKind::AddRepoMember {
agent: agent.clone(),
})
.after_ok(create_repo);
let _init_config = b
.node(SwarmNodeKind::InitAgentConfigRepo { agent })
.after_ok(create_repo);
vec![create_identity.guid()]
})
.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(CreateAgentResponse { node_id: id.get() }))
}
/// Query params for `GET /api/jobq/graph` — `?states=` narrows to root
/// groups in the named states, same shape `hive_jobq_wire::parse_states`
/// parses.
#[derive(Deserialize, utoipa::IntoParams)]
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"
)]
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"
)]
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))
}
/// 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.
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. Registration is idempotent, so the next restart retries.
///
/// Silently does nothing when any of the three preconditions is missing —
/// each is a legitimate deployment shape (no forge here, no state directory
/// to hold a secret, no public vhost), and each is already logged where it
/// is discovered.
fn register_swarm_webhooks(forge: Option<Arc<forge::Client>>, secret: Option<Arc<str>>) {
let Some(forge) = forge else { return };
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 {
if let Err(e) = forge.ensure_swarm_webhooks(&public_url, &secret).await {
tracing::warn!(
error = %format!("{e:#}"),
"registering swarm-wide forge webhooks failed; retrying on 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
/// a half-set environment — `QueueConfig::from_env` refuses that, because
/// silently behaving like an unconfigured host is how every hive ends up
/// reading `never_reported` with nothing to point at.
async fn connect_status_reader() -> Result<Option<Arc<status::StatusReader>>> {
let Some(cfg) = swarm_queue_client::QueueConfig::from_env("SWARM_CONTROLLER")? 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)
}
}
}
#[tokio::main]
async fn main() -> Result<()> {
tracing_subscriber::fmt()
.with_env_filter(
tracing_subscriber::EnvFilter::try_from_default_env()
.unwrap_or_else(|_| tracing_subscriber::EnvFilter::new("info")),
)
.init();
let path = socket_path();
// `RuntimeDirectoryPreserve=yes` keeps the directory across a restart,
// so a socket file from the previous run can outlive the process that
// owned it and `bind` would fail with EADDRINUSE. Unlinking a stale
// socket is safe precisely because the directory is ours alone: nothing
// else can have put a file at this path.
if let Err(e) = std::fs::remove_file(&path)
&& e.kind() != std::io::ErrorKind::NotFound
{
return Err(e).with_context(|| format!("clearing stale socket at {}", path.display()));
}
let listener = tokio::net::UnixListener::bind(&path)
.with_context(|| format!("binding {}", path.display()))?;
// `bind` leaves the socket 0755, and connecting needs write — the
// gateway's nginx is a different user, so it would be locked out.
// 0666 matches how hive-c0re publishes the per-agent sockets
// (`socket_server::start`), and rests on the same argument: **the
// containing directory is the access control, not the socket mode.**
// This directory holds one socket and is bind-mounted into exactly
// one container. That is also why it must not be shared with
// hive-c0re's `/run/hyperhive` — with a 0666 socket, a directory
// that carries more than it should is the whole vulnerability.
std::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o666))
.with_context(|| format!("chmod {}", path.display()))?;
tracing::info!(socket = %path.display(), "swarm-controller listening");
let status = connect_status_reader().await?;
// Same "not fatal, log and carry on" shape as the queue connect above:
// a controller with no bridge wired up still serves everything else,
// and `run_swarm_node` gives an honest per-job failure instead of this
// fn refusing to start.
let auth = match auth::AuthBridge::from_env() {
Ok(auth) => auth.map(Arc::new),
Err(e) => {
tracing::warn!(error = %format!("{e:#}"), "swarm-authelia-bridge misconfigured; agent identity creation is off");
None
}
};
// Same shape again: a controller with no forge configured still serves
// everything else, and the forge-shaped node kinds give an honest
// per-job failure rather than this fn refusing to start.
let forge_client = match forge::Client::from_env() {
Ok(client) => client.map(Arc::new),
Err(e) => {
tracing::warn!(error = %format!("{e:#}"), "forge misconfigured; repo provisioning is off");
None
}
};
let deps = WorkerDeps {
auth,
forge: forge_client.clone(),
};
let jobq = Arc::new(Mutex::new(hive_jobq::scheduler::Scheduler::new(
hive_jobq::Graph::new(),
hive_jobq::resources::ResourceTable::new(),
)));
spawn_jobq_worker(Arc::clone(&jobq), deps);
// Bound to a named variable, not `_` — dropping the provider stops its
// `PeriodicReader`, so it must live as long as `main` does (which it
// does here: this binding never goes out of scope before the process
// exits via `axum::serve(...).await` below).
let _jobq_metrics_provider = jobq_metrics::spawn_exporter(Arc::clone(&jobq));
// Same "log and carry on" shape as the queue/bridge/forge wiring above.
// 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.
let webhook_secret = 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
}
};
register_swarm_webhooks(forge_client, webhook_secret.clone());
let state = AppState {
hives: Arc::new(load_hives()),
links: Arc::new(load_links()),
status,
jobq,
webhook_secret,
swarm_name: load_swarm_name().map(Arc::from),
};
let (router, api) = OpenApiRouter::<AppState>::with_openapi(ApiDoc::openapi())
.routes(routes!(health))
.routes(routes!(get_hives))
.routes(routes!(get_hives_status))
.routes(routes!(get_links))
.routes(routes!(get_swarm_info))
.routes(routes!(get_jobq_graph))
.routes(routes!(get_jobq_rollup))
.routes(routes!(create_agent))
.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`.
let app = router
.route(
"/api/openapi.json",
get(move || async move { Json(api.clone()) }),
)
.with_state(state);
axum::serve(listener, app)
.await
.context("serving swarm-controller")
}
#[cfg(test)]
mod tests {
use super::{
DEFAULT_SOCKET, HIVES_ENV, HiveEntry, LINKS_ENV, NAME_ENV, ServiceLink, StatusUnavailable,
SwarmNodeKind, WorkerDeps, load_hives, load_links, load_swarm_name, run_swarm_node,
};
use std::path::Path;
/// 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 both forge env vars 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 two `forge` env vars 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.
type SharedSched = std::sync::Arc<
std::sync::Mutex<
hive_jobq::scheduler::Scheduler<super::SwarmNodeKind, super::SwarmResourceKind>,
>,
>;
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(),
}]),
links: std::sync::Arc::new(Vec::new()),
status: None,
jobq: std::sync::Arc::clone(&sched),
webhook_secret: None,
swarm_name: None,
};
(state, sched)
}
/// The roster check is the half that makes the recorded hive worth
/// having, so assert it by EFFECT rather than by the message: a hive
/// that is not in this swarm must be refused **before anything is
/// queued**. A version that queued the graph and then complained would
/// satisfy an assertion on the status code alone while still creating
/// the agent — which is the failure this exists to stop.
#[tokio::test]
async fn a_hive_outside_the_roster_is_refused_before_anything_is_queued() {
let (state, sched) = state_with_roster();
let err = super::create_agent(
axum::extract::State(state),
axum::Json(super::CreateAgentRequest {
name: "atlas".to_owned(),
hive: "pr1maa".to_owned(),
}),
)
.await
.expect_err("a hive outside the roster must be refused");
// Names what would have worked: an operator who just mistyped a
// hive cannot see the roster from the error otherwise.
let rendered = format!("{err:?}");
assert!(
rendered.contains("pr1ma"),
"the refusal should name the known hives, got: {rendered}"
);
let queued = sched
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner)
.graph()
.nodes()
.count();
assert_eq!(queued, 0, "a refused creation must queue no work");
}
/// The control for the arm above: the same call with a hive that IS in
/// the roster gets through and queues the graph. Without this, "refused"
/// could equally mean the endpoint refuses everything.
#[tokio::test]
async fn a_hive_in_the_roster_is_accepted_and_queues_the_graph() {
let (state, sched) = state_with_roster();
// The response is bound rather than asserted on: node ids start at
// zero, so every property I reached for first ("> 0") was a claim
// about the id allocator rather than about this endpoint. What the
// accept arm is actually for is the queue count below.
let _queued = super::create_agent(
axum::extract::State(state),
axum::Json(super::CreateAgentRequest {
name: "atlas".to_owned(),
hive: "pr1ma".to_owned(),
}),
)
.await
.expect("a hive in the roster must be accepted");
let queued = sched
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner)
.graph()
.nodes()
.count();
assert!(queued > 0, "an accepted creation must queue work");
}
#[tokio::test]
async fn create_repo_node_runs_end_to_end_and_fails_without_forge_configured() {
unsafe {
std::env::remove_var("SWARM_CONTROLLER_FORGE_URL");
std::env::remove_var("SWARM_CONTROLLER_FORGE_TOKEN_FILE");
}
let mut sched = hive_jobq::scheduler::Scheduler::new(
hive_jobq::Graph::new(),
hive_jobq::resources::ResourceTable::new(),
);
let id = sched
.append(
SwarmNodeKind::CreateRepo {
agent: "atlas".to_owned(),
},
Vec::new(),
None,
)
.expect("insert");
let sched = std::sync::Arc::new(std::sync::Mutex::new(sched));
let deps = WorkerDeps {
auth: None,
forge: None,
};
let runner =
hive_jobq::scheduler::Scheduler::claim_next(&sched, move |id, kind, builder| {
run_swarm_node(id, kind, builder, deps)
})
.expect("the node just inserted is runnable");
runner
.await
.1
.expect("no growth declared, nothing to reject");
let guard = sched.lock().unwrap();
let node = guard.graph().node(id).expect("node still present");
assert_eq!(node.state, hive_jobq::State::Failed);
assert!(
node.error.as_deref().unwrap_or_default().contains("forge"),
"expected a forge-not-configured error, got {:?}",
node.error
);
}
/// 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"},{"name":"umbra","domain":"umbra.example.com"}]"#,
);
}
assert_eq!(
load_hives(),
vec![
HiveEntry {
name: "pr1ma".to_string(),
domain: "pr1ma.example.com".to_string(),
},
HiveEntry {
name: "umbra".to_string(),
domain: "umbra.example.com".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);
}
}
}