1use std::collections::BTreeMap;
12use std::path::{Path, PathBuf};
13
14use anyhow::Context as _;
15
16use crate::config::{MirrorConfig, MirrorProviderSlot, Provider, ServiceConfig};
17use crate::paths::services_dir;
18
19#[derive(Debug, Clone, PartialEq, Eq)]
21pub struct AliasSource {
22 pub service: String,
24 pub component_id: String,
26 pub workload_toml: PathBuf,
28}
29
30#[derive(Debug, Clone, PartialEq, Eq)]
32pub struct AliasCollision {
33 pub alias: String,
34 pub first: AliasSource,
35 pub second: AliasSource,
36}
37
38impl AliasCollision {
39 pub fn message(&self) -> String {
41 format!(
42 "alias {:?} declared in both {} (component {}) and {} (component {})\n\
43 \u{2192} rename the alias in one of these files:\n {}\n {}",
44 self.alias,
45 self.first.service,
46 self.first.component_id,
47 self.second.service,
48 self.second.component_id,
49 self.first.workload_toml.display(),
50 self.second.workload_toml.display(),
51 )
52 }
53}
54
55pub fn check_alias_collisions(workspace_root: &Path) -> anyhow::Result<Vec<AliasCollision>> {
60 let dir = services_dir(workspace_root);
61 if !dir.exists() {
62 return Ok(vec![]);
63 }
64
65 let mut seen: BTreeMap<String, AliasSource> = BTreeMap::new();
67 let mut collisions = Vec::new();
68
69 let mut entries: Vec<_> = std::fs::read_dir(&dir)
70 .with_context(|| format!("reading {}", dir.display()))?
71 .filter_map(|e| e.ok())
72 .filter(|e| e.path().is_dir())
73 .collect();
74 entries.sort_by_key(|e| e.file_name());
75
76 for entry in entries {
77 let svc_dir = entry.path();
78 let service_toml = svc_dir.join("service.toml");
79 if !service_toml.exists() {
80 continue;
81 }
82 let service = match ServiceConfig::load(&service_toml) {
83 Ok(s) => s,
84 Err(e) => {
85 tracing::warn!(
86 path = %service_toml.display(),
87 error = %e,
88 "skipping service with unparseable service.toml"
89 );
90 continue;
91 }
92 };
93
94 for component in &service.components {
95 if component.kind != "static-asset" {
96 continue;
97 }
98 let workload_dir = workspace_root.join(&component.path);
99 let workload_toml_path = workload_dir.join("workload.toml");
100 if !workload_toml_path.exists() {
101 continue;
102 }
103
104 let aliases = match load_static_asset_aliases(&workload_toml_path) {
105 Ok(a) => a,
106 Err(e) => {
107 tracing::warn!(
108 path = %workload_toml_path.display(),
109 error = %e,
110 "skipping workload.toml with parse error"
111 );
112 continue;
113 }
114 };
115
116 for alias_name in aliases.keys() {
117 let source = AliasSource {
118 service: service.name.clone(),
119 component_id: component.id.clone(),
120 workload_toml: workload_toml_path.clone(),
121 };
122 if let Some(first) = seen.get(alias_name) {
123 collisions.push(AliasCollision {
124 alias: alias_name.clone(),
125 first: first.clone(),
126 second: source,
127 });
128 } else {
129 seen.insert(alias_name.clone(), source);
130 }
131 }
132 }
133 }
134
135 Ok(collisions)
136}
137
138fn load_static_asset_aliases(path: &Path) -> anyhow::Result<BTreeMap<String, String>> {
142 let src =
143 std::fs::read_to_string(path).with_context(|| format!("reading {}", path.display()))?;
144 let workload: workload_spec::Workload =
145 toml::from_str(&src).with_context(|| format!("parsing {}", path.display()))?;
146 match workload {
147 workload_spec::Workload::StaticAsset(w) => Ok(w.aliases),
148 _ => Ok(BTreeMap::new()),
149 }
150}
151
152#[derive(Debug, Clone, PartialEq, Eq)]
157pub struct PortSource {
158 pub service: String,
160 pub env: String,
162 pub slot_role: String,
164 pub field: String,
166}
167
168#[derive(Debug, Clone, PartialEq, Eq)]
174pub struct PortCollision {
175 pub port: u16,
176 pub first: PortSource,
177 pub second: PortSource,
178}
179
180impl PortCollision {
181 pub fn is_cross_service(&self) -> bool {
186 self.first.service != self.second.service
187 }
188
189 pub fn message(&self) -> String {
191 format!(
192 "host port {} is bound by both {}/{} (providers.{}.{}) and {}/{} (providers.{}.{})\n\
193 \u{2192} give one a distinct port — local mirrors share the operator's localhost, so \
194 two slots on the same port collide, and the local-static adopt probe may silently \
195 adopt the wrong service.",
196 self.port,
197 self.first.service,
198 self.first.env,
199 self.first.slot_role,
200 self.first.field,
201 self.second.service,
202 self.second.env,
203 self.second.slot_role,
204 self.second.field,
205 )
206 }
207}
208
209const PORT_FIELDS: &[&str] = &["port", "api_port", "console_port"];
211
212fn slot_binds_localhost(slot: &MirrorProviderSlot) -> bool {
216 matches!(
217 slot.inline_kind(),
218 Some(Provider::LocalStatic | Provider::MiniflareContainer | Provider::MinioContainer)
219 )
220}
221
222pub fn check_port_collisions(workspace_root: &Path) -> anyhow::Result<Vec<PortCollision>> {
231 let dir = services_dir(workspace_root);
232 if !dir.exists() {
233 return Ok(vec![]);
234 }
235
236 let mut seen: BTreeMap<u16, PortSource> = BTreeMap::new();
238 let mut collisions = Vec::new();
239
240 let mut svc_entries: Vec<_> = std::fs::read_dir(&dir)
241 .with_context(|| format!("reading {}", dir.display()))?
242 .filter_map(|e| e.ok())
243 .filter(|e| e.path().is_dir())
244 .collect();
245 svc_entries.sort_by_key(|e| e.file_name());
246
247 for entry in svc_entries {
248 let svc_dir = entry.path();
249 let service_toml = svc_dir.join("service.toml");
250 if !service_toml.exists() {
251 continue;
252 }
253 let service = match ServiceConfig::load(&service_toml) {
254 Ok(s) => s,
255 Err(e) => {
256 tracing::warn!(
257 path = %service_toml.display(),
258 error = %e,
259 "skipping service with unparseable service.toml"
260 );
261 continue;
262 }
263 };
264
265 let mirrors_dir = svc_dir.join("mirrors");
266 if !mirrors_dir.exists() {
267 continue;
268 }
269 let mut mirror_entries: Vec<_> = std::fs::read_dir(&mirrors_dir)
270 .with_context(|| format!("reading {}", mirrors_dir.display()))?
271 .filter_map(|e| e.ok())
272 .filter(|e| e.path().extension().map_or(false, |x| x == "toml"))
273 .collect();
274 mirror_entries.sort_by_key(|e| e.file_name());
275
276 for m in mirror_entries {
277 let path = m.path();
278 let env = path
279 .file_stem()
280 .and_then(|s| s.to_str())
281 .unwrap_or_default()
282 .to_string();
283 let mirror = match MirrorConfig::load(&path) {
284 Ok(mc) => mc,
285 Err(e) => {
286 tracing::warn!(
287 path = %path.display(),
288 error = %e,
289 "skipping mirror with parse error"
290 );
291 continue;
292 }
293 };
294
295 let mut roles: Vec<&String> = mirror.providers.keys().collect();
296 roles.sort();
297 for role in roles {
298 let slot = &mirror.providers[role];
299 if !slot_binds_localhost(slot) {
300 continue;
301 }
302 for field in PORT_FIELDS {
303 let Some(port) = crate::reconciler::slot_field_u16(slot.fields(), field) else {
304 continue;
305 };
306 let source = PortSource {
307 service: service.name.clone(),
308 env: env.clone(),
309 slot_role: role.clone(),
310 field: (*field).to_string(),
311 };
312 match seen.get(&port) {
313 Some(first) => collisions.push(PortCollision {
314 port,
315 first: first.clone(),
316 second: source,
317 }),
318 None => {
319 seen.insert(port, source);
320 }
321 }
322 }
323 }
324 }
325 }
326
327 Ok(collisions)
328}
329
330#[cfg(test)]
333mod tests {
334 use super::*;
335 use tempfile::tempdir;
336
337 fn write_service(workspace: &Path, svc_name: &str, component_path: &str) {
338 let svc_dir = workspace.join(".yah/services").join(svc_name);
339 std::fs::create_dir_all(&svc_dir).unwrap();
340 let toml = format!(
341 "schema_version = 1\nname = \"{svc_name}\"\ndomain = \"{svc_name}.example.com\"\n\
342 [[components]]\nid = \"models\"\nkind = \"static-asset\"\n\
343 path = \"{component_path}\"\nrole = \"static\"\n"
344 );
345 std::fs::write(svc_dir.join("service.toml"), toml).unwrap();
346 }
347
348 fn write_workload_with_aliases(dir: &Path, aliases: &[(&str, &str)]) {
349 std::fs::create_dir_all(dir).unwrap();
350 let alias_lines: String = aliases
351 .iter()
352 .map(|(k, v)| format!("\"{k}\" = \"{v}\"\n"))
353 .collect();
354 let content = format!(
355 "kind = \"static-asset\"\nschema_version = \"V1\"\n\
356 [aliases]\n{alias_lines}"
357 );
358 std::fs::write(dir.join("workload.toml"), content).unwrap();
359 }
360
361 #[test]
362 fn cloud_validate_clean_workspace_returns_empty() {
363 let dir = tempdir().unwrap();
364 let root = dir.path();
365
366 write_service(root, "svc-a", "svc-a/models");
367 write_workload_with_aliases(
368 &root.join("svc-a/models"),
369 &[("whisper-default-ggml", "svc-a/whisper/model.bin")],
370 );
371
372 let collisions = check_alias_collisions(root).unwrap();
373 assert!(
374 collisions.is_empty(),
375 "expected no collisions: {collisions:?}"
376 );
377 }
378
379 #[test]
380 fn cloud_validate_rejects_alias_collision() {
381 let dir = tempdir().unwrap();
382 let root = dir.path();
383
384 write_service(root, "svc-a", "svc-a/models");
385 write_workload_with_aliases(
386 &root.join("svc-a/models"),
387 &[("whisper-default-ggml", "svc-a/whisper/model.bin")],
388 );
389
390 write_service(root, "svc-b", "svc-b/models");
391 write_workload_with_aliases(
392 &root.join("svc-b/models"),
393 &[("whisper-default-ggml", "svc-b/whisper/model.bin")],
394 );
395
396 let collisions = check_alias_collisions(root).unwrap();
397 assert_eq!(
398 collisions.len(),
399 1,
400 "expected one collision: {collisions:?}"
401 );
402 let c = &collisions[0];
403 assert_eq!(c.alias, "whisper-default-ggml");
404 assert_eq!(c.first.service, "svc-a");
405 assert_eq!(c.second.service, "svc-b");
406
407 let msg = c.message();
408 assert!(msg.contains("whisper-default-ggml"), "message: {msg}");
409 assert!(msg.contains("svc-a"), "message: {msg}");
410 assert!(msg.contains("svc-b"), "message: {msg}");
411 }
412
413 #[test]
414 fn cloud_validate_distinct_aliases_no_collision() {
415 let dir = tempdir().unwrap();
416 let root = dir.path();
417
418 write_service(root, "svc-a", "svc-a/models");
419 write_workload_with_aliases(
420 &root.join("svc-a/models"),
421 &[
422 ("whisper-default-ggml", "svc-a/model.bin"),
423 ("whisper-default", "svc-a/model.bin"),
424 ],
425 );
426
427 write_service(root, "svc-b", "svc-b/models");
428 write_workload_with_aliases(
429 &root.join("svc-b/models"),
430 &[("whisper-default-coreml", "svc-b/model.tar.gz")],
431 );
432
433 let collisions = check_alias_collisions(root).unwrap();
434 assert!(collisions.is_empty());
435 }
436
437 #[test]
438 fn cloud_validate_multiple_collisions_all_reported() {
439 let dir = tempdir().unwrap();
440 let root = dir.path();
441
442 write_service(root, "svc-a", "svc-a/models");
443 write_workload_with_aliases(
444 &root.join("svc-a/models"),
445 &[
446 ("alias-one", "svc-a/one.bin"),
447 ("alias-two", "svc-a/two.bin"),
448 ],
449 );
450
451 write_service(root, "svc-b", "svc-b/models");
452 write_workload_with_aliases(
453 &root.join("svc-b/models"),
454 &[
455 ("alias-one", "svc-b/one.bin"),
456 ("alias-two", "svc-b/two.bin"),
457 ],
458 );
459
460 let collisions = check_alias_collisions(root).unwrap();
461 assert_eq!(collisions.len(), 2);
462 let names: Vec<_> = collisions.iter().map(|c| c.alias.as_str()).collect();
463 assert!(names.contains(&"alias-one"));
464 assert!(names.contains(&"alias-two"));
465 }
466
467 #[test]
468 fn cloud_validate_missing_services_dir_is_not_error() {
469 let dir = tempdir().unwrap();
470 let collisions = check_alias_collisions(dir.path()).unwrap();
471 assert!(collisions.is_empty());
472 }
473
474 #[test]
475 fn cloud_validate_non_static_asset_workloads_ignored() {
476 let dir = tempdir().unwrap();
477 let root = dir.path();
478
479 write_service(root, "svc-a", "svc-a/api");
480 let workload_dir = root.join("svc-a/api");
482 std::fs::create_dir_all(&workload_dir).unwrap();
483 std::fs::write(
486 workload_dir.join("workload.toml"),
487 "schema_version = \"V1\"\nname = \"api\"\nkind = \"mesofact-static\"\n\
488 bundle_dir = \"dist\"\n",
489 )
490 .unwrap();
491
492 let collisions = check_alias_collisions(root).unwrap();
493 assert!(collisions.is_empty());
494 }
495
496 fn write_mirror(workspace: &Path, svc: &str, env: &str, body: &str) {
499 let dir = workspace.join(".yah/services").join(svc).join("mirrors");
500 std::fs::create_dir_all(&dir).unwrap();
501 std::fs::write(dir.join(format!("{env}.toml")), body).unwrap();
502 }
503
504 fn local_static_mirror(port: u16) -> String {
505 format!(
506 "schema_version = 1\nshape = \"local\"\n\
507 [providers.static]\nkind = \"local-static\"\nport = {port}\n"
508 )
509 }
510
511 #[test]
512 fn port_collision_across_services_and_envs_is_flagged() {
513 let dir = tempdir().unwrap();
514 let root = dir.path();
515 write_service(root, "scrabcake", "scrabcake/site");
516 write_mirror(root, "scrabcake", "dev", &local_static_mirror(4322));
517 write_service(root, "yah-marketing", "yah-marketing/site");
518 write_mirror(
519 root,
520 "yah-marketing",
521 "pond",
522 "schema_version = 1\nshape = \"local\"\n\
523 [providers.static]\nkind = \"miniflare-container\"\nport = 4322\n",
524 );
525
526 let cols = check_port_collisions(root).unwrap();
527 assert_eq!(cols.len(), 1, "{cols:?}");
528 assert_eq!(cols[0].port, 4322);
529 assert_eq!(cols[0].first.service, "scrabcake");
531 assert_eq!(cols[0].second.service, "yah-marketing");
532 assert!(cols[0].is_cross_service(), "different services collide");
533 let msg = cols[0].message();
534 assert!(msg.contains("4322"), "{msg}");
535 assert!(msg.contains("scrabcake"), "{msg}");
536 assert!(msg.contains("yah-marketing"), "{msg}");
537 }
538
539 #[test]
540 fn distinct_ports_no_collision() {
541 let dir = tempdir().unwrap();
542 let root = dir.path();
543 write_service(root, "a", "a/site");
544 write_mirror(root, "a", "dev", &local_static_mirror(4322));
545 write_service(root, "b", "b/site");
546 write_mirror(root, "b", "dev", &local_static_mirror(4323));
547 assert!(check_port_collisions(root).unwrap().is_empty());
548 }
549
550 #[test]
551 fn same_service_two_envs_reusing_a_port_is_flagged() {
552 let dir = tempdir().unwrap();
554 let root = dir.path();
555 write_service(root, "scrabcake", "scrabcake/site");
556 write_mirror(root, "scrabcake", "dev", &local_static_mirror(4352));
557 write_mirror(root, "scrabcake", "cloud", &local_static_mirror(4352));
558 let cols = check_port_collisions(root).unwrap();
559 assert_eq!(cols.len(), 1, "{cols:?}");
560 assert_eq!(cols[0].port, 4352);
561 assert_eq!(cols[0].first.env, "cloud");
563 assert_eq!(cols[0].second.env, "dev");
564 assert!(
565 !cols[0].is_cross_service(),
566 "same service across envs is NOT cross-service"
567 );
568 }
569
570 #[test]
571 fn reference_slots_do_not_bind_localhost_and_are_ignored() {
572 let dir = tempdir().unwrap();
575 let root = dir.path();
576 let ref_slot = "schema_version = 1\nshape = \"local\"\n\
577 [providers.static]\nuse = \"cloudflare\"\nport = 8080\n";
578 write_service(root, "a", "a/site");
579 write_mirror(root, "a", "cloud", ref_slot);
580 write_service(root, "b", "b/site");
581 write_mirror(root, "b", "cloud", ref_slot);
582 assert!(check_port_collisions(root).unwrap().is_empty());
583 }
584
585 #[test]
586 fn minio_api_and_console_ports_collide_across_ponds() {
587 let dir = tempdir().unwrap();
589 let root = dir.path();
590 let minio = "schema_version = 1\nshape = \"local\"\n\
591 [providers.object_store]\nkind = \"minio-container\"\napi_port = 9000\nconsole_port = 9001\n";
592 write_service(root, "a", "a/site");
593 write_mirror(root, "a", "pond", minio);
594 write_service(root, "b", "b/site");
595 write_mirror(root, "b", "pond", minio);
596 let cols = check_port_collisions(root).unwrap();
597 assert_eq!(cols.len(), 2, "{cols:?}");
599 let ports: Vec<u16> = cols.iter().map(|c| c.port).collect();
600 assert!(ports.contains(&9000));
601 assert!(ports.contains(&9001));
602 }
603
604 #[test]
605 fn missing_services_dir_is_not_error_for_ports() {
606 let dir = tempdir().unwrap();
607 assert!(check_port_collisions(dir.path()).unwrap().is_empty());
608 }
609}