deploy the closure the swarm declares, read no forge head, drop the update badge
This commit is contained in:
parent
279428a66f
commit
47c2acb640
2 changed files with 647 additions and 2001 deletions
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
Loading…
Reference in a new issue