//! 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)
}
neiam
/devendra
public · Issues · Pulls · Labels · Forks · Compare · Actions failure · Badges · Packages
⭐
Log in to mark this repository.
3.5 KiB · text
History
d69022d