use super::exec::{DeployOp, FileContents, ProxyServiceOptions, RemoteCommand, shell_quote};
const KAMAL_PROXY_BIN: &str = "/usr/local/bin/kamal-proxy";
pub const KAMAL_PROXY_UNIT_PATH: &str = "/etc/systemd/system/kamal-proxy.service";
pub const KAMAL_PROXY_KNOWN_GOOD_VERSION: &str = "v0.9.2";
pub const KAMAL_PROXY_KNOWN_GOOD_DIGEST: &str =
"sha256:826a6f66c6ba26ac26197ac8755804403c9bb617b90cfac25c7972154c5328ab";
const REQUIRED_DEPLOY_FLAGS: &[&str] = &[
"--target",
"--health-check-path",
"--deploy-timeout",
"--drain-timeout",
];
const REQUIRED_TLS_DEPLOY_FLAGS: &[&str] = &["--host", "--tls"];
const REQUIRED_REREGISTER_FLAGS: &[&str] = &["--force"];
pub struct ProxyCompatProbe {
pub command: RemoteCommand,
verdict: CompatVerdict,
}
type CompatVerdict = Box<dyn Fn(&str) -> Result<(), ProxyCompatFailure> + Send + Sync>;
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ProxyCompatFailure {
pub message: String,
pub binary_missing: bool,
}
impl ProxyCompatProbe {
#[must_use]
pub fn new(
command: RemoteCommand,
verdict: impl Fn(&str) -> Result<(), ProxyCompatFailure> + Send + Sync + 'static,
) -> Self {
Self {
command,
verdict: Box::new(verdict),
}
}
pub fn assess(&self, output: &str) -> Result<(), ProxyCompatFailure> {
(self.verdict)(output)
}
}
impl std::fmt::Debug for ProxyCompatProbe {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("ProxyCompatProbe")
.field("command", &self.command)
.finish_non_exhaustive()
}
}
pub trait ProxyController {
fn ensure_installed_ops(&self, public_port: u16) -> Vec<DeployOp>;
fn refresh_installed_ops(
&self,
public_port: u16,
service: &str,
live_upstream: &str,
snapshot_path: &str,
reregister_options: &ProxyServiceOptions,
) -> Vec<DeployOp> {
let _ = (service, live_upstream, snapshot_path, reregister_options);
self.ensure_installed_ops(public_port)
}
fn proxy_service_options(&self) -> ProxyServiceOptions {
ProxyServiceOptions {
tls: false,
host: None,
}
}
fn route_op(&self, service: &str, upstream: &str) -> DeployOp;
fn flip_op(&self, service: &str, new_upstream: &str) -> DeployOp;
fn compat_probe(&self) -> Option<ProxyCompatProbe> {
None
}
fn binary_install_ops(&self) -> Option<Vec<DeployOp>> {
None
}
fn binary_install_note(&self) -> String {
"installing the reverse-proxy binary".to_owned()
}
}
#[derive(Debug, Clone)]
pub struct KamalProxyController {
health_check_path: String,
deploy_timeout_secs: u64,
drain_timeout_secs: u64,
tls_host: Option<String>,
}
const DEFAULT_DRAIN_TIMEOUT_SECS: u64 = 30;
impl KamalProxyController {
#[must_use]
pub fn new(readiness_timeout_secs: u64) -> Self {
Self {
health_check_path: "/ready".to_owned(),
deploy_timeout_secs: readiness_timeout_secs,
drain_timeout_secs: DEFAULT_DRAIN_TIMEOUT_SECS,
tls_host: None,
}
}
#[must_use]
pub fn with_tls_host(mut self, tls_host: Option<String>) -> Self {
self.tls_host = tls_host;
self
}
fn deploy_shell(&self, service: &str, target: &str, force: bool) -> String {
self.deploy_shell_with_tls(service, target, force, self.tls_host.as_deref())
}
fn deploy_shell_with_tls(
&self,
service: &str,
target: &str,
force: bool,
tls_host: Option<&str>,
) -> String {
let tls = tls_host.map_or_else(String::new, |host| {
format!("--host {host} --tls ", host = shell_quote(host))
});
let force_flag = if force { " --force" } else { "" };
format!(
"env -u XDG_RUNTIME_DIR kamal-proxy deploy {service} --target {target} \
--health-check-path {path} {tls}--deploy-timeout {deploy}s \
--drain-timeout {drain}s{force_flag}",
service = shell_quote(service),
target = shell_quote(target),
path = shell_quote(&self.health_check_path),
deploy = self.deploy_timeout_secs,
drain = self.drain_timeout_secs,
)
}
fn install_shell() -> String {
let pin = KAMAL_PROXY_KNOWN_GOOD_VERSION;
let digest = KAMAL_PROXY_KNOWN_GOOD_DIGEST;
let bin = KAMAL_PROXY_BIN;
format!(
"( set -e; \
if [ -e '{bin}' ] || [ -L '{bin}' ]; then \
echo 'kamal-proxy is already installed at {bin}; refusing to replace it' >&2; \
exit 1; \
fi; \
need=''; \
command -v curl >/dev/null 2>&1 || need=\"$need curl\"; \
command -v docker >/dev/null 2>&1 || need=\"$need docker.io\"; \
if [ -n \"$need\" ]; then \
export DEBIAN_FRONTEND=noninteractive; \
apt-get update -qq; \
apt-get install -y -qq --no-install-recommends $need; \
fi; \
systemctl start docker >/dev/null 2>&1 || true; \
cid=$(docker create 'basecamp/kamal-proxy:{pin}@{digest}'); \
docker cp \"$cid:/usr/local/bin/kamal-proxy\" '{bin}.autumn-new'; \
docker rm -f \"$cid\" >/dev/null 2>&1 || true; \
chmod 755 '{bin}.autumn-new'; \
mv -f '{bin}.autumn-new' '{bin}'; \
'{bin}' deploy --help >/dev/null 2>&1 || {{ \
echo 'the installed kamal-proxy does not run on this host' >&2; exit 1; }} \
) || {{ \
echo 'autumn deploy could not prepare this host with kamal-proxy {pin}. It needs \
an apt-based (Debian/Ubuntu) host, outbound HTTPS to the distro mirrors and to Docker Hub, and \
an SSH user that can install packages. Install kamal-proxy {pin} at {bin} yourself and set \
`[deploy] install_proxy = false` in autumn.toml to skip this step.' >&2; \
exit 1; }}"
)
}
#[must_use]
pub fn compat_probe_command() -> RemoteCommand {
RemoteCommand::new(
"proxy-compat-probe",
"kamal-proxy deploy --help 2>&1 || true",
)
}
#[must_use]
pub fn required_deploy_flags(&self) -> Vec<&'static str> {
let mut flags = REQUIRED_DEPLOY_FLAGS.to_vec();
if self.tls_host.is_some() {
flags.extend_from_slice(REQUIRED_TLS_DEPLOY_FLAGS);
}
flags.extend_from_slice(REQUIRED_REREGISTER_FLAGS);
flags
}
#[cfg_attr(not(test), allow(dead_code))]
pub fn assess_deploy_help(&self, output: &str) -> Result<(), KamalProxyCompatIssue> {
assess_kamal_proxy_deploy_help(output, &self.required_deploy_flags())
}
#[must_use]
pub fn render_proxy_unit(public_port: u16) -> String {
format!(
"[Unit]\n\
Description=kamal-proxy (Autumn deploy front)\n\
After=network-online.target\n\
Wants=network-online.target\n\
\n\
[Service]\n\
Type=simple\n\
StateDirectory=kamal-proxy\n\
Environment=HOME=/var/lib/kamal-proxy\n\
ExecStart={KAMAL_PROXY_BIN} run --http-port {public_port}\n\
Restart=on-failure\n\
RestartSec=2\n\
\n\
[Install]\n\
WantedBy=multi-user.target\n",
)
}
}
impl ProxyController for KamalProxyController {
fn ensure_installed_ops(&self, public_port: u16) -> Vec<DeployOp> {
vec![
DeployOp::WriteFile {
label: "proxy-write-unit",
contents: FileContents::Plain(Self::render_proxy_unit(public_port)),
remote_path: KAMAL_PROXY_UNIT_PATH.to_owned(),
mode: Some(0o644),
},
DeployOp::Run(RemoteCommand::new(
"proxy-install",
"systemctl daemon-reload && systemctl enable --now kamal-proxy.service".to_owned(),
)),
]
}
fn route_op(&self, service: &str, upstream: &str) -> DeployOp {
DeployOp::Run(RemoteCommand::new(
"proxy-route",
self.deploy_shell(service, upstream, false),
))
}
fn flip_op(&self, service: &str, new_upstream: &str) -> DeployOp {
DeployOp::Run(RemoteCommand::new(
"proxy-flip",
self.deploy_shell(service, new_upstream, false),
))
}
fn refresh_installed_ops(
&self,
public_port: u16,
service: &str,
live_upstream: &str,
snapshot_path: &str,
reregister_options: &ProxyServiceOptions,
) -> Vec<DeployOp> {
let unit = shell_quote(KAMAL_PROXY_UNIT_PATH);
let snap = shell_quote(snapshot_path);
let snapshot = DeployOp::Run(RemoteCommand::new(
"proxy-snapshot-unit",
format!("{{ sha256sum {unit} 2>/dev/null || true; }} | cut -d' ' -f1 > {snap}"),
));
let mut ops = Vec::with_capacity(4);
ops.push(snapshot);
ops.extend(self.ensure_installed_ops(public_port));
ops.push(DeployOp::Run(RemoteCommand::new(
"proxy-restart-if-changed",
format!(
"new=\"$( {{ sha256sum {unit} 2>/dev/null || true; }} | cut -d' ' -f1 )\"; \
old=\"$(cat {snap} 2>/dev/null || true)\"; \
rm -f {snap}; \
if [ \"$new\" = \"$old\" ]; then exit 0; fi; \
if ! systemctl is-active --quiet kamal-proxy.service; then exit 0; fi; \
systemctl restart kamal-proxy.service || exit 1; \
n=0; \
until {reregister}; do \
n=$((n+1)); \
if [ \"$n\" -ge 5 ]; then \
echo 'kamal-proxy did not accept the live route after restart' >&2; exit 1; fi; \
sleep 0.5; \
done",
reregister = self.deploy_shell_with_tls(
service,
live_upstream,
true,
reregister_options.host.as_deref()
),
),
)));
ops
}
fn proxy_service_options(&self) -> ProxyServiceOptions {
ProxyServiceOptions {
tls: self.tls_host.is_some(),
host: self.tls_host.clone(),
}
}
fn binary_install_ops(&self) -> Option<Vec<DeployOp>> {
Some(vec![DeployOp::Run(RemoteCommand::new(
"install-proxy",
Self::install_shell(),
))])
}
fn binary_install_note(&self) -> String {
format!(
"no kamal-proxy on this host — installing {KAMAL_PROXY_KNOWN_GOOD_VERSION} at \
{KAMAL_PROXY_BIN}, from the pinned basecamp/kamal-proxy image. This also \
installs, and leaves behind, any of `curl` and a container runtime the host \
is missing. Decline with `[deploy] install_proxy = false`."
)
}
fn compat_probe(&self) -> Option<ProxyCompatProbe> {
let required = self.required_deploy_flags();
Some(ProxyCompatProbe::new(
Self::compat_probe_command(),
move |output| {
assess_kamal_proxy_deploy_help(output, &required).map_err(|issue| {
ProxyCompatFailure {
message: issue.message(),
binary_missing: issue == KamalProxyCompatIssue::BinaryUnusable,
}
})
},
))
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum KamalProxyCompatIssue {
BinaryUnusable,
DeploySubcommandMissing,
MissingFlags(Vec<&'static str>),
}
impl KamalProxyCompatIssue {
#[must_use]
pub fn message(&self) -> String {
let pin = KAMAL_PROXY_KNOWN_GOOD_VERSION;
match self {
Self::BinaryUnusable => format!(
"the kamal-proxy binary at `{KAMAL_PROXY_BIN}` did not respond to \
`kamal-proxy deploy --help` (missing or not executable). Install \
kamal-proxy {pin} there — e.g. `docker create \
basecamp/kamal-proxy:{pin}` then `docker cp \
<id>:/usr/local/bin/kamal-proxy {KAMAL_PROXY_BIN} && chmod 755 \
{KAMAL_PROXY_BIN}` — or let `autumn deploy` install it for you by \
leaving `[deploy] install_proxy` at its default `true`. Aborting \
before any cutover, so live traffic was not touched."
),
Self::DeploySubcommandMissing => format!(
"the installed kamal-proxy has no `deploy` subcommand — the CLI \
surface this deploy is built on has changed. Pin kamal-proxy to a \
compatible version ({pin}) in the target's host bootstrap and \
redeploy. Aborting before any cutover, so live traffic was not \
touched."
),
Self::MissingFlags(flags) => format!(
"the installed kamal-proxy `deploy` command is missing flag(s) this \
deploy requires: {missing}. The kamal-proxy CLI surface has drifted \
from what this release was built against. Pin kamal-proxy to a \
compatible version ({pin}) in the target's host bootstrap and \
redeploy. Aborting before any cutover, so live traffic was not \
touched.",
missing = flags.join(", "),
),
}
}
}
fn output_lists_flag(output: &str, flag: &str) -> bool {
let flen = flag.len();
let mut search_from = 0;
while let Some(rel) = output[search_from..].find(flag) {
let start = search_from + rel;
let end = start + flen;
let before_ok = output[..start]
.chars()
.next_back()
.is_none_or(|c| !c.is_ascii_alphanumeric() && c != '-');
let after_ok = output[end..]
.chars()
.next()
.is_none_or(|c| !c.is_ascii_alphanumeric() && c != '-');
if before_ok && after_ok {
return true;
}
search_from = start + 1;
}
false
}
fn assess_kamal_proxy_deploy_help(
output: &str,
required_flags: &[&'static str],
) -> Result<(), KamalProxyCompatIssue> {
let missing: Vec<&'static str> = required_flags
.iter()
.copied()
.filter(|flag| !output_lists_flag(output, flag))
.collect();
if missing.is_empty() {
return Ok(());
}
if missing.len() < required_flags.len() {
return Err(KamalProxyCompatIssue::MissingFlags(missing));
}
let lower = output.to_ascii_lowercase();
if lower.contains("unknown command") && lower.contains("deploy") {
return Err(KamalProxyCompatIssue::DeploySubcommandMissing);
}
Err(KamalProxyCompatIssue::BinaryUnusable)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn kamal_proxy_flip_command_is_correct() {
let proxy = KamalProxyController::new(60);
let DeployOp::Run(cmd) = proxy.flip_op("myapp", "127.0.0.1:3002") else {
panic!("flip_op must be a Run op");
};
assert_eq!(cmd.label, "proxy-flip");
assert_eq!(
cmd.shell,
"env -u XDG_RUNTIME_DIR kamal-proxy deploy 'myapp' --target '127.0.0.1:3002' \
--health-check-path '/ready' --deploy-timeout 60s --drain-timeout 30s",
);
}
#[test]
fn reregister_is_forced_but_route_and_flip_stay_health_gated() {
let proxy = KamalProxyController::new(60);
let ops = proxy.refresh_installed_ops(
8080,
"myapp",
"127.0.0.1:3001",
"/tmp/snap.sha256",
&ProxyServiceOptions {
tls: false,
host: None,
},
);
let DeployOp::Run(restart) = ops.last().expect("has ops") else {
panic!("last op must be the restart/re-register");
};
assert!(
restart.shell.contains(
"env -u XDG_RUNTIME_DIR kamal-proxy deploy 'myapp' --target '127.0.0.1:3001' \
--health-check-path '/ready' --deploy-timeout 60s --drain-timeout 30s --force"
),
"re-register must be forced + socket-pinned + target the live upstream: {}",
restart.shell,
);
assert!(
restart
.shell
.contains("systemctl restart kamal-proxy.service || exit 1"),
"restart fail-fast must be preserved: {}",
restart.shell,
);
assert!(
restart.shell.contains("if [ \"$n\" -ge 5 ]"),
"the retry ceiling is small (5), not the old 30: {}",
restart.shell,
);
let DeployOp::Run(flip) = proxy.flip_op("myapp", "127.0.0.1:3002") else {
panic!("flip_op must be a Run op");
};
assert!(
!flip.shell.contains("--force"),
"the cutover flip must STAY health-gated (no --force): {}",
flip.shell,
);
let DeployOp::Run(route) = proxy.route_op("myapp", "127.0.0.1:3002") else {
panic!("route_op must be a Run op");
};
assert!(
!route.shell.contains("--force"),
"the first-deploy route must STAY health-gated (no --force): {}",
route.shell,
);
}
#[test]
fn route_and_flip_share_the_same_kamal_deploy_contract() {
let proxy = KamalProxyController::new(45);
let (DeployOp::Run(route), DeployOp::Run(flip)) = (
proxy.route_op("svc", "127.0.0.1:9001"),
proxy.flip_op("svc", "127.0.0.1:9001"),
) else {
panic!("route/flip must be Run ops");
};
assert_eq!(route.label, "proxy-route");
assert_eq!(flip.label, "proxy-flip");
assert_eq!(route.shell, flip.shell);
assert!(flip.shell.contains("--deploy-timeout 45s"));
}
#[test]
fn tls_host_wires_host_and_tls_into_deploy_and_leaves_run_unit_unchanged() {
let proxy = KamalProxyController::new(60).with_tls_host(Some("app.example.com".to_owned()));
let DeployOp::Run(flip) = proxy.flip_op("myapp", "127.0.0.1:3002") else {
panic!("flip_op must be a Run op");
};
assert_eq!(
flip.shell,
"env -u XDG_RUNTIME_DIR kamal-proxy deploy 'myapp' --target '127.0.0.1:3002' \
--health-check-path '/ready' --host 'app.example.com' --tls \
--deploy-timeout 60s --drain-timeout 30s",
);
let DeployOp::Run(route) = proxy.route_op("myapp", "127.0.0.1:3002") else {
panic!("route_op must be a Run op");
};
assert_eq!(
route.shell, flip.shell,
"route and flip share the TLS contract"
);
let ops = proxy.ensure_installed_ops(8080);
let DeployOp::WriteFile {
contents: FileContents::Plain(unit),
..
} = &ops[0]
else {
panic!("op 0 must write the proxy unit");
};
assert!(
unit.contains("ExecStart=/usr/local/bin/kamal-proxy run --http-port 8080\n"),
"run unit must pass only --http-port (443 is kamal-proxy's default), got: {unit}",
);
assert!(
!unit.contains("--https-port"),
"run unit must NOT pass an explicit --https-port, got: {unit}",
);
}
#[test]
fn run_unit_is_tls_invariant() {
let http_only = KamalProxyController::new(60);
let with_tls =
KamalProxyController::new(60).with_tls_host(Some("app.example.com".to_owned()));
let unit_for = |proxy: &KamalProxyController| {
let ops = proxy.ensure_installed_ops(8080);
let DeployOp::WriteFile {
contents: FileContents::Plain(unit),
..
} = &ops[0]
else {
panic!("op 0 must write the proxy unit");
};
unit.clone()
};
let http_only_unit = unit_for(&http_only);
assert!(
http_only_unit.contains("ExecStart=/usr/local/bin/kamal-proxy run --http-port 8080\n"),
"run unit must pass only --http-port, got: {http_only_unit}",
);
assert!(
!http_only_unit.contains("--https-port"),
"run unit must NOT pass an explicit --https-port, got: {http_only_unit}",
);
assert_eq!(
http_only_unit,
unit_for(&with_tls),
"the shared run unit must be identical regardless of any app's TLS flag",
);
}
#[test]
fn run_unit_persists_state_dir_so_routes_survive_reboot() {
let unit = KamalProxyController::render_proxy_unit(8080);
assert!(
unit.contains("StateDirectory=kamal-proxy\n"),
"run unit must declare a persistent StateDirectory, got: {unit}",
);
assert!(
unit.contains("Environment=HOME=/var/lib/kamal-proxy\n"),
"run unit must point HOME at the persistent state dir, got: {unit}",
);
let service = unit
.split("[Install]")
.next()
.expect("unit must have a [Service] section before [Install]");
assert!(service.contains("StateDirectory=kamal-proxy\n"));
assert!(service.contains("Environment=HOME=/var/lib/kamal-proxy\n"));
assert!(
!unit.contains("XDG_RUNTIME_DIR"),
"the run unit does not touch the control socket, got: {unit}",
);
}
#[test]
fn run_unit_state_dir_is_deterministic_across_renders() {
let a = KamalProxyController::render_proxy_unit(8080);
let b = KamalProxyController::render_proxy_unit(8080);
assert_eq!(a, b, "render_proxy_unit must be deterministic");
let other_port = KamalProxyController::render_proxy_unit(9090);
assert!(other_port.contains("StateDirectory=kamal-proxy\n"));
assert!(other_port.contains("Environment=HOME=/var/lib/kamal-proxy\n"));
}
#[test]
fn ensure_installed_supervises_proxy_on_public_port() {
let proxy = KamalProxyController::new(60);
let ops = proxy.ensure_installed_ops(8080);
assert_eq!(ops.len(), 2);
match &ops[0] {
DeployOp::WriteFile {
label,
contents,
remote_path,
..
} => {
assert_eq!(*label, "proxy-write-unit");
assert_eq!(remote_path, KAMAL_PROXY_UNIT_PATH);
let FileContents::Plain(unit) = contents else {
panic!("proxy unit must be plain text");
};
assert!(
unit.contains("ExecStart=/usr/local/bin/kamal-proxy run --http-port 8080\n")
);
assert!(!unit.contains("--https-port"));
}
other => panic!("op 0 should write the proxy unit, got {other:?}"),
}
let DeployOp::Run(cmd) = &ops[1] else {
panic!("op 1 must be the proxy-install Run op");
};
assert_eq!(cmd.label, "proxy-install");
assert_eq!(
cmd.shell,
"systemctl daemon-reload && systemctl enable --now kamal-proxy.service",
);
}
#[test]
fn refresh_installed_ops_snapshots_installs_then_restarts_only_when_changed() {
let proxy = KamalProxyController::new(60);
let ops = proxy.refresh_installed_ops(
8080,
"myapp",
"127.0.0.1:3001",
"/tmp/autumn-kamal-proxy-unit-REL.sha256",
&ProxyServiceOptions {
tls: false,
host: None,
},
);
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_eq!(snapshot.label, "proxy-snapshot-unit");
assert_eq!(
snapshot.shell,
"{ sha256sum '/etc/systemd/system/kamal-proxy.service' 2>/dev/null || true; } \
| cut -d' ' -f1 > '/tmp/autumn-kamal-proxy-unit-REL.sha256'",
);
let install = proxy.ensure_installed_ops(8080);
match (&ops[1], &install[0]) {
(
DeployOp::WriteFile {
label: l1,
remote_path: p1,
contents: FileContents::Plain(u1),
..
},
DeployOp::WriteFile {
contents: FileContents::Plain(u2),
..
},
) => {
assert_eq!(*l1, "proxy-write-unit");
assert_eq!(p1, KAMAL_PROXY_UNIT_PATH);
assert_eq!(u1, u2, "refresh writes the same unit as first deploy");
assert!(u1.contains("StateDirectory=kamal-proxy\n"));
assert!(u1.contains("Environment=HOME=/var/lib/kamal-proxy\n"));
}
_ => panic!("op 1 must re-write the proxy unit"),
}
let DeployOp::Run(inst) = &ops[2] else {
panic!("op 2 must be proxy-install");
};
assert_eq!(inst.label, "proxy-install");
assert_eq!(
inst.shell,
"systemctl daemon-reload && systemctl enable --now kamal-proxy.service",
);
let DeployOp::Run(restart) = &ops[3] else {
panic!("op 3 must be proxy-restart-if-changed");
};
assert_eq!(restart.label, "proxy-restart-if-changed");
let s = &restart.shell;
assert!(
s.contains("if [ \"$new\" = \"$old\" ]; then exit 0; fi"),
"unchanged unit → no restart: {s}"
);
assert!(
s.contains("if ! systemctl is-active --quiet kamal-proxy.service; then exit 0; fi"),
"restart only an active proxy: {s}"
);
assert!(
s.contains("systemctl restart kamal-proxy.service"),
"restart to adopt the changed unit: {s}"
);
assert!(
s.contains(
"env -u XDG_RUNTIME_DIR kamal-proxy deploy 'myapp' --target '127.0.0.1:3001' \
--health-check-path '/ready' --deploy-timeout 60s --drain-timeout 30s --force"
),
"re-register the current live upstream, socket-pinned and forced (#2071): {s}"
);
assert!(s.contains("--force"), "re-register must be --force: {s}");
assert!(
s.contains("rm -f '/tmp/autumn-kamal-proxy-unit-REL.sha256'"),
"the scratch snapshot is cleaned up: {s}"
);
let gate = s.find("if [ \"$new\" = \"$old\" ]").unwrap();
let do_restart = s.find("systemctl restart kamal-proxy.service").unwrap();
let reregister = s.find("kamal-proxy deploy 'myapp'").unwrap();
assert!(
gate < do_restart && do_restart < reregister,
"gate→restart→re-register: {s}"
);
}
#[test]
fn refresh_installed_ops_tls_reregister_carries_host_and_tls() {
let proxy = KamalProxyController::new(60).with_tls_host(Some("app.example.com".to_owned()));
let ops = proxy.refresh_installed_ops(
8080,
"myapp",
"127.0.0.1:3001",
"/tmp/snap.sha256",
&ProxyServiceOptions {
tls: true,
host: Some("app.example.com".to_owned()),
},
);
let DeployOp::Run(restart) = ops.last().expect("has ops") else {
panic!("last op must be the restart");
};
assert!(
restart.shell.contains(
"env -u XDG_RUNTIME_DIR kamal-proxy deploy 'myapp' --target '127.0.0.1:3001' \
--health-check-path '/ready' --host 'app.example.com' --tls \
--deploy-timeout 60s --drain-timeout 30s --force"
),
"TLS re-register carries --host/--tls and --force: {}",
restart.shell,
);
}
#[test]
fn refresh_reregister_preserves_old_options_while_flip_uses_new_host() {
let proxy = KamalProxyController::new(60).with_tls_host(Some("new.example.com".to_owned()));
let ops = proxy.refresh_installed_ops(
8080,
"myapp",
"127.0.0.1:3001",
"/tmp/snap.sha256",
&ProxyServiceOptions {
tls: true,
host: Some("old.example.com".to_owned()),
},
);
let DeployOp::Run(restart) = ops.last().expect("has ops") else {
panic!("last op must be the restart/re-register");
};
assert!(
restart.shell.contains("--host 'old.example.com' --tls")
&& !restart.shell.contains("new.example.com"),
"re-register of the live OLD release preserves the OLD host, not the new: {}",
restart.shell,
);
let DeployOp::Run(flip) = proxy.flip_op("myapp", "127.0.0.1:3002") else {
panic!("flip_op must be a Run op");
};
assert!(
flip.shell.contains("--host 'new.example.com' --tls")
&& !flip.shell.contains("old.example.com"),
"the candidate flip carries the NEW host: {}",
flip.shell,
);
}
#[test]
fn refresh_reregister_can_preserve_tls_off_removing_a_host() {
let proxy = KamalProxyController::new(60).with_tls_host(Some("new.example.com".to_owned()));
let ops = proxy.refresh_installed_ops(
8080,
"myapp",
"127.0.0.1:3001",
"/tmp/snap.sha256",
&ProxyServiceOptions {
tls: false,
host: None,
},
);
let DeployOp::Run(restart) = ops.last().expect("has ops") else {
panic!("last op must be the restart/re-register");
};
assert!(
!restart.shell.contains("--host") && !restart.shell.contains("--tls "),
"preserving TLS-off options emits an HTTP-only re-register: {}",
restart.shell,
);
}
#[test]
fn proxy_service_options_mirror_the_configured_tls_host() {
assert_eq!(
KamalProxyController::new(60).proxy_service_options(),
ProxyServiceOptions {
tls: false,
host: None,
},
);
assert_eq!(
KamalProxyController::new(60)
.with_tls_host(Some("app.example.com".to_owned()))
.proxy_service_options(),
ProxyServiceOptions {
tls: true,
host: Some("app.example.com".to_owned()),
},
);
}
#[test]
fn refresh_installed_ops_default_is_plain_install_no_restart() {
struct PinnedController;
impl ProxyController for PinnedController {
fn ensure_installed_ops(&self, _public_port: u16) -> Vec<DeployOp> {
vec![DeployOp::Run(RemoteCommand::new("install", "true"))]
}
fn route_op(&self, _service: &str, _upstream: &str) -> DeployOp {
DeployOp::Run(RemoteCommand::new("route", "true"))
}
fn flip_op(&self, _service: &str, _new_upstream: &str) -> DeployOp {
DeployOp::Run(RemoteCommand::new("flip", "true"))
}
}
let refreshed = PinnedController.refresh_installed_ops(
80,
"svc",
"127.0.0.1:9001",
"/tmp/x",
&ProxyServiceOptions {
tls: false,
host: None,
},
);
let installed = PinnedController.ensure_installed_ops(80);
assert_eq!(refreshed.len(), installed.len());
assert!(
refreshed
.iter()
.all(|op| op.label() != "proxy-restart-if-changed"
&& op.label() != "proxy-snapshot-unit"),
"the default refresh adds neither a snapshot nor a restart op",
);
}
fn sample_deploy_help() -> &'static str {
"Deploy a new version of a service\n\
\n\
Usage:\n kamal-proxy deploy SERVICE [flags]\n\
\n\
Flags:\n\
\x20 --target host:port Target host and port to route to\n\
\x20 --health-check-path string Path kamal-proxy health-checks\n\
\x20 --host strings Host(s) to route\n\
\x20 --tls Configure TLS for this service\n\
\x20 --deploy-timeout duration How long to wait for the target\n\
\x20 --drain-timeout duration How long to drain the old target\n\
\x20 --force Skip health checks and force deployment\n"
}
#[test]
fn compat_probe_command_is_the_readonly_deploy_help_check() {
let cmd = KamalProxyController::compat_probe_command();
assert_eq!(cmd.label, "proxy-compat-probe");
assert_eq!(cmd.shell, "kamal-proxy deploy --help 2>&1 || true");
}
#[test]
fn required_flags_track_tls_config() {
let http_only = KamalProxyController::new(60);
assert_eq!(
http_only.required_deploy_flags(),
vec![
"--target",
"--health-check-path",
"--deploy-timeout",
"--drain-timeout",
"--force",
],
);
let with_tls =
KamalProxyController::new(60).with_tls_host(Some("app.example.com".to_owned()));
assert_eq!(
with_tls.required_deploy_flags(),
vec![
"--target",
"--health-check-path",
"--deploy-timeout",
"--drain-timeout",
"--host",
"--tls",
"--force",
],
);
let reregister_shell = with_tls.deploy_shell("svc", "127.0.0.1:3001", true);
for flag in with_tls.required_deploy_flags() {
assert!(
reregister_shell.contains(flag),
"required flag {flag} must be one deploy_shell emits, got: {reregister_shell}",
);
}
}
#[test]
fn compatible_help_passes_silently() {
assert_eq!(
KamalProxyController::new(60).assess_deploy_help(sample_deploy_help()),
Ok(()),
);
let no_tls_help = "Flags:\n --target x\n --health-check-path p\n \
--deploy-timeout d\n --drain-timeout d\n --force\n";
assert_eq!(
KamalProxyController::new(60).assess_deploy_help(no_tls_help),
Ok(()),
);
}
#[test]
fn tls_controller_requires_tls_flags() {
let no_tls_help = "Flags:\n --target x\n --health-check-path p\n \
--deploy-timeout d\n --drain-timeout d\n --host h\n --force\n";
let with_tls =
KamalProxyController::new(60).with_tls_host(Some("app.example.com".to_owned()));
assert_eq!(
with_tls.assess_deploy_help(no_tls_help),
Err(KamalProxyCompatIssue::MissingFlags(vec!["--tls"])),
);
}
#[test]
fn a_renamed_flag_is_caught_with_the_exact_missing_flag() {
let drifted = sample_deploy_help().replace("--drain-timeout", "--drain-window");
let issue = KamalProxyController::new(60)
.assess_deploy_help(&drifted)
.expect_err("a renamed flag must be caught");
assert_eq!(
issue,
KamalProxyCompatIssue::MissingFlags(vec!["--drain-timeout"]),
);
let msg = issue.message();
assert!(
msg.contains("--drain-timeout"),
"message names the flag: {msg}"
);
assert!(msg.contains("v0.9.2"), "message names the pin: {msg}");
assert!(
msg.contains("before any cutover"),
"message states nothing was cut over: {msg}",
);
}
#[test]
fn valid_help_with_benign_stderr_noise_still_passes() {
let noisy = format!("bash: nvm: command not found\n{}", sample_deploy_help());
assert_eq!(
KamalProxyController::new(60).assess_deploy_help(&noisy),
Ok(()),
"valid help alongside unrelated stderr noise must pass",
);
let with_tls =
KamalProxyController::new(60).with_tls_host(Some("app.example.com".to_owned()));
assert_eq!(with_tls.assess_deploy_help(&noisy), Ok(()));
}
#[test]
fn a_renamed_flag_amid_stderr_noise_is_still_caught_as_missing_not_unusable() {
let drifted = sample_deploy_help().replace("--drain-timeout", "--drain-window");
let noisy = format!("bash: nvm: command not found\n{drifted}");
assert_eq!(
KamalProxyController::new(60).assess_deploy_help(&noisy),
Err(KamalProxyCompatIssue::MissingFlags(vec!["--drain-timeout"])),
);
}
#[test]
fn a_removed_deploy_subcommand_is_caught() {
let output = "Error: unknown command \"deploy\" for \"kamal-proxy\"\n\
Run 'kamal-proxy --help' for usage.\n";
let issue = KamalProxyController::new(60)
.assess_deploy_help(output)
.expect_err("a removed deploy subcommand must be caught");
assert_eq!(issue, KamalProxyCompatIssue::DeploySubcommandMissing);
assert!(issue.message().contains("no `deploy` subcommand"));
}
#[test]
fn a_missing_binary_is_caught() {
for output in [
"bash: kamal-proxy: command not found\n",
"/usr/local/bin/kamal-proxy: No such file or directory\n",
"bash: /usr/local/bin/kamal-proxy: Permission denied\n",
"bash: /usr/local/bin/kamal-proxy: cannot execute binary file: Exec format error\n",
"", ] {
assert_eq!(
KamalProxyController::new(60).assess_deploy_help(output),
Err(KamalProxyCompatIssue::BinaryUnusable),
"output {output:?} must classify as an unusable binary",
);
}
let msg = KamalProxyCompatIssue::BinaryUnusable.message();
assert!(
msg.contains("/usr/local/bin/kamal-proxy"),
"names the path: {msg}"
);
assert!(msg.contains("v0.9.2"), "names the pin: {msg}");
}
#[test]
fn a_prefix_renamed_flag_is_caught_by_the_boundary_matcher() {
let drifted = sample_deploy_help().replace("--target ", "--target-host ");
assert!(
drifted.contains("--target-host") && !drifted.contains("--target "),
"fixture must carry the renamed superstring and no standalone --target",
);
assert_eq!(
KamalProxyController::new(60).assess_deploy_help(&drifted),
Err(KamalProxyCompatIssue::MissingFlags(vec!["--target"])),
);
assert!(output_lists_flag(" --tls Configure TLS\n", "--tls"));
assert!(!output_lists_flag(" --tls-enabled ...\n", "--tls"));
assert!(!output_lists_flag("run--tls now", "--tls"));
assert!(output_lists_flag("--target=host:port", "--target"));
}
#[test]
fn compat_probe_trait_method_wires_command_and_verdict() {
let probe = KamalProxyController::new(60)
.compat_probe()
.expect("kamal-proxy declares a compat probe");
assert_eq!(
probe.command.shell,
"kamal-proxy deploy --help 2>&1 || true"
);
assert_eq!(probe.assess(sample_deploy_help()), Ok(()));
let drifted = sample_deploy_help().replace("--target", "--upstream");
let err = probe
.assess(&drifted)
.expect_err("drift must fail the verdict");
assert!(
err.message.contains("--target"),
"verdict message names the flag: {}",
err.message
);
assert!(
!err.binary_missing,
"flag drift is not a missing binary: {err:?}"
);
}
#[test]
fn the_verdict_reports_a_missing_binary_as_a_host_prep_case() {
let probe = KamalProxyController::new(60)
.compat_probe()
.expect("kamal-proxy declares a compat probe");
let err = probe
.assess("")
.expect_err("no binary must fail the verdict");
assert!(err.binary_missing, "an unusable binary is host prep's case");
assert!(
err.message.contains("install_proxy"),
"the message names the opt-out that would decline the fix: {}",
err.message
);
}
#[test]
fn the_install_op_lands_the_pinned_binary_atomically_at_the_supervised_path() {
let ops = KamalProxyController::new(60)
.binary_install_ops()
.expect("kamal-proxy can prepare a host");
assert_eq!(ops.len(), 1, "host prep is a single remote command");
let DeployOp::Run(cmd) = &ops[0] else {
panic!("host prep runs a command, it uploads nothing");
};
assert_eq!(cmd.label, "install-proxy");
let shell = &cmd.shell;
assert!(
shell.contains(KAMAL_PROXY_KNOWN_GOOD_VERSION),
"the image tag is pinned, never `latest`: {shell}"
);
assert!(
!shell.contains(":latest"),
"an unpinned tag would make host prep non-reproducible: {shell}"
);
assert!(
shell.contains("command -v docker"),
"an existing container runtime is reused: {shell}"
);
assert!(
shell.contains("command -v curl") && shell.contains("curl\""),
"host prep must ensure the readiness gate's curl: {shell}"
);
assert!(
shell.contains("DEBIAN_FRONTEND=noninteractive"),
"apt must never prompt mid-deploy: {shell}"
);
let staged = format!("{KAMAL_PROXY_BIN}.autumn-new");
assert!(
shell.contains(&format!("chmod 755 '{staged}'"))
&& shell.contains(&format!("mv -f '{staged}' '{KAMAL_PROXY_BIN}'")),
"the binary is staged, marked executable, then moved into place: {shell}"
);
assert!(
shell.starts_with("( set -e;"),
"any failing step must fail the whole op: {shell}"
);
assert!(
shell.contains(&format!(
"{KAMAL_PROXY_KNOWN_GOOD_VERSION}@{KAMAL_PROXY_KNOWN_GOOD_DIGEST}"
)),
"the image is pinned by DIGEST, not just by tag: {shell}"
);
assert!(
shell.contains(&format!(
"if [ -e '{KAMAL_PROXY_BIN}' ] || [ -L '{KAMAL_PROXY_BIN}' ]"
)) && shell.contains("refusing to replace it"),
"the install must refuse to replace anything already occupying the \
target path — including a DANGLING symlink, which `-e` alone reports \
as absent because it follows links: {shell}"
);
assert!(
shell.contains("install_proxy = false") && shell.contains("outbound HTTPS"),
"a failed install must name what the host needs and the opt-out: {shell}"
);
}
#[test]
fn the_install_op_is_a_valid_posix_shell_script() {
let shell = KamalProxyController::install_shell();
let out = std::process::Command::new("sh")
.arg("-n")
.arg("-c")
.arg(&shell)
.output()
.expect("spawn sh -n");
assert!(
out.status.success(),
"the generated install shell must parse as POSIX sh:\n{shell}\n--- sh said ---\n{}",
String::from_utf8_lossy(&out.stderr)
);
assert!(
shell.contains(&format!(
"'{KAMAL_PROXY_BIN}' deploy --help >/dev/null 2>&1 ||"
)),
"the install proves the binary at the SUPERVISED path works — verifying \
the staging path would prove nothing about what was installed: {shell}"
);
}
}