use std::collections::BTreeMap;
use std::path::{Path, PathBuf};
use anyhow::{bail, Context, Result};
use serde::Deserialize;
use workload_spec::{NamespaceId, TenantId, WorkloadSpec};
use crate::config::CloudConfig;
#[derive(Debug, Default, Deserialize)]
struct RootMarker {
tenant: Option<String>,
namespace: Option<String>,
}
#[derive(Debug, Clone)]
pub struct ConfigRoot {
pub config_dir: PathBuf,
pub workspace_root: PathBuf,
pub tenant: TenantId,
pub namespace: NamespaceId,
}
impl ConfigRoot {
pub fn from_config_dir(config_dir: &Path, workspace_root: &Path) -> Result<Self> {
let dir_name = config_dir
.file_name()
.and_then(|n| n.to_str())
.ok_or_else(|| {
anyhow::anyhow!("config root {} has no usable name", config_dir.display())
})?;
let derived_ns = dir_name.strip_prefix('.').unwrap_or(dir_name).to_string();
let marker_path = config_dir.join("namespace.toml");
let marker: RootMarker = if marker_path.exists() {
let text = std::fs::read_to_string(&marker_path)
.with_context(|| format!("reading {}", marker_path.display()))?;
toml::from_str(&text).with_context(|| format!("parsing {}", marker_path.display()))?
} else {
RootMarker::default()
};
let tenant = marker
.tenant
.map(TenantId)
.unwrap_or_else(TenantId::singleton);
let namespace = NamespaceId(marker.namespace.unwrap_or(derived_ns));
Ok(Self {
config_dir: config_dir.to_path_buf(),
workspace_root: workspace_root.to_path_buf(),
tenant,
namespace,
})
}
}
#[derive(Debug)]
pub struct LoadedRoot {
pub config_dir: PathBuf,
pub tenant: TenantId,
pub namespace: NamespaceId,
pub config: CloudConfig,
}
impl LoadedRoot {
pub fn stamp(&self, spec: &mut WorkloadSpec) {
spec.tenant = self.tenant.clone();
spec.namespace = self.namespace.clone();
}
fn declared_names(&self) -> impl Iterator<Item = String> + '_ {
self.config
.services
.keys()
.cloned()
.chain(self.config.workloads.iter().map(|w| w.spec.name.clone()))
}
}
#[derive(Debug)]
pub struct MultiRootConfig {
pub roots: Vec<LoadedRoot>,
}
impl MultiRootConfig {
pub fn load(roots: &[ConfigRoot]) -> Result<Self> {
let mut loaded = Vec::with_capacity(roots.len());
let mut seen_ns: BTreeMap<(TenantId, NamespaceId), PathBuf> = BTreeMap::new();
for root in roots {
let key = (root.tenant.clone(), root.namespace.clone());
if let Some(prev) = seen_ns.insert(key, root.config_dir.clone()) {
bail!(
"two config roots declare the same (tenant={}, namespace={}): \
{} and {} — each sibling tree needs a distinct namespace \
(W206 per-directory invariant)",
root.tenant.0,
root.namespace.0,
prev.display(),
root.config_dir.display(),
);
}
let config = CloudConfig::load_from_config_dir(&root.config_dir, &root.workspace_root)?;
loaded.push(LoadedRoot {
config_dir: root.config_dir.clone(),
tenant: root.tenant.clone(),
namespace: root.namespace.clone(),
config,
});
}
let out = Self { roots: loaded };
out.validate_uniqueness()?;
Ok(out)
}
fn validate_uniqueness(&self) -> Result<()> {
let mut seen: BTreeMap<(TenantId, NamespaceId, String), PathBuf> = BTreeMap::new();
for root in &self.roots {
for name in root.declared_names() {
let key = (root.tenant.clone(), root.namespace.clone(), name.clone());
if let Some(prev) = seen.insert(key, root.config_dir.clone()) {
bail!(
"workload name collision: (tenant={}, namespace={}, name={}) \
is declared in both {} and {}",
root.tenant.0,
root.namespace.0,
name,
prev.display(),
root.config_dir.display(),
);
}
}
}
Ok(())
}
}
pub fn discover(parent: &Path) -> Result<Vec<ConfigRoot>> {
if !parent.is_dir() {
return Ok(vec![]);
}
let mut entries: Vec<_> = std::fs::read_dir(parent)
.with_context(|| format!("reading {}", parent.display()))?
.filter_map(|e| e.ok())
.filter(|e| e.path().is_dir())
.filter(|e| e.file_name().to_str().map_or(false, |n| n.starts_with('.')))
.collect();
entries.sort_by_key(|e| e.file_name());
let mut roots = vec![];
for entry in entries {
let dir = entry.path();
if !dir.join("services").is_dir() && !dir.join("infra").is_dir() {
continue;
}
roots.push(ConfigRoot::from_config_dir(&dir, parent)?);
}
Ok(roots)
}
#[cfg(test)]
mod tests {
use super::*;
fn write_service(config_dir: &Path, name: &str) {
let dir = config_dir.join("services").join(name);
std::fs::create_dir_all(&dir).unwrap();
std::fs::write(
dir.join("service.toml"),
format!("schema_version = 1\nname = \"{name}\"\ndomain = \"{name}.example\"\n"),
)
.unwrap();
}
#[test]
fn namespace_defaults_to_dir_name_sans_dot() {
let tmp = tempfile::tempdir().unwrap();
let dir = tmp.path().join(".noisetable");
std::fs::create_dir_all(dir.join("services")).unwrap();
let root = ConfigRoot::from_config_dir(&dir, tmp.path()).unwrap();
assert_eq!(root.namespace.0, "noisetable");
assert!(root.tenant.is_singleton());
}
#[test]
fn marker_sets_tenant_and_overrides_namespace() {
let tmp = tempfile::tempdir().unwrap();
let dir = tmp.path().join(".noisetable");
std::fs::create_dir_all(&dir).unwrap();
std::fs::write(
dir.join("namespace.toml"),
"tenant = \"ss\"\nnamespace = \"nt\"\n",
)
.unwrap();
let root = ConfigRoot::from_config_dir(&dir, tmp.path()).unwrap();
assert_eq!(root.tenant.0, "ss");
assert_eq!(root.namespace.0, "nt");
}
#[test]
fn discover_finds_config_dirs_skips_non_config_dotdirs() {
let tmp = tempfile::tempdir().unwrap();
write_service(&tmp.path().join(".yah"), "web");
write_service(&tmp.path().join(".noisetable"), "site");
std::fs::create_dir_all(tmp.path().join(".git")).unwrap();
let roots = discover(tmp.path()).unwrap();
let names: Vec<&str> = roots.iter().map(|r| r.namespace.0.as_str()).collect();
assert_eq!(names, vec!["noisetable", "yah"]); }
#[test]
fn union_of_distinct_namespaces_loads() {
let tmp = tempfile::tempdir().unwrap();
write_service(&tmp.path().join(".yah"), "web");
write_service(&tmp.path().join(".noisetable"), "site");
let roots = discover(tmp.path()).unwrap();
let multi = MultiRootConfig::load(&roots).unwrap();
assert_eq!(multi.roots.len(), 2);
}
#[test]
fn same_name_across_namespaces_is_allowed() {
let tmp = tempfile::tempdir().unwrap();
write_service(&tmp.path().join(".yah"), "web");
write_service(&tmp.path().join(".noisetable"), "web");
let roots = discover(tmp.path()).unwrap();
MultiRootConfig::load(&roots).unwrap();
}
#[test]
fn duplicate_namespace_across_roots_is_rejected() {
let tmp = tempfile::tempdir().unwrap();
for d in [".a", ".b"] {
let dir = tmp.path().join(d);
std::fs::create_dir_all(dir.join("services")).unwrap();
std::fs::write(dir.join("namespace.toml"), "namespace = \"shared\"\n").unwrap();
}
let roots = discover(tmp.path()).unwrap();
let err = MultiRootConfig::load(&roots).unwrap_err().to_string();
assert!(err.contains("same (tenant"), "got: {err}");
}
#[test]
fn stamp_overwrites_spec_axes() {
let tmp = tempfile::tempdir().unwrap();
write_service(&tmp.path().join(".noisetable"), "site");
std::fs::write(
tmp.path().join(".noisetable").join("namespace.toml"),
"tenant = \"ss\"\n",
)
.unwrap();
let roots = discover(tmp.path()).unwrap();
let multi = MultiRootConfig::load(&roots).unwrap();
let root = &multi.roots[0];
use workload_spec::{ImageRef, TierTag};
let mut spec = WorkloadSpec::for_forge(
"some-workload",
ImageRef {
registry: "docker.io".into(),
repository: "library/busybox".into(),
tag: "latest".into(),
digest: workload_spec::testing::test_digest(),
},
TierTag("private".into()),
vec![],
);
spec.tenant = TenantId("wrong".into());
spec.namespace = NamespaceId("wrong".into());
root.stamp(&mut spec);
assert_eq!(spec.tenant.0, "ss");
assert_eq!(spec.namespace.0, "noisetable");
}
}