flow-iron 0.1.0

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

use crate::cli::ServerCommand;
use crate::config::{EnvConfig, FleetConfig, Server};
use crate::ssh::SshPool;
use crate::ui;

fn expand_tilde(path: &str) -> String {
    if let Some(rest) = path.strip_prefix("~/") {
        if let Ok(home) = std::env::var("HOME") {
            return format!("{home}/{rest}");
        }
    }
    path.to_string()
}

fn resolve_ssh_key(server_key: Option<&str>, fleet_key: Option<&str>) -> Result<String> {
    if let Some(key) = server_key {
        let expanded = expand_tilde(key);
        if !Path::new(&expanded).exists() {
            bail!("SSH public key not found: {expanded}");
        }
        return Ok(expanded);
    }

    if let Some(key) = fleet_key {
        let expanded = expand_tilde(key);
        if !Path::new(&expanded).exists() {
            bail!("SSH public key not found: {expanded}");
        }
        return Ok(expanded);
    }

    let home = std::env::var("HOME").context("HOME not set")?;
    let ed25519 = format!("{home}/.ssh/id_ed25519.pub");
    if Path::new(&ed25519).exists() {
        return Ok(ed25519);
    }

    let rsa = format!("{home}/.ssh/id_rsa.pub");
    if Path::new(&rsa).exists() {
        return Ok(rsa);
    }

    bail!(
        "No SSH public key found. Provide ssh_key in fleet.toml, use --ssh-key, \
         or ensure ~/.ssh/id_ed25519.pub or ~/.ssh/id_rsa.pub exists"
    )
}

pub async fn run(config_path: &str, command: ServerCommand) -> Result<()> {
    match command {
        ServerCommand::Add {
            name,
            ip,
            host,
            user,
            ssh_user,
            ssh_key,
        } => {
            add(
                config_path,
                &name,
                &ip,
                host.as_deref(),
                &user,
                &ssh_user,
                ssh_key.as_deref(),
            )
            .await
        }
        ServerCommand::Remove { name } => remove(config_path, &name),
        ServerCommand::Check { name } => check(config_path, name.as_deref()).await,
    }
}

async fn add(
    config_path: &str,
    name: &str,
    ip: &str,
    host_override: Option<&str>,
    user: &str,
    ssh_user: &str,
    cli_ssh_key: Option<&str>,
) -> Result<()> {
    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()))?;

    if config.servers.contains_key(name) {
        bail!("Server '{name}' already exists");
    }

    let hostname = if let Some(h) = host_override {
        h.to_string()
    } else {
        let domain = config.domain.as_deref().ok_or_else(|| {
            anyhow::anyhow!(
                "Cannot derive hostname: no 'domain' in fleet.toml (use --host to specify)"
            )
        })?;
        format!("{name}.{domain}")
    };

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

    let cf_token = cf_token.ok_or_else(|| {
        anyhow::anyhow!("Cannot create DNS record: cloudflare_api_token not set in fleet.env.toml")
    })?;

    let sp = ui::spinner(&format!("Creating DNS record {hostname}{ip}..."));
    crate::cloudflare::ensure_dns_record(&cf_token, &hostname, ip).await?;
    sp.finish_and_clear();
    ui::success(&format!("{hostname}{ip}"));

    let ansible_dir = config_path
        .parent()
        .unwrap_or(Path::new("."))
        .join("ansible");
    if !ansible_dir.join("setup.yml").exists() {
        bail!(
            "Ansible playbook not found at {}",
            ansible_dir.join("setup.yml").display()
        );
    }

    let resolved_key = resolve_ssh_key(cli_ssh_key, config.ssh_key.as_deref())?;

    ui::header("Ansible setup");
    let mut cmd = tokio::process::Command::new("ansible-playbook");
    cmd.arg("ansible/setup.yml")
        .arg("-i")
        .arg(format!("{ip},"))
        .arg("-u")
        .arg(ssh_user)
        .arg("-e")
        .arg(format!("ssh_pub_key_path={resolved_key}"))
        .current_dir(config_path.parent().unwrap_or(Path::new(".")));

    if let Some(ref token) = ghcr_token {
        cmd.arg("-e").arg(format!("ghcr_token={token}"));
    }

    let status = cmd
        .status()
        .await
        .context("Failed to run ansible-playbook")?;

    if !status.success() {
        bail!("Ansible setup failed (exit code: {status})");
    }

    write_server_to_config(config_path, name, &hostname, ip, user, cli_ssh_key)?;
    ui::success(&format!("Server '{name}' added and bootstrapped"));
    Ok(())
}

fn remove(config_path: &str, name: &str) -> Result<()> {
    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()))?;

    if !config.servers.contains_key(name) {
        bail!("Server '{name}' does not exist");
    }

    let referencing_apps: Vec<&String> = config
        .apps
        .iter()
        .filter(|(_, app)| app.servers.contains(&name.to_string()))
        .map(|(app_name, _)| app_name)
        .collect();

    if !referencing_apps.is_empty() {
        let app_list: Vec<&str> = referencing_apps.iter().map(|s| s.as_str()).collect();
        bail!(
            "Cannot remove server '{}': referenced by apps: {}",
            name,
            app_list.join(", ")
        );
    }

    remove_server_from_config(config_path, name)?;
    ui::success(&format!("Server '{name}' removed"));
    Ok(())
}

async fn check(config_path: &str, name: Option<&str>) -> Result<()> {
    let fleet = crate::config::load(config_path)?;

    let servers_to_check: Vec<(String, Server)> = if let Some(name) = name {
        let server = fleet
            .servers
            .get(name)
            .with_context(|| format!("Server '{name}' not found"))?;
        vec![(name.to_string(), server.clone())]
    } else {
        fleet
            .servers
            .iter()
            .map(|(k, v)| (k.clone(), v.clone()))
            .collect()
    };

    for (name, server) in &servers_to_check {
        let display = match &server.ip {
            Some(ip) => format!("Server: {} ({} / {})", name, server.host, ip),
            None => format!("Server: {} ({})", name, server.host),
        };
        ui::header(&display);

        let pool = match SshPool::connect_one(name, server).await {
            Ok(pool) => {
                ui::success("SSH connection");
                pool
            }
            Err(e) => {
                ui::error(&format!("SSH connection: {e}"));
                continue;
            }
        };

        run_check(
            &pool,
            name,
            "Docker running",
            "docker info --format '{{.ServerVersion}}'",
        )
        .await;
        run_check(
            &pool,
            name,
            "docker-rollout",
            "test -x /usr/libexec/docker/cli-plugins/docker-rollout",
        )
        .await;
        run_check(&pool, name, "Deploy directory", "test -d /opt/flow").await;
        run_check(
            &pool,
            name,
            "Docker network",
            &format!(
                "docker network inspect {} --format '{{{{.Name}}}}'",
                fleet.network
            ),
        )
        .await;

        let _ = pool.close().await;
    }

    Ok(())
}

async fn run_check(pool: &SshPool, server: &str, label: &str, cmd: &str) {
    match pool.exec(server, cmd).await {
        Ok(_) => ui::success(label),
        Err(_) => ui::error(label),
    }
}

pub fn write_server_to_config(
    config_path: &Path,
    name: &str,
    host: &str,
    ip: &str,
    user: &str,
    ssh_key: Option<&str>,
) -> Result<()> {
    let content = std::fs::read_to_string(config_path)
        .with_context(|| format!("Failed to read {}", config_path.display()))?;
    let mut doc = content
        .parse::<toml_edit::DocumentMut>()
        .with_context(|| format!("Failed to parse {}", config_path.display()))?;

    let servers = doc
        .entry("servers")
        .or_insert_with(|| toml_edit::Item::Table(toml_edit::Table::new()))
        .as_table_mut()
        .context("'servers' is not a table")?;

    let mut server_table = toml_edit::Table::new();
    server_table.insert("host", toml_edit::value(host));
    server_table.insert("ip", toml_edit::value(ip));
    server_table.insert("user", toml_edit::value(user));
    if let Some(key) = ssh_key {
        server_table.insert("ssh_key", toml_edit::value(key));
    }
    servers.insert(name, toml_edit::Item::Table(server_table));

    std::fs::write(config_path, doc.to_string())
        .with_context(|| format!("Failed to write {}", config_path.display()))?;
    Ok(())
}

pub fn remove_server_from_config(config_path: &Path, name: &str) -> Result<()> {
    let content = std::fs::read_to_string(config_path)
        .with_context(|| format!("Failed to read {}", config_path.display()))?;
    let mut doc = content
        .parse::<toml_edit::DocumentMut>()
        .with_context(|| format!("Failed to parse {}", config_path.display()))?;

    let servers = doc
        .get_mut("servers")
        .and_then(|s| s.as_table_mut())
        .context("'servers' table not found")?;

    servers.remove(name);

    std::fs::write(config_path, doc.to_string())
        .with_context(|| format!("Failed to write {}", config_path.display()))?;
    Ok(())
}