//! Flat-file desired-state store.
//!
//! The source of truth is a directory of per-host TOML files under
//! `<config_dir>/hosts/<host_id>.toml`. Each describes the files to install and
//! services to manage in an operator-friendly shape (octal mode strings, inline
//! or file-referenced content), which [`HostConfig::compile`] lowers into a
//! [`Manifest`] plus the file bodies to publish.
//!
//! The web UI edits these TOML files directly (see [`read_raw`]/[`write_raw`]),
//! keeping the on-disk files authoritative and git-friendly.
use std::collections::{HashMap, HashSet};
use std::path::{Path, PathBuf};
use anyhow::{Context, Result, bail};
use serde::{Deserialize, Serialize};
use devendra_proto::{
DirResource, FileResource, FileState, Manifest, PROTOCOL_VERSION, PackageResource,
ServiceResource, ServiceState, content_hash,
};
/// One host's desired state, as authored on disk.
///
/// A host file may pull in shared fragments via [`HostConfig::include`], letting
/// common quadlets/services be defined once and reused across hosts. The root
/// file's own entries take precedence over included ones (see
/// [`HostConfig::resolve`]).
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
pub struct HostConfig {
/// Paths (relative to `config_dir`) of fragment TOML files to merge in
/// before this file's own entries. Fragments have the same shape and may
/// themselves include others.
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub include: Vec<String>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub packages: Vec<PackageEntry>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub directories: Vec<DirEntry>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub files: Vec<FileEntry>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub services: Vec<ServiceEntry>,
}
/// A system package to install or remove on the host.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct PackageEntry {
pub name: String,
/// `"present"` (default) or `"absent"`.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub state: Option<String>,
}
/// A directory to ensure exists on the host.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct DirEntry {
/// Absolute path on the target host.
pub path: String,
/// Octal permission string, e.g. `"0755"`.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub mode: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub owner: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub group: Option<String>,
/// `"present"` (default) or `"absent"`.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub state: Option<String>,
}
/// A config file to install on the host.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct FileEntry {
/// Absolute destination path on the target host.
pub path: String,
/// Inline file content. Mutually exclusive with `source`.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub content: Option<String>,
/// Path (relative to `config_dir`) to a file whose bytes are the content.
/// Mutually exclusive with `content`.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub source: Option<String>,
/// Octal permission string, e.g. `"0644"`.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub mode: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub owner: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub group: Option<String>,
/// `"present"` (default) or `"absent"`.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub state: Option<String>,
}
/// A service to reconcile.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ServiceEntry {
pub name: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub enabled: Option<bool>,
/// `"started"`, `"stopped"`, `"restarted"`, or `"reloaded"`.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub state: Option<String>,
}
/// A compiled host: the manifest to publish plus the file bodies it references,
/// keyed by destination path.
pub struct Compiled {
pub manifest: Manifest,
pub bodies: HashMap<String, Vec<u8>>,
}
impl HostConfig {
/// Resolve `include`s into a single flattened config with no remaining
/// includes. Fragments are merged depth-first in listed order, then this
/// file's own entries are applied last so the root always wins. Entries are
/// keyed by file `path` / service `name`: a later definition replaces an
/// earlier one in place. Diamond includes are merged once; cycles error.
///
/// Include paths are relative to `config_dir`.
pub fn resolve(&self, config_dir: &Path) -> Result<HostConfig> {
let mut merged = HostConfig::default();
let mut visiting = HashSet::new();
let mut done = HashSet::new();
self.merge_into(config_dir, &mut merged, &mut visiting, &mut done)?;
Ok(merged)
}
fn merge_into(
&self,
config_dir: &Path,
merged: &mut HostConfig,
visiting: &mut HashSet<PathBuf>,
done: &mut HashSet<PathBuf>,
) -> Result<()> {
for inc in &self.include {
let path = config_dir.join(inc);
let canon =
std::fs::canonicalize(&path).with_context(|| format!("resolving include {inc}"))?;
if done.contains(&canon) {
continue; // already merged via another path (diamond)
}
if !visiting.insert(canon.clone()) {
bail!("include cycle detected at {inc}");
}
let text =
std::fs::read_to_string(&path).with_context(|| format!("reading include {inc}"))?;
let fragment: HostConfig =
toml::from_str(&text).with_context(|| format!("parsing include {inc}"))?;
fragment.merge_into(config_dir, merged, visiting, done)?;
visiting.remove(&canon);
done.insert(canon);
}
// This file's own entries override anything pulled in from includes.
for p in &self.packages {
match merged.packages.iter_mut().find(|e| e.name == p.name) {
Some(existing) => *existing = p.clone(),
None => merged.packages.push(p.clone()),
}
}
for d in &self.directories {
match merged.directories.iter_mut().find(|e| e.path == d.path) {
Some(existing) => *existing = d.clone(),
None => merged.directories.push(d.clone()),
}
}
for f in &self.files {
match merged.files.iter_mut().find(|e| e.path == f.path) {
Some(existing) => *existing = f.clone(),
None => merged.files.push(f.clone()),
}
}
for s in &self.services {
match merged.services.iter_mut().find(|e| e.name == s.name) {
Some(existing) => *existing = s.clone(),
None => merged.services.push(s.clone()),
}
}
Ok(())
}
/// Lower this config into a [`Manifest`] (at generation 0 — the publisher
/// assigns the real generation) and resolve all file bodies.
///
/// `config_dir` roots relative `source` paths. Call [`Self::resolve`] first
/// if the config may contain `include`s.
pub fn compile(&self, config_dir: &Path) -> Result<Compiled> {
let mut files = Vec::with_capacity(self.files.len());
let mut bodies = HashMap::new();
for f in &self.files {
let state = parse_file_state(f.state.as_deref())?;
let mode = match &f.mode {
Some(m) => {
Some(parse_mode(m).with_context(|| format!("mode {m:?} for {}", f.path))?)
}
None => None,
};
let content_blake3 = if state == FileState::Present {
let body = resolve_body(f, config_dir)
.with_context(|| format!("resolving content for {}", f.path))?;
let hash = content_hash(&body);
bodies.insert(f.path.clone(), body);
Some(hash)
} else {
None
};
files.push(FileResource {
path: f.path.clone(),
state,
content_blake3,
mode,
owner: f.owner.clone(),
group: f.group.clone(),
});
}
let mut packages = Vec::with_capacity(self.packages.len());
for p in &self.packages {
packages.push(PackageResource {
name: p.name.clone(),
state: parse_file_state(p.state.as_deref())
.with_context(|| format!("state for package {}", p.name))?,
});
}
let mut directories = Vec::with_capacity(self.directories.len());
for d in &self.directories {
let mode = match &d.mode {
Some(m) => {
Some(parse_mode(m).with_context(|| format!("mode {m:?} for dir {}", d.path))?)
}
None => None,
};
directories.push(DirResource {
path: d.path.clone(),
state: parse_file_state(d.state.as_deref())
.with_context(|| format!("state for dir {}", d.path))?,
mode,
owner: d.owner.clone(),
group: d.group.clone(),
});
}
let mut services = Vec::with_capacity(self.services.len());
for s in &self.services {
services.push(ServiceResource {
name: s.name.clone(),
enabled: s.enabled,
state: parse_service_state(s.state.as_deref())
.with_context(|| format!("state for service {}", s.name))?,
});
}
Ok(Compiled {
manifest: Manifest {
protocol_version: PROTOCOL_VERSION,
generation: 0,
packages,
directories,
files,
services,
},
bodies,
})
}
}
fn resolve_body(f: &FileEntry, config_dir: &Path) -> Result<Vec<u8>> {
match (&f.content, &f.source) {
(Some(_), Some(_)) => bail!("file {} sets both `content` and `source`", f.path),
(Some(c), None) => Ok(c.clone().into_bytes()),
(None, Some(src)) => {
let path = config_dir.join(src);
std::fs::read(&path).with_context(|| format!("reading source {}", path.display()))
}
(None, None) => bail!(
"file {} is present but has neither `content` nor `source`",
f.path
),
}
}
fn parse_mode(s: &str) -> Result<u32> {
let trimmed = s.trim().trim_start_matches("0o");
u32::from_str_radix(trimmed, 8).with_context(|| format!("invalid octal mode {s:?}"))
}
fn parse_file_state(s: Option<&str>) -> Result<FileState> {
match s {
None | Some("present") => Ok(FileState::Present),
Some("absent") => Ok(FileState::Absent),
Some(other) => bail!("invalid file state {other:?} (want present|absent)"),
}
}
fn parse_service_state(s: Option<&str>) -> Result<Option<ServiceState>> {
Ok(match s {
None => None,
Some("started") => Some(ServiceState::Started),
Some("stopped") => Some(ServiceState::Stopped),
Some("restarted") => Some(ServiceState::Restarted),
Some("reloaded") => Some(ServiceState::Reloaded),
Some(other) => bail!("invalid service state {other:?}"),
})
}
/// A loaded host config paired with its id and any load error.
pub struct LoadedHost {
pub id: String,
pub config: HostConfig,
}
/// List host ids present in the hosts directory (files named `<id>.toml`),
/// sorted. Missing directory yields an empty list.
pub fn list_host_ids(hosts_dir: &Path) -> Result<Vec<String>> {
let mut ids = Vec::new();
let entries = match std::fs::read_dir(hosts_dir) {
Ok(e) => e,
Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(ids),
Err(e) => return Err(e).context("reading hosts dir"),
};
for entry in entries {
let entry = entry?;
let path = entry.path();
if path.extension().and_then(|e| e.to_str()) == Some("toml")
&& let Some(stem) = path.file_stem().and_then(|s| s.to_str())
{
ids.push(stem.to_string());
}
}
ids.sort();
Ok(ids)
}
/// Load and parse a single host's config.
pub fn load_host(hosts_dir: &Path, id: &str) -> Result<HostConfig> {
let raw = read_raw(hosts_dir, id)?;
toml::from_str(&raw).with_context(|| format!("parsing {id}.toml"))
}
/// Load every host config. Parse errors abort the whole load so a broken file
/// can't silently drop a host from a publish.
pub fn load_all(hosts_dir: &Path) -> Result<Vec<LoadedHost>> {
list_host_ids(hosts_dir)?
.into_iter()
.map(|id| {
let config = load_host(hosts_dir, &id)?;
Ok(LoadedHost { id, config })
})
.collect()
}
fn host_path(hosts_dir: &Path, id: &str) -> Result<PathBuf> {
validate_host_id(id)?;
Ok(hosts_dir.join(format!("{id}.toml")))
}
/// Read a host's raw TOML text (for the editor).
pub fn read_raw(hosts_dir: &Path, id: &str) -> Result<String> {
let path = host_path(hosts_dir, id)?;
std::fs::read_to_string(&path).with_context(|| format!("reading {}", path.display()))
}
/// Write a host's raw TOML text, after validating it parses. Creates the hosts
/// directory if needed.
pub fn write_raw(hosts_dir: &Path, id: &str, toml_text: &str) -> Result<()> {
// Reject invalid TOML before touching disk so the editor round-trips safely.
toml::from_str::<HostConfig>(toml_text).context("config does not parse as valid host TOML")?;
let path = host_path(hosts_dir, id)?;
std::fs::create_dir_all(hosts_dir).context("creating hosts dir")?;
std::fs::write(&path, toml_text).with_context(|| format!("writing {}", path.display()))?;
Ok(())
}
/// Write an adopted file body into the config tree as a reusable source file
/// under `<config_dir>/files/<id>/<rel>`, returning the `source` path (relative
/// to `config_dir`) to record in the host TOML. Rejects path traversal in `rel`.
pub fn write_source_file(config_dir: &Path, id: &str, rel: &str, bytes: &[u8]) -> Result<String> {
validate_host_id(id)?;
let rel = rel.trim_start_matches('/');
if rel.is_empty() || rel.split('/').any(|c| c == ".." || c.is_empty()) {
bail!("unsafe source path {rel:?}");
}
let source = format!("files/{id}/{rel}");
let dest = config_dir.join(&source);
if let Some(parent) = dest.parent() {
std::fs::create_dir_all(parent)
.with_context(|| format!("creating {}", parent.display()))?;
}
std::fs::write(&dest, bytes).with_context(|| format!("writing {}", dest.display()))?;
Ok(source)
}
/// Merge `new_files` into host `id`'s config, creating the host if it doesn't
/// exist yet. Existing file entries with the same `path` are replaced; others
/// are kept. The result is written back as TOML.
pub fn upsert_files(hosts_dir: &Path, id: &str, new_files: Vec<FileEntry>) -> Result<()> {
let mut config = match read_raw(hosts_dir, id) {
Ok(raw) => {
toml::from_str::<HostConfig>(&raw).with_context(|| format!("parsing {id}.toml"))?
}
Err(_) => HostConfig::default(), // new host
};
for nf in new_files {
if let Some(existing) = config.files.iter_mut().find(|f| f.path == nf.path) {
*existing = nf;
} else {
config.files.push(nf);
}
}
let toml_text = toml::to_string_pretty(&config).context("serializing host config")?;
write_raw(hosts_dir, id, &toml_text)
}
// ---- fragments -------------------------------------------------------------
//
// Fragments are reusable `HostConfig`-shaped TOML files under
// `<config_dir>/include/`, referenced from a host's `include = [...]`. They are
// flat (no subdirectories) so a fragment id is a single safe path segment.
/// The `include`-path form used to reference fragment `id` from a host file.
pub fn fragment_include_ref(id: &str) -> String {
format!("include/{id}.toml")
}
/// The fragment id an `include`-path refers to, if it's a well-formed reference
/// to `include/<id>.toml`.
pub fn fragment_id_from_ref(reference: &str) -> Option<&str> {
reference.strip_prefix("include/")?.strip_suffix(".toml")
}
/// Append fragment `fragment_id` to host `id`'s `include` list (no-op if already
/// present) and write the config back.
pub fn add_include(hosts_dir: &Path, id: &str, fragment_id: &str) -> Result<()> {
validate_fragment_id(fragment_id)?;
let mut config = load_host(hosts_dir, id)?;
let reference = fragment_include_ref(fragment_id);
if !config.include.contains(&reference) {
config.include.push(reference);
}
let toml_text = toml::to_string_pretty(&config).context("serializing host config")?;
write_raw(hosts_dir, id, &toml_text)
}
/// Remove an `include` reference from host `id`'s config and write it back.
pub fn remove_include(hosts_dir: &Path, id: &str, reference: &str) -> Result<()> {
let mut config = load_host(hosts_dir, id)?;
config.include.retain(|i| i != reference);
let toml_text = toml::to_string_pretty(&config).context("serializing host config")?;
write_raw(hosts_dir, id, &toml_text)
}
/// List fragment ids (files named `<id>.toml` directly under `include/`), sorted.
pub fn list_fragment_ids(include_dir: &Path) -> Result<Vec<String>> {
let mut ids = Vec::new();
let entries = match std::fs::read_dir(include_dir) {
Ok(e) => e,
Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(ids),
Err(e) => return Err(e).context("reading include dir"),
};
for entry in entries {
let entry = entry?;
if !entry.file_type()?.is_file() {
continue;
}
let path = entry.path();
if path.extension().and_then(|e| e.to_str()) == Some("toml")
&& let Some(stem) = path.file_stem().and_then(|s| s.to_str())
{
ids.push(stem.to_string());
}
}
ids.sort();
Ok(ids)
}
fn fragment_path(include_dir: &Path, id: &str) -> Result<PathBuf> {
validate_fragment_id(id)?;
Ok(include_dir.join(format!("{id}.toml")))
}
/// Read a fragment's raw TOML text.
pub fn read_fragment_raw(include_dir: &Path, id: &str) -> Result<String> {
let path = fragment_path(include_dir, id)?;
std::fs::read_to_string(&path).with_context(|| format!("reading {}", path.display()))
}
/// Write a fragment's raw TOML, after validating it parses as a config.
pub fn write_fragment_raw(include_dir: &Path, id: &str, toml_text: &str) -> Result<()> {
toml::from_str::<HostConfig>(toml_text)
.context("fragment does not parse as valid config TOML")?;
let path = fragment_path(include_dir, id)?;
std::fs::create_dir_all(include_dir).context("creating include dir")?;
std::fs::write(&path, toml_text).with_context(|| format!("writing {}", path.display()))?;
Ok(())
}
/// Host ids whose config directly `include`s fragment `id`. Best-effort: broken
/// host files are skipped rather than failing the lookup.
pub fn hosts_using_fragment(hosts_dir: &Path, fragment_id: &str) -> Result<Vec<String>> {
let want = fragment_include_ref(fragment_id);
let mut users = Vec::new();
for id in list_host_ids(hosts_dir)? {
if let Ok(cfg) = load_host(hosts_dir, &id)
&& cfg.include.iter().any(|i| i == &want)
{
users.push(id);
}
}
Ok(users)
}
/// A fragment id must be a safe single path segment (same rules as a host id).
pub fn validate_fragment_id(id: &str) -> Result<()> {
validate_host_id(id).map_err(|_| {
anyhow::anyhow!("invalid fragment id {id:?}: use [A-Za-z0-9._-], not starting with '.'")
})
}
/// A host id must be a safe single path segment so it can key both a filename
/// and a document keyspace.
pub fn validate_host_id(id: &str) -> Result<()> {
if id.is_empty()
|| id.len() > 64
|| !id
.chars()
.all(|c| c.is_ascii_alphanumeric() || c == '-' || c == '_' || c == '.')
|| id.starts_with('.')
{
bail!("invalid host id {id:?}: use [A-Za-z0-9._-], not starting with '.'");
}
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn compiles_inline_and_service() {
let cfg: HostConfig = toml::from_str(
r#"
[[files]]
path = "/etc/containers/systemd/web.container"
content = "[Container]\nImage=nginx\n"
mode = "0644"
[[services]]
name = "web.service"
enabled = true
state = "started"
"#,
)
.unwrap();
let compiled = cfg.compile(Path::new("/nonexistent")).unwrap();
assert_eq!(compiled.manifest.files.len(), 1);
let file = &compiled.manifest.files[0];
assert_eq!(file.mode, Some(0o644));
assert_eq!(file.state, FileState::Present);
// Body is present and its hash matches the manifest.
let body = &compiled.bodies[&file.path];
assert_eq!(
file.content_blake3.as_deref(),
Some(content_hash(body).as_str())
);
let svc = &compiled.manifest.services[0];
assert_eq!(svc.enabled, Some(true));
assert_eq!(svc.state, Some(ServiceState::Started));
assert!(compiled.manifest.requires_daemon_reload());
}
#[test]
fn compiles_packages() {
let cfg: HostConfig = toml::from_str(
r#"
[[packages]]
name = "mosquitto"
[[packages]]
name = "telnet"
state = "absent"
"#,
)
.unwrap();
let compiled = cfg.compile(Path::new("/nonexistent")).unwrap();
assert_eq!(compiled.manifest.packages.len(), 2);
assert_eq!(compiled.manifest.packages[0].name, "mosquitto");
assert_eq!(compiled.manifest.packages[0].state, FileState::Present);
assert_eq!(compiled.manifest.packages[1].state, FileState::Absent);
assert!(compiled.bodies.is_empty());
}
#[test]
fn compiles_directories() {
let cfg: HostConfig = toml::from_str(
r#"
[[directories]]
path = "/mnt/hass"
mode = "0750"
owner = "hass"
"#,
)
.unwrap();
let compiled = cfg.compile(Path::new("/nonexistent")).unwrap();
assert_eq!(compiled.manifest.directories.len(), 1);
let d = &compiled.manifest.directories[0];
assert_eq!(d.path, "/mnt/hass");
assert_eq!(d.mode, Some(0o750));
assert_eq!(d.owner.as_deref(), Some("hass"));
assert_eq!(d.state, devendra_proto::FileState::Present);
// Directories need no file bodies.
assert!(compiled.bodies.is_empty());
}
#[test]
fn absent_file_needs_no_body() {
let cfg: HostConfig = toml::from_str(
r#"
[[files]]
path = "/etc/old.conf"
state = "absent"
"#,
)
.unwrap();
let compiled = cfg.compile(Path::new("/nonexistent")).unwrap();
assert_eq!(compiled.manifest.files[0].state, FileState::Absent);
assert!(compiled.bodies.is_empty());
}
#[test]
fn rejects_bad_host_ids() {
assert!(validate_host_id("web-01").is_ok());
assert!(validate_host_id("a.b_c").is_ok());
assert!(validate_host_id("../etc").is_err());
assert!(validate_host_id("has/slash").is_err());
assert!(validate_host_id(".hidden").is_err());
assert!(validate_host_id("").is_err());
}
#[test]
fn present_file_without_content_is_error() {
let cfg: HostConfig = toml::from_str(
r#"
[[files]]
path = "/etc/x.conf"
"#,
)
.unwrap();
assert!(cfg.compile(Path::new("/nonexistent")).is_err());
}
// ---- includes ----------------------------------------------------------
/// Build a temporary config dir with the given `name -> contents` files.
fn temp_config(files: &[(&str, &str)]) -> PathBuf {
// Deterministic unique dir without external deps or a clock: hash inputs.
let mut hasher = blake3::Hasher::new();
for (n, c) in files {
hasher.update(n.as_bytes());
hasher.update(c.as_bytes());
}
let dir =
std::env::temp_dir().join(format!("devendra-test-{}", hasher.finalize().to_hex()));
let _ = std::fs::remove_dir_all(&dir);
for (name, contents) in files {
let path = dir.join(name);
std::fs::create_dir_all(path.parent().unwrap()).unwrap();
std::fs::write(&path, contents).unwrap();
}
dir
}
#[test]
fn include_merges_fragments_with_root_overriding() {
let dir = temp_config(&[
(
"include/base.toml",
r#"
[[files]]
path = "/etc/shared.conf"
content = "from base"
mode = "0644"
[[services]]
name = "shared.service"
state = "started"
"#,
),
(
"hosts/web.toml",
r#"
include = ["include/base.toml"]
# Override the shared file's content; add a host-specific one.
[[files]]
path = "/etc/shared.conf"
content = "from web"
mode = "0600"
[[files]]
path = "/etc/web.conf"
content = "web only"
"#,
),
]);
let root = load_host(&dir.join("hosts"), "web").unwrap();
let merged = root.resolve(&dir).unwrap();
// shared.conf comes from base's position but with web's values (root wins).
assert_eq!(merged.files.len(), 2);
assert_eq!(merged.files[0].path, "/etc/shared.conf");
assert_eq!(merged.files[0].content.as_deref(), Some("from web"));
assert_eq!(merged.files[0].mode.as_deref(), Some("0600"));
assert_eq!(merged.files[1].path, "/etc/web.conf");
// The service from the include survives.
assert_eq!(merged.services.len(), 1);
assert_eq!(merged.services[0].name, "shared.service");
// And it compiles cleanly end to end.
let compiled = merged.compile(&dir).unwrap();
assert_eq!(compiled.bodies["/etc/shared.conf"], b"from web");
}
#[test]
fn include_cycle_is_rejected() {
let dir = temp_config(&[
("include/a.toml", "include = [\"include/b.toml\"]\n"),
("include/b.toml", "include = [\"include/a.toml\"]\n"),
("hosts/h.toml", "include = [\"include/a.toml\"]\n"),
]);
let root = load_host(&dir.join("hosts"), "h").unwrap();
let err = root.resolve(&dir).unwrap_err();
assert!(format!("{err:#}").contains("cycle"), "{err:#}");
}
#[test]
fn add_and_remove_include() {
let dir = temp_config(&[("hosts/web.toml", "include = [\"include/base.toml\"]\n")]);
let hosts_dir = dir.join("hosts");
add_include(&hosts_dir, "web", "nginx").unwrap();
let cfg = load_host(&hosts_dir, "web").unwrap();
assert_eq!(cfg.include, vec!["include/base.toml", "include/nginx.toml"]);
// Adding the same fragment again is a no-op.
add_include(&hosts_dir, "web", "nginx").unwrap();
assert_eq!(load_host(&hosts_dir, "web").unwrap().include.len(), 2);
remove_include(&hosts_dir, "web", "include/base.toml").unwrap();
assert_eq!(
load_host(&hosts_dir, "web").unwrap().include,
vec!["include/nginx.toml"]
);
assert_eq!(fragment_id_from_ref("include/nginx.toml"), Some("nginx"));
assert_eq!(fragment_id_from_ref("weird"), None);
}
#[test]
fn fragment_crud_and_usage_lookup() {
let dir = temp_config(&[("hosts/web.toml", "include = [\"include/nginx.toml\"]\n")]);
let include_dir = dir.join("include");
let hosts_dir = dir.join("hosts");
// Initially no fragments.
assert!(list_fragment_ids(&include_dir).unwrap().is_empty());
// Write one, read it back, and confirm it lists.
write_fragment_raw(
&include_dir,
"nginx",
"[[files]]\npath=\"/x\"\ncontent=\"y\"\n",
)
.unwrap();
assert_eq!(list_fragment_ids(&include_dir).unwrap(), vec!["nginx"]);
assert!(
read_fragment_raw(&include_dir, "nginx")
.unwrap()
.contains("/x")
);
// The host that includes it is found; another id is not.
assert_eq!(
hosts_using_fragment(&hosts_dir, "nginx").unwrap(),
vec!["web"]
);
assert!(
hosts_using_fragment(&hosts_dir, "other")
.unwrap()
.is_empty()
);
// Invalid TOML and unsafe ids are rejected.
assert!(write_fragment_raw(&include_dir, "bad", "not = = toml").is_err());
assert!(validate_fragment_id("../escape").is_err());
assert_eq!(fragment_include_ref("nginx"), "include/nginx.toml");
}
#[test]
fn diamond_include_merges_once() {
let dir = temp_config(&[
(
"include/common.toml",
"[[files]]\npath = \"/etc/c.conf\"\ncontent = \"c\"\n",
),
("include/left.toml", "include = [\"include/common.toml\"]\n"),
(
"include/right.toml",
"include = [\"include/common.toml\"]\n",
),
(
"hosts/h.toml",
"include = [\"include/left.toml\", \"include/right.toml\"]\n",
),
]);
let root = load_host(&dir.join("hosts"), "h").unwrap();
let merged = root.resolve(&dir).unwrap();
assert_eq!(merged.files.len(), 1, "common should merge exactly once");
}
}
neiam
/devendra
public · Issues · Pulls · Labels · Forks · Compare · Actions failure · Badges · Packages
⭐
Log in to mark this repository.
30.1 KiB · text
History
e832e77