AkurAI Build
Menu

AkurAI-Build

public

Latest change efb3cb4c53c00d38e322bfa4257c355d2e4432cd - Require HTTP 200 from Bunfork readiness checks by fastcoder-engineer

//! EC2 deploy verbs: `deploy-binary`, `rollback`, `nginx-proxy`, `tls`,
//! `cert-status`, `release`, the Bunfork bundle lifecycle, and
//! `mail-autoconfig` — ported 1:1 from `AkurAI-EC2/bin/akurai-ec2`.
//!
//! Fidelity contract: same argument order, same defaults, same env-var names,
//! same stdout contract, same health-gate/auto-rollback behaviour as the bash
//! original. The release engine delegates version bump + changelog + commit +
//! tag to `crate::release::release` (never reimplemented here).

use std::{
    collections::HashMap,
    env, fs,
    io::Write,
    os::unix::fs::PermissionsExt,
    path::{Path, PathBuf},
    process::{Command, Stdio},
    time::{SystemTime, UNIX_EPOCH},
};

use anyhow::{Context, Result, bail, ensure};
use serde_json::{Value, json};

use crate::ec2::{Ec2, say, shell_quote};

const DEFAULT_CERT_DOMAIN: &str = "mail.olibuijr.com";
const CERTBOT_EMAIL: &str = "olibuijr@olibuijr.com";
const BUNFORK_DOMAIN: &str = "bunfork.olibuijr.com";
const BUNFORK_PORT: u16 = 3100;

fn valid_name(value: &str) -> bool {
    !value.is_empty()
        && value
            .bytes()
            .all(|c| c.is_ascii_alphanumeric() || matches!(c, b'-' | b'_' | b'.'))
}

fn valid_domain(value: &str) -> bool {
    !value.is_empty()
        && value
            .bytes()
            .all(|c| c.is_ascii_alphanumeric() || matches!(c, b'-' | b'.'))
}

fn run(command: &mut Command, label: &str) -> Result<()> {
    let status = command.status().with_context(|| format!("run {label}"))?;
    ensure!(status.success(), "{label} failed with {status}");
    Ok(())
}

fn output(command: &mut Command, label: &str) -> Result<String> {
    let result = command.output().with_context(|| format!("run {label}"))?;
    ensure!(
        result.status.success(),
        "{label} failed: {}",
        String::from_utf8_lossy(&result.stderr).trim()
    );
    Ok(String::from_utf8_lossy(&result.stdout).trim().to_owned())
}

fn run_with_stdin(command: &mut Command, stdin: &[u8], label: &str) -> Result<()> {
    let mut child = command
        .stdin(Stdio::piped())
        .spawn()
        .with_context(|| format!("run {label}"))?;
    child
        .stdin
        .take()
        .context("capture stdin")?
        .write_all(stdin)
        .with_context(|| format!("write stdin for {label}"))?;
    let status = child.wait().with_context(|| format!("wait for {label}"))?;
    ensure!(status.success(), "{label} failed with {status}");
    Ok(())
}

fn ssh_host(ec2: &Ec2, host: &str, command: &str) -> Result<()> {
    run(
        Command::new("ssh")
            .arg("-F")
            .arg(&ec2.ssh_config)
            .arg(host)
            .arg(command),
        &format!("ssh {host}"),
    )
}

fn ssh_batch(ec2: &Ec2, host: &str) -> Command {
    let mut command = Command::new("ssh");
    command
        .arg("-F")
        .arg(&ec2.ssh_config)
        .arg("-o")
        .arg("BatchMode=yes")
        .arg("-o")
        .arg("ConnectTimeout=15")
        .arg(host);
    command
}

fn rsync(ec2: &Ec2, args: &[String], label: &str) -> Result<()> {
    let mut command = Command::new("rsync");
    command
        .arg("-e")
        .arg(format!("ssh -F {}", ec2.ssh_config.display()));
    command.args(args);
    run(&mut command, label)
}

/// Disk-backed temp path for host-side archives. bash used `mktemp` in /tmp;
/// this workspace policy forbids /tmp (RAM-backed) for project work, so we
/// stage under `~/.cache/akurai-ec2` instead.
fn temp_path(suffix: &str) -> Result<PathBuf> {
    let dir = PathBuf::from(env::var("HOME")?).join(".cache/akurai-ec2");
    fs::create_dir_all(&dir)?;
    let stamp = SystemTime::now()
        .duration_since(UNIX_EPOCH)
        .map(|d| d.as_millis())
        .unwrap_or(0);
    Ok(dir.join(format!("{suffix}-{}-{stamp}", std::process::id())))
}

fn curl_code(url: &str, failure: &str) -> String {
    match Command::new("curl")
        .args([
            "-s",
            "-o",
            "/dev/null",
            "-w",
            "%{http_code}",
            "--max-time",
            "10",
            url,
        ])
        .output()
    {
        Ok(result) if result.status.success() => {
            String::from_utf8_lossy(&result.stdout).trim().to_owned()
        }
        _ => failure.to_owned(),
    }
}

// ---------------------------------------------------------------- deploy-binary

/// Environment overrides for the generated systemd unit, mirroring the
/// `AKURAI_EXEC_START` / `AKURAI_ENV_FILE` / `AKURAI_DATA_DIR` /
/// `AKURAI_USER` / `AKURAI_HARDEN` variables the bash script honours.
#[derive(Default, Clone)]
struct UnitOverrides {
    exec_start: Option<String>,
    env_file: Option<String>,
    user: Option<String>,
    data_dirs: Vec<String>,
    harden: bool,
}

impl UnitOverrides {
    fn from_env() -> Self {
        UnitOverrides {
            exec_start: env::var("AKURAI_EXEC_START").ok().filter(|v| !v.is_empty()),
            env_file: env::var("AKURAI_ENV_FILE").ok().filter(|v| !v.is_empty()),
            user: env::var("AKURAI_USER").ok().filter(|v| !v.is_empty()),
            data_dirs: env::var("AKURAI_DATA_DIR")
                .unwrap_or_default()
                .lines()
                .filter(|line| !line.is_empty())
                .map(str::to_owned)
                .collect(),
            harden: matches!(env::var("AKURAI_HARDEN").as_deref(), Ok("1" | "true")),
        }
    }
}

pub fn deploy_binary(
    ec2: &Ec2,
    name: &str,
    bin_path: &Path,
    port: u16,
    appdir: Option<&Path>,
    json: bool,
) -> Result<()> {
    deploy_with(
        ec2,
        name,
        bin_path,
        port,
        appdir,
        json,
        &UnitOverrides::from_env(),
    )
}

fn deploy_with(
    ec2: &Ec2,
    name: &str,
    bin_path: &Path,
    port: u16,
    appdir: Option<&Path>,
    json: bool,
    overrides: &UnitOverrides,
) -> Result<()> {
    ensure!(valid_name(name), "invalid service name: {name}");
    ensure!(
        bin_path.is_file(),
        "deploy-binary: binary not found: {}",
        bin_path.display()
    );
    if let Some(path) = appdir {
        ensure!(
            path.is_dir(),
            "deploy-binary: app directory not found: {}",
            path.display()
        );
    }

    let deploy_timestamp = output(
        Command::new("date").args(["-u", "+%Y-%m-%dT%H:%M:%SZ"]),
        "deploy timestamp",
    )?;
    let binary_sha256 = output(Command::new("sha256sum").arg(bin_path), "binary digest")?
        .split_whitespace()
        .next()
        .unwrap_or("")
        .to_owned();
    let asset_sha256 = match appdir {
        Some(path) => {
            let digest = output(
                Command::new("python3")
                    .arg("-c")
                    .arg(ASSET_DIGEST)
                    .arg(path),
                "asset digest",
            )?;
            Some(digest)
        }
        None => None,
    };
    let base = format!("/opt/{name}");
    let binary = format!("{base}/bin/{name}");
    let previous_backup = ec2.ssh(&format!("[ -f {binary} ] && printf true || printf false"))?;
    let previous_backup = previous_backup.trim() == "true";

    say(&format!("deploying '{name}' on port {port}"));
    ec2.ssh(&format!(
        "sudo mkdir -p {base}/bin && sudo chown -R ubuntu:ubuntu {base}"
    ))?;
    // Stop the service first — a running binary is busy and can't be overwritten.
    ec2.ssh(&format!(
        "sudo systemctl stop {name}.service 2>/dev/null || true"
    ))?;
    // Back up the currently-deployed binary before overwriting it — the rollback
    // target if the new build turns out unhealthy (see the health-gate below).
    ec2.ssh(&format!(
        "[ -f {binary} ] && cp -p {binary} {binary}.prev || true"
    ))?;
    ec2.ship(bin_path, &binary)?;
    ec2.ssh(&format!("chmod +x {binary}"))?;
    if let Some(path) = appdir {
        say(&format!("uploading app dir {}", path.display()));
        let mut tar = Command::new("tar")
            .arg("czf")
            .arg("-")
            .arg(".")
            .current_dir(path)
            .stdout(Stdio::piped())
            .spawn()
            .context("start app directory archive")?;
        let stdin = tar.stdout.take().context("capture tar output")?;
        let status = Command::new("ssh")
            .arg("-F")
            .arg(&ec2.ssh_config)
            .arg(&ec2.ssh_host)
            .arg(format!("mkdir -p {base}/app && tar xzf - -C {base}/app"))
            .stdin(stdin)
            .status()
            .context("upload app directory")?;
        ensure!(
            status.success(),
            "app directory upload failed with {status}"
        );
        ensure!(tar.wait()?.success(), "app directory archive failed");
    }

    let user = overrides
        .user
        .clone()
        .unwrap_or_else(|| "ubuntu".to_owned());
    ensure!(valid_name(&user), "invalid service user: {user}");
    let mut exec = overrides.exec_start.clone().unwrap_or_else(|| {
        format!(
            "{binary} serve --dir {base}/app/frontend --host 127.0.0.1 --port {port} --name {name}"
        )
    });
    exec = exec
        .replace("{bin}", &binary)
        .replace("{port}", &port.to_string())
        .replace("{app}", &format!("{base}/app"));
    ensure!(
        !exec.contains(['\r', '\n']),
        "ExecStart cannot contain newlines"
    );
    ensure!(
        !overrides
            .env_file
            .as_deref()
            .is_some_and(|v| v.contains(['\r', '\n'])),
        "EnvironmentFile cannot contain newlines"
    );
    for dir in &overrides.data_dirs {
        ec2.ssh(&format!(
            "sudo mkdir -p {} && sudo chown {user}:{user} {}",
            shell_quote(dir),
            shell_quote(dir)
        ))?;
    }
    // Always ensure the systemd WorkingDirectory ($base/app) exists and is owned
    // by the service user. Single-binary apps upload no appdir, so without this
    // the unit fails at CHDIR (status=200/CHDIR) and crash-loops every deploy.
    ec2.ssh(&format!(
        "sudo mkdir -p {base}/app && sudo chown -R {user}:{user} {base}/app"
    ))?;

    let mut unit = format!(
        "[Unit]\nDescription={name} (AkurAI single-binary app)\nAfter=network.target\n\n\
         [Service]\nType=simple\nUser={user}\nWorkingDirectory={base}/app\n"
    );
    if let Some(env_file) = &overrides.env_file {
        unit.push_str(&format!("EnvironmentFile={env_file}\n"));
    }
    unit.push_str(&format!(
        "ExecStart={exec}\nRestart=on-failure\nRestartSec=2\n"
    ));
    if overrides.harden {
        let mut paths = vec![format!("{base}/app")];
        paths.extend(overrides.data_dirs.iter().cloned());
        unit.push_str(&format!(
            "NoNewPrivileges=true\nPrivateTmp=true\nProtectSystem=full\nReadWritePaths={}\n",
            paths.join(" ")
        ));
    }
    unit.push_str("\n[Install]\nWantedBy=multi-user.target\n");
    say(&format!("installing systemd unit {name}.service"));
    ec2.ssh(&format!(
        "printf %s {} | sudo tee /etc/systemd/system/{name}.service >/dev/null; \
         sudo systemctl daemon-reload && sudo systemctl enable --now {name}.service && \
         sleep 1 && systemctl is-active {name}.service",
        shell_quote(&unit)
    ))?;

    // Health-gate: confirm the app actually answers HTTP on its port — 'active'
    // alone can't tell a serving app from one that crashes on first request. Any
    // HTTP status means it's serving; 000 = connection refused / crashed.
    let mut health = None;
    for _ in 0..6 {
        let code = ec2.ssh(&format!(
            "curl -s -o /dev/null -w '%{{http_code}}' --max-time 4 http://127.0.0.1:{port}/ 2>/dev/null || echo 000"
        ))?;
        let code = code.trim().to_owned();
        if code != "000" {
            health = Some(code);
            break;
        }
        std::thread::sleep(std::time::Duration::from_secs(2));
    }
    let Some(code) = health else {
        say(&format!(
            "{name} failed its health check on :{port} — auto-rolling back"
        ));
        if ec2.ssh(&format!("[ -f {binary}.prev ]")).is_ok() {
            ec2.ssh(&format!(
                "sudo systemctl stop {name}.service; cp -p {binary}.prev {binary}; \
                 sudo systemctl start {name}.service; sleep 2; systemctl is-active {name}.service"
            ))?;
            bail!(
                "rolled back {name} to the previous binary (the new build was unhealthy on :{port})"
            );
        }
        bail!(
            "{name} is unhealthy on :{port} and there is no .prev backup to roll back to — investigate"
        );
    };
    say(&format!(
        "health: {name} answering on :{port} (HTTP {code})"
    ));
    say(&format!("deployed: {name} on 127.0.0.1:{port}"));
    if json {
        let receipt = json!({
            "app": name,
            "port": port,
            "deployed_at": deploy_timestamp,
            "binary_sha256": binary_sha256,
            "asset_tree_sha256": asset_sha256,
            "previous_binary_backed_up": previous_backup,
            "systemd_state": "active",
            "loopback_http_status": code.parse::<u32>().unwrap_or(0),
            "ok": true,
        });
        println!("{receipt}");
    }
    Ok(())
}

const ASSET_DIGEST: &str = r#"import hashlib,pathlib,sys
root=pathlib.Path(sys.argv[1])
d=hashlib.sha256()
for p in sorted(i for i in root.rglob('*') if i.is_file()):
 d.update(p.relative_to(root).as_posix().encode()); d.update(b'\0'); d.update(p.read_bytes())
print(d.hexdigest())
"#;

// ------------------------------------------------------------------- rollback

pub fn rollback(ec2: &Ec2, name: &str) -> Result<()> {
    ensure!(valid_name(name), "invalid service name: {name}");
    let binary = format!("/opt/{name}/bin/{name}");
    ec2.ssh(&format!("[ -f {binary}.prev ]"))
        .with_context(|| format!("no {binary}.prev backup found for {name}"))?;
    say(&format!("rolling back {name} to its previous binary"));
    ec2.ssh(&format!(
        "sudo systemctl stop {name}.service; cp -p {binary}.prev {binary}; \
         sudo systemctl start {name}.service; sleep 2; systemctl is-active {name}.service"
    ))?;
    say(&format!("rolled back: {name}"));
    Ok(())
}

// ---------------------------------------------------------------- nginx-proxy

pub fn nginx_proxy(ec2: &Ec2, domain: &str, port: u16) -> Result<()> {
    ensure!(valid_domain(domain), "invalid domain: {domain}");
    say(&format!("nginx reverse proxy: {domain} → 127.0.0.1:{port}"));
    let vhost = format!(
        "# Map the client's Upgrade header to the Connection value nginx must forward for\n\
         # a WebSocket handshake: \"upgrade\" when upgrading, \"close\" otherwise.\n\
         map $http_upgrade $connection_upgrade {{\n    default upgrade;\n    ''      close;\n}}\n\n\
         server {{\n    listen 80;\n    listen [::]:80;\n    server_name {domain};\n\n    location / {{\n\
         proxy_pass http://127.0.0.1:{port};\n        proxy_http_version 1.1;\n\
         proxy_set_header Host $host;\n        proxy_set_header X-Real-IP $remote_addr;\n\
         proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;\n\
         proxy_set_header X-Forwarded-Proto $scheme;\n\n        # WebSocket upgrade + long-lived realtime streams (WS / SSE).\n\
         proxy_set_header Upgrade $http_upgrade;\n        proxy_set_header Connection $connection_upgrade;\n\
         proxy_read_timeout 3600s;\n        proxy_send_timeout 3600s;\n\
         proxy_buffering off;          # stream SSE/WS frames immediately\n    }}\n}}\n"
    );
    ec2.ssh(&format!(
        "printf %s {} | sudo tee /etc/nginx/sites-available/{domain} >/dev/null; \
         sudo ln -sf /etc/nginx/sites-available/{domain} /etc/nginx/sites-enabled/{domain} && \
         sudo nginx -t && sudo systemctl reload nginx",
        shell_quote(&vhost)
    ))?;
    say(&format!(
        "nginx vhost live for {domain} (HTTP). Public IP: {}",
        ec2.public_ip
    ));
    // TLS idempotency: the HTTP vhost written above clobbers any certbot-managed
    // 443 block, which is why every redeploy used to silently drop HTTPS. If a
    // cert already exists for this domain, re-assert HTTPS so it never regresses.
    if ec2
        .ssh(&format!(
            "sudo test -f /etc/letsencrypt/live/{domain}/fullchain.pem"
        ))
        .is_ok()
    {
        say(&format!(
            "existing cert found — re-asserting HTTPS for {domain}"
        ));
        ec2.ssh(&format!(
            "sudo certbot --nginx -d {domain} --non-interactive --agree-tos -m {CERTBOT_EMAIL} --redirect \
             >/dev/null 2>&1 && sudo nginx -t && sudo systemctl reload nginx"
        ))?;
        say(&format!("HTTPS re-asserted for {domain}"));
    }
    Ok(())
}

// ------------------------------------------------------------------------- tls

pub fn tls(ec2: &Ec2, domain: &str) -> Result<()> {
    ensure!(valid_domain(domain), "invalid domain: {domain}");
    say(&format!("requesting Let's Encrypt cert for {domain}"));
    ec2.ssh_streaming(&format!(
        "sudo certbot --nginx -d {domain} --non-interactive --agree-tos -m {CERTBOT_EMAIL} --redirect"
    ))?;
    say("verifying public TLS and hostname");
    cert_status(ec2, Some(domain), 0, false)
}

// ---------------------------------------------------------------- cert-status

const CERT_STATUS_SCRIPT: &str = r#"import datetime,json,socket,ssl,sys
host,warn,emit=sys.argv[1],int(sys.argv[2]),sys.argv[3]=='1'
try:
 c=ssl.create_default_context()
 with socket.create_connection((host,443),timeout=10) as raw:
  with c.wrap_socket(raw,server_hostname=host) as tls: cert=tls.getpeercert()
 expiry=datetime.datetime.strptime(cert['notAfter'],'%b %d %H:%M:%S %Y %Z').replace(tzinfo=datetime.timezone.utc)
 days=max(0,int((expiry-datetime.datetime.now(datetime.timezone.utc)).total_seconds()//86400))
 sans=[v for k,v in cert.get('subjectAltName',[]) if k=='DNS']
 state='healthy' if days>=warn else 'warning'
 result={'domain':host,'state':state,'hostname_verified':True,'days_remaining':days,'not_after':expiry.isoformat(),'dns_sans':sans}
 print(json.dumps(result,separators=(',',':')) if emit else f'{host}: {state}; {days} days remaining; expires {expiry.isoformat()}')
 raise SystemExit(0 if state=='healthy' else 1)
except (OSError,ssl.SSLError,KeyError,ValueError) as exc:
 result={'domain':host,'state':'error','hostname_verified':False,'error':str(exc)}
 print(json.dumps(result,separators=(',',':')) if emit else f'{host}: error: {exc}')
 raise SystemExit(2)
"#;

fn cert_status_command(domain: &str, warn_days: u32, json: bool) -> Command {
    let mut command = Command::new("python3");
    command
        .arg("-c")
        .arg(CERT_STATUS_SCRIPT)
        .arg(domain)
        .arg(warn_days.to_string())
        .arg(if json { "1" } else { "0" });
    command
}

pub fn cert_status(_ec2: &Ec2, domain: Option<&str>, warn_days: u32, json: bool) -> Result<()> {
    let domain = domain.unwrap_or(DEFAULT_CERT_DOMAIN);
    ensure!(valid_domain(domain), "invalid domain: {domain}");
    ensure!(warn_days <= 3650, "warn-days must be 0..3650");
    run(
        &mut cert_status_command(domain, warn_days, json),
        "certificate status check",
    )
}

/// `cert-status --warn-days 0 --json` output for the publish receipt; `null`
/// when the check fails (mirrors bash `… || printf null`).
fn cert_status_capture(domain: &str) -> Value {
    match cert_status_command(domain, 0, true).output() {
        Ok(result) if result.status.success() => {
            serde_json::from_str(String::from_utf8_lossy(&result.stdout).trim())
                .unwrap_or(Value::Null)
        }
        _ => Value::Null,
    }
}

// ----------------------------------------------------------- verify-bunfork

const VERIFY_BUNDLE_SCRIPT: &str = r#"import hashlib,json,pathlib,sys
root=pathlib.Path(sys.argv[1])
manifest=json.loads((root/'bunfork.json').read_text())
if manifest.get('schema')!='bunfork-deployment-v2': raise SystemExit('invalid Bunfork deployment schema')
expected=set(manifest.get('files',{}))|{'bunfork.json'}
actual=set()
for path in root.rglob('*'):
 rel=path.relative_to(root).as_posix()
 if path.is_symlink() or (path.exists() and not (path.is_file() or path.is_dir())): raise SystemExit(f'unsafe bundle entry: {rel}')
 if path.is_file(): actual.add(rel)
if actual!=expected: raise SystemExit(f'bundle inventory mismatch: missing={sorted(expected-actual)} extra={sorted(actual-expected)}')
for rel,record in manifest['files'].items():
 data=(root/rel).read_bytes()
 if len(data)!=record['bytes'] or hashlib.sha256(data).hexdigest()!=record['sha256']: raise SystemExit(f'bundle digest mismatch: {rel}')
"#;

fn verify_bunfork_bundle(bundle: &Path) -> Result<()> {
    ensure!(
        bundle.is_dir(),
        "bundle directory not found: {}",
        bundle.display()
    );
    ensure!(
        bundle.join("bunfork.json").is_file(),
        "bundle manifest missing: {}",
        bundle.join("bunfork.json").display()
    );
    run(
        Command::new("python3")
            .arg("-c")
            .arg(VERIFY_BUNDLE_SCRIPT)
            .arg(bundle),
        "verify bunfork bundle",
    )
}

pub fn verify_bunfork(_ec2: &Ec2, bundle: &Path) -> Result<()> {
    let bundle = bundle
        .canonicalize()
        .with_context(|| format!("bundle directory not found: {}", bundle.display()))?;
    verify_bunfork_bundle(&bundle)?;
    say(&format!("verified Bunfork bundle: {}", bundle.display()));
    Ok(())
}

// ------------------------------------------------------------ deploy-bunfork

const BUNFORK_INSTALL_SCRIPT: &str = r#"set -euo pipefail
release_id="$1"; port="$2"; domain="$3"; archive="$4"
base=/opt/bunfork
release="$base/releases/$release_id"
previous="$(readlink -f "$base/current" 2>/dev/null || true)"

sudo install -d -m 0755 "$base/releases" "$release"
sudo tar -xzf "$archive" -C "$release"
rm -f "$archive"
sudo python3 - "$release" <<'PY'
import hashlib, json, os, pathlib, stat, sys
root = pathlib.Path(sys.argv[1])
manifest = json.loads((root / "bunfork.json").read_text())
if manifest.get("schema") != "bunfork-deployment-v2":
    raise SystemExit("invalid Bunfork deployment schema")
expected = set(manifest.get("files", {})) | {"bunfork.json"}
actual = set()
for path in root.rglob("*"):
    rel = path.relative_to(root).as_posix()
    if path.is_symlink() or (path.exists() and not (path.is_file() or path.is_dir())):
        raise SystemExit(f"unsafe bundle entry: {rel}")
    if path.is_file():
        actual.add(rel)
if actual != expected:
    raise SystemExit(f"bundle inventory mismatch: missing={sorted(expected-actual)} extra={sorted(actual-expected)}")
for rel, record in manifest["files"].items():
    path = root / rel
    data = path.read_bytes()
    if len(data) != record["bytes"] or hashlib.sha256(data).hexdigest() != record["sha256"]:
        raise SystemExit(f"bundle digest mismatch: {rel}")
PY
sudo chmod 0755 "$release/bunfork"
sudo find "$release" -type d -exec chmod 0755 {} +
sudo find "$release" -type f ! -name bunfork -exec chmod 0644 {} +
sudo chown -R root:root "$release"

if ! id bunfork >/dev/null 2>&1; then
  sudo useradd --system --home-dir /var/lib/bunfork --shell /usr/sbin/nologin bunfork
fi
sudo install -d -o bunfork -g bunfork -m 0750 /var/lib/bunfork /etc/bunfork
if ! sudo test -s /etc/bunfork/db.key; then
  sudo -u bunfork "$release/bunfork" keygen --out /etc/bunfork/db.key
fi
if ! sudo test -s /etc/bunfork/api.token; then
  sudo -u bunfork "$release/bunfork" keygen --out /etc/bunfork/api.token
fi
sudo chmod 0600 /etc/bunfork/db.key /etc/bunfork/api.token
sudo chown bunfork:bunfork /etc/bunfork/db.key /etc/bunfork/api.token
sudo systemctl stop bunfork.service 2>/dev/null || true
if ! sudo -u bunfork "$release/bunfork" \
  --database /var/lib/bunfork/bunfork.db \
  --key-file /etc/bunfork/db.key migrate; then
  if [ -n "$previous" ] && [ -d "$previous" ]; then
    sudo systemctl start bunfork.service || true
  fi
  echo "Bunfork migration failed; existing release restarted" >&2
  exit 1
fi

sudo ln -sfn "$release" "$base/current"
sudo tee /etc/systemd/system/bunfork.service >/dev/null <<UNIT
[Unit]
Description=Bunfork web runtime
After=network-online.target
Wants=network-online.target

[Service]
Type=simple
User=bunfork
Group=bunfork
WorkingDirectory=/opt/bunfork/current
Environment=BUNFORK_ADDRESS=127.0.0.1:$port
Environment=BUNFORK_PUBLIC_ORIGIN=https://$domain
ExecStart=/opt/bunfork/current/bunfork --database /var/lib/bunfork/bunfork.db --key-file /etc/bunfork/db.key serve --token-file /etc/bunfork/api.token
Restart=on-failure
RestartSec=2
UMask=0077
NoNewPrivileges=true
PrivateDevices=true
PrivateTmp=true
ProtectSystem=strict
ProtectHome=true
ReadOnlyPaths=/opt/bunfork /etc/bunfork
ReadWritePaths=/var/lib/bunfork
CapabilityBoundingSet=
LockPersonality=true
MemoryDenyWriteExecute=true
RestrictSUIDSGID=true
RestrictAddressFamilies=AF_UNIX AF_INET AF_INET6

[Install]
WantedBy=multi-user.target
UNIT
sudo systemctl daemon-reload
sudo systemctl enable --now bunfork.service

healthy=0
for _ in 1 2 3 4 5 6 7 8 9 10; do
  if curl -fsS --max-time 4 "http://127.0.0.1:$port/_bunfork/ready" >/dev/null 2>&1; then
    healthy=1
    break
  fi
  sleep 2
done
if [ "$healthy" != 1 ]; then
  sudo systemctl stop bunfork.service || true
  if [ -n "$previous" ] && [ -d "$previous" ]; then
    sudo ln -sfn "$previous" "$base/current"
    sudo systemctl start bunfork.service || true
  fi
  echo "Bunfork readiness failed; restored previous release when available" >&2
  exit 1
fi
sudo systemctl is-active --quiet bunfork.service
printf 'release=%s previous=%s\n' "$release" "${previous:-none}"
"#;

pub fn deploy_bunfork(
    ec2: &Ec2,
    bundle: &Path,
    domain: Option<&str>,
    port: Option<u16>,
) -> Result<()> {
    let bundle = bundle
        .canonicalize()
        .with_context(|| format!("bundle directory not found: {}", bundle.display()))?;
    let domain = domain.unwrap_or(BUNFORK_DOMAIN);
    let port = port.unwrap_or(BUNFORK_PORT);
    ensure!(valid_domain(domain), "invalid domain: {domain}");
    ensure!((1024..=65535).contains(&port), "invalid port: {port}");
    ensure!(
        bundle.is_dir(),
        "bundle directory not found: {}",
        bundle.display()
    );
    ensure!(
        bundle.join("bunfork").is_file(),
        "bundle binary is missing or not executable: {}/bunfork",
        bundle.display()
    );
    ensure!(
        bundle.join("bunfork.json").is_file(),
        "bundle manifest missing: {}/bunfork.json",
        bundle.display()
    );

    verify_bunfork_bundle(&bundle)?;

    let stamp = output(
        Command::new("date").args(["-u", "+%Y%m%dT%H%M%SZ"]),
        "release timestamp",
    )?;
    let digest = output(
        Command::new("sha256sum").arg(bundle.join("bunfork.json")),
        "bundle manifest digest",
    )?;
    let release_id = format!("{stamp}-{}", &digest[..digest.len().min(12)]);
    let archive = temp_path(&format!("bunfork-{release_id}"))?;
    let guard = TempGuard {
        path: Some(archive.clone()),
    };
    run(
        Command::new("tar")
            .arg("-C")
            .arg(&bundle)
            .arg("-czf")
            .arg(&archive)
            .arg("."),
        "archive bunfork bundle",
    )?;

    say("preflighting EC2 SSH");
    ec2.ssh("true")?;
    let remote_archive = format!("/tmp/bunfork-{release_id}.tgz");
    ec2.ship(&archive, &remote_archive)?;
    drop(guard);

    say(&format!("installing Bunfork release {release_id}"));
    ec2.ssh_stdin_streaming(
        &format!(
            "bash -s -- {} {} {} {}",
            shell_quote(&release_id),
            shell_quote(&port.to_string()),
            shell_quote(domain),
            shell_quote(&remote_archive)
        ),
        BUNFORK_INSTALL_SCRIPT.as_bytes(),
    )?;

    let had_cert = ec2
        .ssh(&format!(
            "sudo test -f /etc/letsencrypt/live/{domain}/fullchain.pem"
        ))
        .is_ok();
    nginx_proxy(ec2, domain, port)?;
    if !had_cert {
        tls(ec2, domain)?;
    }
    let ready = format!("https://{domain}/_bunfork/ready");
    if curl_code(&ready, "0") != "200" {
        bail!("public HTTPS readiness failed: {domain}");
    }
    say(&format!(
        "deployed Bunfork: https://{domain} (systemd bunfork.service, release {release_id})"
    ));
    Ok(())
}

/// Removes a staged temp file when dropped (bash `trap … EXIT` equivalent).
struct TempGuard {
    path: Option<PathBuf>,
}

impl Drop for TempGuard {
    fn drop(&mut self) {
        if let Some(path) = &self.path {
            let _ = fs::remove_file(path);
        }
    }
}

// ------------------------------------------------------ migrate-bunfork-state

const BUNFORK_MIGRATE_REMOTE: &str = r#"set -euo pipefail
archive="$1"
stage="$(mktemp -d)"
backup="/var/backups/bunfork/pre-migration-$(date -u +%Y%m%dT%H%M%SZ)"
cleanup() { rm -rf "$stage"; rm -f "$archive"; }
trap cleanup EXIT
mkdir -p "$stage"
tar -xzf "$archive" -C "$stage"
sudo install -d -m 0700 "$backup"
for path in /var/lib/bunfork/bunfork.db /etc/bunfork/db.key /etc/bunfork/api.token; do
  if sudo test -f "$path"; then sudo cp -a "$path" "$backup/"; fi
done
sudo systemctl stop bunfork.service
sudo install -o bunfork -g bunfork -m 0600 "$stage/.local/share/bunfork/bunfork.db" /var/lib/bunfork/bunfork.db
sudo install -o bunfork -g bunfork -m 0600 "$stage/.config/bunfork/db.key" /etc/bunfork/db.key
sudo install -o bunfork -g bunfork -m 0600 "$stage/.config/bunfork/api.token" /etc/bunfork/api.token
if ! sudo -u bunfork /opt/bunfork/current/bunfork --database /var/lib/bunfork/bunfork.db --key-file /etc/bunfork/db.key migrate || \
   ! sudo systemctl start bunfork.service; then
  sudo systemctl stop bunfork.service || true
  for name in bunfork.db db.key api.token; do
    if sudo test -f "$backup/$name"; then
      case "$name" in
        bunfork.db) target=/var/lib/bunfork/bunfork.db ;;
        *) target=/etc/bunfork/$name ;;
      esac
      sudo cp -a "$backup/$name" "$target"
    fi
  done
  sudo systemctl start bunfork.service || true
  echo "state migration failed; restored EC2 pre-migration state" >&2
  exit 1
fi
for _ in 1 2 3 4 5 6 7 8 9 10; do
  curl -fsS --max-time 4 http://127.0.0.1:3100/_bunfork/ready >/dev/null 2>&1 && exit 0
  sleep 2
done
echo "migrated Bunfork did not become ready" >&2
exit 1
"#;

pub fn migrate_bunfork_state(ec2: &Ec2, source_ssh: Option<&str>) -> Result<()> {
    let source_host = source_ssh.unwrap_or("titan");
    let archive = temp_path("bunfork-state")?;
    let mut guard = MigrateGuard {
        archive: Some(archive.clone()),
        source_host,
        ec2,
        migrated: false,
    };
    let source_command = "set -eu; systemctl --user stop bunfork.service; \
        test -s \"$HOME/.local/share/bunfork/bunfork.db\"; test -s \"$HOME/.config/bunfork/db.key\"; \
        test -s \"$HOME/.config/bunfork/api.token\"; \
        tar -C \"$HOME\" -czf - .local/share/bunfork/bunfork.db .config/bunfork/db.key .config/bunfork/api.token";
    say(&format!(
        "stopping source Bunfork on {source_host} for a consistent encrypted-state transfer"
    ));
    {
        let file = fs::File::create(&archive)?;
        fs::set_permissions(&archive, fs::Permissions::from_mode(0o600))?;
        let mut child = ssh_batch(ec2, source_host)
            .arg(source_command)
            .stdout(file)
            .spawn()
            .context("capture source Bunfork state archive")?;
        let status = child.wait().context("wait for source state archive")?;
        ensure!(
            status.success(),
            "source state archive failed with {status}"
        );
    }

    ec2.ssh("true")?;
    let remote_archive = format!(
        "/tmp/bunfork-state-{}.tgz",
        output(
            Command::new("date").args(["-u", "+%Y%m%dT%H%M%SZ"]),
            "remote archive stamp"
        )?
    );
    ec2.ship(&archive, &remote_archive)?;
    ec2.ssh_stdin_streaming(
        &format!("bash -s -- {}", shell_quote(&remote_archive)),
        BUNFORK_MIGRATE_REMOTE.as_bytes(),
    )?;
    if curl_code("https://bunfork.olibuijr.com/_bunfork/ready", "0") != "200" {
        bail!("public Bunfork readiness failed after state migration");
    }
    guard.migrated = true;
    say("migrated encrypted Bunfork state; source service remains stopped pending retirement");
    Ok(())
}

/// bash `trap cleanup_migration EXIT`: drop the archive and restart the source
/// service unless the migration completed.
struct MigrateGuard<'a> {
    archive: Option<PathBuf>,
    source_host: &'a str,
    ec2: &'a Ec2,
    migrated: bool,
}

impl Drop for MigrateGuard<'_> {
    fn drop(&mut self) {
        if let Some(path) = &self.archive {
            let _ = fs::remove_file(path);
        }
        if !self.migrated {
            let _ = ssh_batch(self.ec2, self.source_host)
                .arg("systemctl --user start bunfork.service")
                .output();
        }
    }
}

// ------------------------------------------------------- retire-bunfork-source

const BUNFORK_RETIRE_REMOTE: &str = r#"set -euo pipefail
stamp="$(date -u +%Y%m%dT%H%M%SZ)"
systemctl --user disable --now bunfork.service 2>/dev/null || true
rm -f "$HOME/.config/systemd/user/bunfork.service"
systemctl --user daemon-reload
if [ -d "$HOME/.local/lib/bunfork" ]; then
  mv "$HOME/.local/lib/bunfork" "$HOME/.local/lib/bunfork.retired-$stamp"
fi
if systemctl --user list-unit-files bunfork.service --no-legend 2>/dev/null | grep -q bunfork; then
  echo "source Bunfork unit still exists" >&2
  exit 1
fi
printf 'retired_runtime=%s\n' "$HOME/.local/lib/bunfork.retired-$stamp"
"#;

pub fn retire_bunfork_source(
    ec2: &Ec2,
    source_ssh: Option<&str>,
    domain: Option<&str>,
) -> Result<()> {
    let source_host = source_ssh.unwrap_or("titan");
    let domain = domain.unwrap_or(BUNFORK_DOMAIN);
    if curl_code(&format!("https://{domain}/_bunfork/ready"), "0") != "200" {
        bail!("refuse to retire source: public Bunfork is not ready");
    }
    say(&format!("retiring source Bunfork service on {source_host}"));
    let mut command = ssh_batch(ec2, source_host);
    command.arg("bash -s");
    run_with_stdin(
        &mut command,
        BUNFORK_RETIRE_REMOTE.as_bytes(),
        "retire source Bunfork",
    )?;
    say("source Bunfork service removed; encrypted source data/config retained for rollback");
    Ok(())
}

// ------------------------------------------------------------ mail-autoconfig

pub fn mail_autoconfig(_ec2: &Ec2, action: Option<&str>, domains: &[String]) -> Result<()> {
    match action.unwrap_or("verify") {
        "verify" => {
            let list: Vec<String> = if domains.is_empty() {
                vec!["olibuijr.com".into(), "golfsetridak.is".into()]
            } else {
                domains.to_vec()
            };
            let mut rc = 0;
            for domain in &list {
                say(&format!("autoconfig: {domain}"));
                let mozilla = format!("https://autoconfig.{domain}/mail/config-v1.1.xml");
                let code = curl_code(&mozilla, "000");
                println!("  mozilla   autoconfig.{domain}/mail/config-v1.1.xml -> HTTP {code}");
                if code != "200" {
                    rc = 1;
                }
                let autodiscover =
                    format!("https://autodiscover.{domain}/autodiscover/autodiscover.xml");
                let code = curl_code(&autodiscover, "000");
                println!("  ms        autodiscover.{domain} (POST) -> HTTP {code}");
                if code != "200" {
                    rc = 1;
                }
                for host in [
                    format!("autoconfig.{domain}"),
                    format!("autodiscover.{domain}"),
                ] {
                    let probe = format!(
                        "echo | timeout 10 openssl s_client -servername {} -connect {}:443 2>/dev/null | \
                         openssl x509 -noout -checkhost {} >/dev/null 2>&1",
                        shell_quote(&host),
                        shell_quote(&host),
                        shell_quote(&host)
                    );
                    if Command::new("sh")
                        .arg("-c")
                        .arg(probe)
                        .status()
                        .map(|s| s.success())
                        .unwrap_or(false)
                    {
                        println!("  tls       {host} -> cert matches");
                    } else {
                        println!("  tls       {host} -> MISMATCH/UNREACHABLE");
                        rc = 1;
                    }
                }
                for srv in ["_imaps._tcp", "_submission._tcp", "_autodiscover._tcp"] {
                    let answer = Command::new("dig")
                        .args(["+short", "SRV", &format!("{srv}.{domain}")])
                        .output()
                        .map(|o| {
                            String::from_utf8_lossy(&o.stdout)
                                .replace('\n', " ")
                                .trim()
                                .to_owned()
                        })
                        .unwrap_or_default();
                    println!(
                        "  srv       {srv}.{domain} -> {}",
                        if answer.is_empty() {
                            "<none yet>".to_owned()
                        } else {
                            answer
                        }
                    );
                }
            }
            if rc == 0 {
                say("autoconfig OK (SRV may lag on 1984.is rebuild)");
            } else {
                bail!("autoconfig has failures (see above)");
            }
            Ok(())
        }
        bad => bail!("usage: akurai-build ec2 mail-autoconfig verify [domain...] (got {bad:?})"),
    }
}

// ------------------------------------------------------------------- release

/// Flattened `akurai-deploy.toml` (flat v1 keys, dotted v2 groups, or
/// `[section]` tables), mirroring `libexec/akurai-ec2-manifest.py`.
struct Manifest {
    values: HashMap<String, Value>,
}

const MANIFEST_FLATTEN_SCRIPT: &str = r#"import json,sys,tomllib
with open(sys.argv[1],'rb') as f: cfg=tomllib.load(f)
def flat(prefix,value,out):
 if isinstance(value,dict):
  for k,v in value.items(): flat(f'{prefix}.{k}' if prefix else k,v,out)
 elif prefix!='extra_unit': out[prefix]=value
out={}; flat('',cfg,out); print(json.dumps(out,separators=(',',':')))
"#;

impl Manifest {
    fn load(path: &Path) -> Result<Self> {
        let text = output(
            Command::new("python3")
                .arg("-c")
                .arg(MANIFEST_FLATTEN_SCRIPT)
                .arg(path),
            "parse akurai-deploy.toml",
        )?;
        Ok(Self {
            values: serde_json::from_str(&text)?,
        })
    }

    fn string(&self, keys: &[&str]) -> Option<String> {
        keys.iter()
            .find_map(|key| self.values.get(*key))
            .and_then(|value| match value {
                Value::String(text) => Some(text.clone()),
                Value::Number(number) => Some(number.to_string()),
                Value::Bool(flag) => Some(flag.to_string()),
                _ => None,
            })
    }

    fn required(&self, label: &str, keys: &[&str]) -> Result<String> {
        self.string(keys)
            .filter(|value| !value.is_empty())
            .with_context(|| format!("manifest: '{label}' required"))
    }

    fn boolean(&self, default: bool, keys: &[&str]) -> Result<bool> {
        match self.string(keys).as_deref() {
            None => Ok(default),
            Some("1" | "true" | "yes" | "on") => Ok(true),
            Some("0" | "false" | "no" | "off") => Ok(false),
            Some(value) => bail!(
                "manifest: expected boolean for '{}', got '{value}'",
                keys[0]
            ),
        }
    }

    fn strings(&self, keys: &[&str]) -> Result<Vec<String>> {
        for key in keys {
            if let Some(value) = self.values.get(*key) {
                return match value {
                    Value::Array(items) => items
                        .iter()
                        .map(|item| {
                            item.as_str()
                                .map(str::to_owned)
                                .context("manifest array item must be a string")
                        })
                        .collect(),
                    Value::String(text) => Ok(vec![text.clone()]),
                    _ => bail!("manifest: '{key}' must be a string or string array"),
                };
            }
        }
        Ok(Vec::new())
    }
}

#[derive(Default)]
struct ReleaseArgs {
    mode: Option<String>,
    bump: Option<String>,
    dry: bool,
    changelog: Option<String>,
    yes: bool,
    json: bool,
    workdir: PathBuf,
}

fn set_once(slot: &mut Option<String>, value: &str, label: &str) -> Result<()> {
    if let Some(current) = slot.as_deref() {
        ensure!(
            current == value,
            "release: conflicting {label} values ({current}, {value})"
        );
    }
    *slot = Some(value.to_owned());
    Ok(())
}

fn parse_release_args(args: &[String]) -> Result<ReleaseArgs> {
    let mut parsed = ReleaseArgs {
        workdir: PathBuf::from("."),
        ..Default::default()
    };
    let mut i = 0;
    while i < args.len() {
        match args[i].as_str() {
            "patch" | "minor" | "major" => {
                set_once(&mut parsed.mode, "release", "mode")?;
                set_once(&mut parsed.bump, &args[i], "bump")?;
            }
            "publish" | "ec2" => set_once(&mut parsed.mode, "publish", "mode")?,
            "propagate" => set_once(&mut parsed.mode, "propagate", "mode")?,
            "--mode" => {
                i += 1;
                let value = args.get(i).context("release: --mode requires a value")?;
                ensure!(
                    matches!(value.as_str(), "update" | "publish" | "release"),
                    "release: invalid --mode '{value}' (expected: update, publish, release)"
                );
                set_once(&mut parsed.mode, value, "mode")?;
            }
            "--bump" => {
                i += 1;
                let value = args.get(i).context("release: --bump requires a value")?;
                ensure!(
                    matches!(value.as_str(), "patch" | "minor" | "major"),
                    "release: invalid --bump '{value}' (expected: patch, minor, major)"
                );
                set_once(&mut parsed.bump, value, "bump")?;
            }
            "--dry-run" => parsed.dry = true,
            "--yes" | "-y" => parsed.yes = true,
            "--json" => parsed.json = true,
            "--changelog" => {
                i += 1;
                parsed.changelog = Some(
                    args.get(i)
                        .context("release: --changelog requires a value")?
                        .clone(),
                );
            }
            "-C" => {
                i += 1;
                parsed.workdir =
                    PathBuf::from(args.get(i).context("release: -C requires a directory")?);
            }
            bad => bail!("release: bad arg '{bad}'"),
        }
        i += 1;
    }
    Ok(parsed)
}

/// Synced `~/.cache/akurai-remote-build/<name>` workspace on a build host
/// (bash `remote_sync`/`remote_run`: sync once, then every cargo invocation
/// runs there and the artifact is fetched back).
struct RemoteBuild<'a> {
    ec2: &'a Ec2,
    host: String,
    name: String,
    synced: bool,
}

impl RemoteBuild<'_> {
    fn sync(&mut self) -> Result<()> {
        if self.synced {
            return Ok(());
        }
        let remote = format!(".cache/akurai-remote-build/{}", self.name);
        say(&format!(
            "build_host={}: syncing source → {}:~/{remote}",
            self.host, self.host
        ));
        ssh_host(self.ec2, &self.host, &format!("mkdir -p ~/{remote}"))?;
        rsync(
            self.ec2,
            &[
                "-a".into(),
                "--delete".into(),
                "--exclude".into(),
                "/target".into(),
                "./".into(),
                format!("{}:{remote}/", self.host),
            ],
            "remote source sync",
        )?;
        self.synced = true;
        Ok(())
    }

    fn run(&mut self, command: &str) -> Result<()> {
        self.sync()?;
        ssh_host(
            self.ec2,
            &self.host,
            &format!(
                "cd ~/.cache/akurai-remote-build/{} && export PATH=\"$HOME/.cargo/bin:$PATH\" && {command}",
                self.name
            ),
        )
    }
}

fn gate(_ec2: &Ec2, source: &Path, remote: Option<&mut RemoteBuild>, command: &str) -> Result<()> {
    match remote {
        Some(build) => build.run(command),
        None => {
            let mut process = Command::new("sh");
            process.arg("-c").arg(command).current_dir(source);
            run(&mut process, command)
        }
    }
}

fn build_artifact(
    ec2: &Ec2,
    source: &Path,
    manifest: &Manifest,
    name: &str,
    dry: bool,
    remote: Option<&mut RemoteBuild>,
) -> Result<PathBuf> {
    let mut build = manifest
        .string(&["build", "build.command"])
        .unwrap_or_default();
    let artifact_raw = env::var("AKURAI_RELEASE_ARTIFACT")
        .ok()
        .or_else(|| manifest.string(&["artifact", "build.artifact"]))
        .unwrap_or_default();
    if env::var_os("AKURAI_RELEASE_ARTIFACT").is_some() {
        build.clear(); // env override: ship the prebuilt artifact, no build
    }
    let expanded = artifact_raw
        .strip_prefix('~')
        .map(|rest| env::var("HOME").unwrap_or_default() + rest)
        .unwrap_or(artifact_raw.clone());
    let artifact = source.join(&expanded);
    if build.is_empty() {
        say(&format!(
            "build: (none — using prebuilt artifact {})",
            artifact.display()
        ));
    } else {
        say(&format!("build: {build}"));
    }
    if dry {
        say("  [dry-run] skipping build + artifact check");
        return Ok(artifact);
    }
    if !build.is_empty() {
        if let Some(remote) = remote {
            ensure!(
                !expanded.starts_with('/'),
                "manifest: build_host requires a repo-relative 'artifact' path"
            );
            remote
                .run(&format!(
                    "rustup target add x86_64-unknown-linux-musl >/dev/null 2>&1 || true; {build}"
                ))
                .with_context(|| format!("build failed on {}", remote.host))?;
            if let Some(parent) = artifact.parent() {
                fs::create_dir_all(parent)?;
            }
            rsync(
                ec2,
                &[
                    "-a".into(),
                    format!(
                        "{}:.cache/akurai-remote-build/{name}/{artifact_raw}",
                        remote.host
                    ),
                    artifact.display().to_string(),
                ],
                "remote artifact fetch",
            )?;
        } else {
            run(
                Command::new("sh").arg("-c").arg(&build).current_dir(source),
                "build",
            )?;
        }
    }
    ensure!(
        artifact.is_file(),
        "build artifact not found: {}",
        artifact.display()
    );
    if let Ok(expected) = env::var("AKURAI_RELEASE_SHA256") {
        let actual = output(Command::new("sha256sum").arg(&artifact), "artifact digest")?
            .split_whitespace()
            .next()
            .unwrap_or("")
            .to_owned();
        ensure!(
            actual == expected,
            "build artifact digest mismatch: expected {expected}, got {actual}"
        );
        say(&format!("artifact: verified SHA-256 {actual}"));
    }
    Ok(artifact)
}

const HOOK_SCRIPT: &str = r#"import sys,tomllib
with open(sys.argv[1],'rb') as f: cfg=tomllib.load(f)
print(cfg.get('hooks',{}).get(sys.argv[2],cfg.get(sys.argv[2],'')),end='')
"#;

fn manifest_hook(path: &Path, key: &str) -> Result<String> {
    let value = output(
        Command::new("python3")
            .arg("-c")
            .arg(HOOK_SCRIPT)
            .arg(path)
            .arg(key),
        &format!("parse {key} hook"),
    )?;
    Ok(value)
}

const EXTRA_UNITS_SCRIPT: &str = r#"import json,sys,tomllib
with open(sys.argv[1],'rb') as f: cfg=tomllib.load(f)
units=[]
for u in cfg.get('extra_unit',[]):
 units.append({'name':u.get('name',''),'port':str(u.get('port','')),'domain':u.get('domain','-'),'exec_start':u.get('exec_start',''),'env_file':u.get('env_file',''),'user':u.get('user','ubuntu')})
print(json.dumps(units,separators=(',',':')))
"#;

fn deploy_extra_units(
    ec2: &Ec2,
    source: &Path,
    _manifest: &Manifest,
    artifact: &Path,
    dry: bool,
    harden: bool,
) -> Result<()> {
    let text = output(
        Command::new("python3")
            .arg("-c")
            .arg(EXTRA_UNITS_SCRIPT)
            .arg(source.join("akurai-deploy.toml")),
        "parse extra_unit manifest",
    )?;
    let units: Vec<Value> = serde_json::from_str(&text)?;
    for unit in units {
        let eu_name = unit["name"].as_str().context("extra_unit.name missing")?;
        if eu_name.is_empty() {
            continue;
        }
        let eu_port: u16 = unit["port"]
            .as_str()
            .unwrap_or("")
            .parse()
            .context("extra_unit.port missing")?;
        let eu_domain = unit["domain"].as_str().unwrap_or("-");
        if dry {
            say(&format!(
                "  [dry-run] would: deploy-binary {eu_name} {} {eu_port} (extra unit, shares {eu_name}'s artifact)",
                artifact.display()
            ));
            continue;
        }
        say(&format!("extra unit: {eu_name} on :{eu_port}"));
        let overrides = UnitOverrides {
            exec_start: unit["exec_start"].as_str().map(str::to_owned),
            env_file: unit["env_file"].as_str().map(str::to_owned),
            user: unit["user"].as_str().map(str::to_owned),
            data_dirs: Vec::new(),
            harden,
        };
        deploy_with(ec2, eu_name, artifact, eu_port, None, false, &overrides)?;
        if eu_domain != "-" {
            nginx_proxy(ec2, eu_domain, eu_port)?;
            if let Err(error) = tls(ec2, eu_domain) {
                say(&format!(
                    "  TLS deferred — run 'akurai-build ec2 tls {eu_domain}' once DNS resolves ({error})"
                ));
            }
        }
    }
    Ok(())
}

fn publish(
    ec2: &Ec2,
    source: &Path,
    manifest: &Manifest,
    mode: &str,
    dry: bool,
    json: bool,
) -> Result<()> {
    let name = manifest.required("name (systemd unit)", &["name", "app.name"])?;
    let artifact = build_artifact(ec2, source, manifest, &name, dry, None)?;
    let port: u16 = manifest
        .required("port", &["port", "deploy.port"])?
        .parse()
        .context("manifest: 'port' required to publish")?;
    let appdir = manifest
        .string(&["appdir", "build.appdir"])
        .map(|dir| source.join(dir));
    let domain = manifest.string(&["domain", "app.domain"]);
    let overrides = UnitOverrides {
        exec_start: manifest.string(&["exec_start", "service.exec_start"]),
        env_file: manifest.string(&["env_file", "service.env_file"]),
        user: manifest.string(&["user", "service.user"]),
        data_dirs: manifest.strings(&["data_dir", "service.data_dir"])?,
        harden: manifest.boolean(false, &["harden", "service.harden"])?,
    };
    let cli = manifest.string(&["cli", "deploy.cli"]);
    let cli_target = manifest.string(&["cli_target", "deploy.cli_target"]);
    let mng_nginx = manifest.boolean(true, &["manage_nginx", "deploy.manage_nginx"])?;

    if dry {
        say(&format!(
            "  [dry-run] would: deploy-binary {name} {} {port} {}",
            artifact.display(),
            appdir.as_ref().map_or("", |dir| dir.to_str().unwrap_or(""))
        ));
        if let Some(cli) = &cli {
            let target = cli_target.clone().unwrap_or_else(|| {
                format!("/usr/local/bin/{}", cli.rsplit('/').next().unwrap_or(cli))
            });
            say(&format!("  [dry-run] would install CLI {cli} → {target}"));
        }
        if overrides.exec_start.is_some() {
            say(&format!(
                "  [dry-run]   custom unit: ExecStart='{}' User='{}'{}",
                overrides.exec_start.as_deref().unwrap_or(""),
                overrides.user.as_deref().unwrap_or("ubuntu"),
                overrides
                    .env_file
                    .as_deref()
                    .map_or(String::new(), |f| format!(" EnvironmentFile='{f}'"))
            ));
        }
        match domain.as_deref() {
            Some(domain) if domain != "-" && mng_nginx => {
                say(&format!(
                    "  [dry-run] would: nginx-proxy {domain} {port}; tls {domain}"
                ));
            }
            Some(domain) if domain != "-" => {
                say(&format!(
                    "  [dry-run] nginx: leaving existing {domain} vhost untouched (manage_nginx=false)"
                ));
            }
            _ => {}
        }
        deploy_extra_units(ec2, source, manifest, &artifact, true, overrides.harden)?;
        let prehook = manifest_hook(&source.join("akurai-deploy.toml"), "pre_deploy")?;
        let posthook = manifest_hook(&source.join("akurai-deploy.toml"), "post_deploy")?;
        if !prehook.is_empty() {
            say(&format!("  [dry-run] would run pre_deploy hook: {prehook}"));
        }
        if !posthook.is_empty() {
            say(&format!(
                "  [dry-run] would run post_deploy hook: {posthook}"
            ));
        }
        return Ok(());
    }

    let prehook = manifest_hook(&source.join("akurai-deploy.toml"), "pre_deploy")?;
    if !prehook.is_empty() {
        say("pre-deploy hook");
        let hook = prehook
            .replace("{bin}", &format!("/opt/{name}/bin/{name}"))
            .replace("{port}", &port.to_string())
            .replace("{app}", &format!("/opt/{name}/app"));
        ec2.ssh_streaming(&hook).context("pre_deploy hook failed")?;
    }
    if let Some(dbsnap) = manifest.string(&["db_snapshot", "deploy.db_snapshot"]) {
        let dbkeep: u64 = manifest
            .string(&["db_keep", "deploy.db_keep"])
            .unwrap_or_else(|| "7".into())
            .parse()
            .unwrap_or(7);
        say(&format!("db-snapshot: {dbsnap} (keep {dbkeep})"));
        let snapshot = format!(
            "ts=$(date +%Y%m%d-%H%M%S); sudo systemctl stop {name} 2>/dev/null||true; \
             sudo mkdir -p /var/backups/{name}; [ -d {} ] && sudo cp -a {} /var/backups/{name}/data-$ts || true; \
             sudo systemctl start {name} 2>/dev/null||true; \
             ls -1dt /var/backups/{name}/data-* 2>/dev/null | tail -n +{} | xargs -r sudo rm -rf",
            shell_quote(&dbsnap),
            shell_quote(&dbsnap),
            dbkeep + 1
        );
        if let Err(error) = ec2.ssh_streaming(&snapshot) {
            say(&format!("  snapshot warning (continuing): {error}"));
        }
    }
    deploy_with(
        ec2,
        &name,
        &artifact,
        port,
        appdir.as_deref(),
        json,
        &overrides,
    )?;
    if let Some(cli) = &cli {
        ensure!(Path::new(cli).is_file(), "CLI artifact not found: {cli}");
        let base = cli.rsplit('/').next().unwrap_or(cli);
        let target = cli_target.unwrap_or_else(|| format!("/usr/local/bin/{base}"));
        ensure!(
            target.starts_with("/usr/local/bin/"),
            "manifest: cli_target must be below /usr/local/bin"
        );
        let cli_tmp = format!("/tmp/{name}-{base}.{}", std::process::id());
        say(&format!("installing CLI {base} → {target}"));
        ec2.ship(Path::new(cli), &cli_tmp)?;
        ec2.ssh(&format!(
            "sudo install -o root -g root -m 755 {} {} && rm -f {}",
            shell_quote(&cli_tmp),
            shell_quote(&target),
            shell_quote(&cli_tmp)
        ))
        .context("CLI installation failed")?;
    }
    let published = match domain.as_deref() {
        Some(domain) if domain != "-" => {
            if mng_nginx {
                nginx_proxy(ec2, domain, port)?;
                if let Err(error) = tls(ec2, domain) {
                    say(&format!(
                        "  TLS deferred — run 'akurai-build ec2 tls {domain}' once DNS resolves ({error})"
                    ));
                }
            }
            say(&format!("published → https://{domain}"));
            Some(domain.to_owned())
        }
        _ => {
            say(&format!("published (internal: 127.0.0.1:{port})"));
            None
        }
    };
    deploy_extra_units(ec2, source, manifest, &artifact, false, overrides.harden)?;
    let posthook = manifest_hook(&source.join("akurai-deploy.toml"), "post_deploy")?;
    if !posthook.is_empty() {
        say("post-deploy hook");
        let hook = posthook
            .replace("{bin}", &format!("/opt/{name}/bin/{name}"))
            .replace("{port}", &port.to_string())
            .replace("{app}", &format!("/opt/{name}/app"));
        ec2.ssh_streaming(&hook)
            .context("post_deploy hook failed")?;
    }
    if json {
        let public_http_status: u32 = published
            .as_ref()
            .map(|domain| {
                curl_code(&format!("https://{domain}/"), "0")
                    .parse()
                    .unwrap_or(0)
            })
            .unwrap_or(0);
        let tls = published
            .as_ref()
            .map_or(Value::Null, |domain| cert_status_capture(domain));
        let receipt = json!({
            "app": name,
            "domain": published,
            "mode": mode,
            "public_http_status": public_http_status,
            "tls": tls,
            "ok": public_http_status > 0 || published.is_none(),
        });
        println!("{receipt}");
    }
    say(&format!("{mode}: {name} ✓"));
    Ok(())
}

fn propagate(ec2: &Ec2, dry: bool) -> Result<()> {
    let home = env::var("HOME")?;
    let source = PathBuf::from(&home).join("Projects/AkurAI-Framework");
    let manifest = Manifest::load(&source.join("akurai-deploy.toml"))?;
    let fw_build_host = manifest.string(&["build_host", "build.host"]);
    say("propagate: building Framework binary");
    let mut remote = fw_build_host.as_ref().map(|host| RemoteBuild {
        ec2,
        host: host.clone(),
        name: "akurai-framework".into(),
        synced: false,
    });
    let artifact = source.join("target/x86_64-unknown-linux-musl/release/akurai");
    let fw_build = "cargo build --release --target x86_64-unknown-linux-musl -p akurai";
    say(&format!(
        "build: {fw_build}{}",
        fw_build_host
            .as_deref()
            .map_or(String::new(), |h| format!(" (on {h})"))
    ));
    // bash propagate ignores --dry-run: it always builds and deploys.
    if let Some(build) = remote.as_mut() {
        build
            .run(&format!(
                "rustup target add x86_64-unknown-linux-musl >/dev/null 2>&1 || true; {fw_build}"
            ))
            .with_context(|| format!("Framework build failed on {}", build.host))?;
        fs::create_dir_all(artifact.parent().unwrap_or(&source))?;
        rsync(
            ec2,
            &[
                "-a".into(),
                format!(
                    "{}:.cache/akurai-remote-build/akurai-framework/target/x86_64-unknown-linux-musl/release/akurai",
                    build.host
                ),
                artifact.display().to_string(),
            ],
            "remote artifact fetch",
        )?;
    } else {
        run(
            Command::new("sh")
                .arg("-c")
                .arg(fw_build)
                .current_dir(&source),
            "Framework build",
        )?;
    }
    if dry {
        return Ok(());
    }
    ensure!(
        artifact.is_file(),
        "build artifact not found: {}",
        artifact.display()
    );
    let apps_path = source.join("AKURAI_APPS.toml");
    ensure!(
        apps_path.is_file(),
        "AKURAI_APPS.toml not found at {}",
        apps_path.display()
    );
    let script = r#"import json,sys,tomllib
with open(sys.argv[1],'rb') as f: cfg=tomllib.load(f)
print(json.dumps([a for a in cfg.get('app',[]) if a.get('default_action')=='publish' or a.get('framework_based')],separators=(',',':')))
"#;
    let apps: Vec<Value> = serde_json::from_str(&output(
        Command::new("python3")
            .arg("-c")
            .arg(script)
            .arg(&apps_path),
        "parse AKURAI_APPS.toml",
    )?)?;
    for app in apps {
        let app_name = app["name"]
            .as_str()
            .context("AKURAI_APPS.toml app.name missing")?;
        if app_name.is_empty() {
            continue;
        }
        let app_domain = app["domain"].as_str().unwrap_or("");
        let app_port: u16 = app["port"]
            .as_u64()
            .context("AKURAI_APPS.toml app.port missing")? as u16;
        let app_dir = app["appdir"]
            .as_str()
            .filter(|dir| !dir.is_empty())
            .map(|dir| source.join(dir));
        say(&format!(
            "propagate: deploying {app_name} → {app_domain}:{app_port}"
        ));
        deploy_binary(
            ec2,
            app_name,
            &artifact,
            app_port,
            app_dir.as_deref(),
            false,
        )?;
        if !app_domain.is_empty() {
            let _ = nginx_proxy(ec2, app_domain, app_port);
        }
        say(&format!("propagate: {app_name} ✓"));
    }
    say("propagate: done");
    Ok(())
}

/// Unified release engine entrypoint. `args` is the raw trailing argv so the
/// legacy aliases (`patch|minor|major|publish|ec2|propagate`) and flags
/// (`--mode`, `--bump`, `--dry-run`, `--changelog`, `--json`, `--yes`, `-C`)
/// parse with bash fidelity. Version bump + changelog + commit + tag delegate
/// to `crate::release::release`.
pub fn run_release(ec2: &Ec2, args: &[String]) -> Result<()> {
    let mut args = parse_release_args(args)?;
    let source = args
        .workdir
        .canonicalize()
        .with_context(|| format!("cannot cd to {}", args.workdir.display()))?;
    let manifest_path = source.join("akurai-deploy.toml");
    ensure!(
        manifest_path.is_file(),
        "no akurai-deploy.toml in {} — every app needs one",
        source.display()
    );
    let manifest = Manifest::load(&manifest_path)?;
    if let Some(default) = (args.mode.is_none() && args.bump.is_none())
        .then(|| {
            manifest.string(&[
                "release.default_mode",
                "release.default_action",
                "default_action",
            ])
        })
        .flatten()
    {
        match default.as_str() {
            "update" | "publish" | "release" => args.mode = Some(default),
            "ec2" => args.mode = Some("publish".into()),
            "patch" | "minor" | "major" => {
                args.mode = Some("release".into());
                args.bump = Some(default);
            }
            bad => bail!("manifest: unsupported release default '{bad}'"),
        }
    }
    if args.bump.is_some() && args.mode.is_none() {
        args.mode = Some("release".into());
    }
    let mode = args.mode.as_deref().unwrap_or("release");
    ensure!(
        mode == "release" || args.bump.is_none(),
        "release: --bump applies only to release mode (current mode: {mode})"
    );
    if mode == "propagate" {
        return propagate(ec2, args.dry);
    }
    let name = manifest.required("name (systemd unit)", &["name", "app.name"])?;
    say(&format!(
        "{mode}: {name} @ {}{}",
        source.display(),
        if args.dry { " [DRY-RUN]" } else { "" }
    ));
    if matches!(mode, "publish" | "update") {
        return publish(ec2, &source, &manifest, mode, args.dry, args.json);
    }
    let build_host = manifest.string(&["build_host", "build.host"]);
    let mut remote = build_host.as_ref().map(|host| RemoteBuild {
        ec2,
        host: host.clone(),
        name: name.clone(),
        synced: false,
    });
    for (enabled, label, command) in [
        (
            manifest.boolean(true, &["gate_fmt", "release.gates.fmt"])?,
            "rustfmt",
            "cargo fmt --all -- --check",
        ),
        (
            manifest.boolean(true, &["gate_clippy", "release.gates.clippy"])?,
            "clippy -D warnings",
            "cargo clippy --all-targets -- -D warnings",
        ),
        (
            manifest.boolean(true, &["gate_test", "release.gates.test"])?,
            "tests",
            "cargo test --workspace",
        ),
    ] {
        if enabled {
            say(&format!(
                "gate: {label}{}",
                build_host
                    .as_deref()
                    .map_or(String::new(), |host| format!(" (on {host})"))
            ));
            if !args.dry {
                gate(ec2, &source, remote.as_mut(), command)?;
            }
        }
    }
    let bump = args.bump.as_deref().unwrap_or("patch");
    if args.dry {
        say(&format!(
            "[dry-run] would run release engine with {bump} bump"
        ));
        let _ = (args.yes, args.changelog);
    } else {
        let notes = args.changelog.as_deref().map(|text| {
            if text.starts_with("- ") {
                text.to_owned()
            } else {
                format!("- {text}")
            }
        });
        let outcome = crate::release::release(&source, bump, notes.as_deref())?;
        say(&format!(
            "version: {} → {} ({bump})",
            outcome.previous_version, outcome.version
        ));
        let build_repo = manifest
            .string(&["build_repo", "app.build_repo"])
            .or_else(|| {
                manifest
                    .string(&["repo", "app.repo"])
                    .and_then(|repo| repo.rsplit('/').next().map(str::to_owned))
            })
            .context("manifest: 'build_repo' required to publish the release commit")?;
        say(&format!("git: sync {build_repo} to AkurAI Build"));
        let deploy = PathBuf::from(env::var("HOME")?).join("Projects/AkurAI-Build/deploy.sh");
        ensure!(
            deploy.is_file(),
            "AkurAI Build deploy entrypoint missing: {}",
            deploy.display()
        );
        run(
            Command::new(deploy).args([
                "cli",
                "repo",
                "sync",
                &build_repo,
                &source.display().to_string(),
            ]),
            "AkurAI Build repository sync",
        )?;
        say(&format!(
            "git: commit + tag {} ({})",
            outcome.tag, outcome.commit
        ));
    }
    publish(ec2, &source, &manifest, mode, args.dry, args.json)
}