slave 0.12.0

CLI for slave, a Laravel-inspired Rust framework for MCP-first apps: scaffold an MCP backend with auth, roles, teams and SurrealDB migrations, add server-rendered web apps, and route them by domain or path
//! `slave install:fly` and `slave deploy:fly`: run the single runtime on
//! [Fly.io](https://fly.io).
//!
//! `install:fly` writes a `Dockerfile`, `.dockerignore`, `fly.toml` and
//! `config/apps.fly.toml` (the routes with production hosts). `deploy:fly`
//! creates the Fly app when missing, optionally imports secrets from an env
//! file, and runs `fly deploy`. The database is SurrealDB elsewhere
//! (`SURREAL_*`, e.g. SurrealDB Cloud), so machines are stateless and can be scaled out;
//! migrations run as the release command.

use std::io::{ErrorKind, Write};
use std::path::Path;
use std::process::{Command, Stdio};

use anyhow::{Context, Result, bail};
use slave_gateway::Config;

use crate::cli::{DeployFlyArgs, InstallFlyArgs};
use crate::{apps, cargo, generator, project, runtime, templates};

/// The production routing table the runtime reads on Fly (`SLAVE_ROUTES`).
pub const ROUTES: &str = "config/apps.fly.toml";

/// Secrets the runtime cannot start without.
const REQUIRED_SECRETS: &[&str] = &[
    "SURREAL_URL",
    "SURREAL_USER",
    "SURREAL_PASS",
    "SMTP_URL",
    "MAIL_FROM",
    "PASSKEY_RP_ID",
    "PASSKEY_ORIGIN",
];

/// Region used when `--region` is not given (Singapore).
const DEFAULT_REGION: &str = "sin";

/// Settings the image sets itself; never imported as secrets.
const IMAGE_SETTINGS: &[&str] = &[
    "APP_HOST",
    "APP_PORT",
    "GATEWAY_LISTEN",
    "SLAVE_ROUTES",
    "SLAVE_TRUSTED_PROXY",
    "RUST_LOG",
];

/// `slave install:fly`.
pub fn install(args: &InstallFlyArgs) -> Result<()> {
    let root = project::find_root()?;
    write_files(&root, args)?;
    println!("\nFly.io files written. Next:");
    println!(
        "  slave deploy:fly --secrets .env.production   # SURREAL_*, SMTP_URL, PASSKEY_*, ..."
    );
    Ok(())
}

fn write_files(root: &Path, args: &InstallFlyArgs) -> Result<()> {
    runtime::require(root)?;
    let name = project::package_name(root)?;
    let fly_app = args.app.clone().unwrap_or_else(|| name.clone());
    let region = args.region.as_deref().unwrap_or(DEFAULT_REGION);
    let domain = args
        .domain
        .clone()
        .unwrap_or_else(|| format!("{fly_app}.fly.dev"));

    let text = std::fs::read_to_string(root.join(apps::CONFIG))
        .with_context(|| format!("failed to read {}", apps::CONFIG))?;
    let routes = production_routes(&text, &domain)?;

    let vars = [
        ("name", name.as_str()),
        ("fly_app", fly_app.as_str()),
        ("region", region),
    ];
    for file in templates::FLY {
        let contents = templates::render(file.contents, &vars);
        generator::write_file(&root.join(file.path), &contents, args.force)?;
        println!("  created {}", file.path);
    }
    generator::write_file(&root.join(ROUTES), &routes, args.force)?;
    println!("  created {ROUTES} (hosts under {domain}; edit it to taste)");

    let parsed = Config::parse(&routes)?;
    let separate: Vec<&str> = parsed
        .domains
        .iter()
        .filter(|d| !d.is_mcp() && d.embed == Some(false) && d.upstream.is_none())
        .map(|d| d.app.as_str())
        .collect();
    if !separate.is_empty() {
        println!(
            "\n  note: {} run(s) separately: deploy each as its own Fly app and set \
             `upstream = \"<fly-app>.internal:<port>\"` for it in {ROUTES}",
            separate.join(", ")
        );
    }
    let certs = certificates(&parsed);
    if !certs.is_empty() {
        println!("\nPoint DNS at the app and add a certificate for each host:");
        for host in certs {
            println!("  fly certs add {host} --app {fly_app}");
        }
    }
    Ok(())
}

/// `apps.toml` with `localhost` hosts moved under `domain`
/// (`{toko}.localhost` → `{toko}.<domain>`) and listening on `0.0.0.0:8080`.
fn production_routes(text: &str, domain: &str) -> Result<String> {
    let mut out = String::from(
        "# Routes on Fly.io (SLAVE_ROUTES), from config/apps.toml by `slave install:fly`.\n\
         # Keep it in step with config/apps.toml when apps are added.\n\n",
    );
    for line in text.lines() {
        let trimmed = line.trim_start();
        let value = |key: &str| {
            trimmed
                .strip_prefix(key)
                .map(str::trim_start)
                .and_then(|rest| rest.strip_prefix('='))
                .map(|rest| rest.trim().trim_matches('"').to_owned())
        };
        if let Some(host) = value("host") {
            let host = match host.strip_suffix("localhost") {
                Some("") => domain.to_owned(),
                Some(prefix) if prefix.ends_with('.') => format!("{prefix}{domain}"),
                _ => host,
            };
            out.push_str(&format!("host = \"{host}\"\n"));
        } else if value("listen").is_some() {
            out.push_str("listen = \"0.0.0.0:8080\"\n");
        } else {
            out.push_str(line);
            out.push('\n');
        }
    }
    Config::parse(&out).with_context(|| format!("the generated {ROUTES} is invalid"))?;
    Ok(out)
}

/// Hosts that need a certificate on Fly: `{x}.example.com` → `*.example.com`.
fn certificates(config: &Config) -> Vec<String> {
    let mut hosts: Vec<String> = config
        .domains
        .iter()
        .filter_map(|domain| domain.host.as_deref())
        .filter(|host| !host.ends_with(".fly.dev"))
        .map(|host| {
            host.split('.')
                .map(|label| if label.starts_with('{') { "*" } else { label })
                .collect::<Vec<_>>()
                .join(".")
        })
        .collect();
    hosts.sort();
    hosts.dedup();
    hosts
}

/// `slave deploy:fly`.
pub fn deploy(args: &DeployFlyArgs) -> Result<()> {
    let root = project::find_root()?;
    if !root.join("fly.toml").is_file() {
        println!("No fly.toml yet; writing the Fly.io files first.");
        write_files(&root, &InstallFlyArgs::default())?;
    }
    let fly = fly_binary()?;
    let manifest =
        std::fs::read_to_string(root.join("fly.toml")).context("failed to read fly.toml")?;
    let app = toml_string(&manifest, "app").context("fly.toml has no `app = \"...\"`")?;

    if !root.join("Cargo.lock").is_file() {
        // The image builds with --locked.
        if !cargo::run(&root, &["generate-lockfile"], &[])?.success() {
            bail!("`cargo generate-lockfile` failed");
        }
    }

    if !fly_json(&fly, &root, &["apps", "list", "--json"])?
        .as_array()
        .is_some_and(|list| list.iter().any(|a| a["Name"] == app.as_str()))
    {
        println!("Creating Fly app `{app}`...");
        run(&fly, &root, &["apps", "create", &app])?;
    }
    if let Some(file) = &args.secrets {
        import_secrets(&fly, &root, &app, &root.join(file))?;
    }

    let set: Vec<String> = fly_json(&fly, &root, &["secrets", "list", "--app", &app, "--json"])?
        .as_array()
        .map(|list| {
            list.iter()
                .filter_map(|secret| secret["Name"].as_str().or(secret["name"].as_str()))
                .map(str::to_owned)
                .collect()
        })
        .unwrap_or_default();
    let missing: Vec<&str> = REQUIRED_SECRETS
        .iter()
        .copied()
        .filter(|name| !set.iter().any(|s| s == name))
        .collect();
    if !missing.is_empty() && args.secrets.is_none() {
        bail!(
            "Fly secrets missing: {}; pass --secrets <env file> or `fly secrets set` them (see README)",
            missing.join(", ")
        );
    }

    run(&fly, &root, &["deploy", "--app", &app])?;
    println!("\nDeployed: https://{app}.fly.dev (routes in {ROUTES})");
    Ok(())
}

/// Stages every non-empty setting of `path` (an env file) as a Fly secret,
/// except those the image sets itself.
fn import_secrets(fly: &str, root: &Path, app: &str, path: &Path) -> Result<()> {
    let entries = dotenvy::from_path_iter(path)
        .with_context(|| format!("failed to read {}", path.display()))?
        .collect::<Result<Vec<_>, _>>()
        .with_context(|| format!("failed to parse {}", path.display()))?;
    let mut input = String::new();
    let mut names = Vec::new();
    for (key, value) in entries {
        if value.is_empty() || IMAGE_SETTINGS.contains(&key.as_str()) {
            continue;
        }
        if value.contains('\n') {
            bail!(
                "{key} in {} spans several lines; set it with `fly secrets set`",
                path.display()
            );
        }
        input.push_str(&format!("{key}={value}\n"));
        names.push(key);
    }
    if names.is_empty() {
        println!("  note: {} has no secrets to import", path.display());
        return Ok(());
    }
    let mut child = Command::new(fly)
        .args(["secrets", "import", "--app", app, "--stage"])
        .current_dir(root)
        .stdin(Stdio::piped())
        .spawn()
        .context("failed to run `fly secrets import`")?;
    child
        .stdin
        .take()
        .context("no stdin for `fly secrets import`")?
        .write_all(input.as_bytes())?;
    if !child.wait()?.success() {
        bail!("`fly secrets import` failed");
    }
    println!("  staged secrets: {}", names.join(", "));
    Ok(())
}

/// `fly` or `flyctl`, whichever is installed.
fn fly_binary() -> Result<String> {
    for name in ["fly", "flyctl"] {
        match Command::new(name)
            .arg("version")
            .stdout(Stdio::null())
            .status()
        {
            Ok(_) => return Ok(name.to_owned()),
            Err(err) if err.kind() == ErrorKind::NotFound => continue,
            Err(err) => return Err(err).with_context(|| format!("failed to run `{name}`")),
        }
    }
    bail!(
        "the Fly.io CLI is not installed: https://fly.io/docs/flyctl/install/ (then `fly auth login`)"
    )
}

fn run(fly: &str, root: &Path, args: &[&str]) -> Result<()> {
    let status = Command::new(fly)
        .args(args)
        .current_dir(root)
        .status()
        .with_context(|| format!("failed to run `{fly} {}`", args.join(" ")))?;
    if !status.success() {
        bail!("`{fly} {}` failed", args.join(" "));
    }
    Ok(())
}

fn fly_json(fly: &str, root: &Path, args: &[&str]) -> Result<serde_json::Value> {
    let output = Command::new(fly)
        .args(args)
        .current_dir(root)
        .stderr(Stdio::inherit())
        .output()
        .with_context(|| format!("failed to run `{fly} {}`", args.join(" ")))?;
    if !output.status.success() {
        // Listing fails for an app that does not exist yet.
        return Ok(serde_json::Value::Null);
    }
    serde_json::from_slice(&output.stdout)
        .with_context(|| format!("unexpected output from `{fly} {}`", args.join(" ")))
}

/// The string value of a top-level `key = "..."` line.
fn toml_string(text: &str, key: &str) -> Option<String> {
    text.lines().find_map(|line| {
        let rest = line
            .trim()
            .strip_prefix(key)?
            .trim_start()
            .strip_prefix('=')?;
        Some(rest.trim().trim_matches('"').to_owned())
    })
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn moves_localhost_hosts_under_the_domain() {
        let text = "[gateway]\nlisten = \"127.0.0.1:8000\"\n\n[[domain]]\nhost = \"mcp.localhost\"\napp = \"mcp\"\n\n\
                    [[domain]]\nhost = \"{toko}.localhost\"\napp = \"shop\"\nport = 4000\n\n\
                    [[domain]]\nhost = \"localhost\"\npath = \"/help\"\napp = \"help\"\nport = 4001\n";
        let routes = production_routes(text, "example.com").unwrap();
        assert!(routes.contains("host = \"mcp.example.com\""), "{routes}");
        assert!(routes.contains("host = \"{toko}.example.com\""), "{routes}");
        assert!(routes.contains("host = \"example.com\""), "{routes}");
        assert!(routes.contains("listen = \"0.0.0.0:8080\""), "{routes}");
        assert_eq!(
            certificates(&Config::parse(&routes).unwrap()),
            ["*.example.com", "example.com", "mcp.example.com"]
        );
    }
}