//! Publishing: author compiled host manifests (and their file bodies) into the
//! iroh document the agents sync from.
//!
//! Generation discipline: the agent restarts services when a manifest's
//! generation advances *and* a file changed, so we must not bump a host's
//! generation on a no-op republish. Before writing, we read the host's current
//! manifest from the doc and compare desired state (files + services, ignoring
//! generation). Only a real difference advances the generation.
use std::path::Path;
use anyhow::{Context, Result};
use devendra_node::{DocHandle, Node};
use devendra_proto::{Manifest, keys};
use crate::store::{self, LoadedHost};
/// What happened to one host during a publish.
#[derive(Debug, Clone)]
pub struct PublishOutcome {
pub host_id: String,
pub generation: u64,
pub changed: bool,
}
/// Compile and publish every host under `hosts_dir`. `config_dir` roots
/// relative file `source` references.
pub async fn publish_all(
node: &Node,
doc: &DocHandle,
config_dir: &Path,
) -> Result<Vec<PublishOutcome>> {
let hosts = store::load_all(&config_dir.join("hosts")).context("loading host configs")?;
let mut outcomes = Vec::with_capacity(hosts.len());
for host in hosts {
outcomes.push(publish_host(node, doc, config_dir, &host).await?);
}
Ok(outcomes)
}
/// Compile and publish a single already-loaded host.
pub async fn publish_host(
node: &Node,
doc: &DocHandle,
config_dir: &Path,
host: &LoadedHost,
) -> Result<PublishOutcome> {
store::validate_host_id(&host.id)?;
let compiled = host
.config
.resolve(config_dir)
.with_context(|| format!("resolving includes for host {}", host.id))?
.compile(config_dir)
.with_context(|| format!("compiling host {}", host.id))?;
// Publish file bodies first so they're available (and syncing) by the time
// the agent sees a manifest that references them.
for (path, body) in &compiled.bodies {
let key = keys::file(&host.id, path);
node.write(doc, &key, body.clone())
.await
.with_context(|| format!("publishing body {key}"))?;
}
// Decide the generation by diffing against what's already published.
let existing: Option<Manifest> = match node.read(doc, &keys::manifest(&host.id)).await? {
Some(bytes) => serde_json::from_slice(&bytes).ok(),
None => None,
};
let mut manifest = compiled.manifest;
let (generation, changed) = match &existing {
Some(prev) if same_desired(prev, &manifest) => (prev.generation, false),
Some(prev) => (prev.generation + 1, true),
None => (1, true),
};
manifest.generation = generation;
if changed {
let json = serde_json::to_vec(&manifest).context("serializing manifest")?;
node.write(doc, &keys::manifest(&host.id), json)
.await
.with_context(|| format!("publishing manifest for {}", host.id))?;
}
Ok(PublishOutcome {
host_id: host.id.clone(),
generation,
changed,
})
}
/// Compare two manifests' desired state, ignoring the generation counter.
fn same_desired(a: &Manifest, b: &Manifest) -> bool {
a.protocol_version == b.protocol_version
&& a.packages == b.packages
&& a.directories == b.directories
&& a.files == b.files
&& a.services == b.services
}
#[cfg(test)]
mod tests {
use super::*;
use devendra_proto::{FileResource, FileState, PROTOCOL_VERSION};
fn manifest(generation: u64, path: &str) -> Manifest {
Manifest {
protocol_version: PROTOCOL_VERSION,
generation,
packages: vec![],
directories: vec![],
files: vec![FileResource {
path: path.into(),
state: FileState::Present,
content_blake3: Some("abc".into()),
mode: None,
owner: None,
group: None,
}],
services: vec![],
}
}
#[test]
fn same_desired_ignores_generation() {
assert!(same_desired(&manifest(1, "/a"), &manifest(9, "/a")));
assert!(!same_desired(&manifest(1, "/a"), &manifest(1, "/b")));
}
}
neiam
/devendra
public · Issues · Pulls · Labels · Forks · Compare · Actions failure · Badges · Packages
⭐
Log in to mark this repository.
4.2 KiB · text
History
e832e77