203 lines
7.9 KiB
Rust
203 lines
7.9 KiB
Rust
//! Startup convergence. Two phases, all idempotent and unguarded:
|
|
//! harness files and the agent-set files, plus the
|
|
//! removal of the legacy meta repo's leftovers. They re-run every
|
|
//! boot on purpose — each one is a no-op once its state is already
|
|
//! correct.
|
|
//!
|
|
//! Deliberately *not* here, and the distinction is the point:
|
|
//!
|
|
//! - **One-shot, marker-guarded migrations.** Two used to live here
|
|
//! (repointing containers onto the meta flake, renaming `root` to
|
|
//! `h-root`); both targeted layouts no live hive still has. Add one
|
|
//! only if it cannot be expressed as convergence, and expect to delete
|
|
//! it once every hive has passed it.
|
|
//! - **Create-time setup.** A thing that is true from birth is set where
|
|
//! the agent is created, not re-asserted each morning: ruth's
|
|
//! `manage_root_agent`, for one, is stored in the swarm's
|
|
//! `agent-permissions` bucket when the swarm UI creates her.
|
|
//!
|
|
//! Kill-switch: `HIVE_SKIP_META_MIGRATION=1`. Full sequence and phase
|
|
//! details: `docs/agent-lifecycle/config-changes.md::Startup migrations (older hosts)`.
|
|
|
|
use std::path::Path;
|
|
use std::time::Duration;
|
|
|
|
use anyhow::{Context, Result};
|
|
|
|
use crate::coordinator::Coordinator;
|
|
use crate::lifecycle::{self, AGENT_PREFIX};
|
|
|
|
const KILL_SWITCH: &str = "HIVE_SKIP_META_MIGRATION";
|
|
|
|
/// Timeout for the shellout in the blocking startup convergence. `run` is
|
|
/// awaited *before* the daemon starts serving (main.rs), so any child
|
|
/// process that wedges here freezes the whole daemon — admin socket +
|
|
/// dashboard included — with no diagnostics: a git shellout was observed
|
|
/// blocked for 86min under a concurrent `nixos-rebuild`. The shellout
|
|
/// runs under a timeout, so a stuck phase degrades to a logged warning
|
|
/// instead of a hung boot.
|
|
const GIT_TIMEOUT: Duration = Duration::from_mins(2);
|
|
|
|
pub async fn run() -> Result<()> {
|
|
if std::env::var(KILL_SWITCH).is_ok() {
|
|
tracing::info!("migration: {KILL_SWITCH} set — skipping");
|
|
return Ok(());
|
|
}
|
|
|
|
let names = match enumerate_agents().await {
|
|
Ok(names) => names,
|
|
Err(e) => {
|
|
tracing::warn!(error = ?e, "migration: container list failed; skipping per-agent phases");
|
|
Vec::new()
|
|
}
|
|
};
|
|
tracing::info!(count = names.len(), "migration: scanning");
|
|
|
|
// Phase 0: move harness-owned files out of state/ into harness/.
|
|
// Idempotent — rename is a no-op if the source doesn't exist and
|
|
// the destination already does.
|
|
tracing::debug!("migration: phase 0 (harness files)");
|
|
for name in &names {
|
|
migrate_harness_files(name);
|
|
}
|
|
|
|
match remove_core_token(&crate::paths::state_root()) {
|
|
Ok(true) => tracing::info!("migration: removed the unread forge-core-token"),
|
|
Ok(false) => {}
|
|
Err(e) => tracing::warn!(error = ?e, "migration: removing the forge-core-token failed"),
|
|
}
|
|
match remove_legacy_meta_git(&crate::paths::meta_root()) {
|
|
Ok(true) => tracing::info!("migration: removed the legacy meta repo's .git"),
|
|
Ok(false) => {}
|
|
Err(e) => tracing::warn!(error = ?e, "migration: removing the legacy meta .git failed"),
|
|
}
|
|
|
|
// Phase 1: the files that follow the agent set.
|
|
tracing::debug!("migration: phase 1 (agent_set refresh)");
|
|
// `refresh` takes the agents it is given as the whole set, so an empty
|
|
// stand-in for an unreadable list would drop every agent from the topology.
|
|
let agents = match lifecycle::agents_for_meta_listing().await {
|
|
Ok(agents) => agents,
|
|
Err(e) => {
|
|
tracing::warn!(error = ?e, "migration: container list failed; skipping agent_set refresh");
|
|
return Ok(());
|
|
}
|
|
};
|
|
match tokio::time::timeout(GIT_TIMEOUT, crate::agent_set::refresh(&agents)).await {
|
|
Ok(Err(e)) => tracing::warn!(error = ?e, "migration: agent_set refresh failed"),
|
|
Err(_) => {
|
|
tracing::warn!(timeout = ?GIT_TIMEOUT, "migration: agent_set refresh timed out — skipping");
|
|
}
|
|
Ok(Ok(())) => {}
|
|
}
|
|
|
|
Ok(())
|
|
}
|
|
|
|
/// Move harness-owned sqlite/config files out of the agent-visible state dir
|
|
/// and into the sibling harness dir. Best-effort: logs warnings but never
|
|
/// fails. Idempotent — each file is only moved if present at the old path
|
|
/// and absent at the new path.
|
|
fn migrate_harness_files(name: &hive_types::Ident) {
|
|
const HARNESS_FILES: &[&str] = &[
|
|
"hyperhive-events.sqlite",
|
|
"hyperhive-turn-stats.sqlite",
|
|
"hyperhive-model",
|
|
];
|
|
let state_dir = Coordinator::agent_notes_dir(name);
|
|
let harness_dir = Coordinator::agent_harness_dir(name);
|
|
if let Err(e) = std::fs::create_dir_all(&harness_dir) {
|
|
tracing::warn!(%name, error = ?e, "migration: create harness dir failed");
|
|
return;
|
|
}
|
|
for file in HARNESS_FILES {
|
|
let src = state_dir.join(file);
|
|
let dst = harness_dir.join(file);
|
|
if !src.exists() || dst.exists() {
|
|
continue;
|
|
}
|
|
match std::fs::rename(&src, &dst) {
|
|
Ok(()) => tracing::info!(%name, %file, "migration: moved to harness dir"),
|
|
Err(e) => {
|
|
tracing::warn!(%name, %file, error = ?e, "migration: move to harness dir failed");
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Remove the `.git` of the legacy per-hive meta repo from `meta`, leaving
|
|
/// the plain state beside it (`topology.json`, the swarm limits, any
|
|
/// permission seed file not yet imported). `true` when it was there.
|
|
fn remove_legacy_meta_git(meta: &Path) -> Result<bool> {
|
|
let git_dir = meta.join(".git");
|
|
match std::fs::remove_dir_all(&git_dir) {
|
|
Ok(()) => Ok(true),
|
|
Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(false),
|
|
Err(e) => Err(e).with_context(|| format!("removing {}", git_dir.display())),
|
|
}
|
|
}
|
|
|
|
/// Remove the forge admin token file an earlier hive left in `state`; nothing
|
|
/// reads it. `true` when it was there.
|
|
fn remove_core_token(state: &Path) -> Result<bool> {
|
|
let file = state.join("forge-core-token");
|
|
match std::fs::remove_file(&file) {
|
|
Ok(()) => Ok(true),
|
|
Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(false),
|
|
Err(e) => Err(e).with_context(|| format!("removing {}", file.display())),
|
|
}
|
|
}
|
|
|
|
async fn enumerate_agents() -> Result<Vec<hive_types::Ident>> {
|
|
let containers = lifecycle::list().await?;
|
|
Ok(containers
|
|
.into_iter()
|
|
.filter_map(|c| {
|
|
let name = c.strip_prefix(AGENT_PREFIX)?;
|
|
hive_types::Ident::parse(name).ok()
|
|
})
|
|
.collect())
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
|
|
const STATE_FILES: [&str; 3] = [
|
|
"topology.json",
|
|
"swarm-resource-limits.json",
|
|
"capabilities.json",
|
|
];
|
|
|
|
#[test]
|
|
fn legacy_meta_git_goes_and_the_state_beside_it_stays() {
|
|
let dir = tempfile::tempdir().expect("tempdir");
|
|
std::fs::create_dir_all(dir.path().join(".git/objects")).expect("mkdir .git");
|
|
for name in STATE_FILES {
|
|
std::fs::write(dir.path().join(name), "{}").expect("write state");
|
|
}
|
|
|
|
assert!(remove_legacy_meta_git(dir.path()).expect("first"));
|
|
assert!(!dir.path().join(".git").exists());
|
|
for name in STATE_FILES {
|
|
assert!(dir.path().join(name).exists(), "{name} stays");
|
|
}
|
|
assert!(
|
|
!remove_legacy_meta_git(dir.path()).expect("second"),
|
|
"no-op"
|
|
);
|
|
assert!(dir.path().join("topology.json").exists());
|
|
}
|
|
|
|
#[test]
|
|
fn core_token_goes_once_and_its_neighbours_stay() {
|
|
let dir = tempfile::tempdir().expect("tempdir");
|
|
std::fs::write(dir.path().join("forge-core-token"), "x").expect("write token");
|
|
std::fs::write(dir.path().join("forge-other"), "x").expect("write neighbour");
|
|
|
|
assert!(remove_core_token(dir.path()).expect("first"));
|
|
assert!(!dir.path().join("forge-core-token").exists());
|
|
assert!(dir.path().join("forge-other").exists(), "neighbour stays");
|
|
assert!(!remove_core_token(dir.path()).expect("second"), "no-op");
|
|
}
|
|
}
|