3.5 KiB · text History e832e77
//! Controller side of the adoption workflow: read proposals agents publish,
//! list which hosts have one pending, and accept selected files by turning them
//! into managed config.

use std::collections::HashSet;

use anyhow::{Context, Result, bail};
use devendra_node::{DocHandle, Node};
use devendra_proto::{AdoptionReport, content_hash, keys};

use crate::{Config, publish, store};

/// Read a host's adoption proposal, if any.
pub async fn read_report(
    node: &Node,
    doc: &DocHandle,
    host: &str,
) -> Result<Option<AdoptionReport>> {
    let Some(bytes) = node
        .read(doc, &keys::adopt_manifest(&host.to_string()))
        .await?
    else {
        return Ok(None);
    };
    Ok(serde_json::from_slice(&bytes).ok())
}

/// Read the body of one proposed file, verifying it matches the report's hash.
pub async fn read_body(
    node: &Node,
    doc: &DocHandle,
    host: &str,
    path: &str,
    expected_hash: &str,
) -> Result<Vec<u8>> {
    let key = keys::adopt_file(&host.to_string(), path);
    let body = node
        .read(doc, &key)
        .await?
        .with_context(|| format!("body for {path} not synced yet"))?;
    if content_hash(&body) != expected_hash {
        bail!("content hash mismatch for {path}");
    }
    Ok(body)
}

/// List host ids that currently have a pending adoption proposal.
pub async fn list_pending(node: &Node, doc: &DocHandle) -> Result<Vec<String>> {
    let all = node.list_keys(doc, keys::HOST_PREFIX).await?;
    let mut ids: Vec<String> = all
        .iter()
        .filter_map(|k| keys::host_from_adopt_key(k))
        .map(str::to_string)
        .collect();
    ids.sort();
    ids.dedup();
    Ok(ids)
}

/// Accept the selected proposed files for `host`: write their bodies as source
/// files, merge `[[files]]` entries into the host TOML (creating the host if
/// new), clear the proposal, and republish. Returns the number of files adopted.
pub async fn accept(
    node: &Node,
    doc: &DocHandle,
    config: &Config,
    host: &str,
    selected: &[String],
) -> Result<usize> {
    store::validate_host_id(host)?;
    let Some(report) = read_report(node, doc, host).await? else {
        bail!("no adoption proposal for host {host}");
    };
    let wanted: HashSet<&str> = selected.iter().map(String::as_str).collect();

    let mut new_files = Vec::new();
    for f in &report.files {
        if !wanted.contains(f.path.as_str()) {
            continue;
        }
        let body = read_body(node, doc, host, &f.path, &f.content_blake3).await?;
        let source = store::write_source_file(&config.config_dir, host, &f.rel_path, &body)?;
        new_files.push(store::FileEntry {
            path: f.path.clone(),
            content: None,
            source: Some(source),
            mode: Some(format!("{:04o}", f.mode)),
            owner: None,
            group: None,
            state: None,
        });
    }
    if new_files.is_empty() {
        bail!("no matching files selected");
    }
    let count = new_files.len();

    store::upsert_files(&config.hosts_dir(), host, new_files)?;
    // The proposal has been absorbed into config; clear it so it stops showing.
    node.delete_prefix(doc, &keys::adopt_prefix(&host.to_string()))
        .await?;

    // Publish the now-managed host so agents converge to it.
    let loaded = store::LoadedHost {
        id: host.to_string(),
        config: store::load_host(&config.hosts_dir(), host)?,
    };
    publish::publish_host(node, doc, &config.config_dir, &loaded).await?;

    Ok(count)
}