Watch
0
0
Fork
You've already forked hyperhive
0
hyperhive/hive-c0re/src/migrate.rs

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");
}
}