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 = "acme"          # optional; defaults to the singleton tenant
41/// namespace = "noisetable" # optional; defaults to the dir name sans leading dot
42/// ```
43///
44/// Omit `tenant` for any tree that belongs to the operator's own tenant. The
45/// tenant id is the network isolation key (kamaji `container_net::bridge_for`,
46/// R895-F3): two roots with different ids are isolated from each other on every
47/// node they share. So a marker naming a tenant "to match" a tree that declares
48/// none creates a second tenant rather than joining the first. noisetable's
49/// marker did exactly that (`tenant = "ss"` against yah's implicit `"default"`)
50/// until 2026-09-14.
51#[derive(Debug, Default, Deserialize)]
52struct RootMarker {
53    tenant: Option<String>,
54    namespace: Option<String>,
55}
56
57/// One config root and the `(tenant, namespace)` identity every workload it
58/// declares belongs to. See the [module docs](self) for the per-directory
59/// invariant this enforces.
60#[derive(Debug, Clone)]
61pub struct ConfigRoot {
62    /// The `.X/` directory itself (e.g. `<parent>/.noisetable`).
63    pub config_dir: PathBuf,
64    /// The camp dir (the config dir's parent) — component `path` references
65    /// resolve against this, matching [`CloudConfig::load`].
66    pub workspace_root: PathBuf,
67    /// Isolation axis. Every workload loaded from this root is stamped with it.
68    pub tenant: TenantId,
69    /// Routing/naming axis. Every workload loaded from this root is stamped
70    /// with it; it cannot be overridden per-file.
71    pub namespace: NamespaceId,
72}
73
74impl ConfigRoot {
75    /// Build a [`ConfigRoot`] from a `.X/` directory, reading the optional
76    /// `namespace.toml` marker. The namespace defaults to the directory name
77    /// with its leading dot stripped; the tenant defaults to the singleton.
78    /// `workspace_root` is the config dir's parent.
79    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/// A single loaded root: its `(tenant, namespace)` identity plus the
113/// [`CloudConfig`] read from its tree.
114#[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    /// Stamp this root's `(tenant, namespace)` onto a workload spec, enforcing
124    /// the per-directory invariant: whatever the on-disk `workload.toml` said
125    /// for these axes is overwritten by the root it was loaded from.
126    pub fn stamp(&self, spec: &mut WorkloadSpec) {
127        spec.tenant = self.tenant.clone();
128        spec.namespace = self.namespace.clone();
129    }
130
131    /// Every workload/service name declared by this root (service names from the
132    /// R215+ tree plus any legacy `.yah/cloud/workloads/` names).
133    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/// The loaded, validated union of sibling `.X/` config trees.
143#[derive(Debug)]
144pub struct MultiRootConfig {
145    pub roots: Vec<LoadedRoot>,
146}
147
148impl MultiRootConfig {
149    /// Load and union every [`ConfigRoot`], then validate the union.
150    ///
151    /// Fails if two roots declare the same `(tenant, namespace)` pair (each
152    /// sibling tree must own a distinct namespace) or if any
153    /// `(tenant, namespace, name)` triple is claimed twice across the union.
154    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    /// Validate that `(tenant, namespace, name)` is unique across every workload
186    /// and service in the union. Two namespaces in the same tenant *may* reuse a
187    /// name — that's the namespace's whole job — but a single
188    /// `(tenant, namespace)` pair must not, or the reconciler would silently
189    /// clobber one workload with another.
190    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
212/// Auto-discover sibling `.X/` config roots under `parent`.
213///
214/// A directory qualifies when its name starts with `.` and it contains a
215/// `services/` or `infra/` subtree — so `.yah` and `.noisetable` are picked up
216/// while `.git`, `.DS_Store`, and stray dotfiles are not. Roots are returned in
217/// deterministic (directory-name) order. Each root's `(tenant, namespace)` is
218/// resolved via [`ConfigRoot::from_config_dir`].
219pub 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    /// Write a minimal service under `<config_dir>/services/<name>/service.toml`.
247    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}\"\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        // A dotdir with neither services/ nor infra/ is ignored.
288        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"]); // sorted by dir name
293    }
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        // Two namespaces reusing "web" is fine — that's what namespaces are for.
309        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        // Two different dirs both forced to namespace "shared" via markers.
321        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        // Pretend the file claimed a different identity.
357        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}