Skip to main content

cloud/
multi_root.rs

1//! Multi-root cloud config — union sibling `.X/` config trees (W206 layout (b)).
2//!
3//! Part of R558-F4; the ticket annotation lives in
4//! `.yah/docs/working/W206-yubaba-namespace-tenancy-axes.md`.
5//!
6//! Today a yubaba reconciler reads exactly one config tree (`.yah/`, via
7//! [`CloudConfig::load`]). W206 adds a second consumer (noisetable) that keeps
8//! its declarations in its own repo, materialized as a sibling `.noisetable/`
9//! tree next to `.yah/`. Rather than annotate every TOML with a `namespace`
10//! (layout (a)), the recommended layout (b) gives each project its own config
11//! root and teaches the reconciler to **union** the roots.
12//!
13//! This module is that union layer:
14//!
15//! - [`ConfigRoot`] — one `.X/` directory plus the `(tenant, namespace)` every
16//!   workload it declares belongs to. Namespace defaults to the directory name
17//!   with the leading dot stripped (`.noisetable` → `noisetable`); a
18//!   `<dir>/namespace.toml` marker can set the tenant and override the
19//!   namespace. This is the **per-directory** invariant from W206's open
20//!   questions: one namespace per root, no per-file override.
21//! - [`MultiRootConfig`] — the loaded, validated union. Loading rejects two
22//!   roots that claim the same `(tenant, namespace)` and validates that
23//!   `(tenant, namespace, name)` is unique across every workload/service in the
24//!   union (the safety net that makes friendly co-residence collision-proof).
25//! - [`discover`] — auto-find sibling `.X/` roots under a parent directory.
26
27use 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/// Optional per-root marker (`<config_dir>/namespace.toml`) declaring the
37/// tenant and (optionally) overriding the directory-derived namespace.
38///
39/// ```toml
40/// tenant = "ss"            # optional; defaults to the singleton tenant
41/// namespace = "noisetable" # optional; defaults to the dir name sans leading dot
42/// ```
43#[derive(Debug, Default, Deserialize)]
44struct RootMarker {
45    tenant: Option<String>,
46    namespace: Option<String>,
47}
48
49/// One config root and the `(tenant, namespace)` identity every workload it
50/// declares belongs to. See the [module docs](self) for the per-directory
51/// invariant this enforces.
52#[derive(Debug, Clone)]
53pub struct ConfigRoot {
54    /// The `.X/` directory itself (e.g. `<parent>/.noisetable`).
55    pub config_dir: PathBuf,
56    /// The camp dir (the config dir's parent) — component `path` references
57    /// resolve against this, matching [`CloudConfig::load`].
58    pub workspace_root: PathBuf,
59    /// Isolation axis. Every workload loaded from this root is stamped with it.
60    pub tenant: TenantId,
61    /// Routing/naming axis. Every workload loaded from this root is stamped
62    /// with it; it cannot be overridden per-file.
63    pub namespace: NamespaceId,
64}
65
66impl ConfigRoot {
67    /// Build a [`ConfigRoot`] from a `.X/` directory, reading the optional
68    /// `namespace.toml` marker. The namespace defaults to the directory name
69    /// with its leading dot stripped; the tenant defaults to the singleton.
70    /// `workspace_root` is the config dir's parent.
71    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/// A single loaded root: its `(tenant, namespace)` identity plus the
105/// [`CloudConfig`] read from its tree.
106#[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    /// Stamp this root's `(tenant, namespace)` onto a workload spec, enforcing
116    /// the per-directory invariant: whatever the on-disk `workload.toml` said
117    /// for these axes is overwritten by the root it was loaded from.
118    pub fn stamp(&self, spec: &mut WorkloadSpec) {
119        spec.tenant = self.tenant.clone();
120        spec.namespace = self.namespace.clone();
121    }
122
123    /// Every workload/service name declared by this root (service names from the
124    /// R215+ tree plus any legacy `.yah/cloud/workloads/` names).
125    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/// The loaded, validated union of sibling `.X/` config trees.
135#[derive(Debug)]
136pub struct MultiRootConfig {
137    pub roots: Vec<LoadedRoot>,
138}
139
140impl MultiRootConfig {
141    /// Load and union every [`ConfigRoot`], then validate the union.
142    ///
143    /// Fails if two roots declare the same `(tenant, namespace)` pair (each
144    /// sibling tree must own a distinct namespace) or if any
145    /// `(tenant, namespace, name)` triple is claimed twice across the union.
146    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    /// Validate that `(tenant, namespace, name)` is unique across every workload
178    /// and service in the union. Two namespaces in the same tenant *may* reuse a
179    /// name — that's the namespace's whole job — but a single
180    /// `(tenant, namespace)` pair must not, or the reconciler would silently
181    /// clobber one workload with another.
182    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
204/// Auto-discover sibling `.X/` config roots under `parent`.
205///
206/// A directory qualifies when its name starts with `.` and it contains a
207/// `services/` or `infra/` subtree — so `.yah` and `.noisetable` are picked up
208/// while `.git`, `.DS_Store`, and stray dotfiles are not. Roots are returned in
209/// deterministic (directory-name) order. Each root's `(tenant, namespace)` is
210/// resolved via [`ConfigRoot::from_config_dir`].
211pub 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    /// Write a minimal service under `<config_dir>/services/<name>/service.toml`.
239    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        // A dotdir with neither services/ nor infra/ is ignored.
280        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"]); // sorted by dir name
285    }
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        // Two namespaces reusing "web" is fine — that's what namespaces are for.
301        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        // Two different dirs both forced to namespace "shared" via markers.
313        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        // Pretend the file claimed a different identity.
349        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}