flow-iron 0.4.12

Infrastructure-as-code CLI — deploy Docker Compose apps with Caddy reverse proxy and Cloudflare DNS
Documentation
use anyhow::{Result, bail};

use crate::caddy;
use crate::cloudflare;
use crate::compose;
use crate::config::{DeployStrategy, Fleet, ResolvedApp};
use crate::ssh::SshPool;
use crate::ui;

pub async fn run(fleet: &Fleet, app_filter: Option<&str>, force: bool) -> Result<()> {
    let apps: Vec<&ResolvedApp> = if let Some(name) = app_filter {
        let app = fleet
            .apps
            .get(name)
            .ok_or_else(|| anyhow::anyhow!("Unknown app: {name}"))?;
        vec![app]
    } else {
        fleet.apps.values().collect()
    };

    let needed_servers: std::collections::HashSet<_> =
        apps.iter().flat_map(|a| a.servers.iter()).collect();

    let servers_to_connect: std::collections::HashMap<_, _> = fleet
        .servers
        .iter()
        .filter(|(name, _)| needed_servers.contains(name))
        .map(|(k, v)| (k.clone(), v.clone()))
        .collect();

    let sp = ui::spinner("Connecting to servers...");
    let pool = SshPool::connect(&servers_to_connect).await?;
    sp.finish_and_clear();

    let sp = ui::spinner("Ensuring Docker network...");
    for server_name in &needed_servers {
        pool.exec(
            server_name,
            &format!(
                "docker network create {} 2>/dev/null || true",
                fleet.network
            ),
        )
        .await?;
    }
    sp.finish_and_clear();

    if let (Some(username), Some(token)) = (&fleet.secrets.ghcr_username, &fleet.secrets.ghcr_token)
    {
        let sp = ui::spinner("Logging in to GHCR...");
        for server_name in &needed_servers {
            pool.exec(
                server_name,
                &format!("echo '{token}' | docker login ghcr.io -u {username} --password-stdin"),
            )
            .await?;
        }
        sp.finish_and_clear();
    }

    for app in &apps {
        deploy_app(fleet, app, &pool, force).await?;
    }

    pool.close().await?;
    ui::success("Deploy complete");
    Ok(())
}

async fn deploy_app(fleet: &Fleet, app: &ResolvedApp, pool: &SshPool, force: bool) -> Result<()> {
    if app.servers.is_empty() {
        bail!("App '{}' has no servers assigned", app.name);
    }

    println!();
    ui::header(&format!("Deploying {}", app.name));

    let compose_yaml = compose::generate(app, &fleet.network);
    let env_content = compose::generate_env(app);
    let caddy_fragment = caddy::generate(app);

    for server_name in &app.servers {
        let sp = ui::spinner(&format!("  {server_name} → uploading files..."));

        let app_dir = format!("/opt/flow/{}", app.name);

        pool.exec(server_name, &format!("mkdir -p {app_dir}"))
            .await?;

        let compose_path = format!("{app_dir}/docker-compose.yml");
        pool.upload_file(server_name, &compose_path, &compose_yaml)
            .await?;

        if !env_content.trim().is_empty() {
            let env_path = format!("{app_dir}/.env");
            pool.upload_file(server_name, &env_path, &env_content)
                .await?;
            pool.exec(server_name, &format!("chmod 600 {env_path}"))
                .await?;
        }

        sp.finish_and_clear();

        let sp = ui::spinner(&format!("  {server_name} → pulling images..."));
        pool.exec(server_name, &format!("cd {app_dir} && docker compose pull"))
            .await?;
        sp.finish_and_clear();

        let sp = ui::spinner(&format!("  {server_name} → deploying..."));
        if force {
            pool.exec(
                server_name,
                &format!("cd {app_dir} && docker compose up -d --force-recreate"),
            )
            .await?;
        } else {
            match app.deploy_strategy {
                DeployStrategy::Rolling => {
                    pool.exec(
                        server_name,
                        &format!("cd {app_dir} && docker compose up -d"),
                    )
                    .await?;
                    pool.exec(
                        server_name,
                        &format!(
                            "docker rollout {} -f {}/docker-compose.yml",
                            app.name, app_dir
                        ),
                    )
                    .await?;
                }
                DeployStrategy::Recreate => {
                    pool.exec(
                        server_name,
                        &format!("cd {app_dir} && docker compose up -d"),
                    )
                    .await?;
                }
            }
        }
        sp.finish_and_clear();

        if let Some(ref fragment) = caddy_fragment {
            let sp = ui::spinner(&format!("  {server_name} → updating Caddy..."));
            let caddy_sites_dir = "/opt/flow/caddy/sites";
            pool.exec(server_name, &format!("mkdir -p {caddy_sites_dir}"))
                .await?;
            let caddy_path = format!("{}/{}", caddy_sites_dir, app.name);
            pool.upload_file(server_name, &caddy_path, fragment).await?;
            pool.exec(
                server_name,
                "cd /opt/flow/caddy && docker compose exec caddy caddy reload --config /etc/caddy/Caddyfile",
            )
            .await?;
            sp.finish_and_clear();
        }

        ui::success(&format!("  {}{}", server_name, app.name));
    }

    if let Some(ref routing) = app.routing {
        if !routing.domains.is_empty() {
            if let Some(ref cf_token) = fleet.secrets.cloudflare_api_token {
                let sp = ui::spinner("  Ensuring DNS records...");
                for server_name in &app.servers {
                    let server = &fleet.servers[server_name];
                    let server_ip = match &server.ip {
                        Some(ip) => ip.clone(),
                        None => pool
                            .exec(server_name, "hostname -I | awk '{print $1}'")
                            .await?
                            .trim()
                            .to_string(),
                    };

                    for domain in &routing.domains {
                        cloudflare::ensure_dns_record(cf_token, domain, &server_ip).await?;
                    }
                }
                sp.finish_and_clear();
                ui::success("  DNS records ensured");
            }
        }
    }

    Ok(())
}