1use anyhow::{Context, Result};
84use serde::{Deserialize, Serialize};
85use std::collections::{BTreeMap, HashMap};
86use std::path::Path;
87use thiserror::Error;
88use workload_spec::secrets::SecretAccess;
89use workload_spec::{validate, LifecycleArchetype, TenantId, WorkloadSpec};
90
91#[derive(Debug, Clone, Serialize, Deserialize)]
98#[cfg_attr(feature = "json-schema", derive(schemars::JsonSchema))]
99pub struct NodeAllocatable {
100 pub memory_mb: u32,
102 pub cpu_millis: u32,
104}
105
106#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq)]
128#[cfg_attr(feature = "json-schema", derive(schemars::JsonSchema))]
129pub struct MachineRegistration {
130 #[serde(default, skip_serializing_if = "Option::is_none")]
135 pub hostkey_fingerprint: Option<String>,
136 #[serde(default, skip_serializing_if = "Option::is_none")]
141 pub mesh_ipv4: Option<String>,
142 #[serde(default, skip_serializing_if = "Option::is_none")]
145 pub joined_at: Option<String>,
146}
147
148impl MachineRegistration {
149 pub fn is_empty(&self) -> bool {
152 self.hostkey_fingerprint.is_none() && self.mesh_ipv4.is_none() && self.joined_at.is_none()
153 }
154}
155
156#[derive(Debug, Clone, Serialize, Deserialize)]
163#[cfg_attr(feature = "json-schema", derive(schemars::JsonSchema))]
164pub struct MachineConfig {
165 pub name: String,
166 pub provider: String,
167 #[serde(default, skip_serializing_if = "Option::is_none")]
182 pub vendor: Option<String>,
183 #[serde(default, skip_serializing_if = "Option::is_none")]
187 pub nickname: Option<String>,
188 #[serde(default, skip_serializing_if = "Option::is_none")]
194 pub location: Option<String>,
195 #[serde(default, skip_serializing_if = "Option::is_none")]
198 pub server_type: Option<String>,
199 #[serde(default, skip_serializing_if = "Vec::is_empty")]
207 pub hosts_mirrors: Vec<String>,
208 pub mesh_tags: Vec<String>,
209 #[serde(default, skip_serializing_if = "Option::is_none")]
217 pub region: Option<String>,
218 #[serde(default, skip_serializing_if = "Option::is_none")]
222 pub zone: Option<String>,
223 #[serde(default, skip_serializing_if = "Option::is_none")]
229 pub arch: Option<String>,
230 pub bucket: Option<BucketSpec>,
231 #[serde(
239 rename = "hostkey_fingerprint",
240 default,
241 skip_serializing_if = "Option::is_none"
242 )]
243 pub legacy_hostkey_fingerprint: Option<String>,
244 #[serde(default, skip_serializing_if = "Vec::is_empty")]
251 pub ssh_keys: Vec<u64>,
252 #[serde(default, skip_serializing_if = "Option::is_none")]
258 pub cloudflared: Option<String>,
259 #[serde(default)]
265 pub hosts_operator_bridge: bool,
266 #[serde(default, skip_serializing_if = "Option::is_none")]
272 pub connect: Option<ConnectSpec>,
273 #[serde(default, skip_serializing_if = "Option::is_none")]
277 pub allocatable: Option<NodeAllocatable>,
278 #[serde(default, skip_serializing_if = "Vec::is_empty")]
284 pub taints: Vec<String>,
285 #[serde(default, skip_serializing_if = "MachineRegistration::is_empty")]
288 pub registration: MachineRegistration,
289}
290
291pub fn provider_has_machine_driver(provider: &str) -> bool {
297 matches!(provider, "hetzner" | "vultr" | "digitalocean")
298}
299
300impl MachineConfig {
301 pub fn location(&self) -> &str {
305 self.location.as_deref().unwrap_or("")
306 }
307
308 pub fn server_type(&self) -> &str {
310 self.server_type.as_deref().unwrap_or("")
311 }
312
313 pub fn validate(&self) -> Result<()> {
318 if provider_has_machine_driver(&self.provider) {
319 if self.location.is_none() {
320 anyhow::bail!(
321 "machine '{}' (provider '{}') has an auto-provision driver but no `location`",
322 self.name,
323 self.provider
324 );
325 }
326 if self.server_type.is_none() {
327 anyhow::bail!(
328 "machine '{}' (provider '{}') has an auto-provision driver but no `server_type`",
329 self.name,
330 self.provider
331 );
332 }
333 }
334 Ok(())
335 }
336
337 pub fn hostkey_fingerprint(&self) -> Option<&str> {
342 self.registration
343 .hostkey_fingerprint
344 .as_deref()
345 .or(self.legacy_hostkey_fingerprint.as_deref())
346 }
347
348 pub fn set_hostkey_fingerprint(&mut self, fingerprint: Option<String>) {
352 self.registration.hostkey_fingerprint = fingerprint;
353 self.legacy_hostkey_fingerprint = None;
354 }
355
356 pub fn mesh_ipv4(&self) -> Option<&str> {
364 if let Some(ip) = self.registration.mesh_ipv4.as_deref() {
365 return Some(ip);
366 }
367 let url = self.connect.as_ref()?.yubaba.as_deref()?;
368 mesh_ipv4_from_url(url)
369 }
370
371 pub fn yubaba_url(&self) -> Option<String> {
394 let connect = self.connect.as_ref()?;
395 if let Some(literal) = &connect.yubaba {
396 return Some(literal.clone());
397 }
398 let ip = self.registration.mesh_ipv4.as_deref()?;
399 Some(format!("http://{ip}:{}", connect.yubaba_port()))
400 }
401
402 pub fn normalize(&mut self) {
409 if let Some(fp) = self.legacy_hostkey_fingerprint.take() {
410 self.registration.hostkey_fingerprint.get_or_insert(fp);
411 }
412 if self.registration.mesh_ipv4.is_none() {
413 if let Some(ip) = self
414 .connect
415 .as_ref()
416 .and_then(|c| c.yubaba.as_deref())
417 .and_then(mesh_ipv4_from_url)
418 .map(str::to_string)
419 {
420 self.registration.mesh_ipv4 = Some(ip);
421 if let Some(c) = self.connect.as_mut() {
424 c.yubaba = None;
425 }
426 }
427 }
428 }
429
430 pub fn save(&self, cloud_dir: &Path) -> Result<()> {
438 let dir = cloud_dir.join("machines");
439 std::fs::create_dir_all(&dir).with_context(|| format!("creating {}", dir.display()))?;
440 let path = dir.join(format!("{}.toml", self.name));
441 let mut normalized = self.clone();
442 normalized.normalize();
443 let s = toml::to_string_pretty(&normalized)
444 .with_context(|| format!("serializing machine {}", self.name))?;
445 std::fs::write(&path, s).with_context(|| format!("writing {}", path.display()))
446 }
447}
448
449fn mesh_ipv4_from_url(url: &str) -> Option<&str> {
455 let after_scheme = url.split("://").nth(1).unwrap_or(url);
456 let host = after_scheme.split(['/', ':']).next()?;
457 let ip: std::net::Ipv4Addr = host.parse().ok()?;
458 let [a, b, ..] = ip.octets();
459 (a == 100 && (64..=127).contains(&b)).then_some(host)
461}
462
463#[derive(Debug, Clone, Serialize, Deserialize)]
480#[cfg_attr(feature = "json-schema", derive(schemars::JsonSchema))]
481pub struct ConnectSpec {
482 pub address: String,
486 pub ssh: String,
490 #[serde(default, skip_serializing_if = "Option::is_none")]
494 pub yubaba_port: Option<u16>,
495 #[serde(default, skip_serializing_if = "Option::is_none")]
505 pub yubaba: Option<String>,
506}
507
508pub const DEFAULT_YUBABA_PORT: u16 = 7443;
510
511impl ConnectSpec {
512 pub fn yubaba_port(&self) -> u16 {
514 self.yubaba_port.unwrap_or(DEFAULT_YUBABA_PORT)
515 }
516}
517
518#[derive(Debug, Clone, Serialize, Deserialize)]
519#[cfg_attr(feature = "json-schema", derive(schemars::JsonSchema))]
520pub struct BucketSpec {
521 pub name: String,
522 pub public_read: bool,
523}
524
525#[derive(Debug, Clone, Serialize, Deserialize)]
532pub struct LegacyMirrorConfig {
533 #[serde(rename = "camp", alias = "rig")]
539 pub camp: String,
540 pub regions: Vec<String>,
541 #[serde(alias = "services")]
545 pub workloads: Vec<String>,
546 #[serde(default, skip_serializing_if = "Option::is_none")]
552 pub cloud_domain: Option<String>,
553}
554
555#[derive(Debug, Error)]
557pub enum WorkloadConfigError {
558 #[error("reading {path}: {source}")]
559 Io {
560 path: String,
561 source: std::io::Error,
562 },
563 #[error("parsing {path}: {source}")]
564 Toml {
565 path: String,
566 source: toml::de::Error,
567 },
568 #[error("invalid WorkloadSpec in {path}: {source}")]
569 Shape {
570 path: String,
571 source: validate::ShapeError,
572 },
573}
574
575#[derive(Debug, Clone, Serialize, Deserialize)]
581pub struct WorkloadConfig {
582 #[serde(flatten)]
584 pub spec: WorkloadSpec,
585}
586
587impl WorkloadConfig {
588 pub fn save(&self, cloud_dir: &Path) -> Result<()> {
590 let dir = cloud_dir.join("workloads");
591 std::fs::create_dir_all(&dir).with_context(|| format!("creating {}", dir.display()))?;
592 let path = dir.join(format!("{}.toml", self.spec.name));
593 let s = toml::to_string_pretty(self)
594 .with_context(|| format!("serializing workload {}", self.spec.name))?;
595 std::fs::write(&path, s).with_context(|| format!("writing {}", path.display()))
596 }
597}
598
599#[derive(Debug, Error)]
601pub enum CloudConfigError {
602 #[error(transparent)]
603 Anyhow(#[from] anyhow::Error),
604 #[error("workload validation failed: {0}")]
605 Workload(WorkloadConfigError),
606}
607
608#[derive(Debug, Clone, Serialize, Deserialize, Default)]
614pub struct TopologyConfig {
615 #[serde(default)]
617 pub assignments: Vec<MirrorAssignment>,
618 #[serde(default, skip_serializing_if = "Vec::is_empty")]
621 pub buckets: Vec<BucketLogEntry>,
622}
623
624impl TopologyConfig {
625 pub fn load(path: &Path) -> Result<Self> {
627 if !path.exists() {
628 return Ok(Self::default());
629 }
630 let s =
631 std::fs::read_to_string(path).with_context(|| format!("reading {}", path.display()))?;
632 toml::from_str(&s).with_context(|| format!("parsing {}", path.display()))
633 }
634
635 pub fn save(&self, path: &Path) -> Result<()> {
637 if let Some(parent) = path.parent() {
638 std::fs::create_dir_all(parent)
639 .with_context(|| format!("creating {}", parent.display()))?;
640 }
641 let s = toml::to_string_pretty(self).context("serializing topology")?;
642 std::fs::write(path, s).with_context(|| format!("writing {}", path.display()))
643 }
644
645 pub fn bucket_by_name(&self, name: &str) -> Option<&BucketLogEntry> {
647 self.buckets.iter().find(|b| b.name == name)
648 }
649
650 pub fn bucket_by_name_mut(&mut self, name: &str) -> Option<&mut BucketLogEntry> {
652 self.buckets.iter_mut().find(|b| b.name == name)
653 }
654
655 pub fn is_cross_machine_bucket(&self, name: &str) -> bool {
657 self.buckets
658 .iter()
659 .any(|b| b.name == name && b.machine.is_none())
660 }
661}
662
663#[derive(Debug, Clone, Serialize, Deserialize)]
665pub struct MirrorAssignment {
666 pub mirror: String,
668 pub machine: String,
670}
671
672#[derive(Debug, Clone, Serialize, Deserialize)]
674pub struct BucketLogEntry {
675 pub name: String,
676 #[serde(default, skip_serializing_if = "Option::is_none")]
681 pub machine: Option<String>,
682 pub location: String,
684 #[serde(default = "default_bucket_policy")]
686 pub policy: String,
687}
688
689fn default_bucket_policy() -> String {
690 "private".to_string()
691}
692
693#[derive(Debug, Clone, Serialize, Deserialize)]
699pub struct LegacyServiceConfig {
700 pub name: String,
701 pub image: String,
702 pub version: String,
703 #[serde(default)]
704 pub env: HashMap<String, String>,
705 #[serde(default)]
706 pub ports: Vec<PortMapping>,
707 #[serde(default)]
708 pub mesh_only: bool,
709 #[serde(default, skip_serializing_if = "Option::is_none")]
718 pub bind_interface: Option<String>,
719
720 #[serde(default = "TenantId::singleton")]
727 pub tenant: TenantId,
728}
729
730#[derive(Debug, Clone, Serialize, Deserialize)]
731pub struct PortMapping {
732 pub host: u16,
733 pub container: u16,
734}
735
736#[derive(Debug, Clone, Serialize, Deserialize)]
741pub struct ServiceWithMirrors {
742 pub service: ServiceConfig,
743 pub mirrors: BTreeMap<String, MirrorConfig>,
745 #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
750 pub component_transform_recipes: BTreeMap<String, String>,
751}
752
753#[derive(Debug)]
765pub struct CloudConfig {
766 pub workspace_root: std::path::PathBuf,
769
770 pub machines: Vec<MachineConfig>,
773 pub providers: Vec<ProviderConfig>,
775 pub machine_origins: BTreeMap<String, InfraOrigin>,
782 pub provider_origins: BTreeMap<String, InfraOrigin>,
785 pub services: BTreeMap<String, ServiceWithMirrors>,
787 pub domains: BTreeMap<String, DomainConfig>,
792
793 pub legacy_mirrors: Vec<LegacyMirrorConfig>,
796 pub workloads: Vec<WorkloadConfig>,
798 pub topology: TopologyConfig,
800 pub legacy_services: Vec<LegacyServiceConfig>,
802}
803
804impl CloudConfig {
805 pub fn load(workspace_root: &Path) -> Result<Self> {
816 let mut providers = load_providers(&crate::paths::providers_dir(workspace_root))?;
817 let services = load_services(&crate::paths::services_dir(workspace_root), workspace_root)?;
818 let domains = load_domains(&crate::paths::domains_dir(workspace_root))?;
819
820 Self::cross_ref_validate(&providers, &services, &domains)?;
821
822 let cloud_dir = crate::paths::legacy_cloud_dir(workspace_root);
825 let (legacy_machines, legacy_mirrors, legacy_workloads, topology, legacy_services) =
826 if cloud_dir.exists() {
827 (
828 load_dir::<MachineConfig>(cloud_dir.join("machines"))?,
829 load_mirrors(cloud_dir.join("mirrors"))?,
830 load_workloads(cloud_dir.join("workloads"))?,
831 load_topology(cloud_dir.join("topology.toml"))?,
832 load_dir::<LegacyServiceConfig>(cloud_dir.join("services"))?,
833 )
834 } else {
835 Default::default()
836 };
837
838 let mut workloads = load_workloads(crate::paths::workloads_dir(workspace_root))?;
847 let workload_names: std::collections::HashSet<String> =
848 workloads.iter().map(|w| w.spec.name.clone()).collect();
849 for w in legacy_workloads {
850 if !workload_names.contains(&w.spec.name) {
851 workloads.push(w);
852 }
853 }
854
855 let mut machines = load_dir::<MachineConfig>(crate::paths::machines_dir(workspace_root))?;
859 let names: std::collections::HashSet<String> =
860 machines.iter().map(|m| m.name.clone()).collect();
861 for m in legacy_machines {
862 if !names.contains(&m.name) {
863 machines.push(m);
864 }
865 }
866
867 let sources = SourcesConfig::load(&crate::paths::infra_dir(workspace_root))?;
874 let mut machine_origins = BTreeMap::new();
875 let mut provider_origins = BTreeMap::new();
876 overlay_infra_sources(
877 workspace_root,
878 &sources,
879 &mut machines,
880 &mut providers,
881 &mut machine_origins,
882 &mut provider_origins,
883 );
884
885 Ok(Self {
886 workspace_root: workspace_root.to_path_buf(),
887 machines,
888 providers,
889 machine_origins,
890 provider_origins,
891 services,
892 domains,
893 legacy_mirrors,
894 workloads,
895 topology,
896 legacy_services,
897 })
898 }
899
900 pub fn load_from_config_dir(config_dir: &Path, workspace_root: &Path) -> Result<Self> {
927 let providers = load_providers(&config_dir.join("infra").join("providers"))?;
928 let services = load_services(&config_dir.join("services"), workspace_root)?;
929 let domains = load_domains(&config_dir.join("domains"))?;
930
931 Self::cross_ref_validate(&providers, &services, &domains)?;
932
933 let machines = load_dir::<MachineConfig>(config_dir.join("infra").join("machines"))?;
934
935 Ok(Self {
936 workspace_root: workspace_root.to_path_buf(),
937 machines,
938 providers,
939 machine_origins: BTreeMap::new(),
940 provider_origins: BTreeMap::new(),
941 services,
942 domains,
943 legacy_mirrors: vec![],
944 workloads: vec![],
945 topology: TopologyConfig::default(),
946 legacy_services: vec![],
947 })
948 }
949
950 fn cross_ref_validate(
955 providers: &[ProviderConfig],
956 services: &BTreeMap<String, ServiceWithMirrors>,
957 domains: &BTreeMap<String, DomainConfig>,
958 ) -> Result<()> {
959 let provider_ids: std::collections::HashSet<&str> =
961 providers.iter().map(|p| p.id.as_str()).collect();
962 for (svc_name, svc) in services {
963 for (env, mirror) in &svc.mirrors {
964 for (slot, body) in &mirror.providers {
965 if let Some(id) = body.provider_id() {
966 if !provider_ids.contains(id) {
967 anyhow::bail!(
968 "services/{svc_name}/mirrors/{env}.toml: \
969 providers.{slot}.use = \"{id}\" — no such provider; \
970 declare it at infra/providers/{id}.toml"
971 );
972 }
973 }
974 }
975 }
976 }
977
978 for (dom_name, dom) in domains {
981 for (idx, route) in dom.routes.iter().enumerate() {
982 let Some(component_ref) = route.mode.component() else {
983 continue; };
985 let Some((svc_name, comp_id)) = split_component_ref(component_ref) else {
986 anyhow::bail!(
987 "domains/{dom_name}.toml: routes[{idx}].component = \
988 \"{component_ref}\" — expected \"<service>/<component-id>\""
989 );
990 };
991 let Some(svc) = services.get(svc_name) else {
992 anyhow::bail!(
993 "domains/{dom_name}.toml: routes[{idx}].component = \
994 \"{component_ref}\" — no such service \"{svc_name}\" \
995 under services/"
996 );
997 };
998 if !svc.service.components.iter().any(|c| c.id == comp_id) {
999 anyhow::bail!(
1000 "domains/{dom_name}.toml: routes[{idx}].component = \
1001 \"{component_ref}\" — service \"{svc_name}\" has no \
1002 component with id \"{comp_id}\""
1003 );
1004 }
1005 }
1006 }
1007 Ok(())
1008 }
1009
1010 pub fn domain(&self, name: &str) -> Option<&DomainConfig> {
1012 self.domains.get(name)
1013 }
1014
1015 pub fn machine(&self, name: &str) -> Option<&MachineConfig> {
1016 self.machines.iter().find(|m| m.name == name)
1017 }
1018
1019 pub fn provider(&self, id: &str) -> Option<&ProviderConfig> {
1021 self.providers.iter().find(|p| p.id == id)
1022 }
1023
1024 pub fn service(&self, name: &str) -> Option<&ServiceWithMirrors> {
1026 self.services.get(name)
1027 }
1028
1029 pub fn legacy_mirror(&self, camp: &str) -> Option<&LegacyMirrorConfig> {
1031 self.legacy_mirrors.iter().find(|m| m.camp == camp)
1032 }
1033
1034 pub fn workload(&self, name: &str) -> Option<&WorkloadConfig> {
1035 self.workloads.iter().find(|w| w.spec.name == name)
1036 }
1037
1038 pub fn resolve_machine(&self, req: &RequiredSpec) -> Result<&MachineConfig> {
1047 self.machines
1048 .iter()
1049 .find(|m| req.matches(m))
1050 .ok_or_else(|| {
1051 let candidates = if self.machines.is_empty() {
1052 "(no machines declared under .yah/infra/machines/)".to_string()
1053 } else {
1054 self.machines
1055 .iter()
1056 .map(|m| m.name.as_str())
1057 .collect::<Vec<_>>()
1058 .join(", ")
1059 };
1060 anyhow::anyhow!(
1061 "no candidates matching {} — declared machines: {candidates}",
1062 req.describe()
1063 )
1064 })
1065 }
1066
1067 pub fn resolve_machine_by_mesh_tags(&self, required: &[String]) -> Option<&MachineConfig> {
1075 let req = RequiredSpec {
1076 mesh_tags: required.to_vec(),
1077 ..Default::default()
1078 };
1079 self.resolve_machine(&req).ok()
1080 }
1081
1082 pub fn admit_workload(&self, ws: &WorkloadSpec) -> Result<&MachineConfig> {
1104 let req = RequiredSpec {
1105 mesh_tags: node_selector_mesh_tags(ws),
1106 memory_mb: ws.resources.memory_mb,
1108 cpu_millis: ws.resources.cpu_millis,
1109 repel_archetype: Some(ws.effective_archetype()),
1111 requires_taint: ws.requires_taint().map(str::to_owned),
1113 ..Default::default()
1114 };
1115 self.resolve_machine(&req)
1116 }
1117}
1118
1119pub fn node_selector_mesh_tags(ws: &WorkloadSpec) -> Vec<String> {
1125 ws.annotations
1126 .get(velveteen_exec::remote::NODE_SELECTOR_MESH_TAGS_ANNOTATION)
1127 .map(|v| {
1128 v.split(',')
1129 .map(str::trim)
1130 .filter(|s| !s.is_empty())
1131 .map(String::from)
1132 .collect()
1133 })
1134 .unwrap_or_default()
1135}
1136
1137fn load_providers(dir: &Path) -> Result<Vec<ProviderConfig>> {
1140 if !dir.exists() {
1141 return Ok(vec![]);
1142 }
1143 let mut items = vec![];
1144 let mut entries: Vec<_> = std::fs::read_dir(dir)
1145 .with_context(|| format!("reading {}", dir.display()))?
1146 .filter_map(|e| e.ok())
1147 .filter(|e| e.path().extension().map_or(false, |x| x == "toml"))
1148 .collect();
1149 entries.sort_by_key(|e| e.file_name());
1150 for entry in entries {
1151 items.push(ProviderConfig::load(&entry.path())?);
1152 }
1153 Ok(items)
1154}
1155
1156pub fn canonical_tier(stem: &str) -> &str {
1162 match stem {
1163 "local" => "dev",
1164 "local-sim" | "sim" => "pond",
1165 "prod" => "cloud",
1166 other => other,
1167 }
1168}
1169
1170fn load_services(
1174 dir: &Path,
1175 workspace_root: &Path,
1176) -> Result<BTreeMap<String, ServiceWithMirrors>> {
1177 if !dir.exists() {
1178 return Ok(BTreeMap::new());
1179 }
1180 let mut out = BTreeMap::new();
1181 let mut entries: Vec<_> = std::fs::read_dir(dir)
1182 .with_context(|| format!("reading {}", dir.display()))?
1183 .filter_map(|e| e.ok())
1184 .filter(|e| e.path().is_dir())
1185 .collect();
1186 entries.sort_by_key(|e| e.file_name());
1187
1188 for entry in entries {
1189 let svc_dir = entry.path();
1190 let service_toml = svc_dir.join("service.toml");
1191 if !service_toml.exists() {
1192 continue;
1196 }
1197 let service = ServiceConfig::load(&service_toml)?;
1198 let mut mirrors = BTreeMap::new();
1199 let mirrors_dir = svc_dir.join("mirrors");
1200 if mirrors_dir.exists() {
1201 let mut menv: Vec<_> = std::fs::read_dir(&mirrors_dir)
1202 .with_context(|| format!("reading {}", mirrors_dir.display()))?
1203 .filter_map(|e| e.ok())
1204 .filter(|e| e.path().extension().map_or(false, |x| x == "toml"))
1205 .collect();
1206 menv.sort_by_key(|e| e.file_name());
1207 for m in menv {
1208 let path = m.path();
1209 let stem = path
1210 .file_stem()
1211 .and_then(|s| s.to_str())
1212 .unwrap_or("")
1213 .to_string();
1214 let tier = canonical_tier(&stem).to_string();
1215 mirrors.insert(tier, MirrorConfig::load(&path)?);
1219 }
1220 }
1221 let mut component_transform_recipes = BTreeMap::new();
1222 for component in &service.components {
1223 if component.kind == "static-asset" {
1224 if let Some(recipe) =
1225 read_component_transform_recipe(workspace_root, &component.path)
1226 {
1227 component_transform_recipes.insert(component.id.clone(), recipe);
1228 }
1229 }
1230 }
1231 out.insert(
1232 service.name.clone(),
1233 ServiceWithMirrors {
1234 service,
1235 mirrors,
1236 component_transform_recipes,
1237 },
1238 );
1239 }
1240 Ok(out)
1241}
1242
1243fn read_component_transform_recipe(workspace_root: &Path, component_path: &str) -> Option<String> {
1248 let workload_path = workspace_root.join(component_path).join("workload.toml");
1249 let text = std::fs::read_to_string(&workload_path).ok()?;
1250 let value: toml::Value = toml::from_str(&text).ok()?;
1251 let assets = value.get("asset")?.as_array()?;
1252 for asset in assets {
1253 if let Some(recipe) = asset
1254 .get("derive")
1255 .and_then(|d| d.get("transform"))
1256 .and_then(|t| t.get("recipe"))
1257 .and_then(|r| r.as_str())
1258 {
1259 return Some(recipe.to_string());
1260 }
1261 }
1262 None
1263}
1264
1265fn load_domains(dir: &Path) -> Result<BTreeMap<String, DomainConfig>> {
1268 if !dir.exists() {
1269 return Ok(BTreeMap::new());
1270 }
1271 let mut out = BTreeMap::new();
1272 let mut entries: Vec<_> = std::fs::read_dir(dir)
1273 .with_context(|| format!("reading {}", dir.display()))?
1274 .filter_map(|e| e.ok())
1275 .filter(|e| e.path().extension().map_or(false, |x| x == "toml"))
1276 .collect();
1277 entries.sort_by_key(|e| e.file_name());
1278 for entry in entries {
1279 let path = entry.path();
1280 let stem = path
1281 .file_stem()
1282 .and_then(|s| s.to_str())
1283 .unwrap_or("")
1284 .to_string();
1285 let dom = DomainConfig::load(&path)?;
1286 if dom.name != stem {
1287 anyhow::bail!(
1288 "domains/{}.toml: name = \"{}\" must match the file stem",
1289 stem,
1290 dom.name
1291 );
1292 }
1293 out.insert(dom.name.clone(), dom);
1294 }
1295 Ok(out)
1296}
1297
1298fn load_workloads(dir: std::path::PathBuf) -> Result<Vec<WorkloadConfig>> {
1300 if !dir.exists() {
1301 return Ok(vec![]);
1302 }
1303 let mut items = vec![];
1304 let mut entries: Vec<_> = std::fs::read_dir(&dir)
1305 .with_context(|| format!("reading {}", dir.display()))?
1306 .filter_map(|e| e.ok())
1307 .filter(|e| e.path().extension().map_or(false, |x| x == "toml"))
1308 .collect();
1309 entries.sort_by_key(|e| e.file_name());
1310
1311 for entry in entries {
1312 let path = entry.path();
1313 let path_str = path.display().to_string();
1314 let src =
1315 std::fs::read_to_string(&path).with_context(|| format!("reading {}", path_str))?;
1316 let spec: WorkloadSpec =
1317 toml::from_str(&src).with_context(|| format!("parsing {}", path_str))?;
1318
1319 validate::shape(&spec)
1321 .map_err(|e| anyhow::anyhow!("workload {} failed shape validation: {e}", path_str))?;
1322
1323 items.push(WorkloadConfig { spec });
1324 }
1325 Ok(items)
1326}
1327
1328fn load_mirrors(dir: std::path::PathBuf) -> Result<Vec<LegacyMirrorConfig>> {
1339 if !dir.exists() {
1340 return Ok(vec![]);
1341 }
1342 let mut mirrors = vec![];
1343 let mut entries: Vec<_> = std::fs::read_dir(&dir)
1344 .with_context(|| format!("reading {}", dir.display()))?
1345 .filter_map(|e| e.ok())
1346 .collect();
1347 entries.sort_by_key(|e| e.file_name());
1348
1349 for entry in entries {
1350 let path = entry.path();
1351 if path.is_dir() {
1352 let mirror_toml = path.join("mirror.toml");
1354 if mirror_toml.exists() {
1355 let src = std::fs::read_to_string(&mirror_toml)
1356 .with_context(|| format!("reading {}", mirror_toml.display()))?;
1357 let cfg: LegacyMirrorConfig = toml::from_str(&src)
1358 .with_context(|| format!("parsing {}", mirror_toml.display()))?;
1359 mirrors.push(cfg);
1360 }
1361 } else if path.extension().map_or(false, |e| e == "toml") {
1362 let src = std::fs::read_to_string(&path)
1364 .with_context(|| format!("reading {}", path.display()))?;
1365 let cfg: LegacyMirrorConfig =
1366 toml::from_str(&src).with_context(|| format!("parsing {}", path.display()))?;
1367 mirrors.push(cfg);
1368 }
1369 }
1370 Ok(mirrors)
1371}
1372
1373fn load_topology(path: std::path::PathBuf) -> Result<TopologyConfig> {
1375 if !path.exists() {
1376 return Ok(TopologyConfig::default());
1377 }
1378 let src =
1379 std::fs::read_to_string(&path).with_context(|| format!("reading {}", path.display()))?;
1380 toml::from_str(&src).with_context(|| format!("parsing {}", path.display()))
1381}
1382
1383fn load_dir<T: for<'de> Deserialize<'de>>(dir: std::path::PathBuf) -> Result<Vec<T>> {
1394 if !dir.exists() {
1395 return Ok(vec![]);
1396 }
1397 let mut entries: Vec<_> = std::fs::read_dir(&dir)
1398 .with_context(|| format!("reading {}", dir.display()))?
1399 .collect::<std::io::Result<Vec<_>>>()
1400 .with_context(|| format!("reading {}", dir.display()))?;
1401 entries.sort_by_key(|e| e.file_name());
1402
1403 let mut items = vec![];
1404 for entry in entries {
1405 let path = entry.path();
1406 if path.extension().map_or(false, |e| e == "toml") {
1407 let src = std::fs::read_to_string(&path)
1408 .with_context(|| format!("reading {}", path.display()))?;
1409 let item: T =
1410 toml::from_str(&src).with_context(|| format!("parsing {}", path.display()))?;
1411 items.push(item);
1412 }
1413 }
1414 Ok(items)
1415}
1416
1417#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
1442#[cfg_attr(feature = "json-schema", derive(schemars::JsonSchema))]
1443#[serde(rename_all = "kebab-case")]
1444pub enum Provider {
1445 Cloudflare,
1447 Hetzner,
1449 Vultr,
1452 Static,
1457 LocalStatic,
1460 LocalContainer,
1464 LocalProcess,
1470 MiniflareContainer,
1475 MinioContainer,
1480 LocalPgDev,
1494}
1495
1496#[derive(Debug, Clone, Serialize, Deserialize)]
1503#[cfg_attr(feature = "json-schema", derive(schemars::JsonSchema))]
1504pub struct ProviderConfig {
1505 pub schema_version: u32,
1506 pub id: String,
1507 pub kind: Provider,
1508 #[serde(default, skip_serializing_if = "Option::is_none")]
1512 pub credentials: Option<String>,
1513 #[serde(flatten)]
1518 #[cfg_attr(
1519 feature = "json-schema",
1520 schemars(with = "std::collections::BTreeMap<String, serde_json::Value>")
1521 )]
1522 pub fields: BTreeMap<String, toml::Value>,
1523}
1524
1525impl ProviderConfig {
1526 pub fn load(path: &Path) -> Result<Self> {
1528 let src =
1529 std::fs::read_to_string(path).with_context(|| format!("reading {}", path.display()))?;
1530 toml::from_str(&src).with_context(|| format!("parsing {}", path.display()))
1531 }
1532}
1533
1534#[derive(Debug, Clone, Serialize, Deserialize)]
1541#[cfg_attr(feature = "json-schema", derive(schemars::JsonSchema))]
1542pub struct ServiceConfig {
1543 pub schema_version: u32,
1544 pub name: String,
1545 pub domain: String,
1546 #[serde(default, skip_serializing_if = "Vec::is_empty")]
1547 pub components: Vec<ServiceComponent>,
1548 #[serde(default, skip_serializing_if = "DbCatalog::is_empty")]
1554 pub db: DbCatalog,
1555}
1556
1557impl ServiceConfig {
1558 pub fn load(path: &Path) -> Result<Self> {
1560 let src =
1561 std::fs::read_to_string(path).with_context(|| format!("reading {}", path.display()))?;
1562 toml::from_str(&src).with_context(|| format!("parsing {}", path.display()))
1563 }
1564
1565 pub fn save(&self, workspace_root: &Path) -> Result<()> {
1570 let dir = crate::paths::service_dir(workspace_root, &self.name);
1571 std::fs::create_dir_all(&dir).with_context(|| format!("creating {}", dir.display()))?;
1572 let path = crate::paths::service_toml(workspace_root, &self.name);
1573 let s = toml::to_string_pretty(self)
1574 .with_context(|| format!("serializing service {}", self.name))?;
1575 std::fs::write(&path, s).with_context(|| format!("writing {}", path.display()))
1576 }
1577
1578 pub fn delete(workspace_root: &Path, name: &str) -> Result<bool> {
1582 let dir = crate::paths::service_dir(workspace_root, name);
1583 if !dir.exists() {
1584 return Ok(false);
1585 }
1586 std::fs::remove_dir_all(&dir).with_context(|| format!("removing {}", dir.display()))?;
1587 Ok(true)
1588 }
1589}
1590
1591#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
1599#[cfg_attr(feature = "json-schema", derive(schemars::JsonSchema))]
1600pub struct GitSource {
1601 pub repo: String,
1603 #[serde(default = "default_git_ref")]
1605 pub r#ref: String,
1606 #[serde(default, skip_serializing_if = "Option::is_none")]
1609 pub subdir: Option<String>,
1610}
1611
1612fn default_git_ref() -> String {
1613 "main".to_string()
1614}
1615
1616#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
1620#[cfg_attr(feature = "json-schema", derive(schemars::JsonSchema))]
1621#[serde(tag = "kind", rename_all = "kebab-case")]
1622pub enum InfraSourceKind {
1623 Path {
1628 path: String,
1629 },
1630 Git(GitSource),
1636}
1637
1638#[derive(Debug, Clone, Copy, Default, Serialize, Deserialize, PartialEq, Eq)]
1645#[cfg_attr(feature = "json-schema", derive(schemars::JsonSchema))]
1646#[serde(rename_all = "kebab-case")]
1647pub enum SourceMode {
1648 #[default]
1652 ReadOnly,
1653 Manage,
1655}
1656
1657#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
1660#[cfg_attr(feature = "json-schema", derive(schemars::JsonSchema))]
1661pub struct InfraSource {
1662 pub owner: String,
1666 #[serde(flatten)]
1667 pub kind: InfraSourceKind,
1668 #[serde(default)]
1669 pub mode: SourceMode,
1670 #[serde(default)]
1674 pub select: Vec<String>,
1675}
1676
1677fn default_sources_schema_version() -> u32 {
1678 1
1679}
1680
1681#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
1684#[cfg_attr(feature = "json-schema", derive(schemars::JsonSchema))]
1685pub struct SourcesConfig {
1686 #[serde(default = "default_sources_schema_version")]
1687 pub schema_version: u32,
1688 #[serde(default, rename = "source")]
1691 pub source: Vec<InfraSource>,
1692}
1693
1694impl Default for SourcesConfig {
1695 fn default() -> Self {
1696 Self {
1697 schema_version: default_sources_schema_version(),
1698 source: Vec::new(),
1699 }
1700 }
1701}
1702
1703impl SourcesConfig {
1704 pub fn load(infra_dir: &Path) -> Result<Self> {
1708 let path = infra_dir.join("sources.toml");
1709 if !path.exists() {
1710 return Ok(Self::default());
1711 }
1712 let src =
1713 std::fs::read_to_string(&path).with_context(|| format!("reading {}", path.display()))?;
1714 toml::from_str(&src).with_context(|| format!("parsing {}", path.display()))
1715 }
1716}
1717
1718impl InfraSource {
1719 fn describe(&self) -> String {
1726 match &self.kind {
1727 InfraSourceKind::Path { path } => format!("path:{path}"),
1728 InfraSourceKind::Git(g) => format!("git:{}@{}", g.repo, g.r#ref),
1729 }
1730 }
1731
1732 fn infra_root(&self, workspace_root: &Path) -> std::path::PathBuf {
1746 match &self.kind {
1747 InfraSourceKind::Path { path } => workspace_root.join(path).join(".yah").join("infra"),
1748 InfraSourceKind::Git(g) => {
1749 let checkout = crate::paths::infra_source_cache_dir(workspace_root, &self.owner);
1750 match g.subdir.as_deref() {
1751 Some(subdir) => checkout.join(subdir),
1752 None => checkout,
1753 }
1754 }
1755 }
1756 }
1757}
1758
1759#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
1773#[cfg_attr(feature = "json-schema", derive(schemars::JsonSchema))]
1774pub struct InfraOrigin {
1775 pub owner: String,
1777 pub source: String,
1779 pub mode: SourceMode,
1784}
1785
1786fn load_dir_tolerant<T: for<'de> Deserialize<'de>>(
1801 dir: &Path,
1802) -> (Vec<T>, Vec<(std::path::PathBuf, anyhow::Error)>) {
1803 let Ok(read_dir) = std::fs::read_dir(dir) else {
1804 return (Vec::new(), Vec::new());
1805 };
1806 let mut entries: Vec<_> = read_dir.filter_map(|e| e.ok()).collect();
1807 entries.sort_by_key(|e| e.file_name());
1808
1809 let mut items = Vec::new();
1810 let mut skipped = Vec::new();
1811 for entry in entries {
1812 let path = entry.path();
1813 if path.extension().map_or(true, |e| e != "toml") {
1814 continue;
1815 }
1816 let parsed = std::fs::read_to_string(&path)
1817 .with_context(|| format!("reading {}", path.display()))
1818 .and_then(|src| {
1819 toml::from_str::<T>(&src).with_context(|| format!("parsing {}", path.display()))
1820 });
1821 match parsed {
1822 Ok(item) => items.push(item),
1823 Err(e) => skipped.push((path, e)),
1824 }
1825 }
1826 (items, skipped)
1827}
1828
1829fn machine_matches_select(machine: &MachineConfig, select: &[String]) -> bool {
1838 select.is_empty()
1839 || select
1840 .iter()
1841 .any(|s| *s == machine.name || machine.mesh_tags.contains(s))
1842}
1843
1844fn overlay_infra_sources(
1858 workspace_root: &Path,
1859 sources: &SourcesConfig,
1860 machines: &mut Vec<MachineConfig>,
1861 providers: &mut Vec<ProviderConfig>,
1862 machine_origins: &mut BTreeMap<String, InfraOrigin>,
1863 provider_origins: &mut BTreeMap<String, InfraOrigin>,
1864) {
1865 let mut seen_machine_names: std::collections::HashSet<String> =
1866 machines.iter().map(|m| m.name.clone()).collect();
1867 let mut seen_provider_ids: std::collections::HashSet<String> =
1868 providers.iter().map(|p| p.id.clone()).collect();
1869
1870 for source in &sources.source {
1871 let root = source.infra_root(workspace_root);
1872 let origin = InfraOrigin {
1873 owner: source.owner.clone(),
1874 source: source.describe(),
1875 mode: source.mode,
1876 };
1877
1878 let (foreign_machines, skipped) = load_dir_tolerant::<MachineConfig>(&root.join("machines"));
1879 for (path, e) in skipped {
1880 tracing::warn!(
1881 "infra source {:?} ({}): skipping unparseable machine {}: {e:#}",
1882 source.owner,
1883 root.display(),
1884 path.display()
1885 );
1886 }
1887 for m in foreign_machines {
1888 if seen_machine_names.contains(&m.name) {
1889 continue; }
1891 if !machine_matches_select(&m, &source.select) {
1892 continue;
1893 }
1894 seen_machine_names.insert(m.name.clone());
1895 machine_origins.insert(m.name.clone(), origin.clone());
1896 machines.push(m);
1897 }
1898
1899 let (foreign_providers, skipped) = load_dir_tolerant::<ProviderConfig>(&root.join("providers"));
1900 for (path, e) in skipped {
1901 tracing::warn!(
1902 "infra source {:?} ({}): skipping unparseable provider {}: {e:#}",
1903 source.owner,
1904 root.display(),
1905 path.display()
1906 );
1907 }
1908 for p in foreign_providers {
1909 if seen_provider_ids.contains(&p.id) {
1910 continue;
1911 }
1912 seen_provider_ids.insert(p.id.clone());
1913 provider_origins.insert(p.id.clone(), origin.clone());
1914 providers.push(p);
1915 }
1916 }
1917}
1918
1919#[derive(Debug, Clone, Serialize, Deserialize)]
1923#[cfg_attr(feature = "json-schema", derive(schemars::JsonSchema))]
1924pub struct ServiceComponent {
1925 pub id: String,
1926 pub kind: String,
1927 pub path: String,
1931 #[serde(default, skip_serializing_if = "Option::is_none")]
1934 pub git: Option<GitSource>,
1935 pub role: String,
1937 #[serde(default, skip_serializing_if = "Option::is_none")]
1940 pub publishes: Option<String>,
1941 #[serde(default, skip_serializing_if = "is_zero_u32")]
1945 pub wave: u32,
1946}
1947
1948#[inline]
1949fn is_zero_u32(n: &u32) -> bool {
1950 *n == 0
1951}
1952
1953#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq)]
1961#[cfg_attr(feature = "json-schema", derive(schemars::JsonSchema))]
1962pub struct DbCatalog {
1963 #[serde(default, skip_serializing_if = "Vec::is_empty")]
1965 pub dev: Vec<DevDb>,
1966 #[serde(default, skip_serializing_if = "Vec::is_empty")]
1968 pub pond: Vec<PondDb>,
1969 #[serde(default, skip_serializing_if = "Vec::is_empty")]
1971 pub cloud: Vec<CloudDb>,
1972}
1973
1974impl DbCatalog {
1975 pub fn is_empty(&self) -> bool {
1978 self.dev.is_empty() && self.pond.is_empty() && self.cloud.is_empty()
1979 }
1980}
1981
1982#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
1986#[cfg_attr(feature = "json-schema", derive(schemars::JsonSchema))]
1987pub struct DevDb {
1988 pub name: String,
1991 pub path: String,
1993}
1994
1995#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
2001#[cfg_attr(feature = "json-schema", derive(schemars::JsonSchema))]
2002pub struct PondDb {
2003 pub name: String,
2005 #[serde(default, skip_serializing_if = "Option::is_none")]
2008 pub port: Option<u16>,
2009 #[serde(default, skip_serializing_if = "Option::is_none")]
2012 pub url: Option<String>,
2013 #[serde(default)]
2017 pub kind: PondDbKind,
2018}
2019
2020#[derive(Debug, Clone, Copy, Default, Serialize, Deserialize, PartialEq, Eq)]
2022#[cfg_attr(feature = "json-schema", derive(schemars::JsonSchema))]
2023#[serde(rename_all = "kebab-case")]
2024pub enum PondDbKind {
2025 #[default]
2027 Turso,
2028 Postgres,
2030}
2031
2032#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
2039#[cfg_attr(feature = "json-schema", derive(schemars::JsonSchema))]
2040pub struct CloudDb {
2041 pub name: String,
2043 pub url: String,
2046 #[serde(default, skip_serializing_if = "Option::is_none")]
2050 pub auth_token_env: Option<String>,
2051}
2052
2053#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq)]
2058#[cfg_attr(feature = "json-schema", derive(schemars::JsonSchema))]
2059pub struct CampCloudDbs {
2060 #[serde(default, rename = "cloud", skip_serializing_if = "Vec::is_empty")]
2061 pub cloud: Vec<CloudDb>,
2062}
2063
2064impl CampCloudDbs {
2065 pub fn load(camp_root: &Path) -> Result<Self> {
2068 let path = camp_root.join(".yah/db/cloud.toml");
2069 if !path.exists() {
2070 return Ok(Self::default());
2071 }
2072 let src = std::fs::read_to_string(&path)
2073 .with_context(|| format!("reading {}", path.display()))?;
2074 toml::from_str(&src).with_context(|| format!("parsing {}", path.display()))
2075 }
2076}
2077
2078#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
2080#[cfg_attr(feature = "json-schema", derive(schemars::JsonSchema))]
2081#[serde(rename_all = "kebab-case")]
2082pub enum MirrorShape {
2083 SingleMachine,
2085 Local,
2088 MultiMachine,
2090}
2091
2092#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)]
2108#[cfg_attr(feature = "json-schema", derive(schemars::JsonSchema))]
2109#[serde(rename_all = "kebab-case")]
2110pub enum IngressProvider {
2111 #[default]
2115 None,
2116 CloudflareTunnel,
2120 Passway,
2123}
2124
2125impl IngressProvider {
2126 pub fn is_declared(self) -> bool {
2128 !matches!(self, Self::None)
2129 }
2130
2131 pub fn as_str(self) -> &'static str {
2133 match self {
2134 Self::None => "none",
2135 Self::CloudflareTunnel => "cloudflare-tunnel",
2136 Self::Passway => "passway",
2137 }
2138 }
2139}
2140
2141#[derive(Debug, Clone, Serialize, Deserialize)]
2144#[cfg_attr(feature = "json-schema", derive(schemars::JsonSchema))]
2145pub struct MirrorConfig {
2146 pub schema_version: u32,
2147 pub shape: MirrorShape,
2148 #[serde(default, skip_serializing_if = "not_declared")]
2156 pub ingress: IngressProvider,
2157 #[serde(default)]
2161 pub providers: BTreeMap<String, MirrorProviderSlot>,
2162 #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
2182 pub drivers: BTreeMap<String, MirrorProviderSlot>,
2183 #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
2190 pub asset_aliases: BTreeMap<String, String>,
2191}
2192
2193fn not_declared(ingress: &IngressProvider) -> bool {
2196 !ingress.is_declared()
2197}
2198
2199impl MirrorConfig {
2200 pub fn load(path: &Path) -> Result<Self> {
2202 let src =
2203 std::fs::read_to_string(path).with_context(|| format!("reading {}", path.display()))?;
2204 toml::from_str(&src).with_context(|| format!("parsing {}", path.display()))
2205 }
2206
2207 pub fn save(&self, workspace_root: &Path, service: &str, env: &str) -> Result<()> {
2211 let dir = crate::paths::service_mirrors_dir(workspace_root, service);
2212 std::fs::create_dir_all(&dir).with_context(|| format!("creating {}", dir.display()))?;
2213 let path = crate::paths::service_mirror_toml(workspace_root, service, env);
2214 let s = toml::to_string_pretty(self)
2215 .with_context(|| format!("serializing mirror {service}/{env}"))?;
2216 std::fs::write(&path, s).with_context(|| format!("writing {}", path.display()))
2217 }
2218
2219 pub fn delete(workspace_root: &Path, service: &str, env: &str) -> Result<bool> {
2226 let path = crate::paths::service_mirror_toml(workspace_root, service, env);
2227 if path.exists() {
2228 std::fs::remove_file(&path).with_context(|| format!("removing {}", path.display()))?;
2229 return Ok(true);
2230 }
2231 let legacy: &[&str] = match env {
2233 "dev" => &["local"],
2234 "pond" => &["local-sim", "sim"],
2235 "cloud" => &["prod"],
2236 _ => &[],
2237 };
2238 for stem in legacy {
2239 let alt = crate::paths::service_mirror_toml(workspace_root, service, stem);
2240 if alt.exists() {
2241 std::fs::remove_file(&alt)
2242 .with_context(|| format!("removing {}", alt.display()))?;
2243 return Ok(true);
2244 }
2245 }
2246 Ok(false)
2247 }
2248}
2249
2250#[derive(Debug, Clone, Serialize, Deserialize)]
2256#[cfg_attr(feature = "json-schema", derive(schemars::JsonSchema))]
2257#[serde(untagged)]
2258pub enum MirrorProviderSlot {
2259 Reference {
2260 #[serde(rename = "use")]
2261 provider_id: String,
2262 #[serde(flatten)]
2263 #[cfg_attr(
2264 feature = "json-schema",
2265 schemars(with = "std::collections::BTreeMap<String, serde_json::Value>")
2266 )]
2267 fields: BTreeMap<String, toml::Value>,
2268 },
2269 Inline {
2270 kind: Provider,
2271 #[serde(flatten)]
2272 #[cfg_attr(
2273 feature = "json-schema",
2274 schemars(with = "std::collections::BTreeMap<String, serde_json::Value>")
2275 )]
2276 fields: BTreeMap<String, toml::Value>,
2277 },
2278}
2279
2280impl MirrorProviderSlot {
2281 pub fn provider_id(&self) -> Option<&str> {
2283 match self {
2284 Self::Reference { provider_id, .. } => Some(provider_id),
2285 Self::Inline { .. } => None,
2286 }
2287 }
2288
2289 pub fn inline_kind(&self) -> Option<Provider> {
2292 match self {
2293 Self::Reference { .. } => None,
2294 Self::Inline { kind, .. } => Some(*kind),
2295 }
2296 }
2297
2298 pub fn fields(&self) -> &BTreeMap<String, toml::Value> {
2299 match self {
2300 Self::Reference { fields, .. } | Self::Inline { fields, .. } => fields,
2301 }
2302 }
2303
2304 pub fn required(&self) -> Option<RequiredSpec> {
2308 let v = self.fields().get("required")?.clone();
2309 v.try_into().ok()
2310 }
2311}
2312
2313#[derive(Debug, Clone, Default, Serialize, Deserialize)]
2334#[cfg_attr(feature = "json-schema", derive(schemars::JsonSchema))]
2335pub struct RequiredSpec {
2336 #[serde(default, skip_serializing_if = "Vec::is_empty")]
2337 pub regions: Vec<String>,
2338 #[serde(default, skip_serializing_if = "Vec::is_empty")]
2339 pub zones: Vec<String>,
2340 #[serde(default, skip_serializing_if = "Vec::is_empty")]
2341 pub providers: Vec<String>,
2342 #[serde(default, skip_serializing_if = "Vec::is_empty")]
2343 pub mesh_tags: Vec<String>,
2344
2345 #[serde(default, skip_serializing_if = "is_zero_u32")]
2349 pub memory_mb: u32,
2350 #[serde(default, skip_serializing_if = "is_zero_u32")]
2354 pub cpu_millis: u32,
2355 #[serde(skip)]
2360 pub repel_archetype: Option<LifecycleArchetype>,
2361 #[serde(skip)]
2365 pub requires_taint: Option<String>,
2366}
2367
2368impl RequiredSpec {
2369 pub fn is_unconstrained(&self) -> bool {
2371 self.regions.is_empty()
2372 && self.zones.is_empty()
2373 && self.providers.is_empty()
2374 && self.mesh_tags.is_empty()
2375 && self.memory_mb == 0
2376 && self.cpu_millis == 0
2377 && self.repel_archetype.is_none()
2378 && self.requires_taint.is_none()
2379 }
2380
2381 pub fn matches(&self, machine: &MachineConfig) -> bool {
2394 let member_ok = |constraint: &[String], value: Option<&str>| -> bool {
2395 constraint.is_empty() || value.map_or(false, |v| constraint.iter().any(|c| c == v))
2396 };
2397
2398 if !member_ok(&self.regions, machine.region.as_deref())
2400 || !member_ok(&self.zones, machine.zone.as_deref())
2401 || !member_ok(&self.providers, Some(machine.provider.as_str()))
2402 || !self
2403 .mesh_tags
2404 .iter()
2405 .all(|t| machine.mesh_tags.iter().any(|mt| mt == t))
2406 {
2407 return false;
2408 }
2409
2410 if self.memory_mb > 0 || self.cpu_millis > 0 {
2413 if let Some(alloc) = &machine.allocatable {
2414 if self.memory_mb > alloc.memory_mb || self.cpu_millis > alloc.cpu_millis {
2415 return false;
2416 }
2417 }
2418 }
2419
2420 if let Some(arch) = self.repel_archetype {
2423 let repel_key = format!("no-{}", arch.taint_key());
2424 if machine.taints.iter().any(|t| *t == repel_key) {
2425 return false;
2426 }
2427 }
2428
2429 if let Some(req) = &self.requires_taint {
2432 let has_it = machine.taints.iter().any(|t| t == req)
2433 || machine.mesh_tags.iter().any(|t| t == req);
2434 if !has_it {
2435 return false;
2436 }
2437 }
2438
2439 true
2440 }
2441
2442 pub fn describe(&self) -> String {
2445 let mut parts = Vec::new();
2446 let mut push = |label: &str, vals: &[String]| {
2447 if !vals.is_empty() {
2448 parts.push(format!("required.{label}=[{}]", vals.join(",")));
2449 }
2450 };
2451 push("regions", &self.regions);
2452 push("zones", &self.zones);
2453 push("providers", &self.providers);
2454 push("mesh_tags", &self.mesh_tags);
2455 if self.memory_mb > 0 {
2456 parts.push(format!("memory_mb>={}", self.memory_mb));
2457 }
2458 if self.cpu_millis > 0 {
2459 parts.push(format!("cpu_millis>={}", self.cpu_millis));
2460 }
2461 if let Some(arch) = self.repel_archetype {
2462 parts.push(format!("not-tainted(no-{})", arch.taint_key()));
2463 }
2464 if let Some(req) = &self.requires_taint {
2465 parts.push(format!("requires_taint={req}"));
2466 }
2467 if parts.is_empty() {
2468 "no constraints".to_string()
2469 } else {
2470 parts.join(" + ")
2471 }
2472 }
2473}
2474
2475#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
2496#[cfg_attr(feature = "json-schema", derive(schemars::JsonSchema))]
2497#[serde(rename_all = "kebab-case")]
2498pub enum FrontDoor {
2499 BucketDirect,
2505 Worker,
2507 Passway,
2511}
2512
2513impl FrontDoor {
2514 pub fn is_route_driven(self) -> bool {
2517 matches!(self, FrontDoor::Worker | FrontDoor::Passway)
2518 }
2519
2520 pub fn as_str(self) -> &'static str {
2522 match self {
2523 FrontDoor::BucketDirect => "bucket-direct",
2524 FrontDoor::Worker => "worker",
2525 FrontDoor::Passway => "passway",
2526 }
2527 }
2528}
2529
2530#[derive(Debug, Clone, Serialize, Deserialize)]
2538#[cfg_attr(feature = "json-schema", derive(schemars::JsonSchema))]
2539pub struct DomainConfig {
2540 pub schema_version: u32,
2541 pub name: String,
2544 pub domain: String,
2547 pub front_door: FrontDoor,
2552 pub cdn_bucket: String,
2555 #[serde(default, skip_serializing_if = "Option::is_none")]
2559 pub worker_bundle_path: Option<String>,
2560 #[serde(default, skip_serializing_if = "Vec::is_empty")]
2561 pub routes: Vec<DomainRoute>,
2562}
2563
2564#[derive(Debug, Clone, Serialize, Deserialize)]
2570#[cfg_attr(feature = "json-schema", derive(schemars::JsonSchema))]
2571pub struct DomainRoute {
2572 pub path: String,
2575 #[serde(flatten)]
2576 pub mode: RouteMode,
2577}
2578
2579#[derive(Debug, Clone, Serialize, Deserialize)]
2588#[cfg_attr(feature = "json-schema", derive(schemars::JsonSchema))]
2589#[serde(tag = "mode", rename_all = "kebab-case")]
2590pub enum RouteMode {
2591 Static {
2592 component: String,
2595 },
2596 Backend {
2597 component: String,
2600 origin: String,
2604 },
2605 Redirect {
2606 target: String,
2608 #[serde(default = "default_redirect_status")]
2611 status: u16,
2612 },
2613}
2614
2615fn default_redirect_status() -> u16 {
2616 308
2617}
2618
2619impl DomainConfig {
2620 pub fn load(path: &Path) -> Result<Self> {
2624 let src =
2625 std::fs::read_to_string(path).with_context(|| format!("reading {}", path.display()))?;
2626 let dom: Self =
2627 toml::from_str(&src).with_context(|| format!("parsing {}", path.display()))?;
2628 dom.validate_front_door()
2629 .with_context(|| format!("validating {}", path.display()))?;
2630 Ok(dom)
2631 }
2632
2633 pub fn validate_front_door(&self) -> Result<()> {
2647 match self.front_door {
2648 FrontDoor::BucketDirect => {
2649 if let Some(route) = self.routes.first() {
2650 anyhow::bail!(
2651 "front_door = \"bucket-direct\" but routes[0].path = \"{}\" — \
2652 an R2 custom domain never consults a route table, so this \
2653 route would silently do nothing (no clean URLs, no SPA \
2654 fallback, no branded errors). Set front_door = \"worker\" \
2655 (or \"passway\") to keep the routes, or drop the [[routes]] \
2656 to keep the bucket-direct binding.",
2657 route.path
2658 );
2659 }
2660 if let Some(path) = &self.worker_bundle_path {
2661 anyhow::bail!(
2662 "front_door = \"bucket-direct\" but worker_bundle_path = \
2663 \"{path}\" — nothing deploys a Worker bundle for a domain \
2664 bound straight to R2"
2665 );
2666 }
2667 }
2668 FrontDoor::Worker | FrontDoor::Passway => {
2669 if self.routes.is_empty() {
2670 anyhow::bail!(
2671 "front_door = \"{}\" but [[routes]] is empty — a front door \
2672 with no route table is a silent 404 machine. Declare at \
2673 least one route, or set front_door = \"bucket-direct\" if \
2674 this domain really is served straight from R2.",
2675 self.front_door.as_str()
2676 );
2677 }
2678 }
2679 }
2680 Ok(())
2681 }
2682
2683 pub fn save(&self, workspace_root: &Path) -> Result<()> {
2686 let dir = crate::paths::domains_dir(workspace_root);
2687 std::fs::create_dir_all(&dir).with_context(|| format!("creating {}", dir.display()))?;
2688 let path = crate::paths::domain_toml(workspace_root, &self.name);
2689 let s = toml::to_string_pretty(self)
2690 .with_context(|| format!("serializing domain {}", self.name))?;
2691 std::fs::write(&path, s).with_context(|| format!("writing {}", path.display()))
2692 }
2693
2694 pub fn delete(workspace_root: &Path, name: &str) -> Result<bool> {
2697 let path = crate::paths::domain_toml(workspace_root, name);
2698 if !path.exists() {
2699 return Ok(false);
2700 }
2701 std::fs::remove_file(&path).with_context(|| format!("removing {}", path.display()))?;
2702 Ok(true)
2703 }
2704}
2705
2706impl RouteMode {
2707 pub fn component(&self) -> Option<&str> {
2709 match self {
2710 Self::Static { component } | Self::Backend { component, .. } => Some(component),
2711 Self::Redirect { .. } => None,
2712 }
2713 }
2714}
2715
2716#[derive(Debug, Clone, Serialize, Deserialize)]
2749#[cfg_attr(feature = "json-schema", derive(schemars::JsonSchema))]
2750pub struct SecretConfig {
2751 pub schema_version: u32,
2752
2753 pub name: String,
2757
2758 pub vault_slot: String,
2763
2764 #[serde(default, skip_serializing_if = "Option::is_none")]
2767 pub description: Option<String>,
2768
2769 #[serde(default)]
2780 pub encoding: SecretEncoding,
2781
2782 #[serde(default)]
2788 pub access: SecretAccess,
2789
2790 #[serde(default, skip_serializing_if = "Option::is_none")]
2795 pub target: Option<SecretTargetDecl>,
2796}
2797
2798#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
2801#[cfg_attr(feature = "json-schema", derive(schemars::JsonSchema))]
2802#[serde(rename_all = "kebab-case")]
2803pub enum SecretEncoding {
2804 #[default]
2806 Utf8,
2807 Hex,
2810}
2811
2812#[derive(Debug, Clone, Serialize, Deserialize)]
2817#[cfg_attr(feature = "json-schema", derive(schemars::JsonSchema))]
2818#[serde(tag = "kind", rename_all = "kebab-case")]
2819pub enum SecretTargetDecl {
2820 File {
2822 path: String,
2824 #[serde(default = "default_secret_mode")]
2826 mode: u32,
2827 },
2828 EnvVar { name: String },
2831}
2832
2833fn default_secret_mode() -> u32 {
2834 0o400
2835}
2836
2837impl SecretTargetDecl {
2838 pub fn to_target(&self) -> workload_spec::SecretTarget {
2840 match self {
2841 Self::File { path, mode } => workload_spec::SecretTarget::File {
2842 path: path.into(),
2843 mode: *mode,
2844 },
2845 Self::EnvVar { name } => workload_spec::SecretTarget::EnvVar { name: name.clone() },
2846 }
2847 }
2848}
2849
2850impl SecretConfig {
2851 pub fn load(path: &Path) -> Result<Self> {
2853 let src =
2854 std::fs::read_to_string(path).with_context(|| format!("reading {}", path.display()))?;
2855 let cfg: Self =
2856 toml::from_str(&src).with_context(|| format!("parsing {}", path.display()))?;
2857 cfg.validate()
2858 .with_context(|| format!("validating {}", path.display()))?;
2859 Ok(cfg)
2860 }
2861
2862 pub fn load_dir(dir: &Path) -> Result<BTreeMap<String, Self>> {
2869 let mut out: BTreeMap<String, Self> = BTreeMap::new();
2870 if !dir.exists() {
2871 return Ok(out);
2872 }
2873 for entry in std::fs::read_dir(dir).with_context(|| format!("reading {}", dir.display()))? {
2874 let path = entry?.path();
2875 if path.extension().is_none_or(|e| e != "toml") {
2876 continue;
2877 }
2878 let cfg = Self::load(&path)?;
2879 if let Some(prev) = out.insert(cfg.name.clone(), cfg) {
2880 anyhow::bail!(
2881 "two secret declarations both claim name {:?} (one of them is {}); \
2882 a cluster secret must have exactly one declaration so its access \
2883 rule has one author",
2884 prev.name,
2885 path.display()
2886 );
2887 }
2888 }
2889 Ok(out)
2890 }
2891
2892 pub fn validate(&self) -> Result<()> {
2894 if self.name.trim().is_empty() {
2895 anyhow::bail!("`name` must not be empty");
2896 }
2897 if self.vault_slot.trim().is_empty() {
2898 anyhow::bail!(
2899 "`vault_slot` must not be empty — it names the fob slot holding the value"
2900 );
2901 }
2902 if let SecretAccess::Workloads(entries) = &self.access {
2907 if entries.is_empty() {
2908 anyhow::bail!(
2909 "`[access]` admits nobody: list the workloads allowed to mount {:?} \
2910 (e.g. `workloads = [{{ workload = \"my-service\" }}]`), or set \
2911 `access = \"allow_any\"` to store it unrestricted",
2912 self.name
2913 );
2914 }
2915 if let Some(bad) = entries.iter().find(|e| e.workload.trim().is_empty()) {
2916 anyhow::bail!("`[access]` entry has an empty `workload` name: {bad:?}");
2917 }
2918 }
2919 Ok(())
2920 }
2921}
2922
2923#[cfg(test)]
2924mod secret_config_tests {
2925 use super::*;
2926
2927 fn parse(body: &str) -> Result<SecretConfig> {
2928 let cfg: SecretConfig = toml::from_str(body)?;
2929 cfg.validate()?;
2930 Ok(cfg)
2931 }
2932
2933 #[test]
2934 fn minimal_declaration_parses_with_narrow_defaults() {
2935 let cfg = parse(
2936 r#"
2937schema_version = 1
2938name = "svc/token"
2939vault_slot = "svc-token"
2940[access]
2941workloads = [{ workload = "svc" }]
2942"#,
2943 )
2944 .unwrap();
2945
2946 assert_eq!(cfg.encoding, SecretEncoding::Utf8, "text is the default");
2947 assert!(cfg.target.is_none());
2948 assert!(cfg
2951 .access
2952 .admits(&workload_spec::secrets::SecretConsumer::workload("svc")));
2953 assert!(!cfg
2954 .access
2955 .admits(&workload_spec::secrets::SecretConsumer::workload("other")));
2956 }
2957
2958 #[test]
2959 fn allow_any_is_spelled_as_a_bare_string() {
2960 let cfg = parse(
2962 r#"
2963schema_version = 1
2964name = "public/thing"
2965vault_slot = "slot"
2966access = "allow_any"
2967"#,
2968 )
2969 .unwrap();
2970 assert_eq!(cfg.access, SecretAccess::AllowAny);
2971 }
2972
2973 #[test]
2974 fn a_declaration_with_no_access_block_is_rejected() {
2975 let err = parse(
2979 r#"
2980schema_version = 1
2981name = "svc/token"
2982vault_slot = "svc-token"
2983"#,
2984 )
2985 .unwrap_err()
2986 .to_string();
2987 assert!(err.contains("admits nobody"), "got {err}");
2988 }
2989
2990 #[test]
2991 fn empty_name_or_slot_is_rejected() {
2992 assert!(parse(
2993 r#"
2994schema_version = 1
2995name = ""
2996vault_slot = "slot"
2997access = "allow_any"
2998"#
2999 )
3000 .is_err());
3001 assert!(parse(
3002 r#"
3003schema_version = 1
3004name = "x"
3005vault_slot = " "
3006access = "allow_any"
3007"#
3008 )
3009 .is_err());
3010 }
3011
3012 #[test]
3013 fn target_declaration_maps_onto_the_workload_spec_type() {
3014 let cfg = parse(
3015 r#"
3016schema_version = 1
3017name = "svc/token"
3018vault_slot = "slot"
3019access = "allow_any"
3020[target]
3021kind = "file"
3022path = "/run/secrets/t"
3023"#,
3024 )
3025 .unwrap();
3026 match cfg.target.unwrap().to_target() {
3027 workload_spec::SecretTarget::File { path, mode } => {
3028 assert_eq!(path, std::path::PathBuf::from("/run/secrets/t"));
3029 assert_eq!(mode, 0o400, "owner-read-only by default");
3030 }
3031 other => panic!("expected File, got {other:?}"),
3032 }
3033 }
3034
3035 #[test]
3036 fn load_dir_is_empty_for_a_camp_with_no_secrets() {
3037 let tmp = tempfile::TempDir::new().unwrap();
3038 assert!(SecretConfig::load_dir(&tmp.path().join("nope"))
3039 .unwrap()
3040 .is_empty());
3041 }
3042}
3043
3044fn split_component_ref(s: &str) -> Option<(&str, &str)> {
3047 let (svc, comp) = s.split_once('/')?;
3048 if svc.is_empty() || comp.is_empty() || comp.contains('/') {
3049 return None;
3050 }
3051 Some((svc, comp))
3052}
3053
3054#[cfg(test)]
3055mod tests {
3056 use super::*;
3057 use std::path::PathBuf;
3058
3059 fn make_machine(name: &str, mesh_tags: Vec<&str>) -> MachineConfig {
3060 MachineConfig {
3061 name: name.into(),
3062 provider: "hetzner".into(),
3063 location: Some("hil".into()),
3064 server_type: Some("ccx13".into()),
3065 hosts_mirrors: vec![],
3066 mesh_tags: mesh_tags.into_iter().map(String::from).collect(),
3067 region: None,
3068 zone: None,
3069 arch: None,
3070 bucket: None,
3071 vendor: None,
3072 nickname: None,
3073 legacy_hostkey_fingerprint: None,
3074 registration: Default::default(),
3075 ssh_keys: vec![],
3076 cloudflared: None,
3077 hosts_operator_bridge: false,
3078 connect: None,
3079 allocatable: None,
3080 taints: vec![],
3081 }
3082 }
3083
3084 fn make_machine_topo(
3086 name: &str,
3087 provider: &str,
3088 region: &str,
3089 mesh_tags: Vec<&str>,
3090 ) -> MachineConfig {
3091 MachineConfig {
3092 provider: provider.into(),
3093 region: Some(region.into()),
3094 zone: Some(region.into()),
3095 ..make_machine(name, mesh_tags)
3096 }
3097 }
3098
3099 fn make_empty_cfg(machines: Vec<MachineConfig>) -> CloudConfig {
3100 CloudConfig {
3101 workspace_root: PathBuf::new(),
3102 machines,
3103 providers: vec![],
3104 machine_origins: BTreeMap::new(),
3105 provider_origins: BTreeMap::new(),
3106 services: BTreeMap::new(),
3107 domains: BTreeMap::new(),
3108 legacy_mirrors: vec![],
3109 workloads: vec![],
3110 topology: TopologyConfig::default(),
3111 legacy_services: vec![],
3112 }
3113 }
3114
3115 #[test]
3116 fn required_spec_parses_from_provider_fields() {
3117 let toml_src = r#"
3118use = "hetzner-primary"
3119[required]
3120mesh_tags = ["tag:cloud-runner"]
3121"#;
3122 let slot: MirrorProviderSlot = toml::from_str(toml_src).unwrap();
3123 let req = slot.required().expect("required block present");
3124 assert_eq!(req.mesh_tags, vec!["tag:cloud-runner"]);
3125 }
3126
3127 #[test]
3128 fn required_spec_absent_when_field_missing() {
3129 let slot: MirrorProviderSlot = toml::from_str(r#"use = "hetzner-primary""#).unwrap();
3130 assert!(slot.required().is_none());
3131 }
3132
3133 #[test]
3134 fn db_catalog_parses_all_env_blocks() {
3135 let toml_src = r#"
3137schema_version = 1
3138name = "scrabcake"
3139domain = "scrabcake.net.yah.dev"
3140
3141[[db.dev]]
3142name = "main"
3143path = "data/dev.sqlite"
3144
3145[[db.pond]]
3146name = "main"
3147port = 5433
3148
3149[[db.pond]]
3150name = "pg"
3151port = 5432
3152kind = "postgres"
3153
3154[[db.cloud]]
3155name = "main"
3156url = "libsql://scrabcake.turso.io"
3157auth_token_env = "SCRABCAKE_TURSO_TOKEN"
3158"#;
3159 let svc: ServiceConfig = toml::from_str(toml_src).unwrap();
3160 assert_eq!(svc.db.dev.len(), 1);
3161 assert_eq!(svc.db.dev[0].path, "data/dev.sqlite");
3162 assert_eq!(svc.db.pond.len(), 2);
3163 assert_eq!(svc.db.pond[0].port, Some(5433));
3164 assert_eq!(svc.db.pond[0].kind, PondDbKind::Turso); assert_eq!(svc.db.pond[1].kind, PondDbKind::Postgres);
3166 assert_eq!(
3167 svc.db.cloud[0].auth_token_env.as_deref(),
3168 Some("SCRABCAKE_TURSO_TOKEN")
3169 );
3170 }
3171
3172 #[test]
3173 fn service_without_db_table_has_empty_catalog() {
3174 let svc: ServiceConfig =
3175 toml::from_str("schema_version = 1\nname = \"s\"\ndomain = \"s.dev\"\n").unwrap();
3176 assert!(svc.db.is_empty());
3177 let out = toml::to_string(&svc).unwrap();
3179 assert!(
3180 !out.contains("[db"),
3181 "empty db table should be skipped: {out}"
3182 );
3183 }
3184
3185 #[test]
3186 fn camp_shared_cloud_toml_parses() {
3187 let src = r#"
3188[[cloud]]
3189name = "analytics"
3190url = "postgres://shared/analytics"
3191"#;
3192 let shared: CampCloudDbs = toml::from_str(src).unwrap();
3193 assert_eq!(shared.cloud.len(), 1);
3194 assert_eq!(shared.cloud[0].name, "analytics");
3195 }
3196
3197 #[test]
3198 fn resolve_machine_by_mesh_tags_superset_match() {
3199 let cfg = make_empty_cfg(vec![
3200 make_machine("yah-bnt-1", vec!["tag:primary-yah", "tag:tier-scratch"]),
3201 make_machine("us-west-001", vec!["tag:primary-yah", "tag:cloud-runner"]),
3202 ]);
3203 let picked = cfg
3204 .resolve_machine_by_mesh_tags(&["tag:cloud-runner".into()])
3205 .map(|m| m.name.as_str());
3206 assert_eq!(picked, Some("us-west-001"));
3207 }
3208
3209 #[test]
3210 fn resolve_machine_by_mesh_tags_returns_none_when_no_match() {
3211 let cfg = make_empty_cfg(vec![make_machine("yah-bnt-1", vec!["tag:primary-yah"])]);
3212 assert!(cfg
3213 .resolve_machine_by_mesh_tags(&["tag:cloud-runner".into()])
3214 .is_none());
3215 }
3216
3217 fn ws_with_selector(selector: Option<&str>) -> WorkloadSpec {
3223 use workload_spec::{ImageRef, TierTag};
3224 let mut ws = WorkloadSpec::for_forge(
3225 "R590-F1-test",
3226 ImageRef {
3227 registry: "docker.io".into(),
3228 repository: "library/busybox".into(),
3229 tag: "latest".into(),
3230 digest: workload_spec::testing::test_digest(),
3231 },
3232 TierTag("infra".into()),
3233 vec![],
3234 );
3235 if let Some(sel) = selector {
3236 ws.annotations.insert(
3237 velveteen_exec::remote::NODE_SELECTOR_MESH_TAGS_ANNOTATION.into(),
3238 sel.into(),
3239 );
3240 }
3241 ws
3242 }
3243
3244 fn build_worker_fleet() -> CloudConfig {
3247 make_empty_cfg(vec![
3248 make_machine("us-west-002", vec!["tag:build-worker", "tier:x86"]),
3249 make_machine("pi5-001", vec!["tag:build-worker", "tier:arm"]),
3250 ])
3251 }
3252
3253 #[test]
3254 fn admit_workload_routes_amd64_to_x86_worker() {
3255 let cfg = build_worker_fleet();
3256 let ws = ws_with_selector(Some("tag:build-worker,tier:x86"));
3257 let picked = cfg.admit_workload(&ws).unwrap();
3258 assert_eq!(picked.name, "us-west-002");
3259 }
3260
3261 #[test]
3262 fn admit_workload_routes_arm64_to_pi5_worker() {
3263 let cfg = build_worker_fleet();
3264 let ws = ws_with_selector(Some("tag:build-worker,tier:arm"));
3265 let picked = cfg.admit_workload(&ws).unwrap();
3266 assert_eq!(picked.name, "pi5-001");
3267 }
3268
3269 #[test]
3270 fn admit_workload_rejects_node_missing_required_tag() {
3271 let cfg = make_empty_cfg(vec![make_machine(
3273 "pi5-001",
3274 vec!["tag:build-worker", "tier:arm"],
3275 )]);
3276 let ws = ws_with_selector(Some("tag:build-worker,tier:x86"));
3277 assert!(cfg.admit_workload(&ws).is_err());
3278 }
3279
3280 #[test]
3294 fn equally_matching_machines_admit_in_file_name_order() {
3295 let tmp = tempfile::TempDir::new().unwrap();
3296 let machines = tmp.path().join(".yah").join("infra").join("machines");
3297 std::fs::create_dir_all(&machines).unwrap();
3298 let toml_for = |name: &str| {
3299 format!(
3300 r#"name = "{name}"
3301provider = "static"
3302mesh_tags = ["tag:build-worker", "tier:x86"]
3303"#
3304 )
3305 };
3306 std::fs::write(machines.join("b-second.toml"), toml_for("b-second")).unwrap();
3308 std::fs::write(machines.join("a-first.toml"), toml_for("a-first")).unwrap();
3309
3310 let cfg = CloudConfig::load(tmp.path()).unwrap();
3311 assert_eq!(
3312 cfg.machines.iter().map(|m| m.name.as_str()).collect::<Vec<_>>(),
3313 vec!["a-first", "b-second"],
3314 "machines must load in file-name order, not read_dir order"
3315 );
3316
3317 let ws = ws_with_selector(Some("tag:build-worker,tier:x86"));
3318 assert_eq!(cfg.admit_workload(&ws).unwrap().name, "a-first");
3319 }
3320
3321 #[test]
3322 fn admit_workload_empty_selector_is_unconstrained() {
3323 let cfg = build_worker_fleet();
3326 let ws = ws_with_selector(None);
3327 let picked = cfg.admit_workload(&ws).unwrap();
3328 assert_eq!(picked.name, "us-west-002");
3329 }
3330
3331 #[test]
3332 fn node_selector_mesh_tags_trims_and_drops_empties() {
3333 let ws = ws_with_selector(Some(" tag:build-worker , tier:x86 ,"));
3334 assert_eq!(
3335 node_selector_mesh_tags(&ws),
3336 vec!["tag:build-worker".to_string(), "tier:x86".to_string()]
3337 );
3338 assert!(node_selector_mesh_tags(&ws_with_selector(None)).is_empty());
3339 }
3340
3341 fn two_region_fleet() -> CloudConfig {
3344 make_empty_cfg(vec![
3345 make_machine_topo(
3346 "us-west-001",
3347 "hetzner",
3348 "us-west",
3349 vec!["tag:cloud-runner"],
3350 ),
3351 make_machine_topo(
3352 "eu-west-001",
3353 "hetzner",
3354 "eu-west",
3355 vec!["tag:cloud-runner"],
3356 ),
3357 ])
3358 }
3359
3360 #[test]
3361 fn resolve_machine_matches_on_region_plus_mesh_tags() {
3362 let cfg = two_region_fleet();
3363 let req = RequiredSpec {
3364 regions: vec!["us-west".into()],
3365 mesh_tags: vec!["tag:cloud-runner".into()],
3366 ..Default::default()
3367 };
3368 let picked = cfg.resolve_machine(&req).unwrap();
3369 assert_eq!(picked.name, "us-west-001");
3370 }
3371
3372 #[test]
3373 fn resolve_machine_region_disambiguates_same_tag() {
3374 let cfg = two_region_fleet();
3376 let req = RequiredSpec {
3377 regions: vec!["eu-west".into()],
3378 mesh_tags: vec!["tag:cloud-runner".into()],
3379 ..Default::default()
3380 };
3381 assert_eq!(cfg.resolve_machine(&req).unwrap().name, "eu-west-001");
3382 }
3383
3384 #[test]
3385 fn resolve_machine_fails_loud_with_constraint_summary() {
3386 let cfg = two_region_fleet();
3387 let req = RequiredSpec {
3388 regions: vec!["us-central".into()],
3389 mesh_tags: vec!["tag:cloud-runner".into()],
3390 ..Default::default()
3391 };
3392 let err = cfg.resolve_machine(&req).unwrap_err().to_string();
3393 assert!(err.contains("required.regions=[us-central]"), "got: {err}");
3394 assert!(
3395 err.contains("required.mesh_tags=[tag:cloud-runner]"),
3396 "got: {err}"
3397 );
3398 assert!(err.contains("us-west-001"), "got: {err}");
3400 }
3401
3402 #[test]
3403 fn resolve_machine_provider_axis_filters() {
3404 let cfg = make_empty_cfg(vec![
3405 make_machine_topo("aws-west-1", "aws", "us-west", vec!["tag:cloud-runner"]),
3406 make_machine_topo("hz-west-1", "hetzner", "us-west", vec!["tag:cloud-runner"]),
3407 ]);
3408 let req = RequiredSpec {
3409 regions: vec!["us-west".into()],
3410 providers: vec!["hetzner".into()],
3411 ..Default::default()
3412 };
3413 assert_eq!(cfg.resolve_machine(&req).unwrap().name, "hz-west-1");
3414 }
3415
3416 #[test]
3417 fn unconstrained_required_spec_matches_first_machine() {
3418 let cfg = two_region_fleet();
3419 assert!(RequiredSpec::default().is_unconstrained());
3420 assert_eq!(
3421 cfg.resolve_machine(&RequiredSpec::default()).unwrap().name,
3422 "us-west-001"
3423 );
3424 }
3425
3426 #[test]
3427 fn required_spec_parses_topology_axes_from_toml() {
3428 let toml_src = r#"
3429use = "hetzner-primary"
3430[required]
3431regions = ["us-west"]
3432mesh_tags = ["tag:cloud-runner"]
3433"#;
3434 let slot: MirrorProviderSlot = toml::from_str(toml_src).unwrap();
3435 let req = slot.required().expect("required block present");
3436 assert_eq!(req.regions, vec!["us-west"]);
3437 assert_eq!(req.mesh_tags, vec!["tag:cloud-runner"]);
3438 assert!(req.zones.is_empty());
3439 }
3440
3441 #[test]
3442 fn round_trip_machine() {
3443 let cfg = MachineConfig {
3444 name: "test-pdx-1".into(),
3445 provider: "hetzner".into(),
3446 location: Some("pdx".into()),
3447 server_type: Some("cpx22".into()),
3448 hosts_mirrors: vec!["noisetable".into()],
3449 mesh_tags: vec!["region:pdx".into()],
3450 region: Some("us-west".into()),
3451 zone: Some("pdx".into()),
3452 arch: None,
3453 bucket: Some(BucketSpec {
3454 name: "test-assets-pdx-1".into(),
3455 public_read: false,
3456 }),
3457 vendor: None,
3458 nickname: None,
3459 legacy_hostkey_fingerprint: None,
3460 registration: Default::default(),
3461 ssh_keys: vec![],
3462 cloudflared: None,
3463 hosts_operator_bridge: false,
3464 connect: None,
3465 allocatable: None,
3466 taints: vec![],
3467 };
3468 let s = toml::to_string(&cfg).unwrap();
3469 let back: MachineConfig = toml::from_str(&s).unwrap();
3470 assert_eq!(back.name, cfg.name);
3471 assert_eq!(back.location, cfg.location);
3472 assert_eq!(back.region.as_deref(), Some("us-west"));
3473 assert_eq!(back.zone.as_deref(), Some("pdx"));
3474 }
3475
3476 #[test]
3477 fn round_trip_mirror() {
3478 let cfg = LegacyMirrorConfig {
3479 camp: "noisetable".into(),
3480 regions: vec!["pdx".into(), "iad".into()],
3481 workloads: vec!["asset-registry".into()],
3482 cloud_domain: None,
3483 };
3484 let s = toml::to_string(&cfg).unwrap();
3485 let back: LegacyMirrorConfig = toml::from_str(&s).unwrap();
3486 assert_eq!(back.camp, cfg.camp);
3487 assert_eq!(back.regions, cfg.regions);
3488 assert_eq!(back.workloads, cfg.workloads);
3489 }
3490
3491 #[test]
3492 fn mirror_serialises_as_camp_key() {
3493 let cfg = LegacyMirrorConfig {
3495 camp: "noisetable".into(),
3496 regions: vec!["pdx".into()],
3497 workloads: vec![],
3498 cloud_domain: None,
3499 };
3500 let s = toml::to_string(&cfg).unwrap();
3501 assert!(
3502 s.contains("camp = "),
3503 "serialised key should be 'camp': {s}"
3504 );
3505 assert!(!s.contains("rig = "), "old key should not appear: {s}");
3506 }
3507
3508 #[test]
3509 fn mirror_rig_alias_still_loads() {
3510 let toml_str =
3513 "rig = \"noisetable\"\nregions = [\"pdx\"]\nworkloads = [\"asset-registry\"]\n";
3514 let cfg: LegacyMirrorConfig = toml::from_str(toml_str).unwrap();
3515 assert_eq!(cfg.camp, "noisetable");
3516 }
3517
3518 #[test]
3519 fn mirror_services_alias_still_loads() {
3520 let toml_str =
3523 "camp = \"noisetable\"\nregions = [\"pdx\"]\nservices = [\"asset-registry\"]\n";
3524 let cfg: LegacyMirrorConfig = toml::from_str(toml_str).unwrap();
3525 assert_eq!(cfg.workloads, vec!["asset-registry"]);
3526 }
3527
3528 #[test]
3529 fn round_trip_service_legacy() {
3530 let cfg = LegacyServiceConfig {
3531 name: "asset-registry".into(),
3532 image: "ghcr.io/noisetable/asset-registry".into(),
3533 version: "v1.0.0".into(),
3534 env: HashMap::new(),
3535 ports: vec![PortMapping {
3536 host: 8080,
3537 container: 8080,
3538 }],
3539 mesh_only: false,
3540 bind_interface: None,
3541 tenant: TenantId::singleton(),
3542 };
3543 let s = toml::to_string(&cfg).unwrap();
3544 let back: LegacyServiceConfig = toml::from_str(&s).unwrap();
3545 assert_eq!(back.name, cfg.name);
3546 assert_eq!(back.image, cfg.image);
3547 }
3548
3549 #[test]
3550 fn service_bind_interface_round_trips() {
3551 let cfg = LegacyServiceConfig {
3552 name: "postgres".into(),
3553 image: "postgres".into(),
3554 version: "16".into(),
3555 env: HashMap::new(),
3556 ports: vec![PortMapping {
3557 host: 5432,
3558 container: 5432,
3559 }],
3560 mesh_only: true,
3561 bind_interface: Some("tailscale0".into()),
3562 tenant: TenantId::singleton(),
3563 };
3564 let s = toml::to_string(&cfg).unwrap();
3565 let back: LegacyServiceConfig = toml::from_str(&s).unwrap();
3566 assert_eq!(back.bind_interface.as_deref(), Some("tailscale0"));
3567 }
3568
3569 #[test]
3570 fn service_bind_interface_absent_is_none() {
3571 let toml_str = "name = \"app\"\nimage = \"app\"\nversion = \"v1\"\n";
3572 let cfg: LegacyServiceConfig = toml::from_str(toml_str).unwrap();
3573 assert!(
3574 cfg.bind_interface.is_none(),
3575 "bind_interface should default to None"
3576 );
3577 }
3578
3579 #[test]
3580 fn service_bind_interface_skipped_when_none() {
3581 let cfg = LegacyServiceConfig {
3582 name: "app".into(),
3583 image: "app".into(),
3584 version: "v1".into(),
3585 env: HashMap::new(),
3586 ports: vec![],
3587 mesh_only: false,
3588 bind_interface: None,
3589 tenant: TenantId::singleton(),
3590 };
3591 let s = toml::to_string(&cfg).unwrap();
3592 assert!(!s.contains("bind_interface"), "None should be skipped: {s}");
3593 }
3594
3595 #[test]
3596 fn load_dir_missing_is_empty() {
3597 let dir = std::path::PathBuf::from("/nonexistent/path");
3598 let result: Vec<MachineConfig> = load_dir(dir).unwrap();
3599 assert!(result.is_empty());
3600 }
3601
3602 #[test]
3603 fn topology_round_trip() {
3604 let topo = TopologyConfig {
3605 assignments: vec![
3606 MirrorAssignment {
3607 mirror: "noisetable-pdx".into(),
3608 machine: "noisetable-pdx-1".into(),
3609 },
3610 MirrorAssignment {
3611 mirror: "noisetable-iad".into(),
3612 machine: "noisetable-iad-1".into(),
3613 },
3614 ],
3615 buckets: vec![],
3616 };
3617 let s = toml::to_string(&topo).unwrap();
3618 let back: TopologyConfig = toml::from_str(&s).unwrap();
3619 assert_eq!(back.assignments.len(), 2);
3620 assert_eq!(back.assignments[0].mirror, "noisetable-pdx");
3621 assert_eq!(back.assignments[1].machine, "noisetable-iad-1");
3622 }
3623
3624 #[test]
3625 fn topology_absent_returns_default() {
3626 let tmp = tempfile::TempDir::new().unwrap();
3627 let path = tmp.path().join("topology.toml");
3628 let topo = load_topology(path).unwrap();
3630 assert!(topo.assignments.is_empty());
3631 }
3632
3633 fn make_legacy_cloud_dir(root: &std::path::Path) -> std::path::PathBuf {
3636 let cloud_dir = root.join(".yah").join("cloud");
3637 std::fs::create_dir_all(&cloud_dir).unwrap();
3638 cloud_dir
3639 }
3640
3641 #[test]
3642 fn cloud_config_load_and_lookup() {
3643 let tmp = tempfile::TempDir::new().unwrap();
3644 let root = tmp.path();
3645 let cloud_dir = make_legacy_cloud_dir(root);
3646
3647 let machine = MachineConfig {
3648 name: "noisetable-pdx-1".into(),
3649 provider: "hetzner".into(),
3650 location: Some("pdx".into()),
3651 server_type: Some("cpx22".into()),
3652 hosts_mirrors: vec!["noisetable".into(), "yah".into()],
3653 mesh_tags: vec!["region:pdx".into(), "tier:t2".into()],
3654 region: None,
3655 zone: None,
3656 arch: None,
3657 bucket: Some(BucketSpec {
3658 name: "noisetable-assets-pdx-1".into(),
3659 public_read: false,
3660 }),
3661 vendor: None,
3662 nickname: None,
3663 legacy_hostkey_fingerprint: None,
3664 registration: Default::default(),
3665 ssh_keys: vec![],
3666 cloudflared: None,
3667 hosts_operator_bridge: false,
3668 connect: None,
3669 allocatable: None,
3670 taints: vec![],
3671 };
3672 machine.save(&cloud_dir).unwrap();
3674
3675 let mirror_toml = "camp = \"noisetable\"\nregions = [\"pdx\", \"iad\", \"fsn\"]\nworkloads = [\"asset-registry\"]\n";
3676 std::fs::create_dir_all(cloud_dir.join("mirrors")).unwrap();
3677 std::fs::write(cloud_dir.join("mirrors/noisetable.toml"), mirror_toml).unwrap();
3678
3679 let svc_toml = "name = \"asset-registry\"\nimage = \"ghcr.io/noisetable/asset-registry\"\nversion = \"v1.0.0\"\nmesh_only = false\n";
3681 std::fs::create_dir_all(cloud_dir.join("services")).unwrap();
3682 std::fs::write(cloud_dir.join("services/asset-registry.toml"), svc_toml).unwrap();
3683
3684 let cfg = CloudConfig::load(root).unwrap();
3685
3686 assert_eq!(cfg.machines.len(), 1);
3687 assert_eq!(cfg.legacy_mirrors.len(), 1);
3688 assert_eq!(cfg.legacy_services.len(), 1);
3689 assert_eq!(cfg.workloads.len(), 0); assert!(cfg.services.is_empty(), "no R215+ services/ tree");
3691 assert!(cfg.providers.is_empty(), "no R215+ providers/ tree");
3692
3693 let m = cfg.machine("noisetable-pdx-1").unwrap();
3694 assert_eq!(m.location(), "pdx");
3695 assert_eq!(m.bucket.as_ref().unwrap().name, "noisetable-assets-pdx-1");
3696
3697 let mir = cfg.legacy_mirror("noisetable").unwrap();
3698 assert_eq!(mir.regions, vec!["pdx", "iad", "fsn"]);
3699 assert_eq!(mir.workloads, vec!["asset-registry"]);
3700 }
3701
3702 #[test]
3703 fn mirror_folder_layout_loads() {
3704 let tmp = tempfile::TempDir::new().unwrap();
3706 let root = tmp.path();
3707 let cloud_dir = make_legacy_cloud_dir(root);
3708 let mirror_dir = cloud_dir.join("mirrors").join("yah-com");
3709 std::fs::create_dir_all(&mirror_dir).unwrap();
3710 std::fs::write(
3711 mirror_dir.join("mirror.toml"),
3712 "camp = \"yah\"\nregions = [\"pdx\"]\nworkloads = [\"yah-web\"]\n",
3713 )
3714 .unwrap();
3715
3716 let cfg = CloudConfig::load(root).unwrap();
3717 assert_eq!(cfg.legacy_mirrors.len(), 1);
3718 let mir = cfg.legacy_mirror("yah").unwrap();
3719 assert_eq!(mir.camp, "yah");
3720 assert_eq!(mir.workloads, vec!["yah-web"]);
3721 }
3722
3723 #[test]
3724 fn mirror_folder_and_flat_coexist() {
3725 let tmp = tempfile::TempDir::new().unwrap();
3727 let root = tmp.path();
3728 let cloud_dir = make_legacy_cloud_dir(root);
3729 let mirrors_root = cloud_dir.join("mirrors");
3730 std::fs::create_dir_all(&mirrors_root).unwrap();
3731
3732 std::fs::write(
3734 mirrors_root.join("noisetable.toml"),
3735 "camp = \"noisetable\"\nregions = [\"pdx\"]\nworkloads = []\n",
3736 )
3737 .unwrap();
3738
3739 let yah_com_dir = mirrors_root.join("yah-com");
3741 std::fs::create_dir_all(&yah_com_dir).unwrap();
3742 std::fs::write(
3743 yah_com_dir.join("mirror.toml"),
3744 "camp = \"yah\"\nregions = [\"pdx\"]\nworkloads = []\n",
3745 )
3746 .unwrap();
3747
3748 let cfg = CloudConfig::load(root).unwrap();
3749 assert_eq!(cfg.legacy_mirrors.len(), 2);
3750 assert!(cfg.legacy_mirror("noisetable").is_some());
3751 assert!(cfg.legacy_mirror("yah").is_some());
3752 }
3753
3754 #[test]
3755 fn mirror_malformed_fails_with_field_path() {
3756 let tmp = tempfile::TempDir::new().unwrap();
3759 let root = tmp.path();
3760 let cloud_dir = make_legacy_cloud_dir(root);
3761 let mirror_dir = cloud_dir.join("mirrors").join("bad");
3762 std::fs::create_dir_all(&mirror_dir).unwrap();
3763 std::fs::write(
3765 mirror_dir.join("mirror.toml"),
3766 "regions = [\"pdx\"]\nworkloads = []\n",
3767 )
3768 .unwrap();
3769
3770 let err = CloudConfig::load(root).unwrap_err();
3771 let msg = err.to_string();
3772 assert!(
3773 msg.contains("mirror.toml"),
3774 "error should reference the file path, got: {msg}"
3775 );
3776 }
3777
3778 #[test]
3779 fn workload_config_load_and_validate() {
3780 use workload_spec::{
3781 ExposeSpec, ImageRef, MeshExpose, MeshIdent, NamespaceId, ResourceLimits,
3782 RestartPolicy, SchemaVersion, StopPolicy, TenantId, TierTag, WorkloadSpec,
3783 };
3784
3785 let tmp = tempfile::TempDir::new().unwrap();
3786 let root = tmp.path();
3787 let cloud_dir = make_legacy_cloud_dir(root);
3788 std::fs::create_dir_all(cloud_dir.join("workloads")).unwrap();
3789
3790 let spec = WorkloadSpec {
3791 schema_version: SchemaVersion::V1,
3792 name: "asset-registry".into(),
3793 image: ImageRef {
3794 registry: "ghcr.io".into(),
3795 repository: "noisetable/asset-registry".into(),
3796 tag: "v1.0.0".into(),
3797 digest: workload_spec::testing::test_digest(),
3798 },
3799 tier: TierTag("tenant".into()),
3800 replicas: 1,
3801 command: None,
3802 entrypoint: None,
3803 workdir: None,
3804 user: None,
3805 env: vec![],
3806 secrets: vec![],
3807 volumes: vec![],
3808 resources: ResourceLimits {
3809 memory_mb: 256,
3810 cpu_millis: 512,
3811 ephemeral_storage_mb: 512,
3812 },
3813 depends_on: vec![],
3814 healthcheck: None,
3815 restart_policy: RestartPolicy::Always,
3816 archetype: None,
3817 stop_policy: StopPolicy {
3818 signal: 15,
3819 grace_period: workload_spec::Millis::from_secs(10),
3820 },
3821 expose: ExposeSpec {
3822 mesh: MeshExpose {
3823 identity: MeshIdent("asset-registry.pdx".into()),
3824 ports: vec![8080],
3825 allow_from: vec![],
3826 },
3827 public: None,
3828 operator: None,
3829 },
3830 tenant: TenantId::singleton(),
3831 namespace: NamespaceId::singleton(),
3832 labels: Default::default(),
3833 annotations: Default::default(),
3834 };
3835
3836 let toml_str = toml::to_string_pretty(&spec).unwrap();
3837 std::fs::write(cloud_dir.join("workloads/asset-registry.toml"), &toml_str).unwrap();
3838
3839 let cfg = CloudConfig::load(root).unwrap();
3840 assert_eq!(cfg.workloads.len(), 1);
3841 assert_eq!(cfg.workloads[0].spec.name, "asset-registry");
3842 assert_eq!(cfg.workload("asset-registry").unwrap().spec.replicas, 1);
3843 }
3844
3845 #[cfg(test)]
3849 fn minimal_spec(name: &str, replicas: u32) -> workload_spec::WorkloadSpec {
3850 use workload_spec::{
3851 ExposeSpec, ImageRef, MeshExpose, MeshIdent, NamespaceId, ResourceLimits,
3852 RestartPolicy, SchemaVersion, StopPolicy, TenantId, TierTag, WorkloadSpec,
3853 };
3854 WorkloadSpec {
3855 schema_version: SchemaVersion::V1,
3856 name: name.into(),
3857 image: ImageRef {
3858 registry: "cr.yah.dev".into(),
3859 repository: name.into(),
3860 tag: "v1".into(),
3861 digest: workload_spec::testing::test_digest(),
3862 },
3863 tier: TierTag("infra".into()),
3864 replicas,
3865 command: None,
3866 entrypoint: None,
3867 workdir: None,
3868 user: None,
3869 env: vec![],
3870 secrets: vec![],
3871 volumes: vec![],
3872 resources: ResourceLimits {
3873 memory_mb: 256,
3874 cpu_millis: 250,
3875 ephemeral_storage_mb: 128,
3876 },
3877 depends_on: vec![],
3878 healthcheck: None,
3879 restart_policy: RestartPolicy::Always,
3880 archetype: None,
3881 stop_policy: StopPolicy {
3882 signal: 15,
3883 grace_period: workload_spec::Millis::from_secs(10),
3884 },
3885 expose: ExposeSpec {
3886 mesh: MeshExpose {
3887 identity: MeshIdent(name.into()),
3888 ports: vec![4325],
3889 allow_from: vec![],
3890 },
3891 public: None,
3892 operator: None,
3893 },
3894 tenant: TenantId::singleton(),
3895 namespace: NamespaceId::singleton(),
3896 labels: Default::default(),
3897 annotations: Default::default(),
3898 }
3899 }
3900
3901 #[test]
3914 fn workloads_load_from_the_infra_tree() {
3915 let tmp = tempfile::TempDir::new().unwrap();
3916 let root = tmp.path();
3917 let dir = crate::paths::workloads_dir(root);
3918 std::fs::create_dir_all(&dir).unwrap();
3919 std::fs::write(
3920 dir.join("yah-cloud-admin.toml"),
3921 toml::to_string_pretty(&minimal_spec("yah-cloud-admin", 1)).unwrap(),
3922 )
3923 .unwrap();
3924
3925 let cfg = CloudConfig::load(root).unwrap();
3926 assert_eq!(cfg.workloads.len(), 1);
3927 assert_eq!(
3928 cfg.workload("yah-cloud-admin").unwrap().spec.replicas,
3929 1,
3930 "a workload declared under .yah/infra/workloads/ must be resolvable by name"
3931 );
3932 }
3933
3934 #[test]
3939 fn infra_workload_shadows_the_legacy_copy_of_the_same_name() {
3940 let tmp = tempfile::TempDir::new().unwrap();
3941 let root = tmp.path();
3942
3943 let legacy = make_legacy_cloud_dir(root);
3944 std::fs::create_dir_all(legacy.join("workloads")).unwrap();
3945 std::fs::write(
3946 legacy.join("workloads/shared.toml"),
3947 toml::to_string_pretty(&minimal_spec("shared", 9)).unwrap(),
3948 )
3949 .unwrap();
3950 std::fs::write(
3953 legacy.join("workloads/legacy-only.toml"),
3954 toml::to_string_pretty(&minimal_spec("legacy-only", 3)).unwrap(),
3955 )
3956 .unwrap();
3957
3958 let infra = crate::paths::workloads_dir(root);
3959 std::fs::create_dir_all(&infra).unwrap();
3960 std::fs::write(
3961 infra.join("shared.toml"),
3962 toml::to_string_pretty(&minimal_spec("shared", 1)).unwrap(),
3963 )
3964 .unwrap();
3965
3966 let cfg = CloudConfig::load(root).unwrap();
3967 assert_eq!(cfg.workloads.len(), 2, "one `shared`, plus `legacy-only`");
3968 assert_eq!(
3969 cfg.workload("shared").unwrap().spec.replicas,
3970 1,
3971 "the .yah/infra/ copy must win over the legacy one"
3972 );
3973 assert_eq!(cfg.workload("legacy-only").unwrap().spec.replicas, 3);
3974 }
3975
3976 #[test]
3977 fn workload_loader_rejects_bad_spec() {
3978 use workload_spec::{
3979 ExposeSpec, ImageRef, MeshExpose, MeshIdent, NamespaceId, ResourceLimits,
3980 RestartPolicy, SchemaVersion, StopPolicy, TenantId, TierTag, WorkloadSpec,
3981 };
3982
3983 let tmp = tempfile::TempDir::new().unwrap();
3984 let root = tmp.path();
3985 let cloud_dir = make_legacy_cloud_dir(root);
3986 std::fs::create_dir_all(cloud_dir.join("workloads")).unwrap();
3987
3988 let mut spec = WorkloadSpec {
3991 schema_version: SchemaVersion::V1,
3992 name: "asset-registry".into(),
3993 image: ImageRef {
3994 registry: "ghcr.io".into(),
3995 repository: "test/app".into(),
3996 tag: "v1".into(),
3997 digest: workload_spec::testing::test_digest(),
3998 },
3999 tier: TierTag("tenant".into()),
4000 replicas: 200, command: None,
4002 entrypoint: None,
4003 workdir: None,
4004 user: None,
4005 env: vec![],
4006 secrets: vec![],
4007 volumes: vec![],
4008 resources: ResourceLimits {
4009 memory_mb: 256,
4010 cpu_millis: 512,
4011 ephemeral_storage_mb: 512,
4012 },
4013 depends_on: vec![],
4014 healthcheck: None,
4015 restart_policy: RestartPolicy::Always,
4016 archetype: None,
4017 stop_policy: StopPolicy {
4018 signal: 15,
4019 grace_period: workload_spec::Millis::from_secs(10),
4020 },
4021 expose: ExposeSpec {
4022 mesh: MeshExpose {
4023 identity: MeshIdent("asset-registry.pdx".into()),
4024 ports: vec![8080],
4025 allow_from: vec![],
4026 },
4027 public: None,
4028 operator: None,
4029 },
4030 tenant: TenantId::singleton(),
4031 namespace: NamespaceId::singleton(),
4032 labels: Default::default(),
4033 annotations: Default::default(),
4034 };
4035
4036 let toml_str = toml::to_string_pretty(&spec).unwrap();
4037 std::fs::write(cloud_dir.join("workloads/bad.toml"), &toml_str).unwrap();
4038
4039 let result = CloudConfig::load(root);
4040 assert!(
4041 result.is_err(),
4042 "loading a WorkloadSpec with replicas=200 should return Err"
4043 );
4044 let msg = result.unwrap_err().to_string();
4045 assert!(
4046 msg.contains("shape validation")
4047 || msg.contains("Replicas")
4048 || msg.contains("replicas"),
4049 "error should mention shape validation or replicas field, got: {msg}"
4050 );
4051
4052 let _ = &mut spec;
4054 }
4055
4056 #[test]
4057 fn workload_config_save_round_trip() {
4058 use workload_spec::{
4059 ExposeSpec, ImageRef, MeshExpose, MeshIdent, NamespaceId, ResourceLimits,
4060 RestartPolicy, SchemaVersion, StopPolicy, TenantId, TierTag, WorkloadSpec,
4061 };
4062
4063 let tmp = tempfile::TempDir::new().unwrap();
4064 let root = tmp.path();
4065
4066 let spec = WorkloadSpec {
4067 schema_version: SchemaVersion::V1,
4068 name: "signing-service".into(),
4069 image: ImageRef {
4070 registry: "ghcr.io".into(),
4071 repository: "noisetable/signing".into(),
4072 tag: "v2.0.0".into(),
4073 digest: workload_spec::testing::test_digest(),
4074 },
4075 tier: TierTag("private".into()),
4076 replicas: 2,
4077 command: None,
4078 entrypoint: None,
4079 workdir: None,
4080 user: None,
4081 env: vec![],
4082 secrets: vec![],
4083 volumes: vec![],
4084 resources: ResourceLimits {
4085 memory_mb: 128,
4086 cpu_millis: 256,
4087 ephemeral_storage_mb: 256,
4088 },
4089 depends_on: vec![],
4090 healthcheck: None,
4091 restart_policy: RestartPolicy::Always,
4092 archetype: None,
4093 stop_policy: StopPolicy {
4094 signal: 15,
4095 grace_period: workload_spec::Millis::from_secs(5),
4096 },
4097 expose: ExposeSpec {
4098 mesh: MeshExpose {
4099 identity: MeshIdent("signing.pdx".into()),
4100 ports: vec![9090],
4101 allow_from: vec![],
4102 },
4103 public: None,
4104 operator: None,
4105 },
4106 tenant: TenantId::singleton(),
4107 namespace: NamespaceId::singleton(),
4108 labels: Default::default(),
4109 annotations: Default::default(),
4110 };
4111
4112 let wc = WorkloadConfig { spec };
4113 let cloud_dir = make_legacy_cloud_dir(root);
4114 wc.save(&cloud_dir).unwrap();
4115
4116 let loaded = CloudConfig::load(root).unwrap();
4117 assert_eq!(loaded.workloads.len(), 1);
4118 assert_eq!(loaded.workloads[0].spec.name, "signing-service");
4119 assert_eq!(loaded.workloads[0].spec.replicas, 2);
4120 }
4121
4122 #[test]
4123 fn machine_save_write_back_fingerprint() {
4124 let tmp = tempfile::TempDir::new().unwrap();
4125 let root = tmp.path();
4126
4127 let mut machine = MachineConfig {
4128 name: "test-pdx-1".into(),
4129 provider: "hetzner".into(),
4130 location: Some("pdx".into()),
4131 server_type: Some("cpx22".into()),
4132 hosts_mirrors: vec![],
4133 mesh_tags: vec![],
4134 region: None,
4135 zone: None,
4136 arch: None,
4137 bucket: None,
4138 vendor: None,
4139 nickname: None,
4140 legacy_hostkey_fingerprint: None,
4141 registration: Default::default(),
4142 ssh_keys: vec![],
4143 cloudflared: None,
4144 hosts_operator_bridge: false,
4145 connect: None,
4146 allocatable: None,
4147 taints: vec![],
4148 };
4149 machine.save(root).unwrap();
4150
4151 machine.registration.hostkey_fingerprint = Some("SHA256:abc123".into());
4154 machine.save(root).unwrap();
4155
4156 let reloaded: Vec<MachineConfig> = load_dir(root.join("machines")).unwrap();
4157 assert_eq!(reloaded.len(), 1);
4158 assert_eq!(reloaded[0].hostkey_fingerprint(), Some("SHA256:abc123"));
4159 }
4160
4161 #[test]
4170 fn provider_cloudflare_round_trips() {
4171 let src = r#"
4172schema_version = 1
4173id = "cloudflare"
4174kind = "cloudflare"
4175credentials = "keystore://cloudflare/yah"
4176default_zone = "yah.dev"
4177"#;
4178 let cfg: ProviderConfig = toml::from_str(src).unwrap();
4179 assert_eq!(cfg.id, "cloudflare");
4180 assert_eq!(cfg.kind, Provider::Cloudflare);
4181 assert_eq!(
4182 cfg.credentials.as_deref(),
4183 Some("keystore://cloudflare/yah")
4184 );
4185 assert_eq!(
4186 cfg.fields.get("default_zone").and_then(|v| v.as_str()),
4187 Some("yah.dev"),
4188 );
4189 let back = toml::to_string(&cfg).unwrap();
4190 let again: ProviderConfig = toml::from_str(&back).unwrap();
4191 assert_eq!(again.id, cfg.id);
4192 assert_eq!(again.kind, cfg.kind);
4193 }
4194
4195 #[test]
4196 fn provider_hetzner_round_trips() {
4197 let src = r#"
4198schema_version = 1
4199id = "hetzner"
4200kind = "hetzner"
4201credentials = "keystore://hetzner/yah"
4202default_location = "pdx"
4203default_server_type = "cpx11"
4204ssh_keys = []
4205"#;
4206 let cfg: ProviderConfig = toml::from_str(src).unwrap();
4207 assert_eq!(cfg.kind, Provider::Hetzner);
4208 assert_eq!(
4209 cfg.fields.get("default_location").and_then(|v| v.as_str()),
4210 Some("pdx"),
4211 );
4212 assert!(
4213 cfg.fields
4214 .get("ssh_keys")
4215 .map(|v| v.as_array().unwrap().is_empty())
4216 .unwrap_or(false),
4217 "ssh_keys must round-trip as empty array, got {:?}",
4218 cfg.fields.get("ssh_keys"),
4219 );
4220 }
4221
4222 #[test]
4223 fn provider_orbstack_local_container_round_trips() {
4224 let src = r#"
4225schema_version = 1
4226id = "orbstack"
4227kind = "local-container"
4228runtime = "auto"
4229
4230[discovery]
4231orbstack = "~/.orbstack/run/docker.sock"
4232colima = "~/.colima/default/docker.sock"
4233docker = "/var/run/docker.sock"
4234"#;
4235 let cfg: ProviderConfig = toml::from_str(src).unwrap();
4236 assert_eq!(cfg.kind, Provider::LocalContainer);
4237 assert_eq!(
4238 cfg.fields.get("runtime").and_then(|v| v.as_str()),
4239 Some("auto"),
4240 );
4241 let discovery = cfg
4242 .fields
4243 .get("discovery")
4244 .and_then(|v| v.as_table())
4245 .expect("discovery table");
4246 assert!(discovery.contains_key("orbstack"));
4247 assert!(discovery.contains_key("colima"));
4248 assert!(discovery.contains_key("docker"));
4249 }
4250
4251 #[test]
4252 fn provider_unknown_kind_fails() {
4253 let src = r#"
4254schema_version = 1
4255id = "made-up"
4256kind = "fly-io"
4257"#;
4258 let err = toml::from_str::<ProviderConfig>(src).unwrap_err();
4259 let msg = err.to_string();
4260 assert!(
4261 msg.contains("kind") || msg.contains("variant"),
4262 "unknown provider kind should surface as a serde error, got: {msg}"
4263 );
4264 }
4265
4266 #[test]
4267 fn service_dev_yah_round_trips() {
4268 let src = r#"
4269schema_version = 1
4270name = "dev-yah"
4271domain = "yah.dev"
4272
4273[[components]]
4274id = "site"
4275kind = "mesofact-static"
4276path = "app/yah/web"
4277role = "static"
4278"#;
4279 let cfg: ServiceConfig = toml::from_str(src).unwrap();
4280 assert_eq!(cfg.name, "dev-yah");
4281 assert_eq!(cfg.domain, "yah.dev");
4282 assert_eq!(cfg.components.len(), 1);
4283 let c = &cfg.components[0];
4284 assert_eq!(c.id, "site");
4285 assert_eq!(c.kind, "mesofact-static");
4286 assert_eq!(c.path, "app/yah/web");
4287 assert_eq!(c.role, "static");
4288 assert!(c.publishes.is_none());
4289
4290 let back = toml::to_string(&cfg).unwrap();
4291 let again: ServiceConfig = toml::from_str(&back).unwrap();
4292 assert_eq!(again.name, cfg.name);
4293 assert_eq!(again.components[0].kind, c.kind);
4294 }
4295
4296 #[test]
4297 fn mirror_prod_cloudflare_reference_parses() {
4298 let src = r#"
4299schema_version = 1
4300shape = "single-machine"
4301
4302[providers.static]
4303use = "cloudflare"
4304bucket = "yah-dev"
4305zone = "yah.dev"
4306dns = { record = "@", type = "CNAME" }
4307"#;
4308 let cfg: MirrorConfig = toml::from_str(src).unwrap();
4309 assert_eq!(cfg.shape, MirrorShape::SingleMachine);
4310 let slot = cfg.providers.get("static").expect("static slot");
4311 assert_eq!(slot.provider_id(), Some("cloudflare"));
4312 assert!(slot.inline_kind().is_none());
4313 if let MirrorProviderSlot::Reference { fields, .. } = slot {
4314 assert_eq!(
4315 fields.get("bucket").and_then(|v| v.as_str()),
4316 Some("yah-dev")
4317 );
4318 assert_eq!(fields.get("zone").and_then(|v| v.as_str()), Some("yah.dev"));
4319 let dns = fields
4320 .get("dns")
4321 .and_then(|v| v.as_table())
4322 .expect("dns table");
4323 assert_eq!(dns.get("record").and_then(|v| v.as_str()), Some("@"));
4324 assert_eq!(dns.get("type").and_then(|v| v.as_str()), Some("CNAME"));
4325 } else {
4326 panic!("expected Reference slot");
4327 }
4328 }
4329
4330 #[test]
4331 fn mirror_local_inline_static_and_orbstack_compute_parse() {
4332 let src = r#"
4333schema_version = 1
4334shape = "local"
4335
4336[providers.static]
4337kind = "local-static"
4338port = 4321
4339artifact_dir = ".yah/infra/state/local/static"
4340
4341[providers.compute]
4342use = "orbstack"
4343"#;
4344 let cfg: MirrorConfig = toml::from_str(src).unwrap();
4345 assert_eq!(cfg.shape, MirrorShape::Local);
4346
4347 let static_slot = cfg.providers.get("static").expect("static slot");
4348 assert_eq!(static_slot.inline_kind(), Some(Provider::LocalStatic));
4349 assert!(static_slot.provider_id().is_none());
4350 if let MirrorProviderSlot::Inline { fields, .. } = static_slot {
4351 assert_eq!(fields.get("port").and_then(|v| v.as_integer()), Some(4321));
4352 assert_eq!(
4353 fields.get("artifact_dir").and_then(|v| v.as_str()),
4354 Some(".yah/infra/state/local/static"),
4355 );
4356 } else {
4357 panic!("expected Inline slot for static");
4358 }
4359
4360 let compute_slot = cfg.providers.get("compute").expect("compute slot");
4361 assert_eq!(compute_slot.provider_id(), Some("orbstack"));
4362 }
4363
4364 #[test]
4365 fn mirror_pond_miniflare_minio_parse() {
4366 let src = r#"
4370schema_version = 1
4371shape = "local"
4372
4373[providers.static]
4374kind = "miniflare-container"
4375port = 4322
4376bucket = "yah-dev"
4377
4378[providers.object_store]
4379kind = "minio-container"
4380api_port = 9000
4381console_port = 9001
4382bucket = "yah-dev"
4383"#;
4384 let cfg: MirrorConfig = toml::from_str(src).unwrap();
4385 assert_eq!(cfg.shape, MirrorShape::Local);
4386
4387 let static_slot = cfg.providers.get("static").expect("static slot");
4388 assert_eq!(
4389 static_slot.inline_kind(),
4390 Some(Provider::MiniflareContainer)
4391 );
4392 if let MirrorProviderSlot::Inline { fields, .. } = static_slot {
4393 assert_eq!(fields.get("port").and_then(|v| v.as_integer()), Some(4322));
4394 assert_eq!(
4395 fields.get("bucket").and_then(|v| v.as_str()),
4396 Some("yah-dev")
4397 );
4398 } else {
4399 panic!("expected Inline slot for miniflare-container static");
4400 }
4401
4402 let object_store_slot = cfg
4403 .providers
4404 .get("object_store")
4405 .expect("object_store slot");
4406 assert_eq!(
4407 object_store_slot.inline_kind(),
4408 Some(Provider::MinioContainer)
4409 );
4410 if let MirrorProviderSlot::Inline { fields, .. } = object_store_slot {
4411 assert_eq!(
4412 fields.get("api_port").and_then(|v| v.as_integer()),
4413 Some(9000)
4414 );
4415 assert_eq!(
4416 fields.get("console_port").and_then(|v| v.as_integer()),
4417 Some(9001)
4418 );
4419 assert_eq!(
4420 fields.get("bucket").and_then(|v| v.as_str()),
4421 Some("yah-dev")
4422 );
4423 } else {
4424 panic!("expected Inline slot for minio-container object_store");
4425 }
4426 }
4427
4428 #[test]
4429 fn provider_miniflare_container_kind_round_trips() {
4430 let cfg = MirrorProviderSlot::Inline {
4434 kind: Provider::MiniflareContainer,
4435 fields: BTreeMap::new(),
4436 };
4437 let s = toml::to_string(&cfg).unwrap();
4438 assert!(
4439 s.contains("kind = \"miniflare-container\""),
4440 "kebab-case wire form expected, got: {s}"
4441 );
4442 let back: MirrorProviderSlot = toml::from_str(&s).unwrap();
4443 assert_eq!(back.inline_kind(), Some(Provider::MiniflareContainer));
4444 }
4445
4446 #[test]
4447 fn provider_minio_container_kind_round_trips() {
4448 let cfg = MirrorProviderSlot::Inline {
4449 kind: Provider::MinioContainer,
4450 fields: BTreeMap::new(),
4451 };
4452 let s = toml::to_string(&cfg).unwrap();
4453 assert!(
4454 s.contains("kind = \"minio-container\""),
4455 "kebab-case wire form expected, got: {s}"
4456 );
4457 let back: MirrorProviderSlot = toml::from_str(&s).unwrap();
4458 assert_eq!(back.inline_kind(), Some(Provider::MinioContainer));
4459 }
4460
4461 #[test]
4462 fn mirror_compute_slot_with_machine_reference_parses() {
4463 let src = r#"
4466schema_version = 1
4467shape = "single-machine"
4468
4469[providers.compute]
4470use = "hetzner"
4471machine = "yah-cloud-1"
4472"#;
4473 let cfg: MirrorConfig = toml::from_str(src).unwrap();
4474 let slot = cfg.providers.get("compute").expect("compute slot");
4475 assert_eq!(slot.provider_id(), Some("hetzner"));
4476 if let MirrorProviderSlot::Reference { fields, .. } = slot {
4477 assert_eq!(
4478 fields.get("machine").and_then(|v| v.as_str()),
4479 Some("yah-cloud-1"),
4480 );
4481 }
4482 }
4483
4484 #[test]
4485 fn machine_yah_cloud_1_round_trips_with_existing_shape() {
4486 let src = r#"
4490name = "yah-cloud-1"
4491provider = "hetzner"
4492location = "pdx"
4493server_type = "cpx11"
4494hosts_mirrors = []
4495mesh_tags = ["tag:tier-scratch", "tag:primary-yah"]
4496ssh_keys = [111513970, 111525493]
4497"#;
4498 let cfg: MachineConfig = toml::from_str(src).unwrap();
4499 assert_eq!(cfg.name, "yah-cloud-1");
4500 assert_eq!(cfg.provider, "hetzner");
4501 assert_eq!(cfg.ssh_keys.len(), 2);
4502 }
4503
4504 #[test]
4505 fn static_node_omits_location_server_type_and_carries_connect() {
4506 let src = r#"
4509name = "us-south-001"
4510provider = "static"
4511region = "us-south"
4512mesh_tags = ["tag:cloud-runner", "tag:voter-candidate"]
4513
4514[connect]
4515address = "45.32.194.254"
4516ssh = "root@45.32.194.254"
4517yubaba = "http://127.0.0.1:7443"
4518arch = "x86_64"
4519"#;
4520 let cfg: MachineConfig = toml::from_str(src).unwrap();
4521 assert_eq!(cfg.provider, "static");
4522 assert!(cfg.location.is_none());
4523 assert!(cfg.server_type.is_none());
4524 assert_eq!(cfg.location(), ""); let c = cfg.connect.as_ref().expect("connect block");
4526 assert_eq!(c.ssh, "root@45.32.194.254");
4527 assert_eq!(c.yubaba.as_deref(), Some("http://127.0.0.1:7443"));
4530 assert_eq!(cfg.yubaba_url().as_deref(), Some("http://127.0.0.1:7443"));
4531 assert_eq!(cfg.mesh_ipv4(), None);
4532 assert!(!provider_has_machine_driver(&cfg.provider));
4534 cfg.validate().unwrap();
4535 }
4536
4537 #[test]
4544 fn legacy_shape_still_parses_and_reads_through_accessors() {
4545 let src = r#"
4546name = "us-west-001"
4547provider = "static"
4548region = "us-west"
4549arch = "x86_64"
4550mesh_tags = ["tag:cloud-runner"]
4551hostkey_fingerprint = "SHA256:dmpq"
4552
4553[connect]
4554address = "15.204.89.240"
4555ssh = "debian@15.204.89.240"
4556yubaba = "http://100.64.0.1:7443"
4557"#;
4558 let cfg: MachineConfig = toml::from_str(src).unwrap();
4559 assert_eq!(cfg.hostkey_fingerprint(), Some("SHA256:dmpq"));
4560 assert_eq!(cfg.mesh_ipv4(), Some("100.64.0.1"));
4561 assert_eq!(cfg.yubaba_url().as_deref(), Some("http://100.64.0.1:7443"));
4562 }
4563
4564 #[test]
4568 fn split_shape_is_equivalent_to_legacy_shape() {
4569 let legacy = r#"
4570name = "m"
4571provider = "static"
4572mesh_tags = []
4573hostkey_fingerprint = "SHA256:dmpq"
4574
4575[connect]
4576address = "15.204.89.240"
4577ssh = "debian@15.204.89.240"
4578yubaba = "http://100.64.0.1:7443"
4579"#;
4580 let split = r#"
4581name = "m"
4582provider = "static"
4583mesh_tags = []
4584
4585[connect]
4586address = "15.204.89.240"
4587ssh = "debian@15.204.89.240"
4588
4589[registration]
4590hostkey_fingerprint = "SHA256:dmpq"
4591mesh_ipv4 = "100.64.0.1"
4592"#;
4593 let old: MachineConfig = toml::from_str(legacy).unwrap();
4594 let new: MachineConfig = toml::from_str(split).unwrap();
4595 assert_eq!(old.hostkey_fingerprint(), new.hostkey_fingerprint());
4596 assert_eq!(old.mesh_ipv4(), new.mesh_ipv4());
4597 assert_eq!(old.yubaba_url(), new.yubaba_url());
4598 }
4599
4600 #[test]
4603 fn declared_port_composes_with_observed_mesh_address() {
4604 let src = r#"
4605name = "m"
4606provider = "static"
4607mesh_tags = []
4608
4609[connect]
4610address = "10.0.0.1"
4611ssh = "yah@10.0.0.1"
4612yubaba_port = 9443
4613
4614[registration]
4615mesh_ipv4 = "100.64.0.9"
4616"#;
4617 let cfg: MachineConfig = toml::from_str(src).unwrap();
4618 assert_eq!(cfg.connect.as_ref().unwrap().yubaba_port(), 9443);
4619 assert_eq!(cfg.yubaba_url().as_deref(), Some("http://100.64.0.9:9443"));
4620 }
4621
4622 #[test]
4630 fn a_declared_literal_wins_over_a_registered_mesh_address() {
4631 let src = r#"
4632name = "us-west-014"
4633provider = "static"
4634mesh_tags = []
4635
4636[connect]
4637address = "192.168.10.14"
4638ssh = "yah@192.168.10.14"
4639yubaba = "http://192.168.10.14:7443"
4640
4641[registration]
4642mesh_ipv4 = "100.64.0.6"
4643"#;
4644 let cfg: MachineConfig = toml::from_str(src).unwrap();
4645 assert_eq!(cfg.mesh_ipv4(), Some("100.64.0.6"), "still mesh-joined");
4646 assert_eq!(
4647 cfg.yubaba_url().as_deref(),
4648 Some("http://192.168.10.14:7443"),
4649 "the declared LAN literal must win over the registered mesh address"
4650 );
4651 }
4652
4653 #[test]
4657 fn normalize_migrates_legacy_fields_and_is_idempotent() {
4658 let src = r#"
4659name = "m"
4660provider = "static"
4661mesh_tags = []
4662hostkey_fingerprint = "SHA256:dmpq"
4663
4664[connect]
4665address = "15.204.89.240"
4666ssh = "debian@15.204.89.240"
4667yubaba = "http://100.64.0.1:7443"
4668"#;
4669 let mut cfg: MachineConfig = toml::from_str(src).unwrap();
4670 cfg.normalize();
4671 assert!(cfg.legacy_hostkey_fingerprint.is_none());
4672 assert_eq!(
4673 cfg.registration.hostkey_fingerprint.as_deref(),
4674 Some("SHA256:dmpq")
4675 );
4676 assert_eq!(cfg.registration.mesh_ipv4.as_deref(), Some("100.64.0.1"));
4677 assert!(cfg.connect.as_ref().unwrap().yubaba.is_none());
4678 assert_eq!(cfg.yubaba_url().as_deref(), Some("http://100.64.0.1:7443"));
4680 let once = format!("{cfg:?}");
4681 cfg.normalize();
4682 assert_eq!(once, format!("{cfg:?}"));
4683 }
4684
4685 #[test]
4689 fn normalize_leaves_pre_mesh_loopback_declaration_intact() {
4690 let src = r#"
4691name = "m"
4692provider = "static"
4693mesh_tags = []
4694
4695[connect]
4696address = "192.168.10.11"
4697ssh = "yah@192.168.10.11"
4698yubaba = "http://127.0.0.1:7443"
4699"#;
4700 let mut cfg: MachineConfig = toml::from_str(src).unwrap();
4701 cfg.normalize();
4702 assert_eq!(
4703 cfg.connect.as_ref().unwrap().yubaba.as_deref(),
4704 Some("http://127.0.0.1:7443")
4705 );
4706 assert!(cfg.registration.is_empty());
4707 assert_eq!(cfg.mesh_ipv4(), None);
4708 }
4709
4710 #[test]
4714 fn save_writes_the_split_shape_from_a_legacy_config() {
4715 let tmp = tempfile::TempDir::new().unwrap();
4716 let root = tmp.path();
4717 let src = r#"
4718name = "m"
4719provider = "static"
4720mesh_tags = []
4721hostkey_fingerprint = "SHA256:dmpq"
4722
4723[connect]
4724address = "15.204.89.240"
4725ssh = "debian@15.204.89.240"
4726yubaba = "http://100.64.0.1:7443"
4727"#;
4728 let cfg: MachineConfig = toml::from_str(src).unwrap();
4729 cfg.save(root).unwrap();
4730
4731 let written = std::fs::read_to_string(root.join("machines/m.toml")).unwrap();
4732 let reg_at = written
4733 .find("[registration]")
4734 .unwrap_or_else(|| panic!("no [registration] table: {written}"));
4735 let fp_at = written
4736 .find("hostkey_fingerprint")
4737 .unwrap_or_else(|| panic!("fingerprint dropped: {written}"));
4738 assert!(
4739 fp_at > reg_at,
4740 "legacy top-level field must not be re-emitted: {written}"
4741 );
4742 assert!(
4743 !written.contains("yubaba ="),
4744 "derived URL must not be re-emitted alongside mesh_ipv4: {written}"
4745 );
4746
4747 let reloaded: MachineConfig = toml::from_str(&written).unwrap();
4748 assert_eq!(reloaded.hostkey_fingerprint(), Some("SHA256:dmpq"));
4749 assert_eq!(
4750 reloaded.yubaba_url().as_deref(),
4751 Some("http://100.64.0.1:7443")
4752 );
4753 }
4754
4755 #[test]
4758 fn empty_registration_is_omitted_on_serialize() {
4759 let src = r#"
4760name = "m"
4761provider = "static"
4762mesh_tags = []
4763"#;
4764 let cfg: MachineConfig = toml::from_str(src).unwrap();
4765 assert!(cfg.registration.is_empty());
4766 let out = toml::to_string_pretty(&cfg).unwrap();
4767 assert!(!out.contains("[registration]"), "{out}");
4768 }
4769
4770 #[test]
4771 fn driver_provider_without_location_fails_validate() {
4772 let src = r#"
4777name = "us-west-001"
4778provider = "hetzner"
4779mesh_tags = []
4780"#;
4781 let cfg: MachineConfig = toml::from_str(src).unwrap();
4782 assert!(provider_has_machine_driver(&cfg.provider));
4783 let err = cfg.validate().unwrap_err().to_string();
4784 assert!(
4785 err.contains("location"),
4786 "expected location complaint: {err}"
4787 );
4788 }
4789
4790 fn make_new_tree_with_dev_yah(root: &std::path::Path) {
4794 let infra = root.join(".yah").join("infra");
4795 let providers = infra.join("providers");
4796 std::fs::create_dir_all(&providers).unwrap();
4797 std::fs::write(
4798 providers.join("cloudflare.toml"),
4799 r#"schema_version = 1
4800id = "cloudflare"
4801kind = "cloudflare"
4802credentials = "keystore://cloudflare/yah"
4803default_zone = "yah.dev"
4804"#,
4805 )
4806 .unwrap();
4807 std::fs::write(
4808 providers.join("hetzner.toml"),
4809 r#"schema_version = 1
4810id = "hetzner"
4811kind = "hetzner"
4812credentials = "keystore://hetzner/yah"
4813default_location = "pdx"
4814default_server_type = "cpx11"
4815ssh_keys = []
4816"#,
4817 )
4818 .unwrap();
4819 std::fs::write(
4820 providers.join("orbstack.toml"),
4821 r#"schema_version = 1
4822id = "orbstack"
4823kind = "local-container"
4824runtime = "auto"
4825
4826[discovery]
4827orbstack = "~/.orbstack/run/docker.sock"
4828"#,
4829 )
4830 .unwrap();
4831
4832 let svc = root.join(".yah").join("services").join("dev-yah");
4833 std::fs::create_dir_all(svc.join("mirrors")).unwrap();
4834 std::fs::write(
4835 svc.join("service.toml"),
4836 r#"schema_version = 1
4837name = "dev-yah"
4838domain = "yah.dev"
4839
4840[[components]]
4841id = "site"
4842kind = "mesofact-static"
4843path = "app/yah/web"
4844role = "static"
4845"#,
4846 )
4847 .unwrap();
4848 std::fs::write(
4849 svc.join("mirrors/prod.toml"),
4850 r#"schema_version = 1
4851shape = "single-machine"
4852
4853[providers.static]
4854use = "cloudflare"
4855bucket = "yah-dev"
4856zone = "yah.dev"
4857"#,
4858 )
4859 .unwrap();
4860 std::fs::write(
4861 svc.join("mirrors/local.toml"),
4862 r#"schema_version = 1
4863shape = "local"
4864
4865[providers.static]
4866kind = "local-static"
4867port = 4321
4868
4869[providers.compute]
4870use = "orbstack"
4871"#,
4872 )
4873 .unwrap();
4874 }
4875
4876 #[test]
4877 fn cloud_config_load_new_tree_populates_providers_and_services() {
4878 let tmp = tempfile::TempDir::new().unwrap();
4879 let root = tmp.path();
4880 make_new_tree_with_dev_yah(root);
4881
4882 let cfg = CloudConfig::load(root).unwrap();
4883 assert_eq!(cfg.providers.len(), 3, "three providers loaded");
4884 assert!(cfg.provider("cloudflare").is_some());
4885 assert!(cfg.provider("hetzner").is_some());
4886 assert!(cfg.provider("orbstack").is_some());
4887
4888 let dev = cfg.service("dev-yah").expect("dev-yah service");
4889 assert_eq!(dev.service.domain, "yah.dev");
4890 assert_eq!(dev.service.components.len(), 1);
4891 assert_eq!(dev.mirrors.len(), 2);
4892 assert!(dev.mirrors.contains_key("cloud"), "prod.toml → cloud tier");
4894 assert!(dev.mirrors.contains_key("dev"), "local.toml → dev tier");
4895 assert_eq!(dev.mirrors["cloud"].shape, MirrorShape::SingleMachine);
4896 assert_eq!(dev.mirrors["dev"].shape, MirrorShape::Local);
4897
4898 assert!(cfg.legacy_mirrors.is_empty());
4900 assert!(cfg.legacy_services.is_empty());
4901 assert!(cfg.workloads.is_empty());
4902 }
4903
4904 #[test]
4905 fn cloud_config_cross_ref_fails_on_missing_provider() {
4906 let tmp = tempfile::TempDir::new().unwrap();
4908 let root = tmp.path();
4909 let svc = root.join(".yah").join("services").join("dev-yah");
4910 std::fs::create_dir_all(svc.join("mirrors")).unwrap();
4911 std::fs::write(
4912 svc.join("service.toml"),
4913 "schema_version = 1\nname = \"dev-yah\"\ndomain = \"yah.dev\"\n",
4914 )
4915 .unwrap();
4916 std::fs::write(
4917 svc.join("mirrors/prod.toml"),
4918 "schema_version = 1\nshape = \"single-machine\"\n\n[providers.static]\nuse = \"fly-io\"\n",
4919 ).unwrap();
4920
4921 let err = CloudConfig::load(root).unwrap_err();
4922 let msg = err.to_string();
4923 assert!(
4924 msg.contains("fly-io"),
4925 "error should name the missing provider id, got: {msg}"
4926 );
4927 assert!(
4928 msg.contains("providers/fly-io.toml") || msg.contains("no such provider"),
4929 "error should hint at remedy, got: {msg}"
4930 );
4931 }
4932
4933 #[test]
4934 fn cloud_config_cross_ref_passes_on_inline_only_mirror() {
4935 let tmp = tempfile::TempDir::new().unwrap();
4937 let root = tmp.path();
4938 let svc = root.join(".yah").join("services").join("local-only");
4939 std::fs::create_dir_all(svc.join("mirrors")).unwrap();
4940 std::fs::write(
4941 svc.join("service.toml"),
4942 "schema_version = 1\nname = \"local-only\"\ndomain = \"local.test\"\n",
4943 )
4944 .unwrap();
4945 std::fs::write(
4946 svc.join("mirrors/local.toml"),
4947 "schema_version = 1\nshape = \"local\"\n\n[providers.static]\nkind = \"local-static\"\nport = 8080\n",
4948 ).unwrap();
4949
4950 let cfg = CloudConfig::load(root).unwrap();
4952 assert!(cfg.service("local-only").is_some());
4953 }
4954
4955 #[test]
4956 fn cloud_config_load_coexists_legacy_and_new_trees() {
4957 let tmp = tempfile::TempDir::new().unwrap();
4959 let root = tmp.path();
4960 make_new_tree_with_dev_yah(root);
4961
4962 let cloud_dir = make_legacy_cloud_dir(root);
4963 std::fs::create_dir_all(cloud_dir.join("mirrors")).unwrap();
4964 std::fs::write(
4965 cloud_dir.join("mirrors/noisetable.toml"),
4966 "camp = \"noisetable\"\nregions = [\"pdx\"]\nworkloads = []\n",
4967 )
4968 .unwrap();
4969
4970 let cfg = CloudConfig::load(root).unwrap();
4971 assert_eq!(cfg.providers.len(), 3);
4972 assert!(cfg.service("dev-yah").is_some());
4973 assert_eq!(cfg.legacy_mirrors.len(), 1);
4974 assert!(cfg.legacy_mirror("noisetable").is_some());
4975 }
4976
4977 #[test]
4978 fn web_workload_round_trips() {
4979 let src = r#"
4988schema_version = 1
4989kind = "mesofact-static"
4990
4991[build]
4992command = "bun run build"
4993out_dir = "dist"
4994
4995routes = "./routes.ts"
4996"#;
4997 let v: toml::Value = toml::from_str(src).unwrap();
4998 assert_eq!(
4999 v.get("schema_version").and_then(|x| x.as_integer()),
5000 Some(1)
5001 );
5002 assert_eq!(
5003 v.get("kind").and_then(|x| x.as_str()),
5004 Some("mesofact-static")
5005 );
5006 let build = v
5007 .get("build")
5008 .and_then(|x| x.as_table())
5009 .expect("build table");
5010 assert_eq!(
5011 build.get("command").and_then(|x| x.as_str()),
5012 Some("bun run build")
5013 );
5014 assert_eq!(build.get("out_dir").and_then(|x| x.as_str()), Some("dist"));
5015 }
5016
5017 #[test]
5020 fn service_config_save_creates_canonical_toml_and_round_trips() {
5021 let tmp = tempfile::TempDir::new().unwrap();
5022 let root = tmp.path();
5023
5024 let svc = ServiceConfig {
5025 schema_version: 1,
5026 name: "dev-yah".into(),
5027 domain: "yah.dev".into(),
5028 db: DbCatalog::default(),
5029 components: vec![ServiceComponent {
5030 id: "site".into(),
5031 kind: "mesofact-static".into(),
5032 path: "app/yah/web".into(),
5033 role: "static".into(),
5034 publishes: Some("static".into()),
5035 wave: 0,
5036 git: None,
5037 }],
5038 };
5039 svc.save(root).unwrap();
5040
5041 let path = crate::paths::service_toml(root, "dev-yah");
5043 assert!(
5044 path.exists(),
5045 "service.toml should exist at {}",
5046 path.display()
5047 );
5048
5049 let cfg = CloudConfig::load(root).unwrap();
5051 let loaded = cfg.service("dev-yah").expect("dev-yah service");
5052 assert_eq!(loaded.service.domain, "yah.dev");
5053 assert_eq!(loaded.service.components.len(), 1);
5054 assert_eq!(
5055 loaded.service.components[0].publishes.as_deref(),
5056 Some("static")
5057 );
5058 assert!(loaded.mirrors.is_empty());
5059 }
5060
5061 #[test]
5062 fn service_config_save_overwrites_in_place() {
5063 let tmp = tempfile::TempDir::new().unwrap();
5064 let root = tmp.path();
5065
5066 let mut svc = ServiceConfig {
5067 schema_version: 1,
5068 name: "dev-yah".into(),
5069 domain: "yah.dev".into(),
5070 components: vec![],
5071 db: DbCatalog::default(),
5072 };
5073 svc.save(root).unwrap();
5074 svc.domain = "yah.example".into();
5075 svc.save(root).unwrap();
5076
5077 let cfg = CloudConfig::load(root).unwrap();
5078 assert_eq!(
5079 cfg.service("dev-yah").unwrap().service.domain,
5080 "yah.example"
5081 );
5082 }
5083
5084 #[test]
5085 fn mirror_config_save_round_trips_reference_and_inline_slots() {
5086 let tmp = tempfile::TempDir::new().unwrap();
5087 let root = tmp.path();
5088
5089 ServiceConfig {
5091 schema_version: 1,
5092 name: "dev-yah".into(),
5093 domain: "yah.dev".into(),
5094 components: vec![],
5095 db: DbCatalog::default(),
5096 }
5097 .save(root)
5098 .unwrap();
5099
5100 let providers = crate::paths::providers_dir(root);
5103 std::fs::create_dir_all(&providers).unwrap();
5104 std::fs::write(
5105 providers.join("cloudflare.toml"),
5106 "schema_version = 1\nid = \"cloudflare\"\nkind = \"cloudflare\"\n",
5107 )
5108 .unwrap();
5109
5110 let mut providers_map = BTreeMap::new();
5111 providers_map.insert(
5112 "static".to_string(),
5113 MirrorProviderSlot::Reference {
5114 provider_id: "cloudflare".into(),
5115 fields: {
5116 let mut f = BTreeMap::new();
5117 f.insert("bucket".to_string(), toml::Value::String("yah-dev".into()));
5118 f
5119 },
5120 },
5121 );
5122 providers_map.insert(
5123 "compute".to_string(),
5124 MirrorProviderSlot::Inline {
5125 kind: Provider::LocalStatic,
5126 fields: {
5127 let mut f = BTreeMap::new();
5128 f.insert("port".to_string(), toml::Value::Integer(4321));
5129 f
5130 },
5131 },
5132 );
5133 let mirror = MirrorConfig {
5134 schema_version: 1,
5135 shape: MirrorShape::SingleMachine,
5136 providers: providers_map,
5137 ingress: Default::default(),
5138 drivers: Default::default(),
5139 asset_aliases: Default::default(),
5140 };
5141 mirror.save(root, "dev-yah", "cloud").unwrap();
5143
5144 let path = crate::paths::service_mirror_toml(root, "dev-yah", "cloud");
5145 assert!(
5146 path.exists(),
5147 "mirror toml should exist at {}",
5148 path.display()
5149 );
5150
5151 let cfg = CloudConfig::load(root).unwrap();
5152 let loaded = &cfg.service("dev-yah").unwrap().mirrors["cloud"];
5153 assert_eq!(loaded.shape, MirrorShape::SingleMachine);
5154 assert_eq!(loaded.providers["static"].provider_id(), Some("cloudflare"));
5155 assert_eq!(
5156 loaded.providers["compute"].inline_kind(),
5157 Some(Provider::LocalStatic)
5158 );
5159 }
5160
5161 #[test]
5162 fn service_delete_removes_dir_and_mirrors() {
5163 let tmp = tempfile::TempDir::new().unwrap();
5164 let root = tmp.path();
5165
5166 let svc = ServiceConfig {
5167 schema_version: 1,
5168 name: "dev-yah".into(),
5169 domain: "yah.dev".into(),
5170 components: vec![],
5171 db: DbCatalog::default(),
5172 };
5173 svc.save(root).unwrap();
5174 MirrorConfig {
5175 schema_version: 1,
5176 shape: MirrorShape::Local,
5177 providers: BTreeMap::new(),
5178 ingress: Default::default(),
5179 drivers: Default::default(),
5180 asset_aliases: Default::default(),
5181 }
5182 .save(root, "dev-yah", "local")
5183 .unwrap();
5184
5185 assert!(
5186 ServiceConfig::delete(root, "dev-yah").unwrap(),
5187 "first delete reports true"
5188 );
5189 assert!(!crate::paths::service_dir(root, "dev-yah").exists());
5190 assert!(!ServiceConfig::delete(root, "dev-yah").unwrap());
5192
5193 let cfg = CloudConfig::load(root).unwrap();
5194 assert!(cfg.service("dev-yah").is_none());
5195 }
5196
5197 #[test]
5198 fn mirror_delete_leaves_other_mirrors_and_service_intact() {
5199 let tmp = tempfile::TempDir::new().unwrap();
5200 let root = tmp.path();
5201
5202 ServiceConfig {
5203 schema_version: 1,
5204 name: "dev-yah".into(),
5205 domain: "yah.dev".into(),
5206 components: vec![],
5207 db: DbCatalog::default(),
5208 }
5209 .save(root)
5210 .unwrap();
5211 for env in ["prod", "local"] {
5212 MirrorConfig {
5213 schema_version: 1,
5214 shape: MirrorShape::Local,
5215 providers: BTreeMap::new(),
5216 ingress: Default::default(),
5217 drivers: Default::default(),
5218 asset_aliases: Default::default(),
5219 }
5220 .save(root, "dev-yah", env)
5221 .unwrap();
5222 }
5223
5224 assert!(MirrorConfig::delete(root, "dev-yah", "prod").unwrap());
5225 assert!(!MirrorConfig::delete(root, "dev-yah", "prod").unwrap());
5226
5227 let cfg = CloudConfig::load(root).unwrap();
5228 let svc = cfg
5229 .service("dev-yah")
5230 .expect("service survives mirror delete");
5231 assert!(!svc.mirrors.contains_key("cloud"));
5233 assert!(svc.mirrors.contains_key("dev"));
5234 }
5235
5236 fn write_marketing_service(root: &Path) {
5239 let svc = ServiceConfig {
5240 schema_version: 1,
5241 name: "yah-marketing".into(),
5242 domain: "yah.dev".into(),
5243 db: DbCatalog::default(),
5244 components: vec![ServiceComponent {
5245 id: "site".into(),
5246 kind: "mesofact-static".into(),
5247 path: "app/yah/web".into(),
5248 role: "static".into(),
5249 publishes: None,
5250 wave: 0,
5251 git: None,
5252 }],
5253 };
5254 svc.save(root).unwrap();
5255 }
5256
5257 #[test]
5258 fn round_trip_domain_with_each_route_mode() {
5259 let dom = DomainConfig {
5260 schema_version: 1,
5261 name: "yah-dev".into(),
5262 domain: "yah.dev".into(),
5263 front_door: FrontDoor::Worker,
5264 cdn_bucket: "yah-dev".into(),
5265 worker_bundle_path: Some(".yah/workers/yah-dev/".into()),
5266 routes: vec![
5267 DomainRoute {
5268 path: "/".into(),
5269 mode: RouteMode::Static {
5270 component: "yah-marketing/site".into(),
5271 },
5272 },
5273 DomainRoute {
5274 path: "/dashboard/api/*".into(),
5275 mode: RouteMode::Backend {
5276 component: "yah-dashboard/api".into(),
5277 origin: "https://api.dashboard.yah.dev".into(),
5278 },
5279 },
5280 DomainRoute {
5281 path: "/old".into(),
5282 mode: RouteMode::Redirect {
5283 target: "https://yah.dev/blog".into(),
5284 status: 308,
5285 },
5286 },
5287 ],
5288 };
5289 let s = toml::to_string(&dom).unwrap();
5290 let back: DomainConfig = toml::from_str(&s).unwrap();
5291 assert_eq!(back.name, "yah-dev");
5292 assert_eq!(back.routes.len(), 3);
5293 assert!(matches!(back.routes[0].mode, RouteMode::Static { .. }));
5294 assert!(matches!(back.routes[1].mode, RouteMode::Backend { .. }));
5295 assert!(matches!(back.routes[2].mode, RouteMode::Redirect { .. }));
5296 }
5297
5298 #[test]
5299 fn redirect_status_defaults_to_308() {
5300 let src = r#"
5301schema_version = 1
5302name = "yah-dev"
5303domain = "yah.dev"
5304front_door = "worker"
5305cdn_bucket = "yah-dev"
5306
5307[[routes]]
5308path = "/old"
5309mode = "redirect"
5310target = "https://yah.dev/blog"
5311"#;
5312 let dom: DomainConfig = toml::from_str(src).unwrap();
5313 let RouteMode::Redirect { status, .. } = &dom.routes[0].mode else {
5314 panic!("expected redirect");
5315 };
5316 assert_eq!(*status, 308);
5317 }
5318
5319 #[test]
5320 fn missing_domains_dir_is_empty() {
5321 let tmp = tempfile::TempDir::new().unwrap();
5322 let cfg = CloudConfig::load(tmp.path()).unwrap();
5323 assert!(cfg.domains.is_empty());
5324 }
5325
5326 #[test]
5327 fn save_reload_roundtrip() {
5328 let tmp = tempfile::TempDir::new().unwrap();
5329 let root = tmp.path();
5330 write_marketing_service(root);
5331
5332 let dom = DomainConfig {
5333 schema_version: 1,
5334 name: "yah-dev".into(),
5335 domain: "yah.dev".into(),
5336 front_door: FrontDoor::Worker,
5337 cdn_bucket: "yah-dev".into(),
5338 worker_bundle_path: None,
5339 routes: vec![DomainRoute {
5340 path: "/".into(),
5341 mode: RouteMode::Static {
5342 component: "yah-marketing/site".into(),
5343 },
5344 }],
5345 };
5346 dom.save(root).unwrap();
5347
5348 let cfg = CloudConfig::load(root).unwrap();
5349 let loaded = cfg.domain("yah-dev").expect("yah-dev domain");
5350 assert_eq!(loaded.domain, "yah.dev");
5351 assert_eq!(loaded.routes.len(), 1);
5352 }
5353
5354 #[test]
5355 fn delete_returns_false_when_absent() {
5356 let tmp = tempfile::TempDir::new().unwrap();
5357 assert!(!DomainConfig::delete(tmp.path(), "no-such-domain").unwrap());
5358 }
5359
5360 #[test]
5361 fn delete_returns_true_first_time() {
5362 let tmp = tempfile::TempDir::new().unwrap();
5363 let root = tmp.path();
5364 let dom = DomainConfig {
5365 schema_version: 1,
5366 name: "yah-dev".into(),
5367 domain: "yah.dev".into(),
5368 front_door: FrontDoor::BucketDirect,
5369 cdn_bucket: "yah-dev".into(),
5370 worker_bundle_path: None,
5371 routes: vec![],
5372 };
5373 dom.save(root).unwrap();
5374 assert!(DomainConfig::delete(root, "yah-dev").unwrap());
5375 assert!(!DomainConfig::delete(root, "yah-dev").unwrap());
5376 }
5377
5378 fn write_domain_toml(root: &Path, stem: &str, body: &str) {
5383 let dir = root.join(".yah").join("domains");
5384 std::fs::create_dir_all(&dir).unwrap();
5385 std::fs::write(dir.join(format!("{stem}.toml")), body).unwrap();
5386 }
5387
5388 #[test]
5389 fn front_door_is_required() {
5390 let tmp = tempfile::TempDir::new().unwrap();
5391 let root = tmp.path();
5392 write_marketing_service(root);
5393 write_domain_toml(
5394 root,
5395 "yah-dev",
5396 r#"
5397schema_version = 1
5398name = "yah-dev"
5399domain = "yah.dev"
5400cdn_bucket = "yah-dev"
5401[[routes]]
5402path = "/*"
5403mode = "static"
5404component = "yah-marketing/site"
5405"#,
5406 );
5407 let err = CloudConfig::load(root).unwrap_err().to_string();
5408 assert!(err.contains("yah-dev.toml"), "{err}");
5411 }
5412
5413 #[test]
5414 fn bucket_direct_with_routes_is_rejected() {
5415 let tmp = tempfile::TempDir::new().unwrap();
5416 let root = tmp.path();
5417 write_marketing_service(root);
5418 write_domain_toml(
5419 root,
5420 "cdn-yah-dev",
5421 r#"
5422schema_version = 1
5423name = "cdn-yah-dev"
5424domain = "cdn.yah.dev"
5425front_door = "bucket-direct"
5426cdn_bucket = "yah-dev"
5427[[routes]]
5428path = "/docs/*"
5429mode = "static"
5430component = "yah-marketing/site"
5431"#,
5432 );
5433 let err = format!("{:#}", CloudConfig::load(root).unwrap_err());
5434 assert!(err.contains("front_door"), "{err}");
5435 assert!(err.contains("/docs/*"), "{err}");
5436 }
5437
5438 #[test]
5439 fn bucket_direct_with_worker_bundle_path_is_rejected() {
5440 let tmp = tempfile::TempDir::new().unwrap();
5441 let root = tmp.path();
5442 write_domain_toml(
5443 root,
5444 "cdn-yah-dev",
5445 r#"
5446schema_version = 1
5447name = "cdn-yah-dev"
5448domain = "cdn.yah.dev"
5449front_door = "bucket-direct"
5450cdn_bucket = "yah-dev"
5451worker_bundle_path = ".yah/workers/cdn-yah-dev/"
5452"#,
5453 );
5454 let err = format!("{:#}", CloudConfig::load(root).unwrap_err());
5455 assert!(err.contains("worker_bundle_path"), "{err}");
5456 }
5457
5458 #[test]
5459 fn worker_with_no_routes_is_rejected() {
5460 let tmp = tempfile::TempDir::new().unwrap();
5461 let root = tmp.path();
5462 write_domain_toml(
5463 root,
5464 "yah-dev",
5465 r#"
5466schema_version = 1
5467name = "yah-dev"
5468domain = "yah.dev"
5469front_door = "worker"
5470cdn_bucket = "yah-dev"
5471"#,
5472 );
5473 let err = format!("{:#}", CloudConfig::load(root).unwrap_err());
5474 assert!(err.contains("front_door = \"worker\""), "{err}");
5475 assert!(err.contains("404"), "{err}");
5476 }
5477
5478 #[test]
5479 fn passway_with_no_routes_is_rejected_too() {
5480 let tmp = tempfile::TempDir::new().unwrap();
5481 let root = tmp.path();
5482 write_domain_toml(
5483 root,
5484 "yah-dev",
5485 r#"
5486schema_version = 1
5487name = "yah-dev"
5488domain = "yah.dev"
5489front_door = "passway"
5490cdn_bucket = "yah-dev"
5491"#,
5492 );
5493 let err = format!("{:#}", CloudConfig::load(root).unwrap_err());
5494 assert!(err.contains("front_door = \"passway\""), "{err}");
5495 }
5496
5497 #[test]
5498 fn bucket_direct_without_routes_loads() {
5499 let tmp = tempfile::TempDir::new().unwrap();
5500 let root = tmp.path();
5501 write_domain_toml(
5504 root,
5505 "cdn-yah-dev",
5506 r#"
5507schema_version = 1
5508name = "cdn-yah-dev"
5509domain = "cdn.yah.dev"
5510front_door = "bucket-direct"
5511cdn_bucket = "yah-dev"
5512"#,
5513 );
5514 let cfg = CloudConfig::load(root).unwrap();
5515 let dom = cfg.domain("cdn-yah-dev").expect("cdn-yah-dev domain");
5516 assert_eq!(dom.front_door, FrontDoor::BucketDirect);
5517 assert!(!dom.front_door.is_route_driven());
5518 }
5519
5520 #[test]
5521 fn front_door_round_trips_through_save() {
5522 let tmp = tempfile::TempDir::new().unwrap();
5523 let root = tmp.path();
5524 write_marketing_service(root);
5525 let dom = DomainConfig {
5526 schema_version: 1,
5527 name: "yah-dev".into(),
5528 domain: "yah.dev".into(),
5529 front_door: FrontDoor::Passway,
5530 cdn_bucket: "yah-dev".into(),
5531 worker_bundle_path: None,
5532 routes: vec![DomainRoute {
5533 path: "/*".into(),
5534 mode: RouteMode::Static {
5535 component: "yah-marketing/site".into(),
5536 },
5537 }],
5538 };
5539 dom.save(root).unwrap();
5540 let cfg = CloudConfig::load(root).unwrap();
5541 assert_eq!(
5542 cfg.domain("yah-dev").unwrap().front_door,
5543 FrontDoor::Passway
5544 );
5545 }
5546
5547 #[test]
5553 fn cross_ref_bails_on_missing_service() {
5554 let tmp = tempfile::TempDir::new().unwrap();
5555 let root = tmp.path();
5556 let dom = DomainConfig {
5558 schema_version: 1,
5559 name: "yah-dev".into(),
5560 domain: "yah.dev".into(),
5561 front_door: FrontDoor::Worker,
5562 cdn_bucket: "yah-dev".into(),
5563 worker_bundle_path: None,
5564 routes: vec![DomainRoute {
5565 path: "/".into(),
5566 mode: RouteMode::Static {
5567 component: "yah-marketing/site".into(),
5568 },
5569 }],
5570 };
5571 dom.save(root).unwrap();
5572
5573 let err = CloudConfig::load(root).unwrap_err();
5574 let msg = format!("{err:#}");
5575 assert!(msg.contains("no such service"), "got: {msg}");
5576 assert!(msg.contains("yah-marketing"), "got: {msg}");
5577 }
5578
5579 #[test]
5580 fn cross_ref_bails_on_missing_component() {
5581 let tmp = tempfile::TempDir::new().unwrap();
5582 let root = tmp.path();
5583 write_marketing_service(root); let dom = DomainConfig {
5586 schema_version: 1,
5587 name: "yah-dev".into(),
5588 domain: "yah.dev".into(),
5589 front_door: FrontDoor::Worker,
5590 cdn_bucket: "yah-dev".into(),
5591 worker_bundle_path: None,
5592 routes: vec![DomainRoute {
5593 path: "/".into(),
5594 mode: RouteMode::Static {
5595 component: "yah-marketing/elsewhere".into(),
5596 },
5597 }],
5598 };
5599 dom.save(root).unwrap();
5600
5601 let err = CloudConfig::load(root).unwrap_err();
5602 let msg = format!("{err:#}");
5603 assert!(msg.contains("no component with id"), "got: {msg}");
5604 assert!(msg.contains("elsewhere"), "got: {msg}");
5605 }
5606
5607 #[test]
5608 fn cross_ref_bails_on_malformed_ref() {
5609 let tmp = tempfile::TempDir::new().unwrap();
5610 let root = tmp.path();
5611 write_marketing_service(root);
5612
5613 let dom = DomainConfig {
5614 schema_version: 1,
5615 name: "yah-dev".into(),
5616 domain: "yah.dev".into(),
5617 front_door: FrontDoor::Worker,
5618 cdn_bucket: "yah-dev".into(),
5619 worker_bundle_path: None,
5620 routes: vec![DomainRoute {
5621 path: "/".into(),
5622 mode: RouteMode::Static {
5623 component: "no-slash-here".into(),
5624 },
5625 }],
5626 };
5627 dom.save(root).unwrap();
5628
5629 let err = CloudConfig::load(root).unwrap_err();
5630 let msg = format!("{err:#}");
5631 assert!(msg.contains("expected"), "got: {msg}");
5632 }
5633
5634 #[test]
5635 fn redirect_routes_skip_component_validation() {
5636 let tmp = tempfile::TempDir::new().unwrap();
5637 let root = tmp.path();
5638 let dom = DomainConfig {
5641 schema_version: 1,
5642 name: "yah-dev".into(),
5643 domain: "yah.dev".into(),
5644 front_door: FrontDoor::Worker,
5645 cdn_bucket: "yah-dev".into(),
5646 worker_bundle_path: None,
5647 routes: vec![DomainRoute {
5648 path: "/old".into(),
5649 mode: RouteMode::Redirect {
5650 target: "https://yah.dev/blog".into(),
5651 status: 308,
5652 },
5653 }],
5654 };
5655 dom.save(root).unwrap();
5656
5657 let cfg = CloudConfig::load(root).unwrap();
5658 assert!(cfg.domain("yah-dev").is_some());
5659 }
5660
5661 #[test]
5662 fn name_must_match_file_stem() {
5663 let tmp = tempfile::TempDir::new().unwrap();
5664 let root = tmp.path();
5665 let dir = root.join(".yah").join("domains");
5667 std::fs::create_dir_all(&dir).unwrap();
5668 std::fs::write(
5669 dir.join("yah-dev.toml"),
5670 r#"schema_version = 1
5671name = "different-name"
5672domain = "yah.dev"
5673front_door = "bucket-direct"
5674cdn_bucket = "yah-dev"
5675"#,
5676 )
5677 .unwrap();
5678
5679 let err = CloudConfig::load(root).unwrap_err();
5680 let msg = format!("{err:#}");
5681 assert!(msg.contains("must match the file stem"), "got: {msg}");
5682 }
5683
5684 #[test]
5685 fn net_alias_tier_subdomain_manifest_loads_and_cross_refs() {
5686 let tmp = tempfile::TempDir::new().unwrap();
5691 let root = tmp.path();
5692 write_marketing_service(root); let dom = DomainConfig {
5695 schema_version: 1,
5696 name: "tenant-net-yah-dev".into(),
5697 domain: "tenant.net.yah.dev".into(),
5698 front_door: FrontDoor::Worker,
5699 cdn_bucket: "net-yah-dev".into(), worker_bundle_path: None,
5701 routes: vec![DomainRoute {
5702 path: "/*".into(),
5703 mode: RouteMode::Static {
5704 component: "yah-marketing/site".into(),
5705 },
5706 }],
5707 };
5708 dom.save(root).unwrap();
5709
5710 let cfg = CloudConfig::load(root).unwrap();
5711 let dom = cfg
5712 .domain("tenant-net-yah-dev")
5713 .expect("net-tier subdomain manifest should load");
5714 assert_eq!(dom.domain, "tenant.net.yah.dev");
5715 assert_eq!(dom.cdn_bucket, "net-yah-dev");
5716 }
5717
5718 #[test]
5721 fn machine_allocatable_round_trips() {
5722 let toml_src = r#"
5723name = "us-west-001"
5724provider = "static"
5725mesh_tags = ["tag:cloud-runner"]
5726[allocatable]
5727memory_mb = 3800
5728cpu_millis = 2000
5729"#;
5730 let m: MachineConfig = toml::from_str(toml_src).unwrap();
5731 let a = m.allocatable.as_ref().expect("allocatable should parse");
5732 assert_eq!(a.memory_mb, 3800);
5733 assert_eq!(a.cpu_millis, 2000);
5734
5735 let s = toml::to_string(&m).unwrap();
5736 let back: MachineConfig = toml::from_str(&s).unwrap();
5737 let a2 = back.allocatable.as_ref().unwrap();
5738 assert_eq!(a2.memory_mb, 3800);
5739 assert_eq!(a2.cpu_millis, 2000);
5740 }
5741
5742 #[test]
5743 fn machine_taints_round_trips() {
5744 let toml_src = r#"
5745name = "us-south-001"
5746provider = "static"
5747mesh_tags = ["tag:cloud-runner"]
5748taints = ["no-appliance"]
5749"#;
5750 let m: MachineConfig = toml::from_str(toml_src).unwrap();
5751 assert_eq!(m.taints, vec!["no-appliance"]);
5752
5753 let s = toml::to_string(&m).unwrap();
5754 let back: MachineConfig = toml::from_str(&s).unwrap();
5755 assert_eq!(back.taints, vec!["no-appliance"]);
5756 }
5757
5758 #[test]
5759 fn machine_allocatable_absent_is_none() {
5760 let toml_src = "name = \"node\"\nprovider = \"static\"\nmesh_tags = []\n";
5761 let m: MachineConfig = toml::from_str(toml_src).unwrap();
5762 assert!(m.allocatable.is_none());
5763 assert!(m.taints.is_empty());
5764 }
5765
5766 #[test]
5767 fn machine_allocatable_skipped_when_none() {
5768 let m = make_machine("node", vec![]);
5769 let s = toml::to_string(&m).unwrap();
5770 assert!(
5771 !s.contains("allocatable"),
5772 "None allocatable must be omitted: {s}"
5773 );
5774 assert!(!s.contains("taints"), "empty taints must be omitted: {s}");
5775 }
5776
5777 #[test]
5778 fn machine_multiple_taints_round_trip() {
5779 let toml_src = r#"
5780name = "us-west-002"
5781provider = "static"
5782mesh_tags = ["tag:build-worker"]
5783taints = ["no-server", "no-appliance", "no-voter"]
5784"#;
5785 let m: MachineConfig = toml::from_str(toml_src).unwrap();
5786 assert_eq!(m.taints.len(), 3);
5787 assert!(m.taints.contains(&"no-server".to_string()));
5788 assert!(m.taints.contains(&"no-appliance".to_string()));
5789 assert!(m.taints.contains(&"no-voter".to_string()));
5790 }
5791
5792 fn make_machine_with_capacity(
5795 name: &str,
5796 memory_mb: u32,
5797 cpu_millis: u32,
5798 taints: Vec<&str>,
5799 ) -> MachineConfig {
5800 MachineConfig {
5801 allocatable: Some(NodeAllocatable {
5802 memory_mb,
5803 cpu_millis,
5804 }),
5805 taints: taints.into_iter().map(String::from).collect(),
5806 ..make_machine(name, vec![])
5807 }
5808 }
5809
5810 fn server_spec(memory_mb: u32, cpu_millis: u32) -> WorkloadSpec {
5811 use workload_spec::{ImageRef, LifecycleArchetype, ResourceLimits, TierTag};
5812 let mut ws = WorkloadSpec::for_forge(
5813 "f5-test",
5814 ImageRef {
5815 registry: "localhost".into(),
5816 repository: "test".into(),
5817 tag: "latest".into(),
5818 digest: workload_spec::testing::test_digest(),
5819 },
5820 TierTag("infra".into()),
5821 vec![],
5822 );
5823 ws.archetype = Some(LifecycleArchetype::Server);
5824 ws.resources = ResourceLimits {
5825 memory_mb,
5826 cpu_millis,
5827 ephemeral_storage_mb: 0,
5828 };
5829 ws
5830 }
5831
5832 fn appliance_spec_ws(memory_mb: u32, cpu_millis: u32) -> WorkloadSpec {
5833 use workload_spec::LifecycleArchetype;
5834 let mut ws = server_spec(memory_mb, cpu_millis);
5835 ws.archetype = Some(LifecycleArchetype::Appliance);
5836 ws
5837 }
5838
5839 #[test]
5840 fn capacity_floor_rejects_undersized_node() {
5841 let cfg = make_empty_cfg(vec![make_machine_with_capacity("small", 256, 500, vec![])]);
5842 let ws = server_spec(512, 1000); assert!(cfg.admit_workload(&ws).is_err());
5844 }
5845
5846 #[test]
5847 fn capacity_floor_accepts_exact_fit() {
5848 let cfg = make_empty_cfg(vec![make_machine_with_capacity("exact", 512, 1000, vec![])]);
5849 let ws = server_spec(512, 1000);
5850 assert_eq!(cfg.admit_workload(&ws).unwrap().name, "exact");
5851 }
5852
5853 #[test]
5854 fn capacity_floor_passes_when_allocatable_absent() {
5855 let cfg = make_empty_cfg(vec![make_machine("no-alloc", vec![])]);
5857 let ws = server_spec(99999, 99999); assert_eq!(cfg.admit_workload(&ws).unwrap().name, "no-alloc");
5859 }
5860
5861 #[test]
5862 fn taint_repulsion_blocks_appliance_on_no_appliance_node() {
5863 let cfg = make_empty_cfg(vec![make_machine_with_capacity(
5864 "south",
5865 1024,
5866 2000,
5867 vec!["no-appliance"],
5868 )]);
5869 let ws = appliance_spec_ws(256, 500);
5870 assert!(
5871 cfg.admit_workload(&ws).is_err(),
5872 "appliance must be repelled by no-appliance taint"
5873 );
5874 }
5875
5876 #[test]
5877 fn taint_repulsion_allows_server_on_no_appliance_node() {
5878 let cfg = make_empty_cfg(vec![make_machine_with_capacity(
5880 "south",
5881 1024,
5882 2000,
5883 vec!["no-appliance"],
5884 )]);
5885 let ws = server_spec(256, 500);
5886 assert_eq!(cfg.admit_workload(&ws).unwrap().name, "south");
5887 }
5888
5889 #[test]
5890 fn taint_repulsion_job_not_blocked_by_no_server() {
5891 use workload_spec::LifecycleArchetype;
5892 let cfg = make_empty_cfg(vec![make_machine_with_capacity(
5893 "build-box",
5894 8192,
5895 4000,
5896 vec!["no-server", "no-appliance"],
5897 )]);
5898 let mut ws = server_spec(256, 500);
5899 ws.archetype = Some(LifecycleArchetype::Job);
5900 assert_eq!(cfg.admit_workload(&ws).unwrap().name, "build-box");
5902 }
5903
5904 #[test]
5905 fn requires_taint_affinity_blocks_placement_without_it() {
5906 use workload_spec::{LifecycleArchetype, PUBLIC_IP_TAINT, REQUIRES_TAINT_ANNOTATION};
5907 let mut ws = appliance_spec_ws(256, 512);
5909 ws.archetype = Some(LifecycleArchetype::Appliance);
5910 ws.annotations
5911 .insert(REQUIRES_TAINT_ANNOTATION.into(), PUBLIC_IP_TAINT.into());
5912
5913 let cfg = make_empty_cfg(vec![make_machine_with_capacity(
5915 "no-pip",
5916 2048,
5917 2000,
5918 vec![],
5919 )]);
5920 assert!(cfg.admit_workload(&ws).is_err());
5921
5922 let cfg = make_empty_cfg(vec![make_machine_with_capacity(
5924 "pub-node",
5925 2048,
5926 2000,
5927 vec!["public-ip"],
5928 )]);
5929 assert_eq!(cfg.admit_workload(&ws).unwrap().name, "pub-node");
5930 }
5931
5932 #[test]
5933 fn w244_fleet_scenario_appliance_rejected_from_south_and_west002() {
5934 let cfg = make_empty_cfg(vec![
5939 make_machine_with_capacity("us-south-001", 512, 1000, vec!["no-appliance"]),
5940 make_machine_with_capacity(
5941 "us-west-002",
5942 16384,
5943 8000,
5944 vec!["no-server", "no-appliance", "no-voter"],
5945 ),
5946 make_machine_with_capacity("us-west-001", 4096, 4000, vec![]),
5947 ]);
5948 let ws = appliance_spec_ws(256, 500);
5949 assert_eq!(cfg.admit_workload(&ws).unwrap().name, "us-west-001");
5951 }
5952
5953 #[test]
5954 fn w244_fleet_scenario_job_lands_on_west002_first() {
5955 use workload_spec::LifecycleArchetype;
5956 let cfg = make_empty_cfg(vec![
5958 make_machine_with_capacity("us-west-001", 4096, 4000, vec![]),
5959 make_machine_with_capacity(
5960 "us-west-002",
5961 16384,
5962 8000,
5963 vec!["no-server", "no-appliance", "no-voter"],
5964 ),
5965 ]);
5966 let mut ws = server_spec(256, 500);
5967 ws.archetype = Some(LifecycleArchetype::Job);
5968 let picked = cfg.admit_workload(&ws).unwrap();
5971 assert!(
5973 picked.name == "us-west-001" || picked.name == "us-west-002",
5974 "job must land on an eligible node, got {}",
5975 picked.name
5976 );
5977 }
5978
5979 #[test]
5980 fn r569_f4_macos_node_taints_keep_cloud_critical_off_but_admit_build_jobs() {
5981 use workload_spec::LifecycleArchetype;
5982 let mac_taints = vec!["no-server", "no-appliance", "no-voter"];
5993 let fleet = || {
5994 make_empty_cfg(vec![
5995 make_machine_with_capacity("us-west-015", 24576, 8000, mac_taints.clone()),
5996 make_machine_with_capacity("us-west-001", 4096, 4000, vec![]),
5997 ])
5998 };
5999
6000 let cfg = fleet();
6003 assert_eq!(
6004 cfg.admit_workload(&server_spec(256, 500)).unwrap().name,
6005 "us-west-001",
6006 "a Server workload must never land on the no-server Mac node"
6007 );
6008
6009 let cfg = fleet();
6011 assert_eq!(
6012 cfg.admit_workload(&appliance_spec_ws(256, 500))
6013 .unwrap()
6014 .name,
6015 "us-west-001",
6016 "an Appliance workload must never land on the no-appliance Mac node"
6017 );
6018
6019 let mac_only = make_empty_cfg(vec![make_machine_with_capacity(
6023 "us-west-015",
6024 24576,
6025 8000,
6026 mac_taints.clone(),
6027 )]);
6028 assert!(
6029 mac_only.admit_workload(&server_spec(256, 500)).is_err(),
6030 "a Server workload must be repelled from a Mac-only fleet, not admitted"
6031 );
6032
6033 let mut job = server_spec(256, 500);
6036 job.archetype = Some(LifecycleArchetype::Job);
6037 assert_eq!(
6038 mac_only.admit_workload(&job).unwrap().name,
6039 "us-west-015",
6040 "a build Job must still be admitted on the Mac build-worker"
6041 );
6042 }
6043
6044 #[test]
6047 fn sources_load_is_empty_when_the_file_is_absent() {
6048 let tmp = tempfile::TempDir::new().unwrap();
6051 let cfg = SourcesConfig::load(tmp.path()).unwrap();
6052 assert_eq!(cfg, SourcesConfig::default());
6053 assert!(cfg.source.is_empty());
6054 assert_eq!(cfg.schema_version, 1);
6055 }
6056
6057 #[test]
6058 fn sources_parses_a_path_kind_exactly_like_w274s_example() {
6059 let tmp = tempfile::TempDir::new().unwrap();
6060 std::fs::write(
6061 tmp.path().join("sources.toml"),
6062 r#"
6063schema_version = 1
6064
6065[[source]]
6066owner = "yah"
6067kind = "path"
6068path = "../yah"
6069mode = "read-only"
6070"#,
6071 )
6072 .unwrap();
6073 let cfg = SourcesConfig::load(tmp.path()).unwrap();
6074 assert_eq!(cfg.source.len(), 1);
6075 let s = &cfg.source[0];
6076 assert_eq!(s.owner, "yah");
6077 assert_eq!(s.mode, SourceMode::ReadOnly);
6078 assert!(s.select.is_empty());
6079 match &s.kind {
6080 InfraSourceKind::Path { path } => assert_eq!(path, "../yah"),
6081 other => panic!("expected Path, got {other:?}"),
6082 }
6083 }
6084
6085 #[test]
6086 fn sources_parses_a_git_kind_reusing_gitsource_verbatim() {
6087 let tmp = tempfile::TempDir::new().unwrap();
6088 std::fs::write(
6089 tmp.path().join("sources.toml"),
6090 r#"
6091schema_version = 1
6092
6093[[source]]
6094owner = "yah"
6095kind = "git"
6096repo = "git@github.com:yah-ai/infra.git"
6097ref = "main"
6098subdir = "infra"
6099select = ["tag:cloud-runner"]
6100mode = "read-only"
6101"#,
6102 )
6103 .unwrap();
6104 let cfg = SourcesConfig::load(tmp.path()).unwrap();
6105 assert_eq!(cfg.source.len(), 1);
6106 let s = &cfg.source[0];
6107 assert_eq!(s.select, vec!["tag:cloud-runner".to_string()]);
6108 match &s.kind {
6109 InfraSourceKind::Git(git) => {
6110 assert_eq!(git.repo, "git@github.com:yah-ai/infra.git");
6111 assert_eq!(git.r#ref, "main");
6112 assert_eq!(git.subdir.as_deref(), Some("infra"));
6113 }
6114 other => panic!("expected Git, got {other:?}"),
6115 }
6116 }
6117
6118 #[test]
6119 fn sources_mode_defaults_to_read_only_and_manage_is_explicit() {
6120 let tmp = tempfile::TempDir::new().unwrap();
6121 std::fs::write(
6122 tmp.path().join("sources.toml"),
6123 r#"
6124schema_version = 1
6125
6126[[source]]
6127owner = "a"
6128kind = "path"
6129path = "../a"
6130
6131[[source]]
6132owner = "b"
6133kind = "path"
6134path = "../b"
6135mode = "manage"
6136"#,
6137 )
6138 .unwrap();
6139 let cfg = SourcesConfig::load(tmp.path()).unwrap();
6140 assert_eq!(cfg.source[0].mode, SourceMode::ReadOnly, "omitted mode = read-only");
6141 assert_eq!(cfg.source[1].mode, SourceMode::Manage);
6142 }
6143
6144 #[test]
6145 fn sources_preserves_declaration_order() {
6146 let tmp = tempfile::TempDir::new().unwrap();
6150 std::fs::write(
6151 tmp.path().join("sources.toml"),
6152 r#"
6153schema_version = 1
6154
6155[[source]]
6156owner = "second"
6157kind = "path"
6158path = "../second"
6159
6160[[source]]
6161owner = "first"
6162kind = "path"
6163path = "../first"
6164"#,
6165 )
6166 .unwrap();
6167 let cfg = SourcesConfig::load(tmp.path()).unwrap();
6168 let owners: Vec<&str> = cfg.source.iter().map(|s| s.owner.as_str()).collect();
6169 assert_eq!(owners, vec!["second", "first"]);
6170 }
6171
6172 #[test]
6173 fn sources_round_trips_through_serialize() {
6174 let cfg = SourcesConfig {
6175 schema_version: 1,
6176 source: vec![
6177 InfraSource {
6178 owner: "yah".into(),
6179 kind: InfraSourceKind::Path {
6180 path: "../yah".into(),
6181 },
6182 mode: SourceMode::ReadOnly,
6183 select: vec![],
6184 },
6185 InfraSource {
6186 owner: "yah".into(),
6187 kind: InfraSourceKind::Git(GitSource {
6188 repo: "git@github.com:yah-ai/infra.git".into(),
6189 r#ref: "main".into(),
6190 subdir: Some("infra".into()),
6191 }),
6192 mode: SourceMode::Manage,
6193 select: vec!["tag:cloud-runner".into()],
6194 },
6195 ],
6196 };
6197 let toml_str = toml::to_string_pretty(&cfg).unwrap();
6198 let reloaded: SourcesConfig = toml::from_str(&toml_str).unwrap();
6199 assert_eq!(reloaded, cfg, "round-trip through TOML must be lossless:\n{toml_str}");
6200 }
6201
6202 fn write_min_machine(dir: &Path, name: &str, extra_toml: &str) {
6205 std::fs::create_dir_all(dir).unwrap();
6206 let mesh_tags = if extra_toml.contains("mesh_tags") {
6210 String::new()
6211 } else {
6212 "mesh_tags = []\n".to_string()
6213 };
6214 std::fs::write(
6215 dir.join(format!("{name}.toml")),
6216 format!("name = \"{name}\"\nprovider = \"static\"\n{mesh_tags}{extra_toml}"),
6217 )
6218 .unwrap();
6219 }
6220
6221 fn write_min_provider(dir: &Path, id: &str) {
6222 std::fs::create_dir_all(dir).unwrap();
6223 std::fs::write(
6224 dir.join(format!("{id}.toml")),
6225 format!("schema_version = 1\nid = \"{id}\"\nkind = \"static\"\n"),
6226 )
6227 .unwrap();
6228 }
6229
6230 fn write_sources_toml(camp_root: &Path, body: &str) {
6231 let dir = camp_root.join(".yah/infra");
6232 std::fs::create_dir_all(&dir).unwrap();
6233 std::fs::write(dir.join("sources.toml"), body).unwrap();
6234 }
6235
6236 #[test]
6237 fn load_with_no_sources_toml_is_unchanged() {
6238 let tmp = tempfile::TempDir::new().unwrap();
6239 write_min_machine(&tmp.path().join(".yah/infra/machines"), "local-1", "");
6240 let cfg = CloudConfig::load(tmp.path()).unwrap();
6241 assert_eq!(cfg.machines.len(), 1);
6242 assert!(cfg.machine_origins.is_empty());
6243 assert!(cfg.provider_origins.is_empty());
6244 }
6245
6246 #[test]
6247 fn path_source_overlays_machines_and_providers_tagged_with_origin() {
6248 let camp = tempfile::TempDir::new().unwrap();
6249 let other = tempfile::TempDir::new().unwrap();
6250 write_min_machine(&other.path().join(".yah/infra/machines"), "borrowed-1", "");
6251 write_min_provider(&other.path().join(".yah/infra/providers"), "borrowed-provider");
6252 write_sources_toml(
6253 camp.path(),
6254 &format!(
6255 "schema_version = 1\n\n[[source]]\nowner = \"other\"\nkind = \"path\"\npath = \"{}\"\n",
6256 other.path().display()
6257 ),
6258 );
6259
6260 let cfg = CloudConfig::load(camp.path()).unwrap();
6261 assert_eq!(cfg.machines.len(), 1);
6262 assert_eq!(cfg.machines[0].name, "borrowed-1");
6263 assert_eq!(cfg.providers.len(), 1);
6264 assert_eq!(cfg.providers[0].id, "borrowed-provider");
6265
6266 let origin = cfg.machine_origins.get("borrowed-1").expect("origin recorded");
6267 assert_eq!(origin.owner, "other");
6268 assert_eq!(origin.mode, SourceMode::ReadOnly);
6269 assert!(origin.source.starts_with("path:"));
6270 assert_eq!(
6271 cfg.provider_origins.get("borrowed-provider").unwrap().owner,
6272 "other"
6273 );
6274 }
6275
6276 #[test]
6277 fn camp_local_wins_on_name_collision_and_carries_no_origin() {
6278 let camp = tempfile::TempDir::new().unwrap();
6279 let other = tempfile::TempDir::new().unwrap();
6280 write_min_machine(&camp.path().join(".yah/infra/machines"), "shared", "");
6283 write_min_machine(
6284 &other.path().join(".yah/infra/machines"),
6285 "shared",
6286 "nickname = \"the borrowed one\"\n",
6287 );
6288 write_sources_toml(
6289 camp.path(),
6290 &format!(
6291 "schema_version = 1\n\n[[source]]\nowner = \"other\"\nkind = \"path\"\npath = \"{}\"\n",
6292 other.path().display()
6293 ),
6294 );
6295
6296 let cfg = CloudConfig::load(camp.path()).unwrap();
6297 assert_eq!(cfg.machines.len(), 1, "the name collides, so exactly one entry");
6298 assert_eq!(cfg.machines[0].nickname, None, "camp-local's copy, not the borrowed one");
6299 assert!(
6300 !cfg.machine_origins.contains_key("shared"),
6301 "camp-local entries never carry an origin tag"
6302 );
6303 }
6304
6305 #[test]
6306 fn an_earlier_source_wins_over_a_later_one_on_collision() {
6307 let camp = tempfile::TempDir::new().unwrap();
6308 let first = tempfile::TempDir::new().unwrap();
6309 let second = tempfile::TempDir::new().unwrap();
6310 write_min_machine(&first.path().join(".yah/infra/machines"), "dup", "");
6311 write_min_machine(&second.path().join(".yah/infra/machines"), "dup", "");
6312 write_sources_toml(
6313 camp.path(),
6314 &format!(
6315 "schema_version = 1\n\n[[source]]\nowner = \"first\"\nkind = \"path\"\npath = \"{}\"\n\n[[source]]\nowner = \"second\"\nkind = \"path\"\npath = \"{}\"\n",
6316 first.path().display(),
6317 second.path().display()
6318 ),
6319 );
6320
6321 let cfg = CloudConfig::load(camp.path()).unwrap();
6322 assert_eq!(cfg.machines.len(), 1);
6323 assert_eq!(cfg.machine_origins.get("dup").unwrap().owner, "first");
6324 }
6325
6326 #[test]
6327 fn select_filters_borrowed_machines_by_name_or_mesh_tag() {
6328 let camp = tempfile::TempDir::new().unwrap();
6329 let other = tempfile::TempDir::new().unwrap();
6330 write_min_machine(&other.path().join(".yah/infra/machines"), "runner-1", "mesh_tags = [\"tag:cloud-runner\"]\n");
6331 write_min_machine(&other.path().join(".yah/infra/machines"), "excluded-1", "");
6332 write_sources_toml(
6333 camp.path(),
6334 &format!(
6335 "schema_version = 1\n\n[[source]]\nowner = \"other\"\nkind = \"path\"\npath = \"{}\"\nselect = [\"tag:cloud-runner\"]\n",
6336 other.path().display()
6337 ),
6338 );
6339
6340 let cfg = CloudConfig::load(camp.path()).unwrap();
6341 assert_eq!(cfg.machines.len(), 1);
6342 assert_eq!(cfg.machines[0].name, "runner-1");
6343 }
6344
6345 #[test]
6346 fn one_unparseable_foreign_machine_does_not_sink_the_rest_of_the_directory_or_the_load() {
6347 let camp = tempfile::TempDir::new().unwrap();
6348 let other = tempfile::TempDir::new().unwrap();
6349 let dir = other.path().join(".yah/infra/machines");
6350 write_min_machine(&dir, "good", "");
6351 std::fs::write(dir.join("bad.toml"), "name = 1\nprovider = 2\n").unwrap();
6355 write_sources_toml(
6356 camp.path(),
6357 &format!(
6358 "schema_version = 1\n\n[[source]]\nowner = \"other\"\nkind = \"path\"\npath = \"{}\"\n",
6359 other.path().display()
6360 ),
6361 );
6362
6363 let cfg = CloudConfig::load(camp.path()).unwrap();
6366 assert_eq!(cfg.machines.len(), 1, "the good entry still loads");
6367 assert_eq!(cfg.machines[0].name, "good");
6368 }
6369
6370 #[test]
6371 fn an_unsynced_git_source_overlays_nothing_and_is_not_an_error() {
6372 let camp = tempfile::TempDir::new().unwrap();
6375 write_sources_toml(
6376 camp.path(),
6377 "schema_version = 1\n\n[[source]]\nowner = \"yah\"\nkind = \"git\"\nrepo = \"git@github.com:yah-ai/infra.git\"\nref = \"main\"\n",
6378 );
6379 let cfg = CloudConfig::load(camp.path()).unwrap();
6380 assert!(cfg.machines.is_empty());
6381 assert!(cfg.machine_origins.is_empty());
6382 }
6383
6384 #[test]
6385 fn a_synced_git_source_reads_from_the_cache_dir_not_the_repo_path() {
6386 let camp = tempfile::TempDir::new().unwrap();
6388 let cache = crate::paths::infra_source_cache_dir(camp.path(), "yah");
6389 write_min_machine(&cache.join("machines"), "synced-1", "");
6390 write_sources_toml(
6391 camp.path(),
6392 "schema_version = 1\n\n[[source]]\nowner = \"yah\"\nkind = \"git\"\nrepo = \"git@github.com:yah-ai/infra.git\"\nref = \"main\"\n",
6393 );
6394 let cfg = CloudConfig::load(camp.path()).unwrap();
6395 assert_eq!(cfg.machines.len(), 1);
6396 assert_eq!(cfg.machines[0].name, "synced-1");
6397 assert!(cfg.machine_origins.get("synced-1").unwrap().source.starts_with("git:"));
6398 }
6399
6400 #[test]
6401 fn a_git_sources_subdir_is_honoured_like_the_component_case() {
6402 let camp = tempfile::TempDir::new().unwrap();
6407 let cache = crate::paths::infra_source_cache_dir(camp.path(), "yah");
6408 write_min_machine(&cache.join("infra").join("machines"), "subdir-1", "");
6409 write_min_machine(&cache.join("machines"), "root-decoy", "");
6412 write_sources_toml(
6413 camp.path(),
6414 "schema_version = 1\n\n[[source]]\nowner = \"yah\"\nkind = \"git\"\nrepo = \"git@github.com:yah-ai/infra.git\"\nref = \"main\"\nsubdir = \"infra\"\n",
6415 );
6416 let cfg = CloudConfig::load(camp.path()).unwrap();
6417 assert_eq!(cfg.machines.len(), 1);
6418 assert_eq!(cfg.machines[0].name, "subdir-1");
6419 }
6420
6421 #[test]
6422 fn load_from_config_dir_never_applies_sources_overlay() {
6423 let camp = tempfile::TempDir::new().unwrap();
6429 let other = tempfile::TempDir::new().unwrap();
6430 write_min_machine(&other.path().join(".yah/infra/machines"), "borrowed-1", "");
6431 write_sources_toml(
6432 camp.path(),
6433 &format!(
6434 "schema_version = 1\n\n[[source]]\nowner = \"other\"\nkind = \"path\"\npath = \"{}\"\n",
6435 other.path().display()
6436 ),
6437 );
6438 let sibling_config_dir = camp.path().join(".noisetable");
6439 std::fs::create_dir_all(&sibling_config_dir).unwrap();
6440
6441 let cfg = CloudConfig::load_from_config_dir(&sibling_config_dir, camp.path()).unwrap();
6442 assert!(cfg.machines.is_empty(), "sources.toml must not apply here");
6443 assert!(cfg.machine_origins.is_empty());
6444 }
6445
6446 #[test]
6463 fn a_stale_inherit_machines_key_does_not_change_what_sources_toml_resolves() {
6464 let shared = tempfile::TempDir::new().unwrap();
6465 write_min_machine(&shared.path().join(".yah/infra/machines"), "shared-node-1", "");
6466 write_min_machine(&shared.path().join(".yah/infra/machines"), "shared-node-2", "");
6467
6468 let sources_toml = format!(
6469 "schema_version = 1\n\n[[source]]\nowner = \"yah\"\nkind = \"path\"\npath = \"{}\"\nmode = \"read-only\"\n",
6470 shared.path().display()
6471 );
6472
6473 let clean = tempfile::TempDir::new().unwrap();
6475 write_sources_toml(clean.path(), &sources_toml);
6476
6477 let stale = tempfile::TempDir::new().unwrap();
6480 std::fs::create_dir_all(stale.path().join(".yah")).unwrap();
6481 std::fs::write(
6482 stale.path().join(".yah/camp.toml"),
6483 format!(
6484 "[infra]\ninherit_machines = \"{}\"\n",
6485 shared.path().display()
6486 ),
6487 )
6488 .unwrap();
6489 write_sources_toml(stale.path(), &sources_toml);
6490
6491 let via_clean = CloudConfig::load(clean.path()).unwrap();
6492 let via_stale = CloudConfig::load(stale.path()).unwrap();
6493
6494 let names = |cfg: &CloudConfig| {
6495 let mut v: Vec<String> = cfg.machines.iter().map(|m| m.name.clone()).collect();
6496 v.sort();
6497 v
6498 };
6499 assert_eq!(
6500 names(&via_clean),
6501 names(&via_stale),
6502 "a leftover inherit_machines key must be inert — the retired redirect is gone"
6503 );
6504 assert_eq!(names(&via_clean), vec!["shared-node-1", "shared-node-2"]);
6505
6506 assert_eq!(via_clean.machine_origins.len(), 2);
6511 assert_eq!(via_stale.machine_origins.len(), 2);
6512 for origin in via_stale.machine_origins.values() {
6513 assert_eq!(origin.owner, "yah");
6514 assert_eq!(origin.mode, SourceMode::ReadOnly);
6515 }
6516 }
6517}