1use std::collections::BTreeSet;
40
41use crate::config::{LegacyServiceConfig, MachineConfig};
42use crate::mesh_service;
43
44#[derive(Debug, Clone)]
46pub struct ComposeBundle {
47 pub compose_yaml: String,
49 pub caddyfile: Option<String>,
51 pub firewall_cmds: Vec<String>,
56}
57
58pub fn generate_compose_bundle(
66 machine: &MachineConfig,
67 services: &[LegacyServiceConfig],
68 public_hostname: Option<&str>,
69) -> ComposeBundle {
70 let compose_yaml = build_compose_yaml(machine, services);
71 let caddyfile = build_caddyfile(services, public_hostname);
72 let firewall_cmds = collect_firewall_cmds(services);
73 ComposeBundle {
74 compose_yaml,
75 caddyfile,
76 firewall_cmds,
77 }
78}
79
80fn machine_network(machine: &MachineConfig) -> String {
83 machine
84 .mesh_tags
85 .iter()
86 .find_map(|t| t.strip_prefix("tier:"))
87 .map(|tier| format!("tier-{tier}"))
88 .unwrap_or_else(|| "yah-cloud".to_string())
89}
90
91struct NetworkPlan {
106 base: String,
107 multi_tenant: bool,
108}
109
110impl NetworkPlan {
111 fn derive(machine: &MachineConfig, services: &[LegacyServiceConfig]) -> Self {
112 let base = machine_network(machine);
113 let distinct_tenants: BTreeSet<&str> =
114 services.iter().map(|s| s.tenant.0.as_str()).collect();
115 NetworkPlan {
116 base,
117 multi_tenant: distinct_tenants.len() > 1,
118 }
119 }
120
121 fn network_for(&self, svc: &LegacyServiceConfig) -> String {
123 if self.multi_tenant {
124 format!("{}-{}", svc.tenant.0, self.base)
125 } else {
126 self.base.clone()
127 }
128 }
129
130 fn declared(&self, services: &[LegacyServiceConfig]) -> Vec<String> {
133 if !self.multi_tenant {
134 return vec![self.base.clone()];
135 }
136 services
137 .iter()
138 .map(|s| self.network_for(s))
139 .collect::<BTreeSet<_>>()
140 .into_iter()
141 .collect()
142 }
143}
144
145fn build_compose_yaml(machine: &MachineConfig, services: &[LegacyServiceConfig]) -> String {
146 let plan = NetworkPlan::derive(machine, services);
147 let declared = plan.declared(services);
148
149 let mut out = String::new();
150 out.push_str("# Generated by `yah cloud service deploy` — do not edit manually.\n");
151 out.push_str(&format!(
152 "# Machine: {} | Location: {}\n",
153 machine.name,
154 machine.location()
155 ));
156 out.push_str("version: \"3.8\"\n\n");
157
158 let has_public = services.iter().any(|s| !s.mesh_only);
159
160 if !services.is_empty() || has_public {
161 out.push_str("services:\n");
162 }
163
164 for svc in services {
165 append_service(&mut out, svc, &plan.network_for(svc));
166 }
167
168 if has_public {
169 append_caddy_service(&mut out, &declared);
170 }
171
172 out.push_str("networks:\n");
175 for net in &declared {
176 out.push_str(&format!(" {net}:\n"));
177 out.push_str(" driver: bridge\n");
178 }
179
180 if has_public {
181 out.push_str("\nvolumes:\n");
182 out.push_str(" caddy_data:\n");
183 }
184
185 out
186}
187
188fn append_service(out: &mut String, svc: &LegacyServiceConfig, network: &str) {
189 out.push_str(&format!(" {}:\n", svc.name));
190 out.push_str(&format!(" image: {}:{}\n", svc.image, svc.version));
191 out.push_str(" restart: unless-stopped\n");
192
193 if let Some(iface) = &svc.bind_interface {
198 out.push_str(" network_mode: \"host\"\n");
199 out.push_str(&format!(
202 " env_file:\n - {}\n",
203 mesh_service::MESH_IP_ENV_FILE,
204 ));
205 out.push_str(&format!(
207 " # bind_interface={iface}: use host network + tailscale0 IP. \
208 Apply pg_hba snippet from `yah cloud service recipe postgres`.\n",
209 ));
210
211 if !svc.env.is_empty() {
212 out.push_str(" environment:\n");
213 let mut pairs: Vec<(&String, &String)> = svc.env.iter().collect();
214 pairs.sort_by_key(|(k, _)| k.as_str());
215 for (k, v) in pairs {
216 let escaped = v.replace('"', "\\\"");
217 out.push_str(&format!(" {k}: \"{escaped}\"\n"));
218 }
219 }
220 out.push('\n');
221 return;
222 }
223
224 if !svc.env.is_empty() {
225 out.push_str(" environment:\n");
226 let mut pairs: Vec<(&String, &String)> = svc.env.iter().collect();
227 pairs.sort_by_key(|(k, _)| k.as_str());
228 for (k, v) in pairs {
229 let escaped = v.replace('"', "\\\"");
230 out.push_str(&format!(" {k}: \"{escaped}\"\n"));
231 }
232 }
233
234 if !svc.ports.is_empty() {
235 out.push_str(" expose:\n");
236 for p in &svc.ports {
237 out.push_str(&format!(" - \"{}\"\n", p.container));
238 }
239 }
240
241 out.push_str(&format!(" networks:\n - {network}\n\n"));
242}
243
244fn collect_firewall_cmds(services: &[LegacyServiceConfig]) -> Vec<String> {
248 let mut cmds = Vec::new();
249 for svc in services {
250 if let Some(iface) = &svc.bind_interface {
251 for port in &svc.ports {
252 cmds.extend(mesh_service::ufw_rules_for_mesh_port(iface, port.container));
253 }
254 }
255 }
256 cmds
257}
258
259fn append_caddy_service(out: &mut String, networks: &[String]) {
260 out.push_str(" caddy:\n");
261 out.push_str(" image: caddy:2-alpine\n");
262 out.push_str(" restart: unless-stopped\n");
263 out.push_str(" ports:\n");
264 out.push_str(" - \"80:80\"\n");
265 out.push_str(" - \"443:443\"\n");
266 out.push_str(" volumes:\n");
267 out.push_str(" - /etc/yah-cloud/Caddyfile:/etc/caddy/Caddyfile:ro\n");
268 out.push_str(" - caddy_data:/data\n");
269 out.push_str(" networks:\n");
272 for net in networks {
273 out.push_str(&format!(" - {net}\n"));
274 }
275 out.push('\n');
276}
277
278fn build_caddyfile(services: &[LegacyServiceConfig], hostname: Option<&str>) -> Option<String> {
287 let public: Vec<&LegacyServiceConfig> = services.iter().filter(|s| !s.mesh_only).collect();
288 if public.is_empty() {
289 return None;
290 }
291
292 let mut out = String::new();
293 out.push_str("# Generated by `yah cloud service deploy` — do not edit manually.\n");
294
295 if hostname.is_none() {
296 out.push_str("# Set cloud_domain in mirrors/<camp>.toml for named virtual hosts.\n");
297 out.push_str("# Example: pdx.cloud.noisetable.example { reverse_proxy service:8080 }\n");
298 }
299 out.push('\n');
300
301 for (i, svc) in public.iter().enumerate() {
302 let first_port = first_port(svc);
303 let site = match hostname {
304 Some(h) if i == 0 => h.to_string(),
305 Some(h) => format!("{}.{h}", svc.name),
306 None => format!(":{first_port}"),
307 };
308 out.push_str(&format!("{site} {{\n"));
309 out.push_str(&format!(" reverse_proxy {}:{first_port}\n", svc.name));
310 out.push_str("}\n\n");
311 }
312
313 Some(out)
314}
315
316fn first_port(svc: &LegacyServiceConfig) -> u16 {
317 svc.ports.first().map(|p| p.container).unwrap_or(80)
318}
319
320#[cfg(test)]
323mod tests {
324 use super::*;
325 use crate::config::{BucketSpec, LegacyServiceConfig, MachineConfig, PortMapping}; use std::collections::HashMap;
327
328 fn machine(tags: &[&str]) -> MachineConfig {
329 MachineConfig {
330 name: "test-pdx-1".into(),
331 provider: "hetzner".into(),
332 location: Some("pdx".into()),
333 server_type: Some("cpx22".into()),
334 hosts_mirrors: vec!["noisetable".into()],
335 mesh_tags: tags.iter().map(|t| t.to_string()).collect(),
336 region: None,
337 zone: None,
338 arch: None,
339 bucket: Some(BucketSpec {
340 name: "test-assets-pdx-1".into(),
341 public_read: false,
342 }),
343 vendor: None,
344 nickname: None,
345 legacy_hostkey_fingerprint: None,
346 registration: Default::default(),
347 ssh_keys: vec![],
348 cloudflared: None,
349 hosts_operator_bridge: false,
350 connect: None,
351 allocatable: None,
352 taints: vec![],
353 sovereign_group: None,
354 sovereign_role: None,
355 ingress_floating_ip: None,
356 }
357 }
358
359 fn service(name: &str, mesh_only: bool) -> LegacyServiceConfig {
360 LegacyServiceConfig {
361 name: name.into(),
362 image: format!("ghcr.io/test/{name}"),
363 version: "v1.0.0".into(),
364 env: HashMap::new(),
365 ports: vec![PortMapping {
366 host: 8080,
367 container: 8080,
368 }],
369 mesh_only,
370 bind_interface: None,
371 tenant: workload_spec::TenantId::singleton(),
372 }
373 }
374
375 fn mesh_bound_service(name: &str, port: u16) -> LegacyServiceConfig {
376 LegacyServiceConfig {
377 name: name.into(),
378 image: format!("ghcr.io/test/{name}"),
379 version: "v1.0.0".into(),
380 env: HashMap::new(),
381 ports: vec![PortMapping {
382 host: port,
383 container: port,
384 }],
385 mesh_only: true,
386 bind_interface: Some("tailscale0".into()),
387 tenant: workload_spec::TenantId::singleton(),
388 }
389 }
390
391 #[test]
392 fn mesh_only_service_has_no_caddy() {
393 let m = machine(&["tier:t2"]);
394 let svcs = [service("asset-registry", true)];
395 let bundle = generate_compose_bundle(&m, &svcs, None);
396 assert!(
397 !bundle.compose_yaml.contains("caddy:"),
398 "caddy should not appear"
399 );
400 assert!(bundle.caddyfile.is_none(), "no Caddyfile for mesh-only");
401 }
402
403 #[test]
404 fn public_service_includes_caddy_and_caddyfile() {
405 let m = machine(&["tier:t2"]);
406 let svcs = [service("asset-registry", false)];
407 let bundle = generate_compose_bundle(&m, &svcs, None);
408 assert!(
409 bundle.compose_yaml.contains("caddy:"),
410 "caddy missing from compose"
411 );
412 assert!(
413 bundle.compose_yaml.contains("caddy_data:"),
414 "volume missing"
415 );
416 assert!(
417 bundle.caddyfile.is_some(),
418 "Caddyfile expected for public service"
419 );
420 }
421
422 #[test]
423 fn tier_tag_becomes_network_name() {
424 let m = machine(&["region:pdx", "tier:t2"]);
425 let svcs: [LegacyServiceConfig; 0] = [];
426 let bundle = generate_compose_bundle(&m, &svcs, None);
427 assert!(
428 bundle.compose_yaml.contains("tier-t2:"),
429 "network name mismatch"
430 );
431 assert!(
432 !bundle.compose_yaml.contains("yah-cloud:"),
433 "fallback network present"
434 );
435 }
436
437 #[test]
438 fn no_tier_tag_falls_back_to_yah_cloud_network() {
439 let m = machine(&["region:pdx"]);
440 let svcs: [LegacyServiceConfig; 0] = [];
441 let bundle = generate_compose_bundle(&m, &svcs, None);
442 assert!(
443 bundle.compose_yaml.contains("yah-cloud:"),
444 "fallback network missing"
445 );
446 }
447
448 fn tenant_service(name: &str, tenant: &str, mesh_only: bool) -> LegacyServiceConfig {
449 LegacyServiceConfig {
450 name: name.into(),
451 image: format!("ghcr.io/test/{name}"),
452 version: "v1.0.0".into(),
453 env: HashMap::new(),
454 ports: vec![PortMapping {
455 host: 8080,
456 container: 8080,
457 }],
458 mesh_only,
459 bind_interface: None,
460 tenant: workload_spec::TenantId(tenant.into()),
461 }
462 }
463
464 #[test]
465 fn single_tenant_keeps_one_shared_network() {
466 let m = machine(&["tier:t2"]);
469 let svcs = [service("a", true), service("b", true)];
470 let yaml = generate_compose_bundle(&m, &svcs, None).compose_yaml;
471 assert!(
472 yaml.contains("tier-t2:"),
473 "shared tier network expected:\n{yaml}"
474 );
475 assert!(
476 !yaml.contains("-tier-t2:"),
477 "no tenant-prefixed network when single-tenant:\n{yaml}"
478 );
479 }
480
481 #[test]
482 fn multi_tenant_splits_into_per_tenant_networks() {
483 let m = machine(&["tier:t2"]);
484 let svcs = [
485 tenant_service("yah-api", "ss", true),
486 tenant_service("nt-api", "noisetable", true),
487 ];
488 let yaml = generate_compose_bundle(&m, &svcs, None).compose_yaml;
489 assert!(yaml.contains("ss-tier-t2:"), "ss network missing:\n{yaml}");
490 assert!(
491 yaml.contains("noisetable-tier-t2:"),
492 "noisetable network missing:\n{yaml}"
493 );
494 assert!(
495 yaml.contains(" - ss-tier-t2\n"),
496 "yah-api should join the ss network:\n{yaml}"
497 );
498 assert!(
499 yaml.contains(" - noisetable-tier-t2\n"),
500 "nt-api should join the noisetable network:\n{yaml}"
501 );
502 assert!(
503 !yaml.contains(" - tier-t2\n"),
504 "no service joins the bare tier network when multi-tenant:\n{yaml}"
505 );
506 }
507
508 #[test]
509 fn multi_tenant_caddy_joins_every_tenant_network() {
510 let m = machine(&["tier:t2"]);
511 let svcs = [
514 tenant_service("yah-web", "ss", false),
515 tenant_service("nt-web", "noisetable", false),
516 ];
517 let yaml = generate_compose_bundle(&m, &svcs, None).compose_yaml;
518 assert!(yaml.contains("caddy:"), "caddy present for public services");
519 let after_caddy_vol = yaml
522 .split("- caddy_data:/data")
523 .nth(1)
524 .expect("caddy volumes");
525 let caddy_nets = after_caddy_vol.split("\nnetworks:").next().unwrap();
526 assert!(
527 caddy_nets.contains("- ss-tier-t2"),
528 "caddy joins ss network:\n{yaml}"
529 );
530 assert!(
531 caddy_nets.contains("- noisetable-tier-t2"),
532 "caddy joins noisetable network:\n{yaml}"
533 );
534 }
535
536 #[test]
537 fn image_includes_version() {
538 let m = machine(&[]);
539 let svcs = [service("asset-registry", true)];
540 let bundle = generate_compose_bundle(&m, &svcs, None);
541 assert!(bundle
542 .compose_yaml
543 .contains("ghcr.io/test/asset-registry:v1.0.0"));
544 }
545
546 #[test]
547 fn env_vars_are_rendered_sorted() {
548 let m = machine(&[]);
549 let mut env = HashMap::new();
550 env.insert("ZEBRA".into(), "last".into());
551 env.insert("ALPHA".into(), "first".into());
552 let svc = LegacyServiceConfig {
553 name: "myservice".into(),
554 image: "img".into(),
555 version: "v1".into(),
556 env,
557 ports: vec![],
558 mesh_only: true,
559 bind_interface: None,
560 tenant: workload_spec::TenantId::singleton(),
561 };
562 let bundle = generate_compose_bundle(&m, &[svc], None);
563 let yaml = &bundle.compose_yaml;
564 assert!(yaml.contains("ALPHA: \"first\""), "ALPHA missing");
565 assert!(yaml.contains("ZEBRA: \"last\""), "ZEBRA missing");
566 let alpha_pos = yaml.find("ALPHA").unwrap();
568 let zebra_pos = yaml.find("ZEBRA").unwrap();
569 assert!(alpha_pos < zebra_pos, "env vars not sorted");
570 }
571
572 #[test]
573 fn ports_become_expose() {
574 let m = machine(&[]);
575 let svcs = [service("svc", true)];
576 let bundle = generate_compose_bundle(&m, &svcs, None);
577 assert!(bundle.compose_yaml.contains("expose:\n - \"8080\""));
578 }
579
580 #[test]
581 fn caddyfile_uses_hostname_when_provided() {
582 let m = machine(&[]);
583 let svcs = [service("api", false)];
584 let bundle = generate_compose_bundle(&m, &svcs, Some("pdx.cloud.example.com"));
585 let cf = bundle.caddyfile.unwrap();
586 assert!(
587 cf.contains("pdx.cloud.example.com {"),
588 "hostname missing from Caddyfile"
589 );
590 assert!(
591 cf.contains("reverse_proxy api:8080"),
592 "reverse_proxy missing"
593 );
594 }
595
596 #[test]
597 fn caddyfile_uses_port_placeholder_when_no_hostname() {
598 let m = machine(&[]);
599 let svcs = [service("api", false)];
600 let bundle = generate_compose_bundle(&m, &svcs, None);
601 let cf = bundle.caddyfile.unwrap();
602 assert!(cf.contains(":8080 {"), "port placeholder missing");
603 }
604
605 #[test]
606 fn multiple_public_services_get_subdomains() {
607 let m = machine(&[]);
608 let svcs = [service("api", false), service("admin", false)];
609 let bundle = generate_compose_bundle(&m, &svcs, Some("pdx.cloud.example.com"));
610 let cf = bundle.caddyfile.unwrap();
611 assert!(cf.contains("pdx.cloud.example.com {"));
613 assert!(cf.contains("admin.pdx.cloud.example.com {"));
614 }
615
616 #[test]
617 fn mixed_services_only_routes_public_in_caddyfile() {
618 let m = machine(&[]);
619 let svcs = [service("public-svc", false), service("mesh-svc", true)];
620 let bundle = generate_compose_bundle(&m, &svcs, None);
621 let cf = bundle.caddyfile.as_deref().unwrap();
622 assert!(
623 cf.contains("public-svc"),
624 "public service missing from Caddyfile"
625 );
626 assert!(
627 !cf.contains("mesh-svc"),
628 "mesh-only service leaked into Caddyfile"
629 );
630 }
631
632 #[test]
635 fn bind_interface_emits_network_mode_host() {
636 let m = machine(&["tier:t2"]);
637 let svcs = [mesh_bound_service("postgres", 5432)];
638 let bundle = generate_compose_bundle(&m, &svcs, None);
639 assert!(
640 bundle.compose_yaml.contains("network_mode: \"host\""),
641 "host mode missing:\n{}",
642 bundle.compose_yaml,
643 );
644 }
645
646 #[test]
647 fn bind_interface_emits_env_file_for_mesh_ip() {
648 let m = machine(&[]);
649 let svcs = [mesh_bound_service("postgres", 5432)];
650 let bundle = generate_compose_bundle(&m, &svcs, None);
651 assert!(
652 bundle.compose_yaml.contains(mesh_service::MESH_IP_ENV_FILE),
653 "env_file missing from compose yaml:\n{}",
654 bundle.compose_yaml,
655 );
656 }
657
658 #[test]
659 fn bind_interface_service_not_in_bridge_network() {
660 let m = machine(&["tier:t2"]);
661 let svcs = [mesh_bound_service("postgres", 5432)];
662 let bundle = generate_compose_bundle(&m, &svcs, None);
663 let svc_block_end = bundle
666 .compose_yaml
667 .find("network_mode: \"host\"")
668 .expect("network_mode:host missing");
669 let after = &bundle.compose_yaml[svc_block_end..];
670 let blank = after.find("\n\n").unwrap_or(after.len());
673 let postgres_block = &after[..blank];
674 assert!(
675 !postgres_block.contains("networks:\n -"),
676 "bind_interface service should not be added to the bridge network:\n{}",
677 postgres_block,
678 );
679 }
680
681 #[test]
682 fn bind_interface_service_has_no_expose_block() {
683 let m = machine(&[]);
684 let svcs = [mesh_bound_service("postgres", 5432)];
685 let bundle = generate_compose_bundle(&m, &svcs, None);
686 let svc_start = bundle.compose_yaml.find(" postgres:").unwrap();
688 let svc_end = bundle.compose_yaml[svc_start..]
689 .find("\n\n")
690 .map(|i| svc_start + i)
691 .unwrap_or(bundle.compose_yaml.len());
692 let block = &bundle.compose_yaml[svc_start..svc_end];
693 assert!(
694 !block.contains("expose:"),
695 "expose: must be omitted in host mode:\n{block}"
696 );
697 }
698
699 #[test]
700 fn bind_interface_populates_firewall_cmds() {
701 let m = machine(&[]);
702 let svcs = [mesh_bound_service("postgres", 5432)];
703 let bundle = generate_compose_bundle(&m, &svcs, None);
704 assert_eq!(
705 bundle.firewall_cmds.len(),
706 2,
707 "expected 2 ufw rules: {:?}",
708 bundle.firewall_cmds
709 );
710 assert!(bundle.firewall_cmds[0].contains("allow in on tailscale0 to any port 5432"));
711 assert!(bundle.firewall_cmds[1].contains("deny 5432"));
712 }
713
714 #[test]
715 fn no_bind_interface_produces_empty_firewall_cmds() {
716 let m = machine(&[]);
717 let svcs = [service("api", false), service("worker", true)];
718 let bundle = generate_compose_bundle(&m, &svcs, None);
719 assert!(
720 bundle.firewall_cmds.is_empty(),
721 "no bind_interface → no firewall cmds"
722 );
723 }
724
725 #[test]
726 fn multiple_bound_services_accumulate_firewall_cmds() {
727 let m = machine(&[]);
728 let svcs = [
729 mesh_bound_service("postgres", 5432),
730 mesh_bound_service("nats", 4222),
731 ];
732 let bundle = generate_compose_bundle(&m, &svcs, None);
733 assert_eq!(
735 bundle.firewall_cmds.len(),
736 4,
737 "unexpected rules: {:?}",
738 bundle.firewall_cmds
739 );
740 let all = bundle.firewall_cmds.join("\n");
741 assert!(all.contains("5432"), "postgres rules missing");
742 assert!(all.contains("4222"), "nats rules missing");
743 }
744
745 #[test]
746 fn bind_interface_service_excluded_from_caddyfile() {
747 let m = machine(&[]);
748 let svcs = [mesh_bound_service("postgres", 5432), service("api", false)];
750 let bundle = generate_compose_bundle(&m, &svcs, None);
751 let cf = bundle.caddyfile.as_deref().unwrap();
752 assert!(
753 !cf.contains("postgres"),
754 "bound service must not be in Caddyfile"
755 );
756 assert!(cf.contains("api"), "public service missing from Caddyfile");
757 }
758}