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};
pub const ROUTES: &str = "config/apps.fly.toml";
const REQUIRED_SECRETS: &[&str] = &["DATABASE_URL", "OIDC_ISSUER_URL", "OIDC_AUDIENCES"];
const DEFAULT_REGION: &str = "sin";
const IMAGE_SETTINGS: &[&str] = &[
"APP_HOST",
"APP_PORT",
"GATEWAY_LISTEN",
"SLAVE_ROUTES",
"SLAVE_TRUSTED_PROXY",
"RUST_LOG",
];
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 # OIDC_*, <APP>_OIDC_CLIENT_ID, ...");
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(())
}
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)
}
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
}
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() {
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(())
}
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(())
}
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() {
return Ok(serde_json::Value::Null);
}
serde_json::from_slice(&output.stdout)
.with_context(|| format!("unexpected output from `{fly} {}`", args.join(" ")))
}
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"]
);
}
}