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
2 changed files with 647 additions and 2001 deletions

View file

@ -1,7 +1,10 @@
//! The closure the swarm built for an agent, as a deploy reads its record
//! before downloading it.
//! The closure the swarm declares for an agent: its record in `agent-closures`
//! read before a deploy downloads it, and the marker of the store path the
//! hive last applied, which a changed declaration is compared with.
use anyhow::{Result, anyhow};
use std::path::Path;
use anyhow::{Context as _, Result, anyhow};
use swarm_wire::agent_closures::AgentClosure;
use super::probe;
@ -17,3 +20,162 @@ pub async fn record(agent: &str) -> Result<Option<AgentClosure>> {
.ok_or_else(|| anyhow!("this hive has no connection to the swarm queue"))?;
probe::read_record(&client, agent).await
}
/// Record that `agent` runs the closure at `store_path`.
///
/// # Errors
/// The marker could not be written.
pub fn mark_deployed(agent: &str, store_path: &str) -> Result<()> {
mark_deployed_in(&crate::paths::applied_closure_marker(agent), store_path)
}
/// The store path `agent` was last swapped to; `None` when the hive has no
/// record of one: it never applied a closure, or last did before the marker
/// existed.
#[must_use]
pub fn deployed(agent: &str) -> Option<String> {
deployed_in(&crate::paths::applied_closure_marker(agent))
}
/// Written whole to a temp file beside the marker and renamed over it, so a
/// reader sees the old marker or the new one.
fn mark_deployed_in(marker: &Path, store_path: &str) -> Result<()> {
let tmp = marker.with_extension("tmp");
if let Some(dir) = marker.parent() {
std::fs::create_dir_all(dir).with_context(|| format!("create {}", dir.display()))?;
}
std::fs::write(&tmp, store_path).with_context(|| format!("write {}", tmp.display()))?;
std::fs::rename(&tmp, marker)
.with_context(|| format!("rename {} to {}", tmp.display(), marker.display()))
}
fn deployed_in(marker: &Path) -> Option<String> {
let stored = std::fs::read_to_string(marker).ok()?;
let stored = stored.trim();
(!stored.is_empty()).then(|| stored.to_owned())
}
/// Whether the swarm's declared closure is one the hive should now deploy: the
/// builder pushed it and it differs from what `deployed` says runs.
///
/// An agent with no `deployed` marker is not pending, since the hive cannot
/// tell what it runs: every agent starts that way when the hive first reads
/// this marker, and deploying all of them at once is worse than waiting for the
/// swarm's next deploy request, which writes the marker.
#[must_use]
pub fn pending(record: &AgentClosure, deployed: Option<&str>) -> bool {
record.cached && deployed.is_some_and(|deployed| deployed != record.store_path)
}
/// Whether `deployed` is the closure `record` declares, so deploying it again
/// would change nothing.
#[must_use]
pub fn up_to_date(record: &AgentClosure, deployed: Option<&str>) -> bool {
deployed == Some(record.store_path.as_str())
}
/// The store path the swarm declares for `agent` when it is [`pending`]. A record
/// that cannot be read, or none, is logged and counts as nothing pending: the
/// hive then deploys nothing it was not told to.
pub async fn pending_path(agent: &str) -> Option<String> {
match record(agent).await {
Ok(Some(record)) => {
pending(&record, deployed(agent).as_deref()).then_some(record.store_path)
}
Ok(None) => None,
Err(e) => {
tracing::warn!(%agent, error = %format!("{e:#}"), "cannot read the agent's declared closure");
None
}
}
}
/// Whether the closure the swarm declares for `agent` is the one it runs; false
/// when that cannot be told, so a deploy request goes ahead.
pub async fn is_up_to_date(agent: &str) -> bool {
match record(agent).await {
Ok(Some(record)) => up_to_date(&record, deployed(agent).as_deref()),
_ => false,
}
}
#[cfg(test)]
mod tests {
use super::*;
const DECLARED: &str = "/nix/store/0123456789abcdfghijklmnpqrsvwxyz-nixos-system-alice";
const RUNNING: &str = "/nix/store/zyxwvsrqpnmlkjihgfdcbadfhijklmn-nixos-system-alice";
fn declared(cached: bool) -> AgentClosure {
AgentClosure {
meta_rev: "a".repeat(40),
store_path: DECLARED.to_owned(),
system: "x86_64-linux".to_owned(),
built_unix: 1,
cached,
private_users: false,
}
}
#[test]
fn a_declaration_other_than_the_running_closure_is_pending() {
assert!(pending(&declared(true), Some(RUNNING)));
}
#[test]
fn the_running_closure_is_not_pending() {
assert!(!pending(&declared(true), Some(DECLARED)));
}
#[test]
fn a_closure_the_builder_did_not_push_is_not_pending() {
assert!(!pending(&declared(false), Some(RUNNING)));
}
#[test]
fn only_the_declared_closure_is_up_to_date() {
assert!(up_to_date(&declared(true), Some(DECLARED)));
assert!(!up_to_date(&declared(true), Some(RUNNING)));
assert!(!up_to_date(&declared(true), None), "unknown is deployed");
}
#[test]
fn an_agent_with_no_marker_is_not_pending() {
assert!(!pending(&declared(true), None));
}
#[test]
fn the_marker_holds_the_store_path_and_is_replaced_whole() {
let dir = tempfile::tempdir().unwrap();
let marker = dir.path().join("applied").join(".alice.closure");
assert_eq!(deployed_in(&marker), None, "no marker yet");
mark_deployed_in(&marker, RUNNING).unwrap();
assert_eq!(deployed_in(&marker).as_deref(), Some(RUNNING));
mark_deployed_in(&marker, DECLARED).unwrap();
assert_eq!(deployed_in(&marker).as_deref(), Some(DECLARED));
}
/// A hive that ran the previous marker format has a config commit in its
/// old files and none at the new path: the agent is not pending, and the
/// old files are not read or touched.
#[test]
fn markers_in_the_old_format_are_neither_read_nor_removed() {
let dir = tempfile::tempdir().unwrap();
let old = dir.path().join(".alice.config-rev");
std::fs::write(&old, "1111111111111111111111111111111111111111").unwrap();
let marker = dir.path().join(".alice.closure");
assert_eq!(deployed_in(&marker), None);
assert!(!pending(&declared(true), deployed_in(&marker).as_deref()));
assert!(old.exists());
mark_deployed_in(&marker, DECLARED).unwrap();
assert!(old.exists(), "a successful deploy leaves old markers alone");
}
#[test]
fn an_empty_marker_is_unknown() {
let dir = tempfile::tempdir().unwrap();
let marker = dir.path().join(".alice.closure");
std::fs::write(&marker, "\n").unwrap();
assert_eq!(deployed_in(&marker), None);
}
}

View file

@ -7,9 +7,7 @@
//! [`super::closure`].
//!
//! The verdict is the first of these that fails, in this order:
//! no credentials (`no-credentials`), no record (`no-record`), a record built
//! from another `internal/meta` commit than the head that pins the agent
//! differently (`rev-mismatch`), a record
//! no credentials (`no-credentials`), no record (`no-record`), a record
//! the builder did not push (`not-cached`), the cache not holding the narinfo
//! (`not-cached`), a request that failed or was refused (`fetch-failed`), a
//! narinfo no signature of the key covers (`bad-signature`).
@ -35,7 +33,6 @@ const FAILURES_BEFORE_WARNING: u32 = 3;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Reason {
NoRecord,
RevMismatch,
NotCached,
NoCredentials,
FetchFailed,
@ -47,7 +44,6 @@ impl Reason {
pub const fn label(self) -> &'static str {
match self {
Reason::NoRecord => "no-record",
Reason::RevMismatch => "rev-mismatch",
Reason::NotCached => "not-cached",
Reason::NoCredentials => "no-credentials",
Reason::FetchFailed => "fetch-failed",
@ -84,26 +80,10 @@ impl Cache for Http {
}
}
/// `record` when it was built from the `internal/meta` commit `head`, or from
/// one that pins the agent as `head` does (`same_pins`), and the builder pushed
/// it to the cache.
pub(crate) fn current_record<'a>(
record: Option<&'a AgentClosure>,
head: Option<&str>,
same_pins: bool,
) -> Result<&'a AgentClosure, Unpullable> {
/// `record` when the builder pushed it to the cache.
pub(crate) fn current_record(record: Option<&AgentClosure>) -> Result<&AgentClosure, Unpullable> {
let record =
record.ok_or_else(|| no(Reason::NoRecord, "no build is recorded for the agent"))?;
if head != Some(record.meta_rev.as_str()) && !same_pins {
return Err(no(
Reason::RevMismatch,
format!(
"built from {}, internal/meta is at {}",
record.meta_rev,
head.unwrap_or("nothing")
),
));
}
if !record.cached {
return Err(no(
Reason::NotCached,
@ -113,14 +93,10 @@ pub(crate) fn current_record<'a>(
Ok(record)
}
/// Whether the closure `record` names can be pulled with `facts`. `head` is the
/// `internal/meta` commit a deploy would build from; `same_pins` is whether the
/// record's commit pins the agent as `head` does.
/// Whether the closure `record` names can be pulled with `facts`.
pub(crate) async fn judge(
facts: Option<&Facts>,
record: Option<&AgentClosure>,
head: Option<&str>,
same_pins: bool,
cache: &impl Cache,
) -> Result<(), Unpullable> {
let facts = facts.ok_or_else(|| {
@ -129,7 +105,7 @@ pub(crate) async fn judge(
"the swarm has given this hive no cache facts",
)
})?;
let record = current_record(record, head, same_pins)?;
let record = current_record(record)?;
let text = match cache.narinfo(facts, &record.store_path).await {
Ok(Some(text)) => text,
Ok(None) => return Err(no(Reason::NotCached, "the cache does not hold the narinfo")),
@ -182,7 +158,7 @@ pub async fn follow(client: async_nats::Client, mut shutdown: tokio::sync::watch
/// One pass: publish the facts, then judge every agent.
///
/// # Errors
/// A read that failed (the store, the queue, the forge), which leaves the last
/// A read that failed (the store, the queue), which leaves the last
/// verdicts as they are: the failure says nothing about any agent.
async fn pass(client: &async_nats::Client) -> Result<()> {
swarm_queue_client::ensure_connected(client)?;
@ -190,10 +166,6 @@ async fn pass(client: &async_nats::Client) -> Result<()> {
let agents = crate::lifecycle::agents_for_meta_listing()
.await
.context("enumerating this hive's agents")?;
let head = match &facts {
Some(_) => meta_head().await?,
None => None,
};
let http = Http(narinfo::client()?);
let mut now = Vec::with_capacity(agents.len());
for spec in agents {
@ -201,18 +173,7 @@ async fn pass(client: &async_nats::Client) -> Result<()> {
Some(_) => read_record(client, &spec.name).await?,
None => None,
};
let same_pins = match (&record, head.as_deref()) {
(Some(r), Some(head)) => pins_unchanged(&spec.name, &r.meta_rev, head).await,
_ => false,
};
let verdict = judge(
facts.as_ref(),
record.as_ref(),
head.as_deref(),
same_pins,
&http,
)
.await;
let verdict = judge(facts.as_ref(), record.as_ref(), &http).await;
now.push((spec.name, verdict.as_ref().err().map(|u| u.reason)));
report(&now[now.len() - 1], verdict.err().map(|u| u.detail));
}
@ -222,26 +183,6 @@ async fn pass(client: &async_nats::Client) -> Result<()> {
Ok(())
}
/// Whether `agent` has the same pins at `built` as at `head`. A commit that
/// cannot be read counts as different, which leaves the verdict to the commit
/// ids.
async fn pins_unchanged(agent: &str, built: &str, head: &str) -> bool {
if built == head {
return true;
}
let read = async {
let archetype = crate::archetypes::of(agent)?.is_some();
let source = crate::forge::internal_meta_source()
.await
.context("no forge token")?;
crate::meta_rev::pins_unchanged(&source, agent, archetype, built, head).await
};
read.await.unwrap_or_else(|e| {
tracing::debug!(%agent, error = %format!("{e:#}"), "attic: cannot compare the agent's pins");
false
})
}
pub(crate) async fn read_record(
client: &async_nats::Client,
agent: &str,
@ -256,19 +197,6 @@ pub(crate) async fn read_record(
}
}
/// The `internal/meta` head, or `None` when the swarm has not published it or
/// this hive has no forge token.
async fn meta_head() -> Result<Option<String>> {
use crate::meta_rev::MetaSource as _;
match crate::forge::internal_meta_source().await {
Some(source) => source
.meta_head()
.await
.context("reading internal/meta's head"),
None => Ok(None),
}
}
/// The log line: at info when the verdict differs from the last pass, else
/// debug.
fn report((agent, verdict): &(String, Option<Reason>), detail: Option<String>) {
@ -341,13 +269,9 @@ mod tests {
async fn reason(
facts: Option<&Facts>,
record: Option<&AgentClosure>,
head: Option<&str>,
cache: Answers,
) -> Option<Reason> {
judge(facts, record, head, false, &cache)
.await
.err()
.map(|u| u.reason)
judge(facts, record, &cache).await.err().map(|u| u.reason)
}
fn good() -> Answers {
@ -355,73 +279,37 @@ mod tests {
}
#[tokio::test]
async fn a_signed_narinfo_for_the_head_build_is_pullable() {
assert_eq!(
reason(Some(&facts()), Some(&record()), Some(REV), good()).await,
None
);
async fn a_signed_narinfo_for_the_recorded_build_is_pullable() {
assert_eq!(reason(Some(&facts()), Some(&record()), good()).await, None);
}
#[tokio::test]
async fn no_facts_is_no_credentials_even_with_a_record() {
let r = reason(None, Some(&record()), Some(REV), good()).await;
let r = reason(None, Some(&record()), good()).await;
assert_eq!(r, Some(Reason::NoCredentials));
}
#[tokio::test]
async fn no_record_is_no_record() {
let r = reason(Some(&facts()), None, Some(REV), good()).await;
let r = reason(Some(&facts()), None, good()).await;
assert_eq!(r, Some(Reason::NoRecord));
}
#[tokio::test]
async fn a_record_from_another_commit_is_a_rev_mismatch() {
let r = reason(Some(&facts()), Some(&record()), Some("other"), good()).await;
assert_eq!(r, Some(Reason::RevMismatch));
let r = reason(Some(&facts()), Some(&record()), None, good()).await;
assert_eq!(r, Some(Reason::RevMismatch), "no head to compare with");
}
#[tokio::test]
async fn a_record_from_another_commit_with_the_same_pins_is_pullable() {
let (facts, record) = (facts(), record());
let judged = judge(Some(&facts), Some(&record), Some("other"), true, &good()).await;
assert!(judged.is_ok());
let unpushed = AgentClosure {
cached: false,
..record
};
let judged = judge(Some(&facts), Some(&unpushed), Some("other"), true, &good()).await;
assert_eq!(judged.err().map(|u| u.reason), Some(Reason::NotCached));
}
#[tokio::test]
async fn an_unpushed_record_or_a_missing_narinfo_is_not_cached() {
let unpushed = AgentClosure {
cached: false,
..record()
};
let r = reason(Some(&facts()), Some(&unpushed), Some(REV), good()).await;
let r = reason(Some(&facts()), Some(&unpushed), good()).await;
assert_eq!(r, Some(Reason::NotCached));
let r = reason(
Some(&facts()),
Some(&record()),
Some(REV),
Answers(Ok(None)),
)
.await;
let r = reason(Some(&facts()), Some(&record()), Answers(Ok(None))).await;
assert_eq!(r, Some(Reason::NotCached));
}
#[tokio::test]
async fn a_failed_request_is_fetch_failed() {
let r = reason(
Some(&facts()),
Some(&record()),
Some(REV),
Answers(Err("401")),
)
.await;
let r = reason(Some(&facts()), Some(&record()), Answers(Err("401"))).await;
assert_eq!(r, Some(Reason::FetchFailed));
}
@ -430,7 +318,7 @@ mod tests {
let forged = Answers(Ok(Some(narinfo_signed_by(&SigningKey::from_bytes(
&[9; 32],
)))));
let r = reason(Some(&facts()), Some(&record()), Some(REV), forged).await;
let r = reason(Some(&facts()), Some(&record()), forged).await;
assert_eq!(r, Some(Reason::BadSignature));
}
@ -438,7 +326,6 @@ mod tests {
fn the_labels_are_the_documented_ones() {
let labels = [
Reason::NoRecord,
Reason::RevMismatch,
Reason::NotCached,
Reason::NoCredentials,
Reason::FetchFailed,
@ -449,7 +336,6 @@ mod tests {
labels,
[
"no-record",
"rev-mismatch",
"not-cached",
"no-credentials",
"fetch-failed",