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.
This commit is contained in:
atlas 2026-08-05 11:30:54 +02:00 committed by mara
commit 898dde7402
5 changed files with 107 additions and 0 deletions

11
Cargo.lock generated
View file

@ -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"

View file

@ -21,6 +21,7 @@ members = [
"hive-sock-client",
"hive-types",
"hivectl",
"swarm-controller",
]
[workspace.package]

View file

@ -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

View file

@ -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

View file

@ -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")
}