From 898dde7402b524fec6064616a0564d4ff6633468 Mon Sep 17 00:00:00 2001 From: atlas Date: Wed, 5 Aug 2026 11:30:54 +0200 Subject: [PATCH] feat(swarm-controller): new crate, a unix-socket listener and nothing else First half of the swarm-controller slice: the crate, its workspace entry and its daemonBins entry, so the systemd unit that follows has a binary to point at. It serves one health endpoint and owns no state. That is the whole intent -- this makes the unit real (service user, runtime and state directories, socket, nginx reachability) so the swarm-level surfaces that follow have somewhere to land. Inventing those surfaces now would bake in a shape nobody has agreed to. The socket gets its own runtime directory rather than sharing hive-c0re's. nginx reaches a unix upstream by having the socket's directory bind-mounted into the gateway container, so co-locating this socket with the host admin socket would hand the gateway that socket too. --- Cargo.lock | 11 ++++++ Cargo.toml | 1 + nix/packages/default.nix | 1 + swarm-controller/Cargo.toml | 18 +++++++++ swarm-controller/src/main.rs | 76 ++++++++++++++++++++++++++++++++++++ 5 files changed, 107 insertions(+) create mode 100644 swarm-controller/Cargo.toml create mode 100644 swarm-controller/src/main.rs diff --git a/Cargo.lock b/Cargo.lock index 22171566..caba39bb 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -4421,6 +4421,17 @@ version = "2.6.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292" +[[package]] +name = "swarm-controller" +version = "0.1.0" +dependencies = [ + "anyhow", + "axum", + "tokio", + "tracing", + "tracing-subscriber", +] + [[package]] name = "syn" version = "1.0.109" diff --git a/Cargo.toml b/Cargo.toml index ea41f781..61341ce6 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -21,6 +21,7 @@ members = [ "hive-sock-client", "hive-types", "hivectl", + "swarm-controller", ] [workspace.package] diff --git a/nix/packages/default.nix b/nix/packages/default.nix index 70c5baa7..d7e3eded 100644 --- a/nix/packages/default.nix +++ b/nix/packages/default.nix @@ -40,6 +40,7 @@ let hive-forge = "hyperhive Forgejo CLI"; hive-forge-notify = "hyperhive per-agent Forgejo notification poller daemon"; hive-github-notify = "hyperhive per-agent github.com notification poller daemon"; + swarm-controller = "hyperhive swarm-level controller daemon"; }; # ONE compile of the whole workspace (every bin, sharing the diff --git a/swarm-controller/Cargo.toml b/swarm-controller/Cargo.toml new file mode 100644 index 00000000..351e028e --- /dev/null +++ b/swarm-controller/Cargo.toml @@ -0,0 +1,18 @@ +[package] +name = "swarm-controller" +version.workspace = true +edition.workspace = true + +[[bin]] +name = "swarm-controller" +path = "src/main.rs" + +[dependencies] +anyhow.workspace = true +axum.workspace = true +tokio.workspace = true +tracing.workspace = true +tracing-subscriber.workspace = true + +[lints] +workspace = true diff --git a/swarm-controller/src/main.rs b/swarm-controller/src/main.rs new file mode 100644 index 00000000..d6230d84 --- /dev/null +++ b/swarm-controller/src/main.rs @@ -0,0 +1,76 @@ +//! 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. +//! +//! **Today it serves one endpoint and owns no state.** That is deliberate: +//! this slice exists to make the *unit* real — service user, runtime and +//! state directories, socket, nginx reachability — so the swarm-level +//! surfaces that follow have somewhere to land. Guessing those surfaces +//! now would bake in a shape nobody has agreed to. +//! +//! Distinct from `hive-c0re`, which is per-hive: c0re owns the agents on +//! one host, this owns what is true across hives. + +use std::path::PathBuf; + +use anyhow::{Context, Result}; +use axum::{Router, routing::get}; + +/// 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`. nginx reaches a unix upstream by having the socket's +/// *directory* bind-mounted into the gateway container, so co-locating +/// this socket with c0re's admin socket would hand the gateway the admin +/// socket along with it. +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) +} + +/// Liveness probe. Returns the build's version so an operator can tell +/// *which* controller answered without shelling onto the host. +async fn health() -> &'static str { + concat!("swarm-controller ", env!("CARGO_PKG_VERSION"), "\n") +} + +#[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()))?; + tracing::info!(socket = %path.display(), "swarm-controller listening"); + + let app = Router::new().route("/health", get(health)); + axum::serve(listener, app) + .await + .context("serving swarm-controller") +}