1use std::collections::BTreeMap;
28use std::path::{Path, PathBuf};
29
30use anyhow::{bail, Context, Result};
31use serde::Deserialize;
32use workload_spec::{NamespaceId, TenantId, WorkloadSpec};
33
34use crate::config::CloudConfig;
35
36#[derive(Debug, Default, Deserialize)]
44struct RootMarker {
45 tenant: Option<String>,
46 namespace: Option<String>,
47}
48
49#[derive(Debug, Clone)]
53pub struct ConfigRoot {
54 pub config_dir: PathBuf,
56 pub workspace_root: PathBuf,
59 pub tenant: TenantId,
61 pub namespace: NamespaceId,
64}
65
66impl ConfigRoot {
67 pub fn from_config_dir(config_dir: &Path, workspace_root: &Path) -> Result<Self> {
72 let dir_name = config_dir
73 .file_name()
74 .and_then(|n| n.to_str())
75 .ok_or_else(|| {
76 anyhow::anyhow!("config root {} has no usable name", config_dir.display())
77 })?;
78 let derived_ns = dir_name.strip_prefix('.').unwrap_or(dir_name).to_string();
79
80 let marker_path = config_dir.join("namespace.toml");
81 let marker: RootMarker = if marker_path.exists() {
82 let text = std::fs::read_to_string(&marker_path)
83 .with_context(|| format!("reading {}", marker_path.display()))?;
84 toml::from_str(&text).with_context(|| format!("parsing {}", marker_path.display()))?
85 } else {
86 RootMarker::default()
87 };
88
89 let tenant = marker
90 .tenant
91 .map(TenantId)
92 .unwrap_or_else(TenantId::singleton);
93 let namespace = NamespaceId(marker.namespace.unwrap_or(derived_ns));
94
95 Ok(Self {
96 config_dir: config_dir.to_path_buf(),
97 workspace_root: workspace_root.to_path_buf(),
98 tenant,
99 namespace,
100 })
101 }
102}
103
104#[derive(Debug)]
107pub struct LoadedRoot {
108 pub config_dir: PathBuf,
109 pub tenant: TenantId,
110 pub namespace: NamespaceId,
111 pub config: CloudConfig,
112}
113
114impl LoadedRoot {
115 pub fn stamp(&self, spec: &mut WorkloadSpec) {
119 spec.tenant = self.tenant.clone();
120 spec.namespace = self.namespace.clone();
121 }
122
123 fn declared_names(&self) -> impl Iterator<Item = String> + '_ {
126 self.config
127 .services
128 .keys()
129 .cloned()
130 .chain(self.config.workloads.iter().map(|w| w.spec.name.clone()))
131 }
132}
133
134#[derive(Debug)]
136pub struct MultiRootConfig {
137 pub roots: Vec<LoadedRoot>,
138}
139
140impl MultiRootConfig {
141 pub fn load(roots: &[ConfigRoot]) -> Result<Self> {
147 let mut loaded = Vec::with_capacity(roots.len());
148 let mut seen_ns: BTreeMap<(TenantId, NamespaceId), PathBuf> = BTreeMap::new();
149
150 for root in roots {
151 let key = (root.tenant.clone(), root.namespace.clone());
152 if let Some(prev) = seen_ns.insert(key, root.config_dir.clone()) {
153 bail!(
154 "two config roots declare the same (tenant={}, namespace={}): \
155 {} and {} — each sibling tree needs a distinct namespace \
156 (W206 per-directory invariant)",
157 root.tenant.0,
158 root.namespace.0,
159 prev.display(),
160 root.config_dir.display(),
161 );
162 }
163 let config = CloudConfig::load_from_config_dir(&root.config_dir, &root.workspace_root)?;
164 loaded.push(LoadedRoot {
165 config_dir: root.config_dir.clone(),
166 tenant: root.tenant.clone(),
167 namespace: root.namespace.clone(),
168 config,
169 });
170 }
171
172 let out = Self { roots: loaded };
173 out.validate_uniqueness()?;
174 Ok(out)
175 }
176
177 fn validate_uniqueness(&self) -> Result<()> {
183 let mut seen: BTreeMap<(TenantId, NamespaceId, String), PathBuf> = BTreeMap::new();
184 for root in &self.roots {
185 for name in root.declared_names() {
186 let key = (root.tenant.clone(), root.namespace.clone(), name.clone());
187 if let Some(prev) = seen.insert(key, root.config_dir.clone()) {
188 bail!(
189 "workload name collision: (tenant={}, namespace={}, name={}) \
190 is declared in both {} and {}",
191 root.tenant.0,
192 root.namespace.0,
193 name,
194 prev.display(),
195 root.config_dir.display(),
196 );
197 }
198 }
199 }
200 Ok(())
201 }
202}
203
204pub fn discover(parent: &Path) -> Result<Vec<ConfigRoot>> {
212 if !parent.is_dir() {
213 return Ok(vec![]);
214 }
215 let mut entries: Vec<_> = std::fs::read_dir(parent)
216 .with_context(|| format!("reading {}", parent.display()))?
217 .filter_map(|e| e.ok())
218 .filter(|e| e.path().is_dir())
219 .filter(|e| e.file_name().to_str().map_or(false, |n| n.starts_with('.')))
220 .collect();
221 entries.sort_by_key(|e| e.file_name());
222
223 let mut roots = vec![];
224 for entry in entries {
225 let dir = entry.path();
226 if !dir.join("services").is_dir() && !dir.join("infra").is_dir() {
227 continue;
228 }
229 roots.push(ConfigRoot::from_config_dir(&dir, parent)?);
230 }
231 Ok(roots)
232}
233
234#[cfg(test)]
235mod tests {
236 use super::*;
237
238 fn write_service(config_dir: &Path, name: &str) {
240 let dir = config_dir.join("services").join(name);
241 std::fs::create_dir_all(&dir).unwrap();
242 std::fs::write(
243 dir.join("service.toml"),
244 format!("schema_version = 1\nname = \"{name}\"\ndomain = \"{name}.example\"\n"),
245 )
246 .unwrap();
247 }
248
249 #[test]
250 fn namespace_defaults_to_dir_name_sans_dot() {
251 let tmp = tempfile::tempdir().unwrap();
252 let dir = tmp.path().join(".noisetable");
253 std::fs::create_dir_all(dir.join("services")).unwrap();
254 let root = ConfigRoot::from_config_dir(&dir, tmp.path()).unwrap();
255 assert_eq!(root.namespace.0, "noisetable");
256 assert!(root.tenant.is_singleton());
257 }
258
259 #[test]
260 fn marker_sets_tenant_and_overrides_namespace() {
261 let tmp = tempfile::tempdir().unwrap();
262 let dir = tmp.path().join(".noisetable");
263 std::fs::create_dir_all(&dir).unwrap();
264 std::fs::write(
265 dir.join("namespace.toml"),
266 "tenant = \"ss\"\nnamespace = \"nt\"\n",
267 )
268 .unwrap();
269 let root = ConfigRoot::from_config_dir(&dir, tmp.path()).unwrap();
270 assert_eq!(root.tenant.0, "ss");
271 assert_eq!(root.namespace.0, "nt");
272 }
273
274 #[test]
275 fn discover_finds_config_dirs_skips_non_config_dotdirs() {
276 let tmp = tempfile::tempdir().unwrap();
277 write_service(&tmp.path().join(".yah"), "web");
278 write_service(&tmp.path().join(".noisetable"), "site");
279 std::fs::create_dir_all(tmp.path().join(".git")).unwrap();
281
282 let roots = discover(tmp.path()).unwrap();
283 let names: Vec<&str> = roots.iter().map(|r| r.namespace.0.as_str()).collect();
284 assert_eq!(names, vec!["noisetable", "yah"]); }
286
287 #[test]
288 fn union_of_distinct_namespaces_loads() {
289 let tmp = tempfile::tempdir().unwrap();
290 write_service(&tmp.path().join(".yah"), "web");
291 write_service(&tmp.path().join(".noisetable"), "site");
292
293 let roots = discover(tmp.path()).unwrap();
294 let multi = MultiRootConfig::load(&roots).unwrap();
295 assert_eq!(multi.roots.len(), 2);
296 }
297
298 #[test]
299 fn same_name_across_namespaces_is_allowed() {
300 let tmp = tempfile::tempdir().unwrap();
302 write_service(&tmp.path().join(".yah"), "web");
303 write_service(&tmp.path().join(".noisetable"), "web");
304
305 let roots = discover(tmp.path()).unwrap();
306 MultiRootConfig::load(&roots).unwrap();
307 }
308
309 #[test]
310 fn duplicate_namespace_across_roots_is_rejected() {
311 let tmp = tempfile::tempdir().unwrap();
312 for d in [".a", ".b"] {
314 let dir = tmp.path().join(d);
315 std::fs::create_dir_all(dir.join("services")).unwrap();
316 std::fs::write(dir.join("namespace.toml"), "namespace = \"shared\"\n").unwrap();
317 }
318 let roots = discover(tmp.path()).unwrap();
319 let err = MultiRootConfig::load(&roots).unwrap_err().to_string();
320 assert!(err.contains("same (tenant"), "got: {err}");
321 }
322
323 #[test]
324 fn stamp_overwrites_spec_axes() {
325 let tmp = tempfile::tempdir().unwrap();
326 write_service(&tmp.path().join(".noisetable"), "site");
327 std::fs::write(
328 tmp.path().join(".noisetable").join("namespace.toml"),
329 "tenant = \"ss\"\n",
330 )
331 .unwrap();
332 let roots = discover(tmp.path()).unwrap();
333 let multi = MultiRootConfig::load(&roots).unwrap();
334 let root = &multi.roots[0];
335
336 use workload_spec::{ImageRef, TierTag};
337 let mut spec = WorkloadSpec::for_forge(
338 "some-workload",
339 ImageRef {
340 registry: "docker.io".into(),
341 repository: "library/busybox".into(),
342 tag: "latest".into(),
343 digest: workload_spec::testing::test_digest(),
344 },
345 TierTag("private".into()),
346 vec![],
347 );
348 spec.tenant = TenantId("wrong".into());
350 spec.namespace = NamespaceId("wrong".into());
351 root.stamp(&mut spec);
352 assert_eq!(spec.tenant.0, "ss");
353 assert_eq!(spec.namespace.0, "noisetable");
354 }
355}