Watch
0
0
Fork
You've already forked hyperhive
0

bind a container apply to the closure realised for the agent, and record the deployed pins on drift

hive-priv applies only the path its last RealiseClosure returned for that agent. On drift the swap and create record the pins of the commit the closure was built from.
This commit is contained in:
atlas 2026-10-29 22:36:58 +01:00
commit 6b800600e2
3 changed files with 338 additions and 71 deletions

View file

@ -48,6 +48,8 @@ pub(super) trait Host: Sync {
/// 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<()>;
@ -125,6 +127,10 @@ impl Host for RealHost {
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
}
@ -548,8 +554,8 @@ async fn run_meta_sync(host: &impl Host, name: &str) -> Result<()> {
async fn run_prebuild(host: &impl Host, name: &str) -> Result<()> {
if host.is_running(name).await {
let rev = host.recorded_rev(name)?;
let path = closure_for(host, name, &rev, "prebuild").await?;
tracing::info!(%name, %rev, "prebuild: substituted {path}");
let closure = closure_for(host, name, &rev, "prebuild").await?;
tracing::info!(%name, %rev, "prebuild: substituted {}", closure.path);
}
Ok(())
}
@ -563,13 +569,21 @@ enum NoClosure {
Unpullable(String),
}
/// The store path of 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.
/// 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`]).
///
/// # Errors
/// [`without_closure`]'s refusal, after logging it.
async fn closure_for(host: &impl Host, name: &str, rev: &str, step: &str) -> Result<String> {
async fn closure_for(host: &impl Host, name: &str, rev: &str, step: &str) -> Result<Closure> {
pull_closure(host, name, rev, step)
.await
.map_err(|why| without_closure(name, rev, step, &why))
@ -580,7 +594,7 @@ async fn pull_closure(
name: &str,
rev: &str,
step: &str,
) -> Result<String, NoClosure> {
) -> Result<Closure, NoClosure> {
let record = host
.closure_record(name)
.await
@ -601,7 +615,33 @@ async fn pull_closure(
record.store_path, record.meta_rev
);
}
Ok(record.store_path)
Ok(Closure {
path: record.store_path,
meta_rev: record.meta_rev,
})
}
/// 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))
}
/// What a rebuild or create does when there is no closure to deploy: it
@ -638,11 +678,19 @@ async fn run_swap(
let hive = coord.hive_env();
let paths = Coordinator::agent_paths(name, agent_dir)?;
let rev = host.recorded_rev(name)?;
let toplevel = closure_for(host, name, &rev, "swap").await?;
tracing::info!(%name, %rev, "swap: applying {toplevel}");
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 result = host
.swap_update(name, &hive, &paths, &toplevel, id.get())
.swap_update(name, &hive, &paths, &closure.path, id.get())
.await;
// The bookkeeping node marks what the record holds, so it holds what was
// applied.
if result.is_ok()
&& let Some(deployed) = &deployed
{
host.record_meta(name, deployed)?;
}
// On success the Ok-only bookkeeping tail (rev marker, forge
// sync, kick, rescan, snapshot) runs in the sibling `RebuildBookkeeping` node,
// which deps `AfterOk(Swap)`. On failure `RebuildBookkeeping` is cancel-cascaded
@ -708,9 +756,12 @@ async fn run_provision(host: &impl Host, name: &str) -> Result<()> {
/// 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 toplevel = closure_for(host, name, &build.rev, "create").await?;
tracing::info!(%name, rev = %build.rev, "create: substituted {toplevel}");
host.create_only(name, &toplevel).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)?;
Ok(())
}

View file

@ -129,6 +129,15 @@ impl Host for FakeHost {
Ok(self.built.clone())
}
async fn pins_at(&self, name: &str, rev: &str) -> anyhow::Result<MetaBuild> {
self.step("pins_at", format!("pins_at {name} {rev}"))?;
Ok(MetaBuild {
rev: rev.to_owned(),
hyperhive: "dh".to_owned(),
config: Some("dc".to_owned()),
})
}
async fn realise_closure(&self, name: &str, store_path: &str) -> anyhow::Result<()> {
self.record(format!("realise {name} {store_path}"));
if self.fails("realise") {
@ -531,14 +540,46 @@ async fn swap_applies_a_closure_built_from_another_commit_with_a_warning() {
let id = node_id();
let (result, log) = logging(run_swap(&host, &coord, "alice", id)).await;
result.expect("drift is not a refusal");
// The pins of the commit the closure came from are read before the apply
// and recorded after it, in place of the ones this hive chose; the
// bookkeeping node then marks those.
assert_eq!(
host.calls().last(),
Some(&format!("swap_update alice {CLOSURE} node={}", id.get()))
host.calls(),
[
"recorded_rev alice".to_owned(),
"closure_record alice".to_owned(),
format!("realise alice {CLOSURE}"),
format!("pins_at alice {OTHER_REV}"),
format!("swap_update alice {CLOSURE} node={}", id.get()),
format!("record_meta alice {OTHER_REV}"),
]
);
assert_eq!(log.at(tracing::Level::WARN).len(), 1);
assert!(log.at(tracing::Level::ERROR).is_empty());
}
/// Pins that can't be read, or an apply that fails, leave the record alone.
#[tokio::test]
async fn a_drifted_swap_records_pins_only_for_an_apply_that_happened() {
for failing in ["pins_at", "swap_update"] {
let (_dir, coord) = coordinator();
let host = FakeHost::down().built(OTHER_REV, true).failing(failing);
run_swap(&host, &coord, "alice", node_id())
.await
.expect_err(failing);
let calls = host.calls();
assert!(
!calls.iter().any(|c| c.starts_with("record_meta")),
"{failing}: {calls:?}"
);
assert_eq!(
calls.iter().any(|c| c.starts_with("swap_update")),
failing == "swap_update",
"{failing}: {calls:?}"
);
}
}
/// A failed update fails the node and nothing else: the swap never restarts or
/// stops the container itself — recovery is the DAG's tail `Reconcile`.
#[tokio::test]
@ -897,9 +938,18 @@ async fn create_uses_a_closure_built_from_another_commit_with_a_warning() {
let (result, log) = logging(run_create(&host, "alice")).await;
result.expect("drift is not a refusal");
let calls = host.calls();
assert!(
calls.contains(&format!("create_only alice {CLOSURE}")),
"{calls:?}"
// Created from the swarm's closure, and marked as the commit it was built
// from, not the one this hive chose.
assert_eq!(
calls,
[
"resolve_meta alice".to_owned(),
"closure_record alice".to_owned(),
format!("realise alice {CLOSURE}"),
format!("pins_at alice {OTHER_REV}"),
format!("create_only alice {CLOSURE}"),
format!("mark_deployed alice {OTHER_REV}"),
]
);
assert_eq!(log.at(tracing::Level::WARN).len(), 1);
assert!(log.at(tracing::Level::ERROR).is_empty());

View file

@ -130,7 +130,58 @@ pub(crate) async fn resolve(agent: &str) -> Result<MetaBuild> {
Ok(build)
}
/// Record `build` as what `agent`'s rebuild builds from. The record is in
/// The pins `internal/meta` at `rev` holds for `agent`: the hyperhive commit and,
/// for a custom agent (`archetype` false), its config commit. This is what a
/// deploy of the swarm's closure for `rev` applies, which can differ from the
/// head [`choose`] took.
///
/// # Errors
/// `rev` is not a commit id, the forge read failed, or the flake pins no
/// hyperhive commit or, for a custom agent, no config commit.
pub(crate) async fn pins_at(
source: &impl MetaSource,
agent: &str,
archetype: bool,
rev: &str,
) -> Result<MetaBuild> {
if !valid_meta_rev(rev) {
bail!("{agent}: {rev:?} is not an internal/meta commit id");
}
let flake = source.flake_at(rev).await?;
let Some(hyperhive) = hive_meta_flake::pinned_rev(&flake, "hyperhive") else {
bail!("{agent}: internal/meta at {rev} pins no hyperhive commit");
};
let config = if archetype {
None
} else {
let Some(config) = hive_meta_flake::pinned_rev(&flake, &format!("agent-{agent}")) else {
bail!("{agent}: internal/meta at {rev} pins no config commit for it");
};
Some(config.to_owned())
};
Ok(MetaBuild {
rev: rev.to_owned(),
hyperhive: hyperhive.to_owned(),
config,
})
}
/// [`pins_at`] against the forge this hive reads.
///
/// # Errors
/// As [`pins_at`], or this hive holds no forge token or cannot read the
/// agent's archetype.
pub(crate) async fn resolve_at(agent: &str, rev: &str) -> Result<MetaBuild> {
let archetype = crate::archetypes::of(agent)
.with_context(|| format!("{agent}: cannot read the agent's archetype"))?;
let Some(source) = crate::forge::internal_meta_source().await else {
bail!("{agent}: this hive holds no forge token to read internal/meta with");
};
pins_at(&source, agent, archetype.is_some(), rev).await
}
/// Record `build` as the commit `agent`'s rebuild expects the swarm's closure
/// from, and the pins it deploys. The record is in
/// hive-c0re's own state ([`crate::paths::meta_rev_dir`]), where the agent
/// cannot write.
///
@ -397,6 +448,28 @@ mod tests {
assert_unpinned(&choose(&source, "rev", None).await.unwrap_err(), "rev");
}
#[tokio::test]
async fn the_pins_at_a_commit_are_what_its_flake_holds() {
let source = forge(Some(ARGUS_HEAD));
let argus = pins_at(&source, "argus", false, META).await.unwrap();
assert_eq!(
argus,
MetaBuild {
rev: META.to_owned(),
hyperhive: HYPERHIVE.to_owned(),
config: Some(ARGUS_HEAD.to_owned()),
}
);
// An archetype agent has no config pin of its own.
let rev = pins_at(&source, "rev", true, META).await.unwrap();
assert_eq!((rev.hyperhive.as_str(), rev.config), (HYPERHIVE, None));
// A custom agent the flake doesn't list, and a rev that is no commit id.
let err = pins_at(&source, "nobody", false, META).await.unwrap_err();
assert!(format!("{err:#}").contains("no config commit"), "{err:#}");
let err = pins_at(&source, "argus", false, "main").await.unwrap_err();
assert!(format!("{err:#}").contains("not an internal/meta commit"));
}
#[tokio::test]
async fn no_repo_or_no_commit_is_an_error_naming_the_agent() {
let mut source = forge(Some(ARGUS_HEAD));