Phase 4 (repoint every container onto `meta#<n>`) and phase 5 (rename the `root` container to `h-root`) were marker-guarded one-shots for layouts no live hive still has: containers are rendered onto `meta#<n>` at creation, and the `h-` prefix has been the naming for far longer than any deployment predates. A one-shot nobody can still trigger is dead weight, so both are gone along with `repoint_container`, `rename_manager_container`, `CONTAINER_TIMEOUT` and the two marker paths. Phase 6 was not obsolete, only misplaced. Ruth's tool groups are now seeded by `ensure_root_agent` on the one path that creates her, rather than re-asserted on every hive-c0re boot. The skip-if-already-set guard survives the move: a destroy+recreate under the same name must not reset an operator's chosen group set back to MANAGER_DEFAULT. That also settles a latent bug. Phase 4's marker check was a `return`, not a skip, so on any hive carrying the marker phases 5 and 6 never ran at all — the tool-group backfill, whose whole job was preventing a silent privilege downgrade, has not executed here in a long time. Moving it to create-time removes the question rather than answering it. What stays is convergence: three unguarded, idempotent phases that re-run each boot and no-op once their state is right. The module doc now names the three categories so the next person can tell which kind they're adding.
371 lines
14 KiB
Rust
371 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";
|
|
|
|
/// `gateway/` — generated nginx include fragments for the gateway vhost.
|
|
/// The gateway container bind-mounts *this subdir only* (not the whole
|
|
/// state root) at `/run/hive-state/`, so nginx can read `agents.conf`
|
|
/// without the rest of `/var/lib/hyperhive/` (forge/matrix tokens, etc.)
|
|
/// being exposed to the gateway container.
|
|
// nix: bind-mounted into the gateway container (hive-gateway.nix) — must match.
|
|
#[must_use]
|
|
pub fn gateway_dir() -> PathBuf {
|
|
state_root().join("gateway")
|
|
}
|
|
|
|
/// `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");
|
|
}
|
|
}
|