diff --git a/docs/scheduler/coordinator.md b/docs/scheduler/coordinator.md index 8dd35e70..130334fa 100644 --- a/docs/scheduler/coordinator.md +++ b/docs/scheduler/coordinator.md @@ -42,11 +42,11 @@ because there is no malformed spec to reject. Nix-heavy — hold one of the `buildSlots` permits for the node's duration: -| Node | Wraps | -| ---------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| `Prebuild` | `lifecycle::prebuild_toplevel` — build the toplevel out-of-band while the container keeps serving (its meta preamble is the upstream `MetaSync` node). Skipped when the container is already down; `Swap` builds inline instead | -| `Swap` | drop-in rewrite + `nixos-container update` profile-swap (requires the container stopped); the post-swap bookkeeping tail lives in the sibling `RebuildBookkeeping` node | -| `Create` | first-spawn `nixos-container create` proper; assumes the upstream `Provision` node already registered the agent; builds from the `internal/meta` commit it resolves and marks the agent's pins | +| Node | Wraps | +| ---------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | +| `Prebuild` | `lifecycle::prebuild_toplevel` — build the toplevel out-of-band while the container keeps serving (its meta preamble is the upstream `MetaSync` node). Skipped when the container is already down; `Swap` builds inline instead | +| `Swap` | drop-in rewrite + `nixos-container update` profile-swap (requires the container stopped); the post-swap bookkeeping tail lives in the sibling `RebuildBookkeeping` node | +| `Create` | first-spawn `nixos-container create` proper; assumes the upstream `Provision` node already registered the agent; downloads the closure the swarm cached for the `internal/meta` commit it resolves, else builds it locally, and marks the agent's pins | Cheap — no build slot: diff --git a/hive-c0re/src/job_queue/exec.rs b/hive-c0re/src/job_queue/exec.rs index 9257c642..bb0c9228 100644 --- a/hive-c0re/src/job_queue/exec.rs +++ b/hive-c0re/src/job_queue/exec.rs @@ -310,7 +310,7 @@ async fn dispatch( NodeKind::Swap { .. } => run_swap(host, coord, agent, id).await, NodeKind::RebuildBookkeeping { .. } => run_rebuild_bookkeeping(host, coord, agent).await, NodeKind::Provision { .. } => run_provision(host, agent).await, - NodeKind::Create { .. } => run_create(host, agent).await, + NodeKind::Create { .. } => run_create(host, agent, id).await, NodeKind::DestroyContainer { .. } => run_destroy_container(host, coord, agent).await, NodeKind::PurgeState { .. } => { run_purge_state(host, agent).await; @@ -672,7 +672,7 @@ fn drift(downloaded: &str, evaluated: Option<&str>) -> Option { } } -/// Why a rebuild builds instead of downloading the swarm's closure. +/// Why a rebuild or create builds instead of downloading the swarm's closure. struct LocalBuild { reason: String, /// The cache served a path no trusted key signed. @@ -776,8 +776,30 @@ async fn run_provision(host: &impl Host, name: &str) -> Result<()> { /// `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. -async fn run_create(host: &impl Host, name: &str) -> Result<()> { +/// +/// The swarm's cache is tried first, as in [`run_prebuild`]: when it holds the +/// closure for the resolved commit it is downloaded, and the local evaluation +/// that follows is a no-op build on a path match and builds the evaluated path +/// on a mismatch. Without a cached closure the create builds it locally. +async fn run_create(host: &impl Host, name: &str, id: NodeId) -> Result<()> { let build = host.resolve_meta(name).await?; + match substitute(host, name, &build.rev).await { + Ok(path) => { + let evaluated = host.prebuild_toplevel(name, &build.rev, id.get()).await?; + match drift(&path, evaluated.as_deref()) { + None => tracing::info!(%name, rev = %build.rev, "create: substituted {path}"), + Some(note) => tracing::warn!(%name, rev = %build.rev, "create: {note}"), + } + } + Err(fallback) => { + let reason = &fallback.reason; + if fallback.signature_refused { + tracing::error!(%name, rev = %build.rev, "create: built locally: {reason}"); + } else { + tracing::warn!(%name, rev = %build.rev, "create: built locally: {reason}"); + } + } + } host.create_only(name, &build.rev).await?; host.mark_deployed(name, &build)?; Ok(()) diff --git a/hive-c0re/src/job_queue/exec/tests.rs b/hive-c0re/src/job_queue/exec/tests.rs index 12b67f27..3a6b6fe4 100644 --- a/hive-c0re/src/job_queue/exec/tests.rs +++ b/hive-c0re/src/job_queue/exec/tests.rs @@ -834,26 +834,84 @@ async fn provision_and_write_dropin_forward_and_propagate_failure() { } #[tokio::test] -async fn create_builds_at_the_resolved_rev_then_marks_it_deployed() { +async fn create_without_a_record_builds_locally_at_the_resolved_rev() { let host = FakeHost::up(); - run_create(&host, "alice").await.expect("ok"); + run_create(&host, "alice", node_id()).await.expect("ok"); assert_eq!( host.calls(), [ "resolve_meta alice".to_owned(), + format!("cached_closure alice {REV}"), format!("create_only alice {REV}"), format!("mark_deployed alice {REV}"), ] ); } +#[tokio::test] +async fn create_downloads_a_cached_closure_and_builds_nothing() { + let host = FakeHost::up().with_closure(); + let id = node_id(); + run_create(&host, "alice", id).await.expect("ok"); + assert_eq!( + host.calls(), + [ + "resolve_meta alice".to_owned(), + format!("cached_closure alice {REV}"), + format!("realise alice {CLOSURE}"), + format!("prebuild alice {REV} node={}", id.get()), + format!("create_only alice {REV}"), + format!("mark_deployed alice {REV}"), + ] + ); + assert_eq!(host.builds(), Vec::::new()); +} + +#[tokio::test] +async fn create_builds_locally_when_the_download_fails() { + let host = FakeHost::up().with_closure().failing("realise"); + run_create(&host, "alice", node_id()).await.expect("ok"); + assert_eq!( + host.calls(), + [ + "resolve_meta alice".to_owned(), + format!("cached_closure alice {REV}"), + format!("realise alice {CLOSURE}"), + format!("create_only alice {REV}"), + format!("mark_deployed alice {REV}"), + ] + ); +} + +#[tokio::test] +async fn create_builds_the_evaluated_path_when_it_differs_from_the_download() { + let other = "/nix/store/zzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzz-nixos-system-alice"; + let host = FakeHost { + evaluates: Some(other.to_owned()), + ..FakeHost::up().with_closure() + }; + run_create(&host, "alice", node_id()).await.expect("ok"); + assert_eq!(host.builds(), [other.to_owned()]); + let calls = host.calls(); + assert!( + calls + .iter() + .any(|c| c == &format!("create_only alice {REV}")), + "{calls:?}" + ); +} + #[tokio::test] async fn a_failed_create_is_never_marked_deployed() { let host = FakeHost::up().failing("create_only"); - run_create(&host, "alice").await.expect_err("create fails"); - assert_eq!(host.calls().len(), 2); + run_create(&host, "alice", node_id()) + .await + .expect_err("create fails"); + assert_eq!(host.calls().len(), 3); let host = FakeHost::up().failing("resolve_meta"); - run_create(&host, "alice").await.expect_err("resolve fails"); + run_create(&host, "alice", node_id()) + .await + .expect_err("resolve fails"); assert_eq!(host.calls(), ["resolve_meta alice"]); }