Watch
0
0
Fork
You've already forked hyperhive
0
hyperhive/swarm-controller/src/main.rs

241 lines
9.6 KiB
Rust

//! Swarm-level controller daemon. Runs as the unprivileged
//! `swarm-controller` user on whichever host the operator flips
//! `services.hyperhive.deploy.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
//! `startup::load_hives`. A config change means a redeploy, same as every other
//! option this process reads.
//!
//! It persists `webhook-secret` under its `StateDirectory` (see `webhook`),
//! because that key is handed to Forgejo at registration and so cannot be
//! regenerated per boot, and the operator admin sync's granted set beside it
//! (see `matrix_account::operator_admin`).
//!
//! Distinct from `hive-c0re`, which is per-hive: c0re owns the agents on
//! one host, this owns what is true across hives.
use std::os::unix::fs::PermissionsExt as _;
use std::sync::{Arc, Mutex};
use anyhow::{Context, Result};
mod agent_archetype;
mod agent_control;
mod agent_icon;
mod agent_identity;
mod agent_input;
mod agent_limits;
mod agent_login;
mod agent_permissions;
mod agent_renewal;
mod agent_state_stream;
mod agent_status;
mod agents_md;
mod agents_md_initial;
mod alerts;
mod archetypes;
mod auth;
mod builder;
mod claude_login;
mod config_pr;
mod forge;
mod forge_account;
mod github_account;
mod inference;
mod issue_report;
mod jobq;
mod linked_accounts;
mod matrix_account;
mod message_stream;
mod otel_http_client;
mod pulls;
mod queue_identity;
mod read_policy;
mod review_classifier;
mod routes;
mod schedules;
mod startup;
mod status;
mod store;
mod subagent_term;
mod term_stream;
#[cfg(test)]
mod test_nats;
mod vcs_metrics;
mod wanted;
mod webhook;
use jobq::{Late, WorkerDeps, spawn_jobq_worker};
use routes::AppState;
use startup::{
agent_icon_reader, agent_status_reader, agents_md_reader, build_app,
configured_matrix_homeserver, connect_status_reader, load_hives, load_links, load_swarm_name,
load_webhook_secret, reported_inference, socket_path, spawn_agent_renewal,
spawn_matrix_account_backfill, spawn_per_agent_upkeep, start_forge, wanted_writer,
};
#[tokio::main]
async fn main() -> Result<()> {
hive_log::init();
swarm_secret_client::install_crypto_provider();
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.**
// The unit makes the directory `0750`, group `swarm-controller-clients`,
// so only nginx and root reach the socket. The directory holds one
// socket, which 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");
// Before every user of the identity: the queue, the bridge and both OTLP
// exporters read it from `queue_identity::get`.
queue_identity::init().await?;
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 access still serves
// everything else, and the forge-shaped node kinds give an honest
// per-job failure rather than this fn refusing to start. Both slots stay
// empty until `start_forge` below fills them.
let forge_client: Late<Arc<forge::Client>> = Arc::default();
let config_prs: Late<Arc<config_pr::ConfigPrCache>> = Arc::default();
let deps = WorkerDeps {
auth: auth.clone(),
forge: Arc::clone(&forge_client),
queue: status.as_ref().map(|s| s.queue_client()),
wanted: wanted_writer(status.as_ref()),
matrix_homeserver: configured_matrix_homeserver(),
};
let jobq = Arc::new(Mutex::new(hive_jobq::scheduler::Scheduler::new(
hive_jobq::Graph::new(),
hive_jobq::resources::ResourceTable::new(),
)));
let hives = load_hives();
spawn_matrix_account_backfill(&jobq, deps.matrix_homeserver.clone(), &hives, auth.clone());
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).
// Own `AuthenticatedHttpClient` instance, independent of `vcs_metrics`'s
// — both read the same env vars, and a client is cheap enough
// (one `reqwest::blocking::Client`) that sharing one across two
// otherwise-independent exporters isn't worth the plumbing. `Ok` becomes
// `Some`, `Err` becomes `None` — same "log and carry on" shape as the
// rest of this function's optional wiring; a controller that can't
// authenticate its jobq push still serves every other route.
let jobq_http_client = crate::vcs_metrics::authenticated_http_client()
.inspect_err(
|e| tracing::warn!(error = ?e, "otel jobq-metrics: no authenticated http client"),
)
.ok()
.map(|c| Box::new(c) as Box<dyn opentelemetry_http::HttpClient>);
let _jobq_metrics_provider =
hive_jobq_metrics::spawn_exporter(Arc::clone(&jobq), "swarm-controller", jobq_http_client);
let webhook_secret = load_webhook_secret();
// Diagnostic only — `put_matrix_account` does not call this yet (see its
// own doc comment), so this cannot change any request's outcome. Logged
// so an operator can tell the config landed before the slice that
// consults it does.
if matrix_account::homeserver_or_configured_default(
None,
matrix_account::configured_default_homeserver(),
)
.is_none()
{
tracing::debug!(
"{}; matrix-account requests still need their own homeserver",
matrix_account::DEFAULT_HOMESERVER_ENV
);
}
start_forge(
Arc::clone(&forge_client),
Arc::clone(&config_prs),
Arc::clone(&jobq),
hives.clone(),
webhook_secret.clone(),
auth.clone(),
status.as_ref().map(|s| s.queue_client()),
)
.await;
spawn_agent_renewal(&jobq, wanted_writer(status.as_ref()), &hives);
spawn_per_agent_upkeep(status.as_ref(), &hives);
// Before serving, because a hive whose role does not exist cannot log in,
// and one whose policy does not exist logs in able to read nothing —
// either way it cannot collect what this daemon writes for it. A store
// that is not up yet is retried in the background instead of being
// abandoned, since the two of them boot together.
let hive_names: Vec<String> = hives.iter().map(|h| h.name.clone()).collect();
read_policy::ensure_hive_access(hive_names).await;
// Agents keep the policy they were minted with until this rewrites it, so
// what `policy::render_agent` grants today reaches them only from here.
read_policy::ensure_agent_policies().await;
let state = AppState {
hives: Arc::new(hives),
links: Arc::new(load_links()),
wanted: wanted_writer(status.as_ref()),
agent_status: agent_status_reader(status.as_ref()),
agent_icons: agent_icon_reader(status.as_ref()),
agents_md: agents_md_reader(status.as_ref()),
reported_inference: reported_inference(status.as_ref()),
agent_login: startup::agent_login(status.as_ref()),
runs: review_classifier::runs::Runs::Live(status.as_ref().map(|s| s.queue_client())),
builds: builder::runs::Builds::Live(status.as_ref().map(|s| s.queue_client())),
status,
jobq,
webhook_secret,
config_prs,
swarm_name: load_swarm_name().map(Arc::from),
auth,
forge: forge_client,
create_gate: Arc::default(),
accounts: linked_accounts::Accounts::Live,
try_outs: review_classifier::TryOuts::default(),
reviews: Arc::default(),
alerts: alerts::from_env(),
};
axum::serve(listener, build_app(state))
.await
.context("serving swarm-controller")
}