`agents.conf` and `gateway.htpasswd` move from /var/lib/hyperhive/gateway to /var/lib/hive-gateway/conf, alongside the `tls/` the gateway already kept there. nginx reads both as an unprivileged user. Under c0re's state dir it could only reach them by traversing a directory systemd re-declares `0750 hive-core` on every c0re start — so nginx was given `SupplementaryGroups = [ "hive-core" ]`, which also handed it read access to everything else group-readable in that tree. The tokens are individually 0600, but the broker sqlite carries no explicit mode: every message between every agent was readable by the process whose job is parsing untrusted network input. Moving the files removes the need and the exposure together. The group is gone, and its absence is now commented as load-bearing so it doesn't come back as a fix for a symptom it would recreate. Also drops this module's `/var/lib/hyperhive` tmpfiles rule. It declared `0755 root root` and could never win against `StateDirectoryMode`, and a losing declaration still reads as a guarantee — that is what sent the first diagnosis of the outage looking for who had changed the mode. Ordering is unchanged and still the thing that makes a fresh boot work: tmpfiles runs before services and seeds both files empty-but-valid, nginx names them (an `include` of a missing file is fatal, not empty), and content arrives when c0re writes and reloads — which it does on every topology change, so a boot against the empty seed resolves itself. Folds in the mode fix: `write` now sets 0644 on the tmp file before the rename, because a rename carries the source's mode and discards the destination's, and the tmpfiles rule that declares 0644 is create-if-absent so it never re-applies.
380 lines
14 KiB
Rust
380 lines
14 KiB
Rust
//! Central host-side state paths under `/var/lib/hyperhive` (and the
|
|
//! `/run/hyperhive` + `/run/hive-agent` runtime roots).
|
|
//!
|
|
//! Historically these were flat string literals scattered across many
|
|
//! modules (`broker.sqlite`, `matrix-admin-token`, `agent-sockets.json`,
|
|
//! …). This module is the **single Rust-side source** for every host
|
|
//! path — the strictly host-side ones grouped into subdirs (`db/`,
|
|
//! `forge/`, `matrix/`, `run/`), plus the **nix-coupled** roots
|
|
//! (`agents/`, `applied/`, `meta/`, `shared/`, `gateway/`, `knowledge/`,
|
|
//! the register/core tokens, the `/run` runtime dirs).
|
|
//!
|
|
//! Nix-coupled paths are NOT excluded (the old stance) — they're moved
|
|
//! here too, each carrying a `// nix: …` comment naming the module /
|
|
//! bind-mount whose literal must stay in lockstep. Centralising the Rust
|
|
//! side (one place to grep, one place to change) and documenting the nix
|
|
//! counterpart is strictly better than scattering the literals to avoid
|
|
//! drift — the drift risk is the same either way, and the reboot outage
|
|
//! is the argument for a single source. The nix modules keep their own
|
|
//! literals (that's their source of truth; out of scope here).
|
|
//!
|
|
//! [`relocate_legacy_state`] moves any file still at the old flat
|
|
//! location into its new subdir on startup, before the broker db is
|
|
//! opened.
|
|
|
|
use std::path::{Path, PathBuf};
|
|
|
|
// Layout facts shared with the host-side `hivectl` CLI live in
|
|
// `hive-host-sock` (the crate hivectl links instead of the whole daemon);
|
|
// re-exported here so this module stays the daemon's single-source facade for
|
|
// its own callsites.
|
|
pub use hive_host_sock::{AGENTS_ROOT, GATEWAY_HTPASSWD, HOST_SOCKET, agent_state_dir};
|
|
|
|
/// Root of all hive-c0re persistent state.
|
|
// nix: bind-mount source `services.hyperhive.c0re.statePath` (hive-c0re.nix) — must match.
|
|
pub const STATE_ROOT: &str = "/var/lib/hyperhive";
|
|
|
|
/// `/run/hyperhive` — hive-c0re's runtime root (host admin socket, the
|
|
/// per-agent runtime dirs). Regenerated each boot; not persistent state.
|
|
// nix: `RuntimeDirectory=hyperhive` on the hive-c0re service (hive-c0re.nix) — must match.
|
|
// priv-sock: `hive_priv_sock::AGENT_RUNTIME_ROOT` is `RUNTIME_ROOT + "/agents"` and must
|
|
// stay in sync; the privsep boundary prevents importing across the crate.
|
|
pub const RUNTIME_ROOT: &str = "/run/hyperhive";
|
|
|
|
/// Default broker db path (`db/broker.sqlite`). Exposed as a `&str` for
|
|
/// the `--broker-db` clap `default_value`; `build_logs.sqlite` is placed
|
|
/// alongside it (the build-logs store keys off the broker db's parent).
|
|
pub const BROKER_DB: &str = "/var/lib/hyperhive/db/broker.sqlite";
|
|
|
|
#[must_use]
|
|
pub fn state_root() -> PathBuf {
|
|
PathBuf::from(STATE_ROOT)
|
|
}
|
|
|
|
/// `db/` — sqlite databases (broker, build logs).
|
|
#[must_use]
|
|
pub fn db_dir() -> PathBuf {
|
|
state_root().join("db")
|
|
}
|
|
|
|
/// `webhook-secret` — hex-encoded 32-byte HMAC secret shared between
|
|
/// hive-c0re's webhook handlers and the Forgejo webhook registrations.
|
|
/// Generated on first startup and persisted; Forgejo is re-registered
|
|
/// whenever the secret changes.
|
|
#[must_use]
|
|
pub fn webhook_secret_file() -> PathBuf {
|
|
state_root().join("webhook-secret")
|
|
}
|
|
|
|
/// `forge/` — hive-c0re's own forge provisioning markers.
|
|
#[must_use]
|
|
pub fn forge_dir() -> PathBuf {
|
|
state_root().join("forge")
|
|
}
|
|
|
|
/// `forge/core-avatar-set` — marker: core account avatar uploaded.
|
|
#[must_use]
|
|
pub fn forge_core_avatar_marker() -> PathBuf {
|
|
forge_dir().join("core-avatar-set")
|
|
}
|
|
|
|
/// `forge/agent-configs-avatar-set` — marker: agent-configs org avatar set.
|
|
#[must_use]
|
|
pub fn forge_config_org_avatar_marker() -> PathBuf {
|
|
forge_dir().join("agent-configs-avatar-set")
|
|
}
|
|
|
|
/// `forge/email-aligned-<name>` — marker: `<name>`'s forge email aligned.
|
|
#[must_use]
|
|
pub fn forge_email_aligned_marker(name: &str) -> PathBuf {
|
|
forge_dir().join(format!("email-aligned-{name}"))
|
|
}
|
|
|
|
/// `forge/repo-creation-disabled-<name>` — marker: `<name>`'s forge user
|
|
/// has had `max_repo_creation = 0` applied (blocks direct agent-initiated
|
|
/// repo creation). One-shot guard so the PATCH runs once per
|
|
/// agent (including agents provisioned before the change); delete to
|
|
/// re-apply.
|
|
#[must_use]
|
|
pub fn forge_repo_creation_disabled_marker(name: &str) -> PathBuf {
|
|
forge_dir().join(format!("repo-creation-disabled-{name}"))
|
|
}
|
|
|
|
/// `matrix/` — host-side matrix provisioning state (admin token, hive
|
|
/// Space room id, per-agent password creds). The shared registration
|
|
/// token is bind-mounted into the tuwunel container via nix and stays
|
|
/// at its own path (tracked separately).
|
|
#[must_use]
|
|
pub fn matrix_dir() -> PathBuf {
|
|
state_root().join("matrix")
|
|
}
|
|
|
|
/// `matrix/admin-token` — hive system admin access token.
|
|
#[must_use]
|
|
pub fn matrix_admin_token() -> PathBuf {
|
|
matrix_dir().join("admin-token")
|
|
}
|
|
|
|
/// `matrix/space-room-id` — persisted hive Space room id.
|
|
#[must_use]
|
|
pub fn matrix_space_room_id() -> PathBuf {
|
|
matrix_dir().join("space-room-id")
|
|
}
|
|
|
|
/// `matrix/chat-room-id` — persisted default "hive chat" room id (the
|
|
/// `m.space.child` of the hive Space every agent + the operator can join).
|
|
#[must_use]
|
|
pub fn matrix_chat_room_id() -> PathBuf {
|
|
matrix_dir().join("chat-room-id")
|
|
}
|
|
|
|
/// `matrix/creds/` — per-agent throwaway matrix passwords (survive
|
|
/// `destroy --purge`; agents auth by token, this is recovery only).
|
|
#[must_use]
|
|
pub fn matrix_creds_dir() -> PathBuf {
|
|
matrix_dir().join("creds")
|
|
}
|
|
|
|
/// `run/` — runtime maps hive-c0re regenerates on every meta sync.
|
|
#[must_use]
|
|
pub fn run_dir() -> PathBuf {
|
|
state_root().join("run")
|
|
}
|
|
|
|
/// `run/agent-sockets.json` — name→socket-path map for UDS upstreams.
|
|
#[must_use]
|
|
pub fn agent_sockets_file() -> PathBuf {
|
|
run_dir().join("agent-sockets.json")
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Nix-coupled roots + runtime dirs. Each carries a `// nix:` note naming the
|
|
// module / bind-mount whose literal must stay in lockstep with the value here.
|
|
// The nix modules keep their own literals (their source of truth); this is the
|
|
// single Rust-side source.
|
|
// ---------------------------------------------------------------------------
|
|
|
|
/// `agents/` root (`AGENTS_ROOT`) as an owned `PathBuf`.
|
|
#[must_use]
|
|
pub fn agents_root() -> PathBuf {
|
|
PathBuf::from(AGENTS_ROOT)
|
|
}
|
|
|
|
/// `applied/` — per-agent *applied* (deployed) config repos + rev markers,
|
|
/// distinct from the proposed configs under `agents/<name>/config`.
|
|
// nix: read by hive-c0re only, but paired with `agents/` in the deploy flow.
|
|
#[must_use]
|
|
pub fn applied_root() -> PathBuf {
|
|
state_root().join("applied")
|
|
}
|
|
|
|
/// `applied/<name>` — one agent's applied config working tree.
|
|
#[must_use]
|
|
pub fn applied_dir(name: &str) -> PathBuf {
|
|
applied_root().join(name)
|
|
}
|
|
|
|
/// `applied/.<name>.hyperhive-rev` — marker recording the flake rev an
|
|
/// agent was last successfully rebuilt against (auto-update staleness check).
|
|
#[must_use]
|
|
pub fn applied_rev_marker(name: &str) -> PathBuf {
|
|
applied_root().join(format!(".{name}.hyperhive-rev"))
|
|
}
|
|
|
|
/// `meta/` — the meta flake working tree (inputs, `flake.lock`, `.git`).
|
|
// nix: bind-mounted read-only into agent containers as `/meta` (the harness nix modules) — must match.
|
|
#[must_use]
|
|
pub fn meta_root() -> PathBuf {
|
|
state_root().join("meta")
|
|
}
|
|
|
|
/// `meta/flake.lock` — the meta flake lock (read for input rev display).
|
|
#[must_use]
|
|
pub fn meta_flake_lock() -> PathBuf {
|
|
meta_root().join("flake.lock")
|
|
}
|
|
|
|
/// `meta/.git/index.lock` — git index lock; checked before meta git ops
|
|
/// so a stale lock from a crashed process can be cleared.
|
|
#[must_use]
|
|
pub fn meta_git_index_lock() -> PathBuf {
|
|
meta_root().join(".git/index.lock")
|
|
}
|
|
|
|
/// `shared/` — the cross-agent `/shared` scratch space. A `&str` (the
|
|
/// dashboard state-file allow-list uses it for prefix checks), so it
|
|
/// stays a const; [`shared_root`] wraps it.
|
|
// nix: bind-mounted into every agent container as `/shared` (the harness nix modules) — must match.
|
|
pub const SHARED_ROOT: &str = "/var/lib/hyperhive/shared";
|
|
|
|
#[must_use]
|
|
pub fn shared_root() -> PathBuf {
|
|
PathBuf::from(SHARED_ROOT)
|
|
}
|
|
|
|
/// `knowledge/` — local checkout of the `internal/knowledge` repo. A
|
|
/// `&str` (used as a git `-C` arg / clone target throughout the knowledge
|
|
/// worker), so it stays a const rather than a `PathBuf` fn.
|
|
// nix: bind-mounted read-only into agent containers as `/knowledge` (the harness nix modules) — must match.
|
|
pub const KNOWLEDGE_DIR: &str = "/var/lib/hyperhive/knowledge";
|
|
|
|
/// `/var/lib/hive-gateway/conf` — generated nginx include fragments,
|
|
/// deliberately **outside** `STATE_ROOT`.
|
|
///
|
|
/// nginx runs on the host as an unprivileged user and reads these
|
|
/// directly. Keeping them here rather than under `/var/lib/hyperhive`
|
|
/// means nginx needs no access to c0re's state dir at all — no shared
|
|
/// parent to traverse, so no group membership handing it everything else
|
|
/// that lives there (the broker db above all). The separation IS the
|
|
/// confinement; nothing else is doing that job.
|
|
///
|
|
/// Sibling of the gateway's `tls/`, which already lived here.
|
|
// nix: named by the gateway's nginx config (hive-gateway/vhosts.nix) and created by
|
|
// its tmpfiles rules (hive-gateway/default.nix) — must match.
|
|
pub const GATEWAY_CONF_DIR: &str = "/var/lib/hive-gateway/conf";
|
|
|
|
#[must_use]
|
|
pub fn gateway_dir() -> PathBuf {
|
|
PathBuf::from(GATEWAY_CONF_DIR)
|
|
}
|
|
|
|
/// `gateway/agents.conf` — per-agent nginx `location` blocks (UDS upstreams).
|
|
#[must_use]
|
|
pub fn gateway_agents_conf() -> PathBuf {
|
|
gateway_dir().join("agents.conf")
|
|
}
|
|
|
|
/// `forge-core-token` — the hive-c0re forge account API token. A `&str`
|
|
/// (used in `Path::new` + user-facing `format!` messages), so it stays a
|
|
/// const rather than a `PathBuf` fn.
|
|
// nix: bind-mounted into the forge container / read at provisioning (hive-forge.nix) — must match.
|
|
pub const FORGE_CORE_TOKEN: &str = "/var/lib/hyperhive/forge-core-token";
|
|
|
|
/// `matrix-register-token` — shared matrix registration token.
|
|
// nix: bind-mounted into the tuwunel/matrix container (hive-matrix.nix) — must match.
|
|
#[must_use]
|
|
pub fn matrix_register_token() -> PathBuf {
|
|
state_root().join("matrix-register-token")
|
|
}
|
|
|
|
/// `/run/hyperhive` — the runtime root (host admin socket + per-agent dirs).
|
|
#[must_use]
|
|
pub fn runtime_root() -> PathBuf {
|
|
PathBuf::from(RUNTIME_ROOT)
|
|
}
|
|
|
|
/// `/run/hyperhive/agents` — per-agent runtime dir root (regenerated each boot).
|
|
#[must_use]
|
|
pub fn agent_runtime_root() -> PathBuf {
|
|
runtime_root().join("agents")
|
|
}
|
|
|
|
/// `/run/hyperhive/agents/<name>` — one agent's runtime dir.
|
|
#[must_use]
|
|
pub fn agent_runtime_dir(name: &str) -> PathBuf {
|
|
agent_runtime_root().join(name)
|
|
}
|
|
|
|
/// Move any host-side state file still at its legacy flat location
|
|
/// (directly under [`STATE_ROOT`]) into its new subdir. Idempotent and
|
|
/// rename-based: a move only happens when the old path exists and the
|
|
/// new one doesn't, so re-runs are no-ops.
|
|
///
|
|
/// Must run **before** the broker db is opened (the broker + build-logs
|
|
/// dbs are relocated here). Safe because those dbs use rollback-journal
|
|
/// mode (no `-wal`/`-shm` sidecars after a clean shutdown), and a rename
|
|
/// within the same filesystem is atomic.
|
|
pub fn relocate_legacy_state() {
|
|
let root = state_root();
|
|
let moves: [(&str, PathBuf); 8] = [
|
|
("broker.sqlite", db_dir().join("broker.sqlite")),
|
|
("build_logs.sqlite", db_dir().join("build_logs.sqlite")),
|
|
("forge-core-avatar-set", forge_core_avatar_marker()),
|
|
(
|
|
"forge-agent-configs-avatar-set",
|
|
forge_config_org_avatar_marker(),
|
|
),
|
|
("matrix-admin-token", matrix_admin_token()),
|
|
("matrix-space-room-id", matrix_space_room_id()),
|
|
("matrix-creds", matrix_creds_dir()),
|
|
("agent-sockets.json", agent_sockets_file()),
|
|
];
|
|
for (old_rel, new) in &moves {
|
|
move_if_legacy(&root.join(old_rel), new);
|
|
}
|
|
// `forge-email-aligned-<name>` markers: glob the flat root.
|
|
if let Ok(rd) = std::fs::read_dir(&root) {
|
|
for ent in rd.flatten() {
|
|
if let Some(name) = ent
|
|
.file_name()
|
|
.to_str()
|
|
.and_then(|s| s.strip_prefix("forge-email-aligned-"))
|
|
{
|
|
move_if_legacy(&ent.path(), &forge_email_aligned_marker(name));
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Rename `old` → `new` when `old` exists and `new` doesn't, creating
|
|
/// `new`'s parent dir first. Logs on success / failure; never panics
|
|
/// (a failed relocate must not take the daemon down — worst case the
|
|
/// owning module recreates fresh state at the new path).
|
|
fn move_if_legacy(old: &Path, new: &Path) {
|
|
if !old.exists() || new.exists() {
|
|
return;
|
|
}
|
|
if let Some(parent) = new.parent() {
|
|
let _ = std::fs::create_dir_all(parent);
|
|
}
|
|
match std::fs::rename(old, new) {
|
|
Ok(()) => tracing::info!(
|
|
from = %old.display(),
|
|
to = %new.display(),
|
|
"relocate: moved legacy state into subdir"
|
|
),
|
|
Err(e) => tracing::warn!(
|
|
from = %old.display(),
|
|
to = %new.display(),
|
|
error = ?e,
|
|
"relocate: rename failed; owning module will recreate at the new path"
|
|
),
|
|
}
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
use std::fs;
|
|
|
|
#[test]
|
|
fn move_if_legacy_moves_then_is_idempotent() {
|
|
let tmp = tempfile::tempdir().unwrap();
|
|
let old = tmp.path().join("flat-file");
|
|
let new = tmp.path().join("sub/dir/new-file");
|
|
fs::write(&old, b"payload").unwrap();
|
|
|
|
move_if_legacy(&old, &new);
|
|
assert!(!old.exists(), "old should be gone after move");
|
|
assert_eq!(fs::read(&new).unwrap(), b"payload");
|
|
|
|
// Re-run with old absent → no-op, new untouched.
|
|
move_if_legacy(&old, &new);
|
|
assert_eq!(fs::read(&new).unwrap(), b"payload");
|
|
}
|
|
|
|
#[test]
|
|
fn move_if_legacy_skips_when_new_exists() {
|
|
let tmp = tempfile::tempdir().unwrap();
|
|
let old = tmp.path().join("flat");
|
|
let new = tmp.path().join("sub/new");
|
|
fs::write(&old, b"OLD").unwrap();
|
|
fs::create_dir_all(new.parent().unwrap()).unwrap();
|
|
fs::write(&new, b"NEW").unwrap();
|
|
|
|
// New already present → must NOT overwrite, old left in place.
|
|
move_if_legacy(&old, &new);
|
|
assert_eq!(fs::read(&new).unwrap(), b"NEW");
|
|
assert!(old.exists(), "old left untouched when new exists");
|
|
}
|
|
}
|