Watch
0
0
Fork
You've already forked hyperhive
0

deploy the closure the swarm declares, read no forge head, drop the update badge

This commit is contained in:
atlas 2026-11-01 23:56:40 +01:00 • committed by mara
commit 47c2acb640

View file

@ -1,5 +1,5 @@
//! Node executors — one async fn per [`NodeKind`], each a thin wrapper
//! over existing `lifecycle.rs` / `meta_rev.rs` / `actions.rs` code. Node
//! over existing `lifecycle.rs` / `actions.rs` code. Node
//! executors keep their own internal error handling where it exists
//! today (cold-start fallback inside `Reconcile`, warn-only forge sync in
//! the `Swap` tail); DAG-level failure handling is cancel-downstream in
@ -15,7 +15,6 @@ use hive_jobq::NodeId;
use super::model::NodeKind;
use crate::coordinator::{AgentPaths, Coordinator, HiveEnv};
use crate::meta_rev::MetaBuild;
use crate::power::{ReconcileAction, reconcile_action};
use crate::stats::sweep_health::{self, SweepHealth};
@ -35,21 +34,17 @@ pub const GRACEFUL_STOP_TIMEOUT: std::time::Duration = std::time::Duration::from
const PAUSE_ACK_TIMEOUT: std::time::Duration = GRACEFUL_STOP_TIMEOUT;
/// The container-state calls the executors below make on the host: systemd,
/// `hive-priv`, the swarm's closure record, the `internal/meta` record. Production uses
/// [`RealHost`], which forwards to `lifecycle` / `meta_rev` / `Coordinator`
/// `hive-priv`, the swarm's closure record and the marker of the closure applied.
/// Production uses [`RealHost`], which forwards to `lifecycle` / `Coordinator`
/// unchanged; tests substitute a recording fake.
pub(super) trait Host: Sync {
/// Whether the agent's container unit is active.
async fn is_running(&self, name: &str) -> bool;
/// Stop the agent's container.
async fn kill(&self, name: &str) -> Result<()>;
/// The `internal/meta` commit `MetaSync` recorded for the agent.
fn recorded_rev(&self, name: &str) -> Result<String>;
/// The record the swarm's builder wrote for the agent; `None` when it wrote
/// none.
async fn closure_record(&self, name: &str) -> Result<Option<AgentClosure>>;
/// The pins `internal/meta` at the commit `rev` holds for the agent.
async fn pins_at(&self, name: &str, rev: &str) -> Result<MetaBuild>;
/// Download `store_path` from the swarm's cache into the store, never
/// building it.
async fn realise_closure(&self, name: &str, store_path: &str) -> Result<()>;
@ -72,15 +67,8 @@ pub(super) trait Host: Sync {
async fn prepare_rebuild_dirs(&self, name: &str, paths: &AgentPaths) -> Result<()>;
/// Rewrite the host-side files that follow the agent set.
async fn refresh_agent_set(&self) -> Result<()>;
/// Pin the `internal/meta` commit the agent builds from.
async fn resolve_meta(&self, name: &str) -> Result<MetaBuild>;
/// Record `build` as the commit `Prebuild` and `Swap` expect the swarm's
/// closure to be built from.
fn record_meta(&self, name: &str, build: &MetaBuild) -> Result<()>;
/// The build `MetaSync` recorded, if any.
fn recorded_build(&self, name: &str) -> Option<MetaBuild>;
/// Mark the agent as running the pins of `build`.
fn mark_deployed(&self, name: &str, build: &MetaBuild) -> Result<()>;
/// Mark the agent as running the closure at `store_path`.
fn mark_deployed(&self, name: &str, store_path: &str) -> Result<()>;
/// First-spawn provisioning: repos, state subvolume, agent-set refresh.
async fn provision(&self, name: &str, paths: &AgentPaths) -> Result<()>;
/// `nixos-container create` from the closure `toplevel`.
@ -117,18 +105,10 @@ impl Host for RealHost {
crate::lifecycle::kill(name).await
}
fn recorded_rev(&self, name: &str) -> Result<String> {
Ok(crate::meta_rev::recorded_or_err(name)?.rev)
}
async fn closure_record(&self, name: &str) -> Result<Option<AgentClosure>> {
crate::attic::closure::record(name).await
}
async fn pins_at(&self, name: &str, rev: &str) -> Result<MetaBuild> {
crate::meta_rev::resolve_at(name, rev).await
}
async fn realise_closure(&self, name: &str, store_path: &str) -> Result<()> {
crate::priv_client::realise_closure(name, store_path).await
}
@ -165,20 +145,8 @@ impl Host for RealHost {
crate::agent_set::refresh(&agents).await
}
async fn resolve_meta(&self, name: &str) -> Result<MetaBuild> {
crate::meta_rev::resolve(name).await
}
fn record_meta(&self, name: &str, build: &MetaBuild) -> Result<()> {
crate::meta_rev::record(name, build)
}
fn recorded_build(&self, name: &str) -> Option<MetaBuild> {
crate::meta_rev::recorded(name)
}
fn mark_deployed(&self, name: &str, build: &MetaBuild) -> Result<()> {
crate::meta_rev::mark_deployed(name, build)
fn mark_deployed(&self, name: &str, store_path: &str) -> Result<()> {
crate::attic::closure::mark_deployed(name, store_path)
}
async fn provision(&self, name: &str, paths: &AgentPaths) -> Result<()> {
@ -283,7 +251,7 @@ async fn dispatch(
NodeKind::MetaSync { .. } => run_meta_sync(host, agent).await,
NodeKind::Prebuild { .. } => run_prebuild(host, agent).await,
NodeKind::Swap { .. } => run_swap(host, coord, agent, id).await,
NodeKind::RebuildBookkeeping { .. } => run_rebuild_bookkeeping(host, coord, agent).await,
NodeKind::RebuildBookkeeping { .. } => run_rebuild_bookkeeping(coord).await,
NodeKind::Provision { .. } => run_provision(host, agent).await,
NodeKind::Create { .. } => run_create(host, agent).await,
NodeKind::DestroyContainer { .. } => run_destroy_container(host, coord, agent).await,
@ -502,12 +470,9 @@ fn run_set_wanted(coord: &Arc<Coordinator>, agent: &str, up: bool) -> Result<()>
Ok(())
}
/// The rebuild's preamble: runtime-dir prep, the agent-set refresh, and the
/// `internal/meta` commit `Prebuild` and `Swap` expect the swarm's closure to
/// be built from.
///
/// Fails when `internal/meta` does not pin the agent's current config yet:
/// there is no commit to deploy.
/// The rebuild's preamble: runtime-dir prep, the archetype and agent-set
/// refresh. Reads nothing from the forge: what a rebuild deploys is the swarm's
/// declared closure, read by `Prebuild` and `Swap`.
async fn run_meta_sync(host: &impl Host, name: &str) -> Result<()> {
// Runs while the agent is still up — the runtime dir and MCP listener
// already exist. Use the pure path accessor; no need to re-register the
@ -521,9 +486,6 @@ async fn run_meta_sync(host: &impl Host, name: &str) -> Result<()> {
}
host.prepare_rebuild_dirs(name, &paths).await?;
host.refresh_agent_set().await?;
// Resolved once here so `Prebuild` and `Swap` expect the same commit.
let build = host.resolve_meta(name).await?;
host.record_meta(name, &build)?;
Ok(())
}
@ -534,9 +496,8 @@ async fn run_meta_sync(host: &impl Host, name: &str) -> Result<()> {
/// is already down: there is no uptime to protect, and `Swap` fails the same way.
async fn run_prebuild(host: &impl Host, name: &str) -> Result<()> {
if host.is_running(name).await {
let rev = host.recorded_rev(name)?;
let closure = closure_for(host, name, &rev, "prebuild").await?;
tracing::info!(%name, %rev, "prebuild: substituted {}", closure.path);
let closure = closure_for(host, name, "prebuild").await?;
tracing::info!(%name, "prebuild: substituted {closure}");
}
Ok(())
}
@ -550,32 +511,19 @@ enum NoClosure {
Unpullable(String),
}
/// The closure the swarm built for an agent, in the store.
struct Closure {
path: String,
/// The `internal/meta` commit the builder built it from.
meta_rev: String,
}
/// The closure the swarm built for `name`, downloaded into the store. The
/// deploy applies the swarm's closure whatever commit it was built from: when
/// that is not the `rev` this hive chose, it is logged, and the caller records
/// the pins of the commit actually deployed ([`deployed_pins`]).
/// The store path of the closure the swarm declares for `name`, downloaded into
/// the store. Whatever `internal/meta` commit it was built from, it is the one
/// deployed.
///
/// # Errors
/// [`without_closure`]'s refusal, after logging it.
async fn closure_for(host: &impl Host, name: &str, rev: &str, step: &str) -> Result<Closure> {
pull_closure(host, name, rev, step)
async fn closure_for(host: &impl Host, name: &str, step: &str) -> Result<String> {
pull_closure(host, name)
.await
.map_err(|why| without_closure(name, rev, step, &why))
.map_err(|why| without_closure(name, step, &why))
}
async fn pull_closure(
host: &impl Host,
name: &str,
rev: &str,
step: &str,
) -> Result<Closure, NoClosure> {
async fn pull_closure(host: &impl Host, name: &str) -> Result<String, NoClosure> {
let record = host
.closure_record(name)
.await
@ -589,52 +537,7 @@ async fn pull_closure(
host.realise_closure(name, &record.store_path)
.await
.map_err(|e| NoClosure::Unpullable(format!("{e:#}")))?;
if record.meta_rev != rev && !same_pins(host, name, &record.meta_rev, rev).await {
tracing::warn!(
%name, %rev, built_from = %record.meta_rev,
"{step}: the swarm built {} from internal/meta {}, not the {rev} this hive chose; deploying the swarm's",
record.store_path, record.meta_rev
);
}
Ok(Closure {
path: record.store_path,
meta_rev: record.meta_rev,
})
}
/// Whether the agent has the same pins at the commits `built` and `chosen`. A
/// commit whose pins cannot be read counts as different.
async fn same_pins(host: &impl Host, name: &str, built: &str, chosen: &str) -> bool {
match (
host.pins_at(name, built).await,
host.pins_at(name, chosen).await,
) {
(Ok(built), Ok(chosen)) => built.same_pins(&chosen),
_ => false,
}
}
/// The pins to record for a deploy of `closure` when this hive chose the commit
/// `rev`: those of the commit the closure was built from, so the markers say
/// what runs. `None` when they are the same commit.
///
/// # Errors
/// The pins of the closure's commit could not be read. The caller fails before
/// applying anything.
async fn deployed_pins(
host: &impl Host,
name: &str,
closure: &Closure,
rev: &str,
) -> Result<Option<MetaBuild>> {
if closure.meta_rev == rev {
return Ok(None);
}
let pins = host
.pins_at(name, &closure.meta_rev)
.await
.with_context(|| format!("{name}: reading the pins of the commit the swarm built from"))?;
Ok(Some(pins))
Ok(record.store_path)
}
/// What a rebuild or create does when there is no closure to deploy: it
@ -645,18 +548,19 @@ async fn deployed_pins(
/// This is the one place that decides what an unpullable closure (the cache
/// unreachable, a TLS error, a failed download) costs. A rebuild never builds
/// locally on the hive.
fn without_closure(name: &str, rev: &str, step: &str, why: &NoClosure) -> anyhow::Error {
fn without_closure(name: &str, step: &str, why: &NoClosure) -> anyhow::Error {
let reason = match why {
NoClosure::NoRecord => "the swarm has recorded no build of this agent".to_owned(),
NoClosure::Unpullable(e) => format!("the swarm's closure cannot be pulled: {e}"),
};
tracing::error!(%name, %rev, "{step}: {reason}; nothing was changed");
tracing::error!(%name, "{step}: {reason}; nothing was changed");
anyhow::anyhow!("{name}: {reason}")
}
/// Profile-swap: re-apply drop-ins (rebuild is the reconcile verb), point the
/// container at the swarm's closure, then the post-rebuild bookkeeping tail
/// (rev marker, `Rebuilt` event, forge sync, kick, rescan).
/// container at the swarm's closure and, once that holds, mark the agent as
/// running it. The post-rebuild bookkeeping tail (`Rebuilt` event, forge sync,
/// kick, rescan) is the next node.
/// The recovery-start on failure is NOT here — the DAG's tail
/// `Reconcile` runs after this node terminal ok *or* fail.
async fn run_swap(
@ -670,21 +574,20 @@ async fn run_swap(
let agent_dir = crate::paths::agent_runtime_dir(name);
let hive = coord.hive_env();
let paths = Coordinator::agent_paths(name, agent_dir)?;
let rev = host.recorded_rev(name)?;
let closure = closure_for(host, name, &rev, "swap").await?;
let deployed = deployed_pins(host, name, &closure, &rev).await?;
tracing::info!(%name, %rev, "swap: applying {}", closure.path);
let closure = closure_for(host, name, "swap").await?;
tracing::info!(%name, "swap: applying {closure}");
let result = host
.swap_update(name, &hive, &paths, &closure.path, id.get())
.swap_update(name, &hive, &paths, &closure, id.get())
.await;
// The bookkeeping node marks what the record holds, so it holds what was
// applied.
// The marker names what was applied; a failed write is not a failed swap
// (the container runs the closure), it only means the next comparison
// sees the declaration as new.
if result.is_ok()
&& let Some(deployed) = &deployed
&& let Err(e) = host.mark_deployed(name, &closure)
{
host.record_meta(name, deployed)?;
tracing::warn!(%name, error = ?e, "write the deployed-closure marker failed");
}
// On success the Ok-only bookkeeping tail (rev marker, forge
// On success the Ok-only bookkeeping tail (forge
// sync, kick, rescan, snapshot) runs in the sibling `RebuildBookkeeping` node,
// which deps `AfterOk(Swap)`. On failure `RebuildBookkeeping` is cancel-cascaded
// and the tail `Reconcile` (`AfterAny(RebuildBookkeeping)`) handles recovery; here
@ -702,16 +605,7 @@ async fn run_swap(
/// means the profile swap succeeded. Store/forge work only — no nix
/// build (build-slot-exempt); the agent lease taken at `Swap` is still held
/// (the whole chain up to `Reconcile` is one agent's subgraph).
async fn run_rebuild_bookkeeping(
host: &impl Host,
coord: &Arc<Coordinator>,
name: &str,
) -> Result<()> {
if let Some(build) = host.recorded_build(name)
&& let Err(e) = host.mark_deployed(name, &build)
{
tracing::warn!(%name, error = ?e, "write rev markers failed");
}
async fn run_rebuild_bookkeeping(coord: &Arc<Coordinator>) -> Result<()> {
// The `Rebuilt` manager event is emitted exactly once per agent by the DAG's
// `EmitRebuilt` tail — emitting ok here and letting a failed tail `Reconcile`
// add a contradictory !ok would double-report the same rebuild.
@ -724,7 +618,7 @@ async fn run_rebuild_bookkeeping(
// alongside `run_start`'s own kick for the same boot. `hive-agent` now
// self-announces once on its own startup instead — one message per
// actual boot, not one per DAG node that happens to run during it.
// Rescan so dashboards drop the "needs update" chip.
// Rescan so dashboards see the rebuilt container.
coord.rescan_containers_and_emit().await;
Ok(())
}
@ -743,19 +637,15 @@ async fn run_provision(host: &impl Host, name: &str) -> Result<()> {
/// dir creation and MCP listener registration are deferred to the tail
/// `Reconcile` (`converge_start_preamble` + `register_agent`) so this
/// node stays purely "create", not "create + start". Marks the agent as
/// running the pins it was created from.
/// running the closure it was created from.
///
/// The container is created from the swarm's closure for the resolved commit;
/// with none to deploy the create fails and makes no container.
/// The container is created from the swarm's declared closure; with none to
/// deploy the create fails and makes no container.
async fn run_create(host: &impl Host, name: &str) -> Result<()> {
let build = host.resolve_meta(name).await?;
let closure = closure_for(host, name, &build.rev, "create").await?;
let build = deployed_pins(host, name, &closure, &build.rev)
.await?
.unwrap_or(build);
tracing::info!(%name, rev = %build.rev, "create: substituted {}", closure.path);
host.create_only(name, &closure.path).await?;
host.mark_deployed(name, &build)?;
let closure = closure_for(host, name, "create").await?;
tracing::info!(%name, "create: substituted {closure}");
host.create_only(name, &closure).await?;
host.mark_deployed(name, &closure)?;
Ok(())
}