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