#![allow(clippy::unwrap_used, clippy::expect_used)]
use std::io::{Read, Write};
use std::net::{SocketAddr, TcpStream};
use std::path::{Path, PathBuf};
use std::process::{Command, Output};
use std::sync::Arc;
use std::sync::atomic::{AtomicBool, AtomicU64, Ordering};
use std::thread;
use std::time::{Duration, Instant};
use testcontainers::core::{CgroupnsMode, ContainerPort, Mount, WaitFor};
use testcontainers::runners::SyncRunner as _;
use testcontainers::{Container, GenericImage, ImageExt};
const APP_NAME: &str = "e2eapp";
const PUBLIC_PORT: u16 = 8080;
const READINESS_TIMEOUT_SECS: u64 = 12;
const SIGNING_SECRET: &str = "a1b2c3d4e5f60718293a4b5c6d7e8f90a1b2c3d4e5f60718293a4b5c6d7e8f90";
fn run_ok(cmd: &mut Command, what: &str) -> Output {
let out = cmd
.output()
.unwrap_or_else(|e| panic!("failed to spawn {what}: {e}"));
assert!(
out.status.success(),
"{what} failed (status {:?})\n--- stdout ---\n{}\n--- stderr ---\n{}",
out.status.code(),
String::from_utf8_lossy(&out.stdout),
String::from_utf8_lossy(&out.stderr),
);
out
}
fn ssh_cmd(key: &Path, known_hosts: &Path, port: u16, remote: &str) -> Command {
let mut c = Command::new("ssh");
c.args([
"-i",
&key.display().to_string(),
"-p",
&port.to_string(),
"-o",
"BatchMode=yes",
"-o",
"StrictHostKeyChecking=accept-new",
"-o",
&format!("UserKnownHostsFile={}", known_hosts.display()),
"-o",
"ConnectTimeout=5",
"root@127.0.0.1",
remote,
]);
c
}
struct SshAgent {
sock: PathBuf,
pid: Option<u32>,
}
impl SshAgent {
fn start(key: &Path, dir: &Path) -> Self {
let sock = dir.join("agent.sock");
let _ = std::fs::remove_file(&sock);
let out = run_ok(
Command::new("ssh-agent").args(["-a", &sock.display().to_string()]),
"ssh-agent",
);
let stdout = String::from_utf8_lossy(&out.stdout);
let pid = stdout
.split("SSH_AGENT_PID=")
.nth(1)
.and_then(|s| s.split(';').next())
.and_then(|s| s.trim().parse::<u32>().ok());
run_ok(
Command::new("ssh-add")
.arg(key.display().to_string())
.env("SSH_AUTH_SOCK", &sock),
"ssh-add throwaway key",
);
Self { sock, pid }
}
}
impl Drop for SshAgent {
fn drop(&mut self) {
if let Some(pid) = self.pid {
let _ = Command::new("kill").arg(pid.to_string()).output();
}
let _ = std::fs::remove_file(&self.sock);
}
}
fn http_get(host_port: u16, path: &str) -> std::io::Result<(u16, String)> {
let mut stream = TcpStream::connect_timeout(
&format!("127.0.0.1:{host_port}").parse().unwrap(),
Duration::from_secs(3),
)?;
stream.set_read_timeout(Some(Duration::from_secs(3)))?;
stream.set_write_timeout(Some(Duration::from_secs(3)))?;
write!(
stream,
"GET {path} HTTP/1.0\r\nHost: 127.0.0.1\r\nConnection: close\r\n\r\n"
)?;
let mut raw = Vec::new();
stream.read_to_end(&mut raw)?;
let text = String::from_utf8_lossy(&raw).into_owned();
let status = text
.lines()
.next()
.and_then(|l| l.split_whitespace().nth(1))
.and_then(|c| c.parse::<u16>().ok())
.ok_or_else(|| std::io::Error::other("no status line"))?;
let body = text
.split_once("\r\n\r\n")
.map_or("", |(_, b)| b)
.to_string();
Ok((status, body))
}
fn wait_for_http_ok(host_port: u16, path: &str, timeout: Duration) -> String {
let deadline = Instant::now() + timeout;
let mut last = String::from("(never connected)");
while Instant::now() < deadline {
match http_get(host_port, path) {
Ok((200, body)) => return body,
Ok((code, body)) => last = format!("status {code}: {body}"),
Err(e) => last = format!("error: {e}"),
}
thread::sleep(Duration::from_millis(200));
}
panic!("timed out waiting for 200 on {path} (public port {host_port}); last = {last}");
}
struct Prober {
stop: Arc<AtomicBool>,
total: Arc<AtomicU64>,
failures: Arc<AtomicU64>,
handle: Option<thread::JoinHandle<()>>,
}
impl Prober {
fn start(host_port: u16) -> Self {
let stop = Arc::new(AtomicBool::new(false));
let total = Arc::new(AtomicU64::new(0));
let failures = Arc::new(AtomicU64::new(0));
let (s, t, f) = (stop.clone(), total.clone(), failures.clone());
let handle = thread::spawn(move || {
while !s.load(Ordering::Relaxed) {
t.fetch_add(1, Ordering::Relaxed);
match http_get(host_port, "/") {
Ok((200, _)) => {}
_ => {
f.fetch_add(1, Ordering::Relaxed);
}
}
thread::sleep(Duration::from_millis(100));
}
});
Self {
stop,
total,
failures,
handle: Some(handle),
}
}
fn finish(mut self) -> (u64, u64) {
self.stop.store(true, Ordering::Relaxed);
if let Some(h) = self.handle.take() {
let _ = h.join();
}
(
self.total.load(Ordering::Relaxed),
self.failures.load(Ordering::Relaxed),
)
}
}
fn extract_kamal_proxy(dest: &Path) {
let create = run_ok(
Command::new("docker").args(["create", "basecamp/kamal-proxy:latest"]),
"docker create basecamp/kamal-proxy",
);
let cid = String::from_utf8_lossy(&create.stdout).trim().to_string();
let cp = Command::new("docker")
.args([
"cp",
&format!("{cid}:/usr/local/bin/kamal-proxy"),
&dest.display().to_string(),
])
.output()
.expect("docker cp kamal-proxy");
let _ = Command::new("docker").args(["rm", "-f", &cid]).output();
assert!(
cp.status.success(),
"docker cp kamal-proxy failed: {}",
String::from_utf8_lossy(&cp.stderr)
);
}
const FIXTURE_TAG_DEFAULT: &str = "autumn-deploy-e2e:test";
const FIXTURE_TAG_PAM: &str = "autumn-deploy-e2e:pam";
const FIXTURE_TAG_FLEET: &str = "autumn-deploy-e2e:fleet";
fn build_fixture_image(ctx: &Path, pubkey: &str, enable_pam: bool) -> String {
let tag = if enable_pam {
FIXTURE_TAG_PAM
} else {
FIXTURE_TAG_DEFAULT
};
build_fixture_image_as(ctx, pubkey, enable_pam, tag)
}
fn build_fixture_image_as(ctx: &Path, pubkey: &str, enable_pam: bool, tag: &str) -> String {
std::fs::write(
ctx.join("Dockerfile"),
include_str!("fixtures/deploy/Dockerfile"),
)
.expect("write Dockerfile");
std::fs::write(ctx.join("authorized_keys"), pubkey).expect("write authorized_keys");
extract_kamal_proxy(&ctx.join("kamal-proxy"));
run_ok(
Command::new("docker").args([
"build",
"--build-arg",
&format!("ENABLE_PAM_SYSTEMD={}", u8::from(enable_pam)),
"-t",
tag,
&ctx.display().to_string(),
]),
"docker build fixture image",
);
tag.to_owned()
}
fn compile_app(src_dir: &Path, out: &Path, version: &str, refuse_ready: bool) {
let src = include_str!("fixtures/deploy/app_template.rs")
.replace("__VERSION__", version)
.replace(
"__REFUSE_READY__",
if refuse_ready { "true" } else { "false" },
);
let src_path = src_dir.join(format!("app_{version}.rs"));
std::fs::write(&src_path, src).expect("write app source");
run_ok(
Command::new("rustc").args([
"-O",
"-C",
"target-feature=+crt-static",
&src_path.display().to_string(),
"-o",
&out.display().to_string(),
]),
&format!("rustc compile app {version}"),
);
}
struct Workspace {
_root: tempfile::TempDir,
home: PathBuf,
project: PathBuf,
key: PathBuf,
known_hosts: PathBuf,
pubkey: String,
agent: SshAgent,
app_v1: PathBuf,
app_v2: PathBuf,
app_bad: PathBuf,
}
impl Workspace {
fn build() -> Self {
let root = tempfile::tempdir().expect("tempdir");
let base = root.path().to_path_buf();
let home = base.join("home");
let ssh_dir = home.join(".ssh");
let project = base.join("project");
let bins = base.join("bins");
let src = base.join("src");
for d in [&ssh_dir, &project, &bins, &src] {
std::fs::create_dir_all(d).expect("mkdir");
}
let key = ssh_dir.join("id_ed25519");
run_ok(
Command::new("ssh-keygen").args([
"-t",
"ed25519",
"-N",
"",
"-q",
"-f",
&key.display().to_string(),
]),
"ssh-keygen",
);
let pubkey = std::fs::read_to_string(ssh_dir.join("id_ed25519.pub")).expect("read pubkey");
let known_hosts = ssh_dir.join("known_hosts");
std::fs::write(&known_hosts, "").expect("touch known_hosts");
set_mode(&ssh_dir, 0o700);
set_mode(&key, 0o600);
let agent = SshAgent::start(&key, &base);
let app_v1 = bins.join("app_v1");
let app_v2 = bins.join("app_v2");
let app_bad = bins.join("app_bad");
compile_app(&src, &app_v1, "v1", false);
compile_app(&src, &app_v2, "v2", false);
compile_app(&src, &app_bad, "bad", true);
Self {
_root: root,
home,
project,
key,
known_hosts,
pubkey,
agent,
app_v1,
app_v2,
app_bad,
}
}
fn write_config(&self, ssh_port: u16) {
std::fs::write(
self.project.join("autumn.toml"),
format!(
"[server]\nport = {PUBLIC_PORT}\n\n\
[deploy]\nhost = \"127.0.0.1\"\nuser = \"root\"\nssh_port = {ssh_port}\n\
app_name = \"{APP_NAME}\"\nreadiness_timeout_secs = {READINESS_TIMEOUT_SECS}\n",
),
)
.expect("write autumn.toml");
}
fn write_config_fleet(&self, hosts: &[&str], ssh_port: u16) {
let list = hosts
.iter()
.map(|host| format!("\"{host}\""))
.collect::<Vec<_>>()
.join(", ");
std::fs::write(
self.project.join("autumn.toml"),
format!(
"[server]\nport = {PUBLIC_PORT}\n\n\
[deploy]\nhosts = [{list}]\nuser = \"root\"\nssh_port = {ssh_port}\n\
app_name = \"{APP_NAME}\"\nreadiness_timeout_secs = {READINESS_TIMEOUT_SECS}\n",
),
)
.expect("write fleet autumn.toml");
}
fn stage_release(&self, bin: &Path) {
let rel = self.project.join("target").join("release");
std::fs::create_dir_all(&rel).expect("mkdir target/release");
std::fs::copy(bin, rel.join(APP_NAME)).expect("stage release binary");
}
fn autumn_deploy(&self, args: &[&str]) -> Output {
let mut c = Command::new(env!("CARGO_BIN_EXE_autumn"));
c.arg("deploy")
.args(args)
.current_dir(&self.project)
.env("HOME", &self.home)
.env("SSH_AUTH_SOCK", &self.agent.sock)
.env("AUTUMN_SECURITY__SIGNING_SECRET", SIGNING_SECRET);
c.output().expect("spawn autumn deploy")
}
fn ssh(&self, port: u16, remote: &str) -> Output {
ssh_cmd(&self.key, &self.known_hosts, port, remote)
.output()
.expect("spawn ssh")
}
}
#[cfg(unix)]
fn set_mode(path: &Path, mode: u32) {
use std::os::unix::fs::PermissionsExt as _;
std::fs::set_permissions(path, std::fs::Permissions::from_mode(mode)).expect("chmod");
}
#[cfg(not(unix))]
const fn set_mode(_path: &Path, _mode: u32) {}
fn wait_for_boot(ws: &Workspace, ssh_port: u16) {
let deadline = Instant::now() + Duration::from_secs(150);
let mut last = String::new();
while Instant::now() < deadline {
let out = ws.ssh(ssh_port, "systemctl is-system-running || true");
let stdout = String::from_utf8_lossy(&out.stdout);
let stderr = String::from_utf8_lossy(&out.stderr);
last = format!("stdout={stdout:?} stderr={stderr:?}");
let state = stdout.trim();
if state == "running" || state == "degraded" {
return;
}
thread::sleep(Duration::from_millis(500));
}
panic!("container systemd never finished booting; last = {last}");
}
#[test]
#[ignore = "requires Docker + ssh client; run with --ignored"]
#[allow(clippy::too_many_lines)]
fn deploy_e2e_full_lifecycle() {
let ws = Workspace::build();
let ctx = tempfile::tempdir().expect("ctx tempdir");
let image_tag = build_fixture_image(ctx.path(), &ws.pubkey, false);
let (repo, tag) = image_tag.split_once(':').unwrap();
let container = GenericImage::new(repo.to_string(), tag.to_string())
.with_exposed_port(ContainerPort::Tcp(22))
.with_exposed_port(ContainerPort::Tcp(PUBLIC_PORT))
.with_wait_for(WaitFor::Nothing)
.with_privileged(true)
.with_cgroupns_mode(CgroupnsMode::Host)
.with_mount(Mount::bind_mount("/sys/fs/cgroup", "/sys/fs/cgroup"))
.with_mount(Mount::tmpfs_mount("/run"))
.with_mount(Mount::tmpfs_mount("/run/lock"))
.with_startup_timeout(Duration::from_secs(180))
.start()
.expect("start fixture container");
let ssh_port = container
.get_host_port_ipv4(ContainerPort::Tcp(22))
.expect("mapped ssh port");
let public_host_port = container
.get_host_port_ipv4(ContainerPort::Tcp(PUBLIC_PORT))
.expect("mapped public port");
eprintln!("[e2e] container up: ssh_port={ssh_port} public_port={public_host_port}");
wait_for_boot(&ws, ssh_port);
eprintln!("[e2e] container systemd booted");
ws.write_config(ssh_port);
ws.stage_release(&ws.app_v1);
let out = ws.autumn_deploy(&["up"]);
assert!(
out.status.success(),
"first `deploy up` failed:\n{}",
String::from_utf8_lossy(&out.stderr)
);
let first_deploy_log = String::from_utf8_lossy(&out.stderr).into_owned();
let body = wait_for_http_ok(public_host_port, "/", Duration::from_secs(30));
assert!(
body.contains("e2eapp v1"),
"first deploy should serve v1, got: {body:?}"
);
eprintln!(
"[e2e] 1. first deploy OK — public port serves {:?}",
body.trim()
);
let en = ws.ssh(
ssh_port,
&format!("systemctl is-enabled {APP_NAME}-blue.service || true"),
);
assert!(
String::from_utf8_lossy(&en.stdout).trim() == "enabled",
"blue slot unit should be enabled for boot survival: {:?}",
String::from_utf8_lossy(&en.stdout)
);
eprintln!("[e2e] (simulated reboot survival: {APP_NAME}-blue.service is enabled)");
let migrated = ws.ssh(
ssh_port,
&format!(
"test -f /srv/autumn/{APP_NAME}/current/{APP_NAME}.migrated && echo present \
|| echo missing"
),
);
assert_eq!(
String::from_utf8_lossy(&migrated.stdout).trim(),
"present",
"the first deploy must run its pending migrations (#1607, AC-3)"
);
let op_line = |label: &str| format!("\u{2192} {label}\n");
let migrate_at = first_deploy_log
.find(&op_line("migrate"))
.expect("the first deploy logs its migrate op");
let start_at = first_deploy_log
.find(&op_line("enable-now"))
.expect("the first deploy logs its app start");
assert!(
migrate_at < start_at,
"the first deploy must migrate before starting the release:\n{first_deploy_log}"
);
eprintln!("[e2e] (#1607 AC-3: pending migrations ran before the first release started)");
let manifest_present = ws.ssh(
ssh_port,
&format!(
"test -f /srv/autumn/{APP_NAME}/current/autumn.toml && echo present || echo missing"
),
);
assert_eq!(
String::from_utf8_lossy(&manifest_present.stdout).trim(),
"present",
"the project autumn.toml should be uploaded to the release dir (#1952)"
);
let unit_has_manifest_dir = ws.ssh(
ssh_port,
&format!("grep -c 'AUTUMN_MANIFEST_DIR=/srv/autumn/{APP_NAME}/releases/' /etc/systemd/system/{APP_NAME}-blue.service || true"),
);
assert!(
String::from_utf8_lossy(&unit_has_manifest_dir.stdout)
.trim()
.parse::<u32>()
.unwrap_or(0)
>= 1,
"the slot unit should set AUTUMN_MANIFEST_DIR to the release dir (#1952)"
);
eprintln!(
"[e2e] (#1952: autumn.toml uploaded to the release dir and AUTUMN_MANIFEST_DIR set)"
);
thread::sleep(Duration::from_millis(1200)); ws.stage_release(&ws.app_v2);
let prober = Prober::start(public_host_port);
let out = ws.autumn_deploy(&["up"]);
let (total, failures) = prober.finish();
assert!(
out.status.success(),
"redeploy `deploy up` failed:\n{}",
String::from_utf8_lossy(&out.stderr)
);
assert_eq!(
failures, 0,
"zero-downtime redeploy dropped {failures}/{total} requests"
);
assert!(total > 0, "prober made no requests");
let body = wait_for_http_ok(public_host_port, "/", Duration::from_secs(30));
assert!(
body.contains("e2eapp v2"),
"redeploy should serve v2, got: {body:?}"
);
eprintln!(
"[e2e] 2. zero-downtime redeploy OK — {failures}/{total} requests failed during cutover; now serves {:?}",
body.trim()
);
let out = ws.autumn_deploy(&["rollback"]);
assert!(
out.status.success(),
"`deploy rollback` failed:\n{}",
String::from_utf8_lossy(&out.stderr)
);
let body = wait_for_http_ok(public_host_port, "/", Duration::from_secs(30));
assert!(
body.contains("e2eapp v1"),
"rollback should restore v1, got: {body:?}"
);
eprintln!(
"[e2e] 3. on-demand rollback OK — restored {:?}",
body.trim()
);
thread::sleep(Duration::from_millis(1200));
ws.stage_release(&ws.app_bad);
let prober = Prober::start(public_host_port);
let out = ws.autumn_deploy(&["up"]);
let (total, failures) = prober.finish();
assert!(
!out.status.success(),
"forced-failure deploy should exit non-zero (readiness gate must time out)"
);
let stderr = String::from_utf8_lossy(&out.stderr);
assert!(
stderr.contains("The previous release now runs on the migrated schema."),
"a single-host rollback after `migrate` must name the schema:\n{stderr}"
);
assert_eq!(
failures, 0,
"old release should keep serving through the failed attempt: {failures}/{total} dropped"
);
let body = wait_for_http_ok(public_host_port, "/", Duration::from_secs(10));
assert!(
body.contains("e2eapp v1"),
"after forced-failure the old v1 release must still serve, got: {body:?}"
);
eprintln!(
"[e2e] 4. forced-failure auto-rollback OK — deploy exited non-zero, {failures}/{total} \
requests failed, old release still serves {:?}",
body.trim()
);
eprintln!("[e2e] all deploy AC-7 lifecycle assertions passed");
drop(container);
let _ = Command::new("docker")
.args(["rmi", "-f", &image_tag])
.output();
}
#[test]
#[ignore = "requires Docker + ssh client; run with --ignored"]
fn deploy_e2e_pam_systemd_control_socket() {
let ws = Workspace::build();
let ctx = tempfile::tempdir().expect("ctx tempdir");
let image_tag = build_fixture_image(ctx.path(), &ws.pubkey, true);
let (repo, tag) = image_tag.split_once(':').unwrap();
let container = GenericImage::new(repo.to_string(), tag.to_string())
.with_exposed_port(ContainerPort::Tcp(22))
.with_exposed_port(ContainerPort::Tcp(PUBLIC_PORT))
.with_wait_for(WaitFor::Nothing)
.with_privileged(true)
.with_cgroupns_mode(CgroupnsMode::Host)
.with_mount(Mount::bind_mount("/sys/fs/cgroup", "/sys/fs/cgroup"))
.with_mount(Mount::tmpfs_mount("/run"))
.with_mount(Mount::tmpfs_mount("/run/lock"))
.with_startup_timeout(Duration::from_secs(180))
.start()
.expect("start pam fixture container");
let ssh_port = container
.get_host_port_ipv4(ContainerPort::Tcp(22))
.expect("mapped ssh port");
let public_host_port = container
.get_host_port_ipv4(ContainerPort::Tcp(PUBLIC_PORT))
.expect("mapped public port");
wait_for_boot(&ws, ssh_port);
ws.write_config(ssh_port);
let xdg = ws.ssh(ssh_port, "printf %s \"${XDG_RUNTIME_DIR:-<unset>}\"");
let xdg = String::from_utf8_lossy(&xdg.stdout).into_owned();
assert_eq!(
xdg.trim(),
"/run/user/0",
"pam_systemd should set XDG_RUNTIME_DIR=/run/user/0 in ssh sessions (real-host shape)"
);
ws.stage_release(&ws.app_v1);
let out = ws.autumn_deploy(&["up"]);
assert!(
out.status.success(),
"first `deploy up` under pam_systemd failed (control-socket mismatch?):\n{}",
String::from_utf8_lossy(&out.stderr)
);
let body = wait_for_http_ok(public_host_port, "/", Duration::from_secs(30));
assert!(
body.contains("e2eapp v1"),
"deploy under real pam_systemd should serve v1 through kamal-proxy, got: {body:?}"
);
drop(container);
let _ = Command::new("docker")
.args(["rmi", "-f", &image_tag])
.output();
}
struct FleetNode {
_container: Container<GenericImage>,
ssh_port: u16,
public_port: u16,
ip: String,
}
fn container_bridge_ip(id: &str) -> String {
let out = run_ok(
Command::new("docker").args([
"inspect",
"-f",
"{{range .NetworkSettings.Networks}}{{.IPAddress}}{{end}}",
id,
]),
"docker inspect container bridge ip",
);
let ip = String::from_utf8_lossy(&out.stdout).trim().to_owned();
assert!(
ip.parse::<std::net::Ipv4Addr>().is_ok(),
"expected an IPv4 bridge address for container {id}, got {ip:?} \
(the fleet e2e names hosts by bridge IP — see the note on FleetNode)"
);
ip
}
fn start_fleet_node(image_tag: &str) -> FleetNode {
let (repo, tag) = image_tag.split_once(':').unwrap();
let container = GenericImage::new(repo.to_string(), tag.to_string())
.with_exposed_port(ContainerPort::Tcp(22))
.with_exposed_port(ContainerPort::Tcp(PUBLIC_PORT))
.with_wait_for(WaitFor::Nothing)
.with_privileged(true)
.with_cgroupns_mode(CgroupnsMode::Host)
.with_network("bridge")
.with_mount(Mount::bind_mount("/sys/fs/cgroup", "/sys/fs/cgroup"))
.with_mount(Mount::tmpfs_mount("/run"))
.with_mount(Mount::tmpfs_mount("/run/lock"))
.with_startup_timeout(Duration::from_secs(180))
.start()
.expect("start fleet fixture container");
let ssh_port = container
.get_host_port_ipv4(ContainerPort::Tcp(22))
.expect("mapped ssh port");
let public_port = container
.get_host_port_ipv4(ContainerPort::Tcp(PUBLIC_PORT))
.expect("mapped public port");
let ip = container_bridge_ip(container.id());
FleetNode {
_container: container,
ssh_port,
public_port,
ip,
}
}
fn assert_bridge_reachable(ip: &str) {
let addr: SocketAddr = format!("{ip}:22")
.parse()
.unwrap_or_else(|e| panic!("bad bridge address {ip}:22: {e}"));
let deadline = Instant::now() + Duration::from_secs(30);
let mut last = String::from("(never attempted)");
while Instant::now() < deadline {
match TcpStream::connect_timeout(&addr, Duration::from_secs(3)) {
Ok(_) => return,
Err(e) => last = e.to_string(),
}
thread::sleep(Duration::from_millis(300));
}
panic!(
"container bridge address {ip}:22 is not reachable from this host ({last}). The fleet \
e2e must address its hosts by docker bridge IP because `[deploy]` carries ONE \
fleet-wide `ssh_port` (see the FleetNode note); that needs a Linux docker host whose \
default bridge is routable, which is what the CI Docker-dependent step provides."
);
}
fn current_release(ws: &Workspace, ssh_port: u16) -> String {
let out = ws.ssh(
ssh_port,
&format!("readlink -f /srv/autumn/{APP_NAME}/current 2>/dev/null || true"),
);
String::from_utf8_lossy(&out.stdout)
.trim()
.rsplit('/')
.next()
.unwrap_or_default()
.to_owned()
}
fn releases_listing(ws: &Workspace, ssh_port: u16) -> String {
let out = ws.ssh(
ssh_port,
&format!("ls -1 /srv/autumn/{APP_NAME}/releases 2>/dev/null | sort || true"),
);
String::from_utf8_lossy(&out.stdout).trim().to_owned()
}
fn migrate_run_count(ws: &Workspace, ssh_port: u16) -> u32 {
let out = ws.ssh(
ssh_port,
"journalctl --no-pager --output=cat 2>/dev/null | grep -c 'migrate: no-op' || true",
);
String::from_utf8_lossy(&out.stdout)
.trim()
.parse()
.unwrap_or(0)
}
fn assert_state_row(stderr: &str, host: &str, state: &str) {
assert!(
stderr
.lines()
.any(|line| line.contains(host) && line.contains(state)),
"expected a fleet row naming {host} with state {state:?}; full stderr:\n{stderr}"
);
}
#[test]
#[ignore = "requires Docker + ssh client; run with --ignored"]
#[allow(clippy::too_many_lines)]
fn fleet_rolling_deploy_lifecycle() {
let ws = Workspace::build();
let ctx = tempfile::tempdir().expect("ctx tempdir");
let image_tag = build_fixture_image_as(ctx.path(), &ws.pubkey, false, FIXTURE_TAG_FLEET);
let host_a = start_fleet_node(&image_tag);
let host_b = start_fleet_node(&image_tag);
eprintln!(
"[fleet] host 1: ip={} ssh={} public={}",
host_a.ip, host_a.ssh_port, host_a.public_port
);
eprintln!(
"[fleet] host 2: ip={} ssh={} public={}",
host_b.ip, host_b.ssh_port, host_b.public_port
);
wait_for_boot(&ws, host_a.ssh_port);
wait_for_boot(&ws, host_b.ssh_port);
eprintln!("[fleet] both containers booted");
assert_bridge_reachable(&host_a.ip);
assert_bridge_reachable(&host_b.ip);
for ip in [host_a.ip.as_str(), host_b.ip.as_str()] {
let _ = Command::new("ssh-keygen").args(["-R", ip]).output();
}
ws.write_config_fleet(&[host_a.ip.as_str(), host_b.ip.as_str()], 22);
ws.stage_release(&ws.app_v1);
let out = ws.autumn_deploy(&["up"]);
let stderr = String::from_utf8_lossy(&out.stderr).into_owned();
assert!(out.status.success(), "fleet first deploy failed:\n{stderr}");
assert!(
stderr.contains("across 2 hosts, ONE AT A TIME"),
"the rollout header should announce a serial 2-host rollout:\n{stderr}"
);
assert_state_row(&stderr, &host_a.ip, "first deploy");
assert_state_row(&stderr, &host_b.ip, "first deploy");
assert!(
stderr.contains(&format!("[1/2 {}] deploying release", host_a.ip))
&& stderr.contains(&format!("[2/2 {}] deploying release", host_b.ip)),
"hosts must roll out in `[deploy] hosts` declaration order:\n{stderr}"
);
assert!(
stderr.contains("Fleet deploy complete") && stderr.contains("all 2 hosts serving"),
"a clean fleet rollout should report every host serving:\n{stderr}"
);
let body_a = wait_for_http_ok(host_a.public_port, "/", Duration::from_secs(30));
let body_b = wait_for_http_ok(host_b.public_port, "/", Duration::from_secs(30));
assert!(
body_a.contains("e2eapp v1") && body_b.contains("e2eapp v1"),
"both hosts should serve v1 after the fleet first deploy, got {body_a:?} / {body_b:?}"
);
let rel_v1 = current_release(&ws, host_a.ssh_port);
assert!(!rel_v1.is_empty(), "host 1 should have a `current` release");
assert_eq!(
rel_v1,
current_release(&ws, host_b.ssh_port),
"a fleet run mints exactly ONE release id for every host (#1621)"
);
eprintln!("[fleet] 1. fleet first deploy OK — both hosts serve v1 as release {rel_v1}");
thread::sleep(Duration::from_millis(1200)); ws.stage_release(&ws.app_v2);
let prober_a = Prober::start(host_a.public_port);
let prober_b = Prober::start(host_b.public_port);
let out = ws.autumn_deploy(&["up"]);
let (total_a, failures_a) = prober_a.finish();
let (total_b, failures_b) = prober_b.finish();
let stderr = String::from_utf8_lossy(&out.stderr).into_owned();
assert!(
out.status.success(),
"fleet rolling redeploy failed:\n{stderr}"
);
assert_eq!(
failures_a, 0,
"host 1 dropped {failures_a}/{total_a} requests during the rolling redeploy"
);
assert_eq!(
failures_b, 0,
"host 2 dropped {failures_b}/{total_b} requests during the rolling redeploy"
);
assert!(
total_a > 0 && total_b > 0,
"both probers must have made requests ({total_a} / {total_b})"
);
assert_state_row(&stderr, &host_a.ip, "zero-downtime redeploy");
assert_state_row(&stderr, &host_b.ip, "zero-downtime redeploy");
assert!(
stderr.contains(&format!("migrate ({} only", host_a.ip))
&& stderr.contains("the schema is fleet-wide")
&& stderr.contains(&format!("{} skip it", host_b.ip)),
"the rollout header should place the single migration on host 1 only:\n{stderr}"
);
assert!(
stderr.contains(
"the schema has moved; from here an automatic rollback restores BINARIES only"
),
"the fleet should say out loud that a rollback will not undo the migration:\n{stderr}"
);
let migrated_a = migrate_run_count(&ws, host_a.ssh_port);
let migrated_b = migrate_run_count(&ws, host_b.ssh_port);
eprintln!("[fleet] migrate one-shot runs: host 1 = {migrated_a}, host 2 = {migrated_b}");
assert!(
migrated_a >= 1,
"the migrate one-shot must have run on host 1 (journal showed {migrated_a} runs)"
);
assert_eq!(
migrated_b, 0,
"hosts 2..N must NEVER run the fleet migration (journal showed {migrated_b} runs)"
);
let rel_v2 = current_release(&ws, host_a.ssh_port);
assert_eq!(
rel_v2,
current_release(&ws, host_b.ssh_port),
"both hosts must converge on the redeploy's single release id"
);
assert_ne!(rel_v2, rel_v1, "the redeploy must mint a NEW release id");
let body_a = wait_for_http_ok(host_a.public_port, "/", Duration::from_secs(30));
let body_b = wait_for_http_ok(host_b.public_port, "/", Duration::from_secs(30));
assert!(
body_a.contains("e2eapp v2") && body_b.contains("e2eapp v2"),
"both hosts should serve v2 after the rolling redeploy, got {body_a:?} / {body_b:?}"
);
eprintln!(
"[fleet] 2. rolling redeploy OK — 0/{total_a} and 0/{total_b} requests failed; \
both hosts serve v2 as release {rel_v2}"
);
thread::sleep(Duration::from_millis(1200));
let releases_b_before = releases_listing(&ws, host_b.ssh_port);
ws.stage_release(&ws.app_bad); let prober_a = Prober::start(host_a.public_port);
let prober_b = Prober::start(host_b.public_port);
let out = ws.autumn_deploy(&["up"]);
let (total_a, failures_a) = prober_a.finish();
let (total_b, failures_b) = prober_b.finish();
let stderr = String::from_utf8_lossy(&out.stderr).into_owned();
assert!(
!out.status.success(),
"a readiness-gate timeout on host 1 must exit non-zero:\n{stderr}"
);
assert_eq!(
failures_a, 0,
"host 1's old release must keep serving through the failed attempt \
({failures_a}/{total_a} dropped)"
);
assert_eq!(
failures_b, 0,
"host 2 was never touched and must not drop a request \
({failures_b}/{total_b} dropped)"
);
assert!(
stderr.contains(&format!(
"rollout halted at {} (`readiness-gate`)",
host_a.ip
)) && stderr.contains("the remaining hosts were not touched"),
"the rollout must halt at host 1's readiness gate:\n{stderr}"
);
assert!(
stderr.contains(&format!(
"fleet rollout halted at {} during `readiness-gate`",
host_a.ip
)),
"the process error must name the failing host and step:\n{stderr}"
);
assert_state_row(
&stderr,
&host_a.ip,
"previous release still serving (rolled back at `readiness-gate`)",
);
assert_state_row(&stderr, &host_b.ip, "untouched (not reached)");
assert!(
!stderr.contains("Compensating"),
"a failure on the FIRST host has nothing to compensate:\n{stderr}"
);
assert_eq!(
releases_listing(&ws, host_b.ssh_port),
releases_b_before,
"host 2 must have no release dir from the halted rollout"
);
assert_eq!(
current_release(&ws, host_b.ssh_port),
rel_v2,
"host 2's `current` must be unchanged by the halted rollout"
);
assert_eq!(
migrate_run_count(&ws, host_b.ssh_port),
0,
"host 2 must still never have migrated"
);
assert_eq!(
current_release(&ws, host_a.ssh_port),
rel_v2,
"host 1's candidate was torn down, so its `current` must still be the v2 release"
);
let body_a = wait_for_http_ok(host_a.public_port, "/", Duration::from_secs(15));
let body_b = wait_for_http_ok(host_b.public_port, "/", Duration::from_secs(15));
assert!(
body_a.contains("e2eapp v2") && body_b.contains("e2eapp v2"),
"both hosts must still serve v2 after the halted rollout, got {body_a:?} / {body_b:?}"
);
eprintln!(
"[fleet] 3. halt-on-host-1 OK — exit non-zero, host 2 untouched, both still serve v2"
);
thread::sleep(Duration::from_millis(1200));
let releases_dir = format!("/srv/autumn/{APP_NAME}/releases");
let mounted = ws.ssh(
host_b.ssh_port,
&format!(
"mount --bind {releases_dir} {releases_dir} && \
mount -o remount,bind,ro {releases_dir} {releases_dir}"
),
);
assert!(
mounted.status.success(),
"could not remount host 2's releases dir read-only: {}",
String::from_utf8_lossy(&mounted.stderr)
);
let writable = ws.ssh(
host_b.ssh_port,
&format!(
"touch {releases_dir}/.e2e-write-probe >/dev/null 2>&1 && echo writable \
|| echo readonly"
),
);
assert_eq!(
String::from_utf8_lossy(&writable.stdout).trim(),
"readonly",
"the scenario-4 injection is inoperative: host 2's releases dir is still writable, \
so host 2 would deploy successfully and nothing would be compensated"
);
let releases_b_before = releases_listing(&ws, host_b.ssh_port);
ws.stage_release(&ws.app_v2);
let out = ws.autumn_deploy(&["up"]);
let stderr = String::from_utf8_lossy(&out.stderr).into_owned();
assert!(
!out.status.success(),
"a failed host 2 must exit non-zero even though host 1 succeeded:\n{stderr}"
);
assert!(
stderr.contains(&format!("[1/2 {}] serving", host_a.ip)),
"host 1 must have cut over before host 2 was touched:\n{stderr}"
);
assert!(
stderr.contains(&format!("rollout halted at {} (", host_b.ip))
&& stderr.contains(&format!("fleet rollout halted at {} during ", host_b.ip)),
"the halt (and the process error) must name host 2:\n{stderr}"
);
assert!(
stderr.contains("Compensating 1 host(s) in reverse rollout order"),
"host 1 was on the new release, so the fleet must compensate it:\n{stderr}"
);
assert!(
stderr.contains(&format!(
"[{}] rolling back to the previous release",
host_a.ip
)),
"the compensation must name host 1:\n{stderr}"
);
assert_state_row(
&stderr,
&host_a.ip,
"previous release restored (rolled back by the fleet)",
);
assert_state_row(
&stderr,
&host_b.ip,
"previous release still serving (rolled back at `",
);
assert!(
stderr.contains("the compensating rollback restored BINARIES only"),
"the fleet must say the migration was NOT rolled back with the binaries:\n{stderr}"
);
assert_eq!(
current_release(&ws, host_a.ssh_port),
rel_v2,
"host 1 must be back on its pre-scenario release after the compensating rollback"
);
assert_eq!(
current_release(&ws, host_b.ssh_port),
rel_v2,
"host 2's `current` must be untouched by the failed cutover"
);
assert_eq!(
releases_listing(&ws, host_b.ssh_port),
releases_b_before,
"host 2's pre-boundary failure must leave no release dir behind"
);
let body_a = wait_for_http_ok(host_a.public_port, "/", Duration::from_secs(30));
let body_b = wait_for_http_ok(host_b.public_port, "/", Duration::from_secs(30));
assert!(
body_a.contains("e2eapp v2") && body_b.contains("e2eapp v2"),
"the fleet must converge back on v2, got {body_a:?} / {body_b:?}"
);
eprintln!("[fleet] 4. compensation OK — host 1 auto-rolled back, fleet converged on {rel_v2}");
let out = ws.autumn_deploy(&["status", "--json"]);
assert!(
out.status.success(),
"`deploy status --json` failed:\n{}",
String::from_utf8_lossy(&out.stderr)
);
let report: serde_json::Value = serde_json::from_slice(&out.stdout).unwrap_or_else(|e| {
panic!(
"`deploy status --json` did not emit JSON on stdout ({e}):\n{}",
String::from_utf8_lossy(&out.stdout)
)
});
let hosts = report["hosts"].as_array().expect("a `hosts` array");
assert_eq!(
hosts.len(),
2,
"status must report every fleet host: {report}"
);
assert_eq!(
hosts[0]["host"],
serde_json::json!(host_a.ip),
"status rows keep `[deploy] hosts` declaration order: {report}"
);
assert_eq!(hosts[1]["host"], serde_json::json!(host_b.ip));
for host in hosts {
assert_eq!(host["reachable"], serde_json::json!(true), "{report}");
assert_eq!(host["mode"], serde_json::json!("deployed"), "{report}");
assert_eq!(
host["release"],
serde_json::json!(rel_v2),
"both hosts must report the SAME release: {report}"
);
assert_eq!(host["ready"], serde_json::json!(200), "{report}");
assert_eq!(host["maintenance"], serde_json::json!(false), "{report}");
assert_eq!(
host["proxy_port"],
serde_json::json!(PUBLIC_PORT),
"{report}"
);
assert!(!host["live_slot"].is_null(), "{report}");
assert_eq!(host["drift"], serde_json::json!([]), "{report}");
}
assert_eq!(
report["version_drift"],
serde_json::json!(false),
"{report}"
);
assert_eq!(report["state_drift"], serde_json::json!([]), "{report}");
assert_eq!(report["drifted"], serde_json::json!(false), "{report}");
let out = ws.autumn_deploy(&["status", "--strict"]);
assert!(
out.status.success(),
"`deploy status --strict` must exit 0 on an undrifted fleet:\n{}",
String::from_utf8_lossy(&out.stderr)
);
eprintln!("[fleet] 5. deploy status OK — 2 hosts, one release, no drift");
eprintln!("[fleet] all #1621 fleet rollout assertions passed");
drop(host_a);
drop(host_b);
let _ = Command::new("docker")
.args(["rmi", "-f", &image_tag])
.output();
}