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:
parent
5235166bb3
commit
898dde7402
5 changed files with 107 additions and 0 deletions
11
Cargo.lock
generated
11
Cargo.lock
generated
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -21,6 +21,7 @@ members = [
|
|||
"hive-sock-client",
|
||||
"hive-types",
|
||||
"hivectl",
|
||||
"swarm-controller",
|
||||
]
|
||||
|
||||
[workspace.package]
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
18
swarm-controller/Cargo.toml
Normal file
18
swarm-controller/Cargo.toml
Normal 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
|
||||
76
swarm-controller/src/main.rs
Normal file
76
swarm-controller/src/main.rs
Normal 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")
|
||||
}
|
||||
Loading…
Reference in a new issue