Menu
AkurAI-Build
publicLatest change b5908a988216fc900158c35b037d3f25008b0056 - ec2: emit the HTTPS server block directly instead of re-asserting via certbot by Ólafur Búi Ólafsson
//! 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 has_certificate = ec2
.ssh(&format!(
"sudo test -f /etc/letsencrypt/live/{domain}/fullchain.pem && \
sudo test -f /etc/letsencrypt/live/{domain}/privkey.pem"
))
.is_ok();
let proxy = format!(
"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 }}"
);
let http_action = if has_certificate {
"return 301 https://$host$request_uri;".to_string()
} else {
proxy.clone()
};
let https_server = if has_certificate {
format!(
"\nserver {{\n listen 443 ssl;\n listen [::]:443 ssl;\n server_name {domain};\n\
ssl_certificate /etc/letsencrypt/live/{domain}/fullchain.pem;\n\
ssl_certificate_key /etc/letsencrypt/live/{domain}/privkey.pem;\n\
ssl_protocols TLSv1.2 TLSv1.3;\n\n {proxy}\n}}\n"
)
} else {
String::new()
};
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 {http_action}\n}}\n\
{https_server}"
);
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)
))?;
let scheme = if has_certificate { "HTTPS" } else { "HTTP" };
say(&format!(
"nginx vhost live for {domain} ({scheme}). Public IP: {}",
ec2.public_ip
));
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
rollback() {
sudo systemctl stop bunfork.service || true
sudo rm -f /var/lib/bunfork/bunfork.db /etc/bunfork/db.key /etc/bunfork/api.token
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
}
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
rollback
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
rollback
echo "migrated Bunfork did not become ready; restored EC2 pre-migration state" >&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()]
} 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::try_from(
app["port"]
.as_u64()
.context("AKURAI_APPS.toml app.port missing")?,
)
.context("AKURAI_APPS.toml app.port out of range")?;
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() {
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)
}