use std::fmt;
use std::io::Write as _;
use std::path::{Path, PathBuf};
use std::process::Command;
use super::proxy::ProxyController;
use super::{PreflightCheck, ResolvedDeployConfig};
#[derive(Clone, PartialEq, Eq)]
pub struct Secret(String);
impl Secret {
#[must_use]
pub fn new(value: impl Into<String>) -> Self {
Self(value.into())
}
#[must_use]
pub fn expose(&self) -> &str {
&self.0
}
}
impl fmt::Debug for Secret {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.write_str("Secret(<redacted>)")
}
}
impl fmt::Display for Secret {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.write_str("<redacted>")
}
}
#[derive(Debug, Clone)]
pub enum FileContents {
Plain(String),
Secret(Secret),
}
impl FileContents {
#[must_use]
fn as_str(&self) -> &str {
match self {
Self::Plain(s) => s,
Self::Secret(s) => s.expose(),
}
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct RemoteCommand {
pub label: &'static str,
pub shell: String,
}
impl RemoteCommand {
pub(crate) fn new(label: &'static str, shell: impl Into<String>) -> Self {
Self {
label,
shell: shell.into(),
}
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct CommandOutput {
pub stdout: String,
pub stderr: String,
}
#[derive(Debug)]
pub enum DeployOp {
Run(RemoteCommand),
UploadFile {
label: &'static str,
local: PathBuf,
remote_path: String,
mode: Option<u32>,
},
WriteFile {
label: &'static str,
contents: FileContents,
remote_path: String,
mode: Option<u32>,
},
}
impl DeployOp {
#[must_use]
pub const fn label(&self) -> &'static str {
match self {
Self::Run(cmd) => cmd.label,
Self::UploadFile { label, .. } | Self::WriteFile { label, .. } => label,
}
}
}
#[derive(Debug, Clone)]
pub struct ManifestUpload {
pub local: PathBuf,
pub remote_basename: String,
}
fn manifest_upload_ops(release_dir: &str, manifests: &[ManifestUpload]) -> Vec<DeployOp> {
manifests
.iter()
.map(|m| DeployOp::UploadFile {
label: "upload-config",
local: m.local.clone(),
remote_path: format!("{release_dir}/{}", m.remote_basename),
mode: Some(0o600),
})
.collect()
}
#[derive(Debug, thiserror::Error)]
pub enum DeployExecError {
#[error("failed to launch `{program}`: {source}")]
Spawn {
program: String,
#[source]
source: std::io::Error,
},
#[error("remote command `{label}` failed: {message}")]
CommandFailed {
label: &'static str,
message: String,
},
#[error("upload to `{remote_path}` failed: {message}")]
UploadFailed {
remote_path: String,
message: String,
},
#[error("could not stage a local file for upload: {message}")]
Stage {
message: String,
},
#[error(
"preflight failed: {failed} check(s) did not pass — aborting before touching the server"
)]
PreflightAborted {
failed: usize,
},
#[error("{message}")]
ProxyIncompatible {
message: String,
},
#[error(
"no previous release to roll back to — the current release is the only one on the target"
)]
NoPreviousRelease,
#[error(
"redeploy failed at `{failed_step}`; the candidate was auto-rolled-back and torn down — \
the previous release is still serving"
)]
CandidateRolledBack {
failed_step: &'static str,
#[source]
source: Box<Self>,
},
#[error(
"first deploy failed at `{failed_step}`; there is no previous release to roll back to — \
the candidate was torn down, nothing is serving"
)]
FirstDeployTornDown {
failed_step: &'static str,
#[source]
source: Box<Self>,
},
#[error(
"rollback failed at `{failed_step}`; the restarted slot was disabled again — \
the original release is still serving"
)]
RollbackFailed {
failed_step: &'static str,
#[source]
source: Box<Self>,
},
}
pub trait DeployExecutor {
fn run(&self, cmd: &RemoteCommand) -> Result<CommandOutput, DeployExecError>;
fn upload(
&self,
local: &Path,
remote_path: &str,
mode: Option<u32>,
) -> Result<(), DeployExecError>;
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct SshTarget {
pub host: String,
pub user: String,
pub port: u16,
}
impl SshTarget {
#[must_use]
pub fn from_resolved(cfg: &ResolvedDeployConfig) -> Option<Self> {
cfg.host
.as_deref()
.map(str::trim)
.filter(|h| !h.is_empty())
.map(|host| Self {
host: host.to_owned(),
user: cfg.user.clone(),
port: cfg.ssh_port,
})
}
}
const SSH_BATCH_OPTS: [&str; 4] = [
"-o",
"BatchMode=yes",
"-o",
"StrictHostKeyChecking=accept-new",
];
#[must_use]
pub fn ssh_argv(target: &SshTarget, remote_shell: &str) -> Vec<String> {
let mut argv = vec!["-p".to_owned(), target.port.to_string()];
argv.extend(SSH_BATCH_OPTS.iter().map(|s| (*s).to_owned()));
argv.push(format!("{}@{}", target.user, target.host));
argv.push(remote_shell.to_owned());
argv
}
#[must_use]
pub fn scp_argv(target: &SshTarget, local: &Path, remote_path: &str) -> Vec<String> {
let mut argv = vec!["-P".to_owned(), target.port.to_string()];
argv.extend(SSH_BATCH_OPTS.iter().map(|s| (*s).to_owned()));
argv.push(local.display().to_string());
argv.push(format!("{}@{}:{}", target.user, target.host, remote_path));
argv
}
pub fn shell_quote(value: &str) -> String {
format!("'{}'", value.replace('\'', "'\\''"))
}
pub const SLOT_BLUE: &str = "blue";
pub const SLOT_GREEN: &str = "green";
#[must_use]
pub fn slot_app_port(public_port: u16, slot: &str) -> u16 {
let offset = if slot == SLOT_GREEN { 2 } else { 1 };
public_port.saturating_add(offset)
}
#[must_use]
pub fn other_slot(slot: &str) -> &'static str {
if slot == SLOT_BLUE {
SLOT_GREEN
} else {
SLOT_BLUE
}
}
fn canonical_slot(slot: &str) -> &'static str {
if slot.trim() == SLOT_GREEN {
SLOT_GREEN
} else {
SLOT_BLUE
}
}
#[must_use]
pub fn slot_unit_name(service: &str, slot: &str) -> String {
format!("{service}-{slot}")
}
fn live_slot_marker(cfg: &ResolvedDeployConfig) -> String {
format!("{}/shared/live-slot", cfg.app_dir)
}
fn previous_release_marker(cfg: &ResolvedDeployConfig) -> String {
format!("{}/shared/previous-release", cfg.app_dir)
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct SlotPlan {
pub public_port: u16,
pub candidate_slot: &'static str,
pub candidate_port: u16,
pub live_slot: &'static str,
pub live_port: u16,
}
impl SlotPlan {
#[must_use]
pub fn first(public_port: u16) -> Self {
Self {
public_port,
candidate_slot: SLOT_BLUE,
candidate_port: slot_app_port(public_port, SLOT_BLUE),
live_slot: SLOT_BLUE,
live_port: slot_app_port(public_port, SLOT_BLUE),
}
}
#[must_use]
pub fn redeploy(public_port: u16, live_slot: &str) -> Self {
let live_slot = canonical_slot(live_slot);
let candidate_slot = other_slot(live_slot);
Self {
public_port,
candidate_slot,
candidate_port: slot_app_port(public_port, candidate_slot),
live_slot,
live_port: slot_app_port(public_port, live_slot),
}
}
}
#[must_use]
#[allow(clippy::too_many_arguments)]
pub fn first_deploy_ops(
cfg: &ResolvedDeployConfig,
proxy: &impl ProxyController,
unit: &str,
env_file: Secret,
binary_local: &Path,
manifests: &[ManifestUpload],
release_id: &str,
plan: &SlotPlan,
) -> Vec<DeployOp> {
let release_dir = format!("{}/{release_id}", cfg.releases_dir());
let remote_binary = format!("{release_dir}/{}", cfg.app_name);
let shared_dir = cfg.shared_dir();
let env_path = cfg.env_file();
let current = cfg.current_symlink();
let unit_name = slot_unit_name(&cfg.service_name, plan.candidate_slot);
let unit_path = format!("/etc/systemd/system/{unit_name}.service");
let mut ops = proxy.ensure_installed_ops(plan.public_port);
ops.push(DeployOp::Run(RemoteCommand::new(
"prepare-dirs",
format!(
"mkdir -p {} {}",
shell_quote(&release_dir),
shell_quote(&shared_dir)
),
)));
ops.push(DeployOp::UploadFile {
label: "upload-binary",
local: binary_local.to_path_buf(),
remote_path: remote_binary,
mode: Some(0o755),
});
ops.extend(manifest_upload_ops(&release_dir, manifests));
ops.extend([
DeployOp::WriteFile {
label: "write-env",
contents: FileContents::Secret(env_file),
remote_path: env_path,
mode: Some(0o600),
},
DeployOp::WriteFile {
label: "write-unit",
contents: FileContents::Plain(unit.to_owned()),
remote_path: unit_path,
mode: Some(0o644),
},
DeployOp::Run(RemoteCommand::new(
"link-current",
format!(
"ln -sfn {} {}",
shell_quote(&release_dir),
shell_quote(¤t)
),
)),
DeployOp::Run(RemoteCommand::new(
"daemon-reload",
"systemctl daemon-reload",
)),
DeployOp::Run(RemoteCommand::new(
"enable-now",
format!(
"systemctl enable {unit_name}.service && systemctl restart {unit_name}.service"
),
)),
DeployOp::Run(record_live_slot(
cfg,
plan.candidate_slot,
plan.candidate_port,
)),
DeployOp::Run(RemoteCommand::new(
"clear-previous",
format!("rm -f {}", shell_quote(&previous_release_marker(cfg))),
)),
DeployOp::Run(RemoteCommand::new(
"readiness-gate",
readiness_poll_shell(plan.candidate_port, cfg.readiness_timeout_secs),
)),
]);
ops.push(proxy.route_op(&cfg.service_name, &loopback_upstream(plan.candidate_port)));
ops
}
#[must_use]
#[allow(clippy::too_many_arguments)]
pub fn cutover_ops(
cfg: &ResolvedDeployConfig,
proxy: &impl ProxyController,
unit: &str,
env_file: Secret,
binary_local: &Path,
manifests: &[ManifestUpload],
release_id: &str,
plan: &SlotPlan,
) -> Vec<DeployOp> {
let release_dir = format!("{}/{release_id}", cfg.releases_dir());
let remote_binary = format!("{release_dir}/{}", cfg.app_name);
let shared_dir = cfg.shared_dir();
let env_path = cfg.env_file();
let current = cfg.current_symlink();
let candidate_unit = slot_unit_name(&cfg.service_name, plan.candidate_slot);
let candidate_unit_path = format!("/etc/systemd/system/{candidate_unit}.service");
let live_unit = slot_unit_name(&cfg.service_name, plan.live_slot);
let mut ops = proxy.refresh_installed_ops(
plan.public_port,
&cfg.service_name,
&loopback_upstream(plan.live_port),
&proxy_unit_snapshot_path(release_id),
);
ops.push(DeployOp::Run(RemoteCommand::new(
"prepare-dirs",
format!(
"mkdir -p {} {}",
shell_quote(&release_dir),
shell_quote(&shared_dir)
),
)));
ops.push(DeployOp::UploadFile {
label: "upload-binary",
local: binary_local.to_path_buf(),
remote_path: remote_binary,
mode: Some(0o755),
});
ops.extend(manifest_upload_ops(&release_dir, manifests));
ops.extend([
DeployOp::WriteFile {
label: "write-env",
contents: FileContents::Secret(env_file),
remote_path: env_path,
mode: Some(0o600),
},
DeployOp::WriteFile {
label: "write-candidate-unit",
contents: FileContents::Plain(unit.to_owned()),
remote_path: candidate_unit_path,
mode: Some(0o644),
},
DeployOp::Run(RemoteCommand::new(
"daemon-reload",
"systemctl daemon-reload",
)),
DeployOp::Run(RemoteCommand::new(
"start-candidate",
format!(
"systemctl enable {candidate_unit}.service && \
systemctl restart {candidate_unit}.service"
),
)),
DeployOp::Run(release_migrate_command(cfg, &release_dir)),
DeployOp::Run(RemoteCommand::new(
"readiness-gate",
readiness_poll_shell(plan.candidate_port, cfg.readiness_timeout_secs),
)),
proxy.flip_op(&cfg.service_name, &loopback_upstream(plan.candidate_port)),
DeployOp::Run(commit_markers_command(
cfg,
&release_dir,
plan.candidate_slot,
plan.candidate_port,
PrevMarkerFallback {
slot: plan.live_slot,
port: plan.live_port,
},
)),
DeployOp::Run(RemoteCommand::new(
"drain-old",
format!("systemctl disable --now {live_unit}.service"),
)),
DeployOp::Run(RemoteCommand::new(
"prune",
prune_releases_shell(
&cfg.releases_dir(),
¤t,
&previous_release_marker(cfg),
cfg.keep_releases,
),
)),
]);
ops
}
#[must_use]
pub fn candidate_teardown_ops(
cfg: &ResolvedDeployConfig,
release_id: &str,
plan: &SlotPlan,
) -> Vec<DeployOp> {
let candidate_unit = slot_unit_name(&cfg.service_name, plan.candidate_slot);
let release_dir = format!("{}/{release_id}", cfg.releases_dir());
vec![
DeployOp::Run(RemoteCommand::new(
"teardown-candidate-unit",
format!("systemctl disable --now {candidate_unit}.service || true"),
)),
DeployOp::Run(RemoteCommand::new(
"teardown-candidate-dir",
format!("rm -rf {}", shell_quote(&release_dir)),
)),
]
}
#[must_use]
pub fn first_deploy_teardown_ops(
cfg: &ResolvedDeployConfig,
release_id: &str,
plan: &SlotPlan,
) -> Vec<DeployOp> {
let mut ops = candidate_teardown_ops(cfg, release_id, plan);
ops.push(DeployOp::Run(RemoteCommand::new(
"teardown-current-symlink",
format!("rm -f {}", shell_quote(&cfg.current_symlink())),
)));
ops.push(DeployOp::Run(RemoteCommand::new(
"teardown-slot-markers",
format!(
"rm -f {} {}",
shell_quote(&live_slot_marker(cfg)),
shell_quote(&previous_release_marker(cfg)),
),
)));
ops
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct RollbackTarget {
pub release_dir: String,
pub slot: &'static str,
pub port: u16,
}
pub fn resolve_rollback_target(
cfg: &ResolvedDeployConfig,
public_port: u16,
exec: &impl DeployExecutor,
) -> Result<RollbackTarget, DeployExecError> {
let shell = format!(
"prev=$(cat {marker} 2>/dev/null); \
if [ -z \"$prev\" ]; then printf 'none'; else printf 'prev:%s' \"$prev\"; fi",
marker = shell_quote(&previous_release_marker(cfg)),
);
let out = exec.run(&RemoteCommand::new("resolve-previous", shell))?;
let stdout = out.stdout.trim();
let Some(rest) = stdout.strip_prefix("prev:") else {
return Err(DeployExecError::NoPreviousRelease);
};
let mut parts = rest.splitn(3, '\t');
let release_dir = parts.next().unwrap_or_default().trim().to_owned();
if release_dir.is_empty() {
return Err(DeployExecError::NoPreviousRelease);
}
let slot = canonical_slot(parts.next().unwrap_or(SLOT_BLUE).trim());
let port = parts
.next()
.and_then(|p| p.trim().parse::<u16>().ok())
.unwrap_or_else(|| slot_app_port(public_port, slot));
Ok(RollbackTarget {
release_dir,
slot,
port,
})
}
#[must_use]
pub fn rollback_ops(
cfg: &ResolvedDeployConfig,
proxy: &impl ProxyController,
target: &RollbackTarget,
) -> Vec<DeployOp> {
let previous_unit = slot_unit_name(&cfg.service_name, target.slot);
let previous_unit_path = format!("/etc/systemd/system/{previous_unit}.service");
let target_unit = super::render_app_unit(cfg, &target.release_dir, target.port, target.slot);
let rolled_back_unit = slot_unit_name(&cfg.service_name, other_slot(target.slot));
let public_port = target
.port
.saturating_sub(if target.slot == SLOT_GREEN { 2 } else { 1 });
let former_live_fallback_port = slot_app_port(public_port, other_slot(target.slot));
vec![
DeployOp::WriteFile {
label: "write-target-unit",
contents: FileContents::Plain(target_unit),
remote_path: previous_unit_path,
mode: Some(0o644),
},
DeployOp::Run(RemoteCommand::new(
"daemon-reload",
"systemctl daemon-reload",
)),
DeployOp::Run(RemoteCommand::new(
"restart-previous",
format!(
"systemctl enable {previous_unit}.service && \
systemctl restart {previous_unit}.service"
),
)),
proxy.flip_op(&cfg.service_name, &loopback_upstream(target.port)),
DeployOp::Run(commit_markers_command(
cfg,
&target.release_dir,
target.slot,
target.port,
PrevMarkerFallback {
slot: other_slot(target.slot),
port: former_live_fallback_port,
},
)),
DeployOp::Run(RemoteCommand::new(
"readiness-gate",
readiness_poll_shell(target.port, cfg.readiness_timeout_secs),
)),
DeployOp::Run(RemoteCommand::new(
"drain-rolled-back-slot",
format!("systemctl disable --now {rolled_back_unit}.service"),
)),
]
}
#[must_use]
pub fn rollback_teardown_ops(cfg: &ResolvedDeployConfig, target: &RollbackTarget) -> Vec<DeployOp> {
let restarted_unit = slot_unit_name(&cfg.service_name, target.slot);
vec![DeployOp::Run(RemoteCommand::new(
"teardown-rollback-slot",
format!("systemctl disable --now {restarted_unit}.service || true"),
))]
}
fn loopback_upstream(port: u16) -> String {
format!("127.0.0.1:{port}")
}
fn proxy_unit_snapshot_path(release_id: &str) -> String {
format!("/tmp/autumn-kamal-proxy-unit-{release_id}.sha256")
}
fn record_live_slot(cfg: &ResolvedDeployConfig, slot: &str, port: u16) -> RemoteCommand {
RemoteCommand::new(
"record-live-slot",
format!(
"printf '%s\\t%s' {} {} > {}",
shell_quote(slot),
port,
shell_quote(&live_slot_marker(cfg))
),
)
}
#[derive(Clone, Copy)]
struct PrevMarkerFallback<'a> {
slot: &'a str,
port: u16,
}
fn commit_markers_command(
cfg: &ResolvedDeployConfig,
release_dir: &str,
slot: &str,
port: u16,
prev_fallback: PrevMarkerFallback<'_>,
) -> RemoteCommand {
let shared = cfg.shared_dir();
let prev_tmpl = format!("{shared}/previous-release.tmp.XXXXXX");
let live_tmpl = format!("{shared}/live-slot.tmp.XXXXXX");
RemoteCommand::new(
"commit-markers",
format!(
"prev=$(readlink {current} 2>/dev/null); \
live=$(cat {live_marker} 2>/dev/null); \
lslot=$(printf '%s' \"$live\" | cut -f1); \
lport=$(printf '%s' \"$live\" | cut -s -f2); \
[ -n \"$lslot\" ] || lslot={prev_slot}; \
[ -n \"$lport\" ] || lport={prev_port}; \
if [ -n \"$prev\" ]; then \
ptmp=$(mktemp {prev_tmpl}) && \
printf '%s\\t%s\\t%s' \"$prev\" \"$lslot\" \"$lport\" > \"$ptmp\" && \
mv -f \"$ptmp\" {prev_marker}; \
fi && \
ln -sfn {release_dir} {current} && \
ltmp=$(mktemp {live_tmpl}) && \
printf '%s\\t%s' {slot} {port} > \"$ltmp\" && \
mv -f \"$ltmp\" {live_marker}",
current = shell_quote(&cfg.current_symlink()),
live_marker = shell_quote(&live_slot_marker(cfg)),
prev_slot = shell_quote(prev_fallback.slot),
prev_port = prev_fallback.port,
prev_tmpl = shell_quote(&prev_tmpl),
prev_marker = shell_quote(&previous_release_marker(cfg)),
release_dir = shell_quote(release_dir),
live_tmpl = shell_quote(&live_tmpl),
slot = shell_quote(slot),
port = port,
),
)
}
fn release_migrate_command(cfg: &ResolvedDeployConfig, release_dir: &str) -> RemoteCommand {
let bin = format!("{release_dir}/{}", cfg.app_name);
let release_id = release_dir.rsplit('/').next().unwrap_or(release_dir);
RemoteCommand::new(
"migrate",
format!(
"systemd-run --wait --collect --quiet --unit={service}-migrate-{release_id} \
--property=EnvironmentFile={env} --setenv=AUTUMN_MIGRATE=1 {bin}",
service = cfg.service_name,
env = shell_quote(&cfg.env_file()),
bin = shell_quote(&bin),
),
)
}
#[must_use]
fn prune_releases_shell(
releases_dir: &str,
current: &str,
previous_marker: &str,
keep: u32,
) -> String {
let keep = keep.max(1);
format!(
"cd {dir} && \
cur=$(basename \"$(readlink -f {current} 2>/dev/null)\" 2>/dev/null); \
prev=; \
if [ -f {marker} ]; then prev=$(basename \"$(cut -f1 {marker} 2>/dev/null)\" 2>/dev/null); fi; \
ls -1dt */ 2>/dev/null | sed 's:/$::' | grep -vxF \"$cur\" | grep -vxF \"$prev\" \
| tail -n +{tail} | xargs -r rm -rf",
dir = shell_quote(releases_dir),
current = shell_quote(current),
marker = shell_quote(previous_marker),
tail = keep + 1,
)
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum DeployMode {
First,
Redeploy {
live_slot: &'static str,
},
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum InstalledProxyPort {
Absent,
Unreadable,
Port(u16),
}
const PROXY_LIST_DELIM: &str = "---autumn-kamal-proxy-list---";
const PROXY_UNIT_DELIM: &str = "---autumn-kamal-proxy-unit---";
const NO_PROXY_UNIT_SENTINEL: &str = "---autumn-no-proxy-unit---";
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct DeployProbe {
pub mode: DeployMode,
pub proxy_list: String,
pub installed_proxy_port: InstalledProxyPort,
}
fn parse_installed_proxy_port(section: &str) -> InstalledProxyPort {
let trimmed = section.trim();
if trimmed == NO_PROXY_UNIT_SENTINEL {
return InstalledProxyPort::Absent;
}
let mut lines = trimmed.lines().map(str::trim).filter(|l| !l.is_empty());
let (Some(single), None) = (lines.next(), lines.next()) else {
return InstalledProxyPort::Unreadable;
};
single
.rsplit_once(char::is_whitespace)
.and_then(|(_, n)| n.trim().parse::<u16>().ok())
.map_or(InstalledProxyPort::Unreadable, InstalledProxyPort::Port)
}
pub fn probe_deploy_state(
cfg: &ResolvedDeployConfig,
exec: &impl DeployExecutor,
) -> Result<DeployProbe, DeployExecError> {
let shell = format!(
"if [ -L {current} ]; then printf 'redeploy:'; cat {marker} 2>/dev/null || printf '{blue}'; \
else printf 'first'; fi; \
printf '\\n{delim}\\n'; \
env -u XDG_RUNTIME_DIR kamal-proxy list 2>/dev/null || true; \
printf '\\n{unit_delim}\\n'; \
if [ -f {unit} ]; then grep -hoE -e '--http-port[[:space:]]+[0-9]+' {unit} 2>/dev/null || true; \
else printf '%s' '{no_unit}'; fi",
current = shell_quote(&cfg.current_symlink()),
marker = shell_quote(&live_slot_marker(cfg)),
blue = SLOT_BLUE,
delim = PROXY_LIST_DELIM,
unit_delim = PROXY_UNIT_DELIM,
unit = shell_quote(super::proxy::KAMAL_PROXY_UNIT_PATH),
no_unit = NO_PROXY_UNIT_SENTINEL,
);
let out = exec.run(&RemoteCommand::new("detect-current", shell))?;
let (mode_part, rest) = out
.stdout
.split_once(PROXY_LIST_DELIM)
.unwrap_or((out.stdout.as_str(), ""));
let (proxy_list, installed_proxy_port) = match rest.split_once(PROXY_UNIT_DELIM) {
Some((list, unit_section)) => (list, parse_installed_proxy_port(unit_section)),
None => (rest, InstalledProxyPort::Absent),
};
let mode = mode_part
.trim()
.strip_prefix("redeploy:")
.map_or(DeployMode::First, |marker| {
let live_slot = canonical_slot(marker.split('\t').next().unwrap_or(SLOT_BLUE));
DeployMode::Redeploy { live_slot }
});
Ok(DeployProbe {
mode,
proxy_list: proxy_list.to_owned(),
installed_proxy_port,
})
}
pub fn probe_proxy_compat(
proxy: &impl ProxyController,
exec: &impl DeployExecutor,
) -> Result<(), DeployExecError> {
let Some(probe) = proxy.compat_probe() else {
return Ok(());
};
let out = exec.run(&probe.command)?;
let combined = if out.stderr.trim().is_empty() {
out.stdout
} else {
format!("{}\n{}", out.stdout, out.stderr)
};
probe
.assess(&combined)
.map_err(|message| DeployExecError::ProxyIncompatible { message })
}
fn slot_for_port(public_port: u16, port: u16) -> Option<&'static str> {
match port.checked_sub(public_port)? {
1 => Some(SLOT_BLUE),
2 => Some(SLOT_GREEN),
_ => None,
}
}
fn loopback_port(field: &str) -> Option<u16> {
const HOST: &str = "127.0.0.1:";
let start = field.find(HOST)? + HOST.len();
let rest = &field[start..];
let digits: String = rest.chars().take_while(char::is_ascii_digit).collect();
if digits.is_empty() {
return None;
}
if rest[digits.len()..]
.chars()
.next()
.is_some_and(|c| c.is_ascii_alphanumeric())
{
return None;
}
digits.parse().ok()
}
#[must_use]
pub fn proxy_live_target_port(list_stdout: &str, service_name: &str) -> Option<u16> {
let service = service_name.trim();
if service.is_empty() {
return None;
}
let mut rows = list_stdout
.lines()
.filter(|line| line.split_whitespace().any(|field| field == service));
let row = rows.next()?;
if rows.next().is_some() {
return None;
}
let mut port: Option<u16> = None;
for field in row.split_whitespace() {
if let Some(p) = loopback_port(field) {
match port {
None => port = Some(p),
Some(existing) if existing == p => {}
Some(_) => return None,
}
}
}
port
}
fn proxy_live_slot(
list_stdout: &str,
service_name: &str,
public_port: u16,
) -> Option<&'static str> {
slot_for_port(
public_port,
proxy_live_target_port(list_stdout, service_name)?,
)
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct SlotReconcile {
pub live_slot: &'static str,
pub repair: bool,
pub warn: Option<String>,
}
#[must_use]
pub fn reconcile_live_slot(
marker_slot: &str,
proxy_list_stdout: &str,
service_name: &str,
public_port: u16,
) -> SlotReconcile {
let marker_slot = canonical_slot(marker_slot);
match proxy_live_slot(proxy_list_stdout, service_name, public_port) {
Some(proxy_slot) if proxy_slot != marker_slot => SlotReconcile {
live_slot: proxy_slot,
repair: true,
warn: Some(format!(
"deploy state drift: live-slot marker says {marker_slot} but \
kamal-proxy is serving {proxy_slot}; reconciling from proxy"
)),
},
_ => SlotReconcile {
live_slot: marker_slot,
repair: false,
warn: None,
},
}
}
#[must_use]
pub fn live_slot_marker_repair_op(
cfg: &ResolvedDeployConfig,
slot: &str,
public_port: u16,
) -> DeployOp {
let slot = canonical_slot(slot);
DeployOp::Run(record_live_slot(
cfg,
slot,
slot_app_port(public_port, slot),
))
}
#[must_use]
fn readiness_poll_shell(port: u16, timeout_secs: u64) -> String {
format!(
"end=$(($(date +%s)+{timeout_secs})); \
until curl -fsS http://127.0.0.1:{port}/ready >/dev/null 2>&1; do \
if [ $(date +%s) -ge $end ]; then \
echo 'readiness gate: /ready not healthy within {timeout_secs}s' >&2; exit 1; \
fi; sleep 2; done"
)
}
#[derive(Debug, Clone, Copy)]
enum TeardownKind {
PreviousStillServing,
NoPreviousRelease,
RollbackFailed,
}
impl TeardownKind {
const fn cleanup_note(self) -> &'static str {
match self {
Self::PreviousStillServing | Self::NoPreviousRelease => "rolling back the candidate",
Self::RollbackFailed => "disabling the restarted slot",
}
}
}
pub fn execute_first_deploy(
checks: &[PreflightCheck],
ops: &[DeployOp],
teardown: &[DeployOp],
exec: &impl DeployExecutor,
) -> Result<(), DeployExecError> {
execute_with_teardown(
checks,
ops,
teardown,
"proxy-route",
TeardownKind::NoPreviousRelease,
exec,
)
}
pub fn execute_redeploy(
checks: &[PreflightCheck],
ops: &[DeployOp],
teardown: &[DeployOp],
exec: &impl DeployExecutor,
) -> Result<(), DeployExecError> {
execute_with_teardown(
checks,
ops,
teardown,
"proxy-flip",
TeardownKind::PreviousStillServing,
exec,
)
}
pub fn execute_rollback(
checks: &[PreflightCheck],
ops: &[DeployOp],
teardown: &[DeployOp],
exec: &impl DeployExecutor,
) -> Result<(), DeployExecError> {
execute_with_teardown(
checks,
ops,
teardown,
"proxy-flip",
TeardownKind::RollbackFailed,
exec,
)
}
fn execute_with_teardown(
checks: &[PreflightCheck],
ops: &[DeployOp],
teardown: &[DeployOp],
boundary_label: &str,
kind: TeardownKind,
exec: &impl DeployExecutor,
) -> Result<(), DeployExecError> {
let failed = checks.iter().filter(|c| c.blocking()).count();
if failed > 0 {
return Err(DeployExecError::PreflightAborted { failed });
}
let boundary = ops.iter().position(|op| op.label() == boundary_label);
for (index, op) in ops.iter().enumerate() {
if let Err(source) = run_one(op, exec) {
let at_or_before_boundary = boundary.is_some_and(|b| index <= b);
if !at_or_before_boundary {
return Err(source);
}
eprintln!(
" \u{2717} {} failed — {}\u{2026}",
op.label(),
kind.cleanup_note()
);
run_teardown(teardown, exec);
let failed_step = op.label();
let source = Box::new(source);
return Err(match kind {
TeardownKind::PreviousStillServing => DeployExecError::CandidateRolledBack {
failed_step,
source,
},
TeardownKind::NoPreviousRelease => DeployExecError::FirstDeployTornDown {
failed_step,
source,
},
TeardownKind::RollbackFailed => DeployExecError::RollbackFailed {
failed_step,
source,
},
});
}
}
Ok(())
}
#[cfg_attr(not(test), allow(dead_code))]
pub fn run_ops(ops: &[DeployOp], exec: &impl DeployExecutor) -> Result<(), DeployExecError> {
for op in ops {
run_one(op, exec)?;
}
Ok(())
}
fn run_one(op: &DeployOp, exec: &impl DeployExecutor) -> Result<(), DeployExecError> {
eprintln!(" \u{2192} {}", op.label());
match op {
DeployOp::Run(cmd) => {
exec.run(cmd)?;
}
DeployOp::UploadFile {
local,
remote_path,
mode,
..
} => {
exec.upload(local, remote_path, *mode)?;
}
DeployOp::WriteFile {
contents,
remote_path,
mode,
..
} => {
let staged = stage_temp_file(contents.as_str(), *mode)?;
exec.upload(staged.path(), remote_path, *mode)?;
}
}
Ok(())
}
fn run_teardown(ops: &[DeployOp], exec: &impl DeployExecutor) {
for op in ops {
let _ = run_one(op, exec);
}
}
fn stage_temp_file(
contents: &str,
mode: Option<u32>,
) -> Result<tempfile::NamedTempFile, DeployExecError> {
let mut file = tempfile::NamedTempFile::new().map_err(|e| DeployExecError::Stage {
message: e.to_string(),
})?;
#[cfg(unix)]
if let Some(mode) = mode {
use std::os::unix::fs::PermissionsExt as _;
let perms = std::fs::Permissions::from_mode(mode);
std::fs::set_permissions(file.path(), perms).map_err(|e| DeployExecError::Stage {
message: e.to_string(),
})?;
}
#[cfg(not(unix))]
let _ = mode;
file.write_all(contents.as_bytes())
.map_err(|e| DeployExecError::Stage {
message: e.to_string(),
})?;
file.flush().map_err(|e| DeployExecError::Stage {
message: e.to_string(),
})?;
Ok(file)
}
#[derive(Debug, Clone)]
pub struct SshExecutor {
target: SshTarget,
}
impl SshExecutor {
#[must_use]
pub const fn new(target: SshTarget) -> Self {
Self { target }
}
}
impl DeployExecutor for SshExecutor {
fn run(&self, cmd: &RemoteCommand) -> Result<CommandOutput, DeployExecError> {
let argv = ssh_argv(&self.target, &cmd.shell);
let output =
Command::new("ssh")
.args(&argv)
.output()
.map_err(|source| DeployExecError::Spawn {
program: "ssh".to_owned(),
source,
})?;
if output.status.success() {
Ok(CommandOutput {
stdout: String::from_utf8_lossy(&output.stdout).into_owned(),
stderr: String::from_utf8_lossy(&output.stderr).into_owned(),
})
} else {
Err(DeployExecError::CommandFailed {
label: cmd.label,
message: format!(
"exit status {}: {}",
output
.status
.code()
.map_or_else(|| "signal".to_owned(), |c| c.to_string()),
String::from_utf8_lossy(&output.stderr).trim()
),
})
}
}
fn upload(
&self,
local: &Path,
remote_path: &str,
mode: Option<u32>,
) -> Result<(), DeployExecError> {
let argv = scp_argv(&self.target, local, remote_path);
let output =
Command::new("scp")
.args(&argv)
.output()
.map_err(|source| DeployExecError::Spawn {
program: "scp".to_owned(),
source,
})?;
if !output.status.success() {
return Err(DeployExecError::UploadFailed {
remote_path: remote_path.to_owned(),
message: String::from_utf8_lossy(&output.stderr).trim().to_owned(),
});
}
if let Some(mode) = mode {
let chmod = RemoteCommand::new(
"chmod",
format!("chmod {mode:o} {}", shell_quote(remote_path)),
);
self.run(&chmod)?;
}
Ok(())
}
}
#[cfg(test)]
mod tests {
use super::*;
use std::cell::RefCell;
#[derive(Default)]
struct RecordingExecutor {
calls: RefCell<Vec<RecordedCall>>,
fail_labels: Vec<&'static str>,
stdout_by_label: Vec<(&'static str, String)>,
}
#[derive(Debug, Clone, PartialEq, Eq)]
enum RecordedCall {
Run {
label: &'static str,
shell: String,
},
Upload {
remote_path: String,
mode: Option<u32>,
},
}
impl RecordingExecutor {
fn new() -> Self {
Self::default()
}
fn failing_on(label: &'static str) -> Self {
Self {
fail_labels: vec![label],
..Self::default()
}
}
fn with_stdout(mut self, label: &'static str, stdout: impl Into<String>) -> Self {
self.stdout_by_label.push((label, stdout.into()));
self
}
fn calls(&self) -> Vec<RecordedCall> {
self.calls.borrow().clone()
}
fn run_labels(&self) -> Vec<&'static str> {
self.calls
.borrow()
.iter()
.filter_map(|c| match c {
RecordedCall::Run { label, .. } => Some(*label),
RecordedCall::Upload { .. } => None,
})
.collect()
}
fn shell_for(&self, label: &str) -> Option<String> {
self.calls.borrow().iter().find_map(|c| match c {
RecordedCall::Run { label: l, shell } if *l == label => Some(shell.clone()),
_ => None,
})
}
}
impl DeployExecutor for RecordingExecutor {
fn run(&self, cmd: &RemoteCommand) -> Result<CommandOutput, DeployExecError> {
self.calls.borrow_mut().push(RecordedCall::Run {
label: cmd.label,
shell: cmd.shell.clone(),
});
if self.fail_labels.contains(&cmd.label) {
return Err(DeployExecError::CommandFailed {
label: cmd.label,
message: "scripted failure".to_owned(),
});
}
let stdout = self
.stdout_by_label
.iter()
.find(|(l, _)| *l == cmd.label)
.map(|(_, out)| out.clone())
.unwrap_or_default();
Ok(CommandOutput {
stdout,
stderr: String::new(),
})
}
fn upload(
&self,
_local: &Path,
remote_path: &str,
mode: Option<u32>,
) -> Result<(), DeployExecError> {
self.calls.borrow_mut().push(RecordedCall::Upload {
remote_path: remote_path.to_owned(),
mode,
});
Ok(())
}
}
fn resolved() -> ResolvedDeployConfig {
ResolvedDeployConfig::resolve(
&autumn_web::config::DeployConfig {
host: Some("203.0.113.10".to_owned()),
..autumn_web::config::DeployConfig::default()
},
"myapp",
)
.expect("deploy config resolves")
}
const RELEASE_ID: &str = "20260714T120000Z";
const RELEASE_DIR: &str = "/srv/autumn/myapp/releases/20260714T120000Z";
fn proxy() -> super::super::proxy::KamalProxyController {
super::super::proxy::KamalProxyController::new(60)
}
fn sample_ops(env: Secret) -> Vec<DeployOp> {
let cfg = resolved();
let plan = SlotPlan::first(3000);
let unit = super::super::render_app_unit(
&cfg,
RELEASE_DIR,
plan.candidate_port,
plan.candidate_slot,
);
first_deploy_ops(
&cfg,
&proxy(),
&unit,
env,
Path::new("/local/target/release/myapp"),
&sample_manifests(),
RELEASE_ID,
&plan,
)
}
fn sample_manifests() -> Vec<ManifestUpload> {
vec![
ManifestUpload {
local: PathBuf::from("/local/autumn.toml"),
remote_basename: "autumn.toml".to_owned(),
},
ManifestUpload {
local: PathBuf::from("/local/autumn-prod.toml"),
remote_basename: "autumn-prod.toml".to_owned(),
},
]
}
fn sample_cutover_ops(env: Secret) -> Vec<DeployOp> {
let cfg = resolved();
let plan = SlotPlan::redeploy(3000, SLOT_BLUE);
let unit = super::super::render_app_unit(
&cfg,
RELEASE_DIR,
plan.candidate_port,
plan.candidate_slot,
);
cutover_ops(
&cfg,
&proxy(),
&unit,
env,
Path::new("/local/target/release/myapp"),
&sample_manifests(),
RELEASE_ID,
&plan,
)
}
#[test]
fn detect_current_prints_no_proxy_sentinel_literally() {
let cfg = resolved();
let exec = RecordingExecutor::new();
let _ = probe_deploy_state(&cfg, &exec);
let shell = exec
.shell_for("detect-current")
.expect("detect-current ran");
let safe = format!("printf '%s' '{NO_PROXY_UNIT_SENTINEL}'");
let unsafe_form = format!("printf '{NO_PROXY_UNIT_SENTINEL}'");
assert!(
shell.contains(&safe),
"the no-proxy sentinel must be printed via `printf '%s'`: {shell}"
);
assert!(
!shell.contains(&unsafe_form),
"the no-proxy sentinel must not be printed as a bare printf format \
string (its `--` prefix would be parsed as an invalid option): {shell}"
);
}
#[test]
fn first_deploy_installs_and_routes_proxy() {
let ops = sample_ops(Secret::new("AUTUMN_SECURITY__SIGNING_SECRET=topsecret\n"));
let exec = RecordingExecutor::new();
run_ops(&ops, &exec).expect("recording executor never fails");
let labels = exec.run_labels();
let install = labels
.iter()
.position(|&l| l == "proxy-install")
.expect("proxy is installed on first deploy");
let route = labels
.iter()
.position(|&l| l == "proxy-route")
.expect("proxy is routed at the initial release");
let readiness = labels
.iter()
.position(|&l| l == "readiness-gate")
.expect("readiness gate");
assert!(install < route, "proxy is installed before it is routed");
assert!(
readiness < route,
"proxy is routed only after the app reports /ready"
);
let proxy_unit = exec.shell_for("proxy-install").expect("proxy-install ran");
assert_eq!(
proxy_unit,
"systemctl daemon-reload && systemctl enable --now kamal-proxy.service",
);
assert!(
exec.calls().iter().any(|c| matches!(
c,
RecordedCall::Upload { remote_path, .. }
if remote_path == "/etc/systemd/system/kamal-proxy.service"
)),
"the proxy systemd unit is written to its final path"
);
let enable = exec.shell_for("enable-now").expect("enable-now ran");
assert!(
enable.contains("myapp-blue.service"),
"the app runs as a slot-scoped unit: {enable}"
);
assert!(
enable.contains("systemctl enable myapp-blue.service")
&& enable.contains("systemctl restart myapp-blue.service"),
"first-deploy start must force a restart, not `enable --now`: {enable}"
);
assert!(
!enable.contains("enable --now"),
"first-deploy start must not use `enable --now` (won't relaunch an active slot): {enable}"
);
let dr = labels.iter().position(|&l| l == "daemon-reload");
let start = labels.iter().position(|&l| l == "enable-now");
assert!(
dr.is_some() && start.is_some() && dr < start,
"daemon-reload must precede the app start so restart loads the new unit"
);
let gate = exec.shell_for("readiness-gate").expect("gate ran");
assert!(gate.contains("127.0.0.1:3001/ready"), "gate: {gate}");
let route_cmd = exec.shell_for("proxy-route").expect("route ran");
assert!(
route_cmd.contains("--target '127.0.0.1:3001'"),
"proxy routes at the blue loopback port: {route_cmd}"
);
}
#[test]
#[allow(clippy::too_many_lines)]
fn redeploy_produces_exact_zero_downtime_sequence() {
let ops = sample_cutover_ops(Secret::new("AUTUMN_SECURITY__SIGNING_SECRET=topsecret\n"));
let exec = RecordingExecutor::new();
run_ops(&ops, &exec).expect("recording executor never fails");
assert_eq!(
exec.run_labels(),
vec![
"proxy-snapshot-unit",
"proxy-install",
"proxy-restart-if-changed",
"prepare-dirs",
"daemon-reload",
"start-candidate",
"migrate",
"readiness-gate",
"proxy-flip",
"commit-markers",
"drain-old",
"prune",
],
"unexpected cutover sequence"
);
assert!(
exec.calls().iter().any(|c| matches!(
c,
RecordedCall::Upload { remote_path, mode }
if remote_path == "/srv/autumn/myapp/shared/autumn.env" && *mode == Some(0o600)
)),
"candidate env written 0600"
);
assert!(
exec.calls().iter().any(|c| matches!(
c,
RecordedCall::Upload { remote_path, .. }
if remote_path == "/etc/systemd/system/myapp-green.service"
)),
"candidate unit is the idle (green) slot"
);
let gate = exec.shell_for("readiness-gate").expect("gate ran");
assert!(gate.contains("127.0.0.1:3002/ready"), "gate: {gate}");
let flip = exec.shell_for("proxy-flip").expect("flip ran");
assert!(
flip.contains("kamal-proxy deploy") && flip.contains("--target '127.0.0.1:3002'"),
"flip targets the candidate: {flip}"
);
let drain = exec.shell_for("drain-old").expect("drain ran");
assert!(
drain.contains("disable --now myapp-blue.service"),
"{drain}"
);
let commit = exec
.shell_for("commit-markers")
.expect("commit-markers ran");
assert!(
commit.contains("readlink '/srv/autumn/myapp/current'")
&& commit.contains("cut -f1")
&& commit.contains("cut -s -f2")
&& commit.contains("|| lslot='blue'"),
"commit-markers reads current + the live-slot marker for previous-release: {commit}"
);
assert!(
commit.contains("printf '%s\\t%s\\t%s' \"$prev\" \"$lslot\" \"$lport\""),
"commit-markers writes the {{dir}}\\t{{slot}}\\t{{port}} previous-release format: {commit}"
);
assert!(
commit.contains("ln -sfn")
&& commit.contains(RELEASE_DIR)
&& commit.contains("'/srv/autumn/myapp/current'"),
"commit-markers repoints current to the new release: {commit}"
);
assert!(
commit.contains("printf '%s\\t%s' 'green' 3002"),
"commit-markers writes the {{slot}}\\t{{port}} live-slot format: {commit}"
);
assert!(
commit.contains("$(mktemp '/srv/autumn/myapp/shared/previous-release.tmp.XXXXXX')")
&& commit.contains("mv -f \"$ptmp\" '/srv/autumn/myapp/shared/previous-release'"),
"previous-release is written via temp-file + mv (atomic): {commit}"
);
assert!(
commit.contains("$(mktemp '/srv/autumn/myapp/shared/live-slot.tmp.XXXXXX')")
&& commit.contains("mv -f \"$ltmp\" '/srv/autumn/myapp/shared/live-slot'"),
"live-slot is written via temp-file + mv (atomic): {commit}"
);
assert!(
!commit.contains("> '/srv/autumn/myapp/shared/live-slot'")
&& !commit.contains("> '/srv/autumn/myapp/shared/previous-release'"),
"no bare `>` truncation onto a live marker (temp+mv only): {commit}"
);
let prev_write = commit
.find("mv -f \"$ptmp\"")
.expect("previous-release mv present");
let link = commit.find("ln -sfn").expect("ln -sfn present");
let live_write = commit
.find("mv -f \"$ltmp\"")
.expect("live-slot mv present");
assert!(
prev_write < link && link < live_write,
"commit-markers order must be previous-release write < ln -sfn < live-slot write: {commit}"
);
let labels = exec.run_labels();
let pos = |l: &str| labels.iter().position(|&x| x == l).unwrap();
let start_candidate = exec
.shell_for("start-candidate")
.expect("start-candidate ran");
assert!(
start_candidate.contains("systemctl enable myapp-green.service")
&& start_candidate.contains("systemctl restart myapp-green.service"),
"candidate start must force a restart, not `enable --now`: {start_candidate}"
);
assert!(
!start_candidate.contains("enable --now"),
"candidate start must not use `enable --now`: {start_candidate}"
);
assert!(
pos("daemon-reload") < pos("start-candidate"),
"daemon-reload must precede the candidate start so restart loads the new unit"
);
assert!(pos("migrate") < pos("proxy-flip"), "migrate before flip");
assert!(
pos("readiness-gate") < pos("proxy-flip"),
"flip only after a passing readiness gate"
);
assert!(
pos("proxy-flip") < pos("commit-markers"),
"flip before committing the state markers"
);
assert!(
pos("commit-markers") < pos("drain-old"),
"commit the markers before draining the old release"
);
assert!(pos("drain-old") < pos("prune"), "drain before prune");
}
#[test]
fn cutover_refreshes_proxy_unit_and_restarts_only_when_changed() {
let ops = sample_cutover_ops(Secret::new("AUTUMN_SECURITY__SIGNING_SECRET=x\n"));
assert!(
ops.iter().any(|op| matches!(
op,
DeployOp::WriteFile { label: "proxy-write-unit", remote_path, contents: FileContents::Plain(unit), .. }
if remote_path == "/etc/systemd/system/kamal-proxy.service"
&& unit.contains("StateDirectory=kamal-proxy\n")
&& unit.contains("Environment=HOME=/var/lib/kamal-proxy\n")
)),
"cutover must rewrite the reboot-durable proxy unit to its final path"
);
let exec = RecordingExecutor::new();
run_ops(&ops, &exec).expect("recording executor never fails");
let labels = exec.run_labels();
let pos = |l: &str| labels.iter().position(|&x| x == l);
assert!(
pos("proxy-install").is_some(),
"cutover installs the proxy unit"
);
let restart = pos("proxy-restart-if-changed").expect("restart-if-changed present");
assert!(
pos("proxy-snapshot-unit").unwrap() < pos("proxy-install").unwrap()
&& pos("proxy-install").unwrap() < restart,
"snapshot → install → restart-if-changed, in order: {labels:?}"
);
assert!(
restart < pos("prepare-dirs").unwrap() && restart < pos("proxy-flip").unwrap(),
"the proxy refresh precedes the candidate work and the flip: {labels:?}"
);
let snapshot = exec.shell_for("proxy-snapshot-unit").expect("snapshot ran");
assert!(
snapshot.contains("sha256sum '/etc/systemd/system/kamal-proxy.service'")
&& snapshot.contains("/tmp/autumn-kamal-proxy-unit-20260714T120000Z.sha256"),
"snapshot hashes the live unit into a per-release scratch path: {snapshot}"
);
let restart_sh = exec
.shell_for("proxy-restart-if-changed")
.expect("restart-if-changed ran");
assert!(
restart_sh.contains("if [ \"$new\" = \"$old\" ]; then exit 0; fi"),
"an UNCHANGED unit must short-circuit before any restart: {restart_sh}"
);
assert!(
restart_sh.contains("systemctl is-active --quiet kamal-proxy.service")
&& restart_sh.contains("systemctl restart kamal-proxy.service"),
"on a change it restarts the live proxy: {restart_sh}"
);
assert!(
restart_sh.contains(
"env -u XDG_RUNTIME_DIR kamal-proxy deploy 'myapp' --target '127.0.0.1:3001'"
),
"on a change it re-registers the CURRENT live upstream, socket-pinned: {restart_sh}"
);
let gate = restart_sh
.find("if [ \"$new\" = \"$old\" ]")
.expect("change gate present");
let do_restart = restart_sh
.find("systemctl restart kamal-proxy.service")
.expect("restart present");
let reregister = restart_sh
.find("kamal-proxy deploy 'myapp' --target '127.0.0.1:3001'")
.expect("re-register present");
assert!(
gate < do_restart && do_restart < reregister,
"order must be change-gate → restart → re-register: {restart_sh}"
);
}
fn sample_teardown_ops() -> Vec<DeployOp> {
let cfg = resolved();
let plan = SlotPlan::redeploy(3000, SLOT_BLUE);
candidate_teardown_ops(&cfg, RELEASE_ID, &plan)
}
#[test]
fn redeploy_migration_failure_leaves_old_serving_and_cleans_candidate() {
let ops = sample_cutover_ops(Secret::new("AUTUMN_SECURITY__SIGNING_SECRET=x\n"));
let teardown = sample_teardown_ops();
let exec = RecordingExecutor::failing_on("migrate");
let checks = vec![PreflightCheck::pass("ssh_reachability", "ok")];
let err = execute_redeploy(&checks, &ops, &teardown, &exec)
.expect_err("a failed migration must fail the deploy");
assert!(
matches!(
err,
DeployExecError::CandidateRolledBack {
failed_step: "migrate",
..
}
),
"expected a CandidateRolledBack at migrate, got {err:?}"
);
let labels = exec.run_labels();
assert!(
!labels.contains(&"proxy-flip"),
"no flip after a failed migration"
);
assert!(!labels.contains(&"drain-old"), "old release not drained");
assert!(
!labels.contains(&"commit-markers"),
"current not repointed (commit-markers never ran)"
);
assert!(!labels.contains(&"prune"), "nothing pruned");
assert!(
labels.contains(&"teardown-candidate-unit")
&& labels.contains(&"teardown-candidate-dir"),
"candidate must be torn down: {labels:?}"
);
let td_unit = exec
.shell_for("teardown-candidate-unit")
.expect("teardown-candidate-unit ran");
assert!(
td_unit.contains("disable --now myapp-green.service"),
"teardown stops+disables the candidate (green) slot unit: {td_unit}"
);
let td_dir = exec
.shell_for("teardown-candidate-dir")
.expect("teardown-candidate-dir ran");
assert!(
td_dir.contains("rm -rf") && td_dir.contains(RELEASE_DIR),
"teardown removes the candidate release dir: {td_dir}"
);
}
#[test]
fn redeploy_readiness_failure_auto_rolls_back_and_tears_down_candidate() {
let ops = sample_cutover_ops(Secret::new("AUTUMN_SECURITY__SIGNING_SECRET=x\n"));
let teardown = sample_teardown_ops();
let exec = RecordingExecutor::failing_on("readiness-gate");
let checks = vec![PreflightCheck::pass("ssh_reachability", "ok")];
let err = execute_redeploy(&checks, &ops, &teardown, &exec)
.expect_err("a readiness timeout must fail the deploy");
assert!(
matches!(
err,
DeployExecError::CandidateRolledBack {
failed_step: "readiness-gate",
..
}
),
"expected a CandidateRolledBack at readiness-gate, got {err:?}"
);
let labels = exec.run_labels();
assert!(
!labels.contains(&"proxy-flip"),
"no flip on readiness timeout"
);
assert!(!labels.contains(&"drain-old"), "old release not drained");
assert!(
!labels.contains(&"commit-markers"),
"current not repointed (commit-markers never ran)"
);
assert!(
labels.contains(&"teardown-candidate-unit")
&& labels.contains(&"teardown-candidate-dir"),
"candidate must be torn down: {labels:?}"
);
let td_unit = exec
.shell_for("teardown-candidate-unit")
.expect("teardown-candidate-unit ran");
assert!(
td_unit.contains("disable --now myapp-green.service"),
"teardown stops+disables the candidate (green) slot unit: {td_unit}"
);
let td_dir = exec
.shell_for("teardown-candidate-dir")
.expect("teardown-candidate-dir ran");
assert!(
td_dir.contains(RELEASE_DIR),
"teardown removes the candidate release dir: {td_dir}"
);
}
#[test]
fn teardown_fails_safe_when_boundary_missing() {
let ops = vec![
DeployOp::Run(RemoteCommand::new("upload-release", "true")),
DeployOp::Run(RemoteCommand::new("prxy-flip-typo", "true")),
DeployOp::Run(RemoteCommand::new("commit-markers", "true")),
];
let teardown = vec![
DeployOp::Run(RemoteCommand::new("teardown-candidate-unit", "true")),
DeployOp::Run(RemoteCommand::new("teardown-candidate-dir", "true")),
];
let exec = RecordingExecutor::failing_on("commit-markers");
let checks = vec![PreflightCheck::pass("ssh_reachability", "ok")];
let err = execute_with_teardown(
&checks,
&ops,
&teardown,
"proxy-flip",
TeardownKind::PreviousStillServing,
&exec,
)
.expect_err("a late failure must still surface an error");
assert!(
matches!(
err,
DeployExecError::CommandFailed {
label: "commit-markers",
..
}
),
"an unknown boundary must surface the raw error, not a teardown wrapper: {err:?}"
);
let labels = exec.run_labels();
assert!(
!labels.iter().any(|l| l.starts_with("teardown")),
"a missing/mislabeled boundary must never trigger teardown: {labels:?}"
);
assert!(
!labels.contains(&"teardown-candidate-unit")
&& !labels.contains(&"teardown-candidate-dir"),
"candidate must not be disabled or removed on an unknown boundary: {labels:?}"
);
}
#[test]
#[allow(clippy::too_many_lines)]
fn deploy_rollback_produces_exact_ordered_sequence() {
let cfg = resolved();
let exec = RecordingExecutor::new().with_stdout(
"resolve-previous",
"prev:/srv/autumn/myapp/releases/20260713T090000Z\tblue\t3001",
);
let target = resolve_rollback_target(&cfg, 3000, &exec).expect("previous release resolves");
assert_eq!(target.slot, SLOT_BLUE);
assert_eq!(target.port, 3001);
assert_eq!(
target.release_dir,
"/srv/autumn/myapp/releases/20260713T090000Z"
);
let ops = rollback_ops(&cfg, &proxy(), &target);
run_ops(&ops, &exec).expect("recording executor never fails");
assert_eq!(
exec.run_labels(),
vec![
"resolve-previous",
"daemon-reload",
"restart-previous",
"proxy-flip",
"commit-markers",
"readiness-gate",
"drain-rolled-back-slot",
],
"unexpected rollback sequence"
);
let commit = exec
.shell_for("commit-markers")
.expect("commit-markers ran");
assert!(
commit.contains("readlink '/srv/autumn/myapp/current'")
&& commit.contains("cut -f1")
&& commit.contains("cut -s -f2")
&& commit.contains("|| lslot='green'"),
"commit-markers reads current + the live-slot marker for previous-release: {commit}"
);
assert!(
commit.contains("printf '%s\\t%s\\t%s' \"$prev\" \"$lslot\" \"$lport\""),
"commit-markers writes the {{dir}}\\t{{slot}}\\t{{port}} previous-release format: {commit}"
);
assert!(
commit.contains("ln -sfn")
&& commit.contains("/srv/autumn/myapp/releases/20260713T090000Z")
&& commit.contains("'/srv/autumn/myapp/current'"),
"commit-markers repoints current to the target release: {commit}"
);
assert!(
commit.contains("printf '%s\\t%s' 'blue' 3001"),
"commit-markers writes the {{slot}}\\t{{port}} live-slot format: {commit}"
);
assert!(
commit.contains("$(mktemp '/srv/autumn/myapp/shared/previous-release.tmp.XXXXXX')")
&& commit.contains("mv -f \"$ptmp\" '/srv/autumn/myapp/shared/previous-release'")
&& commit.contains("$(mktemp '/srv/autumn/myapp/shared/live-slot.tmp.XXXXXX')")
&& commit.contains("mv -f \"$ltmp\" '/srv/autumn/myapp/shared/live-slot'"),
"both markers are written via temp-file + mv (atomic): {commit}"
);
assert!(
!commit.contains("> '/srv/autumn/myapp/shared/live-slot'")
&& !commit.contains("> '/srv/autumn/myapp/shared/previous-release'"),
"no bare `>` truncation onto a live marker (temp+mv only): {commit}"
);
let prev_write = commit
.find("mv -f \"$ptmp\"")
.expect("previous-release mv present");
let link = commit.find("ln -sfn").expect("ln -sfn present");
let live_write = commit
.find("mv -f \"$ltmp\"")
.expect("live-slot mv present");
assert!(
prev_write < link && link < live_write,
"commit-markers order must be previous-release write < ln -sfn < live-slot write: {commit}"
);
let restart = exec.shell_for("restart-previous").expect("restart ran");
assert!(
restart.contains("systemctl enable myapp-blue.service")
&& restart.contains("systemctl restart myapp-blue.service"),
"rollback force-relaunches the previous (blue) slot unit: {restart}"
);
assert!(
!restart.contains("enable --now"),
"rollback restart must not use `enable --now`: {restart}"
);
let flip = exec.shell_for("proxy-flip").expect("flip ran");
assert!(
flip.contains("kamal-proxy deploy") && flip.contains("--target '127.0.0.1:3001'"),
"flip targets the previous release's address: {flip}"
);
let gate = exec.shell_for("readiness-gate").expect("gate ran");
assert!(gate.contains("127.0.0.1:3001/ready"), "gate: {gate}");
let labels = exec.run_labels();
let pos = |l: &str| labels.iter().position(|&x| x == l).unwrap();
assert!(
pos("restart-previous") < pos("proxy-flip"),
"the previous unit is up before the flip"
);
assert!(
pos("proxy-flip") < pos("commit-markers"),
"flip before committing the state markers"
);
assert!(
pos("commit-markers") < pos("readiness-gate"),
"commit the markers before the re-probe"
);
let drain = exec.shell_for("drain-rolled-back-slot").expect("drain ran");
assert!(
drain.contains("disable --now myapp-green.service"),
"rollback drains the former-live (green) slot: {drain}"
);
assert!(
!drain.contains("myapp-blue.service"),
"rollback must NOT drain the slot it rolled back to (blue): {drain}"
);
assert!(
pos("readiness-gate") < pos("drain-rolled-back-slot"),
"drain the former-live slot only after confirming the rollback is healthy"
);
}
#[test]
fn commit_markers_writes_both_markers_atomically_via_temp_and_mv() {
let ops = sample_cutover_ops(Secret::new("AUTUMN_SECURITY__SIGNING_SECRET=x\n"));
let exec = RecordingExecutor::new();
run_ops(&ops, &exec).expect("recording executor never fails");
let commit = exec
.shell_for("commit-markers")
.expect("commit-markers ran");
assert!(
commit.contains("ltmp=$(mktemp '/srv/autumn/myapp/shared/live-slot.tmp.XXXXXX')"),
"live-slot temp is mktemp'd in the shared dir (same-fs atomic mv): {commit}"
);
assert!(
commit.contains("printf '%s\\t%s' 'green' 3002 > \"$ltmp\""),
"live-slot payload ({{slot}}\\t{{port}}) is written to the temp file: {commit}"
);
assert!(
commit.contains("mv -f \"$ltmp\" '/srv/autumn/myapp/shared/live-slot'"),
"live-slot is committed by an atomic mv of the temp file: {commit}"
);
assert!(
commit
.contains("ptmp=$(mktemp '/srv/autumn/myapp/shared/previous-release.tmp.XXXXXX')"),
"previous-release temp is mktemp'd in the shared dir: {commit}"
);
assert!(
commit.contains("printf '%s\\t%s\\t%s' \"$prev\" \"$lslot\" \"$lport\" > \"$ptmp\""),
"previous-release payload ({{dir}}\\t{{slot}}\\t{{port}}) is written to the temp file: {commit}"
);
assert!(
commit.contains("mv -f \"$ptmp\" '/srv/autumn/myapp/shared/previous-release'"),
"previous-release is committed by an atomic mv of the temp file: {commit}"
);
assert!(
commit.contains("if [ -n \"$prev\" ]; then"),
"previous-release write stays guarded on a non-empty prev: {commit}"
);
assert!(
!commit.contains("> '/srv/autumn/myapp/shared/live-slot'")
&& !commit.contains("> '/srv/autumn/myapp/shared/previous-release'"),
"markers are updated only via temp+mv, never a bare `>` truncation: {commit}"
);
}
#[test]
fn rollback_rerenders_target_unit_before_restart() {
let cfg = resolved();
let target = RollbackTarget {
release_dir: "/srv/autumn/myapp/releases/20260713T090000Z".to_owned(),
slot: SLOT_BLUE,
port: 3001,
};
let ops = rollback_ops(&cfg, &proxy(), &target);
let (unit_idx, unit_path, unit_contents) = ops
.iter()
.enumerate()
.find_map(|(i, op)| match op {
DeployOp::WriteFile {
label: "write-target-unit",
remote_path,
contents,
..
} => Some((i, remote_path.clone(), contents.as_str().to_owned())),
_ => None,
})
.expect("rollback re-renders the target unit");
assert_eq!(
unit_path, "/etc/systemd/system/myapp-blue.service",
"the re-rendered unit is written to the target slot's unit path"
);
assert!(
unit_contents.contains("ExecStart=/srv/autumn/myapp/releases/20260713T090000Z/myapp"),
"re-rendered unit ExecStart references the target release dir: {unit_contents}"
);
assert!(
unit_contents.contains("WorkingDirectory=/srv/autumn/myapp/releases/20260713T090000Z"),
"re-rendered unit WorkingDirectory is the target release dir: {unit_contents}"
);
assert!(
unit_contents.contains("AUTUMN_SERVER__PORT=3001"),
"re-rendered unit binds the target's persisted port (3001): {unit_contents}"
);
let label_idx = |want: &str| {
ops.iter()
.position(|op| op.label() == want)
.unwrap_or_else(|| panic!("op {want} present"))
};
let reload_idx = label_idx("daemon-reload");
let restart_idx = label_idx("restart-previous");
assert!(
unit_idx < reload_idx && reload_idx < restart_idx,
"order must be write-target-unit ({unit_idx}) < daemon-reload ({reload_idx}) \
< restart-previous ({restart_idx})"
);
}
#[test]
fn rollback_reads_target_release_own_manifest_and_uploads_none() {
let cfg = resolved();
let target = RollbackTarget {
release_dir: "/srv/autumn/myapp/releases/20260713T090000Z".to_owned(),
slot: SLOT_BLUE,
port: 3001,
};
let ops = rollback_ops(&cfg, &proxy(), &target);
let unit_contents = ops
.iter()
.find_map(|op| match op {
DeployOp::WriteFile {
label: "write-target-unit",
contents,
..
} => Some(contents.as_str().to_owned()),
_ => None,
})
.expect("rollback re-renders the target unit");
assert!(
unit_contents.contains(
"Environment=AUTUMN_MANIFEST_DIR=/srv/autumn/myapp/releases/20260713T090000Z"
),
"rolled-back unit points AUTUMN_MANIFEST_DIR at the target release dir \
(its own manifest): {unit_contents}"
);
assert!(
!ops.iter().any(|op| op.label() == "upload-config"),
"rollback uploads no manifest — the target release dir already carries \
the manifest it shipped with"
);
}
#[test]
fn rollback_drains_the_slot_it_flipped_away_from() {
let cfg = resolved();
let exec = RecordingExecutor::new().with_stdout(
"resolve-previous",
"prev:/srv/autumn/myapp/releases/20260713T090000Z\tgreen\t3002",
);
let target = resolve_rollback_target(&cfg, 3000, &exec).expect("previous release resolves");
assert_eq!(target.slot, SLOT_GREEN, "roll back to the non-live slot");
let ops = rollback_ops(&cfg, &proxy(), &target);
run_ops(&ops, &exec).expect("recording executor never fails");
let labels = exec.run_labels();
let pos = |l: &str| labels.iter().position(|&x| x == l).unwrap();
let drain = exec.shell_for("drain-rolled-back-slot").expect("drain ran");
assert!(
drain.contains("disable --now myapp-blue.service"),
"drain targets other_slot(previous) = the former-live blue slot: {drain}"
);
assert!(
!drain.contains("myapp-green.service"),
"drain must NOT target the slot we rolled back to (green): {drain}"
);
assert!(
pos("proxy-flip") < pos("drain-rolled-back-slot"),
"drain the former-live slot only after the flip moves traffic off it"
);
assert!(
pos("readiness-gate") < pos("drain-rolled-back-slot"),
"drain only after the rolled-back release is confirmed healthy"
);
}
fn sample_rollback(
exec: &RecordingExecutor,
) -> (ResolvedDeployConfig, Vec<DeployOp>, Vec<DeployOp>) {
let cfg = resolved();
let target = resolve_rollback_target(&cfg, 3000, exec).expect("previous release resolves");
let ops = rollback_ops(&cfg, &proxy(), &target);
let teardown = rollback_teardown_ops(&cfg, &target);
(cfg, ops, teardown)
}
#[test]
fn rollback_flip_failure_disables_restarted_slot_and_leaves_original() {
let exec = RecordingExecutor::failing_on("proxy-flip").with_stdout(
"resolve-previous",
"prev:/srv/autumn/myapp/releases/20260713T090000Z\tblue\t3001",
);
let (_cfg, ops, teardown) = sample_rollback(&exec);
let checks = vec![PreflightCheck::pass("ssh_reachability", "ok")];
let err = execute_rollback(&checks, &ops, &teardown, &exec)
.expect_err("a flip failure must fail the rollback");
assert!(
matches!(
err,
DeployExecError::RollbackFailed {
failed_step: "proxy-flip",
..
}
),
"expected RollbackFailed at proxy-flip, got {err:?}"
);
let labels = exec.run_labels();
assert!(
labels.contains(&"teardown-rollback-slot"),
"the restarted slot must be disabled: {labels:?}"
);
let td = exec
.shell_for("teardown-rollback-slot")
.expect("teardown-rollback-slot ran");
assert!(
td.contains("disable --now myapp-blue.service"),
"teardown disables the slot the rollback restarted: {td}"
);
assert!(
!exec.calls().iter().any(|c| matches!(
c,
RecordedCall::Run { shell, .. } if shell.contains("rm -rf")
)),
"the target release dir must NOT be removed: {:?}",
exec.calls()
);
assert!(
!labels.contains(&"commit-markers"),
"no marker may be written on a pre-/at-flip failure: {labels:?}"
);
assert!(
!labels.contains(&"readiness-gate") && !labels.contains(&"drain-rolled-back-slot"),
"no post-flip op may run after a failed flip: {labels:?}"
);
}
#[test]
fn rollback_restart_previous_failure_disables_restarted_slot() {
let exec = RecordingExecutor::failing_on("restart-previous").with_stdout(
"resolve-previous",
"prev:/srv/autumn/myapp/releases/20260713T090000Z\tblue\t3001",
);
let (_cfg, ops, teardown) = sample_rollback(&exec);
let checks = vec![PreflightCheck::pass("ssh_reachability", "ok")];
let err = execute_rollback(&checks, &ops, &teardown, &exec)
.expect_err("a restart-previous failure must fail the rollback");
assert!(
matches!(
err,
DeployExecError::RollbackFailed {
failed_step: "restart-previous",
..
}
),
"expected RollbackFailed at restart-previous, got {err:?}"
);
let labels = exec.run_labels();
let td = exec
.shell_for("teardown-rollback-slot")
.expect("teardown-rollback-slot ran");
assert!(
td.contains("disable --now myapp-blue.service"),
"teardown disables the slot the rollback restarted: {td}"
);
assert!(
!labels.contains(&"proxy-flip"),
"no flip after a restart-previous failure: {labels:?}"
);
assert!(
!labels.contains(&"commit-markers"),
"no marker may be written: {labels:?}"
);
assert!(
!exec.calls().iter().any(|c| matches!(
c,
RecordedCall::Run { shell, .. } if shell.contains("rm -rf")
)),
"the target release dir must NOT be removed: {:?}",
exec.calls()
);
}
#[test]
fn execute_rollback_happy_path_runs_full_sequence_without_teardown() {
let exec = RecordingExecutor::new().with_stdout(
"resolve-previous",
"prev:/srv/autumn/myapp/releases/20260713T090000Z\tblue\t3001",
);
let (_cfg, ops, teardown) = sample_rollback(&exec);
let checks = vec![PreflightCheck::pass("ssh_reachability", "ok")];
execute_rollback(&checks, &ops, &teardown, &exec).expect("a healthy rollback succeeds");
let labels = exec.run_labels();
assert_eq!(
labels,
vec![
"resolve-previous",
"daemon-reload",
"restart-previous",
"proxy-flip",
"commit-markers",
"readiness-gate",
"drain-rolled-back-slot",
],
"happy-path rollback runs the full sequence"
);
assert!(
!labels.contains(&"teardown-rollback-slot"),
"a healthy rollback must not disable the restarted slot: {labels:?}"
);
}
#[test]
fn execute_rollback_preflight_failure_aborts_before_any_call() {
let cfg = resolved();
let exec = RecordingExecutor::new();
let target = RollbackTarget {
release_dir: "/srv/autumn/myapp/releases/20260713T090000Z".to_owned(),
slot: SLOT_BLUE,
port: 3001,
};
let ops = rollback_ops(&cfg, &proxy(), &target);
let teardown = rollback_teardown_ops(&cfg, &target);
let checks = vec![
PreflightCheck::pass("signing_secret", "ok"),
PreflightCheck::fail("ssh_reachability", "no target host configured", "set host"),
];
let err = execute_rollback(&checks, &ops, &teardown, &exec)
.expect_err("failing preflight must abort the rollback");
assert!(
matches!(err, DeployExecError::PreflightAborted { failed: 1 }),
"expected PreflightAborted, got {err:?}"
);
assert!(
exec.calls().is_empty(),
"no remote call may run when preflight fails: {:?}",
exec.calls()
);
}
#[test]
fn resolve_rollback_target_reads_the_marker_not_the_mtime_newest_dir() {
let cfg = resolved();
let marker_a = "/srv/autumn/myapp/releases/20260101T000000Z"; let exec = RecordingExecutor::new()
.with_stdout("resolve-previous", format!("prev:{marker_a}\tgreen\t3002"));
let target =
resolve_rollback_target(&cfg, 3000, &exec).expect("marker names a previous release");
assert_eq!(target.release_dir, marker_a, "dir comes from the marker");
assert_eq!(
target.slot, SLOT_GREEN,
"slot comes from the SAME marker line"
);
assert_eq!(
target.port, 3002,
"port is read from the SAME marker line, not re-derived"
);
let probe = exec.shell_for("resolve-previous").expect("probe ran");
assert!(
probe.contains("/srv/autumn/myapp/shared/previous-release"),
"the probe reads the previous-release marker: {probe}"
);
assert!(
!probe.contains("ls -1dt"),
"the mtime scan must be gone: {probe}"
);
}
#[test]
fn resolve_rollback_target_absent_marker_degrades_to_no_previous() {
let cfg = resolved();
let exec = RecordingExecutor::new().with_stdout("resolve-previous", "none");
let err = resolve_rollback_target(&cfg, 3000, &exec)
.expect_err("an absent marker must error, not crash");
assert!(
matches!(err, DeployExecError::NoPreviousRelease),
"expected NoPreviousRelease, got {err:?}"
);
}
#[test]
fn rollback_uses_the_persisted_port_not_the_current_config() {
let cfg = resolved();
let current_public = 4000; let derived_now = slot_app_port(current_public, SLOT_BLUE);
assert_eq!(derived_now, 4001, "re-derivation would give the WRONG port");
let exec = RecordingExecutor::new().with_stdout(
"resolve-previous",
"prev:/srv/autumn/myapp/releases/20260713T090000Z\tblue\t3001",
);
let target = resolve_rollback_target(&cfg, current_public, &exec)
.expect("previous release resolves");
assert_eq!(
target.port, 3001,
"RollbackTarget.port is the MARKER's port, not the re-derived one"
);
assert_ne!(
target.port, derived_now,
"the persisted port must win over the current-config derivation"
);
let ops = rollback_ops(&cfg, &proxy(), &target);
run_ops(&ops, &exec).expect("recording executor never fails");
let flip = exec.shell_for("proxy-flip").expect("flip ran");
assert!(
flip.contains("--target '127.0.0.1:3001'") && !flip.contains("4001"),
"flip targets the persisted port, not the re-derived one: {flip}"
);
let gate = exec.shell_for("readiness-gate").expect("gate ran");
assert!(
gate.contains("127.0.0.1:3001/ready") && !gate.contains("4001"),
"readiness re-probe uses the persisted port: {gate}"
);
let restart = exec.shell_for("restart-previous").expect("restart ran");
assert!(
restart.contains("systemctl enable myapp-blue.service")
&& restart.contains("systemctl restart myapp-blue.service"),
"restart force-relaunches the previous slot unit: {restart}"
);
}
#[test]
fn resolve_rollback_target_without_port_field_falls_back_to_derived() {
let cfg = resolved();
let exec = RecordingExecutor::new().with_stdout(
"resolve-previous",
"prev:/srv/autumn/myapp/releases/20260713T090000Z\tblue",
);
let target =
resolve_rollback_target(&cfg, 3000, &exec).expect("legacy 2-field marker still parses");
assert_eq!(target.slot, SLOT_BLUE);
assert_eq!(
target.port,
slot_app_port(3000, SLOT_BLUE),
"a portless marker falls back to the derived port"
);
}
#[test]
fn rollback_with_no_previous_release_errors() {
let cfg = resolved();
let exec = RecordingExecutor::new().with_stdout("resolve-previous", "none");
let err =
resolve_rollback_target(&cfg, 3000, &exec).expect_err("no previous release must error");
assert!(
matches!(err, DeployExecError::NoPreviousRelease),
"expected NoPreviousRelease, got {err:?}"
);
let labels = exec.run_labels();
assert_eq!(labels, vec!["resolve-previous"], "only the probe may run");
assert!(!labels.contains(&"proxy-flip"), "no flip");
assert!(
!labels.contains(&"commit-markers"),
"current not repointed (commit-markers never ran)"
);
assert!(
!labels.iter().any(|l| l.starts_with("teardown")),
"nothing torn down"
);
}
#[test]
fn first_deploy_readiness_failure_tears_down_candidate_no_previous() {
let ops = sample_ops(Secret::new("AUTUMN_SECURITY__SIGNING_SECRET=x\n"));
let cfg = resolved();
let plan = SlotPlan::first(3000);
let teardown = first_deploy_teardown_ops(&cfg, RELEASE_ID, &plan);
let exec = RecordingExecutor::failing_on("readiness-gate");
let checks = vec![PreflightCheck::pass("ssh_reachability", "ok")];
let err = execute_first_deploy(&checks, &ops, &teardown, &exec)
.expect_err("first-deploy readiness failure must fail the deploy");
assert!(
matches!(
err,
DeployExecError::FirstDeployTornDown {
failed_step: "readiness-gate",
..
}
),
"expected FirstDeployTornDown at readiness-gate, got {err:?}"
);
let labels = exec.run_labels();
assert!(!labels.contains(&"proxy-route"), "no route on failed gate");
let td_unit = exec
.shell_for("teardown-candidate-unit")
.expect("teardown-candidate-unit ran");
assert!(
td_unit.contains("disable --now myapp-blue.service"),
"teardown stops+disables the first-deploy (blue) slot unit: {td_unit}"
);
}
#[test]
fn first_deploy_teardown_unlinks_current_and_marker() {
let cfg = resolved();
let plan = SlotPlan::first(3000);
let teardown = first_deploy_teardown_ops(&cfg, RELEASE_ID, &plan);
let labels: Vec<&str> = teardown.iter().map(DeployOp::label).collect();
assert_eq!(
labels,
vec![
"teardown-candidate-unit",
"teardown-candidate-dir",
"teardown-current-symlink",
"teardown-slot-markers",
],
"first-deploy teardown must also clean current + markers: {labels:?}"
);
let exec = RecordingExecutor::new();
run_teardown(&teardown, &exec);
let rm_current = exec
.shell_for("teardown-current-symlink")
.expect("teardown-current-symlink ran");
assert!(
rm_current.contains("rm -f '/srv/autumn/myapp/current'"),
"teardown unlinks the current symlink: {rm_current}"
);
let rm_markers = exec
.shell_for("teardown-slot-markers")
.expect("teardown-slot-markers ran");
assert!(
rm_markers.contains("/srv/autumn/myapp/shared/live-slot")
&& rm_markers.contains("/srv/autumn/myapp/shared/previous-release"),
"teardown removes the live-slot and previous-release markers: {rm_markers}"
);
}
#[test]
fn redeploy_teardown_leaves_current_and_markers_intact() {
let teardown = sample_teardown_ops();
let labels: Vec<&str> = teardown.iter().map(DeployOp::label).collect();
assert_eq!(
labels,
vec!["teardown-candidate-unit", "teardown-candidate-dir"],
"redeploy teardown is candidate-only: {labels:?}"
);
assert!(
!labels.contains(&"teardown-current-symlink"),
"redeploy teardown must not unlink current: {labels:?}"
);
assert!(
!labels.contains(&"teardown-slot-markers"),
"redeploy teardown must not remove the live-slot marker: {labels:?}"
);
}
#[test]
fn prune_keeps_exactly_keep_releases() {
let cfg = resolved();
assert_eq!(cfg.keep_releases, 3, "default keep_releases");
let ops = sample_cutover_ops(Secret::new("AUTUMN_SECURITY__SIGNING_SECRET=x\n"));
let exec = RecordingExecutor::new();
run_ops(&ops, &exec).expect("run ok");
let prune = exec.shell_for("prune").expect("prune ran");
assert!(
prune.contains("ls -1dt */") && prune.contains("tail -n +4"),
"prune must keep exactly keep_releases (3): {prune}"
);
assert!(
prune.contains("/srv/autumn/myapp/releases"),
"prune operates on the releases dir: {prune}"
);
}
#[test]
fn prune_preserves_current_and_previous_targets() {
let ops = sample_cutover_ops(Secret::new("AUTUMN_SECURITY__SIGNING_SECRET=x\n"));
let exec = RecordingExecutor::new();
run_ops(&ops, &exec).expect("run ok");
let prune = exec.shell_for("prune").expect("prune ran");
assert!(
prune.contains("readlink -f '/srv/autumn/myapp/current'"),
"prune resolves the current symlink target: {prune}"
);
assert!(
prune.contains("cut -f1 '/srv/autumn/myapp/shared/previous-release'"),
"prune reads the previous-release marker dir field: {prune}"
);
let exclude = prune.find("grep -vxF \"$cur\"");
let exclude_prev = prune.find("grep -vxF \"$prev\"");
let tail = prune.find("tail -n +");
assert!(
exclude.is_some() && exclude_prev.is_some(),
"prune excludes both protected basenames: {prune}"
);
assert!(
exclude < tail && exclude_prev < tail,
"protected exclusions precede the keep-count bound: {prune}"
);
}
#[cfg(target_os = "linux")]
#[test]
fn prune_shell_protects_current_and_previous_dirs_end_to_end() {
let root = std::env::temp_dir().join(format!(
"autumn-prune-test-{}-{}",
std::process::id(),
std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap()
.as_nanos()
));
let releases = root.join("releases");
let shared = root.join("shared");
std::fs::create_dir_all(&releases).expect("mk releases");
std::fs::create_dir_all(&shared).expect("mk shared");
let dirs = [
("prev_old", 100u64), ("stale_old", 200), ("stale_new", 300), ("cur_new", 400), ];
for (name, mtime) in dirs {
let d = releases.join(name);
std::fs::create_dir(&d).expect("mk release dir");
let ok = std::process::Command::new("touch")
.arg("-m")
.arg("-d")
.arg(format!("@{mtime}"))
.arg(&d)
.status()
.expect("run touch")
.success();
assert!(ok, "touch must set mtime");
}
let current = root.join("current");
std::os::unix::fs::symlink(releases.join("cur_new"), ¤t).expect("symlink current");
let marker = shared.join("previous-release");
std::fs::write(
&marker,
format!("{}\tblue", releases.join("prev_old").display()),
)
.expect("write marker");
let shell = prune_releases_shell(
releases.to_str().unwrap(),
current.to_str().unwrap(),
marker.to_str().unwrap(),
1,
);
let status = std::process::Command::new("sh")
.arg("-c")
.arg(&shell)
.status()
.expect("run prune shell");
assert!(status.success(), "prune shell exited non-zero");
assert!(
releases.join("cur_new").exists(),
"current target must survive"
);
assert!(
releases.join("prev_old").exists(),
"previous-release target must survive even though it is the oldest"
);
assert!(
releases.join("stale_new").exists(),
"newest non-protected release is retained by keep=1"
);
assert!(
!releases.join("stale_old").exists(),
"old unprotected release must be pruned"
);
std::fs::remove_dir_all(&root).ok();
}
#[test]
fn detect_deploy_mode_picks_first_vs_redeploy() {
let cfg = resolved();
let first = RecordingExecutor::new().with_stdout("detect-current", "first");
assert_eq!(
probe_deploy_state(&cfg, &first).unwrap().mode,
DeployMode::First
);
let redeploy =
RecordingExecutor::new().with_stdout("detect-current", "redeploy:green\t3002");
assert_eq!(
probe_deploy_state(&cfg, &redeploy).unwrap().mode,
DeployMode::Redeploy {
live_slot: SLOT_GREEN,
}
);
let legacy = RecordingExecutor::new().with_stdout("detect-current", "redeploy:green");
assert_eq!(
probe_deploy_state(&cfg, &legacy).unwrap().mode,
DeployMode::Redeploy {
live_slot: SLOT_GREEN,
}
);
let default_blue = RecordingExecutor::new().with_stdout("detect-current", "redeploy:");
assert_eq!(
probe_deploy_state(&cfg, &default_blue).unwrap().mode,
DeployMode::Redeploy {
live_slot: SLOT_BLUE,
}
);
}
fn proxy_list_serving(service: &str, port: u16) -> String {
format!(
"Service Host Target State TLS\n\
{service} example.com 127.0.0.1:{port} running no\n"
)
}
#[test]
fn probe_deploy_state_captures_the_proxy_list_section() {
let cfg = resolved();
let stdout = format!(
"redeploy:blue\t3001\n---autumn-kamal-proxy-list---\n{}",
proxy_list_serving("myapp", 3001)
);
let exec = RecordingExecutor::new().with_stdout("detect-current", stdout);
let probe = probe_deploy_state(&cfg, &exec).unwrap();
assert_eq!(
probe.mode,
DeployMode::Redeploy {
live_slot: SLOT_BLUE,
}
);
assert!(
probe.proxy_list.contains("127.0.0.1:3001"),
"proxy list section captured: {}",
probe.proxy_list
);
let shell = exec.shell_for("detect-current").expect("probe ran");
assert!(
shell.contains("kamal-proxy list") && shell.contains("|| true"),
"probe runs kamal-proxy list best-effort: {shell}"
);
assert!(
shell.contains("env -u XDG_RUNTIME_DIR kamal-proxy list"),
"probe socket-pins the kamal-proxy list invocation: {shell}"
);
let legacy = RecordingExecutor::new().with_stdout("detect-current", "redeploy:green\t3002");
let probe = probe_deploy_state(&cfg, &legacy).unwrap();
assert_eq!(
probe.mode,
DeployMode::Redeploy {
live_slot: SLOT_GREEN,
}
);
assert!(
probe.proxy_list.is_empty(),
"no delimiter → empty proxy list"
);
assert_eq!(
probe.installed_proxy_port,
InstalledProxyPort::Absent,
"a missing unit delimiter degrades to Absent (no spurious refuse)"
);
}
#[test]
fn probe_deploy_state_captures_the_installed_proxy_port() {
let cfg = resolved();
let stdout = format!(
"redeploy:blue\t3001\n---autumn-kamal-proxy-list---\n{}\n\
---autumn-kamal-proxy-unit---\n--http-port 80",
proxy_list_serving("myapp", 3001)
);
let exec = RecordingExecutor::new().with_stdout("detect-current", stdout);
let probe = probe_deploy_state(&cfg, &exec).unwrap();
assert_eq!(
probe.installed_proxy_port,
InstalledProxyPort::Port(80),
"the installed unit's --http-port is captured"
);
assert!(
probe.proxy_list.contains("127.0.0.1:3001")
&& !probe.proxy_list.contains("--http-port"),
"the list section is split off from the unit section: {}",
probe.proxy_list
);
let shell = exec.shell_for("detect-current").expect("probe ran");
assert!(
shell.contains("--http-port")
&& shell.contains("/etc/systemd/system/kamal-proxy.service")
&& shell.contains("[ -f "),
"probe greps the installed unit's --http-port guarded on the file: {shell}"
);
let absent = RecordingExecutor::new().with_stdout(
"detect-current",
"redeploy:blue\t3001\n---autumn-kamal-proxy-list---\n\
---autumn-kamal-proxy-unit---\n---autumn-no-proxy-unit---",
);
assert_eq!(
probe_deploy_state(&cfg, &absent)
.unwrap()
.installed_proxy_port,
InstalledProxyPort::Absent,
"the no-unit sentinel parses to Absent"
);
let unreadable = RecordingExecutor::new().with_stdout(
"detect-current",
"redeploy:blue\t3001\n---autumn-kamal-proxy-list---\n\
---autumn-kamal-proxy-unit---\n",
);
assert_eq!(
probe_deploy_state(&cfg, &unreadable)
.unwrap()
.installed_proxy_port,
InstalledProxyPort::Unreadable,
"a present unit with no readable --http-port fails closed as Unreadable"
);
}
#[test]
fn parse_installed_proxy_port_classifies_the_unit_section() {
assert_eq!(
parse_installed_proxy_port("---autumn-no-proxy-unit---"),
InstalledProxyPort::Absent
);
assert_eq!(
parse_installed_proxy_port("--http-port 80"),
InstalledProxyPort::Port(80)
);
assert_eq!(
parse_installed_proxy_port(" --http-port 3000 "),
InstalledProxyPort::Port(3000)
);
assert_eq!(
parse_installed_proxy_port(" "),
InstalledProxyPort::Unreadable
);
assert_eq!(
parse_installed_proxy_port("--http-port 80\n--http-port 8080"),
InstalledProxyPort::Unreadable
);
assert_eq!(
parse_installed_proxy_port("--http-port 99999"),
InstalledProxyPort::Unreadable
);
}
fn compatible_deploy_help() -> &'static str {
"Usage:\n kamal-proxy deploy SERVICE [flags]\n\nFlags:\n \
--target host:port\n --health-check-path string\n --host strings\n \
--tls\n --deploy-timeout duration\n --drain-timeout duration\n \
--force\n"
}
#[test]
fn probe_proxy_compat_passes_silently_on_a_compatible_host() {
let exec =
RecordingExecutor::new().with_stdout("proxy-compat-probe", compatible_deploy_help());
assert!(probe_proxy_compat(&proxy(), &exec).is_ok());
assert_eq!(exec.run_labels(), vec!["proxy-compat-probe"]);
let shell = exec.shell_for("proxy-compat-probe").expect("probe ran");
assert_eq!(shell, "kamal-proxy deploy --help 2>&1 || true");
}
#[test]
fn probe_proxy_compat_fails_closed_on_a_drifted_cli_surface() {
let drifted = compatible_deploy_help().replace("--drain-timeout", "--drain-window");
let exec = RecordingExecutor::new().with_stdout("proxy-compat-probe", drifted);
let err = probe_proxy_compat(&proxy(), &exec)
.expect_err("a drifted CLI surface must fail closed");
match err {
DeployExecError::ProxyIncompatible { message } => {
assert!(
message.contains("--drain-timeout"),
"names the flag: {message}"
);
assert!(message.contains("v0.9.2"), "names the pin: {message}");
assert!(
message.contains("before any cutover"),
"states nothing was cut over: {message}",
);
}
other => panic!("expected ProxyIncompatible, got {other:?}"),
}
}
#[test]
fn probe_proxy_compat_fails_closed_on_a_missing_binary() {
let exec = RecordingExecutor::new(); let err =
probe_proxy_compat(&proxy(), &exec).expect_err("an unusable binary must fail closed");
assert!(matches!(err, DeployExecError::ProxyIncompatible { .. }));
}
#[test]
fn probe_proxy_compat_is_a_noop_when_the_controller_declares_no_probe() {
struct NoProbeController;
impl ProxyController for NoProbeController {
fn ensure_installed_ops(&self, _public_port: u16) -> Vec<DeployOp> {
Vec::new()
}
fn route_op(&self, _service: &str, _upstream: &str) -> DeployOp {
DeployOp::Run(RemoteCommand::new("noop", "true"))
}
fn flip_op(&self, _service: &str, _new_upstream: &str) -> DeployOp {
DeployOp::Run(RemoteCommand::new("noop", "true"))
}
}
let exec = RecordingExecutor::new();
assert!(probe_proxy_compat(&NoProbeController, &exec).is_ok());
assert!(
exec.run_labels().is_empty(),
"no probe declared → no remote command runs"
);
}
#[test]
fn reconcile_uses_proxy_slot_and_warns_on_disagreement() {
let list = proxy_list_serving("myapp", 3001);
let decision = reconcile_live_slot(SLOT_GREEN, &list, "myapp", 3000);
assert_eq!(decision.live_slot, SLOT_BLUE);
assert!(decision.repair, "a genuine disagreement repairs the marker");
let warn = decision.warn.expect("disagreement warns");
assert!(
warn.contains("drift") && warn.contains(SLOT_GREEN) && warn.contains(SLOT_BLUE),
"warn names the disagreement: {warn}"
);
let plan = SlotPlan::redeploy(3000, decision.live_slot);
assert_eq!(plan.live_slot, SLOT_BLUE);
assert_eq!(plan.candidate_slot, SLOT_GREEN);
assert_eq!(plan.candidate_port, 3002);
let cfg = resolved();
let op = live_slot_marker_repair_op(&cfg, decision.live_slot, 3000);
match op {
DeployOp::Run(cmd) => {
assert_eq!(cmd.label, "record-live-slot");
assert!(
cmd.shell.contains(SLOT_BLUE) && cmd.shell.contains("3001"),
"repair op writes the proxy slot+port: {}",
cmd.shell
);
}
other => panic!("repair should be a Run op, got {other:?}"),
}
}
#[test]
fn reconcile_agreement_keeps_marker_without_warn_or_repair() {
let list = proxy_list_serving("myapp", 3002);
let decision = reconcile_live_slot(SLOT_GREEN, &list, "myapp", 3000);
assert_eq!(decision.live_slot, SLOT_GREEN);
assert!(!decision.repair, "agreement never repairs");
assert!(decision.warn.is_none(), "agreement never warns");
}
#[test]
fn reconcile_falls_back_to_marker_on_absent_or_unclear_proxy_signal() {
let cases: &[(&str, &str)] = &[
("empty output", ""),
(
"service absent from the list",
"Service Target\nother 127.0.0.1:3001\n",
),
(
"target has no loopback port (unparseable)",
"Service Target\nmyapp example.com:3001\n",
),
(
"loopback port maps to neither slot (public+3)",
"Service Target\nmyapp 127.0.0.1:3003\n",
),
(
"trailing junk after the port is not partially parsed",
"Service Target\nmyapp 127.0.0.1:3001x\n",
),
];
for (name, list) in cases {
let decision = reconcile_live_slot(SLOT_BLUE, list, "myapp", 3000);
assert_eq!(
decision.live_slot, SLOT_BLUE,
"fall back keeps marker: {name}"
);
assert!(!decision.repair, "no repair on unclear signal: {name}");
assert!(decision.warn.is_none(), "no warn on unclear signal: {name}");
}
}
#[test]
fn reconcile_falls_back_when_the_service_is_ambiguous() {
let list = "Service Target\n\
myapp 127.0.0.1:3001\n\
myapp 127.0.0.1:3002\n";
let decision = reconcile_live_slot(SLOT_GREEN, list, "myapp", 3000);
assert_eq!(decision.live_slot, SLOT_GREEN, "ambiguous → keep marker");
assert!(!decision.repair);
assert!(decision.warn.is_none());
let two_ports = "Service Target Prev\n\
myapp 127.0.0.1:3001 127.0.0.1:3002\n";
let decision = reconcile_live_slot(SLOT_GREEN, two_ports, "myapp", 3000);
assert_eq!(decision.live_slot, SLOT_GREEN);
assert!(!decision.repair);
assert!(decision.warn.is_none());
}
#[test]
fn proxy_live_target_port_extracts_the_observed_upstream_port() {
let list = proxy_list_serving("myapp", 3001);
assert_eq!(proxy_live_target_port(&list, "myapp"), Some(3001));
assert_eq!(proxy_live_slot(&list, "myapp", 5000), None);
assert_eq!(proxy_live_slot(&list, "myapp", 3000), Some(SLOT_BLUE));
assert_eq!(proxy_live_target_port(&list, "other"), None);
assert_eq!(proxy_live_target_port(&list, ""), None);
let twice = format!(
"{}{}",
proxy_list_serving("myapp", 3001),
"myapp x 127.0.0.1:3002\n"
);
assert_eq!(proxy_live_target_port(&twice, "myapp"), None);
let two_ports = "Service Target Prev\n\
myapp 127.0.0.1:3001 127.0.0.1:3002\n";
assert_eq!(proxy_live_target_port(two_ports, "myapp"), None);
assert_eq!(proxy_live_target_port("", "myapp"), None);
}
#[test]
fn reconcile_scopes_strictly_to_the_target_service_name() {
let list = "Service Target\n\
myapp-staging 127.0.0.1:3001\n";
let decision = reconcile_live_slot(SLOT_GREEN, list, "myapp", 3000);
assert_eq!(
decision.live_slot, SLOT_GREEN,
"substring service not matched"
);
assert!(!decision.repair);
assert!(decision.warn.is_none());
}
#[test]
fn slot_plan_puts_candidate_on_the_idle_slot() {
let plan = SlotPlan::redeploy(3000, SLOT_BLUE);
assert_eq!(plan.candidate_slot, SLOT_GREEN);
assert_eq!(plan.candidate_port, 3002);
assert_eq!(plan.live_slot, SLOT_BLUE);
assert_eq!(plan.live_port, 3001);
assert_ne!(plan.candidate_port, plan.public_port);
let flipped = SlotPlan::redeploy(3000, SLOT_GREEN);
assert_eq!(flipped.candidate_slot, SLOT_BLUE);
assert_eq!(flipped.candidate_port, 3001);
let first = SlotPlan::first(3000);
assert_eq!(first.candidate_slot, SLOT_BLUE);
assert_eq!(first.candidate_port, 3001);
}
#[test]
fn env_file_op_is_0600_and_never_echoes_the_secret() {
let secret_body = "AUTUMN_SECURITY__SIGNING_SECRET=super-secret-signing-value\n\
AUTUMN_DATABASE__URL=postgres://u:p@db/app\n";
let ops = sample_ops(Secret::new(secret_body));
let env_op = ops
.iter()
.find(|op| op.label() == "write-env")
.expect("write-env op present");
match env_op {
DeployOp::WriteFile { mode, contents, .. } => {
assert_eq!(*mode, Some(0o600), "env file must be mode 0600 (AC-5)");
assert!(
matches!(contents, FileContents::Secret(_)),
"env file contents must be a Secret"
);
}
other => panic!("write-env should be a WriteFile op, got {other:?}"),
}
let debug = format!("{ops:#?}");
assert!(
!debug.contains("super-secret-signing-value"),
"secret leaked into ops Debug output"
);
assert!(
!debug.contains("postgres://u:p@db/app"),
"db url leaked into ops Debug output"
);
let exec = RecordingExecutor::new();
run_ops(&ops, &exec).expect("run ok");
let calls_debug = format!("{:#?}", exec.calls());
assert!(
!calls_debug.contains("super-secret-signing-value"),
"secret leaked into recorded executor calls"
);
}
#[test]
fn first_deploy_uploads_config_manifest_to_release_at_0600() {
let ops = sample_ops(Secret::new("AUTUMN_SECURITY__SIGNING_SECRET=x\n"));
let base_path = format!("{RELEASE_DIR}/autumn.toml");
let base = ops
.iter()
.find(|op| {
matches!(op, DeployOp::UploadFile { label: "upload-config", remote_path, .. }
if *remote_path == base_path)
})
.expect("base autumn.toml is uploaded to the release dir");
match base {
DeployOp::UploadFile { mode, .. } => {
assert_eq!(
*mode,
Some(0o600),
"config manifest must be owner-only 0600"
);
}
other => panic!("upload-config should be an UploadFile op, got {other:?}"),
}
let sibling_path = format!("{RELEASE_DIR}/autumn-prod.toml");
assert!(
ops.iter().any(|op| matches!(
op,
DeployOp::UploadFile { label: "upload-config", remote_path, mode: Some(0o600), .. }
if *remote_path == sibling_path
)),
"profile sibling autumn-prod.toml is uploaded to the release dir"
);
let cfg_idx = ops
.iter()
.position(|op| op.label() == "upload-config")
.expect("upload-config present");
let reload_idx = ops
.iter()
.position(|op| op.label() == "daemon-reload")
.expect("daemon-reload present");
assert!(
cfg_idx < reload_idx,
"config is uploaded before daemon-reload"
);
}
#[test]
fn cutover_uploads_config_manifest_to_release_at_0600() {
let ops = sample_cutover_ops(Secret::new("AUTUMN_SECURITY__SIGNING_SECRET=x\n"));
let base_path = format!("{RELEASE_DIR}/autumn.toml");
assert!(
ops.iter().any(|op| matches!(
op,
DeployOp::UploadFile { label: "upload-config", remote_path, mode: Some(0o600), .. }
if *remote_path == base_path
)),
"redeploy re-uploads autumn.toml to the release dir at 0600"
);
}
#[test]
fn no_manifest_means_no_config_upload_op() {
let cfg = resolved();
let plan = SlotPlan::first(3000);
let unit = super::super::render_app_unit(
&cfg,
RELEASE_DIR,
plan.candidate_port,
plan.candidate_slot,
);
let ops = first_deploy_ops(
&cfg,
&proxy(),
&unit,
Secret::new("AUTUMN_SECURITY__SIGNING_SECRET=x\n"),
Path::new("/local/target/release/myapp"),
&[],
RELEASE_ID,
&plan,
);
assert!(
!ops.iter().any(|op| op.label() == "upload-config"),
"no manifest → no upload-config op"
);
}
#[test]
fn secret_debug_and_display_are_redacted() {
let secret = Secret::new("hunter2");
assert_eq!(format!("{secret:?}"), "Secret(<redacted>)");
assert_eq!(format!("{secret}"), "<redacted>");
assert_eq!(secret.expose(), "hunter2");
}
#[test]
fn ssh_and_scp_argv_are_built_correctly() {
let target = SshTarget {
host: "203.0.113.10".to_owned(),
user: "deploy".to_owned(),
port: 2222,
};
let ssh = ssh_argv(&target, "systemctl daemon-reload");
assert_eq!(
ssh,
vec![
"-p".to_owned(),
"2222".to_owned(),
"-o".to_owned(),
"BatchMode=yes".to_owned(),
"-o".to_owned(),
"StrictHostKeyChecking=accept-new".to_owned(),
"deploy@203.0.113.10".to_owned(),
"systemctl daemon-reload".to_owned(),
]
);
let scp = scp_argv(
&target,
Path::new("/local/target/release/myapp"),
"/srv/autumn/myapp/releases/r1/myapp",
);
assert_eq!(
scp,
vec![
"-P".to_owned(),
"2222".to_owned(),
"-o".to_owned(),
"BatchMode=yes".to_owned(),
"-o".to_owned(),
"StrictHostKeyChecking=accept-new".to_owned(),
"/local/target/release/myapp".to_owned(),
"deploy@203.0.113.10:/srv/autumn/myapp/releases/r1/myapp".to_owned(),
]
);
}
#[test]
fn preflight_failure_aborts_before_any_executor_call() {
let ops = sample_ops(Secret::new("AUTUMN_SECURITY__SIGNING_SECRET=x\n"));
let exec = RecordingExecutor::new();
let checks = vec![
PreflightCheck::pass("signing_secret", "ok"),
PreflightCheck::fail("ssh_reachability", "no target host configured", "set host"),
];
let err = execute_first_deploy(&checks, &ops, &[], &exec)
.expect_err("failing preflight must abort the deploy");
assert!(
matches!(err, DeployExecError::PreflightAborted { failed: 1 }),
"expected PreflightAborted, got {err:?}"
);
assert!(
exec.calls().is_empty(),
"no remote call may run when preflight fails: {:?}",
exec.calls()
);
}
#[test]
fn passing_preflight_runs_the_full_sequence() {
let ops = sample_ops(Secret::new("AUTUMN_SECURITY__SIGNING_SECRET=x\n"));
let exec = RecordingExecutor::new();
let checks = vec![PreflightCheck::pass("ssh_reachability", "reachable")];
execute_first_deploy(&checks, &ops, &[], &exec).expect("all checks pass → deploy runs");
assert_eq!(exec.calls().len(), 15, "the full op sequence should run");
}
#[test]
fn readiness_timeout_surfaces_a_command_failure() {
let ops = sample_ops(Secret::new("AUTUMN_SECURITY__SIGNING_SECRET=x\n"));
let exec = RecordingExecutor::failing_on("readiness-gate");
let err = run_ops(&ops, &exec).expect_err("readiness timeout must fail the deploy");
assert!(
matches!(
err,
DeployExecError::CommandFailed {
label: "readiness-gate",
..
}
),
"expected a readiness-gate CommandFailed, got {err:?}"
);
assert!(
!exec.run_labels().contains(&"proxy-route"),
"proxy must not be routed after a failed readiness gate"
);
}
}