use std::fmt;
use std::fmt::Write as _;
use std::io::Write as _;
use std::path::{Path, PathBuf};
use std::process::Command;
use super::proxy::{ProxyCompatFailure, 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>,
},
#[error("{source}")]
PostCutover {
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; 10] = [
"-o",
"BatchMode=yes",
"-o",
"StrictHostKeyChecking=accept-new",
"-o",
"ConnectTimeout=10",
"-o",
"ServerAliveInterval=15",
"-o",
"ServerAliveCountMax=4",
];
#[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)
}
fn proxy_options_marker(cfg: &ResolvedDeployConfig) -> String {
format!("{}/shared/proxy-options", cfg.app_dir)
}
fn last_deploy_marker(cfg: &ResolvedDeployConfig) -> String {
format!("{}/shared/last-deploy", cfg.app_dir)
}
pub const LAST_DEPLOY_DEPLOYED: &str = "deployed";
pub const LAST_DEPLOY_ROLLED_BACK: &str = "rolled back";
pub const LAST_DEPLOY_TORN_DOWN: &str = "torn down";
fn record_last_deploy_fragment(cfg: &ResolvedDeployConfig, result: &str) -> String {
let shared = cfg.shared_dir();
let tmpl = format!("{shared}/last-deploy.tmp.XXXXXX");
format!(
"{{ rtmp=$(mktemp {tmpl}) && printf '%s\\t%s' {result} \
\"$(date -u +%Y-%m-%dT%H:%M:%SZ)\" > \"$rtmp\" && mv -f \"$rtmp\" {marker} || true; }}",
tmpl = shell_quote(&tmpl),
result = shell_quote(result),
marker = shell_quote(&last_deploy_marker(cfg)),
)
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct LastDeploy {
pub result: String,
pub at: Option<String>,
}
fn parse_last_deploy(raw: &str) -> Option<LastDeploy> {
let raw = raw.trim();
if raw.is_empty() {
return None;
}
let (result, at) = raw.split_once('\t').unwrap_or((raw, ""));
let result = result.trim();
if result.is_empty() {
return None;
}
Some(LastDeploy {
result: result.to_owned(),
at: Some(at.trim())
.filter(|at| !at.is_empty())
.map(ToOwned::to_owned),
})
}
#[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),
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum MigrateStep {
Run,
Skip,
}
#[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,
migrate: MigrateStep,
) -> 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.extend(sqlite_data_dir_guard_op(cfg).map(DeployOp::Run));
ops.extend(sqlite_data_link_op(cfg, &release_dir).map(DeployOp::Run));
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",
)),
]);
if matches!(migrate, MigrateStep::Run) {
ops.push(DeployOp::Run(release_migrate_command(cfg, &release_dir)));
}
ops.extend(sqlite_data_adopted_op(cfg).map(DeployOp::Run));
ops.extend([
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(record_proxy_options(cfg, &proxy.proxy_service_options())),
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,
reregister_options: &ProxyServiceOptions,
migrate: MigrateStep,
) -> 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),
reregister_options,
);
ops.push(DeployOp::Run(RemoteCommand::new(
"prepare-dirs",
format!(
"mkdir -p {} {}",
shell_quote(&release_dir),
shell_quote(&shared_dir)
),
)));
ops.extend(sqlite_data_dir_guard_op(cfg).map(DeployOp::Run));
ops.extend(sqlite_data_link_op(cfg, &release_dir).map(DeployOp::Run));
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"
),
)),
]);
if matches!(migrate, MigrateStep::Run) {
ops.push(DeployOp::Run(release_migrate_command(cfg, &release_dir)));
}
ops.extend(sqlite_data_adopted_op(cfg).map(DeployOp::Run));
ops.extend([
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,
},
LAST_DEPLOY_DEPLOYED,
)),
DeployOp::Run(record_proxy_options(cfg, &proxy.proxy_service_options())),
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.push(DeployOp::Run(RemoteCommand::new(
"teardown-last-deploy",
record_last_deploy_fragment(cfg, LAST_DEPLOY_TORN_DOWN),
)));
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));
let mut ops = Vec::new();
ops.extend(sqlite_data_dir_guard_op(cfg).map(DeployOp::Run));
ops.extend(sqlite_data_link_op(cfg, &target.release_dir).map(DeployOp::Run));
ops.extend([
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,
},
LAST_DEPLOY_ROLLED_BACK,
)),
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"),
)),
]);
ops
}
#[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))
),
)
}
fn record_proxy_options(
cfg: &ResolvedDeployConfig,
options: &ProxyServiceOptions,
) -> RemoteCommand {
let shared = cfg.shared_dir();
let tmpl = format!("{shared}/proxy-options.tmp.XXXXXX");
RemoteCommand::new(
"record-proxy-options",
format!(
"otmp=$(mktemp {tmpl}) && printf '%s' {value} > \"$otmp\" && mv -f \"$otmp\" {marker} \
&& {last_deploy}",
tmpl = shell_quote(&tmpl),
value = shell_quote(&options.marker_value()),
marker = shell_quote(&proxy_options_marker(cfg)),
last_deploy = record_last_deploy_fragment(cfg, LAST_DEPLOY_DEPLOYED),
),
)
}
#[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<'_>,
last_deploy_result: &str,
) -> 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} && \
{last_deploy}",
last_deploy = record_last_deploy_fragment(cfg, last_deploy_result),
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,
),
)
}
pub const PRE_MIGRATE_LABELS: &[&str] = &[
"install-proxy",
"proxy-snapshot-unit",
"proxy-write-unit",
"proxy-install",
"proxy-restart-if-changed",
"prepare-dirs",
"check-data-dir",
"link-data",
"upload-binary",
"upload-config",
"write-env",
"write-unit",
"write-candidate-unit",
"link-current",
"daemon-reload",
"start-candidate",
"upload",
"stage-local-file",
LIVE_SLOT_REPAIR_LABEL,
];
#[must_use]
pub fn failed_before_migrating(failed_step: &str) -> bool {
PRE_MIGRATE_LABELS.contains(&failed_step)
}
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} \
--working-directory={workdir} --property=EnvironmentFile={env} \
--setenv=AUTUMN_MIGRATE=1 --setenv=AUTUMN_MANIFEST_DIR={manifest} {bin}",
service = cfg.service_name,
workdir = shell_quote(release_dir),
env = shell_quote(&cfg.env_file()),
manifest = shell_quote(release_dir),
bin = shell_quote(&bin),
),
)
}
#[must_use]
pub fn sqlite_data_link_op(cfg: &ResolvedDeployConfig, release_dir: &str) -> Option<RemoteCommand> {
let relative = cfg.relative_sqlite_data_file()?;
let shared = cfg.shared_sqlite_data_file()?;
let marker = cfg.sqlite_data_marker_file();
let superseded = format!("{shared}.superseded");
let shared_parent = parent_dir(&shared);
let in_release = format!("{release_dir}/{relative}");
let release_parent = parent_dir(&in_release);
let current = format!("{}/{relative}", cfg.current_symlink());
let service = &cfg.service_name;
let refusal = format!(
"autumn deploy: {current} is a live SQLite database. The data file must live at \
{shared} to survive a deploy, and moving it while the app runs is not safe, so \
this deploy stopped."
);
let recovery = adoption_recovery(service, ¤t, &shared, MoveSource::TheLinkPathItself);
let linked_refusal = format!(
"autumn deploy: {current} is a symlink to a SQLite database that is not \
{shared}. The data file must live there to survive a deploy, so this deploy \
stopped rather than link past it and serve a different database."
);
let linked_recovery = adoption_recovery(service, ¤t, &shared, MoveSource::WhatItPointsAt);
let missing_refusal = format!(
"autumn deploy: the SQLite database {shared} is missing, but {marker} records that \
it existed. The volume holding {shared_parent} is most likely not mounted, and \
continuing would create a fresh empty database and orphan the real one, so this \
deploy stopped."
);
let missing_recovery = format!(
"Mount the volume holding {shared} and deploy again. If the database was \
deliberately removed and a new one should be created, remove {marker} first."
);
let occupied =
format!("autumn deploy: refusing to move {in_release} aside: {superseded} already exists");
Some(RemoteCommand::new(
"link-data",
format!(
"if {{ [ -e {current_q} ] || [ -L {current_q} ]; }} && \
! {{ [ -L {current_q} ] && [ \"$(readlink {current_q})\" = {shared_q} ]; }}; then \
if [ -L {current_q} ]; then \
echo {linked_refusal_q} >&2; echo {linked_recovery_q} >&2; \
else echo {refusal_q} >&2; echo {recovery_q} >&2; fi; exit 1; \
fi && \
if [ -e {shared_q} ]; then : > {marker_q} || exit 1; \
elif [ -e {marker_q} ]; then \
echo {missing_refusal_q} >&2; echo {missing_recovery_q} >&2; exit 1; fi && \
mkdir -p {shared_parent_q} {release_parent_q} && \
if [ -e {in_release_q} ] && [ ! -L {in_release_q} ]; then \
if [ -e {superseded_q} ]; then echo {occupied_q} >&2; exit 1; fi; \
for s in -wal -shm -journal; do \
if [ -e {in_release_q}$s ]; then \
mv -f {in_release_q}$s {superseded_q}$s || exit 1; fi; \
done; \
mv -f {in_release_q} {superseded_q} || exit 1; \
fi && \
rm -f {in_release_q} && ln -s {shared_q} {in_release_q}",
shared_parent_q = shell_quote(&shared_parent),
release_parent_q = shell_quote(&release_parent),
shared_q = shell_quote(&shared),
marker_q = shell_quote(&marker),
superseded_q = shell_quote(&superseded),
current_q = shell_quote(¤t),
in_release_q = shell_quote(&in_release),
refusal_q = shell_quote(&refusal),
recovery_q = shell_quote(&recovery),
linked_refusal_q = shell_quote(&linked_refusal),
linked_recovery_q = shell_quote(&linked_recovery),
missing_refusal_q = shell_quote(&missing_refusal),
missing_recovery_q = shell_quote(&missing_recovery),
occupied_q = shell_quote(&occupied),
),
))
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum MoveSource {
TheLinkPathItself,
WhatItPointsAt,
}
fn adoption_recovery(service: &str, current: &str, shared: &str, source: MoveSource) -> String {
let blue_q = shell_quote(&format!("{}.service", slot_unit_name(service, SLOT_BLUE)));
let green_q = shell_quote(&format!("{}.service", slot_unit_name(service, SLOT_GREEN)));
let current_q = shell_quote(current);
let shared_q = shell_quote(shared);
let (resolve, src) = match source {
MoveSource::TheLinkPathItself => (String::new(), current_q.clone()),
MoveSource::WhatItPointsAt => (
format!("src=$(readlink -f {current_q}) && "),
"\"$src\"".to_owned(),
),
};
let mut sidecars = String::new();
for suffix in ["-wal", "-shm", "-journal"] {
write!(
sidecars,
"{{ [ ! -e {src}{suffix} ] || mv {src}{suffix} {shared_q}{suffix}; }} && "
)
.expect("writing to a String cannot fail");
}
let cleanup = match source {
MoveSource::TheLinkPathItself => String::new(),
MoveSource::WhatItPointsAt => format!(" && rm -f {current_q}"),
};
format!(
"Run this on the host once, then deploy again: systemctl stop {blue_q} {green_q} && \
{resolve}{sidecars}mv {src} {shared_q}{cleanup}"
)
}
#[must_use]
pub fn sqlite_data_adopted_op(cfg: &ResolvedDeployConfig) -> Option<RemoteCommand> {
let shared = cfg.managed_sqlite_data_file()?;
Some(RemoteCommand::new(
"record-data-adopted",
format!(
"if [ -e {shared_q} ]; then : > {marker_q} || exit 1; fi",
shared_q = shell_quote(&shared),
marker_q = shell_quote(&cfg.sqlite_data_marker_file()),
),
))
}
#[must_use]
pub fn sqlite_data_dir_guard_op(cfg: &ResolvedDeployConfig) -> Option<RemoteCommand> {
let path = cfg.persistent_sqlite_data_file()?;
let app_dir = &cfg.app_dir;
let marker = cfg.sqlite_data_marker_file();
let missing_refusal = format!(
"autumn deploy: the SQLite database {path} is missing, but {marker} records that \
it existed. The volume holding it is most likely not mounted, and continuing \
would create a fresh empty database and orphan the real one, so this deploy \
stopped."
);
let missing_recovery = format!(
"Mount the volume holding {path} and deploy again. If the database was \
deliberately removed and a new one should be created, remove {marker} first."
);
let refusal = format!(
"autumn deploy: the SQLite database {path} resolves to a path inside the deploy's \
own app directory ({app_dir}), where only {} is yours: `releases/` is replaced on \
every deploy and deleted by release retention, and the rest of `shared/` holds \
deploy state files that would overwrite it. Only the host can see this, because \
`{app_dir}` resolves elsewhere there. Move the database there, or outside \
{app_dir} altogether.",
cfg.shared_data_dir()
);
Some(RemoteCommand::new(
"check-data-dir",
format!(
"app=$(readlink -f {app_dir_q} 2>/dev/null || printf '%s' {app_dir_q}); \
db=$(readlink -f {db_q} 2>/dev/null || printf '%s' {db_q}); \
dir=$(dirname \"$db\"); \
case \"$dir\" in \
\"$app/shared/data\"|\"$app/shared/data/\"*) \
if [ -e \"$db\" ]; then : > {marker_q} || exit 1; \
elif [ -e {marker_q} ]; then \
echo {missing_refusal_q} >&2; echo {missing_recovery_q} >&2; exit 1; fi; \
mkdir -p \"$dir\" || exit 1 ;; \
\"$app\"|\"$app/\"*) echo {refusal_q} >&2; exit 1 ;; \
esac",
app_dir_q = shell_quote(app_dir),
db_q = shell_quote(path),
marker_q = shell_quote(&marker),
refusal_q = shell_quote(&refusal),
missing_refusal_q = shell_quote(&missing_refusal),
missing_recovery_q = shell_quote(&missing_recovery),
),
))
}
fn parent_dir(path: &str) -> String {
path.rsplit_once('/')
.map_or_else(|| ".".to_owned(), |(head, _)| head.to_owned())
}
#[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 struct ProxyServiceOptions {
pub tls: bool,
pub host: Option<String>,
}
impl ProxyServiceOptions {
#[must_use]
fn marker_value(&self) -> String {
format!(
"{}\t{}",
if self.tls { "1" } else { "0" },
self.host.as_deref().unwrap_or("")
)
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum ProxyOptionsMarker {
Absent,
Unreadable,
Options(ProxyServiceOptions),
}
fn parse_proxy_options(section: &str) -> ProxyOptionsMarker {
let s = section.trim_matches(|c| c == '\n' || c == '\r');
if s.is_empty() {
return ProxyOptionsMarker::Absent;
}
let mut parts = s.splitn(2, '\t');
let tls_field = parts.next().unwrap_or_default();
let Some(host_field) = parts.next() else {
return ProxyOptionsMarker::Unreadable;
};
match tls_field {
"1" if !host_field.is_empty() => ProxyOptionsMarker::Options(ProxyServiceOptions {
tls: true,
host: Some(host_field.to_owned()),
}),
"0" => ProxyOptionsMarker::Options(ProxyServiceOptions {
tls: false,
host: None,
}),
_ => ProxyOptionsMarker::Unreadable,
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum InstalledProxyPort {
Absent,
Unreadable,
Port(u16),
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct PublicPortMove {
pub old_port: u16,
pub new_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---";
const PROXY_OPTIONS_DELIM: &str = "---autumn-kamal-proxy-options---";
const CURRENT_RELEASE_DELIM: &str = "---autumn-current-release---";
#[must_use]
pub fn release_id_from_dir(dir: &str) -> Option<&str> {
let trimmed = dir.trim().trim_end_matches('/');
let id = trimmed.rsplit('/').next().unwrap_or_default();
(!id.is_empty()).then_some(id)
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct DeployProbe {
pub mode: DeployMode,
pub proxy_list: String,
pub installed_proxy_port: InstalledProxyPort,
pub last_proxy_options: ProxyOptionsMarker,
pub current_release_dir: Option<String>,
}
impl DeployProbe {
#[must_use]
pub fn reconcile(&self, cfg: &ResolvedDeployConfig, public_port: u16) -> Option<SlotReconcile> {
match &self.mode {
DeployMode::First => None,
DeployMode::Redeploy { live_slot } => Some(reconcile_live_slot(
live_slot,
&self.proxy_list,
&cfg.service_name,
public_port,
)),
}
}
}
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; \
printf '\\n{opts_delim}\\n'; \
cat {opts_marker} 2>/dev/null || true; \
printf '\\n{current_delim}\\n'; \
d=$(readlink -f {current} 2>/dev/null) && r=$(readlink -f {releases} 2>/dev/null) \
&& [ -d \"$d\" ] && [ \"${{d%/*}}\" = \"$r\" ] && printf '%s' \"$d\" || true",
current = shell_quote(&cfg.current_symlink()),
releases = shell_quote(&cfg.releases_dir()),
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,
opts_delim = PROXY_OPTIONS_DELIM,
opts_marker = shell_quote(&proxy_options_marker(cfg)),
current_delim = CURRENT_RELEASE_DELIM,
);
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, last_proxy_options, current_release_dir) =
match rest.split_once(PROXY_UNIT_DELIM) {
Some((list, after_unit)) => {
let (unit_section, after_opts) = after_unit
.split_once(PROXY_OPTIONS_DELIM)
.unwrap_or((after_unit, ""));
let (opts_section, current_section) = after_opts
.split_once(CURRENT_RELEASE_DELIM)
.unwrap_or((after_opts, ""));
(
list,
parse_installed_proxy_port(unit_section),
parse_proxy_options(opts_section),
parse_current_release(current_section),
)
}
None => (
rest,
InstalledProxyPort::Absent,
ProxyOptionsMarker::Absent,
None,
),
};
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,
last_proxy_options,
current_release_dir,
})
}
fn parse_current_release(section: &str) -> Option<String> {
let trimmed = section.trim();
(!trimmed.is_empty()).then(|| trimmed.to_owned())
}
const HOST_STATUS_DELIM: &str = "---autumn-host-status---";
const MAINTENANCE_ON_SENTINEL: &str = "maintenance-on";
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum MaintenanceStatus {
On,
Off,
Unknown,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum MaintenanceFlagSource {
Shared,
Unshared,
Unknown,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct LiveMaintenanceFlag {
pub path: String,
pub present: bool,
}
fn live_maintenance_flag_shell(cfg: &ResolvedDeployConfig, live_slot: &str) -> String {
format!(
"if [ -f {unit} ]; then \
autumn_mf=$(sed -n 's|^Environment={flag_env}=||p' {unit} 2>/dev/null | tail -n 1); \
autumn_wd=$(sed -n 's|^WorkingDirectory=||p' {unit} 2>/dev/null | tail -n 1); \
if [ -z \"$autumn_mf\" ] && [ -n \"$autumn_wd\" ]; then \
autumn_mf=\"$autumn_wd/{legacy_rel}\"; fi; \
if [ -n \"$autumn_mf\" ]; then printf '%s\\n' \"$autumn_mf\"; \
if [ -f \"$autumn_mf\" ]; then printf '%s' '{on}'; fi; fi; fi",
unit = shell_quote(&format!(
"/etc/systemd/system/{}.service",
slot_unit_name(&cfg.service_name, live_slot)
)),
flag_env = autumn_web::maintenance::MAINTENANCE_FLAG_FILE_ENV,
legacy_rel = autumn_web::maintenance::MAINTENANCE_FLAG_FILE,
on = MAINTENANCE_ON_SENTINEL,
)
}
fn parse_live_maintenance_flag(section: &str) -> Option<LiveMaintenanceFlag> {
let mut lines = section.trim_start_matches('\n').lines();
let path = lines.next().unwrap_or_default().trim();
if path.is_empty() {
return None;
}
Some(LiveMaintenanceFlag {
path: path.to_owned(),
present: lines
.next()
.is_some_and(|line| line.trim() == MAINTENANCE_ON_SENTINEL),
})
}
pub fn probe_live_maintenance_flag(
cfg: &ResolvedDeployConfig,
live_slot: &str,
exec: &impl DeployExecutor,
) -> Result<Option<LiveMaintenanceFlag>, DeployExecError> {
let out = exec.run(&RemoteCommand::new(
"detect-maintenance-flag",
live_maintenance_flag_shell(cfg, live_slot),
))?;
Ok(parse_live_maintenance_flag(&out.stdout))
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct HostStatusProbe {
pub deploy: DeployProbe,
pub ready_code: Option<u16>,
pub shared_maintenance_flag: bool,
pub maintenance: MaintenanceStatus,
pub maintenance_flag_source: MaintenanceFlagSource,
pub last_deploy: Option<LastDeploy>,
}
impl HostStatusProbe {
#[must_use]
pub fn reconcile(&self, cfg: &ResolvedDeployConfig, public_port: u16) -> Option<SlotReconcile> {
self.deploy.reconcile(cfg, public_port)
}
}
pub fn probe_host_status(
cfg: &ResolvedDeployConfig,
public_port: u16,
exec: &impl DeployExecutor,
) -> Result<HostStatusProbe, DeployExecError> {
let deploy = probe_deploy_state(cfg, exec)?;
let live_slot = deploy
.reconcile(cfg, public_port)
.map_or(SLOT_BLUE, |reconcile| reconcile.live_slot);
let shared_flag = cfg.maintenance_flag_file();
let shell = format!(
"curl -o /dev/null -s -m 5 -w '%{{http_code}}' http://127.0.0.1:{port}/ready 2>/dev/null \
|| true; \
printf '\\n{delim}\\n'; \
if [ -f {flag} ]; then printf '%s' '{on}'; fi; \
printf '\\n{delim}\\n'; \
cat {last_deploy} 2>/dev/null || true; \
printf '\\n{delim}\\n'; \
{unit_flag}",
port = slot_app_port(public_port, live_slot),
delim = HOST_STATUS_DELIM,
flag = shell_quote(&shared_flag),
on = MAINTENANCE_ON_SENTINEL,
last_deploy = shell_quote(&last_deploy_marker(cfg)),
unit_flag = live_maintenance_flag_shell(cfg, live_slot),
);
let out = exec.run(&RemoteCommand::new("probe-host-status", shell))?;
let mut sections = out.stdout.split(HOST_STATUS_DELIM);
let ready_section = sections.next().unwrap_or_default();
let shared_flag_section = sections.next().unwrap_or_default();
let last_deploy_section = sections.next().unwrap_or_default();
let unit_flag_section = sections.next().unwrap_or_default();
let (maintenance, maintenance_flag_source) =
match parse_live_maintenance_flag(unit_flag_section) {
None => (MaintenanceStatus::Unknown, MaintenanceFlagSource::Unknown),
Some(flag) => (
if flag.present {
MaintenanceStatus::On
} else {
MaintenanceStatus::Off
},
if flag.path == shared_flag {
MaintenanceFlagSource::Shared
} else {
MaintenanceFlagSource::Unshared
},
),
};
Ok(HostStatusProbe {
deploy,
ready_code: ready_section
.trim()
.parse::<u16>()
.ok()
.filter(|code| *code > 0),
shared_maintenance_flag: shared_flag_section.trim() == MAINTENANCE_ON_SENTINEL,
maintenance,
maintenance_flag_source,
last_deploy: parse_last_deploy(last_deploy_section),
})
}
const RELEASE_DIR_PRESENT: &str = "present";
const RELEASE_DIR_ABSENT: &str = "absent";
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum ReleaseDirState {
Absent,
Present,
Unreadable,
}
pub fn probe_release_dir(
cfg: &ResolvedDeployConfig,
release_id: &str,
exec: &impl DeployExecutor,
) -> Result<ReleaseDirState, DeployExecError> {
let release_dir = format!("{}/{release_id}", cfg.releases_dir());
probe_dir_state("probe-release-dir", &release_dir, exec)
}
pub fn probe_rollback_target_dir(
release_dir: &str,
exec: &impl DeployExecutor,
) -> Result<ReleaseDirState, DeployExecError> {
probe_dir_state("probe-rollback-target", release_dir, exec)
}
const OLD_SLOT_STOPPED: &str = "stopped";
const OLD_SLOT_NOT_STOPPED: &str = "not-stopped";
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum OldSlotState {
Stopped,
NotStopped,
Unreadable,
}
pub fn retry_drain_old(
cfg: &ResolvedDeployConfig,
live_slot: &str,
exec: &impl DeployExecutor,
) -> Result<OldSlotState, DeployExecError> {
let unit = shell_quote(&format!(
"{}.service",
slot_unit_name(&cfg.service_name, live_slot)
));
let shell = format!(
"unit={unit}; \
systemctl disable --now \"$unit\" >/dev/null 2>&1; \
state=\"$(systemctl show --property=LoadState \"$unit\" 2>/dev/null) \
$(systemctl show --property=ActiveState \"$unit\" 2>/dev/null) \
$(systemctl show --property=UnitFileState \"$unit\" 2>/dev/null)\"; \
case \"$state\" in \
'LoadState=loaded ActiveState=inactive UnitFileState=disabled'|\
'LoadState=loaded ActiveState=failed UnitFileState=disabled') \
printf '%s' '{OLD_SLOT_STOPPED}' ;; \
*) printf '%s' '{OLD_SLOT_NOT_STOPPED}' ;; esac"
);
let out = exec.run(&RemoteCommand::new("drain-old-retry", shell))?;
Ok(match out.stdout.trim() {
OLD_SLOT_STOPPED => OldSlotState::Stopped,
OLD_SLOT_NOT_STOPPED => OldSlotState::NotStopped,
_ => OldSlotState::Unreadable,
})
}
fn probe_dir_state(
label: &'static str,
dir: &str,
exec: &impl DeployExecutor,
) -> Result<ReleaseDirState, DeployExecError> {
let shell = format!(
"if [ -d {dir} ]; then printf '%s' '{RELEASE_DIR_PRESENT}'; \
else printf '%s' '{RELEASE_DIR_ABSENT}'; fi",
dir = shell_quote(dir),
);
let out = exec.run(&RemoteCommand::new(label, shell))?;
Ok(match out.stdout.trim() {
RELEASE_DIR_PRESENT => ReleaseDirState::Present,
RELEASE_DIR_ABSENT => ReleaseDirState::Absent,
_ => ReleaseDirState::Unreadable,
})
}
fn run_proxy_compat_probe(
proxy: &impl ProxyController,
exec: &impl DeployExecutor,
) -> Result<Option<Result<(), ProxyCompatFailure>>, DeployExecError> {
let Some(probe) = proxy.compat_probe() else {
return Ok(None);
};
let out = exec.run(&probe.command)?;
let combined = if out.stderr.trim().is_empty() {
out.stdout
} else {
format!("{}\n{}", out.stdout, out.stderr)
};
Ok(Some(probe.assess(&combined)))
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum ProxyProvisioning {
Auto,
Disabled,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum ProxyReadiness {
Ready,
NeedsInstall,
}
pub fn assess_proxy_readiness(
proxy: &impl ProxyController,
exec: &impl DeployExecutor,
provisioning: ProxyProvisioning,
) -> Result<ProxyReadiness, DeployExecError> {
let Some(verdict) = run_proxy_compat_probe(proxy, exec)? else {
return Ok(ProxyReadiness::Ready);
};
let Err(failure) = verdict else {
return Ok(ProxyReadiness::Ready);
};
if !failure.binary_missing {
return Err(DeployExecError::ProxyIncompatible {
message: failure.message,
});
}
match (provisioning, proxy.binary_install_ops()) {
(ProxyProvisioning::Auto, Some(_)) => Ok(ProxyReadiness::NeedsInstall),
(ProxyProvisioning::Disabled, _) => Err(DeployExecError::ProxyIncompatible {
message: format!(
"{} (`[deploy] install_proxy = false` declines host preparation, so this \
deploy will not install it for you)",
failure.message,
),
}),
(ProxyProvisioning::Auto, None) => Err(DeployExecError::ProxyIncompatible {
message: failure.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);
let mut repair = record_live_slot(cfg, slot, slot_app_port(public_port, slot));
repair.label = LIVE_SLOT_REPAIR_LABEL;
DeployOp::Run(repair)
}
pub const LIVE_SLOT_REPAIR_LABEL: &str = "repair-live-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 public_port_rebind_snapshot_path(release_id: &str) -> String {
format!("/tmp/autumn-kamal-proxy-portmove-{release_id}.sha256")
}
#[must_use]
pub fn public_port_rebind_ops(
cfg: &ResolvedDeployConfig,
proxy: &impl ProxyController,
release_id: &str,
live_port: u16,
reregister_options: &ProxyServiceOptions,
target_public_port: u16,
) -> Vec<DeployOp> {
proxy.refresh_installed_ops(
target_public_port,
&cfg.service_name,
&loopback_upstream(live_port),
&public_port_rebind_snapshot_path(release_id),
reregister_options,
)
}
#[derive(Debug, thiserror::Error)]
pub enum PublicPortRebindError {
#[error(
"server.port could not be moved from {old_port} to {new_port} (`{failed_step}` \
failed: {source}) — rolled back, and the release is still live and reachable at \
{old_port}. Retry the port change alone in a separate deploy."
)]
RolledBack {
old_port: u16,
new_port: u16,
failed_step: &'static str,
#[source]
source: Box<DeployExecError>,
},
#[error(
"server.port move from {old_port} to {new_port} failed (`{failed_step}`: {source}) \
AND the rollback to {old_port} ALSO failed ({rollback_source}) — the proxy's public \
bind on this host is now UNKNOWN. Check `systemctl status kamal-proxy` and \
`kamal-proxy list` on the host by hand before retrying."
)]
RollbackFailed {
old_port: u16,
new_port: u16,
failed_step: &'static str,
#[source]
source: Box<DeployExecError>,
rollback_source: Box<DeployExecError>,
},
}
pub fn execute_public_port_rebind(
ops: &[DeployOp],
rollback: &[DeployOp],
old_port: u16,
new_port: u16,
exec: &impl DeployExecutor,
) -> Result<(), PublicPortRebindError> {
for op in ops {
if let Err(source) = run_one(op, exec) {
let failed_step = op.label();
eprintln!(
" \u{2717} {failed_step} failed \u{2014} rolling the public port back to \
{old_port}\u{2026}"
);
return match run_ops(rollback, exec) {
Ok(()) => Err(PublicPortRebindError::RolledBack {
old_port,
new_port,
failed_step,
source: Box::new(source),
}),
Err(rollback_source) => Err(PublicPortRebindError::RollbackFailed {
old_port,
new_port,
failed_step,
source: Box::new(source),
rollback_source: Box::new(rollback_source),
}),
};
}
}
Ok(())
}
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) {
match boundary {
Some(b) if index > b => {
return Err(DeployExecError::PostCutover {
failed_step: op.label(),
source: Box::new(source),
});
}
None => return Err(source),
Some(_) => {}
}
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(())
}
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)]
#[allow(clippy::redundant_pub_crate)]
pub(crate) mod test_support {
use super::{CommandOutput, DeployExecError, DeployExecutor, Path, RemoteCommand};
use std::cell::RefCell;
use std::rc::Rc;
pub(crate) type FleetTape = Rc<RefCell<Vec<(String, RecordedCall)>>>;
pub(crate) const PROBE_LABELS: [&str; 8] = [
"proxy-compat-probe",
"detect-current",
"probe-release-dir",
"probe-rollback-target",
"resolve-previous",
"probe-host-status",
"detect-maintenance-flag",
"drain-old-retry",
];
#[derive(Debug, Clone, PartialEq, Eq)]
pub(crate) enum RecordedCall {
Run {
label: &'static str,
shell: String,
},
Upload {
remote_path: String,
mode: Option<u32>,
},
}
impl RecordedCall {
pub(crate) const fn run_label(&self) -> Option<&'static str> {
match self {
Self::Run { label, .. } => Some(*label),
Self::Upload { .. } => None,
}
}
}
#[derive(Default)]
pub(crate) struct RecordingExecutor {
calls: RefCell<Vec<RecordedCall>>,
fail_labels: Vec<&'static str>,
transport_fail_labels: Vec<&'static str>,
fail_on_occurrence: Vec<(&'static str, usize)>,
stdout_by_label: Vec<(&'static str, String)>,
upload_fail_paths: Vec<String>,
host: String,
tape: Option<FleetTape>,
strict: bool,
}
impl RecordingExecutor {
pub(crate) fn new() -> Self {
Self::default()
}
pub(crate) fn failing_on(label: &'static str) -> Self {
Self {
fail_labels: vec![label],
..Self::default()
}
}
pub(crate) fn failing(mut self, label: &'static str) -> Self {
self.fail_labels.push(label);
self
}
pub(crate) fn failing_on_occurrence(
mut self,
label: &'static str,
occurrence: usize,
) -> Self {
self.fail_on_occurrence.push((label, occurrence));
self
}
pub(crate) fn transport_failing(mut self, label: &'static str) -> Self {
self.transport_fail_labels.push(label);
self
}
pub(crate) fn with_stdout(
mut self,
label: &'static str,
stdout: impl Into<String>,
) -> Self {
self.stdout_by_label.push((label, stdout.into()));
self
}
pub(crate) fn failing_upload(mut self, fragment: impl Into<String>) -> Self {
self.upload_fail_paths.push(fragment.into());
self
}
pub(crate) fn strict(mut self) -> Self {
self.strict = true;
self
}
pub(crate) fn recording_as(mut self, host: impl Into<String>, tape: FleetTape) -> Self {
self.host = host.into();
self.tape = Some(tape);
self
}
pub(crate) fn calls(&self) -> Vec<RecordedCall> {
self.calls.borrow().clone()
}
pub(crate) fn run_labels(&self) -> Vec<&'static str> {
self.calls
.borrow()
.iter()
.filter_map(RecordedCall::run_label)
.collect()
}
pub(crate) 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,
})
}
fn record(&self, call: RecordedCall) {
if let Some(tape) = &self.tape {
tape.borrow_mut().push((self.host.clone(), call.clone()));
}
self.calls.borrow_mut().push(call);
}
}
impl DeployExecutor for RecordingExecutor {
fn run(&self, cmd: &RemoteCommand) -> Result<CommandOutput, DeployExecError> {
let occurrence = self
.calls
.borrow()
.iter()
.filter(|c| c.run_label() == Some(cmd.label))
.count()
+ 1;
self.record(RecordedCall::Run {
label: cmd.label,
shell: cmd.shell.clone(),
});
if self.transport_fail_labels.contains(&cmd.label) {
return Err(DeployExecError::Spawn {
program: "ssh".to_owned(),
source: std::io::Error::other("scripted transport failure"),
});
}
if self.fail_labels.contains(&cmd.label)
|| self.fail_on_occurrence.contains(&(cmd.label, occurrence))
{
return Err(DeployExecError::CommandFailed {
label: cmd.label,
message: "scripted failure".to_owned(),
});
}
let scripted = self
.stdout_by_label
.iter()
.find(|(l, _)| *l == cmd.label)
.map(|(_, out)| out.clone());
assert!(
!(self.strict && scripted.is_none() && PROBE_LABELS.contains(&cmd.label)),
"unscripted probe `{}`{}: this fake would return EMPTY stdout, which every \
probe parser degrades to \"absent / first deploy\" — script it with \
`.with_stdout(\"{}\", …)` (issue #1621)",
cmd.label,
if self.host.is_empty() {
String::new()
} else {
format!(" on host {}", self.host)
},
cmd.label,
);
Ok(CommandOutput {
stdout: scripted.unwrap_or_default(),
stderr: String::new(),
})
}
fn upload(
&self,
_local: &Path,
remote_path: &str,
mode: Option<u32>,
) -> Result<(), DeployExecError> {
self.record(RecordedCall::Upload {
remote_path: remote_path.to_owned(),
mode,
});
if self
.upload_fail_paths
.iter()
.any(|fragment| remote_path.contains(fragment.as_str()))
{
return Err(DeployExecError::CommandFailed {
label: "upload",
message: "scripted upload failure".to_owned(),
});
}
Ok(())
}
}
}
#[cfg(test)]
mod tests {
use super::super::SqliteDataPlacement;
use super::test_support::{RecordedCall, RecordingExecutor};
use super::*;
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")
}
fn resolved_sqlite() -> ResolvedDeployConfig {
resolved().with_sqlite_data_placement(SqliteDataPlacement::Relative("app.db".to_owned()))
}
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> {
sample_ops_with(env, MigrateStep::Run)
}
fn sample_ops_with(env: Secret, migrate: MigrateStep) -> 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,
migrate,
)
}
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> {
sample_cutover_ops_with(env, MigrateStep::Run)
}
fn sample_cutover_ops_with(env: Secret, migrate: MigrateStep) -> 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,
&ProxyServiceOptions {
tls: false,
host: None,
},
migrate,
)
}
#[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]
fn first_deploy_runs_pending_migrations_before_the_app_starts() {
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 pos = |needle: &str| labels.iter().position(|&l| l == needle);
let migrate = pos("migrate").expect("the first deploy runs pending migrations");
assert!(
migrate < pos("enable-now").expect("the app starts"),
"migrations must run before the first release starts: {labels:?}"
);
assert!(
migrate < pos("readiness-gate").expect("readiness gate")
&& migrate < pos("proxy-route").expect("proxy is routed"),
"migrations must precede the readiness gate and the route: {labels:?}"
);
let calls = exec.calls();
let migrate_call = calls
.iter()
.position(|c| c.run_label() == Some("migrate"))
.expect("migrate is recorded");
for path in [
"/srv/autumn/myapp/shared/autumn.env",
"/srv/autumn/myapp/releases/20260714T120000Z/myapp",
] {
let uploaded = calls
.iter()
.position(|c| {
matches!(c, RecordedCall::Upload { remote_path, .. } if remote_path == path)
})
.unwrap_or_else(|| panic!("{path} is uploaded"));
assert!(
uploaded < migrate_call,
"{path} must be in place before the migrate one-shot runs: {calls:?}"
);
}
let shell = exec.shell_for("migrate").expect("migrate ran");
assert!(
shell.contains("systemd-run --wait")
&& shell.contains("--setenv=AUTUMN_MIGRATE=1")
&& shell.contains("/srv/autumn/myapp/releases/20260714T120000Z/myapp"),
"the first deploy runs the real migrate one-shot from the release dir: {shell}"
);
}
#[test]
fn the_migrate_one_shot_matches_the_slot_unit_directory_and_manifest() {
let release_dir = "/srv/autumn/myapp/releases/20260714T120000Z";
let unit = super::super::render_app_unit(&resolved(), release_dir, 3001, SLOT_BLUE);
for (path, ops) in [
(
"first deploy",
sample_ops(Secret::new("AUTUMN_SECURITY__SIGNING_SECRET=x\n")),
),
(
"redeploy",
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 shell = exec.shell_for("migrate").expect("migrate ran");
assert!(
shell.contains(&format!("--working-directory='{release_dir}'")),
"{path}: the one-shot must run in the release dir, like the slot \
unit: {shell}"
);
assert!(
unit.contains(&format!("WorkingDirectory={release_dir}")),
"{path}: the slot unit's WorkingDirectory is the contract being \
matched: {unit}"
);
assert!(
shell.contains(&format!("--setenv=AUTUMN_MANIFEST_DIR='{release_dir}'")),
"{path}: the one-shot must load the release's own manifest: {shell}"
);
assert!(
unit.contains(&format!("Environment=AUTUMN_MANIFEST_DIR={release_dir}")),
"{path}: the slot unit's manifest dir is the contract being matched"
);
}
}
#[test]
fn first_deploy_migrate_skip_omits_only_the_migrate_op() {
let run = sample_ops_with(
Secret::new("AUTUMN_SECURITY__SIGNING_SECRET=topsecret\n"),
MigrateStep::Run,
);
let skip = sample_ops_with(
Secret::new("AUTUMN_SECURITY__SIGNING_SECRET=topsecret\n"),
MigrateStep::Skip,
);
let exec_run = RecordingExecutor::new();
run_ops(&run, &exec_run).expect("recording executor never fails");
let exec_skip = RecordingExecutor::new();
run_ops(&skip, &exec_skip).expect("recording executor never fails");
let with: Vec<&str> = exec_run
.run_labels()
.into_iter()
.filter(|l| *l != "migrate")
.collect();
assert_eq!(
with,
exec_skip.run_labels(),
"Skip must remove the migrate op and nothing else"
);
assert!(
!exec_skip.run_labels().contains(&"migrate"),
"Skip must not schedule a migration"
);
}
#[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",
"record-proxy-options",
"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_ops_skip_omits_only_the_migrate_op() {
let labels = |ops: &[DeployOp]| ops.iter().map(DeployOp::label).collect::<Vec<_>>();
let run = labels(&sample_cutover_ops_with(
Secret::new("AUTUMN_SECURITY__SIGNING_SECRET=x\n"),
MigrateStep::Run,
));
let skip = labels(&sample_cutover_ops_with(
Secret::new("AUTUMN_SECURITY__SIGNING_SECRET=x\n"),
MigrateStep::Skip,
));
assert_eq!(
run.iter().filter(|l| **l == "migrate").count(),
1,
"MigrateStep::Run must emit exactly one migrate op, got: {run:?}"
);
let expected: Vec<&'static str> = run.iter().copied().filter(|l| *l != "migrate").collect();
assert_eq!(
skip, expected,
"MigrateStep::Skip must be MigrateStep::Run minus exactly the `migrate` \
op\n run: {run:?}\n skip: {skip:?}"
);
assert!(
!skip.contains(&"migrate"),
"a skipped cutover must carry no migrate op, got: {skip:?}"
);
assert!(
skip.contains(&"proxy-flip"),
"skipping the migration must not disturb the cutover boundary, got: {skip:?}"
);
}
#[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 public_port_rebind_ops_threads_the_target_port_live_loopback_and_release_id() {
let cfg = resolved();
let options = ProxyServiceOptions {
tls: false,
host: None,
};
let ops = public_port_rebind_ops(&cfg, &proxy(), RELEASE_ID, 3002, &options, 8080);
assert_eq!(ops.len(), 4, "snapshot + write-unit + install + restart");
let DeployOp::Run(snapshot) = &ops[0] else {
panic!("op 0 must be the snapshot Run op");
};
assert!(
snapshot
.shell
.contains(&format!("autumn-kamal-proxy-portmove-{RELEASE_ID}.sha256")),
"the snapshot path is keyed on release_id, in its OWN scratch namespace \
(distinct from the cutover's own durability-refresh snapshot): {}",
snapshot.shell,
);
let DeployOp::WriteFile {
contents: FileContents::Plain(unit),
..
} = &ops[1]
else {
panic!("op 1 must re-write the proxy unit");
};
assert!(
unit.contains("--http-port 8080\n"),
"the rewritten unit binds the TARGET public port: {unit}"
);
let DeployOp::Run(restart) = &ops[3] else {
panic!("op 3 must be proxy-restart-if-changed");
};
assert!(
restart.shell.contains("--target '127.0.0.1:3002'"),
"the re-register targets the NOW-LIVE release's loopback port: {}",
restart.shell,
);
}
#[test]
fn public_port_rebind_ops_threads_tls_and_host_through_the_reregister() {
let cfg = resolved();
let options = ProxyServiceOptions {
tls: true,
host: Some("app.example.com".to_owned()),
};
let controller = super::super::proxy::KamalProxyController::new(60)
.with_tls_host(Some("app.example.com".to_owned()));
let ops = public_port_rebind_ops(&cfg, &controller, RELEASE_ID, 3002, &options, 8080);
let DeployOp::Run(restart) = &ops[3] else {
panic!("op 3 must be proxy-restart-if-changed");
};
assert!(
restart.shell.contains("--host 'app.example.com' --tls"),
"the phase-4 re-register carries the release's own TLS/host: {}",
restart.shell,
);
}
#[test]
fn execute_public_port_rebind_succeeds_without_touching_rollback() {
let cfg = resolved();
let options = ProxyServiceOptions {
tls: false,
host: None,
};
let ops = public_port_rebind_ops(&cfg, &proxy(), RELEASE_ID, 3002, &options, 8080);
let rollback = public_port_rebind_ops(&cfg, &proxy(), RELEASE_ID, 3002, &options, 80);
let exec = RecordingExecutor::new();
execute_public_port_rebind(&ops, &rollback, 80, 8080, &exec)
.expect("a healthy rebind succeeds");
assert_eq!(
exec.run_labels(),
vec![
"proxy-snapshot-unit",
"proxy-install",
"proxy-restart-if-changed"
],
"a healthy rebind runs only the forward ops, never the rollback"
);
}
#[test]
fn execute_public_port_rebind_rolls_back_to_the_old_port_on_failure() {
let cfg = resolved();
let options = ProxyServiceOptions {
tls: false,
host: None,
};
let ops = public_port_rebind_ops(&cfg, &proxy(), RELEASE_ID, 3002, &options, 8080);
let rollback = public_port_rebind_ops(&cfg, &proxy(), RELEASE_ID, 3002, &options, 80);
let exec = RecordingExecutor::new().failing_on_occurrence("proxy-restart-if-changed", 1);
let err = execute_public_port_rebind(&ops, &rollback, 80, 8080, &exec)
.expect_err("a failed rebind must roll back");
match err {
PublicPortRebindError::RolledBack {
old_port,
new_port,
failed_step,
..
} => {
assert_eq!(old_port, 80);
assert_eq!(new_port, 8080);
assert_eq!(failed_step, "proxy-restart-if-changed");
}
other @ PublicPortRebindError::RollbackFailed { .. } => {
panic!("expected RolledBack, got {other:?}")
}
}
assert_eq!(
exec.run_labels(),
vec![
"proxy-snapshot-unit",
"proxy-install",
"proxy-restart-if-changed",
"proxy-snapshot-unit",
"proxy-install",
"proxy-restart-if-changed",
],
"a rolled-back rebind runs the forward attempt then the full rollback"
);
}
#[test]
fn execute_public_port_rebind_reports_when_the_rollback_itself_fails() {
let cfg = resolved();
let options = ProxyServiceOptions {
tls: false,
host: None,
};
let ops = public_port_rebind_ops(&cfg, &proxy(), RELEASE_ID, 3002, &options, 8080);
let rollback = public_port_rebind_ops(&cfg, &proxy(), RELEASE_ID, 3002, &options, 80);
let exec = RecordingExecutor::failing_on("proxy-restart-if-changed");
let err = execute_public_port_rebind(&ops, &rollback, 80, 8080, &exec)
.expect_err("a doubly-failed rebind must report RollbackFailed");
match err {
PublicPortRebindError::RollbackFailed {
old_port,
new_port,
failed_step,
..
} => {
assert_eq!(old_port, 80);
assert_eq!(new_port, 8080);
assert_eq!(failed_step, "proxy-restart-if-changed");
}
other @ PublicPortRebindError::RolledBack { .. } => {
panic!("expected RollbackFailed, got {other:?}")
}
}
}
#[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",
"teardown-last-deploy",
],
"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 first_deploy_teardown_never_touches_the_proxy_route() {
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!(
!labels.iter().any(|l| l.contains("proxy")),
"this chain must never run a proxy op: {labels:?}"
);
}
#[test]
fn first_deploy_teardown_records_the_torn_down_result() {
let cfg = resolved();
let plan = SlotPlan::first(3000);
let teardown = first_deploy_teardown_ops(&cfg, RELEASE_ID, &plan);
let exec = RecordingExecutor::new();
run_teardown(&teardown, &exec);
let shell = exec
.shell_for("teardown-last-deploy")
.expect("teardown-last-deploy ran");
assert!(
shell.contains("printf '%s\\t%s' 'torn down'")
&& shell.contains("date -u +%Y-%m-%dT%H:%M:%SZ"),
"the teardown must record `torn down` plus a UTC timestamp: {shell}"
);
assert!(
shell.contains("mv -f \"$rtmp\" '/srv/autumn/myapp/shared/last-deploy'"),
"it must land on the same marker `deploy status` reads: {shell}"
);
assert!(
!shell.contains("'deployed'"),
"a torn-down host must never claim a successful deploy: {shell}"
);
assert!(
shell.contains("|| true; }"),
"the teardown's last-deploy write must never be able to fail the op: {shell}"
);
let labels: Vec<&str> = teardown.iter().map(DeployOp::label).collect();
assert_eq!(
labels.last().copied(),
Some("teardown-last-deploy"),
"the last-deploy record must be the final teardown op: {labels:?}"
);
}
#[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:?}"
);
assert!(
!labels.contains(&"teardown-last-deploy"),
"redeploy teardown must not rewrite the last-deploy marker — the previous \
release is still serving: {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 probe_release_dir_is_read_only_and_fails_closed() {
let cfg = resolved();
let absent = RecordingExecutor::new().with_stdout("probe-release-dir", "absent");
assert_eq!(
probe_release_dir(&cfg, RELEASE_ID, &absent).unwrap(),
ReleaseDirState::Absent,
"a free release dir is the normal case"
);
let shell = absent.shell_for("probe-release-dir").expect("probe ran");
assert_eq!(
shell,
format!(
"if [ -d '{RELEASE_DIR}' ]; then printf '%s' 'present'; \
else printf '%s' 'absent'; fi"
),
"the collision probe must be a read-only directory test",
);
assert_eq!(
absent.run_labels(),
vec!["probe-release-dir"],
"the probe runs exactly one command and mutates nothing"
);
let present = RecordingExecutor::new().with_stdout("probe-release-dir", "present\n");
assert_eq!(
probe_release_dir(&cfg, RELEASE_ID, &present).unwrap(),
ReleaseDirState::Present,
"an existing release dir is reported"
);
let garbled = RecordingExecutor::new().with_stdout("probe-release-dir", "bash: -c: line 0");
assert_eq!(
probe_release_dir(&cfg, RELEASE_ID, &garbled).unwrap(),
ReleaseDirState::Unreadable,
"an unexpected capture must fail closed, never degrade to Absent"
);
let empty = RecordingExecutor::new();
assert_eq!(
probe_release_dir(&cfg, RELEASE_ID, &empty).unwrap(),
ReleaseDirState::Unreadable,
"empty output must fail closed (the trap every other probe degrades on)"
);
}
#[test]
fn probe_rollback_target_dir_is_read_only_and_distinctly_labelled() {
let previous = "/srv/autumn/myapp/releases/20260101T000000Z";
let present = RecordingExecutor::new().with_stdout("probe-rollback-target", "present");
assert_eq!(
probe_rollback_target_dir(previous, &present).unwrap(),
ReleaseDirState::Present,
"a retained rollback target is the normal case"
);
assert_eq!(
present
.shell_for("probe-rollback-target")
.expect("probe ran"),
format!(
"if [ -d '{previous}' ]; then printf '%s' 'present'; \
else printf '%s' 'absent'; fi"
),
"the target probe must be the same read-only directory test",
);
assert_eq!(
present.run_labels(),
vec!["probe-rollback-target"],
"the probe runs exactly one command, under its OWN label so a tape can \
tell it apart from the candidate-dir collision probe, and mutates nothing"
);
let absent = RecordingExecutor::new().with_stdout("probe-rollback-target", "absent\n");
assert_eq!(
probe_rollback_target_dir(previous, &absent).unwrap(),
ReleaseDirState::Absent,
"a pruned rollback target must be reported so the caller declines"
);
let garbled =
RecordingExecutor::new().with_stdout("probe-rollback-target", "bash: -c: line 0");
assert_eq!(
probe_rollback_target_dir(previous, &garbled).unwrap(),
ReleaseDirState::Unreadable,
"an unexpected capture must fail closed, never degrade to Present"
);
}
#[test]
fn retry_drain_old_retries_once_and_reads_the_old_unit_state() {
let cfg = resolved();
let stopped = RecordingExecutor::new().with_stdout("drain-old-retry", "stopped\n");
assert_eq!(
retry_drain_old(&cfg, SLOT_BLUE, &stopped).unwrap(),
OldSlotState::Stopped,
);
let shell = stopped.shell_for("drain-old-retry").expect("retry ran");
assert_eq!(
shell,
"unit='myapp-blue.service'; \
systemctl disable --now \"$unit\" >/dev/null 2>&1; \
state=\"$(systemctl show --property=LoadState \"$unit\" 2>/dev/null) \
$(systemctl show --property=ActiveState \"$unit\" 2>/dev/null) \
$(systemctl show --property=UnitFileState \"$unit\" 2>/dev/null)\"; \
case \"$state\" in \
'LoadState=loaded ActiveState=inactive UnitFileState=disabled'|\
'LoadState=loaded ActiveState=failed UnitFileState=disabled') \
printf '%s' 'stopped' ;; \
*) printf '%s' 'not-stopped' ;; esac",
"retry the same stop, then read each property on its own line. \
`is-active` is false while the unit is `deactivating`. An enabled unit \
starts again at boot. A unit that is not found proves nothing.",
);
assert_eq!(stopped.run_labels(), vec!["drain-old-retry"]);
let not_stopped = RecordingExecutor::new().with_stdout("drain-old-retry", "not-stopped");
assert_eq!(
retry_drain_old(&cfg, SLOT_BLUE, ¬_stopped).unwrap(),
OldSlotState::NotStopped,
);
for garbled in ["", "bash: -c: line 0", "stopped extra"] {
let exec = RecordingExecutor::new().with_stdout("drain-old-retry", garbled);
assert_eq!(
retry_drain_old(&cfg, SLOT_BLUE, &exec).unwrap(),
OldSlotState::Unreadable,
"output {garbled:?} proves nothing, so it must not read as stopped",
);
}
assert!(
test_support::PROBE_LABELS.contains(&"drain-old-retry"),
"the caller parses its stdout, so a strict fake must require a script"
);
}
#[test]
fn strict_recording_executor_refuses_to_fake_an_unscripted_probe() {
let cfg = resolved();
let strict = RecordingExecutor::new().strict();
let panicked = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
let _ = probe_deploy_state(&cfg, &strict);
}));
assert!(
panicked.is_err(),
"an unscripted probe must fail loudly under strict mode"
);
let scripted = RecordingExecutor::new()
.strict()
.with_stdout("detect-current", "first\n");
assert_eq!(
probe_deploy_state(&cfg, &scripted).unwrap().mode,
DeployMode::First,
"strict mode changes nothing for a scripted probe"
);
assert!(
run_ops(
&[DeployOp::Run(RemoteCommand::new("prune", "true"))],
&RecordingExecutor::new().strict()
)
.is_ok(),
"strict mode only guards labels whose stdout is parsed"
);
}
#[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
);
}
#[test]
fn proxy_options_marker_round_trips_through_serialize_and_parse() {
let tls_on = ProxyServiceOptions {
tls: true,
host: Some("app.example.com".to_owned()),
};
let on_marker = tls_on.marker_value();
assert_eq!(on_marker, "1\tapp.example.com");
assert_eq!(
parse_proxy_options(&on_marker),
ProxyOptionsMarker::Options(tls_on),
);
let tls_off = ProxyServiceOptions {
tls: false,
host: None,
};
assert_eq!(tls_off.marker_value(), "0\t");
assert_eq!(
parse_proxy_options(&tls_off.marker_value()),
ProxyOptionsMarker::Options(tls_off.clone()),
);
assert_eq!(
parse_proxy_options(&format!("\n{}\n", tls_off.marker_value())),
ProxyOptionsMarker::Options(tls_off),
);
}
#[test]
fn record_proxy_options_writes_the_marker_atomically() {
let cfg = resolved();
let cmd = record_proxy_options(
&cfg,
&ProxyServiceOptions {
tls: true,
host: Some("app.example.com".to_owned()),
},
);
assert_eq!(cmd.label, "record-proxy-options");
assert!(
cmd.shell
.contains("mktemp '/srv/autumn/myapp/shared/proxy-options.tmp.XXXXXX'"),
"mktemp's a temp file in the marker's own shared dir: {}",
cmd.shell,
);
assert!(
cmd.shell.contains("printf '%s' '1\tapp.example.com'"),
"writes the {{tls}}\\t{{host}} marker value: {}",
cmd.shell,
);
assert!(
cmd.shell
.contains("mv -f \"$otmp\" '/srv/autumn/myapp/shared/proxy-options'"),
"mv's the temp file onto the final marker path (atomic): {}",
cmd.shell,
);
let off = record_proxy_options(
&cfg,
&ProxyServiceOptions {
tls: false,
host: None,
},
);
assert!(
off.shell.contains("printf '%s' '0\t'"),
"TLS-off writes `0\\t` (empty host — a removed host is representable): {}",
off.shell,
);
}
#[test]
fn parse_proxy_options_classifies_the_marker_section() {
assert_eq!(parse_proxy_options(""), ProxyOptionsMarker::Absent);
assert_eq!(parse_proxy_options("\n\n"), ProxyOptionsMarker::Absent);
assert_eq!(
parse_proxy_options("1\ta.com"),
ProxyOptionsMarker::Options(ProxyServiceOptions {
tls: true,
host: Some("a.com".to_owned()),
}),
);
assert_eq!(
parse_proxy_options("0\t"),
ProxyOptionsMarker::Options(ProxyServiceOptions {
tls: false,
host: None,
}),
);
assert_eq!(
parse_proxy_options("garbage"),
ProxyOptionsMarker::Unreadable
);
assert_eq!(parse_proxy_options("2\tx"), ProxyOptionsMarker::Unreadable);
assert_eq!(parse_proxy_options("1\t"), ProxyOptionsMarker::Unreadable);
}
#[test]
fn cutover_records_proxy_options_after_the_marker_commit() {
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 labels = exec.run_labels();
let commit = labels
.iter()
.position(|&l| l == "commit-markers")
.expect("commit-markers ran");
let record = labels
.iter()
.position(|&l| l == "record-proxy-options")
.expect("record-proxy-options ran");
assert!(
commit < record,
"record-proxy-options is written after the marker commit: {labels:?}",
);
let shell = exec
.shell_for("record-proxy-options")
.expect("record-proxy-options ran");
assert!(
shell.contains("printf '%s' '0\t'"),
"records the TLS-off options the default controller registered: {shell}",
);
}
#[test]
fn first_deploy_records_proxy_options() {
let ops = sample_ops(Secret::new("AUTUMN_SECURITY__SIGNING_SECRET=x\n"));
let exec = RecordingExecutor::new();
run_ops(&ops, &exec).expect("recording executor never fails");
let labels = exec.run_labels();
let live = labels
.iter()
.position(|&l| l == "record-live-slot")
.expect("record-live-slot ran");
let record = labels
.iter()
.position(|&l| l == "record-proxy-options")
.expect("record-proxy-options ran on first deploy");
assert!(
live < record,
"record-proxy-options follows record-live-slot on first deploy: {labels:?}",
);
}
#[test]
fn probe_deploy_state_captures_the_proxy_options_marker() {
let cfg = resolved();
let stdout = format!(
"redeploy:blue\t3001\n---autumn-kamal-proxy-list---\n{}\n\
---autumn-kamal-proxy-unit---\n--http-port 80\n\
---autumn-kamal-proxy-options---\n1\tapp.example.com",
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.last_proxy_options,
ProxyOptionsMarker::Options(ProxyServiceOptions {
tls: true,
host: Some("app.example.com".to_owned()),
}),
"the proxy-options marker is captured",
);
assert_eq!(probe.installed_proxy_port, InstalledProxyPort::Port(80));
let shell = exec.shell_for("detect-current").expect("probe ran");
assert!(
shell.contains("---autumn-kamal-proxy-options---")
&& shell.contains("cat '/srv/autumn/myapp/shared/proxy-options'"),
"probe cats the proxy-options marker behind its delimiter: {shell}",
);
let legacy = RecordingExecutor::new().with_stdout(
"detect-current",
"redeploy:blue\t3001\n---autumn-kamal-proxy-list---\n\
---autumn-kamal-proxy-unit---\n--http-port 80",
);
assert_eq!(
probe_deploy_state(&cfg, &legacy)
.unwrap()
.last_proxy_options,
ProxyOptionsMarker::Absent,
"a missing options delimiter degrades to Absent (proceed as legacy)",
);
let unreadable = RecordingExecutor::new().with_stdout(
"detect-current",
"redeploy:blue\t3001\n---autumn-kamal-proxy-list---\n\
---autumn-kamal-proxy-unit---\n--http-port 80\n\
---autumn-kamal-proxy-options---\ngarbage",
);
assert_eq!(
probe_deploy_state(&cfg, &unreadable)
.unwrap()
.last_proxy_options,
ProxyOptionsMarker::Unreadable,
"an unparseable proxy-options marker fails closed as Unreadable",
);
}
#[test]
fn probe_deploy_state_captures_the_current_release_dir() {
let cfg = resolved();
let stdout = format!(
"redeploy:blue\t3001\n---autumn-kamal-proxy-list---\n{}\n\
---autumn-kamal-proxy-unit---\n--http-port 80\n\
---autumn-kamal-proxy-options---\n0\t\n\
---autumn-current-release---\n/srv/autumn/myapp/releases/20260714T120000Z",
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.current_release_dir.as_deref(),
Some("/srv/autumn/myapp/releases/20260714T120000Z"),
);
assert_eq!(
release_id_from_dir(probe.current_release_dir.as_deref().unwrap()),
Some("20260714T120000Z"),
"the release id is the release dir's basename"
);
assert_eq!(
probe.last_proxy_options,
ProxyOptionsMarker::Options(ProxyServiceOptions {
tls: false,
host: None,
})
);
assert_eq!(probe.installed_proxy_port, InstalledProxyPort::Port(80));
let shell = exec.shell_for("detect-current").expect("probe ran");
assert!(
shell.contains("---autumn-current-release---")
&& shell.contains("readlink -f '/srv/autumn/myapp/current'"),
"probe resolves the current symlink behind its delimiter: {shell}"
);
assert_eq!(
exec.run_labels(),
vec!["detect-current"],
"the probe's op label must not change when a section is added"
);
}
#[test]
fn probe_deploy_state_degrades_when_the_current_release_section_is_missing() {
let cfg = resolved();
let legacy = RecordingExecutor::new().with_stdout(
"detect-current",
"redeploy:blue\t3001\n---autumn-kamal-proxy-list---\n\
---autumn-kamal-proxy-unit---\n--http-port 80",
);
assert!(
probe_deploy_state(&cfg, &legacy)
.unwrap()
.current_release_dir
.is_none(),
"a missing fifth delimiter degrades to unknown"
);
let empty = RecordingExecutor::new().with_stdout(
"detect-current",
"first\n---autumn-kamal-proxy-list---\n\
---autumn-kamal-proxy-unit---\n---autumn-no-proxy-unit---\n\
---autumn-kamal-proxy-options---\n\n---autumn-current-release---\n",
);
let probe = probe_deploy_state(&cfg, &empty).unwrap();
assert_eq!(probe.mode, DeployMode::First);
assert!(
probe.current_release_dir.is_none(),
"an empty current section is unknown, not an empty release id"
);
}
#[cfg(target_os = "linux")]
fn probe_local_app_dir(app_dir: &Path) -> (ResolvedDeployConfig, DeployProbe) {
let mut cfg = resolved();
cfg.app_dir = app_dir.to_str().expect("utf-8 temp dir").to_owned();
let render = RecordingExecutor::new();
probe_deploy_state(&cfg, &render).expect("probe renders");
let shell = render.shell_for("detect-current").expect("probe ran");
let out = std::process::Command::new("sh")
.arg("-c")
.arg(&shell)
.output()
.expect("run probe shell");
assert!(
out.status.success(),
"probe shell must not fail: {}",
String::from_utf8_lossy(&out.stderr)
);
let stdout = String::from_utf8(out.stdout).expect("utf-8 stdout");
let replay = RecordingExecutor::new().with_stdout("detect-current", stdout);
let probe = probe_deploy_state(&cfg, &replay).expect("probe parses");
(cfg, probe)
}
#[cfg(target_os = "linux")]
fn app_dir_with_current(root: &Path, name: &str, target: &Path) -> std::path::PathBuf {
let app = root.join(name);
std::fs::create_dir_all(app.join("releases/r1/sub")).expect("mk release");
std::fs::create_dir_all(app.join("releases2/r1")).expect("mk sibling");
std::fs::write(app.join("releases/file"), b"").expect("mk file");
std::os::unix::fs::symlink(target, app.join("current")).expect("link current");
app
}
#[cfg(target_os = "linux")]
#[test]
fn probe_names_a_release_only_when_current_resolves_to_a_release_dir() {
let tree = tempfile::tempdir().expect("temp dir");
let root = tree.path();
let outside = root.join("outside/r9");
std::fs::create_dir_all(&outside).expect("mk outside dir");
let rows: [(&str, &Path, Option<&str>); 7] = [
("relative", Path::new("releases/r1"), Some("r1")),
("dangling", Path::new("releases/gone"), None),
("outside", &outside, None),
("file", Path::new("releases/file"), None),
("releases-root", Path::new("releases"), None),
("deep", Path::new("releases/r1/sub"), None),
("sibling", Path::new("releases2/r1"), None),
];
for (name, target, want) in rows {
let (_, probe) = probe_local_app_dir(&app_dir_with_current(root, name, target));
assert!(
matches!(probe.mode, DeployMode::Redeploy { .. }),
"{name}: `[ -L current ]` still decides the mode"
);
assert_eq!(
probe
.current_release_dir
.as_deref()
.and_then(release_id_from_dir),
want,
"{name}: {:?}",
probe.current_release_dir
);
}
let abs = root.join("absolute");
let (_, probe) = probe_local_app_dir(&app_dir_with_current(
root,
"absolute",
&abs.join("releases/r1"),
));
assert_eq!(
probe
.current_release_dir
.as_deref()
.and_then(release_id_from_dir),
Some("r1")
);
let real = app_dir_with_current(root, "real", Path::new("releases/r1"));
let alias = root.join("alias");
std::os::unix::fs::symlink(&real, &alias).expect("link app dir");
let (_, probe) = probe_local_app_dir(&alias);
assert_eq!(
probe
.current_release_dir
.as_deref()
.and_then(release_id_from_dir),
Some("r1"),
"a symlinked app dir still names its release"
);
let store = root.join("store");
std::fs::create_dir_all(store.join("r2")).expect("mk store release");
let linked = root.join("linked");
std::fs::create_dir_all(&linked).expect("mk app dir");
std::os::unix::fs::symlink(&store, linked.join("releases")).expect("link releases");
std::os::unix::fs::symlink("releases/r2", linked.join("current")).expect("link current");
let (_, probe) = probe_local_app_dir(&linked);
assert_eq!(
probe
.current_release_dir
.as_deref()
.and_then(release_id_from_dir),
Some("r2"),
"a symlinked releases dir still names its release"
);
}
#[cfg(target_os = "linux")]
#[test]
fn a_dangling_current_is_state_drift_end_to_end() {
use super::super::fleet::{DRIFT_RELEASE_UNREADABLE, HostStatus, ReleaseId, fleet_drift};
let tree = tempfile::tempdir().expect("temp dir");
let app = app_dir_with_current(tree.path(), "app", Path::new("releases/gone"));
let (cfg, deploy) = probe_local_app_dir(&app);
let probe = HostStatusProbe {
deploy,
ready_code: Some(200),
shared_maintenance_flag: false,
maintenance: MaintenanceStatus::Off,
maintenance_flag_source: MaintenanceFlagSource::Shared,
last_deploy: None,
};
let status = HostStatus::from_probe(&cfg, 3000, &probe);
assert_eq!(
status.release,
ReleaseId::Unknown,
"the status names no release"
);
let report = fleet_drift(&[status]);
assert!(
report
.state_drift
.contains(&("203.0.113.10".to_owned(), DRIFT_RELEASE_UNREADABLE)),
"{:?}",
report.state_drift
);
assert!(report.drifted(), "`--strict` must exit non-zero");
}
fn status_probe_stdout(release: &str, live_port: u16) -> String {
format!(
"redeploy:blue\t{live_port}\n---autumn-kamal-proxy-list---\n{}\n\
---autumn-kamal-proxy-unit---\n--http-port 3000\n\
---autumn-kamal-proxy-options---\n0\t\n\
---autumn-current-release---\n/srv/autumn/myapp/releases/{release}",
proxy_list_serving("myapp", live_port)
)
}
#[test]
fn the_cutover_and_the_rollback_record_the_last_deploy_result() {
let cfg = resolved();
let marker = "'/srv/autumn/myapp/shared/last-deploy'";
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");
for label in ["commit-markers", "record-proxy-options"] {
let shell = exec.shell_for(label).expect("op ran");
assert!(
shell.contains("mktemp '/srv/autumn/myapp/shared/last-deploy.tmp.XXXXXX'")
&& shell.contains(&format!("mv -f \"$rtmp\" {marker}")),
"{label} must write the last-deploy marker atomically: {shell}"
);
assert!(
shell.contains("printf '%s\\t%s' 'deployed'")
&& shell.contains("date -u +%Y-%m-%dT%H:%M:%SZ"),
"{label} must record `deployed` plus a UTC timestamp: {shell}"
);
assert!(
shell.contains("|| true; }"),
"the last-deploy write must be advisory, never able to fail its op: {shell}"
);
}
let first = sample_ops(Secret::new("AUTUMN_SECURITY__SIGNING_SECRET=x\n"));
let first_exec = RecordingExecutor::new();
run_ops(&first, &first_exec).expect("recording executor never fails");
let first_record = first_exec
.shell_for("record-proxy-options")
.expect("first deploy records proxy options");
assert!(
first_record.contains("printf '%s\\t%s' 'deployed'") && first_record.contains(marker),
"a first deploy must record its result too: {first_record}"
);
let rb_exec = RecordingExecutor::new().with_stdout(
"resolve-previous",
"prev:/srv/autumn/myapp/releases/20260713T090000Z\tblue\t3001",
);
let target =
resolve_rollback_target(&cfg, 3000, &rb_exec).expect("previous release resolves");
let rb_ops = rollback_ops(&cfg, &proxy(), &target);
run_ops(&rb_ops, &rb_exec).expect("recording executor never fails");
let rb = rb_exec
.shell_for("commit-markers")
.expect("commit-markers ran");
assert!(
rb.contains("printf '%s\\t%s' 'rolled back'") && rb.contains(marker),
"a rollback must record `rolled back`, not `deployed`: {rb}"
);
}
#[test]
fn the_status_probe_reads_the_last_deploy_marker_and_degrades_to_unknown() {
let cfg = resolved();
let probe_with = |stdout: &str| {
let exec = RecordingExecutor::new()
.with_stdout(
"detect-current",
status_probe_stdout("20260714T120000Z", 3001),
)
.with_stdout("probe-host-status", stdout.to_owned());
(probe_host_status(&cfg, 3000, &exec).unwrap(), exec)
};
let (probe, exec) = probe_with(
"200\n---autumn-host-status---\n\n---autumn-host-status---\n\
rolled back\t2026-07-14T12:31:10Z",
);
assert_eq!(
probe.last_deploy,
Some(LastDeploy {
result: "rolled back".to_owned(),
at: Some("2026-07-14T12:31:10Z".to_owned()),
})
);
let shell = exec
.shell_for("probe-host-status")
.expect("status probe ran");
assert!(
shell.contains("cat '/srv/autumn/myapp/shared/last-deploy'"),
"the marker is read from the release-independent shared dir: {shell}"
);
assert_eq!(
exec.run_labels(),
vec!["detect-current", "probe-host-status"]
);
let (older, _) =
probe_with("200\n---autumn-host-status---\n\n---autumn-host-status---\ndeployed");
assert_eq!(
older.last_deploy,
Some(LastDeploy {
result: "deployed".to_owned(),
at: None,
})
);
for stdout in [
"200\n---autumn-host-status---\n\n---autumn-host-status---\n",
"200\n---autumn-host-status---\nmaintenance-on",
] {
let (unknown, _) = probe_with(stdout);
assert_eq!(
unknown.last_deploy, None,
"an unreadable marker must not be reported as a result: {stdout:?}"
);
}
}
#[test]
fn probe_host_status_consults_the_live_slot_unit_for_the_maintenance_flag_path() {
let cfg = resolved();
let exec = RecordingExecutor::new()
.with_stdout("detect-current", status_probe_stdout(RELEASE_ID, 3001))
.with_stdout("probe-host-status", "200\n---autumn-host-status---\n");
probe_host_status(&cfg, 3000, &exec).expect("status probe runs");
let shell = exec
.shell_for("probe-host-status")
.expect("status probe ran");
assert!(
shell.contains("'/etc/systemd/system/myapp-blue.service'"),
"the status probe must read the LIVE SLOT UNIT to learn which flag file \
the running app polls: {shell}"
);
assert!(
shell.contains(autumn_web::maintenance::MAINTENANCE_FLAG_FILE_ENV),
"the probe must resolve the unit's flag-file override: {shell}"
);
assert!(
shell.contains(autumn_web::maintenance::MAINTENANCE_FLAG_FILE),
"the probe must fall back to the legacy cwd-relative path for a unit \
that declares no override: {shell}"
);
assert_eq!(
exec.run_labels(),
vec!["detect-current", "probe-host-status"],
"`deploy status` must never mutate a host"
);
}
#[test]
fn the_status_read_and_the_maintenance_write_resolve_the_flag_with_one_shell() {
let cfg = resolved();
let exec = RecordingExecutor::new()
.with_stdout("detect-current", status_probe_stdout(RELEASE_ID, 3001))
.with_stdout("probe-host-status", "200\n---autumn-host-status---\n")
.with_stdout(
"detect-maintenance-flag",
"/srv/autumn/myapp/releases/r9/tmp/autumn-maintenance.json\n",
);
probe_host_status(&cfg, 3000, &exec).expect("status probe runs");
let status_shell = exec
.shell_for("probe-host-status")
.expect("status probe ran");
let resolution = live_maintenance_flag_shell(&cfg, SLOT_BLUE);
assert!(
status_shell.contains(&resolution),
"`deploy status` must resolve the flag through the shared fragment: \
{status_shell}"
);
let flag = probe_live_maintenance_flag(&cfg, SLOT_BLUE, &exec)
.expect("the flag probe runs")
.expect("the unit resolved a path");
assert_eq!(
exec.shell_for("detect-maintenance-flag"),
Some(resolution),
"the write path must run the SAME resolution, not a second copy of it"
);
assert_eq!(
flag.path, "/srv/autumn/myapp/releases/r9/tmp/autumn-maintenance.json",
"the resolved path is the unit's, whatever `current` happens to say"
);
assert!(!flag.present, "no sentinel line means the file is absent");
let blank = RecordingExecutor::new().with_stdout("detect-maintenance-flag", "");
assert_eq!(
probe_live_maintenance_flag(&cfg, SLOT_BLUE, &blank).expect("the probe runs"),
None,
"an unreadable unit proves nothing about which file the app polls"
);
}
fn host_status_capture(ready: &str, shared_on: bool, unit_section: &str) -> String {
format!(
"{ready}\n{HOST_STATUS_DELIM}\n{shared}\n{HOST_STATUS_DELIM}\n\n{unit_section}",
shared = if shared_on {
MAINTENANCE_ON_SENTINEL
} else {
""
},
)
}
#[test]
fn probe_host_status_reports_the_flag_state_the_running_unit_observes() {
let cfg = resolved();
let legacy_flag = format!(
"{RELEASE_DIR}/{}",
autumn_web::maintenance::MAINTENANCE_FLAG_FILE
);
let probe_with_sections = |shared_on: bool, unit_section: String| {
let exec = RecordingExecutor::new()
.with_stdout("detect-current", status_probe_stdout(RELEASE_ID, 3001))
.with_stdout(
"probe-host-status",
host_status_capture("200", shared_on, &unit_section),
);
probe_host_status(&cfg, 3000, &exec).expect("status probe runs")
};
let maintained = probe_with_sections(
false,
format!("{HOST_STATUS_DELIM}\n{legacy_flag}\n{MAINTENANCE_ON_SENTINEL}"),
);
assert_eq!(maintained.maintenance, MaintenanceStatus::On);
assert_eq!(
maintained.maintenance_flag_source,
MaintenanceFlagSource::Unshared,
"a unit with no override polls a path the fleet switch does not own",
);
let shared_only =
probe_with_sections(true, format!("{HOST_STATUS_DELIM}\n{legacy_flag}\n"));
assert_eq!(shared_only.maintenance, MaintenanceStatus::Off);
assert_eq!(
shared_only.maintenance_flag_source,
MaintenanceFlagSource::Unshared
);
let shared_path = cfg.maintenance_flag_file();
let modern = probe_with_sections(
true,
format!("{HOST_STATUS_DELIM}\n{shared_path}\n{MAINTENANCE_ON_SENTINEL}"),
);
assert_eq!(modern.maintenance, MaintenanceStatus::On);
assert_eq!(
modern.maintenance_flag_source,
MaintenanceFlagSource::Shared
);
let cleared = probe_with_sections(false, format!("{HOST_STATUS_DELIM}\n{shared_path}\n"));
assert_eq!(cleared.maintenance, MaintenanceStatus::Off);
assert_eq!(
cleared.maintenance_flag_source,
MaintenanceFlagSource::Shared
);
}
#[test]
fn probe_host_status_fails_closed_when_the_flag_path_cannot_be_proved() {
let cfg = resolved();
for unit_section in [
String::new(),
format!("{HOST_STATUS_DELIM}\n"),
format!("{HOST_STATUS_DELIM}\n \n"),
] {
let exec = RecordingExecutor::new()
.with_stdout("detect-current", status_probe_stdout(RELEASE_ID, 3001))
.with_stdout(
"probe-host-status",
host_status_capture("200", true, &unit_section),
);
let probe = probe_host_status(&cfg, 3000, &exec).expect("status probe runs");
assert_eq!(
probe.maintenance,
MaintenanceStatus::Unknown,
"an unprovable flag path must not render as a confident on/off: \
{unit_section:?}"
);
assert_eq!(
probe.maintenance_flag_source,
MaintenanceFlagSource::Unknown
);
}
}
#[test]
fn probe_host_status_reads_readiness_and_maintenance_off_the_live_slot() {
let cfg = resolved();
let exec = RecordingExecutor::new()
.with_stdout(
"detect-current",
status_probe_stdout("20260714T120000Z", 3001),
)
.with_stdout(
"probe-host-status",
format!(
"200\n---autumn-host-status---\nmaintenance-on\n---autumn-host-status---\n\n\
---autumn-host-status---\n{}\nmaintenance-on",
resolved().maintenance_flag_file()
),
);
let probe = probe_host_status(&cfg, 3000, &exec).unwrap();
assert_eq!(probe.ready_code, Some(200));
assert!(probe.shared_maintenance_flag);
assert_eq!(probe.maintenance, MaintenanceStatus::On);
assert_eq!(
probe.deploy.current_release_dir.as_deref(),
Some("/srv/autumn/myapp/releases/20260714T120000Z")
);
let shell = exec
.shell_for("probe-host-status")
.expect("status probe ran");
assert!(
shell.contains("http://127.0.0.1:3001/ready"),
"readiness is polled on the live slot's loopback port: {shell}"
);
assert!(shell.contains("-m 5"), "the curl must be bounded: {shell}");
assert!(
shell.contains("'/srv/autumn/myapp/shared/autumn-maintenance.json'"),
"the maintenance flag is read from the shared dir: {shell}"
);
assert!(
!shell.contains("/releases/"),
"the status probe must not look for a release-scoped flag: {shell}"
);
assert_eq!(
exec.run_labels(),
vec!["detect-current", "probe-host-status"],
"`deploy status` must never mutate a host"
);
}
#[test]
fn probe_host_status_degrades_to_unknown_readiness_rather_than_failing() {
let cfg = resolved();
for capture in [
"000\n---autumn-host-status---\n",
"---autumn-host-status---\n",
"",
] {
let exec = RecordingExecutor::new()
.with_stdout("detect-current", status_probe_stdout("r1", 3001))
.with_stdout("probe-host-status", capture);
let probe = probe_host_status(&cfg, 3000, &exec).unwrap();
assert_eq!(
probe.ready_code, None,
"capture {capture:?} must degrade to unknown readiness"
);
assert!(
!probe.shared_maintenance_flag,
"no sentinel means the shared flag is absent, never unknown-as-on"
);
assert_eq!(probe.maintenance, MaintenanceStatus::Unknown);
}
}
#[test]
fn probe_host_status_polls_the_slot_the_proxy_is_actually_serving() {
let cfg = resolved();
let drifted = format!(
"redeploy:blue\t3001\n---autumn-kamal-proxy-list---\n{}\n\
---autumn-kamal-proxy-unit---\n--http-port 3000\n\
---autumn-kamal-proxy-options---\n0\t\n\
---autumn-current-release---\n/srv/autumn/myapp/releases/r1",
proxy_list_serving("myapp", 3002)
);
let exec = RecordingExecutor::new()
.with_stdout("detect-current", drifted)
.with_stdout("probe-host-status", "200\n---autumn-host-status---\n");
let probe = probe_host_status(&cfg, 3000, &exec).unwrap();
let reconcile = probe
.reconcile(&cfg, 3000)
.expect("a redeploy host reconciles");
assert_eq!(reconcile.live_slot, SLOT_GREEN);
assert!(reconcile.repair, "the disagreement is reported as drift");
let shell = exec
.shell_for("probe-host-status")
.expect("status probe ran");
assert!(
shell.contains("http://127.0.0.1:3002/ready"),
"readiness must follow the PROXY's slot, not the marker's: {shell}"
);
}
#[test]
fn release_id_from_dir_takes_the_basename_and_rejects_junk() {
assert_eq!(
release_id_from_dir("/srv/autumn/myapp/releases/20260714T120000Z"),
Some("20260714T120000Z")
);
assert_eq!(
release_id_from_dir("/srv/autumn/myapp/releases/20260714T120000Z/"),
Some("20260714T120000Z")
);
assert_eq!(release_id_from_dir(""), None);
assert_eq!(release_id_from_dir("/"), None);
}
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\
---autumn-kamal-proxy-remove-help---\
Usage:\n kamal-proxy remove SERVICE [flags]\n"
}
#[test]
fn a_controller_with_no_compat_probe_is_never_gated_or_probed() {
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"))
}
fn deregister_op(&self, _service: &str) -> DeployOp {
DeployOp::Run(RemoteCommand::new("noop", "true"))
}
}
let exec = RecordingExecutor::new();
assert_eq!(
assess_proxy_readiness(&NoProbeController, &exec, ProxyProvisioning::Auto)
.expect("a controller with no probe is never gated"),
ProxyReadiness::Ready
);
assert!(
exec.run_labels().is_empty(),
"no probe declared → no remote command runs"
);
}
#[test]
fn a_drifted_cli_surface_fails_closed_with_the_flag_and_the_pin() {
let drifted = compatible_deploy_help().replace("--drain-timeout", "--drain-window");
let exec = RecordingExecutor::new().with_stdout("proxy-compat-probe", drifted);
let err = assess_proxy_readiness(&proxy(), &exec, ProxyProvisioning::Auto)
.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 pre_migrate_labels_match_both_builders() {
let first = sample_ops(Secret::new("AUTUMN_SECURITY__SIGNING_SECRET=x\n"));
let redeploy = sample_cutover_ops(Secret::new("AUTUMN_SECURITY__SIGNING_SECRET=x\n"));
for (path, ops) in [("first deploy", &first), ("redeploy", &redeploy)] {
let labels: Vec<&str> = ops.iter().map(DeployOp::label).collect();
let migrate = labels
.iter()
.position(|l| *l == "migrate")
.unwrap_or_else(|| panic!("{path} runs a migration: {labels:?}"));
for label in &labels[..migrate] {
assert!(
PRE_MIGRATE_LABELS.contains(label),
"{path}: `{label}` runs before `migrate` but is missing from \
PRE_MIGRATE_LABELS — a host that failed there would be reported as \
having moved the schema"
);
}
for label in &labels[migrate..] {
assert!(
!PRE_MIGRATE_LABELS.contains(label),
"{path}: `{label}` runs at or after `migrate` but is listed as \
PRE-migrate — a host that failed there would be reported as NOT \
having moved the schema, which is the dangerous direction"
);
}
}
assert!(failed_before_migrating("install-proxy"));
assert!(failed_before_migrating(LIVE_SLOT_REPAIR_LABEL));
assert!(!failed_before_migrating("readiness-gate"));
assert!(!failed_before_migrating("some-future-op"));
}
#[test]
fn a_compatible_host_needs_no_preparation_and_is_never_touched() {
let exec =
RecordingExecutor::new().with_stdout("proxy-compat-probe", compatible_deploy_help());
assert_eq!(
assess_proxy_readiness(&proxy(), &exec, ProxyProvisioning::Auto)
.expect("a compatible host passes"),
ProxyReadiness::Ready
);
assert_eq!(exec.run_labels(), vec!["proxy-compat-probe"]);
}
#[test]
fn a_bare_host_is_assessed_as_needing_preparation_without_being_mutated() {
let exec = RecordingExecutor::new(); assert_eq!(
assess_proxy_readiness(&proxy(), &exec, ProxyProvisioning::Auto)
.expect("a bare host is preparable"),
ProxyReadiness::NeedsInstall
);
assert_eq!(
exec.run_labels(),
vec!["proxy-compat-probe"],
"assessing a host must not mutate it"
);
}
#[test]
fn host_prep_never_replaces_a_working_but_drifted_binary() {
let drifted = compatible_deploy_help().replace("--drain-timeout", "--drain-window");
let exec = RecordingExecutor::new().with_stdout("proxy-compat-probe", drifted);
let err = assess_proxy_readiness(&proxy(), &exec, ProxyProvisioning::Auto)
.expect_err("CLI drift must fail closed");
assert!(matches!(err, DeployExecError::ProxyIncompatible { .. }));
}
#[test]
fn host_prep_can_be_declined_and_then_says_exactly_what_to_install() {
let exec = RecordingExecutor::new(); let err = assess_proxy_readiness(&proxy(), &exec, ProxyProvisioning::Disabled)
.expect_err("a missing binary with host prep declined must fail closed");
let DeployExecError::ProxyIncompatible { message } = err else {
panic!("expected ProxyIncompatible");
};
assert!(
message.contains("install_proxy"),
"names the opt-out that is in force: {message}"
);
assert!(
message.contains(super::super::proxy::KAMAL_PROXY_KNOWN_GOOD_VERSION),
"names the version to install: {message}"
);
}
#[test]
fn a_controller_that_cannot_prepare_a_host_reports_the_missing_binary() {
struct NoInstallerController(super::super::proxy::KamalProxyController);
impl ProxyController for NoInstallerController {
fn ensure_installed_ops(&self, public_port: u16) -> Vec<DeployOp> {
self.0.ensure_installed_ops(public_port)
}
fn route_op(&self, service: &str, upstream: &str) -> DeployOp {
self.0.route_op(service, upstream)
}
fn flip_op(&self, service: &str, new_upstream: &str) -> DeployOp {
self.0.flip_op(service, new_upstream)
}
fn deregister_op(&self, service: &str) -> DeployOp {
self.0.deregister_op(service)
}
fn compat_probe(&self) -> Option<super::super::proxy::ProxyCompatProbe> {
self.0.compat_probe()
}
}
let exec = RecordingExecutor::new();
let err = assess_proxy_readiness(
&NoInstallerController(proxy()),
&exec,
ProxyProvisioning::Auto,
)
.expect_err("a controller that cannot prepare a host must fail closed");
assert!(matches!(err, DeployExecError::ProxyIncompatible { .. }));
}
#[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, LIVE_SLOT_REPAIR_LABEL);
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,
MigrateStep::Run,
);
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(),
"-o".to_owned(),
"ConnectTimeout=10".to_owned(),
"-o".to_owned(),
"ServerAliveInterval=15".to_owned(),
"-o".to_owned(),
"ServerAliveCountMax=4".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(),
"-o".to_owned(),
"ConnectTimeout=10".to_owned(),
"-o".to_owned(),
"ServerAliveInterval=15".to_owned(),
"-o".to_owned(),
"ServerAliveCountMax=4".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(), 17, "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"
);
}
#[test]
fn no_data_link_op_without_a_sqlite_data_file() {
assert!(sqlite_data_link_op(&resolved(), RELEASE_DIR).is_none());
let labels: Vec<&str> = sample_ops(Secret::new("X=1\n"))
.iter()
.map(DeployOp::label)
.collect();
assert!(!labels.contains(&"link-data"), "{labels:?}");
}
#[test]
fn the_data_link_op_points_the_release_at_the_shared_file() {
let cfg = resolved_sqlite();
let op = sqlite_data_link_op(&cfg, RELEASE_DIR).expect("a SQLite app links its data file");
assert_eq!(op.label, "link-data");
assert!(
op.shell.contains(
"ln -s '/srv/autumn/myapp/shared/data/app.db' \
'/srv/autumn/myapp/releases/20260714T120000Z/app.db'"
),
"the release path must be a link to the shared file: {}",
op.shell
);
assert!(
op.shell
.contains("mkdir -p '/srv/autumn/myapp/shared/data'"),
"{}",
op.shell
);
assert!(
op.shell
.contains("rm -f '/srv/autumn/myapp/releases/20260714T120000Z/app.db'"),
"{}",
op.shell
);
}
#[test]
fn the_data_link_op_refuses_to_relocate_a_live_database() {
let op = sqlite_data_link_op(&resolved_sqlite(), RELEASE_DIR).expect("linked");
assert!(
op.shell
.contains("[ -e '/srv/autumn/myapp/current/app.db' ]"),
"the refusal must fire when the current release still holds a database: {}",
op.shell
);
assert!(
!op.shell
.contains("if [ ! -e '/srv/autumn/myapp/shared/data/app.db' ] &&"),
"the shared file existing must not disable the refusal: {}",
op.shell
);
assert!(
op.shell.contains("exit 1"),
"it must stop the deploy: {}",
op.shell
);
assert!(
op.shell.contains(
r"systemctl stop '\''myapp-blue.service'\'' '\''myapp-green.service'\'' &&"
),
"the message must name the units to stop: {}",
op.shell
);
assert!(
op.shell.contains(
r"mv '\''/srv/autumn/myapp/current/app.db'\'' '\''/srv/autumn/myapp/shared/data/app.db'\''"
),
"the message must name the move, sidecars included: {}",
op.shell
);
assert!(
!op.shell
.contains("mv '/srv/autumn/myapp/current/app.db' '/srv/autumn/myapp/shared"),
"the op must never relocate the live database itself: {}",
op.shell
);
}
#[test]
fn the_data_link_op_never_expands_a_configured_path() {
let hostile = resolved().with_sqlite_data_placement(SqliteDataPlacement::Relative(
"$(touch pwned).db".to_owned(),
));
let op = sqlite_data_link_op(&hostile, RELEASE_DIR).expect("linked");
let guard = format!(
r#""$(readlink {})""#,
shell_quote("/srv/autumn/myapp/current/$(touch pwned).db")
);
let elsewhere = op.shell.replace(&guard, "");
for (index, _) in elsewhere.match_indices('"') {
assert!(
inside_single_quotes(&elsewhere, index),
"a double quote outside single quotes makes paths expandable: {elsewhere}"
);
}
assert!(
op.shell.contains(&guard),
"the dangling-link guard must quote its path: {}",
op.shell
);
for (index, _) in op.shell.match_indices("$(touch pwned)") {
assert!(
inside_single_quotes(&op.shell, index),
"every occurrence must sit inside a single-quoted word: {}",
op.shell
);
}
assert_eq!(op.shell.matches("for s in -wal -shm -journal").count(), 1);
}
fn inside_single_quotes(shell: &str, index: usize) -> bool {
let mut quoted = false;
let mut chars = shell.char_indices();
while let Some((i, c)) = chars.next() {
if i >= index {
break;
}
if quoted {
if c == '\'' {
quoted = false;
}
} else if c == '\\' {
chars.next();
} else if c == '\'' {
quoted = true;
}
}
quoted
}
#[test]
fn the_data_link_op_prints_a_recovery_command_that_is_safe_to_paste() {
let hostile = resolved().with_sqlite_data_placement(SqliteDataPlacement::Relative(
"$(touch pwned).db".to_owned(),
));
let op = sqlite_data_link_op(&hostile, RELEASE_DIR).expect("linked");
assert!(
op.shell
.contains(r"mv '\''/srv/autumn/myapp/current/$(touch pwned).db'\'' "),
"the pasted `mv` must carry the path as a quoted word: {}",
op.shell
);
let spaced = resolved()
.with_sqlite_data_placement(SqliteDataPlacement::Relative("app data.db".to_owned()));
let op = sqlite_data_link_op(&spaced, RELEASE_DIR).expect("linked");
assert!(
op.shell
.contains(r"mv '\''/srv/autumn/myapp/current/app data.db'\'' "),
"the source must be one quoted word: {}",
op.shell
);
}
#[test]
fn the_data_link_op_refuses_a_legacy_symlinked_database() {
let op = sqlite_data_link_op(&resolved_sqlite(), RELEASE_DIR).expect("linked");
assert!(
op.shell.contains(
"if { [ -e '/srv/autumn/myapp/current/app.db' ] || \
[ -L '/srv/autumn/myapp/current/app.db' ]; }"
),
"a dangling `current` symlink must count as occupied: {}",
op.shell
);
assert!(
op.shell.contains(
"[ \"$(readlink '/srv/autumn/myapp/current/app.db')\" = \
'/srv/autumn/myapp/shared/data/app.db' ]"
),
"our own link to the shared file must stay exempt: {}",
op.shell
);
assert!(
!op.shell
.contains("[ ! -L '/srv/autumn/myapp/current/app.db' ]; then"),
"the symlink case must not be excluded from the refusal: {}",
op.shell
);
assert!(
op.shell
.contains("is a symlink to a SQLite database that is not"),
"the symlink case needs its own refusal: {}",
op.shell
);
assert!(
op.shell
.contains(r"src=$(readlink -f '\''/srv/autumn/myapp/current/app.db'\'') && ")
&& op
.shell
.contains(r#"mv "$src" '\''/srv/autumn/myapp/shared/data/app.db'\''"#),
"the symlink recovery must move the target to the exact shared path: {}",
op.shell
);
for suffix in ["-wal", "-shm", "-journal"] {
assert!(
op.shell.contains(&format!(
r#"{{ [ ! -e "$src"{suffix} ] || mv "$src"{suffix} '\''/srv/autumn/myapp/shared/data/app.db'\''{suffix}; }} && "#
)),
"{suffix} must move to the matching shared name, gating what follows: {}",
op.shell
);
}
}
#[test]
fn both_recoveries_move_the_sidecars_before_the_database() {
let op = sqlite_data_link_op(&resolved_sqlite(), RELEASE_DIR).expect("linked");
let shared = r"'\''/srv/autumn/myapp/shared/data/app.db'\''";
let recoveries: Vec<&str> = op
.shell
.match_indices("Run this on the host once")
.map(|(index, _)| {
let rest = op.shell.get(index..).unwrap_or_default();
rest.split_once("' >&2").map_or(rest, |(line, _)| line)
})
.collect();
assert_eq!(recoveries.len(), 2, "both refusals print one: {}", op.shell);
for recovery in recoveries {
let database = recovery
.match_indices(shared)
.map(|(index, _)| index)
.find(|index| {
!recovery
.get(index + shared.len()..)
.is_some_and(|rest| rest.starts_with('-'))
})
.unwrap_or_else(|| panic!("no database move in: {recovery}"));
for suffix in ["-wal", "-shm", "-journal"] {
let sidecar = recovery
.find(&format!("{shared}{suffix}"))
.unwrap_or_else(|| panic!("{suffix} is not moved by: {recovery}"));
assert!(
sidecar < database,
"{suffix} must move before the database: {recovery}"
);
}
assert!(
!recovery.contains("for s in"),
"each sidecar must gate the next on its own: {recovery}"
);
assert!(
!recovery.contains("exit "),
"a pasted recovery must never exit the operator's shell: {recovery}"
);
let stop = recovery.find("systemctl stop").expect("a stop");
let rest = recovery.get(stop..).unwrap_or_default();
assert!(
rest.find("&&").unwrap_or(usize::MAX) < rest.find(';').unwrap_or(usize::MAX),
"the stop must gate the move with `&&` before any `;`: {recovery}"
);
assert!(
!recovery.contains("app.db*") && !recovery.contains(&format!("{shared}*")),
"sidecars must be named, not globbed: {recovery}"
);
}
}
#[cfg(target_os = "linux")]
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum Current {
Absent,
LinkToShared,
LinkElsewhere,
RealFile,
}
#[cfg(target_os = "linux")]
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum Verdict {
Proceed,
Refuse,
}
#[cfg(target_os = "linux")]
type LinkDataRow = (Current, bool, bool, Verdict, &'static str);
#[cfg(target_os = "linux")]
fn build_link_data_layout(
root: &Path,
current: Current,
shared_exists: bool,
marker: bool,
) -> PathBuf {
let releases = root.join("releases");
let previous = releases.join("prev");
let release = releases.join("new");
std::fs::create_dir_all(&release).expect("release dir");
std::fs::create_dir_all(&previous).expect("previous release dir");
let shared_dir = root.join("shared");
let shared_file = shared_dir.join("data/app.db");
std::fs::create_dir_all(shared_dir.join("data")).expect("shared dir");
if shared_exists {
std::fs::write(&shared_file, b"SHARED").expect("shared db");
}
if marker {
std::fs::write(shared_dir.join("sqlite-data-adopted"), b"").expect("marker");
}
std::fs::write(root.join("operator.db"), b"OPERATOR").expect("operator db");
if current != Current::Absent {
let at = previous.join("app.db");
match current {
Current::LinkToShared => {
std::os::unix::fs::symlink(&shared_file, &at).expect("link to shared");
}
Current::LinkElsewhere => {
std::os::unix::fs::symlink(root.join("operator.db"), &at)
.expect("link elsewhere");
}
Current::RealFile => std::fs::write(&at, b"LIVE").expect("live db"),
Current::Absent => unreachable!(),
}
std::os::unix::fs::symlink(&previous, root.join("current")).expect("current symlink");
}
release
}
#[cfg(target_os = "linux")]
fn check_link_data_row(root: &Path, row: LinkDataRow) {
let (current, shared_exists, marker, want, why) = row;
let release = build_link_data_layout(root, current, shared_exists, marker);
let shared_file = root.join("shared/data/app.db");
let elsewhere = root.join("operator.db");
let mut cfg = resolved_sqlite();
cfg.app_dir = root.to_str().expect("utf-8 temp dir").to_owned();
let op = sqlite_data_link_op(&cfg, release.to_str().expect("utf-8")).expect("linked");
let out = std::process::Command::new("sh")
.arg("-c")
.arg(&op.shell)
.output()
.expect("run link-data");
let got = if out.status.success() {
Verdict::Proceed
} else {
Verdict::Refuse
};
assert_eq!(
got,
want,
"{current:?} + shared={shared_exists} + marker={marker} ({why}): {}",
String::from_utf8_lossy(&out.stderr)
);
if want == Verdict::Proceed {
assert_eq!(
std::fs::read_link(release.join("app.db")).ok().as_deref(),
Some(shared_file.as_path()),
"{why}: the release must be linked at the shared file"
);
if shared_exists {
assert_eq!(
std::fs::read(&shared_file).expect("shared db"),
b"SHARED",
"{why}: the shared database must be left exactly as it was"
);
}
} else {
assert!(
!release.join("app.db").exists(),
"{why}: a refusal must not link anything"
);
assert_eq!(
std::fs::read(&elsewhere).expect("operator db"),
b"OPERATOR",
"{why}: a refusal must not touch the operator's database"
);
assert!(
!String::from_utf8_lossy(&out.stderr).is_empty(),
"{why}: a refusal must say why"
);
}
}
#[cfg(target_os = "linux")]
#[test]
fn the_link_op_decides_every_state_of_current_and_the_shared_file() {
use Current::{Absent, LinkElsewhere, LinkToShared, RealFile};
use Verdict::{Proceed, Refuse};
let table: [LinkDataRow; 9] = [
(Absent, false, false, Proceed, "a genuine first deploy"),
(
LinkToShared,
false,
false,
Proceed,
"a retry after a first deploy that failed before the migration",
),
(
LinkToShared,
false,
true,
Refuse,
"the database existed and is gone: an unmounted volume (#2589 item 17)",
),
(LinkToShared, true, true, Proceed, "already adopted"),
(
LinkToShared,
true,
false,
Proceed,
"adopted before the marker existed",
),
(
LinkElsewhere,
true,
true,
Refuse,
"an operator database, silently swapped for the shared one (#2589 item 8)",
),
(
LinkElsewhere,
false,
false,
Refuse,
"an operator database on a mount that is away",
),
(
RealFile,
true,
true,
Refuse,
"a live pre-#1909 database, swapped on rollback (#2589 item 8)",
),
(RealFile, false, false, Refuse, "a live pre-#1909 database"),
];
for (index, row) in table.into_iter().enumerate() {
let root = std::env::temp_dir()
.join(format!("autumn-link-data-{}-{index}", std::process::id()));
std::fs::remove_dir_all(&root).ok();
check_link_data_row(&root, row);
std::fs::remove_dir_all(&root).ok();
}
}
#[cfg(target_os = "linux")]
#[test]
fn seeing_the_shared_database_records_the_marker_outside_the_data_mount() {
let cfg = resolved_sqlite();
assert_eq!(
cfg.sqlite_data_marker_file(),
"/srv/autumn/myapp/shared/sqlite-data-adopted",
"a marker under shared/data would vanish with the mount it reports on"
);
let root = std::env::temp_dir().join(format!("autumn-marker-{}", std::process::id()));
std::fs::remove_dir_all(&root).ok();
let release = root.join("releases/new");
std::fs::create_dir_all(&release).expect("release dir");
let shared_data = root.join("shared/data");
std::fs::create_dir_all(&shared_data).expect("shared dir");
std::fs::write(shared_data.join("app.db"), b"SHARED").expect("shared db");
let mut cfg = resolved_sqlite();
cfg.app_dir = root.to_str().expect("utf-8 temp dir").to_owned();
let op = sqlite_data_link_op(&cfg, release.to_str().unwrap()).expect("linked");
assert!(
std::process::Command::new("sh")
.arg("-c")
.arg(&op.shell)
.status()
.expect("run link-data")
.success()
);
assert!(
root.join("shared/sqlite-data-adopted").exists(),
"the marker must be recorded once the database is seen"
);
std::fs::remove_dir_all(&root).ok();
}
#[test]
fn the_marker_is_recorded_after_the_migration_on_both_deploy_paths() {
let cfg = resolved_sqlite();
let plan = SlotPlan {
live_slot: SLOT_GREEN,
live_port: 3002,
candidate_slot: SLOT_BLUE,
candidate_port: 3001,
public_port: 3000,
};
let unit = super::super::render_app_unit(&cfg, RELEASE_DIR, 3001, SLOT_BLUE);
for (path, ops) in [
(
"first deploy",
first_deploy_ops(
&cfg,
&proxy(),
&unit,
Secret::new("X=1\n"),
Path::new("/tmp/app"),
&[],
RELEASE_ID,
&plan,
MigrateStep::Run,
),
),
(
"redeploy",
cutover_ops(
&cfg,
&proxy(),
&unit,
Secret::new("X=1\n"),
Path::new("/tmp/app"),
&[],
RELEASE_ID,
&plan,
&ProxyServiceOptions {
tls: false,
host: None,
},
MigrateStep::Run,
),
),
] {
let labels: Vec<&str> = ops.iter().map(DeployOp::label).collect();
let migrate = labels
.iter()
.position(|l| *l == "migrate")
.unwrap_or_else(|| panic!("{path}: no migrate step: {labels:?}"));
let record = labels
.iter()
.position(|l| *l == "record-data-adopted")
.unwrap_or_else(|| panic!("{path}: the marker is never recorded: {labels:?}"));
assert!(
migrate < record,
"{path}: the marker must be recorded after the migration creates \
the database: {labels:?}"
);
}
let op = sqlite_data_adopted_op(&cfg).expect("a SQLite app records the marker");
assert!(
op.shell
.contains("if [ -e '/srv/autumn/myapp/shared/data/app.db' ]"),
"{}",
op.shell
);
assert!(
op.shell
.contains(": > '/srv/autumn/myapp/shared/sqlite-data-adopted'"),
"{}",
op.shell
);
assert!(sqlite_data_adopted_op(&resolved()).is_none());
}
#[cfg(target_os = "linux")]
#[test]
fn an_absolute_database_in_shared_data_refuses_a_missing_volume() {
let table = [
(
false,
false,
true,
"a genuine first deploy: nothing has been created yet",
),
(true, true, true, "already adopted and present"),
(true, false, true, "adopted before the marker existed"),
(
false,
true,
false,
"it existed and is gone — an unmounted volume, not a first deploy",
),
];
for (index, (db_exists, marker, may_proceed, why)) in table.into_iter().enumerate() {
let root =
std::env::temp_dir().join(format!("autumn-absvol-{}-{index}", std::process::id()));
std::fs::remove_dir_all(&root).ok();
let app = root.join("srv/myapp");
std::fs::create_dir_all(app.join("shared")).expect("shared dir");
let db = app.join("shared/data/app.db");
if db_exists {
std::fs::create_dir_all(app.join("shared/data")).expect("data dir");
std::fs::write(&db, b"REAL").expect("db");
}
if marker {
std::fs::write(app.join("shared/sqlite-data-adopted"), b"").expect("marker");
}
let mut cfg = resolved();
cfg.app_dir = app.to_str().expect("utf-8").to_owned();
cfg.sqlite_data =
SqliteDataPlacement::Persistent(db.to_str().expect("utf-8").to_owned());
let op = sqlite_data_dir_guard_op(&cfg).expect("verified");
let out = std::process::Command::new("sh")
.arg("-c")
.arg(&op.shell)
.output()
.expect("run check-data-dir");
assert_eq!(
out.status.success(),
may_proceed,
"db={db_exists} marker={marker} ({why}): {}",
String::from_utf8_lossy(&out.stderr)
);
if may_proceed {
assert!(
app.join("shared/data").is_dir(),
"{why}: the parent must be created so the migration can open it"
);
} else {
assert!(
!app.join("shared/data").exists(),
"{why}: the absent mount point must not be recreated"
);
assert!(
!String::from_utf8_lossy(&out.stderr).is_empty(),
"{why}: a refusal must say why"
);
}
std::fs::remove_dir_all(&root).ok();
}
}
#[test]
fn the_marker_covers_an_absolute_database_in_shared_data_too() {
let mut cfg = resolved();
cfg.sqlite_data =
SqliteDataPlacement::Persistent("/srv/autumn/myapp/shared/data/app.db".to_owned());
let op = sqlite_data_adopted_op(&cfg).expect("an in-namespace database is recorded");
assert!(
op.shell
.contains("if [ -e '/srv/autumn/myapp/shared/data/app.db' ]")
&& op
.shell
.contains(": > '/srv/autumn/myapp/shared/sqlite-data-adopted'"),
"{}",
op.shell
);
cfg.sqlite_data = SqliteDataPlacement::Persistent("/var/lib/myapp/app.db".to_owned());
assert!(sqlite_data_adopted_op(&cfg).is_none());
assert!(sqlite_data_adopted_op(&resolved()).is_none());
}
#[cfg(target_os = "linux")]
#[test]
fn the_persistent_data_guard_resolves_a_symlinked_app_dir_on_the_host() {
let root = std::env::temp_dir().join(format!("autumn-datadir-{}", std::process::id()));
std::fs::remove_dir_all(&root).ok();
let real = root.join("mnt/apps/myapp");
std::fs::create_dir_all(real.join("releases/r1")).expect("release dir");
std::fs::create_dir_all(real.join("shared/data")).expect("shared dir");
let link = root.join("srv-myapp");
std::fs::create_dir_all(root.join("srv")).ok();
std::os::unix::fs::symlink(&real, &link).expect("symlinked app dir");
let inside = real.join("releases/r1/app.db");
let bare_inside = real.join("app.db");
let shared = real.join("shared/data/app.db");
let outside = root.join("var/lib/app.db");
std::fs::create_dir_all(root.join("var/lib")).expect("operator dir");
std::fs::write(&inside, b"LIVE").expect("live db");
let linked = root.join("var/lib/linked.db");
std::os::unix::fs::symlink(&inside, &linked).expect("symlinked database");
for (path, refuse) in [
(&inside, true),
(&linked, true),
(&bare_inside, true),
(&shared, false),
(&outside, false),
] {
let mut cfg = resolved();
cfg.app_dir = link.to_str().expect("utf-8 temp dir").to_owned();
cfg.sqlite_data =
SqliteDataPlacement::Persistent(path.to_str().expect("utf-8").to_owned());
let op = sqlite_data_dir_guard_op(&cfg).expect("a persistent file is verified");
assert_eq!(op.label, "check-data-dir");
let out = std::process::Command::new("sh")
.arg("-c")
.arg(&op.shell)
.output()
.expect("run check-data-dir");
assert_eq!(
out.status.success(),
!refuse,
"{}: {}",
path.display(),
String::from_utf8_lossy(&out.stderr)
);
}
assert!(sqlite_data_dir_guard_op(&resolved()).is_none());
assert!(sqlite_data_dir_guard_op(&resolved_sqlite()).is_none());
std::fs::remove_dir_all(&root).ok();
}
#[cfg(target_os = "linux")]
#[test]
fn the_persistent_data_guard_creates_a_shared_data_parent_but_not_the_operators() {
let root = std::env::temp_dir().join(format!("autumn-mkdata-{}", std::process::id()));
std::fs::remove_dir_all(&root).ok();
let app = root.join("srv/myapp");
std::fs::create_dir_all(app.join("shared")).expect("shared dir");
std::fs::create_dir_all(root.join("var/lib")).expect("operator dir");
let recommended = app.join("shared/data/app.db");
let mut cfg = resolved();
cfg.app_dir = app.to_str().expect("utf-8").to_owned();
cfg.sqlite_data =
SqliteDataPlacement::Persistent(recommended.to_str().expect("utf-8").to_owned());
let op = sqlite_data_dir_guard_op(&cfg).expect("verified");
assert!(
std::process::Command::new("sh")
.arg("-c")
.arg(&op.shell)
.status()
.expect("run check-data-dir")
.success()
);
assert!(
app.join("shared/data").is_dir(),
"the recommended placement must have its parent created, or the \
migration cannot open the database"
);
let theirs = root.join("var/lib/nested/app.db");
cfg.sqlite_data =
SqliteDataPlacement::Persistent(theirs.to_str().expect("utf-8").to_owned());
let op = sqlite_data_dir_guard_op(&cfg).expect("verified");
assert!(
std::process::Command::new("sh")
.arg("-c")
.arg(&op.shell)
.status()
.expect("run check-data-dir")
.success()
);
assert!(
!root.join("var/lib/nested").exists(),
"the deploy must not reach past its own namespace to create directories"
);
std::fs::remove_dir_all(&root).ok();
}
#[test]
fn the_data_link_op_never_deletes_a_real_database_file() {
let op = sqlite_data_link_op(&resolved_sqlite(), RELEASE_DIR).expect("linked");
assert!(
!op.shell.contains("rm -rf"),
"no recursive delete may touch a database path: {}",
op.shell
);
assert!(
op.shell.contains(
"if [ -e '/srv/autumn/myapp/releases/20260714T120000Z/app.db' ] && \
[ ! -L '/srv/autumn/myapp/releases/20260714T120000Z/app.db' ]"
),
"a real file must be distinguished from a stale link: {}",
op.shell
);
assert!(
op.shell.contains(
"mv -f '/srv/autumn/myapp/releases/20260714T120000Z/app.db' \
'/srv/autumn/myapp/shared/data/app.db.superseded'"
),
"a real file must be moved aside into shared/: {}",
op.shell
);
assert!(
op.shell
.contains("if [ -e '/srv/autumn/myapp/shared/data/app.db.superseded' ]"),
"an existing superseded copy must be refused, not overwritten: {}",
op.shell
);
assert!(
op.shell.contains("already exists"),
"and it must say so: {}",
op.shell
);
let superseded = "'/srv/autumn/myapp/shared/data/app.db.superseded'";
let sidecars = op
.shell
.find(&format!("{superseded}$s"))
.expect("the sidecar move");
let database = op
.shell
.find(&format!(
"mv -f '/srv/autumn/myapp/releases/20260714T120000Z/app.db' {superseded}"
))
.expect("the database move");
assert!(
sidecars < database,
"the sidecars must be set aside before the database: {}",
op.shell
);
}
#[test]
fn the_data_link_op_moves_every_sidecar_kind() {
let op = sqlite_data_link_op(&resolved_sqlite(), RELEASE_DIR).expect("linked");
assert!(
op.shell.contains("for s in -wal -shm -journal"),
"the move-aside step must cover every sidecar: {}",
op.shell
);
}
#[test]
fn every_release_root_payload_is_refused_as_a_database_path() {
let manifests = [
ManifestUpload {
local: PathBuf::from("/tmp/autumn.toml"),
remote_basename: "autumn.toml".to_owned(),
},
ManifestUpload {
local: PathBuf::from("/tmp/autumn-prod.toml"),
remote_basename: "autumn-prod.toml".to_owned(),
},
ManifestUpload {
local: PathBuf::from("/tmp/prod.lock"),
remote_basename: "prod.lock".to_owned(),
},
];
let cfg = resolved_sqlite().with_release_payloads(
manifests
.iter()
.map(|m| m.remote_basename.clone())
.collect(),
);
let plan = SlotPlan {
live_slot: SLOT_GREEN,
live_port: 3002,
candidate_slot: SLOT_BLUE,
candidate_port: 3001,
public_port: 3000,
};
let unit = super::super::render_app_unit(&cfg, RELEASE_DIR, 3001, SLOT_BLUE);
let ops = first_deploy_ops(
&cfg,
&proxy(),
&unit,
Secret::new("X=1\n"),
Path::new("/tmp/app"),
&manifests,
RELEASE_ID,
&plan,
MigrateStep::Run,
);
let root = format!("{RELEASE_DIR}/");
let payloads: Vec<String> = ops
.iter()
.filter_map(|op| match op {
DeployOp::UploadFile { remote_path, .. }
| DeployOp::WriteFile { remote_path, .. } => Some(remote_path.clone()),
DeployOp::Run(_) => None,
})
.filter_map(|path| path.strip_prefix(&root).map(str::to_owned))
.filter(|rest| !rest.contains('/'))
.collect();
assert!(
payloads.contains(&"myapp".to_owned()),
"the binary must be among the release-root payloads: {payloads:?}"
);
assert!(
payloads.len() > manifests.len(),
"expected the binary and every manifest: {payloads:?}"
);
for payload in &payloads {
let url = format!("sqlite://{payload}");
assert!(
matches!(
super::super::classify_sqlite_data_file(Some(&url), &cfg),
super::super::SqliteDataFile::Refused(_)
),
"{payload} is written into the release root, so a database at that \
path would be truncated by the upload — it must be refused"
);
}
}
#[test]
fn the_data_link_precedes_the_migration_on_both_deploy_paths() {
let cfg = resolved_sqlite();
let plan = SlotPlan {
live_slot: SLOT_GREEN,
live_port: 3002,
candidate_slot: SLOT_BLUE,
candidate_port: 3001,
public_port: 3000,
};
let unit = super::super::render_app_unit(&cfg, RELEASE_DIR, 3001, SLOT_BLUE);
for (path, ops) in [
(
"first deploy",
first_deploy_ops(
&cfg,
&proxy(),
&unit,
Secret::new("X=1\n"),
Path::new("/tmp/app"),
&[],
RELEASE_ID,
&plan,
MigrateStep::Run,
),
),
(
"redeploy",
cutover_ops(
&cfg,
&proxy(),
&unit,
Secret::new("X=1\n"),
Path::new("/tmp/app"),
&[],
RELEASE_ID,
&plan,
&ProxyServiceOptions {
tls: false,
host: None,
},
MigrateStep::Run,
),
),
] {
let labels: Vec<&str> = ops.iter().map(DeployOp::label).collect();
let link = labels
.iter()
.position(|l| *l == "link-data")
.unwrap_or_else(|| panic!("{path}: no link-data op in {labels:?}"));
let prepare = labels
.iter()
.position(|l| *l == "prepare-dirs")
.expect("prepare-dirs");
let migrate = labels
.iter()
.position(|l| *l == "migrate")
.expect("migrate");
assert!(
prepare < link && link < migrate,
"{path}: the link must sit between prepare-dirs and migrate: {labels:?}"
);
}
}
#[test]
fn rollback_relinks_the_target_release_before_starting_it() {
let target = RollbackTarget {
release_dir: "/srv/autumn/myapp/releases/20260713T120000Z".to_owned(),
slot: SLOT_GREEN,
port: 3002,
};
let ops = rollback_ops(&resolved_sqlite(), &proxy(), &target);
let labels: Vec<&str> = ops.iter().map(DeployOp::label).collect();
let link = labels
.iter()
.position(|l| *l == "link-data")
.unwrap_or_else(|| panic!("no link-data op in {labels:?}"));
let start = labels
.iter()
.position(|l| *l == "restart-previous")
.expect("restart-previous");
assert!(
link < start,
"the link must precede the restart: {labels:?}"
);
assert!(
ops.iter().any(|op| matches!(
op,
DeployOp::Run(c)
if c.shell
.contains("'/srv/autumn/myapp/releases/20260713T120000Z/app.db'")
)),
"the rollback must link the TARGET release dir, not the current one"
);
let plain: Vec<&str> = rollback_ops(&resolved(), &proxy(), &target)
.iter()
.map(DeployOp::label)
.collect();
assert!(!plain.contains(&"link-data"), "{plain:?}");
}
#[test]
fn pruning_a_release_never_follows_the_data_symlink() {
let shell = prune_releases_shell(
"/srv/autumn/myapp/releases",
"/srv/autumn/myapp/current",
"/srv/autumn/myapp/shared/previous-release",
3,
);
assert!(shell.contains("rm -rf"), "{shell}");
for follows in ["-follow", "-L ", "--dereference", "cp -L"] {
assert!(
!shell.contains(follows),
"the prune must not follow symlinks ({follows}): {shell}"
);
}
}
}