build agents only from internal/meta; delete the hive's local meta flake
The hive no longer renders, locks or commits a meta flake. A rebuild's MetaSync node picks the internal/meta commit and records it with the hyperhive and config pins it holds; Prebuild, Swap and Create build from it, and a head that does not pin the agent's current config fails the build naming the agent. hive-priv's meta_rev is required. Removed: meta.rs sync/lock/render, the manual-rebuild relock, the boot sweep's hyperhive bump (MetaLock), the update-meta-inputs form and its panel, MetaWindow, the hive's hyperhive_rev setting. Kept: agent_set::refresh holds what sync_agents also did (topology.json, the gateway's agent-socket map and nginx include), called from spawn, destroy, rebuild and the migration. The boot reconcile and the stale-start upgrade compare the agent's marker with the hyperhive pin of internal/meta; the dashboard's deployed chip reads the config marker a successful build writes.
This commit is contained in:
parent
cea4827918
commit
4aa11b3a94
64 changed files with 1042 additions and 3793 deletions
|
|
@ -1,9 +1,8 @@
|
|||
//! Node executors — one async fn per [`NodeKind`], each a thin wrapper
|
||||
//! over existing `lifecycle.rs` / `meta.rs` / `actions.rs` code. Node
|
||||
//! over existing `lifecycle.rs` / `meta_rev.rs` / `actions.rs` code. Node
|
||||
//! executors keep their own internal error handling where it exists
|
||||
//! today (cold-start fallback inside `Reconcile`, non-fatal boot-time
|
||||
//! lock bump inside the sweep `MetaLock`, warn-only forge sync in the
|
||||
//! `Swap` tail); DAG-level failure handling is cancel-downstream in
|
||||
//! today (cold-start fallback inside `Reconcile`, warn-only forge sync in
|
||||
//! the `Swap` tail); DAG-level failure handling is cancel-downstream in
|
||||
//! the queue.
|
||||
|
||||
use std::sync::{Arc, Mutex, OnceLock};
|
||||
|
|
@ -61,18 +60,18 @@ pub(super) async fn run_node(
|
|||
kind: &NodeKind,
|
||||
) -> (super::JobBuilder, Result<()>) {
|
||||
// The agent this node targets rides the payload — empty for the agentless
|
||||
// kinds (`MetaLock`), which never read it.
|
||||
// kinds (the sweeps), which never read it.
|
||||
let agent = kind.agent();
|
||||
// Every arm is `Result<()>`; the three that grow work declare into `builder`
|
||||
// *synchronously*, after their own awaits have finished. Borrowing `&builder`
|
||||
// Every arm is `Result<()>`; the one that grows work declares into `builder`
|
||||
// *synchronously*, after its own awaits have finished. Borrowing `&builder`
|
||||
// inside an `.await` would make this future non-`Send` (see above), so the
|
||||
// growth executors return what to grow rather than taking the builder.
|
||||
let result = match kind {
|
||||
NodeKind::MetaSync { relock, .. } => run_meta_sync(coord, agent, *relock).await,
|
||||
NodeKind::MetaSync { .. } => run_meta_sync(agent).await,
|
||||
NodeKind::Prebuild { .. } => run_prebuild(agent, id).await,
|
||||
NodeKind::Swap { .. } => run_swap(coord, agent, id).await,
|
||||
NodeKind::RebuildBookkeeping { .. } => run_rebuild_bookkeeping(coord, agent).await,
|
||||
NodeKind::Provision { .. } => run_provision(coord, agent).await,
|
||||
NodeKind::Provision { .. } => run_provision(agent).await,
|
||||
NodeKind::Create { .. } => run_create(agent).await,
|
||||
NodeKind::DestroyContainer { .. } => run_destroy_container(coord, agent).await,
|
||||
NodeKind::PurgeState { .. } => {
|
||||
|
|
@ -83,24 +82,7 @@ pub(super) async fn run_node(
|
|||
run_destroy_bookkeeping(coord, agent).await;
|
||||
Ok(())
|
||||
}
|
||||
NodeKind::MetaLock {
|
||||
sweep,
|
||||
fanout,
|
||||
inputs,
|
||||
} => run_meta_lock(coord, *sweep, fanout.clone(), inputs)
|
||||
.await
|
||||
.map(|agents| {
|
||||
// `sweep` is the whole difference: it relocks per-agent like a
|
||||
// manual rebuild, and it drains agents that were mid-turn when
|
||||
// the host came up. A cascade does neither. Decided here rather
|
||||
// than returned, since `run_meta_lock` would only be deriving
|
||||
// it from the `sweep` this call site already holds.
|
||||
if *sweep {
|
||||
super::templates::grown_graceful_rebuilds(&builder, &agents, true);
|
||||
} else {
|
||||
super::templates::grown_rebuilds(&builder, &agents, false);
|
||||
}
|
||||
}),
|
||||
|
||||
NodeKind::Reconcile { .. } => run_reconcile(coord, agent).await.map(|sub| {
|
||||
if let Some(kind) = sub {
|
||||
super::templates::fanned_out_mechanical(&builder, kind);
|
||||
|
|
@ -258,12 +240,12 @@ async fn run_wanted_pull(coord: &Arc<Coordinator>) -> Result<()> {
|
|||
crate::workers::wanted::pull(coord).await
|
||||
}
|
||||
|
||||
/// Rerender the meta flake from whatever containers still exist on disk.
|
||||
/// Idempotent — a no-op when nothing changed. Lives here because the destroy
|
||||
/// tail is its only caller; it moved with `destroy` when that became a DAG.
|
||||
async fn sync_meta_after_lifecycle(coord: &Coordinator) -> Result<()> {
|
||||
/// Refresh the host-side agent-set files from whatever containers still exist
|
||||
/// on disk. Idempotent. Lives here because the destroy tail is its only
|
||||
/// caller; it moved with `destroy` when that became a DAG.
|
||||
async fn refresh_agent_set_after_lifecycle() -> Result<()> {
|
||||
let agents = crate::lifecycle::agents_for_meta_listing().await?;
|
||||
crate::meta::sync_agents(&coord.hive_env(), &agents).await
|
||||
crate::agent_set::refresh(&agents).await
|
||||
}
|
||||
|
||||
/// `nixos-container destroy`, then drop the agent from the roster and clear
|
||||
|
|
@ -319,15 +301,13 @@ async fn run_purge_state(agent: &str) {
|
|||
|
||||
/// Post-destroy bookkeeping. Infallible by construction: every step is
|
||||
/// warn-and-continue, because the destroy it follows has already succeeded and
|
||||
/// none of this is undoable — a failed meta sync or power-store write is a
|
||||
/// none of this is undoable — a failed agent-set refresh or power-store write is a
|
||||
/// bookkeeping drift to log, not a reason to red a DAG whose container is
|
||||
/// already gone.
|
||||
async fn run_destroy_bookkeeping(coord: &Arc<Coordinator>, agent: &str) {
|
||||
// Meta flake: drop the agent's input + nixosConfiguration so a future spawn
|
||||
// under the same name re-seeds cleanly, and so the meta lock doesn't
|
||||
// reference a vanished applied repo.
|
||||
if let Err(e) = sync_meta_after_lifecycle(coord).await {
|
||||
tracing::warn!(error = ?e, %agent, "meta sync after destroy failed");
|
||||
// Drop the agent from the topology and the gateway files.
|
||||
if let Err(e) = refresh_agent_set_after_lifecycle().await {
|
||||
tracing::warn!(error = ?e, %agent, "agent_set refresh after destroy failed");
|
||||
}
|
||||
// Drop the durable power intent — a future agent of the same name seeds
|
||||
// fresh from its observed state.
|
||||
|
|
@ -380,21 +360,16 @@ fn run_set_wanted(coord: &Arc<Coordinator>, agent: &str, up: bool) -> Result<()>
|
|||
Ok(())
|
||||
}
|
||||
|
||||
/// The rebuild's meta preamble: runtime-dir prep, an idempotent meta
|
||||
/// `sync_agents`, and the optional per-agent relock. Runs under the deploy
|
||||
/// window (`Resource::MetaWindow`, held by the scheduler for this
|
||||
/// node) so its commits can never land inside another node's staged
|
||||
/// prepare→finalize window.
|
||||
/// The rebuild's preamble: runtime-dir prep, the agent-set refresh, and the
|
||||
/// `internal/meta` commit `Prebuild` and `Swap` build from.
|
||||
///
|
||||
/// Deliberately a separate node from the [`run_prebuild`] it feeds: that
|
||||
/// build takes minutes and only *reads* the store, so keeping the global
|
||||
/// window off it is what lets rebuilds of different agents overlap.
|
||||
async fn run_meta_sync(coord: &Arc<Coordinator>, name: &str, relock: bool) -> Result<()> {
|
||||
/// Fails when `internal/meta` does not pin the agent's current config yet:
|
||||
/// there is no other flake to build it from.
|
||||
async fn run_meta_sync(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
|
||||
// listener (event-driven: registered at start/create).
|
||||
let agent_dir = crate::paths::agent_runtime_dir(name);
|
||||
let hive = coord.hive_env();
|
||||
let paths = Coordinator::agent_paths(name, agent_dir)?;
|
||||
// A failed read keeps the archetype last stored, so the agent is built as
|
||||
// it was.
|
||||
|
|
@ -402,28 +377,18 @@ async fn run_meta_sync(coord: &Arc<Coordinator>, name: &str, relock: bool) -> Re
|
|||
tracing::warn!(%name, error = ?e, "rebuild: reading the swarm's archetype failed; keeping the stored one");
|
||||
}
|
||||
crate::lifecycle::prepare_rebuild_dirs(name, &paths).await?;
|
||||
// Idempotent meta sync so a manual rebuild can also recover from a
|
||||
// divergent meta repo; then bump just this agent's input. `relock =
|
||||
// false` only for meta-update cascade children, where re-locking
|
||||
// would revert the bump the cascade just committed.
|
||||
let agents = crate::lifecycle::agents_for_meta_listing().await?;
|
||||
crate::meta::sync_agents(&hive, &agents).await?;
|
||||
// An archetype agent has no `agent-<name>` input to relock.
|
||||
if relock && crate::archetypes::of(name)?.is_none() {
|
||||
crate::meta::lock_update_for_rebuild(name).await?;
|
||||
}
|
||||
// The commit of the swarm's `internal/meta` that `Prebuild` and `Swap`
|
||||
// build this agent from, resolved once here so both build the same flake;
|
||||
// none leaves them on the hive's own flake rendered above.
|
||||
let meta_rev = crate::meta_rev::resolve(name).await;
|
||||
crate::meta_rev::record(name, meta_rev.as_deref())?;
|
||||
crate::agent_set::refresh(&agents).await?;
|
||||
// Resolved once here so `Prebuild` and `Swap` build the same flake.
|
||||
let build = crate::meta_rev::resolve(name).await?;
|
||||
crate::meta_rev::record(name, &build)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Out-of-band toplevel build while the container keeps serving: warm
|
||||
/// `system.build.toplevel` so the later `Swap` hits cache and skips
|
||||
/// straight to the profile-swap, against a meta repo the upstream
|
||||
/// `MetaSync` node has already synced. The warm build is skipped when the
|
||||
/// straight to the profile-swap, from the `internal/meta` commit the upstream
|
||||
/// `MetaSync` node recorded. The warm build is skipped when the
|
||||
/// container is already down: its only purpose is to shrink the swap's
|
||||
/// downtime window, so a stopped agent (no uptime to preserve) doesn't
|
||||
/// pay the double eval — `Swap` builds inline instead.
|
||||
|
|
@ -433,10 +398,8 @@ async fn run_prebuild(name: &str, id: NodeId) -> Result<()> {
|
|||
// stopped agent has no uptime to preserve, so skip the (expensive)
|
||||
// eval and let the downstream `Swap` build inline.
|
||||
if crate::lifecycle::is_running(name).await {
|
||||
let flake_ref = match crate::meta_rev::recorded(name) {
|
||||
Some(rev) => crate::meta_rev::flake_ref(name, &rev),
|
||||
None => format!("{}#{name}", crate::paths::meta_root().display()),
|
||||
};
|
||||
let build = crate::meta_rev::recorded_or_err(name)?;
|
||||
let flake_ref = crate::meta_rev::flake_ref(name, &build.rev);
|
||||
crate::lifecycle::prebuild_toplevel(name, &flake_ref, Some(id.get())).await?;
|
||||
}
|
||||
Ok(())
|
||||
|
|
@ -453,10 +416,9 @@ async fn run_swap(coord: &Arc<Coordinator>, name: &str, id: NodeId) -> Result<()
|
|||
let agent_dir = crate::paths::agent_runtime_dir(name);
|
||||
let hive = coord.hive_env();
|
||||
let paths = Coordinator::agent_paths(name, agent_dir)?;
|
||||
let meta_rev = crate::meta_rev::recorded(name);
|
||||
let build = crate::meta_rev::recorded_or_err(name)?;
|
||||
let result =
|
||||
crate::lifecycle::swap_update(name, &hive, &paths, meta_rev.as_deref(), Some(id.get()))
|
||||
.await;
|
||||
crate::lifecycle::swap_update(name, &hive, &paths, &build.rev, Some(id.get())).await;
|
||||
// On success the Ok-only bookkeeping tail (rev marker, forge/matrix
|
||||
// sync, kick, rescan, snapshot) runs in the sibling `RebuildBookkeeping` node,
|
||||
// which deps `AfterOk(Swap)`. On failure `RebuildBookkeeping` is cancel-cascaded
|
||||
|
|
@ -476,10 +438,10 @@ async fn run_swap(coord: &Arc<Coordinator>, name: &str, id: NodeId) -> Result<()
|
|||
/// 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(coord: &Arc<Coordinator>, name: &str) -> Result<()> {
|
||||
if let Some(rev) = crate::auto_update::current_flake_rev(&coord.hyperhive_flake)
|
||||
&& let Err(e) = std::fs::write(crate::paths::applied_rev_marker(name), rev)
|
||||
if let Some(build) = crate::meta_rev::recorded(name)
|
||||
&& let Err(e) = crate::meta_rev::mark_deployed(name, &build)
|
||||
{
|
||||
tracing::warn!(%name, error = ?e, "write rev marker failed");
|
||||
tracing::warn!(%name, error = ?e, "write rev markers failed");
|
||||
}
|
||||
// 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`
|
||||
|
|
@ -497,138 +459,33 @@ async fn run_rebuild_bookkeeping(coord: &Arc<Coordinator>, name: &str) -> Result
|
|||
// 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; lock bump →
|
||||
// meta-inputs re-render.
|
||||
// Rescan so dashboards drop the "needs update" chip.
|
||||
coord.rescan_containers_and_emit().await;
|
||||
crate::dashboard::emit_meta_inputs_snapshot(coord);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// First-spawn pre-create provisioning: proposed/applied repos, state
|
||||
/// subvolume, and the meta `sync_agents` registration. Runs under the
|
||||
/// deploy window (it declares `Resource::MetaWindow`) so its commit can't
|
||||
/// land inside another node's staged deploy window.
|
||||
async fn run_provision(coord: &Arc<Coordinator>, name: &str) -> Result<()> {
|
||||
/// subvolume, and the agent-set refresh.
|
||||
async fn run_provision(name: &str) -> Result<()> {
|
||||
let agent_dir = crate::paths::agent_runtime_dir(name);
|
||||
let hive = coord.hive_env();
|
||||
let paths = Coordinator::agent_paths(name, agent_dir)?;
|
||||
crate::lifecycle::provision_container(name, &hive, &paths).await?;
|
||||
crate::lifecycle::provision_container(name, &paths).await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// `nixos-container create` proper — the upstream `Provision` node
|
||||
/// already registered the agent in meta, so this only reads the store
|
||||
/// (no deploy-window gate needed, mirroring `Prebuild`'s build). Runtime
|
||||
/// already registered the agent, so this only reads the store. Runtime
|
||||
/// 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".
|
||||
/// node stays purely "create", not "create + start". Marks the agent as
|
||||
/// running the pins it was created from.
|
||||
async fn run_create(name: &str) -> Result<()> {
|
||||
let meta_rev = crate::meta_rev::resolve(name).await;
|
||||
crate::lifecycle::create_only(name, meta_rev.as_deref()).await?;
|
||||
let build = crate::meta_rev::resolve(name).await?;
|
||||
crate::lifecycle::create_only(name, &build.rev).await?;
|
||||
crate::meta_rev::mark_deployed(name, &build)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Meta flake lock bump. Boot-sweep flavour is non-fatal (a failed
|
||||
/// bump must not cancel the fan-out rebuilds — they proceed against
|
||||
/// the current lock, exactly like today's sweep); the meta-update
|
||||
/// flavour propagates errors, and a failed bump fans out nothing.
|
||||
/// Returns the agents whose rebuild subgraphs the caller should grow into this
|
||||
/// node, rather than declaring them here — the declaration has to happen outside
|
||||
/// any `.await` (see [`run_node`]).
|
||||
///
|
||||
/// Only the agent list: *which* rebuild flavour to grow is a pure function of
|
||||
/// `sweep`, which the caller passed in, so returning it too would be a round
|
||||
/// trip rather than a decision.
|
||||
async fn run_meta_lock(
|
||||
coord: &Arc<Coordinator>,
|
||||
sweep: bool,
|
||||
fanout: Option<Vec<String>>,
|
||||
inputs: &[String],
|
||||
) -> Result<Vec<String>> {
|
||||
if sweep {
|
||||
if let Err(e) = crate::meta::lock_update_hyperhive().await {
|
||||
tracing::warn!(error = ?e, "startup sweep: meta lock_update_hyperhive failed");
|
||||
}
|
||||
// Grow one rebuild subgraph per stale agent into *this* boot DAG
|
||||
// (rooted on this `MetaLock`, so they build against the post-bump
|
||||
// lock), rather than fanning out child DAGs. The caller grows them
|
||||
// with the graceful flavour.
|
||||
return Ok(fanout.unwrap_or_default());
|
||||
}
|
||||
let _progress = coord.meta_update_guard();
|
||||
// Resolved before the lock bump so a failed container list aborts
|
||||
// the update before anything is committed.
|
||||
let cascade = match fanout {
|
||||
Some(list) => validate_agent_names(list),
|
||||
None => meta_update_cascade_agents(inputs).await?,
|
||||
};
|
||||
crate::meta::lock_update(inputs).await?;
|
||||
// Lock file changed — meta-inputs panel re-renders.
|
||||
crate::dashboard::emit_meta_inputs_snapshot(coord);
|
||||
// Pull each cascade agent's own input too — an agent's config-repo main
|
||||
// is trusted, so a meta-input bump is a reasonable place to also catch
|
||||
// it up. Without this, a meta-input bump rebuilds every affected agent
|
||||
// against whatever `applied/<name>` already happened to be locked to —
|
||||
// normally current, but silently stale forever if a past deploy failed
|
||||
// and nothing since retried it.
|
||||
// An archetype agent has no config repo and no `agent-<name>` input, so
|
||||
// it is left out here; it is still rebuilt with the cascade.
|
||||
let archetypes = crate::archetypes::read_at(&crate::archetypes::path())?;
|
||||
let custom: Vec<String> = cascade
|
||||
.iter()
|
||||
.filter(|name| !archetypes.contains_key(*name))
|
||||
.cloned()
|
||||
.collect();
|
||||
catch_up_cascade_agents(
|
||||
&custom,
|
||||
crate::forge::fast_forward_applied_main,
|
||||
|agent_inputs| async move { crate::meta::lock_update(&agent_inputs).await },
|
||||
)
|
||||
.await?;
|
||||
// Grow one rebuild subgraph per affected agent into *this* meta-update
|
||||
// DAG (rooted on this `MetaLock`, so they build against the post-bump
|
||||
// lock), rather than fanning out child DAGs. `relock = false` — the
|
||||
// cascade children must NOT re-lock, which would revert the bump this
|
||||
// node just committed (the property the old `fanout_specs` meta-update
|
||||
// branch encoded).
|
||||
Ok(cascade)
|
||||
}
|
||||
|
||||
/// Fast-forward each cascade agent's `applied/<name>/main` to its forge
|
||||
/// config main, then relock every `agent-<name>` input in one call.
|
||||
///
|
||||
/// The declared `agent-<name>` input is the `applied/<name>` mirror itself,
|
||||
/// and the cascade's rebuilds (`relock = false`) build whatever this relock
|
||||
/// pins, so the mirror has to move first. The fast-forward is best-effort
|
||||
/// per agent: a failed one leaves that agent relocked against whatever its
|
||||
/// mirror already has. The relock is one combined call, so one broken input
|
||||
/// fails it for the whole cascade.
|
||||
///
|
||||
/// The fast-forward and the relock are passed in so the order is testable
|
||||
/// without nix or a forge.
|
||||
async fn catch_up_cascade_agents<'a, FF, Relock>(
|
||||
cascade: &'a [String],
|
||||
mut fast_forward: impl FnMut(&'a str) -> FF,
|
||||
relock: impl FnOnce(Vec<String>) -> Relock,
|
||||
) -> Result<()>
|
||||
where
|
||||
FF: std::future::Future<Output = bool>,
|
||||
Relock: std::future::Future<Output = Result<()>>,
|
||||
{
|
||||
if cascade.is_empty() {
|
||||
return Ok(());
|
||||
}
|
||||
for name in cascade {
|
||||
if !fast_forward(name).await {
|
||||
tracing::warn!(
|
||||
%name,
|
||||
"meta-update cascade: applied/main fast-forward skipped or failed"
|
||||
);
|
||||
}
|
||||
}
|
||||
relock(cascade.iter().map(|name| format!("agent-{name}")).collect()).await
|
||||
}
|
||||
|
||||
/// Idempotent power-converge *planner*: compare `wanted` (durable
|
||||
/// intent) against observed state and, when they diverge, fan the
|
||||
/// mechanical `Start` / `Stop` out as a first-class node appended to
|
||||
|
|
@ -814,202 +671,3 @@ async fn run_write_dropin(coord: &Arc<Coordinator>, name: &str) -> Result<()> {
|
|||
crate::lifecycle::write_dropins(name, &hive, &paths).await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Compute which agents a `nix flake update <inputs>` on the meta
|
||||
/// flake affects — the fan-out set for `MetaUpdate` DAGs. Empty
|
||||
/// `inputs` or any input under `hyperhive` → every container;
|
||||
/// otherwise just the agents named by `agent-<name>` inputs.
|
||||
/// Sorted by name — the parent field the old depth sort keyed on is
|
||||
/// gone, and with every agent a root that sort already reduced to
|
||||
/// this.
|
||||
///
|
||||
/// `inputs` is the caller-supplied flake-input-name list (the dashboard's
|
||||
/// `POST /api/meta-update` form field, operator-supplied but not
|
||||
/// otherwise validated) — the `agent-<name>` branch parses agent names
|
||||
/// straight out of it, so each is validated through [`hive_types::Ident`]
|
||||
/// before it ever reaches a filesystem path or a forge URL built from a
|
||||
/// cascade agent's name. The `touched_hyperhive` branch's names need no
|
||||
/// such filter: they come from `lifecycle::list()`, already real
|
||||
/// container names by construction, not parsed out of caller input.
|
||||
///
|
||||
/// # Errors
|
||||
///
|
||||
/// The every-container branch could not list the containers.
|
||||
pub async fn meta_update_cascade_agents(inputs: &[String]) -> Result<Vec<String>> {
|
||||
let touched_hyperhive = inputs
|
||||
.iter()
|
||||
.any(|i| i == "hyperhive" || i.starts_with("hyperhive/"));
|
||||
let touched_agents = parse_agent_input_names(inputs);
|
||||
let mut names = if touched_hyperhive || inputs.is_empty() {
|
||||
crate::lifecycle::agent_names(crate::lifecycle::list().await)
|
||||
.context("listing containers for the meta-update cascade")?
|
||||
} else {
|
||||
touched_agents
|
||||
};
|
||||
names.sort();
|
||||
Ok(names)
|
||||
}
|
||||
|
||||
/// Parse `agent-<name>` input strings into validated agent names. Pure and
|
||||
/// synchronous (no `lifecycle::list()` call) so the validation — the part
|
||||
/// that actually matters for safety, since these names end up in
|
||||
/// filesystem paths and forge URLs — is unit-testable without touching
|
||||
/// live container state. Delegates the actual filtering to
|
||||
/// [`validate_agent_names`] after stripping the `agent-` prefix.
|
||||
fn parse_agent_input_names(inputs: &[String]) -> Vec<String> {
|
||||
let candidates = inputs
|
||||
.iter()
|
||||
.filter_map(|i| i.strip_prefix("agent-"))
|
||||
.map(|rest| rest.split('/').next().unwrap_or(rest).to_owned());
|
||||
validate_agent_names(candidates)
|
||||
}
|
||||
|
||||
/// Drop any name that isn't a well-formed [`hive_types::Ident`] — the same
|
||||
/// validating parser every other agent-name wire field in this codebase
|
||||
/// goes through, so a name that reaches
|
||||
/// [`fast_forward_applied_main`](crate::forge::fast_forward_applied_main) or
|
||||
/// an `agent-configs/<name>` forge URL built off it has already
|
||||
/// passed the same bar as one that arrived over a socket. Not just a
|
||||
/// helper for [`parse_agent_input_names`]: `run_meta_lock`'s `fanout`
|
||||
/// parameter carries pre-computed agent names too, and while today's only
|
||||
/// caller of that branch (the boot sweep, `sweep = true`) returns before
|
||||
/// ever reaching the filesystem/git code below, nothing in the type
|
||||
/// enforces that pairing — so this same filter also guards the
|
||||
/// `Some(list)` arm, closing the gap for good rather than relying on it
|
||||
/// staying incidental.
|
||||
fn validate_agent_names(names: impl IntoIterator<Item = String>) -> Vec<String> {
|
||||
names
|
||||
.into_iter()
|
||||
.filter(|name| {
|
||||
let valid = hive_types::Ident::parse(name).is_ok();
|
||||
if !valid {
|
||||
tracing::warn!(%name, "meta-update cascade: dropping malformed agent name");
|
||||
}
|
||||
valid
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod cascade_agent_tests {
|
||||
use super::{catch_up_cascade_agents, parse_agent_input_names, validate_agent_names};
|
||||
use std::cell::RefCell;
|
||||
use std::collections::BTreeMap;
|
||||
|
||||
/// Runs [`catch_up_cascade_agents`] over `cascade` against a model of
|
||||
/// the applied mirrors: every mirror starts at `"old"`, a fast-forward
|
||||
/// moves it to `"new"` unless the agent is in `failing`. Returns the
|
||||
/// mirror head the relock saw for each input it was given.
|
||||
async fn relocked_heads(cascade: &[&str], failing: &[&str]) -> Vec<(String, &'static str)> {
|
||||
let cascade: Vec<String> = cascade.iter().map(|&n| n.to_owned()).collect();
|
||||
let heads = RefCell::new(
|
||||
cascade
|
||||
.iter()
|
||||
.map(|n| (n.clone(), "old"))
|
||||
.collect::<BTreeMap<_, _>>(),
|
||||
);
|
||||
let relocked = RefCell::new(Vec::new());
|
||||
let fast_forward = |name: &str| {
|
||||
let ok = !failing.contains(&name);
|
||||
if ok {
|
||||
heads.borrow_mut().insert(name.to_owned(), "new");
|
||||
}
|
||||
async move { ok }
|
||||
};
|
||||
let relock = |inputs: Vec<String>| {
|
||||
for input in inputs {
|
||||
let name = input.strip_prefix("agent-").expect("agent input");
|
||||
let head = heads.borrow()[name];
|
||||
relocked.borrow_mut().push((input, head));
|
||||
}
|
||||
async { anyhow::Ok(()) }
|
||||
};
|
||||
catch_up_cascade_agents(&cascade, fast_forward, relock)
|
||||
.await
|
||||
.expect("catch up");
|
||||
relocked.into_inner()
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn the_cascade_relock_pins_the_fast_forwarded_head() {
|
||||
assert_eq!(
|
||||
relocked_heads(&["iris", "atlas"], &[]).await,
|
||||
vec![
|
||||
("agent-iris".to_owned(), "new"),
|
||||
("agent-atlas".to_owned(), "new"),
|
||||
]
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn a_failed_fast_forward_still_relocks_that_agent() {
|
||||
assert_eq!(
|
||||
relocked_heads(&["iris", "atlas"], &["iris"]).await,
|
||||
vec![
|
||||
("agent-iris".to_owned(), "old"),
|
||||
("agent-atlas".to_owned(), "new"),
|
||||
]
|
||||
);
|
||||
}
|
||||
|
||||
/// A relock with no inputs is a bare `nix flake update`, which bumps
|
||||
/// every meta input.
|
||||
#[tokio::test]
|
||||
async fn an_empty_cascade_moves_nothing_and_relocks_nothing() {
|
||||
let fast_forwarded = std::cell::Cell::new(false);
|
||||
let relocked = std::cell::Cell::new(false);
|
||||
let outcome = catch_up_cascade_agents(
|
||||
&[],
|
||||
|_| {
|
||||
fast_forwarded.set(true);
|
||||
async { true }
|
||||
},
|
||||
|_| {
|
||||
relocked.set(true);
|
||||
async { anyhow::Ok(()) }
|
||||
},
|
||||
)
|
||||
.await;
|
||||
outcome.expect("an empty cascade is not an error");
|
||||
assert!(!fast_forwarded.get(), "nothing to fast-forward");
|
||||
assert!(!relocked.get(), "an empty relock would bump every input");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn well_formed_agent_inputs_pass_through() {
|
||||
let inputs = vec!["agent-iris".to_owned(), "agent-atlas".to_owned()];
|
||||
assert_eq!(parse_agent_input_names(&inputs), vec!["iris", "atlas"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn path_traversal_in_an_agent_input_is_dropped() {
|
||||
let inputs = vec!["agent-../../etc".to_owned(), "agent-iris".to_owned()];
|
||||
assert_eq!(parse_agent_input_names(&inputs), vec!["iris"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn non_agent_inputs_are_ignored_not_misparsed() {
|
||||
let inputs = vec!["hyperhive".to_owned(), "nixpkgs".to_owned()];
|
||||
assert!(parse_agent_input_names(&inputs).is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn oversized_or_uppercase_names_are_dropped() {
|
||||
let too_long = format!("agent-{}", "a".repeat(64));
|
||||
let inputs = vec![too_long, "agent-Iris".to_owned(), "agent-iris".to_owned()];
|
||||
assert_eq!(parse_agent_input_names(&inputs), vec!["iris"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn fanout_supplied_names_are_validated_too() {
|
||||
// `run_meta_lock`'s `Some(list)` arm feeds `fanout` straight into
|
||||
// this filter — pin that a bogus name in that pre-computed list
|
||||
// gets dropped exactly like a parsed `agent-<name>` input would.
|
||||
let names = vec![
|
||||
"iris".to_owned(),
|
||||
"../../etc".to_owned(),
|
||||
"atlas".to_owned(),
|
||||
];
|
||||
assert_eq!(validate_agent_names(names), vec!["iris", "atlas"]);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in a new issue