4.2 KiB · text History d69022d
//! 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")));
    }
}