use std::collections::BTreeSet;
use crate::config::{LegacyServiceConfig, MachineConfig};
use crate::mesh_service;
#[derive(Debug, Clone)]
pub struct ComposeBundle {
pub compose_yaml: String,
pub caddyfile: Option<String>,
pub firewall_cmds: Vec<String>,
}
pub fn generate_compose_bundle(
machine: &MachineConfig,
services: &[LegacyServiceConfig],
public_hostname: Option<&str>,
) -> ComposeBundle {
let compose_yaml = build_compose_yaml(machine, services);
let caddyfile = build_caddyfile(services, public_hostname);
let firewall_cmds = collect_firewall_cmds(services);
ComposeBundle {
compose_yaml,
caddyfile,
firewall_cmds,
}
}
fn machine_network(machine: &MachineConfig) -> String {
machine
.mesh_tags
.iter()
.find_map(|t| t.strip_prefix("tier:"))
.map(|tier| format!("tier-{tier}"))
.unwrap_or_else(|| "yah-cloud".to_string())
}
struct NetworkPlan {
base: String,
multi_tenant: bool,
}
impl NetworkPlan {
fn derive(machine: &MachineConfig, services: &[LegacyServiceConfig]) -> Self {
let base = machine_network(machine);
let distinct_tenants: BTreeSet<&str> =
services.iter().map(|s| s.tenant.0.as_str()).collect();
NetworkPlan {
base,
multi_tenant: distinct_tenants.len() > 1,
}
}
fn network_for(&self, svc: &LegacyServiceConfig) -> String {
if self.multi_tenant {
format!("{}-{}", svc.tenant.0, self.base)
} else {
self.base.clone()
}
}
fn declared(&self, services: &[LegacyServiceConfig]) -> Vec<String> {
if !self.multi_tenant {
return vec![self.base.clone()];
}
services
.iter()
.map(|s| self.network_for(s))
.collect::<BTreeSet<_>>()
.into_iter()
.collect()
}
}
fn build_compose_yaml(machine: &MachineConfig, services: &[LegacyServiceConfig]) -> String {
let plan = NetworkPlan::derive(machine, services);
let declared = plan.declared(services);
let mut out = String::new();
out.push_str("# Generated by `yah cloud service deploy` — do not edit manually.\n");
out.push_str(&format!(
"# Machine: {} | Location: {}\n",
machine.name,
machine.location()
));
out.push_str("version: \"3.8\"\n\n");
let has_public = services.iter().any(|s| !s.mesh_only);
if !services.is_empty() || has_public {
out.push_str("services:\n");
}
for svc in services {
append_service(&mut out, svc, &plan.network_for(svc));
}
if has_public {
append_caddy_service(&mut out, &declared);
}
out.push_str("networks:\n");
for net in &declared {
out.push_str(&format!(" {net}:\n"));
out.push_str(" driver: bridge\n");
}
if has_public {
out.push_str("\nvolumes:\n");
out.push_str(" caddy_data:\n");
}
out
}
fn append_service(out: &mut String, svc: &LegacyServiceConfig, network: &str) {
out.push_str(&format!(" {}:\n", svc.name));
out.push_str(&format!(" image: {}:{}\n", svc.image, svc.version));
out.push_str(" restart: unless-stopped\n");
if let Some(iface) = &svc.bind_interface {
out.push_str(" network_mode: \"host\"\n");
out.push_str(&format!(
" env_file:\n - {}\n",
mesh_service::MESH_IP_ENV_FILE,
));
out.push_str(&format!(
" # bind_interface={iface}: use host network + tailscale0 IP. \
Apply pg_hba snippet from `yah cloud service recipe postgres`.\n",
));
if !svc.env.is_empty() {
out.push_str(" environment:\n");
let mut pairs: Vec<(&String, &String)> = svc.env.iter().collect();
pairs.sort_by_key(|(k, _)| k.as_str());
for (k, v) in pairs {
let escaped = v.replace('"', "\\\"");
out.push_str(&format!(" {k}: \"{escaped}\"\n"));
}
}
out.push('\n');
return;
}
if !svc.env.is_empty() {
out.push_str(" environment:\n");
let mut pairs: Vec<(&String, &String)> = svc.env.iter().collect();
pairs.sort_by_key(|(k, _)| k.as_str());
for (k, v) in pairs {
let escaped = v.replace('"', "\\\"");
out.push_str(&format!(" {k}: \"{escaped}\"\n"));
}
}
if !svc.ports.is_empty() {
out.push_str(" expose:\n");
for p in &svc.ports {
out.push_str(&format!(" - \"{}\"\n", p.container));
}
}
out.push_str(&format!(" networks:\n - {network}\n\n"));
}
fn collect_firewall_cmds(services: &[LegacyServiceConfig]) -> Vec<String> {
let mut cmds = Vec::new();
for svc in services {
if let Some(iface) = &svc.bind_interface {
for port in &svc.ports {
cmds.extend(mesh_service::ufw_rules_for_mesh_port(iface, port.container));
}
}
}
cmds
}
fn append_caddy_service(out: &mut String, networks: &[String]) {
out.push_str(" caddy:\n");
out.push_str(" image: caddy:2-alpine\n");
out.push_str(" restart: unless-stopped\n");
out.push_str(" ports:\n");
out.push_str(" - \"80:80\"\n");
out.push_str(" - \"443:443\"\n");
out.push_str(" volumes:\n");
out.push_str(" - /etc/yah-cloud/Caddyfile:/etc/caddy/Caddyfile:ro\n");
out.push_str(" - caddy_data:/data\n");
out.push_str(" networks:\n");
for net in networks {
out.push_str(&format!(" - {net}\n"));
}
out.push('\n');
}
fn build_caddyfile(services: &[LegacyServiceConfig], hostname: Option<&str>) -> Option<String> {
let public: Vec<&LegacyServiceConfig> = services.iter().filter(|s| !s.mesh_only).collect();
if public.is_empty() {
return None;
}
let mut out = String::new();
out.push_str("# Generated by `yah cloud service deploy` — do not edit manually.\n");
if hostname.is_none() {
out.push_str("# Set cloud_domain in mirrors/<camp>.toml for named virtual hosts.\n");
out.push_str("# Example: pdx.cloud.noisetable.example { reverse_proxy service:8080 }\n");
}
out.push('\n');
for (i, svc) in public.iter().enumerate() {
let first_port = first_port(svc);
let site = match hostname {
Some(h) if i == 0 => h.to_string(),
Some(h) => format!("{}.{h}", svc.name),
None => format!(":{first_port}"),
};
out.push_str(&format!("{site} {{\n"));
out.push_str(&format!(" reverse_proxy {}:{first_port}\n", svc.name));
out.push_str("}\n\n");
}
Some(out)
}
fn first_port(svc: &LegacyServiceConfig) -> u16 {
svc.ports.first().map(|p| p.container).unwrap_or(80)
}
#[cfg(test)]
mod tests {
use super::*;
use crate::config::{BucketSpec, LegacyServiceConfig, MachineConfig, PortMapping}; use std::collections::HashMap;
fn machine(tags: &[&str]) -> MachineConfig {
MachineConfig {
name: "test-pdx-1".into(),
provider: "hetzner".into(),
location: Some("pdx".into()),
server_type: Some("cpx22".into()),
hosts_mirrors: vec!["noisetable".into()],
mesh_tags: tags.iter().map(|t| t.to_string()).collect(),
region: None,
zone: None,
arch: None,
bucket: Some(BucketSpec {
name: "test-assets-pdx-1".into(),
public_read: false,
}),
vendor: None,
nickname: None,
legacy_hostkey_fingerprint: None,
registration: Default::default(),
ssh_keys: vec![],
cloudflared: None,
hosts_operator_bridge: false,
connect: None,
allocatable: None,
taints: vec![],
sovereign_group: None,
sovereign_role: None,
}
}
fn service(name: &str, mesh_only: bool) -> LegacyServiceConfig {
LegacyServiceConfig {
name: name.into(),
image: format!("ghcr.io/test/{name}"),
version: "v1.0.0".into(),
env: HashMap::new(),
ports: vec![PortMapping {
host: 8080,
container: 8080,
}],
mesh_only,
bind_interface: None,
tenant: workload_spec::TenantId::singleton(),
}
}
fn mesh_bound_service(name: &str, port: u16) -> LegacyServiceConfig {
LegacyServiceConfig {
name: name.into(),
image: format!("ghcr.io/test/{name}"),
version: "v1.0.0".into(),
env: HashMap::new(),
ports: vec![PortMapping {
host: port,
container: port,
}],
mesh_only: true,
bind_interface: Some("tailscale0".into()),
tenant: workload_spec::TenantId::singleton(),
}
}
#[test]
fn mesh_only_service_has_no_caddy() {
let m = machine(&["tier:t2"]);
let svcs = [service("asset-registry", true)];
let bundle = generate_compose_bundle(&m, &svcs, None);
assert!(
!bundle.compose_yaml.contains("caddy:"),
"caddy should not appear"
);
assert!(bundle.caddyfile.is_none(), "no Caddyfile for mesh-only");
}
#[test]
fn public_service_includes_caddy_and_caddyfile() {
let m = machine(&["tier:t2"]);
let svcs = [service("asset-registry", false)];
let bundle = generate_compose_bundle(&m, &svcs, None);
assert!(
bundle.compose_yaml.contains("caddy:"),
"caddy missing from compose"
);
assert!(
bundle.compose_yaml.contains("caddy_data:"),
"volume missing"
);
assert!(
bundle.caddyfile.is_some(),
"Caddyfile expected for public service"
);
}
#[test]
fn tier_tag_becomes_network_name() {
let m = machine(&["region:pdx", "tier:t2"]);
let svcs: [LegacyServiceConfig; 0] = [];
let bundle = generate_compose_bundle(&m, &svcs, None);
assert!(
bundle.compose_yaml.contains("tier-t2:"),
"network name mismatch"
);
assert!(
!bundle.compose_yaml.contains("yah-cloud:"),
"fallback network present"
);
}
#[test]
fn no_tier_tag_falls_back_to_yah_cloud_network() {
let m = machine(&["region:pdx"]);
let svcs: [LegacyServiceConfig; 0] = [];
let bundle = generate_compose_bundle(&m, &svcs, None);
assert!(
bundle.compose_yaml.contains("yah-cloud:"),
"fallback network missing"
);
}
fn tenant_service(name: &str, tenant: &str, mesh_only: bool) -> LegacyServiceConfig {
LegacyServiceConfig {
name: name.into(),
image: format!("ghcr.io/test/{name}"),
version: "v1.0.0".into(),
env: HashMap::new(),
ports: vec![PortMapping {
host: 8080,
container: 8080,
}],
mesh_only,
bind_interface: None,
tenant: workload_spec::TenantId(tenant.into()),
}
}
#[test]
fn single_tenant_keeps_one_shared_network() {
let m = machine(&["tier:t2"]);
let svcs = [service("a", true), service("b", true)];
let yaml = generate_compose_bundle(&m, &svcs, None).compose_yaml;
assert!(
yaml.contains("tier-t2:"),
"shared tier network expected:\n{yaml}"
);
assert!(
!yaml.contains("-tier-t2:"),
"no tenant-prefixed network when single-tenant:\n{yaml}"
);
}
#[test]
fn multi_tenant_splits_into_per_tenant_networks() {
let m = machine(&["tier:t2"]);
let svcs = [
tenant_service("yah-api", "ss", true),
tenant_service("nt-api", "noisetable", true),
];
let yaml = generate_compose_bundle(&m, &svcs, None).compose_yaml;
assert!(yaml.contains("ss-tier-t2:"), "ss network missing:\n{yaml}");
assert!(
yaml.contains("noisetable-tier-t2:"),
"noisetable network missing:\n{yaml}"
);
assert!(
yaml.contains(" - ss-tier-t2\n"),
"yah-api should join the ss network:\n{yaml}"
);
assert!(
yaml.contains(" - noisetable-tier-t2\n"),
"nt-api should join the noisetable network:\n{yaml}"
);
assert!(
!yaml.contains(" - tier-t2\n"),
"no service joins the bare tier network when multi-tenant:\n{yaml}"
);
}
#[test]
fn multi_tenant_caddy_joins_every_tenant_network() {
let m = machine(&["tier:t2"]);
let svcs = [
tenant_service("yah-web", "ss", false),
tenant_service("nt-web", "noisetable", false),
];
let yaml = generate_compose_bundle(&m, &svcs, None).compose_yaml;
assert!(yaml.contains("caddy:"), "caddy present for public services");
let after_caddy_vol = yaml
.split("- caddy_data:/data")
.nth(1)
.expect("caddy volumes");
let caddy_nets = after_caddy_vol.split("\nnetworks:").next().unwrap();
assert!(
caddy_nets.contains("- ss-tier-t2"),
"caddy joins ss network:\n{yaml}"
);
assert!(
caddy_nets.contains("- noisetable-tier-t2"),
"caddy joins noisetable network:\n{yaml}"
);
}
#[test]
fn image_includes_version() {
let m = machine(&[]);
let svcs = [service("asset-registry", true)];
let bundle = generate_compose_bundle(&m, &svcs, None);
assert!(bundle
.compose_yaml
.contains("ghcr.io/test/asset-registry:v1.0.0"));
}
#[test]
fn env_vars_are_rendered_sorted() {
let m = machine(&[]);
let mut env = HashMap::new();
env.insert("ZEBRA".into(), "last".into());
env.insert("ALPHA".into(), "first".into());
let svc = LegacyServiceConfig {
name: "myservice".into(),
image: "img".into(),
version: "v1".into(),
env,
ports: vec![],
mesh_only: true,
bind_interface: None,
tenant: workload_spec::TenantId::singleton(),
};
let bundle = generate_compose_bundle(&m, &[svc], None);
let yaml = &bundle.compose_yaml;
assert!(yaml.contains("ALPHA: \"first\""), "ALPHA missing");
assert!(yaml.contains("ZEBRA: \"last\""), "ZEBRA missing");
let alpha_pos = yaml.find("ALPHA").unwrap();
let zebra_pos = yaml.find("ZEBRA").unwrap();
assert!(alpha_pos < zebra_pos, "env vars not sorted");
}
#[test]
fn ports_become_expose() {
let m = machine(&[]);
let svcs = [service("svc", true)];
let bundle = generate_compose_bundle(&m, &svcs, None);
assert!(bundle.compose_yaml.contains("expose:\n - \"8080\""));
}
#[test]
fn caddyfile_uses_hostname_when_provided() {
let m = machine(&[]);
let svcs = [service("api", false)];
let bundle = generate_compose_bundle(&m, &svcs, Some("pdx.cloud.example.com"));
let cf = bundle.caddyfile.unwrap();
assert!(
cf.contains("pdx.cloud.example.com {"),
"hostname missing from Caddyfile"
);
assert!(
cf.contains("reverse_proxy api:8080"),
"reverse_proxy missing"
);
}
#[test]
fn caddyfile_uses_port_placeholder_when_no_hostname() {
let m = machine(&[]);
let svcs = [service("api", false)];
let bundle = generate_compose_bundle(&m, &svcs, None);
let cf = bundle.caddyfile.unwrap();
assert!(cf.contains(":8080 {"), "port placeholder missing");
}
#[test]
fn multiple_public_services_get_subdomains() {
let m = machine(&[]);
let svcs = [service("api", false), service("admin", false)];
let bundle = generate_compose_bundle(&m, &svcs, Some("pdx.cloud.example.com"));
let cf = bundle.caddyfile.unwrap();
assert!(cf.contains("pdx.cloud.example.com {"));
assert!(cf.contains("admin.pdx.cloud.example.com {"));
}
#[test]
fn mixed_services_only_routes_public_in_caddyfile() {
let m = machine(&[]);
let svcs = [service("public-svc", false), service("mesh-svc", true)];
let bundle = generate_compose_bundle(&m, &svcs, None);
let cf = bundle.caddyfile.as_deref().unwrap();
assert!(
cf.contains("public-svc"),
"public service missing from Caddyfile"
);
assert!(
!cf.contains("mesh-svc"),
"mesh-only service leaked into Caddyfile"
);
}
#[test]
fn bind_interface_emits_network_mode_host() {
let m = machine(&["tier:t2"]);
let svcs = [mesh_bound_service("postgres", 5432)];
let bundle = generate_compose_bundle(&m, &svcs, None);
assert!(
bundle.compose_yaml.contains("network_mode: \"host\""),
"host mode missing:\n{}",
bundle.compose_yaml,
);
}
#[test]
fn bind_interface_emits_env_file_for_mesh_ip() {
let m = machine(&[]);
let svcs = [mesh_bound_service("postgres", 5432)];
let bundle = generate_compose_bundle(&m, &svcs, None);
assert!(
bundle.compose_yaml.contains(mesh_service::MESH_IP_ENV_FILE),
"env_file missing from compose yaml:\n{}",
bundle.compose_yaml,
);
}
#[test]
fn bind_interface_service_not_in_bridge_network() {
let m = machine(&["tier:t2"]);
let svcs = [mesh_bound_service("postgres", 5432)];
let bundle = generate_compose_bundle(&m, &svcs, None);
let svc_block_end = bundle
.compose_yaml
.find("network_mode: \"host\"")
.expect("network_mode:host missing");
let after = &bundle.compose_yaml[svc_block_end..];
let blank = after.find("\n\n").unwrap_or(after.len());
let postgres_block = &after[..blank];
assert!(
!postgres_block.contains("networks:\n -"),
"bind_interface service should not be added to the bridge network:\n{}",
postgres_block,
);
}
#[test]
fn bind_interface_service_has_no_expose_block() {
let m = machine(&[]);
let svcs = [mesh_bound_service("postgres", 5432)];
let bundle = generate_compose_bundle(&m, &svcs, None);
let svc_start = bundle.compose_yaml.find(" postgres:").unwrap();
let svc_end = bundle.compose_yaml[svc_start..]
.find("\n\n")
.map(|i| svc_start + i)
.unwrap_or(bundle.compose_yaml.len());
let block = &bundle.compose_yaml[svc_start..svc_end];
assert!(
!block.contains("expose:"),
"expose: must be omitted in host mode:\n{block}"
);
}
#[test]
fn bind_interface_populates_firewall_cmds() {
let m = machine(&[]);
let svcs = [mesh_bound_service("postgres", 5432)];
let bundle = generate_compose_bundle(&m, &svcs, None);
assert_eq!(
bundle.firewall_cmds.len(),
2,
"expected 2 ufw rules: {:?}",
bundle.firewall_cmds
);
assert!(bundle.firewall_cmds[0].contains("allow in on tailscale0 to any port 5432"));
assert!(bundle.firewall_cmds[1].contains("deny 5432"));
}
#[test]
fn no_bind_interface_produces_empty_firewall_cmds() {
let m = machine(&[]);
let svcs = [service("api", false), service("worker", true)];
let bundle = generate_compose_bundle(&m, &svcs, None);
assert!(
bundle.firewall_cmds.is_empty(),
"no bind_interface → no firewall cmds"
);
}
#[test]
fn multiple_bound_services_accumulate_firewall_cmds() {
let m = machine(&[]);
let svcs = [
mesh_bound_service("postgres", 5432),
mesh_bound_service("nats", 4222),
];
let bundle = generate_compose_bundle(&m, &svcs, None);
assert_eq!(
bundle.firewall_cmds.len(),
4,
"unexpected rules: {:?}",
bundle.firewall_cmds
);
let all = bundle.firewall_cmds.join("\n");
assert!(all.contains("5432"), "postgres rules missing");
assert!(all.contains("4222"), "nats rules missing");
}
#[test]
fn bind_interface_service_excluded_from_caddyfile() {
let m = machine(&[]);
let svcs = [mesh_bound_service("postgres", 5432), service("api", false)];
let bundle = generate_compose_bundle(&m, &svcs, None);
let cf = bundle.caddyfile.as_deref().unwrap();
assert!(
!cf.contains("postgres"),
"bound service must not be in Caddyfile"
);
assert!(cf.contains("api"), "public service missing from Caddyfile");
}
}