The store path and every identifier around it called this an admin token. It is not one: of ~15 hive-c0re call sites only two need homeserver admin, and the homeserver no longer promotes the account at boot, so the name overstated both what the credential is and what it may do. Renaming it to the account was not enough either. "The `@hive:` token" reads as the token of a hive user, and no such user is provisioned — `@hive:<server_name>` is the appservice registration's own `sender_localpart`, an account the homeserver creates for itself when it loads the registration. So it is the **sender token**: the matrix appservice sender account's access token, at `swarm/services/matrix/sender-token`. The name says what it authenticates as rather than what it may do, which is the part that was wrong. The path has one constructor, and the bao grant, the grant assertion and three unit tests pin its literal independently — so a half-finished rename fails a check rather than leaving the minter and its readers disagreeing at runtime. `tracing` messages are renamed with the code, so the journal reads the way the source does. The host-side file keeps its name (`matrix/access-token`): it carried no admin framing, and renaming it would orphan the file on every deployed hive for nothing. `docs/tools/hivectl-cli.md` is regenerated from the clap tree.
413 lines
16 KiB
Rust
413 lines
16 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-sender-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: the same literal is written in `nix/host-modules/hive-c0re/default.nix` — must match.
|
|
// Not option-driven: there is no `statePath` option, the path is hardcoded both sides.
|
|
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
|
|
// (`nix/host-modules/hive-c0re/default.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}"))
|
|
}
|
|
|
|
/// `agent-identity/` — one subdir per agent holding the store identity this
|
|
/// hive collected for it. Staging only: the files exist so `systemd-nspawn`
|
|
/// has something to `--load-credential` from, and nothing on the host ever
|
|
/// reads them back.
|
|
///
|
|
/// Under the state root rather than `/run` deliberately. The forward happens
|
|
/// when a container's nspawn config is (re)written, and a container can be
|
|
/// restarted long after that; material that vanished with `/run` would leave
|
|
/// a boot where the agent's identity is simply absent and the only symptom is
|
|
/// a unit inside the container refusing to start.
|
|
#[must_use]
|
|
pub fn agent_identity_root() -> PathBuf {
|
|
state_root().join("agent-identity")
|
|
}
|
|
|
|
/// `agent-identity/<name>` — one agent's staged store identity.
|
|
///
|
|
/// ⚠️ Deliberately **not** under `agents/<name>`: that tree is bind-mounted
|
|
/// into the container (and a parent's, for a child), so a private key placed
|
|
/// there would be readable by the agent as a plain file, bypassing the
|
|
/// credential mechanism that exists to control exactly that.
|
|
#[must_use]
|
|
pub fn agent_identity_dir(name: &str) -> PathBuf {
|
|
agent_identity_root().join(name)
|
|
}
|
|
|
|
/// `matrix/` — host-side matrix provisioning state (the appservice sender 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/access-token` — the matrix appservice sender account's access token.
|
|
#[must_use]
|
|
pub fn matrix_sender_token() -> PathBuf {
|
|
matrix_dir().join("access-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: read at provisioning; the same literal is in
|
|
// `nix/host-modules/hive-c0re/default.nix` and `nix/host-modules/hive-ci.nix` — must match.
|
|
pub const FORGE_CORE_TOKEN: &str = "/var/lib/hyperhive/forge-core-token";
|
|
|
|
/// `matrix-appservice-token` — the `as_token` of the hive's appservice
|
|
/// registration, which authorises every account this daemon creates.
|
|
// nix: minted by the `hive-matrix-appservice` activation script in
|
|
// `nix/host-modules/hive-matrix.nix`, which renders it into the registration
|
|
// file the homeserver loads — must match. Read-only here on purpose: a token
|
|
// minted on this side would not be the one in that file.
|
|
#[must_use]
|
|
pub fn matrix_appservice_token() -> PathBuf {
|
|
state_root().join("matrix-appservice-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-sender-token", matrix_sender_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");
|
|
}
|
|
}
|