Skip to main content

cloud/
compose.rs

1//! Podman Compose file generation for yah-cloud service deployments (R040-F7).
2//!
3//! Translates `MachineConfig` + `LegacyServiceConfig[]` into a ready-to-deploy
4//! `ComposeBundle` containing:
5//!
6//! - `compose.yml`: Podman Compose (Docker Compose v3-compatible) stack
7//! - `Caddyfile`: Caddy reverse-proxy config for `mesh_only: false` services
8//!   (omitted when all services are mesh-only)
9//!
10//! ## Tier isolation
11//! Services share a Compose network named after the machine's first `tier:*`
12//! tag (`tier:t2` → network `tier-t2`). Tier isolation at the software layer
13//! (Postgres roles, Headscale ACL tags) is the camp owner's responsibility;
14//! this just scopes the container network so different-tier stacks on the
15//! same host are not bridged together.
16//!
17//! ## Tenant isolation (W206 / R558-T2)
18//! When a machine hosts services from a single tenant (the common, degenerate
19//! case — every [`LegacyServiceConfig::tenant`] is the singleton), the scheme
20//! above is unchanged. When it hosts services from **two or more distinct
21//! tenants**, the network splits per tenant: each service joins
22//! `<tenant>-<tier>` (e.g. `ss-tier-t2`, `noisetable-tier-t2`) so cross-tenant
23//! stacks on the same host are not bridged together. The shared Caddy ingress
24//! joins every tenant network so a single reverse proxy still reaches any
25//! public service. See [`NetworkPlan`].
26//!
27//! ## Caddy reverse proxy
28//! `mesh_only: false` services are exposed through a Caddy container that
29//! Cloudflare orange-cloud (or a Cloudflare Tunnel via R040-F15) terminates
30//! in front of. Caddy is chosen over nginx because it handles TLS from
31//! Cloudflare origin certs without extra config, and its reverse_proxy
32//! directive handles service discovery by container name.
33//!
34//! ## mesh_only services
35//! Services with `mesh_only: true` use `expose:` only (no host-port
36//! binding). They are reachable within the Compose network and, once
37//! R040-F16 lands its `bind_interface` plumbing, directly via Tailscale.
38
39use std::collections::BTreeSet;
40
41use crate::config::{LegacyServiceConfig, MachineConfig};
42use crate::mesh_service;
43
44/// A rendered compose bundle ready to push to the yubaba's `POST /compose`.
45#[derive(Debug, Clone)]
46pub struct ComposeBundle {
47    /// Podman compose YAML (Docker Compose v3-compatible).
48    pub compose_yaml: String,
49    /// Caddy reverse-proxy config — `None` when all services are `mesh_only`.
50    pub caddyfile: Option<String>,
51    /// Shell commands the yubaba must execute after writing compose files —
52    /// typically ufw rules for services that have `bind_interface` set.
53    /// Commands are executed in order via `sh -c`; failures are logged but
54    /// do not abort the deploy (ufw may not be installed on dev machines).
55    pub firewall_cmds: Vec<String>,
56}
57
58/// Generate a `ComposeBundle` for a machine's full service set.
59///
60/// `services` is the union of all declared services across the mirrors the
61/// machine hosts. Pass an optional `public_hostname` (e.g.
62/// `"pdx.cloud.noisetable.example"`) to get a domain-named Caddyfile; omit
63/// it and Caddy falls back to port-based listeners (useful for staging / when
64/// `cloud_domain` isn't set yet in `mirrors/<camp>.toml`).
65pub 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
80/// Derive the primary Compose network name from the machine's tier tag.
81/// Falls back to `"yah-cloud"` when no `tier:*` tag is present.
82fn 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
91/// How a machine's services map onto Compose bridge networks (W206 / R558-T2).
92///
93/// Single-tenant (degenerate) machines keep the historical
94/// one-network-per-machine scheme: every service joins the tier-derived
95/// [`machine_network`] (`tier-t2`, or `yah-cloud` when the machine carries no
96/// `tier:*` tag). When a machine hosts services from **two or more distinct
97/// tenants**, the network is split per tenant — each service joins
98/// `<tenant>-<base>` (e.g. `ss-tier-t2`, `noisetable-tier-t2`) so cross-tenant
99/// stacks on the same host are not bridged together. The shared Caddy ingress
100/// joins every declared network so it can still reach any public service.
101///
102/// The tenant prefix uses the base network (`tier-t2`) as a suffix rather than
103/// replacing it, keeping the tier legible in the network name and the
104/// single-tenant output byte-identical to the pre-T2 renderer.
105struct 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    /// Network the given service joins.
122    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    /// All distinct networks to declare in the compose `networks:` block and
131    /// attach the shared Caddy ingress to. Sorted for deterministic output.
132    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    // Networks block — one entry per declared network (a single tier network
173    // when single-tenant, one `<tenant>-<tier>` network per tenant otherwise).
174    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    // Services with bind_interface use the host network stack directly so they
194    // can bind to a specific interface (e.g. tailscale0). In host mode the
195    // container is not joined to the compose bridge network and expose: is a
196    // no-op, so both are omitted.
197    if let Some(iface) = &svc.bind_interface {
198        out.push_str("    network_mode: \"host\"\n");
199        // Emit env_file so the process picks up POSTGRES_LISTEN_ADDRESSES (or
200        // equivalent) that cloud-init writes at first boot from `tailscale ip`.
201        out.push_str(&format!(
202            "    env_file:\n      - {}\n",
203            mesh_service::MESH_IP_ENV_FILE,
204        ));
205        // Annotate with the pg_hba hint so operators know what to configure.
206        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
244/// Collect ufw firewall commands for all services that declare a `bind_interface`.
245/// Each such service gets an `allow in on <iface> port <port>` + `deny <port>`
246/// pair, mirroring the yubaba-7443 pattern established in `mirror.yml`.
247fn 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    // Caddy joins every tenant network so a single ingress can reverse-proxy
270    // public services regardless of which tenant they belong to.
271    out.push_str("    networks:\n");
272    for net in networks {
273        out.push_str(&format!("      - {net}\n"));
274    }
275    out.push('\n');
276}
277
278/// Build a Caddyfile for non-mesh_only services. Returns `None` when all
279/// services are mesh-only.
280///
281/// When `hostname` is supplied each service gets a named virtual host
282/// (`<hostname>` for the first service, `<service>.<hostname>` for the
283/// rest). Without a hostname Caddy uses `:port` placeholders so the stack
284/// is immediately testable — operators swap in the real domain once
285/// Cloudflare DNS is wired (SECRETS.md).
286fn 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// ── Tests ───────────────────────────────────────────────────────────────────
321
322#[cfg(test)]
323mod tests {
324    use super::*;
325    use crate::config::{BucketSpec, LegacyServiceConfig, MachineConfig, PortMapping}; // PortMapping used in test constructors
326    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        // Two services, both the singleton tenant → one shared tier network,
467        // no per-tenant split (R558-T2 degenerate case is byte-identical).
468        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        // Public (mesh_only=false) services across two tenants → the shared
512        // Caddy ingress must join both tenant networks to reach them.
513        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        // Isolate Caddy's own `networks:` list: it sits between its
520        // `caddy_data:/data` volume line and the top-level `networks:` block.
521        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        // Sorted: ALPHA before ZEBRA
567        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        // First gets the bare hostname, rest get subdomain prefix
612        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    // ── bind_interface (R040-F16) ────────────────────────────────────────────
633
634    #[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        // The "networks:" block at the top level is still present (for caddy etc.),
664        // but the postgres service entry must NOT have a `networks:` directive.
665        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        // The postgres block ends with a blank line; there must not be a "networks:" line
671        // before that blank line.
672        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        // In network_mode:host, expose: is meaningless — ensure it's omitted.
687        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        // 2 rules per service × 2 services = 4
734        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        // A pg service has bind_interface + mesh_only:true — must NOT appear in Caddyfile.
749        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}