flow-iron 0.5.0

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, Runner};
use crate::runner;
use crate::ssh::SshPool;
use crate::ui;

pub async fn run(fleet: &Fleet, app_filter: Option<&str>, force: bool) -> Result<()> {
    let (apps, runners): (Vec<&ResolvedApp>, Vec<(&str, &Runner)>) = if let Some(name) = app_filter
    {
        if let Some(app) = fleet.apps.get(name) {
            (vec![app], vec![])
        } else if let Some(runner) = fleet.runners.get(name) {
            (vec![], vec![(name, runner)])
        } else {
            bail!("Unknown app or runner: {name}");
        }
    } else {
        let apps = fleet.apps.values().collect();
        let runners = fleet.runners.iter().map(|(k, v)| (k.as_str(), v)).collect();
        (apps, runners)
    };

    let mut needed_servers: std::collections::HashSet<_> =
        apps.iter().flat_map(|a| a.servers.iter()).collect();
    for (_, r) in &runners {
        needed_servers.insert(&r.server);
    }

    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.gh_username, &fleet.secrets.gh_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?;
    }

    for (name, r) in &runners {
        deploy_runner(fleet, name, r, &pool).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(())
}

async fn deploy_runner(fleet: &Fleet, name: &str, r: &Runner, pool: &SshPool) -> Result<()> {
    let gh_token = fleet
        .secrets
        .gh_token
        .as_deref()
        .ok_or_else(|| anyhow::anyhow!("gh_token not set — run `flow login gh`"))?;

    println!();
    ui::header(&format!("Deploying runner-{name}"));

    let compose_yaml = runner::generate_compose(name, r);
    let env_content = runner::generate_env(gh_token);
    let runner_dir = format!("/opt/flow/runner-{name}");

    let sp = ui::spinner(&format!("  {} → uploading files...", r.server));
    pool.exec(&r.server, &format!("mkdir -p {runner_dir}"))
        .await?;
    pool.upload_file(
        &r.server,
        &format!("{runner_dir}/docker-compose.yml"),
        &compose_yaml,
    )
    .await?;
    pool.upload_file(&r.server, &format!("{runner_dir}/.env"), &env_content)
        .await?;
    pool.exec(&r.server, &format!("chmod 600 {runner_dir}/.env"))
        .await?;
    sp.finish_and_clear();

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

    let sp = ui::spinner(&format!("  {} → deploying...", r.server));
    pool.exec(
        &r.server,
        &format!("cd {runner_dir} && docker compose up -d"),
    )
    .await?;
    sp.finish_and_clear();

    ui::success(&format!("  {} → runner-{}", r.server, name));
    Ok(())
}