flow-iron 0.4.3

Infrastructure-as-code CLI — deploy Docker Compose apps with Caddy reverse proxy and Cloudflare DNS
Documentation
use anyhow::{Context, Result, bail};
use serde::Deserialize;
use std::collections::{HashMap, HashSet};
use std::net::Ipv4Addr;
use std::path::Path;

#[derive(Debug, Deserialize)]
pub struct FleetConfig {
    pub domain: Option<String>,
    pub ssh_key: Option<String>,
    #[serde(default = "default_network")]
    pub network: String,
    #[serde(default)]
    pub servers: HashMap<String, Server>,
    #[serde(default)]
    pub apps: HashMap<String, App>,
}

fn default_network() -> String {
    "flow".to_string()
}

#[derive(Debug, Deserialize, Clone)]
#[serde(deny_unknown_fields)]
pub struct Server {
    pub host: String,
    pub ip: Option<String>,
    #[serde(default = "default_user")]
    pub user: String,
    pub ssh_key: Option<String>,
}

fn default_user() -> String {
    "deploy".to_string()
}

#[derive(Debug, Deserialize, Clone)]
#[serde(deny_unknown_fields)]
pub struct App {
    pub image: String,
    #[serde(default)]
    pub servers: Vec<String>,
    pub port: Option<u16>,
    #[serde(default)]
    pub deploy_strategy: DeployStrategy,
    #[serde(default)]
    pub routing: Option<Routing>,
    #[serde(default)]
    pub services: Vec<Sidecar>,
    #[serde(default)]
    pub ports: Vec<PortMapping>,
}

#[derive(Debug, Deserialize, Clone, Default, PartialEq)]
#[serde(rename_all = "lowercase")]
pub enum DeployStrategy {
    #[default]
    Rolling,
    Recreate,
}

#[derive(Debug, Deserialize, Clone)]
#[serde(deny_unknown_fields)]
pub struct Routing {
    #[serde(default)]
    pub routes: Vec<String>,
    pub health_path: Option<String>,
    pub health_interval: Option<String>,
}

#[derive(Debug, Deserialize, Clone)]
#[serde(deny_unknown_fields)]
pub struct Sidecar {
    pub name: String,
    pub image: String,
    #[serde(default)]
    pub volumes: Vec<String>,
    pub healthcheck: Option<String>,
    pub depends_on: Option<String>,
}

#[derive(Debug, Deserialize, Clone)]
#[serde(deny_unknown_fields)]
pub struct PortMapping {
    pub internal: u16,
    pub external: u16,
    #[serde(default = "default_protocol")]
    pub protocol: String,
}

fn default_protocol() -> String {
    "tcp".to_string()
}

#[derive(Debug, Deserialize, Default)]
pub struct EnvConfig {
    #[serde(default)]
    pub apps: HashMap<String, AppEnv>,
    #[serde(default)]
    pub fleet: FleetSecrets,
}

#[derive(Debug, Deserialize, Default, Clone)]
pub struct AppEnv {
    #[serde(flatten)]
    pub env: HashMap<String, toml::Value>,
    #[serde(default)]
    pub services: HashMap<String, HashMap<String, String>>,
}

#[derive(Debug, Deserialize, Default, Clone)]
pub struct FleetSecrets {
    pub ghcr_token: Option<String>,
    pub ghcr_username: Option<String>,
    pub cloudflare_api_token: Option<String>,
}

#[derive(Debug)]
pub struct Fleet {
    pub domain: Option<String>,
    pub network: String,
    pub servers: HashMap<String, Server>,
    pub apps: HashMap<String, ResolvedApp>,
    pub secrets: FleetSecrets,
}

#[derive(Debug, Clone)]
pub struct ResolvedApp {
    pub name: String,
    pub image: String,
    pub servers: Vec<String>,
    pub port: Option<u16>,
    pub deploy_strategy: DeployStrategy,
    pub routing: Option<Routing>,
    pub env: HashMap<String, String>,
    pub services: Vec<ResolvedSidecar>,
    pub ports: Vec<PortMapping>,
}

#[derive(Debug, Clone)]
pub struct ResolvedSidecar {
    pub name: String,
    pub image: String,
    pub volumes: Vec<String>,
    pub env: HashMap<String, String>,
    pub healthcheck: Option<String>,
    pub depends_on: Option<String>,
}

fn is_valid_caddy_duration(s: &str) -> bool {
    for suffix in &["ms", "s", "m", "h", "d"] {
        if let Some(num_part) = s.strip_suffix(suffix) {
            return !num_part.is_empty() && num_part.parse::<f64>().is_ok();
        }
    }
    false
}

fn validate(config: &FleetConfig) -> Result<()> {
    for (server_name, server) in &config.servers {
        if let Some(ref ip) = server.ip {
            if ip.parse::<Ipv4Addr>().is_err() {
                bail!("Server '{server_name}' has invalid IP '{ip}'");
            }
        }
    }

    let mut all_routes: Vec<(&str, &str)> = Vec::new();

    for (app_name, app) in &config.apps {
        if app.servers.is_empty() {
            bail!("App '{app_name}' has no servers");
        }

        if app.image.is_empty() {
            bail!("App '{app_name}' has an empty image");
        }

        if app.routing.is_some() && app.port.is_none() {
            bail!("App '{app_name}' has routing but no port");
        }

        if !app.ports.is_empty() && app.routing.is_some() {
            bail!("App '{app_name}' has both routing and ports (mutually exclusive)");
        }

        if let Some(port) = app.port {
            if port == 0 {
                bail!("App '{app_name}' has invalid port 0");
            }
        }
        for pm in &app.ports {
            if pm.internal == 0 || pm.external == 0 {
                bail!("App '{app_name}' has invalid port 0");
            }
            if pm.protocol != "tcp" && pm.protocol != "udp" {
                bail!(
                    "App '{app_name}' has invalid port protocol '{}' (must be tcp or udp)",
                    pm.protocol
                );
            }
        }

        if let Some(ref routing) = app.routing {
            for route in &routing.routes {
                if route.is_empty() {
                    bail!("App '{app_name}' has an empty route");
                }
                if route.contains(char::is_whitespace) {
                    bail!("App '{app_name}' has route '{route}' containing whitespace");
                }
                if route.contains("://") {
                    bail!(
                        "App '{app_name}' has route '{route}' with protocol prefix (use bare hostname)"
                    );
                }
                if !route.contains('.') {
                    bail!(
                        "App '{app_name}' has route '{route}' with no domain (expected hostname like example.com)"
                    );
                }
                all_routes.push((route, app_name));
            }
            if let Some(ref health_path) = routing.health_path {
                if !health_path.starts_with('/') {
                    bail!(
                        "App '{app_name}' has invalid health_path '{health_path}' (must start with /)"
                    );
                }
            }
            if let Some(ref health_interval) = routing.health_interval {
                if !is_valid_caddy_duration(health_interval) {
                    bail!(
                        "App '{app_name}' has invalid health_interval '{health_interval}' (expected format: 5s, 1m, 500ms)"
                    );
                }
            }
        }

        let sidecar_names: Vec<&str> = app.services.iter().map(|s| s.name.as_str()).collect();
        let mut seen_sidecar_names: HashSet<&str> = HashSet::new();
        for name in &sidecar_names {
            if !seen_sidecar_names.insert(name) {
                bail!("App '{app_name}' has duplicate service name '{name}'");
            }
        }
        for svc in &app.services {
            if svc.image.is_empty() {
                bail!(
                    "Service '{}' in app '{}' has an empty image",
                    svc.name,
                    app_name
                );
            }
            if let Some(ref dep) = svc.depends_on {
                if !sidecar_names.contains(&dep.as_str()) {
                    bail!(
                        "Service '{}' in app '{}' depends on '{}' which doesn't exist",
                        svc.name,
                        app_name,
                        dep
                    );
                }
            }
        }
    }

    let mut seen_routes: HashMap<&str, &str> = HashMap::new();
    for (route, app_name) in &all_routes {
        if let Some(other_app) = seen_routes.get(route) {
            bail!("Duplicate route '{route}' in apps '{other_app}' and '{app_name}'");
        }
        seen_routes.insert(route, app_name);
    }

    Ok(())
}

pub fn load(config_path: &str) -> Result<Fleet> {
    let config_path = Path::new(config_path);
    let content = std::fs::read_to_string(config_path)
        .with_context(|| format!("Failed to read {}", config_path.display()))?;
    let config: FleetConfig = toml::from_str(&content)
        .with_context(|| format!("Failed to parse {}", config_path.display()))?;

    let env_path = config_path.with_file_name("fleet.env.toml");
    let env_config: EnvConfig = if env_path.exists() {
        let env_content = std::fs::read_to_string(&env_path)
            .with_context(|| format!("Failed to read {}", env_path.display()))?;
        toml::from_str(&env_content)
            .with_context(|| format!("Failed to parse {}", env_path.display()))?
    } else {
        EnvConfig::default()
    };

    for (app_name, app) in &config.apps {
        for server in &app.servers {
            if !config.servers.contains_key(server) {
                bail!("App '{app_name}' references unknown server '{server}'");
            }
        }
    }

    validate(&config)?;

    let mut resolved_apps = HashMap::new();
    for (name, app) in config.apps {
        let mut env = HashMap::new();

        if let Some(app_env) = env_config.apps.get(&name) {
            for (k, v) in &app_env.env {
                if let toml::Value::String(s) = v {
                    env.insert(k.clone(), s.clone());
                }
            }
        }

        let resolved_services: Vec<ResolvedSidecar> = app
            .services
            .iter()
            .map(|svc| {
                let mut svc_env = HashMap::new();
                if let Some(app_env) = env_config.apps.get(&name) {
                    if let Some(svc_env_vals) = app_env.services.get(&svc.name) {
                        for (k, v) in svc_env_vals {
                            svc_env.insert(k.clone(), v.clone());
                        }
                    }
                }
                ResolvedSidecar {
                    name: svc.name.clone(),
                    image: svc.image.clone(),
                    volumes: svc.volumes.clone(),
                    env: svc_env,
                    healthcheck: svc.healthcheck.clone(),
                    depends_on: svc.depends_on.clone(),
                }
            })
            .collect();

        resolved_apps.insert(
            name.clone(),
            ResolvedApp {
                name: name.clone(),
                image: app.image,
                servers: app.servers,
                port: app.port,
                deploy_strategy: app.deploy_strategy,
                routing: app.routing,
                env,
                services: resolved_services,
                ports: app.ports,
            },
        );
    }

    Ok(Fleet {
        domain: config.domain,
        network: config.network,
        servers: config.servers,
        apps: resolved_apps,
        secrets: env_config.fleet,
    })
}