#![allow(clippy::redundant_pub_crate)]
use std::path::Path;
use super::exec::{
self, DeployOp, ManifestUpload, MigrateStep, ProxyServiceOptions, Secret, SlotPlan,
};
use super::proxy::ProxyController;
use super::{ResolvedDeployConfig, ResolvedFleet};
pub(crate) const FLEET_MIGRATE_PLACEMENT_NOTE: &str = "[migrate] runs once, on the first host in rollout order, before its cutover — \
hosts 2..N skip it";
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(crate) enum HostMode {
First,
Redeploy,
}
impl HostMode {
pub(crate) const fn from_deploy_mode(mode: &exec::DeployMode) -> Self {
match mode {
exec::DeployMode::First => Self::First,
exec::DeployMode::Redeploy { .. } => Self::Redeploy,
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(crate) enum MigratePlacement {
FirstHost(usize),
None,
}
pub(crate) const fn migrate_placement(modes: &[HostMode]) -> MigratePlacement {
if modes.is_empty() {
MigratePlacement::None
} else {
MigratePlacement::FirstHost(0)
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub(crate) struct HostPlan {
pub(crate) host: String,
pub(crate) mode: HostMode,
pub(crate) migrate: MigrateStep,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub(crate) struct FleetPlan {
pub(crate) hosts: Vec<HostPlan>,
}
impl FleetPlan {
pub(crate) fn migrating_host(&self) -> Option<&HostPlan> {
self.hosts
.iter()
.find(|h| matches!(h.migrate, MigrateStep::Run))
}
}
#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
pub(crate) enum FleetPlanError {
#[error(
"fleet plan input mismatch: {hosts} resolved host(s) but {modes} probe mode(s) — every \
host must be probed before any host is touched (#1621)"
)]
ModeCountMismatch {
hosts: usize,
modes: usize,
},
}
pub(crate) fn plan_fleet(
fleet: &ResolvedFleet,
modes: &[HostMode],
) -> Result<FleetPlan, FleetPlanError> {
if fleet.hosts.len() != modes.len() {
return Err(FleetPlanError::ModeCountMismatch {
hosts: fleet.hosts.len(),
modes: modes.len(),
});
}
let placement = migrate_placement(modes);
let hosts = fleet
.hosts
.iter()
.zip(modes)
.enumerate()
.map(|(index, (cfg, mode))| HostPlan {
host: cfg.host.clone().unwrap_or_default(),
mode: *mode,
migrate: if placement == MigratePlacement::FirstHost(index) {
MigrateStep::Run
} else {
MigrateStep::Skip
},
})
.collect();
Ok(FleetPlan { hosts })
}
pub(crate) struct HostOpsInput<'a, P: ProxyController> {
pub(crate) cfg: &'a ResolvedDeployConfig,
pub(crate) proxy: &'a P,
pub(crate) unit: &'a str,
pub(crate) env_file: &'a Secret,
pub(crate) binary_local: &'a Path,
pub(crate) manifests: &'a [ManifestUpload],
pub(crate) release_id: &'a str,
pub(crate) slots: &'a SlotPlan,
pub(crate) reregister_options: &'a ProxyServiceOptions,
}
pub(crate) fn host_ops<P: ProxyController>(
plan: &HostPlan,
input: &HostOpsInput<'_, P>,
) -> Vec<DeployOp> {
match plan.mode {
HostMode::First => exec::first_deploy_ops(
input.cfg,
input.proxy,
input.unit,
input.env_file.clone(),
input.binary_local,
input.manifests,
input.release_id,
input.slots,
plan.migrate,
),
HostMode::Redeploy => exec::cutover_ops(
input.cfg,
input.proxy,
input.unit,
input.env_file.clone(),
input.binary_local,
input.manifests,
input.release_id,
input.slots,
input.reregister_options,
plan.migrate,
),
}
}
pub(crate) fn fleet_plan_lines(hosts: &[String]) -> Vec<String> {
let mut lines = Vec::with_capacity(hosts.len() + 5);
lines.push(String::new());
lines.push(format!(
"Fleet rollout ({} hosts, in `[deploy] hosts` declaration order):",
hosts.len()
));
for (index, host) in hosts.iter().enumerate() {
lines.push(format!(" {}. {host}", index + 1));
}
lines.push(
" Hosts roll out ONE AT A TIME in the order above: each host runs the steps \
above in full and must finish its cutover before the next host is started, so \
the rest of the fleet keeps serving throughout."
.to_owned(),
);
lines.push(format!(" {FLEET_MIGRATE_PLACEMENT_NOTE}."));
lines.push(
" This section is descriptive, like the steps above: `deploy plan` contacts no \
host, so it cannot know which hosts are first deploys and which are redeploys \
— the migrate placement is the rule `autumn deploy up` applies after probing \
every host, not a host named here."
.to_owned(),
);
lines
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub(crate) enum HostOutcome {
Untouched,
Serving,
Degraded {
label: &'static str,
},
RolledBack {
failed_step: &'static str,
},
TornDown {
failed_step: &'static str,
},
LiveOnNew {
failed_step: &'static str,
},
AmbiguousMarkers,
CompensatedRollback,
CompensatedTeardown,
CompensationFailed {
failed_step: &'static str,
},
Manual {
reason: &'static str,
},
}
impl HostOutcome {
pub(crate) const fn on_new_release(&self) -> bool {
matches!(
self,
Self::Serving
| Self::Degraded { .. }
| Self::LiveOnNew { .. }
| Self::AmbiguousMarkers
| Self::Manual { .. }
| Self::CompensationFailed { .. }
)
}
pub(crate) const fn went_back(&self) -> bool {
match self {
Self::RolledBack { .. }
| Self::TornDown { .. }
| Self::CompensatedRollback
| Self::CompensatedTeardown => true,
Self::Untouched
| Self::Serving
| Self::Degraded { .. }
| Self::LiveOnNew { .. }
| Self::AmbiguousMarkers
| Self::Manual { .. }
| Self::CompensationFailed { .. } => false,
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(crate) enum PostBoundaryClass {
Housekeeping,
Ambiguous,
Functional,
}
pub(crate) const HOUSEKEEPING_LABELS: [&str; 3] = ["record-proxy-options", "drain-old", "prune"];
pub(crate) const AMBIGUOUS_MARKERS_LABEL: &str = "commit-markers";
pub(crate) fn classify_post_boundary(label: &str) -> PostBoundaryClass {
if HOUSEKEEPING_LABELS.contains(&label) {
PostBoundaryClass::Housekeeping
} else if label == AMBIGUOUS_MARKERS_LABEL {
PostBoundaryClass::Ambiguous
} else {
PostBoundaryClass::Functional
}
}
pub(crate) const MANUAL_AMBIGUOUS_MARKERS: &str =
"release markers left mid-transaction by `commit-markers`";
pub(crate) const MANUAL_ROLLBACK_TARGET_MISSING: &str = "rollback target release dir missing";
pub(crate) const MANUAL_ROLLBACK_TARGET_UNPROVABLE: &str =
"rollback target release dir could not be verified";
pub(crate) const MANUAL_NO_PREVIOUS_RELEASE: &str = "no previous release recorded to roll back to";
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(crate) enum RollbackAction {
Rollback(usize),
Teardown(usize),
Manual(usize, &'static str),
}
pub(crate) fn fleet_rollback_set(
outcomes: &[HostOutcome],
modes: &[HostMode],
) -> Vec<RollbackAction> {
outcomes
.iter()
.zip(modes)
.enumerate()
.rev()
.filter_map(|(index, (outcome, mode))| match outcome {
HostOutcome::Serving | HostOutcome::Degraded { .. } | HostOutcome::LiveOnNew { .. } => {
Some(match mode {
HostMode::First => RollbackAction::Teardown(index),
HostMode::Redeploy => RollbackAction::Rollback(index),
})
}
HostOutcome::AmbiguousMarkers => {
Some(RollbackAction::Manual(index, MANUAL_AMBIGUOUS_MARKERS))
}
HostOutcome::Untouched
| HostOutcome::RolledBack { .. }
| HostOutcome::TornDown { .. }
| HostOutcome::CompensatedRollback
| HostOutcome::CompensatedTeardown
| HostOutcome::CompensationFailed { .. }
| HostOutcome::Manual { .. } => None,
})
.collect()
}
pub(crate) fn failed_step_label(err: &exec::DeployExecError) -> &'static str {
match err {
exec::DeployExecError::CommandFailed { label, .. } => label,
exec::DeployExecError::CandidateRolledBack { failed_step, .. }
| exec::DeployExecError::FirstDeployTornDown { failed_step, .. }
| exec::DeployExecError::RollbackFailed { failed_step, .. } => failed_step,
exec::DeployExecError::PostCutover { source, .. } => failed_step_label(source),
exec::DeployExecError::UploadFailed { .. } => "upload",
exec::DeployExecError::Stage { .. } => "stage-local-file",
exec::DeployExecError::Spawn { .. } => "ssh-transport",
exec::DeployExecError::PreflightAborted { .. } => "preflight",
exec::DeployExecError::ProxyIncompatible { .. } => "proxy-compat-probe",
exec::DeployExecError::NoPreviousRelease => "resolve-previous",
}
}
const fn attributed_step_label(err: &exec::DeployExecError) -> Option<&'static str> {
match err {
exec::DeployExecError::CommandFailed { label, .. } => Some(label),
exec::DeployExecError::PostCutover { failed_step, .. }
| exec::DeployExecError::CandidateRolledBack { failed_step, .. }
| exec::DeployExecError::FirstDeployTornDown { failed_step, .. }
| exec::DeployExecError::RollbackFailed { failed_step, .. } => Some(failed_step),
exec::DeployExecError::Spawn { .. }
| exec::DeployExecError::UploadFailed { .. }
| exec::DeployExecError::Stage { .. }
| exec::DeployExecError::PreflightAborted { .. }
| exec::DeployExecError::ProxyIncompatible { .. }
| exec::DeployExecError::NoPreviousRelease => None,
}
}
fn post_boundary_class(err: &exec::DeployExecError, reported_label: &str) -> PostBoundaryClass {
match attributed_step_label(err) {
Some(AMBIGUOUS_MARKERS_LABEL) | None => PostBoundaryClass::Ambiguous,
Some(_) => classify_post_boundary(reported_label),
}
}
pub(crate) fn classify_failure(err: &exec::DeployExecError) -> HostOutcome {
match err {
exec::DeployExecError::CandidateRolledBack { failed_step, .. } => {
HostOutcome::RolledBack { failed_step }
}
exec::DeployExecError::FirstDeployTornDown { failed_step, .. } => {
HostOutcome::TornDown { failed_step }
}
_ => HostOutcome::LiveOnNew {
failed_step: failed_step_label(err),
},
}
}
pub(crate) fn classify_host_outcome(err: &exec::DeployExecError) -> HostOutcome {
match classify_failure(err) {
HostOutcome::LiveOnNew { failed_step } => match post_boundary_class(err, failed_step) {
PostBoundaryClass::Housekeeping => HostOutcome::Degraded { label: failed_step },
PostBoundaryClass::Ambiguous => HostOutcome::AmbiguousMarkers,
PostBoundaryClass::Functional => HostOutcome::LiveOnNew { failed_step },
},
clean => clean,
}
}
pub(crate) const FLEET_SCHEMA_MOVED_NOTE: &str = "the schema has moved; from here an automatic rollback restores BINARIES only — \
it never rolls a migration back";
pub(crate) const FLEET_RECOVERY_HINT: &str = "Roll ONE back with `autumn deploy rollback --only <host>`, or the whole fleet with \
`autumn deploy rollback`.";
pub(crate) const FLEET_SCHEMA_NOT_ROLLED_BACK_NOTE: &str = "the compensating rollback restored BINARIES only — the migration that already ran was \
NOT rolled back; confirm the schema still fits the release now serving";
pub(crate) const FLEET_SCHEMA_AHEAD_OF_BINARIES_NOTE: &str = "no host is serving the new release, but the migration that already ran was NOT rolled \
back — the binaries went back and the schema did not; confirm the release now serving \
still fits the migrated schema";
pub(crate) fn schema_moved(plan: &FleetPlan, outcomes: &[HostOutcome]) -> bool {
plan.hosts.iter().zip(outcomes).any(|(host, outcome)| {
host.migrate == MigrateStep::Run
&& match outcome {
HostOutcome::Untouched => false,
HostOutcome::RolledBack { failed_step } | HostOutcome::TornDown { failed_step } => {
!exec::failed_before_migrating(failed_step)
}
_ => true,
}
})
}
pub(crate) fn manual_recovery_lines(cfg: &ResolvedDeployConfig, reason: &str) -> Vec<String> {
let host = cfg.host.as_deref().unwrap_or_default();
let shared = cfg.shared_dir();
vec![
format!(" \u{2757} {host}: NOT rolled back automatically \u{2014} {reason}."),
format!(
" inspect: ssh {user}@{host} 'cat {shared}/previous-release {shared}/live-slot; \
ls {releases}'",
user = cfg.user,
releases = cfg.releases_dir(),
),
" the previous-release marker names the release dir, slot and port this host \
should return to; restore it by hand before deploying the fleet again."
.to_owned(),
]
}
pub(crate) fn fleet_rollout_lines(plan: &FleetPlan, release_id: &str) -> Vec<String> {
let count = plan.hosts.len();
let mut lines = Vec::with_capacity(count + 3);
let host_word = if count == 1 { "host" } else { "hosts" };
lines.push(format!(
"Rolling release {release_id} across {count} {host_word}, ONE AT A TIME, in \
`[deploy] hosts` order:"
));
for (index, host) in plan.hosts.iter().enumerate() {
let mode = match host.mode {
HostMode::First => "first deploy",
HostMode::Redeploy => "zero-downtime redeploy",
};
lines.push(format!(" {}. {} — {mode}", index + 1, host.host));
}
if let Some(migrating) = plan.migrating_host() {
let skipped: Vec<&str> = plan
.hosts
.iter()
.filter(|h| h.host != migrating.host)
.map(|h| h.host.as_str())
.collect();
lines.push(if skipped.is_empty() {
format!(
" \u{2192} migrate ({} only \u{2014} the schema is fleet-wide)",
migrating.host,
)
} else {
format!(
" \u{2192} migrate ({} only \u{2014} the schema is fleet-wide; {} skip it)",
migrating.host,
skipped.join(", "),
)
});
}
lines
}
fn state_table_lines(title: &str, rows: &[(&'static str, &str, String)]) -> Vec<String> {
let width = rows
.iter()
.map(|(_, host, _)| host.chars().count())
.max()
.unwrap_or(0);
let mut lines = Vec::with_capacity(rows.len() + 1);
lines.push(title.to_owned());
for (marker, host, state) in rows {
lines.push(format!(" {marker} {host:<width$} {state}"));
}
lines
}
pub(crate) fn fleet_summary_lines(
plan: &FleetPlan,
outcomes: &[HostOutcome],
release_id: &str,
) -> Vec<String> {
let mut rows: Vec<(&'static str, &str, String)> = Vec::with_capacity(plan.hosts.len());
for (host, outcome) in plan.hosts.iter().zip(outcomes) {
let state = match outcome {
HostOutcome::Untouched => "untouched (not reached)".to_owned(),
HostOutcome::Serving => format!("serving {release_id}"),
HostOutcome::Degraded { label } => format!(
"serving {release_id} \u{2014} DEGRADED: `{label}` failed after the cutover; \
traffic is fine, but repair it (a redeploy of this host does) before the \
next deploy refuses it"
),
HostOutcome::LiveOnNew { failed_step } => {
format!(
"serving {release_id} \u{2014} but `{failed_step}` failed AFTER the cutover"
)
}
HostOutcome::AmbiguousMarkers => format!(
"serving {release_id} \u{2014} `{AMBIGUOUS_MARKERS_LABEL}` failed AFTER the \
cutover, so the release markers are mid-transaction and this host was NOT \
rolled back automatically"
),
HostOutcome::Manual { reason } => {
format!("serving {release_id} \u{2014} NOT rolled back automatically: {reason}")
}
HostOutcome::RolledBack { failed_step } => {
format!("previous release still serving (rolled back at `{failed_step}`)")
}
HostOutcome::TornDown { failed_step } => {
format!("NOTHING serving (first deploy torn down at `{failed_step}`)")
}
HostOutcome::CompensatedRollback => {
"previous release restored (rolled back by the fleet)".to_owned()
}
HostOutcome::CompensatedTeardown => {
"NOTHING serving (the fleet removed this host's new first deploy; its proxy \
still holds the route until the next deploy)"
.to_owned()
}
HostOutcome::CompensationFailed { failed_step } => format!(
"serving {release_id} \u{2014} the compensating rollback FAILED at \
`{failed_step}`"
),
};
let marker = match outcome {
HostOutcome::Serving => "\u{2705}",
HostOutcome::Untouched => "\u{2013}",
HostOutcome::Degraded { .. } => "\u{26A0}\u{FE0F} ",
HostOutcome::CompensatedRollback | HostOutcome::CompensatedTeardown => {
"\u{21A9}\u{FE0F} "
}
_ => "\u{274C}",
};
rows.push((marker, host.host.as_str(), state));
}
let mut lines = state_table_lines("Fleet state:", &rows);
let on_new: Vec<&str> = plan
.hosts
.iter()
.zip(outcomes)
.filter(|(_, outcome)| outcome.on_new_release())
.map(|(host, _)| host.host.as_str())
.collect();
if !on_new.is_empty() {
lines.push(format!(
" On {release_id}: {}. {FLEET_RECOVERY_HINT}",
on_new.join(", ")
));
}
if schema_moved(plan, outcomes) {
if !on_new.is_empty() {
lines.push(format!(" \u{26A0}\u{FE0F} {FLEET_SCHEMA_MOVED_NOTE}"));
}
let compensated = outcomes.iter().any(|outcome| {
matches!(
outcome,
HostOutcome::CompensatedRollback | HostOutcome::CompensatedTeardown
)
});
if compensated {
lines.push(format!(
" \u{26A0}\u{FE0F} {FLEET_SCHEMA_NOT_ROLLED_BACK_NOTE}"
));
} else if on_new.is_empty() && outcomes.iter().any(HostOutcome::went_back) {
lines.push(format!(
" \u{26A0}\u{FE0F} {FLEET_SCHEMA_AHEAD_OF_BINARIES_NOTE}"
));
}
}
lines
}
pub(crate) fn fleet_completion_line(
release_id: &str,
total: usize,
configured_host_count: usize,
degraded: usize,
) -> String {
let hosts_word = if total == 1 { "host" } else { "hosts" };
let untouched = configured_host_count.saturating_sub(total);
let degraded_clause = if degraded == 0 {
String::new()
} else {
format!(
" {degraded} finished with post-cutover housekeeping failures (above) \u{2014} \
repair them before the next deploy."
)
};
let marker = if degraded == 0 {
"\u{2705}"
} else {
"\u{26A0}\u{FE0F} "
};
if untouched == 0 {
return if degraded == 0 {
format!(
"\n\u{2705} Fleet deploy complete \u{2014} all {total} hosts serving {release_id}."
)
} else {
format!(
"\n\u{26A0}\u{FE0F} Fleet deploy complete \u{2014} all {total} hosts serve \
{release_id}, but {degraded} finished with post-cutover housekeeping failures \
(above). Repair them before the next deploy."
)
};
}
let untouched_word = if untouched == 1 { "host" } else { "hosts" };
format!(
"\n{marker} `--only` run complete \u{2014} the {total} {hosts_word} it targeted now serve \
{release_id}.{degraded_clause} THIS RUN WAS NARROWED: the other {untouched} configured \
{untouched_word} were NOT touched and may be on another release \u{2014} finish with a \
full `autumn deploy up` (no `--only`) so the fleet converges."
)
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub(crate) enum ReleaseId {
Known(String),
Unknown,
}
pub(crate) const DRIFT_LIVE_SLOT_MARKER: &str =
"live-slot marker disagrees with the slot kamal-proxy is serving";
pub(crate) const DRIFT_HOST_NOT_DEPLOYED: &str = "no release is deployed on this host while the rest of the fleet is serving one — it is \
carrying no traffic (deploy it with `autumn deploy up`)";
pub(crate) const DRIFT_PROXY_OPTIONS_UNREADABLE: &str =
"shared/proxy-options marker is unreadable — the NEXT deploy of this host will refuse";
pub(crate) const DRIFT_PROXY_PORT_MISMATCH: &str =
"the installed proxy unit binds a different public port than `[server] port` configures";
pub(crate) const DRIFT_RELEASE_UNREADABLE: &str = "this host has a `current` symlink but the release it points at could not be read (a broken \
symlink or a missing releases dir) — repair it before the next deploy, which would record \
that unresolvable target as this host's rollback point";
pub(crate) const DRIFT_MAINTENANCE_UNPROVEN: &str = "the live slot unit could not be read, so which maintenance flag file this host's app \
polls is unknown — the maintenance column reports `?` rather than guessing";
pub(crate) const DRIFT_MAINTENANCE_UNSHARED_FLAG: &str = "this host's app polls a release-local maintenance flag file, not the shared one (its slot \
unit predates AUTUMN_MAINTENANCE_FLAG_FILE) — redeploy it so maintenance survives cutovers";
pub(crate) const LAST_DEPLOY_SCOPE_NOTE: &str = "`last deploy` is the last action each host COMPLETED (`deployed` / `rolled back`, with \
the host's UTC time) \u{2014} a `rolled back` host was compensated or rolled back after its \
last cutover. A deploy that failed BEFORE cutover never changes it, so it is not a verdict \
on the last rollout, and it is reported rather than counted as drift.";
pub(crate) const MAINTENANCE_DOES_NOT_DRAIN_NOTE: &str = "maintenance mode does NOT drain a host from your load balancer: `/ready` stays 200 by \
design, so a maintained host keeps taking traffic and answers it with 503. Drain at the \
load balancer if you need the host out of rotation.";
#[derive(Debug, Clone, PartialEq, Eq)]
pub(crate) struct HostStatus {
pub(crate) host: String,
pub(crate) reachable: bool,
pub(crate) mode: Option<HostMode>,
pub(crate) release: ReleaseId,
pub(crate) live_slot: Option<&'static str>,
pub(crate) ready_code: Option<u16>,
pub(crate) maintenance: exec::MaintenanceStatus,
pub(crate) maintenance_flag_source: exec::MaintenanceFlagSource,
pub(crate) installed_proxy_port: exec::InstalledProxyPort,
pub(crate) proxy_options: exec::ProxyOptionsMarker,
pub(crate) public_port: u16,
pub(crate) live_slot_marker_drift: bool,
pub(crate) last_deploy: Option<exec::LastDeploy>,
}
impl HostStatus {
pub(crate) fn unreachable(host: impl Into<String>) -> Self {
Self {
host: host.into(),
reachable: false,
mode: None,
release: ReleaseId::Unknown,
live_slot: None,
ready_code: None,
maintenance: exec::MaintenanceStatus::Unknown,
maintenance_flag_source: exec::MaintenanceFlagSource::Unknown,
installed_proxy_port: exec::InstalledProxyPort::Absent,
proxy_options: exec::ProxyOptionsMarker::Absent,
public_port: 0,
live_slot_marker_drift: false,
last_deploy: None,
}
}
pub(crate) fn from_probe(
cfg: &ResolvedDeployConfig,
public_port: u16,
probe: &exec::HostStatusProbe,
) -> Self {
let reconcile = probe.reconcile(cfg, public_port);
Self {
host: cfg.host.clone().unwrap_or_default(),
reachable: true,
mode: Some(HostMode::from_deploy_mode(&probe.deploy.mode)),
release: probe
.deploy
.current_release_dir
.as_deref()
.and_then(exec::release_id_from_dir)
.map_or(ReleaseId::Unknown, |id| ReleaseId::Known(id.to_owned())),
live_slot: reconcile.as_ref().map(|r| r.live_slot),
ready_code: probe.ready_code,
maintenance: probe.maintenance,
maintenance_flag_source: probe.maintenance_flag_source,
installed_proxy_port: probe.deploy.installed_proxy_port.clone(),
proxy_options: probe.deploy.last_proxy_options.clone(),
public_port,
live_slot_marker_drift: reconcile.is_some_and(|r| r.repair),
last_deploy: probe.last_deploy.clone(),
}
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub(crate) struct DriftReport {
pub(crate) releases: Vec<(String, ReleaseId)>,
pub(crate) version_drift: bool,
pub(crate) state_drift: Vec<(String, &'static str)>,
}
impl DriftReport {
pub(crate) const fn drifted(&self) -> bool {
self.version_drift || !self.state_drift.is_empty()
}
pub(crate) fn unknown_releases(&self) -> Vec<&str> {
self.releases
.iter()
.filter(|(_, release)| matches!(release, ReleaseId::Unknown))
.map(|(host, _)| host.as_str())
.collect()
}
pub(crate) fn known_releases(&self) -> Vec<&str> {
let mut seen: Vec<&str> = Vec::new();
for (_, release) in &self.releases {
if let ReleaseId::Known(id) = release
&& !seen.contains(&id.as_str())
{
seen.push(id.as_str());
}
}
seen
}
}
pub(crate) fn fleet_drift(hosts: &[HostStatus]) -> DriftReport {
let releases: Vec<(String, ReleaseId)> = hosts
.iter()
.map(|status| (status.host.clone(), status.release.clone()))
.collect();
let mut known: Vec<&str> = Vec::new();
for status in hosts {
if let ReleaseId::Known(id) = &status.release
&& !known.contains(&id.as_str())
{
known.push(id.as_str());
}
}
let any_release_deployed = hosts
.iter()
.any(|status| status.reachable && matches!(status.release, ReleaseId::Known(_)));
let mut state_drift: Vec<(String, &'static str)> = Vec::new();
for status in hosts.iter().filter(|status| status.reachable) {
if status.mode == Some(HostMode::First) && any_release_deployed {
state_drift.push((status.host.clone(), DRIFT_HOST_NOT_DEPLOYED));
}
if status.mode == Some(HostMode::Redeploy) && matches!(status.release, ReleaseId::Unknown) {
state_drift.push((status.host.clone(), DRIFT_RELEASE_UNREADABLE));
}
if status.live_slot_marker_drift {
state_drift.push((status.host.clone(), DRIFT_LIVE_SLOT_MARKER));
}
if matches!(status.proxy_options, exec::ProxyOptionsMarker::Unreadable) {
state_drift.push((status.host.clone(), DRIFT_PROXY_OPTIONS_UNREADABLE));
}
if matches!(status.installed_proxy_port, exec::InstalledProxyPort::Port(port) if port != status.public_port)
{
state_drift.push((status.host.clone(), DRIFT_PROXY_PORT_MISMATCH));
}
if status.mode == Some(HostMode::Redeploy) {
match status.maintenance_flag_source {
exec::MaintenanceFlagSource::Unknown => {
state_drift.push((status.host.clone(), DRIFT_MAINTENANCE_UNPROVEN));
}
exec::MaintenanceFlagSource::Unshared => {
state_drift.push((status.host.clone(), DRIFT_MAINTENANCE_UNSHARED_FLAG));
}
exec::MaintenanceFlagSource::Shared => {}
}
}
}
DriftReport {
version_drift: known.len() > 1,
releases,
state_drift,
}
}
fn status_row_cells(status: &HostStatus, report: &DriftReport) -> [String; 8] {
[
match status.mode {
_ if !status.reachable => "unreachable".to_owned(),
Some(HostMode::First) => "not deployed".to_owned(),
Some(HostMode::Redeploy) => "deployed".to_owned(),
None => "unknown".to_owned(),
},
match &status.release {
ReleaseId::Known(id) => id.clone(),
ReleaseId::Unknown => "release unknown".to_owned(),
},
status.live_slot.unwrap_or("slot ?").to_owned(),
status
.ready_code
.map_or_else(|| "ready ?".to_owned(), |code| format!("ready {code}")),
match status.maintenance {
exec::MaintenanceStatus::On => "maintenance ON".to_owned(),
exec::MaintenanceStatus::Off => "maintenance off".to_owned(),
exec::MaintenanceStatus::Unknown => "maintenance ?".to_owned(),
},
match &status.installed_proxy_port {
exec::InstalledProxyPort::Port(port) => format!("proxy {port}"),
exec::InstalledProxyPort::Absent => "proxy absent".to_owned(),
exec::InstalledProxyPort::Unreadable => "proxy ?".to_owned(),
},
status.last_deploy.as_ref().map_or_else(
|| "last deploy: ?".to_owned(),
|last| {
let mut cell = format!("last deploy: {}", last.result);
if let Some(at) = &last.at {
cell.push(' ');
cell.push_str(at);
}
cell
},
),
{
let reasons: Vec<&str> = report
.state_drift
.iter()
.filter(|(host, _)| *host == status.host)
.map(|(_, reason)| *reason)
.collect();
if reasons.is_empty() {
String::new()
} else {
format!("\u{26A0}\u{FE0F} {}", reasons.join("; "))
}
},
]
}
pub(crate) fn fleet_status_lines(hosts: &[HostStatus], report: &DriftReport) -> Vec<String> {
let cells: Vec<[String; 8]> = hosts
.iter()
.map(|status| status_row_cells(status, report))
.collect();
let mut widths = [0_usize; 8];
for row in &cells {
for (width, cell) in widths.iter_mut().zip(row) {
*width = (*width).max(cell.chars().count());
}
}
let rows: Vec<(&'static str, &str, String)> = hosts
.iter()
.zip(&cells)
.map(|(status, cells)| {
let marker = if !status.reachable {
"\u{274C}"
} else if report
.state_drift
.iter()
.any(|(host, _)| *host == status.host)
|| status.maintenance == exec::MaintenanceStatus::On
{
"\u{26A0}\u{FE0F} "
} else {
"\u{2705}"
};
let mut state = String::new();
for (index, cell) in cells.iter().enumerate() {
if index > 0 {
state.push_str(" ");
}
state.push_str(cell);
if index + 1 < cells.len() {
for _ in cell.chars().count()..widths[index] {
state.push(' ');
}
}
}
(marker, status.host.as_str(), state.trim_end().to_owned())
})
.collect();
let mut lines = state_table_lines("Fleet status:", &rows);
if report.version_drift {
lines.push(format!(
" \u{26A0}\u{FE0F} VERSION DRIFT: {} distinct releases across this fleet ({}). \
Converge with `autumn deploy up`, or roll the fleet back with \
`autumn deploy rollback`.",
report.known_releases().len(),
report.known_releases().join(", "),
));
}
let unknown = report.unknown_releases();
if !unknown.is_empty() {
let (not_deployed, rest): (Vec<&str>, Vec<&str>) = unknown.iter().partition(|host| {
hosts.iter().any(|status| {
status.host == **host && status.reachable && status.mode == Some(HostMode::First)
})
});
let unreadable: Vec<&str> = rest
.into_iter()
.filter(|host| {
!hosts.iter().any(|status| {
status.host == **host
&& status.reachable
&& status.mode == Some(HostMode::Redeploy)
})
})
.collect();
if !not_deployed.is_empty() {
lines.push(format!(
" \u{2139}\u{FE0F} no release deployed on {} \u{2014} nothing is installed \
there yet.",
not_deployed.join(", "),
));
}
if !unreadable.is_empty() {
lines.push(format!(
" \u{2139}\u{FE0F} release unknown on {} (the `current` symlink was unreadable) \
\u{2014} reported, not counted as drift.",
unreadable.join(", "),
));
}
}
for (host, reason) in &report.state_drift {
lines.push(format!(" \u{26A0}\u{FE0F} {host}: {reason}"));
}
if hosts.iter().any(|status| status.reachable) {
lines.push(format!(" \u{2139}\u{FE0F} {LAST_DEPLOY_SCOPE_NOTE}"));
}
if hosts
.iter()
.any(|status| status.maintenance == exec::MaintenanceStatus::On)
{
lines.push(format!(
" \u{2139}\u{FE0F} {MAINTENANCE_DOES_NOT_DRAIN_NOTE}"
));
}
lines
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(crate) enum MaintenanceOutcome {
Applied,
AppliedSharedOnly,
LiveUnitUnchanged {
failed_step: &'static str,
},
Failed {
failed_step: &'static str,
},
}
impl MaintenanceOutcome {
pub(crate) const fn failed(self) -> bool {
matches!(self, Self::Failed { .. } | Self::LiveUnitUnchanged { .. })
}
}
pub(crate) fn fleet_maintenance_summary_lines(
hosts: &[String],
outcomes: &[MaintenanceOutcome],
on: bool,
) -> Vec<String> {
let verb = if on { "enabled" } else { "disabled" };
let mut rows: Vec<(&'static str, &str, String)> = Vec::with_capacity(hosts.len());
for (host, outcome) in hosts.iter().zip(outcomes) {
let (marker, state) = match outcome {
MaintenanceOutcome::Applied => ("\u{2705}", format!("maintenance {verb}")),
MaintenanceOutcome::AppliedSharedOnly => (
"\u{26A0}\u{FE0F} ",
format!(
"maintenance {verb} (shared flag only \u{2014} no release is promoted on \
this host, so no running unit polls a release-local flag)"
),
),
MaintenanceOutcome::LiveUnitUnchanged { failed_step } => (
"\u{274C}",
format!(
"PARTIAL \u{2014} shared flag {written}, but the file this host's RUNNING \
unit polls was NOT (failed at `{failed_step}`), so this host may still \
be {state}",
written = if on { "written" } else { "removed" },
state = if on {
"serving traffic"
} else {
"in maintenance"
},
),
),
MaintenanceOutcome::Failed { failed_step } => (
"\u{274C}",
format!("UNCHANGED \u{2014} failed at `{failed_step}`"),
),
};
rows.push((marker, host.as_str(), state));
}
let mut lines = state_table_lines("Fleet maintenance state:", &rows);
let changed: Vec<&str> = hosts
.iter()
.zip(outcomes)
.filter(|(_, outcome)| !outcome.failed())
.map(|(host, _)| host.as_str())
.collect();
let failed: Vec<&str> = hosts
.iter()
.zip(outcomes)
.filter(|(_, outcome)| outcome.failed())
.map(|(host, _)| host.as_str())
.collect();
if !failed.is_empty() && !changed.is_empty() {
lines.push(format!(
" \u{26A0}\u{FE0F} Changed anyway: {changed}. NOT reversed automatically \u{2014} \
reverse them with `autumn deploy maintenance {reverse}` if the fleet must stay \
consistent while {failed} is repaired.",
changed = changed.join(", "),
reverse = if on { "off" } else { "on" },
failed = failed.join(", "),
));
}
if on {
lines.push(format!(
" \u{2139}\u{FE0F} {MAINTENANCE_DOES_NOT_DRAIN_NOTE}"
));
}
lines
}
pub(crate) const FLEET_ONLY_MIXED_WARNING: &str = "`--only` narrows this command to part of the fleet, so the hosts it skips keep \
running whatever they are running now — THE FLEET MAY END UP MIXED. This is a \
repair lever, not a faster deploy: finish with a full `autumn deploy up` (no \
`--only`) so every host converges on one release (#1621).";
pub(crate) fn only_selection_warning_lines(
configured: &[String],
selected: &[String],
) -> Vec<String> {
let skipped: Vec<&str> = configured
.iter()
.filter(|host| !selected.iter().any(|picked| picked == *host))
.map(String::as_str)
.collect();
if skipped.is_empty() {
return Vec::new();
}
vec![
format!("\u{26A0}\u{FE0F} {FLEET_ONLY_MIXED_WARNING}"),
format!(
" this run touches: {}",
selected
.iter()
.map(String::as_str)
.collect::<Vec<_>>()
.join(", "),
),
format!(" left as they are: {}", skipped.join(", ")),
String::new(),
]
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(crate) enum RollbackOutcome {
RolledBack,
NoPreviousRelease,
Failed {
failed_step: &'static str,
},
}
pub(crate) fn fleet_rollback_summary_lines(
hosts: &[String],
outcomes: &[RollbackOutcome],
) -> Vec<String> {
let mut rows: Vec<(&'static str, &str, String)> = Vec::with_capacity(hosts.len());
for (host, outcome) in hosts.iter().zip(outcomes) {
let (marker, state) = match outcome {
RollbackOutcome::RolledBack => {
("\u{21A9}\u{FE0F} ", "previous release restored".to_owned())
}
RollbackOutcome::NoPreviousRelease => (
"\u{26A0}\u{FE0F} ",
"SKIPPED \u{2014} no previous release recorded on this host, so there is \
nothing to roll back to; it still serves what it served before"
.to_owned(),
),
RollbackOutcome::Failed { failed_step } => (
"\u{274C}",
format!(
"NOT rolled back \u{2014} failed at `{failed_step}`; this host still \
serves what it served before"
),
),
};
rows.push((marker, host.as_str(), state));
}
let mut lines = state_table_lines("Fleet rollback state:", &rows);
let stalled: Vec<&str> = hosts
.iter()
.zip(outcomes)
.filter(|(_, outcome)| !matches!(outcome, RollbackOutcome::RolledBack))
.map(|(host, _)| host.as_str())
.collect();
if !stalled.is_empty() {
lines.push(format!(
" \u{26A0}\u{FE0F} The fleet is now MIXED: {} did not roll back. Retry one host \
with `autumn deploy rollback --only <host>`, or deploy the intended release to \
it explicitly.",
stalled.join(", "),
));
}
lines.push(
" \u{26A0}\u{FE0F} This restored BINARIES only \u{2014} no migration was rolled \
back; confirm the schema still fits the release now serving."
.to_owned(),
);
lines
}
#[cfg(test)]
pub(crate) mod test_support {
use super::{FleetPlan, HostMode, HostOpsInput, HostPlan, host_ops};
use crate::deploy::ResolvedDeployConfig;
use crate::deploy::exec::test_support::{FleetTape, RecordedCall, RecordingExecutor};
use crate::deploy::exec::{
DeployOp, ManifestUpload, ProxyServiceOptions, SLOT_BLUE, Secret, SlotPlan,
};
use crate::deploy::{ResolvedFleet, render_app_unit};
use std::path::{Path, PathBuf};
use std::rc::Rc;
#[derive(Default)]
struct HostScript {
host: String,
stdout: Vec<(&'static str, String)>,
fail: Vec<&'static str>,
transport_fail: Vec<&'static str>,
upload_fail: Vec<String>,
}
#[derive(Default)]
pub(crate) struct FleetRecorder {
tape: FleetTape,
scripts: Vec<HostScript>,
}
impl FleetRecorder {
pub(crate) fn new() -> Self {
Self::default()
}
fn entry(&mut self, host: &str) -> &mut HostScript {
if let Some(index) = self.scripts.iter().position(|s| s.host == host) {
return &mut self.scripts[index];
}
self.scripts.push(HostScript {
host: host.to_owned(),
..HostScript::default()
});
self.scripts
.last_mut()
.expect("a script was just pushed for this host")
}
pub(crate) fn script(
mut self,
host: &str,
label: &'static str,
stdout: impl Into<String>,
) -> Self {
let stdout = stdout.into();
self.entry(host).stdout.push((label, stdout));
self
}
pub(crate) fn fail(mut self, host: &str, label: &'static str) -> Self {
self.entry(host).fail.push(label);
self
}
pub(crate) fn transport_fail(mut self, host: &str, label: &'static str) -> Self {
self.entry(host).transport_fail.push(label);
self
}
pub(crate) fn fail_upload(mut self, host: &str, fragment: impl Into<String>) -> Self {
self.entry(host).upload_fail.push(fragment.into());
self
}
pub(crate) fn executor(&self, cfg: &ResolvedDeployConfig) -> RecordingExecutor {
let host = cfg.host.clone().unwrap_or_default();
let mut exec = RecordingExecutor::new()
.strict()
.recording_as(host.clone(), Rc::clone(&self.tape));
if let Some(script) = self.scripts.iter().find(|s| s.host == host) {
for (label, stdout) in &script.stdout {
exec = exec.with_stdout(label, stdout.clone());
}
for label in &script.fail {
exec = exec.failing(label);
}
for label in &script.transport_fail {
exec = exec.transport_failing(label);
}
for fragment in &script.upload_fail {
exec = exec.failing_upload(fragment.clone());
}
}
exec
}
pub(crate) fn calls_for(&self, host: &str) -> Vec<RecordedCall> {
self.tape
.borrow()
.iter()
.filter(|(h, _)| h == host)
.map(|(_, call)| call.clone())
.collect()
}
pub(crate) fn run_labels_for(&self, host: &str) -> Vec<&'static str> {
self.calls_for(host)
.iter()
.filter_map(RecordedCall::run_label)
.collect()
}
pub(crate) fn first_mutating(&self, host: &str, read_only: &[&str]) -> Option<usize> {
self.tape.borrow().iter().position(|(h, call)| {
h == host && !call.run_label().is_some_and(|l| read_only.contains(&l))
})
}
pub(crate) fn positions_of(&self, label: &str) -> Vec<usize> {
self.tape
.borrow()
.iter()
.enumerate()
.filter(|(_, (_, call))| call.run_label() == Some(label))
.map(|(index, _)| index)
.collect()
}
pub(crate) fn index_of(&self, host: &str, label: &str) -> Option<usize> {
self.tape
.borrow()
.iter()
.position(|(h, call)| h == host && call.run_label() == Some(label))
}
pub(crate) fn mutating(&self, read_only: &[&str]) -> Vec<(String, RecordedCall)> {
self.tape
.borrow()
.iter()
.filter(|(_, call)| !call.run_label().is_some_and(|l| read_only.contains(&l)))
.cloned()
.collect()
}
}
pub(crate) const RELEASE_ID: &str = "20260714T120000Z";
pub(crate) const PUBLIC_PORT: u16 = 3000;
fn manifests() -> Vec<ManifestUpload> {
vec![ManifestUpload {
local: PathBuf::from("/local/autumn.toml"),
remote_basename: "autumn.toml".to_owned(),
}]
}
pub(crate) fn host_ops_for(cfg: &ResolvedDeployConfig, plan: &HostPlan) -> Vec<DeployOp> {
let slots = match plan.mode {
HostMode::First => SlotPlan::first(PUBLIC_PORT),
HostMode::Redeploy => SlotPlan::redeploy(PUBLIC_PORT, SLOT_BLUE),
};
let release_dir = format!("{}/{RELEASE_ID}", cfg.releases_dir());
let unit = render_app_unit(
cfg,
&release_dir,
slots.candidate_port,
slots.candidate_slot,
);
host_ops(
plan,
&HostOpsInput {
cfg,
proxy: &crate::deploy::proxy::KamalProxyController::new(60),
unit: &unit,
env_file: &Secret::new("AUTUMN_SECURITY__SIGNING_SECRET=x\n"),
binary_local: Path::new("/local/target/release/myapp"),
manifests: &manifests(),
release_id: RELEASE_ID,
slots: &slots,
reregister_options: &ProxyServiceOptions {
tls: false,
host: None,
},
},
)
}
pub(crate) fn per_host_ops(fleet: &ResolvedFleet, plan: &FleetPlan) -> Vec<Vec<DeployOp>> {
fleet
.hosts
.iter()
.zip(&plan.hosts)
.map(|(cfg, host_plan)| host_ops_for(cfg, host_plan))
.collect()
}
pub(crate) fn fleet_op_labels(fleet: &ResolvedFleet, plan: &FleetPlan) -> Vec<&'static str> {
per_host_ops(fleet, plan)
.iter()
.flat_map(|ops| ops.iter().map(DeployOp::label).collect::<Vec<_>>())
.collect()
}
}
#[cfg(test)]
mod tests {
use super::test_support::per_host_ops;
use super::*;
use autumn_web::config::DeployConfig;
fn fleet_of(hosts: &[&str]) -> ResolvedFleet {
ResolvedFleet::resolve(
&DeployConfig {
hosts: hosts.iter().map(|h| (*h).to_owned()).collect(),
..DeployConfig::default()
},
"myapp",
)
.expect("a well-formed fleet resolves")
}
fn labels(ops: &[DeployOp]) -> Vec<&'static str> {
ops.iter().map(DeployOp::label).collect()
}
fn shell_for<'a>(ops: &'a [DeployOp], label: &str) -> Option<&'a str> {
ops.iter().find_map(|op| match op {
DeployOp::Run(cmd) if cmd.label == label => Some(cmd.shell.as_str()),
_ => None,
})
}
fn upload_path<'a>(ops: &'a [DeployOp], label: &str) -> Option<&'a str> {
ops.iter().find_map(|op| match op {
DeployOp::UploadFile {
label: l,
remote_path,
..
} if *l == label => Some(remote_path.as_str()),
_ => None,
})
}
fn plan_and_build(hosts: &[&str], modes: &[HostMode]) -> (FleetPlan, Vec<Vec<DeployOp>>) {
let fleet = fleet_of(hosts);
let plan = plan_fleet(&fleet, modes).expect("a well-formed fleet plans");
let ops = per_host_ops(&fleet, &plan);
(plan, ops)
}
#[test]
fn fleet_plan_runs_migrations_exactly_once() {
let (plan, per_host) = plan_and_build(
&["web-a", "web-b", "web-c"],
&[HostMode::Redeploy, HostMode::Redeploy, HostMode::Redeploy],
);
assert_eq!(
plan.migrating_host().map(|h| h.host.as_str()),
Some("web-a"),
"an all-redeploy fleet migrates on the first host, got: {:?}",
plan.hosts
);
assert_eq!(
plan.hosts
.iter()
.filter(|h| h.migrate == MigrateStep::Run)
.count(),
1,
"exactly one host may be assigned the migration, got: {:?}",
plan.hosts
);
let flat: Vec<&'static str> = per_host.iter().flat_map(|ops| labels(ops)).collect();
assert_eq!(
flat.iter().filter(|l| **l == "migrate").count(),
1,
"a fleet rollout must schedule exactly one migrate op, got: {flat:?}"
);
let migrate = flat
.iter()
.position(|l| *l == "migrate")
.expect("the fleet migrates");
let first_flip = flat
.iter()
.position(|l| *l == "proxy-flip" || *l == "proxy-route")
.expect("the fleet cuts over somewhere");
assert!(
migrate < first_flip,
"the migration must precede the first cutover in the fleet: \
migrate at {migrate}, first boundary at {first_flip}, labels: {flat:?}"
);
let migrate_shell = per_host
.iter()
.find_map(|ops| shell_for(ops, "migrate"))
.expect("one host carries the migrate op");
assert!(
migrate_shell.contains("--setenv=AUTUMN_MIGRATE=1"),
"the fleet migrate op must still trigger the app's migrate-only mode: {migrate_shell}"
);
assert!(
migrate_shell.contains("--wait"),
"the fleet migrate op must still block on the one-shot's exit status: {migrate_shell}"
);
}
#[test]
fn every_fleet_host_vector_carries_exactly_one_boundary_label() {
let (plan, per_host) = plan_and_build(
&["web-a", "web-b", "web-c"],
&[HostMode::First, HostMode::Redeploy, HostMode::Redeploy],
);
for (host_plan, ops) in plan.hosts.iter().zip(&per_host) {
let host_labels = labels(ops);
let expected = match host_plan.mode {
HostMode::First => "proxy-route",
HostMode::Redeploy => "proxy-flip",
};
assert_eq!(
host_labels.iter().filter(|l| **l == expected).count(),
1,
"{} must carry exactly one `{expected}` boundary, got: {host_labels:?}",
host_plan.host
);
let other = match host_plan.mode {
HostMode::First => "proxy-flip",
HostMode::Redeploy => "proxy-route",
};
assert!(
!host_labels.contains(&other),
"{} must not carry the other mode's boundary `{other}`, got: {host_labels:?}",
host_plan.host
);
}
}
#[test]
fn migrate_placement_picks_the_first_host_in_rollout_order() {
assert_eq!(
migrate_placement(&[HostMode::First, HostMode::Redeploy, HostMode::Redeploy]),
MigratePlacement::FirstHost(0),
"a leading first-deploy host carries the migration rather than deferring it"
);
assert_eq!(
migrate_placement(&[HostMode::Redeploy, HostMode::Redeploy]),
MigratePlacement::FirstHost(0),
"an all-redeploy fleet migrates on host 1"
);
assert_eq!(
migrate_placement(&[HostMode::First, HostMode::First]),
MigratePlacement::FirstHost(0),
"an all-first-deploy fleet migrates on host 1 (it no longer migrates nowhere)"
);
assert_eq!(
migrate_placement(&[]),
MigratePlacement::None,
"an empty mode list places no migration"
);
}
#[test]
fn host_mode_projects_the_probed_deploy_mode() {
assert_eq!(
HostMode::from_deploy_mode(&exec::DeployMode::First),
HostMode::First,
"an unpromoted host takes the first-deploy path"
);
assert_eq!(
HostMode::from_deploy_mode(&exec::DeployMode::Redeploy {
live_slot: exec::SLOT_GREEN
}),
HostMode::Redeploy,
"a host already serving takes the cutover path, whichever slot is live"
);
assert_eq!(
HostMode::from_deploy_mode(&exec::DeployMode::Redeploy {
live_slot: exec::SLOT_BLUE
}),
HostMode::Redeploy,
"the live slot must not change the planning decision"
);
}
#[test]
fn a_mixed_fleet_migrates_once_on_host_one_before_any_cutover() {
let (plan, per_host) =
plan_and_build(&["web-a", "web-b"], &[HostMode::First, HostMode::Redeploy]);
assert_eq!(
plan.hosts
.iter()
.filter(|h| h.migrate == MigrateStep::Run)
.map(|h| h.host.as_str())
.collect::<Vec<_>>(),
vec!["web-a"],
"the leading first-deploy host carries the migration: {:?}",
plan.hosts
);
let flat: Vec<&'static str> = per_host.iter().flat_map(|ops| labels(ops)).collect();
assert_eq!(
flat.iter().filter(|l| **l == "migrate").count(),
1,
"a mixed fleet migrates exactly once, got: {flat:?}"
);
let migrate = flat.iter().position(|l| *l == "migrate").expect("migrates");
let boundary = flat
.iter()
.position(|l| *l == "proxy-route" || *l == "proxy-flip")
.expect("the fleet cuts over");
assert!(
migrate < boundary,
"the migration must precede the earliest cutover: migrate at {migrate}, \
boundary at {boundary}, labels: {flat:?}"
);
}
#[test]
fn an_all_first_deploy_fleet_migrates_exactly_once_on_host_one() {
let (plan, per_host) =
plan_and_build(&["web-a", "web-b"], &[HostMode::First, HostMode::First]);
assert_eq!(
plan.hosts
.iter()
.filter(|h| h.migrate == MigrateStep::Run)
.map(|h| h.host.as_str())
.collect::<Vec<_>>(),
vec!["web-a"],
"host 1 alone carries the migration, got: {:?}",
plan.hosts
);
let flat: Vec<&'static str> = per_host.iter().flat_map(|ops| labels(ops)).collect();
assert_eq!(
flat.iter().filter(|l| **l == "migrate").count(),
1,
"an all-first-deploy fleet must migrate exactly once, got: {flat:?}"
);
let migrate = flat.iter().position(|l| *l == "migrate").expect("migrates");
let first_route = flat
.iter()
.position(|l| *l == "proxy-route")
.expect("the fleet routes its first host");
assert!(
migrate < first_route,
"the migration must precede the earliest cutover: migrate at {migrate}, \
first route at {first_route}, labels: {flat:?}"
);
}
#[test]
fn every_fleet_host_deploys_the_identical_release_id() {
let (_plan, per_host) = plan_and_build(
&["web-a", "web-b", "web-c"],
&[HostMode::Redeploy, HostMode::Redeploy, HostMode::Redeploy],
);
let expected_dir = "/srv/autumn/myapp/releases/20260714T120000Z";
let mut binary_paths: Vec<&str> = Vec::new();
for ops in &per_host {
let binary = upload_path(ops, "upload-binary").expect("each host uploads the binary");
assert_eq!(
binary,
format!("{expected_dir}/myapp"),
"every host must upload into the SAME fleet release dir"
);
let prepare = shell_for(ops, "prepare-dirs").expect("each host prepares its dirs");
assert!(
prepare.contains(expected_dir),
"every host must prepare the SAME fleet release dir: {prepare}"
);
binary_paths.push(binary);
}
binary_paths.dedup();
assert_eq!(
binary_paths.len(),
1,
"the fleet must deploy exactly one release id, got: {binary_paths:?}"
);
}
#[test]
fn plan_fleet_refuses_a_mode_count_mismatch_without_panicking() {
let fleet = fleet_of(&["web-a", "web-b"]);
let err = plan_fleet(&fleet, &[HostMode::Redeploy])
.expect_err("a short mode list must be refused");
assert_eq!(
err,
FleetPlanError::ModeCountMismatch { hosts: 2, modes: 1 },
"the refusal must name both counts, got: {err:?}"
);
}
#[test]
fn failure_classification_distinguishes_a_clean_host_from_a_live_one() {
let boxed = || {
Box::new(exec::DeployExecError::CommandFailed {
label: "readiness-gate",
message: "scripted".to_owned(),
})
};
assert_eq!(
classify_failure(&exec::DeployExecError::CandidateRolledBack {
failed_step: "readiness-gate",
source: boxed(),
}),
HostOutcome::RolledBack {
failed_step: "readiness-gate"
},
"a pre-boundary redeploy failure leaves the previous release serving"
);
assert_eq!(
classify_failure(&exec::DeployExecError::FirstDeployTornDown {
failed_step: "readiness-gate",
source: boxed(),
}),
HostOutcome::TornDown {
failed_step: "readiness-gate"
},
"a pre-boundary first deploy has no previous release to fall back to"
);
assert_eq!(
classify_failure(&exec::DeployExecError::CommandFailed {
label: "drain-old",
message: "scripted".to_owned(),
}),
HostOutcome::LiveOnNew {
failed_step: "drain-old"
},
"a post-boundary failure leaves the host running the NEW release"
);
}
#[test]
fn failed_step_labels_are_static_and_never_quote_a_driver_message() {
assert_eq!(
failed_step_label(&exec::DeployExecError::CommandFailed {
label: "migrate",
message: "postgres://user:pw@db/app is unreachable".to_owned(),
}),
"migrate",
"the label is taken, the message is not"
);
assert_eq!(
failed_step_label(&exec::DeployExecError::UploadFailed {
remote_path: "/srv/autumn/myapp/shared/autumn.env".to_owned(),
message: "scripted".to_owned(),
}),
"upload",
"an upload failure attributes to a fixed label, never the remote path"
);
assert_eq!(
failed_step_label(&exec::DeployExecError::ProxyIncompatible {
message: "missing --drain-timeout".to_owned(),
}),
"proxy-compat-probe",
);
assert_eq!(
failed_step_label(&exec::DeployExecError::NoPreviousRelease),
"resolve-previous",
);
}
#[test]
fn the_rollout_header_names_the_single_migrating_host_and_who_skips() {
let fleet = fleet_of(&["web-a", "web-b", "web-c"]);
let plan = plan_fleet(
&fleet,
&[HostMode::First, HostMode::Redeploy, HostMode::Redeploy],
)
.expect("a well-formed fleet plans");
let rendered = fleet_rollout_lines(&plan, "20260714T120000Z").join("\n");
assert!(
rendered.contains("1. web-a — first deploy")
&& rendered.contains("2. web-b — zero-downtime redeploy")
&& rendered.contains("3. web-c — zero-downtime redeploy"),
"the header must show rollout order and each host's probed mode:\n{rendered}"
);
assert!(
rendered.contains("migrate (web-a only") && rendered.contains("web-b, web-c skip it"),
"the header must name the migrating host and the hosts that skip:\n{rendered}"
);
}
#[test]
fn the_rollout_header_drops_the_skip_clause_for_a_one_host_plan() {
let fleet = fleet_of(&["web-b"]);
let plan =
plan_fleet(&fleet, &[HostMode::Redeploy]).expect("a one-host plan is well-formed");
let rendered = fleet_rollout_lines(&plan, "20260714T120000Z").join("\n");
assert!(
rendered.contains(" \u{2192} migrate (web-b only \u{2014} the schema is fleet-wide)"),
"a one-host plan must render the migrate line with no skip clause:\n{rendered}"
);
assert!(
!rendered.contains("skip it"),
"there is no other host in this run to skip the migration:\n{rendered}"
);
assert!(
!rendered.contains("; "),
"no dangling clause may survive on the migrate line:\n{rendered}"
);
assert!(
rendered.contains("across 1 host,"),
"a one-host plan must not say `across 1 hosts`:\n{rendered}"
);
}
#[test]
fn every_rollout_migrates_on_host_one_whatever_the_mix() {
let fleet = fleet_of(&["10.0.0.2", "10.0.0.3", "10.0.0.1"]);
for modes in [
[HostMode::First, HostMode::First, HostMode::Redeploy],
[HostMode::Redeploy, HostMode::First, HostMode::First],
[HostMode::First, HostMode::First, HostMode::First],
[HostMode::Redeploy, HostMode::Redeploy, HostMode::Redeploy],
] {
let plan = plan_fleet(&fleet, &modes).expect("a well-formed fleet plans");
let rendered = fleet_rollout_lines(&plan, "20260714T120000Z").join("\n");
assert!(
rendered.contains("migrate (10.0.0.2 only")
&& rendered.contains("10.0.0.3, 10.0.0.1 skip it"),
"host 1 carries the migration for {modes:?}:\n{rendered}"
);
assert!(
!rendered.contains("\u{26A0}\u{FE0F}"),
"no migration-ordering hazard survives for {modes:?}:\n{rendered}"
);
assert!(
!rendered.contains("autumn migrate"),
"the operator is never told to migrate by hand for {modes:?}:\n{rendered}"
);
}
}
#[test]
fn the_completion_line_is_honest_about_a_narrowed_run() {
let narrowed = fleet_completion_line("20260714T120000Z", 1, 3, 0);
assert!(
!narrowed.contains("Fleet deploy complete"),
"a narrowed run must not claim the fleet is complete:\n{narrowed}"
);
assert!(
narrowed.contains("`--only` run complete")
&& narrowed.contains("the 1 host it targeted now serve 20260714T120000Z")
&& narrowed.contains("THIS RUN WAS NARROWED")
&& narrowed.contains("the other 2 configured hosts were NOT touched")
&& narrowed.contains("may be on another release")
&& narrowed.contains("full `autumn deploy up` (no `--only`)"),
"the narrowed line must name the narrowing, the untouched count and the \
converging command:\n{narrowed}"
);
let narrowed_degraded = fleet_completion_line("20260714T120000Z", 2, 5, 1);
assert!(
narrowed_degraded.contains("THIS RUN WAS NARROWED")
&& narrowed_degraded.contains("the other 3 configured hosts")
&& narrowed_degraded.contains("post-cutover housekeeping failures"),
"a narrowed run with a degraded host must say both:\n{narrowed_degraded}"
);
assert_eq!(
fleet_completion_line("20260714T120000Z", 2, 2, 0),
"\n\u{2705} Fleet deploy complete \u{2014} all 2 hosts serving 20260714T120000Z.",
);
assert_eq!(
fleet_completion_line("20260714T120000Z", 3, 3, 1),
"\n\u{26A0}\u{FE0F} Fleet deploy complete \u{2014} all 3 hosts serve \
20260714T120000Z, but 1 finished with post-cutover housekeeping failures (above). \
Repair them before the next deploy.",
);
assert!(
fleet_completion_line("R", 3, 0, 0).contains("Fleet deploy complete"),
"an under-reported configured count must not fabricate a narrowed run"
);
}
#[test]
fn the_summary_names_every_host_state_and_the_recovery_command() {
let fleet = fleet_of(&["web-a", "web-b", "web-c"]);
let plan = plan_fleet(
&fleet,
&[HostMode::Redeploy, HostMode::Redeploy, HostMode::Redeploy],
)
.expect("a well-formed fleet plans");
let rendered = fleet_summary_lines(
&plan,
&[
HostOutcome::Serving,
HostOutcome::RolledBack {
failed_step: "readiness-gate",
},
HostOutcome::Untouched,
],
"20260714T120000Z",
)
.join("\n");
assert!(
rendered.contains("web-a") && rendered.contains("serving 20260714T120000Z"),
"a cut-over host must be reported with the release it serves:\n{rendered}"
);
assert!(
rendered.contains("previous release still serving")
&& rendered.contains("readiness-gate"),
"a rolled-back host must name the step that failed:\n{rendered}"
);
assert!(
rendered.contains("untouched"),
"an unreached host must be reported as untouched, not silently omitted:\n{rendered}"
);
assert!(
rendered.contains("autumn deploy rollback") && rendered.contains("web-a"),
"the summary must name the recovery command for hosts on the new release:\n{rendered}"
);
assert!(
rendered.contains(FLEET_SCHEMA_MOVED_NOTE),
"the summary must state that an automatic rollback restores binaries only:\n{rendered}"
);
let none = fleet_summary_lines(
&plan,
&[
HostOutcome::TornDown {
failed_step: "readiness-gate",
},
HostOutcome::Untouched,
HostOutcome::Untouched,
],
"20260714T120000Z",
)
.join("\n");
assert!(
!none.contains("autumn deploy rollback") && !none.contains(FLEET_SCHEMA_MOVED_NOTE),
"with no host on the new release there is nothing to roll back:\n{none}"
);
assert!(
none.contains("NOTHING serving"),
"a torn-down first deploy leaves nothing serving and must say so:\n{none}"
);
}
#[test]
fn classify_post_boundary_separates_housekeeping_from_functional_failures() {
for label in ["record-proxy-options", "drain-old", "prune"] {
assert_eq!(
classify_post_boundary(label),
PostBoundaryClass::Housekeeping,
"`{label}` runs after the flip and does not touch traffic, so the rollout \
must continue"
);
}
assert_eq!(
classify_post_boundary("commit-markers"),
PostBoundaryClass::Ambiguous,
"`commit-markers` writes the marker triple as one transaction — a failure \
inside it leaves the rollback target unprovable, so it must fail closed"
);
for label in ["proxy-flip", "some-future-post-flip-op", ""] {
assert_eq!(
classify_post_boundary(label),
PostBoundaryClass::Functional,
"an unrecognised post-boundary label (`{label}`) must fail closed to \
Functional, never be waved through as housekeeping"
);
}
}
#[test]
fn a_post_boundary_housekeeping_failure_degrades_instead_of_halting() {
let boxed = || {
Box::new(exec::DeployExecError::CommandFailed {
label: "prune",
message: "scripted".to_owned(),
})
};
assert_eq!(
classify_host_outcome(&exec::DeployExecError::CommandFailed {
label: "prune",
message: "scripted".to_owned(),
}),
HostOutcome::Degraded { label: "prune" },
"a failed `prune` leaves the host live and healthy on the new release"
);
assert_eq!(
classify_host_outcome(&exec::DeployExecError::CommandFailed {
label: "commit-markers",
message: "scripted".to_owned(),
}),
HostOutcome::AmbiguousMarkers,
"a failed `commit-markers` must never be auto-rolled-back"
);
assert_eq!(
classify_host_outcome(&exec::DeployExecError::CommandFailed {
label: "some-future-post-flip-op",
message: "scripted".to_owned(),
}),
HostOutcome::LiveOnNew {
failed_step: "some-future-post-flip-op"
},
"an unknown post-boundary failure halts and compensates"
);
assert_eq!(
classify_host_outcome(&exec::DeployExecError::CandidateRolledBack {
failed_step: "readiness-gate",
source: boxed(),
}),
HostOutcome::RolledBack {
failed_step: "readiness-gate"
},
"a pre-boundary outcome is already clean and must pass through unrefined"
);
}
#[test]
fn a_post_boundary_failure_is_classified_by_the_op_that_was_running() {
let transport = || {
Box::new(exec::DeployExecError::Spawn {
program: "ssh".to_owned(),
source: std::io::Error::other("scripted"),
})
};
assert_eq!(
classify_host_outcome(&exec::DeployExecError::PostCutover {
failed_step: "commit-markers",
source: transport(),
}),
HostOutcome::AmbiguousMarkers,
"a transport that dropped during `commit-markers` leaves the marker triple \
exactly as unprovable as a non-zero exit does — it must fail closed too"
);
assert_eq!(
classify_host_outcome(&exec::DeployExecError::PostCutover {
failed_step: "drain-old",
source: transport(),
}),
HostOutcome::LiveOnNew {
failed_step: "ssh-transport"
},
"a dropped transport must never be waved through as harmless housekeeping"
);
assert_eq!(
classify_host_outcome(&exec::DeployExecError::PostCutover {
failed_step: "prune",
source: Box::new(exec::DeployExecError::CommandFailed {
label: "prune",
message: "scripted".to_owned(),
}),
}),
HostOutcome::Degraded { label: "prune" },
"a remote `prune` failure is still bookkeeping — the rollout continues"
);
assert_eq!(
classify_host_outcome(&exec::DeployExecError::Spawn {
program: "ssh".to_owned(),
source: std::io::Error::other("scripted"),
}),
HostOutcome::AmbiguousMarkers,
"an unattributable post-boundary failure must decline the automatic rollback"
);
}
#[test]
fn the_compensation_set_is_the_reverse_of_the_rollout_order() {
let actions = fleet_rollback_set(
&[
HostOutcome::Serving,
HostOutcome::Serving,
HostOutcome::LiveOnNew {
failed_step: "record-live-slot",
},
HostOutcome::Untouched,
],
&[
HostMode::Redeploy,
HostMode::First,
HostMode::Redeploy,
HostMode::Redeploy,
],
);
assert_eq!(
actions,
vec![
RollbackAction::Rollback(2),
RollbackAction::Teardown(1),
RollbackAction::Rollback(0),
],
"compensation runs strictly newest-first, includes the post-boundary host \
itself, skips the unreached host, and tears down (not rolls back) a first \
deploy"
);
}
#[test]
fn a_degraded_host_is_still_compensated_when_the_fleet_rolls_back() {
let actions = fleet_rollback_set(
&[
HostOutcome::Degraded { label: "prune" },
HostOutcome::LiveOnNew {
failed_step: "record-live-slot",
},
],
&[HostMode::Redeploy, HostMode::Redeploy],
);
assert_eq!(
actions,
vec![RollbackAction::Rollback(1), RollbackAction::Rollback(0)],
"a degraded host must not be stranded on the new release"
);
}
#[test]
fn clean_and_ambiguous_hosts_are_never_auto_rolled_back() {
let actions = fleet_rollback_set(
&[
HostOutcome::RolledBack {
failed_step: "readiness-gate",
},
HostOutcome::TornDown {
failed_step: "readiness-gate",
},
HostOutcome::AmbiguousMarkers,
],
&[HostMode::Redeploy, HostMode::First, HostMode::Redeploy],
);
assert_eq!(
actions,
vec![RollbackAction::Manual(2, MANUAL_AMBIGUOUS_MARKERS)],
"only the ambiguous host is reported, and never touched"
);
assert!(
fleet_rollback_set(&[], &[]).is_empty(),
"an empty fleet compensates nothing"
);
}
#[test]
fn the_summary_reports_degraded_compensated_and_manual_hosts() {
let fleet = fleet_of(&["web-a", "web-b", "web-c", "web-d"]);
let plan = plan_fleet(
&fleet,
&[
HostMode::Redeploy,
HostMode::Redeploy,
HostMode::First,
HostMode::Redeploy,
],
)
.expect("a well-formed fleet plans");
let rendered = fleet_summary_lines(
&plan,
&[
HostOutcome::CompensatedRollback,
HostOutcome::Degraded { label: "prune" },
HostOutcome::CompensatedTeardown,
HostOutcome::AmbiguousMarkers,
],
"20260714T120000Z",
)
.join("\n");
assert!(
rendered.contains("previous release restored"),
"a fleet-compensated host must say so, not borrow the per-host wording:\n\
{rendered}"
);
assert!(
rendered.contains("DEGRADED") && rendered.contains("prune"),
"a degraded host must name the housekeeping step that failed:\n{rendered}"
);
assert!(
rendered.contains("NOTHING serving") && rendered.contains("still holds the route"),
"a torn-down first deploy must state the proxy-route residue:\n{rendered}"
);
assert!(
rendered.contains("mid-transaction") && rendered.contains("NOT rolled back"),
"an ambiguous host must say it was deliberately left alone:\n{rendered}"
);
assert!(
rendered.contains(FLEET_SCHEMA_NOT_ROLLED_BACK_NOTE),
"a compensated fleet whose migration ran must state that the schema did NOT \
come back with the binaries:\n{rendered}"
);
}
#[test]
fn a_rollout_that_died_before_migrating_never_claims_the_schema_moved() {
let fleet = fleet_of(&["web-a", "web-b"]);
let plan = plan_fleet(&fleet, &[HostMode::First, HostMode::First])
.expect("a well-formed fleet plans");
for failed_step in ["install-proxy", "upload", "prepare-dirs", "daemon-reload"] {
assert!(
!schema_moved(
&plan,
&[
HostOutcome::TornDown { failed_step },
HostOutcome::Untouched
]
),
"a host that died at `{failed_step}` never reached its migration"
);
let rendered = fleet_summary_lines(
&plan,
&[
HostOutcome::TornDown { failed_step },
HostOutcome::Untouched,
],
"20260714T120000Z",
)
.join("\n");
assert!(
!rendered.contains(FLEET_SCHEMA_AHEAD_OF_BINARIES_NOTE)
&& !rendered.contains(FLEET_SCHEMA_MOVED_NOTE),
"no schema statement belongs on a rollout that died at \
`{failed_step}`:\n{rendered}"
);
}
for failed_step in ["migrate", "readiness-gate", "some-future-op"] {
assert!(
schema_moved(
&plan,
&[
HostOutcome::TornDown { failed_step },
HostOutcome::Untouched
]
),
"a host that died at `{failed_step}` may have moved the schema"
);
}
}
#[test]
fn the_schema_note_tracks_whether_the_migration_host_was_reached() {
let fleet = fleet_of(&["web-a", "web-b"]);
let migrating = plan_fleet(&fleet, &[HostMode::Redeploy, HostMode::Redeploy])
.expect("a well-formed fleet plans");
assert!(
schema_moved(
&migrating,
&[
HostOutcome::RolledBack {
failed_step: "readiness-gate"
},
HostOutcome::Untouched
]
),
"a pre-boundary failure AFTER `migrate` still moved the schema"
);
assert!(
!schema_moved(
&migrating,
&[HostOutcome::Untouched, HostOutcome::Untouched]
),
"an unreached migrating host migrated nothing"
);
let first_only = plan_fleet(&fleet, &[HostMode::First, HostMode::First])
.expect("a well-formed fleet plans");
assert!(
schema_moved(&first_only, &[HostOutcome::Serving, HostOutcome::Serving]),
"an all-first-deploy fleet migrates on host 1, so a reached host moved the schema"
);
assert!(
!schema_moved(
&first_only,
&[HostOutcome::Untouched, HostOutcome::Untouched]
),
"an all-first-deploy fleet whose host 1 was never reached migrated nothing"
);
let rendered = fleet_summary_lines(
&first_only,
&[HostOutcome::Untouched, HostOutcome::Untouched],
"20260714T120000Z",
)
.join("\n");
assert!(
!rendered.contains(FLEET_SCHEMA_NOT_ROLLED_BACK_NOTE),
"no migration ran, so there is no schema statement to make:\n{rendered}"
);
assert!(
!rendered.contains(FLEET_SCHEMA_MOVED_NOTE),
"a rollout that reached no migrating host must never claim the schema \
moved:\n{rendered}"
);
assert!(
schema_moved(
&first_only,
&[
HostOutcome::CompensatedTeardown,
HostOutcome::CompensatedTeardown,
]
),
"a compensated all-first fleet still migrated on host 1"
);
let compensated = fleet_summary_lines(
&first_only,
&[
HostOutcome::CompensatedTeardown,
HostOutcome::CompensatedTeardown,
],
"20260714T120000Z",
)
.join("\n");
assert!(
compensated.contains(FLEET_SCHEMA_NOT_ROLLED_BACK_NOTE),
"the compensating rollback restored binaries only — say so:\n{compensated}"
);
let served = fleet_summary_lines(
&first_only,
&[HostOutcome::Serving, HostOutcome::Serving],
"20260714T120000Z",
)
.join("\n");
assert!(
served.contains(FLEET_SCHEMA_MOVED_NOTE),
"host 1 migrated before it took traffic, so the schema statement is \
true here:\n{served}"
);
assert!(
served.contains(FLEET_RECOVERY_HINT),
"the recovery lever is still named — hosts ARE on the new release:\n{served}"
);
let migrated = fleet_summary_lines(
&migrating,
&[HostOutcome::Serving, HostOutcome::Serving],
"20260714T120000Z",
)
.join("\n");
assert!(
migrated.contains(FLEET_SCHEMA_MOVED_NOTE),
"the migrating fleet DID move the schema and must say so:\n{migrated}"
);
}
#[test]
fn the_summary_states_the_schema_truth_when_the_migrating_host_rolls_itself_back() {
let fleet = fleet_of(&["web-a", "web-b", "web-c"]);
let plan = plan_fleet(
&fleet,
&[HostMode::Redeploy, HostMode::Redeploy, HostMode::Redeploy],
)
.expect("a well-formed fleet plans");
assert_eq!(
plan.hosts[0].migrate,
MigrateStep::Run,
"the fleet migration lands on the first redeploy host"
);
let outcomes = [
HostOutcome::RolledBack {
failed_step: "readiness-gate",
},
HostOutcome::Untouched,
HostOutcome::Untouched,
];
assert!(
schema_moved(&plan, &outcomes),
"the migrating host was reached, so the schema moved"
);
let rendered = fleet_summary_lines(&plan, &outcomes, "20260714T120000Z").join("\n");
assert!(
rendered.contains(FLEET_SCHEMA_AHEAD_OF_BINARIES_NOTE),
"the migration ran and every binary went back, so the summary must say the \
schema did NOT come back with them:\n{rendered}"
);
assert!(
!rendered.contains(FLEET_SCHEMA_NOT_ROLLED_BACK_NOTE)
&& !rendered.contains(FLEET_SCHEMA_MOVED_NOTE),
"no compensation ran and no host is forward, so neither of those notes is \
true here:\n{rendered}"
);
assert!(
!rendered.contains(FLEET_RECOVERY_HINT),
"no host is on the new release, so there is nothing to roll back:\n{rendered}"
);
}
#[test]
fn exactly_one_schema_note_speaks_for_every_outcome_mix() {
let fleet = fleet_of(&["web-a", "web-b", "web-c"]);
let plan = plan_fleet(
&fleet,
&[HostMode::Redeploy, HostMode::Redeploy, HostMode::Redeploy],
)
.expect("a well-formed fleet plans");
let readiness = "readiness-gate";
let mixes: [[HostOutcome; 3]; 7] = [
[
HostOutcome::RolledBack {
failed_step: readiness,
},
HostOutcome::Untouched,
HostOutcome::Untouched,
],
[
HostOutcome::CompensatedRollback,
HostOutcome::RolledBack {
failed_step: readiness,
},
HostOutcome::Untouched,
],
[
HostOutcome::CompensationFailed {
failed_step: "proxy-flip-back",
},
HostOutcome::RolledBack {
failed_step: readiness,
},
HostOutcome::Untouched,
],
[
HostOutcome::CompensatedRollback,
HostOutcome::AmbiguousMarkers,
HostOutcome::Untouched,
],
[
HostOutcome::Serving,
HostOutcome::RolledBack {
failed_step: readiness,
},
HostOutcome::Untouched,
],
[
HostOutcome::Serving,
HostOutcome::Degraded { label: "prune" },
HostOutcome::Serving,
],
[
HostOutcome::Untouched,
HostOutcome::Untouched,
HostOutcome::Untouched,
],
];
for mix in &mixes {
let rendered = fleet_summary_lines(&plan, mix, "20260714T120000Z").join("\n");
let moved = rendered.contains(FLEET_SCHEMA_MOVED_NOTE);
let not_rolled_back = rendered.contains(FLEET_SCHEMA_NOT_ROLLED_BACK_NOTE);
let ahead = rendered.contains(FLEET_SCHEMA_AHEAD_OF_BINARIES_NOTE);
assert_eq!(
schema_moved(&plan, mix),
moved || not_rolled_back || ahead,
"the summary must state the schema truth exactly when the migration ran \
({mix:?}):\n{rendered}"
);
assert!(
!(ahead && (moved || not_rolled_back || rendered.contains(FLEET_RECOVERY_HINT))),
"\"nothing is forward\" cannot share a summary with a note that says \
something is ({mix:?}):\n{rendered}"
);
}
}
#[test]
fn manual_recovery_lines_name_the_markers_and_carry_no_secret() {
let cfg = &fleet_of(&["web-b"]).hosts[0];
let rendered = manual_recovery_lines(cfg, MANUAL_AMBIGUOUS_MARKERS).join("\n");
assert!(
rendered.contains("web-b") && rendered.contains(MANUAL_AMBIGUOUS_MARKERS),
"the block must name the host and the reason:\n{rendered}"
);
assert!(
rendered.contains("previous-release") && rendered.contains("live-slot"),
"the block must name both markers a human has to reconcile:\n{rendered}"
);
assert!(
!rendered.contains("autumn deploy rollback"),
"`deploy rollback` has no single target under `[deploy] hosts` today, so the \
manual block must not promise it:\n{rendered}"
);
for secret in [
"AUTUMN_SECURITY__SIGNING_SECRET",
"postgres://",
"topsecret",
] {
assert!(
!rendered.contains(secret),
"manual recovery output must never carry `{secret}`:\n{rendered}"
);
}
}
#[test]
fn fleet_plan_lines_render_hosts_in_rollout_order_with_one_migrate_note() {
let hosts = vec![
"web-1.example.com".to_owned(),
"web-2.example.com".to_owned(),
"web-3.example.com".to_owned(),
];
let rendered = fleet_plan_lines(&hosts).join("\n");
let mut previous = 0usize;
for host in &hosts {
let at = rendered
.find(host.as_str())
.unwrap_or_else(|| panic!("{host} must appear in the fleet plan:\n{rendered}"));
assert!(
at >= previous,
"hosts must render in declaration (rollout) order:\n{rendered}"
);
previous = at;
}
assert_eq!(
rendered.matches(FLEET_MIGRATE_PLACEMENT_NOTE).count(),
1,
"the migrate placement is a single fleet-wide note, not a per-host line:\n{rendered}"
);
}
#[test]
fn only_warns_loudly_whenever_it_excludes_a_configured_host() {
let configured = ["web-a".to_owned(), "web-b".to_owned(), "web-c".to_owned()];
let rendered = only_selection_warning_lines(&configured, &["web-b".to_owned()]).join("\n");
assert!(
rendered.contains(FLEET_ONLY_MIXED_WARNING),
"a narrowing selection must print the mixed-fleet warning:\n{rendered}"
);
assert!(
rendered.contains("web-a") && rendered.contains("web-c"),
"the warning must name the hosts left behind:\n{rendered}"
);
assert!(
rendered.contains("web-b"),
"the warning must name the hosts it WILL touch:\n{rendered}"
);
assert!(
rendered.contains("autumn deploy up"),
"the warning must name the command that converges the fleet again:\n{rendered}"
);
}
#[test]
fn a_selection_that_excludes_nothing_prints_nothing() {
let configured = ["web-a".to_owned(), "web-b".to_owned()];
assert!(
only_selection_warning_lines(&configured, &configured).is_empty(),
"selecting the whole fleet excludes nothing and must print nothing"
);
assert!(
only_selection_warning_lines(&["web-a".to_owned()], &["web-a".to_owned()]).is_empty(),
"a single-host deploy must print nothing new"
);
}
#[test]
fn the_fleet_rollback_table_names_every_host_and_its_state() {
let hosts = ["web-a".to_owned(), "web-b".to_owned(), "web-c".to_owned()];
let rendered = fleet_rollback_summary_lines(
&hosts,
&[
RollbackOutcome::RolledBack,
RollbackOutcome::NoPreviousRelease,
RollbackOutcome::Failed {
failed_step: "readiness-gate",
},
],
)
.join("\n");
for host in &hosts {
assert!(
rendered.contains(host.as_str()),
"every attempted host must appear in the table:\n{rendered}"
);
}
assert!(
rendered.contains("readiness-gate"),
"a failed host must name the step it failed at:\n{rendered}"
);
assert!(
rendered.contains("no previous release"),
"a skipped host must say WHY nothing happened to it:\n{rendered}"
);
for secret in [
"AUTUMN_SECURITY__SIGNING_SECRET",
"postgres://",
"topsecret",
] {
assert!(
!rendered.contains(secret),
"the rollback table must never carry `{secret}`:\n{rendered}"
);
}
}
fn status(host: &str, release: Option<&str>) -> HostStatus {
HostStatus {
host: host.to_owned(),
reachable: true,
mode: Some(HostMode::Redeploy),
release: release.map_or(ReleaseId::Unknown, |id| ReleaseId::Known(id.to_owned())),
live_slot: Some(exec::SLOT_BLUE),
ready_code: Some(200),
maintenance: exec::MaintenanceStatus::Off,
maintenance_flag_source: exec::MaintenanceFlagSource::Shared,
installed_proxy_port: exec::InstalledProxyPort::Port(3000),
proxy_options: exec::ProxyOptionsMarker::Options(exec::ProxyServiceOptions {
tls: false,
host: None,
}),
public_port: 3000,
live_slot_marker_drift: false,
last_deploy: Some(exec::LastDeploy {
result: exec::LAST_DEPLOY_DEPLOYED.to_owned(),
at: Some("2026-07-14T12:00:03Z".to_owned()),
}),
}
}
#[test]
fn fleet_drift_reports_two_different_releases_as_version_drift() {
let report = fleet_drift(&[
status("web-a", Some("20260714T120000Z")),
status("web-b", Some("20260714T130000Z")),
]);
assert!(report.version_drift, "differing releases are version drift");
assert_eq!(
report.releases,
vec![
(
"web-a".to_owned(),
ReleaseId::Known("20260714T120000Z".to_owned())
),
(
"web-b".to_owned(),
ReleaseId::Known("20260714T130000Z".to_owned())
),
],
"the report names each host's release as its evidence"
);
assert!(
report.state_drift.is_empty(),
"version drift is not state drift: {:?}",
report.state_drift
);
let converged = fleet_drift(&[
status("web-a", Some("20260714T120000Z")),
status("web-b", Some("20260714T120000Z")),
status("web-c", Some("20260714T120000Z")),
]);
assert!(!converged.version_drift);
}
#[test]
fn fleet_drift_never_treats_an_unknown_release_as_drift() {
let all_unknown = fleet_drift(&[status("web-a", None), status("web-b", None)]);
assert!(
!all_unknown.version_drift,
"an all-unknown fleet is not drifted, it is unreported"
);
assert_eq!(
all_unknown.releases,
vec![
("web-a".to_owned(), ReleaseId::Unknown),
("web-b".to_owned(), ReleaseId::Unknown),
],
"…and the unknowns are still named"
);
assert!(all_unknown.unknown_releases().contains(&"web-a"));
let mixed = fleet_drift(&[
status("web-a", Some("20260714T120000Z")),
status("web-b", None),
]);
assert!(!mixed.version_drift, "one known release is not drift");
assert_eq!(mixed.unknown_releases(), vec!["web-b"]);
}
#[test]
fn fleet_drift_reports_state_drift_separately_from_version_drift() {
let mut marker_drift = status("web-a", Some("r1"));
marker_drift.live_slot_marker_drift = true;
let mut unreadable_options = status("web-b", Some("r1"));
unreadable_options.proxy_options = exec::ProxyOptionsMarker::Unreadable;
let mut wrong_port = status("web-c", Some("r1"));
wrong_port.installed_proxy_port = exec::InstalledProxyPort::Port(8080);
let report = fleet_drift(&[marker_drift, unreadable_options, wrong_port]);
assert!(
!report.version_drift,
"every host is on r1 — this is state drift, not version drift"
);
assert_eq!(
report.state_drift,
vec![
("web-a".to_owned(), DRIFT_LIVE_SLOT_MARKER),
("web-b".to_owned(), DRIFT_PROXY_OPTIONS_UNREADABLE),
("web-c".to_owned(), DRIFT_PROXY_PORT_MISMATCH),
],
"each host is named with its own reason"
);
assert!(
report.drifted(),
"state drift alone must make --strict fail"
);
let clean = fleet_drift(&[status("web-a", Some("r1")), status("web-b", Some("r1"))]);
assert!(!clean.drifted() && clean.state_drift.is_empty());
}
#[test]
fn fleet_drift_flags_a_host_that_is_not_deployed_at_all() {
let mut not_deployed = status("web-b", None);
not_deployed.mode = Some(HostMode::First);
let report = fleet_drift(&[status("web-a", Some("r1")), not_deployed.clone()]);
assert!(
!report.version_drift,
"one known release is still not VERSION drift — this is a separate signal"
);
assert_eq!(
report.state_drift,
vec![("web-b".to_owned(), DRIFT_HOST_NOT_DEPLOYED)],
"a host carrying no release while its peers serve one is drift"
);
assert!(
report.drifted(),
"`deploy status --strict` must exit non-zero on it"
);
let mut second = status("web-a", None);
second.mode = Some(HostMode::First);
let fresh = fleet_drift(&[second, not_deployed.clone()]);
assert!(
!fresh.drifted() && fresh.state_drift.is_empty(),
"a fleet that has never been deployed is not drifted: {:?}",
fresh.state_drift
);
let rows = [status("web-a", Some("r1")), not_deployed];
let rendered = fleet_status_lines(&rows, &fleet_drift(&rows)).join("\n");
assert!(
rendered.contains(DRIFT_HOST_NOT_DEPLOYED),
"the drift reason must be named on the row:\n{rendered}"
);
assert!(
!rendered.contains("web-b, ") && rendered.contains("no release deployed on web-b"),
"a host that is not deployed must be described as such:\n{rendered}"
);
assert!(
!rendered.contains("`current` symlink was unreadable"),
"…and never blamed on a symlink that was never there:\n{rendered}"
);
}
#[test]
fn a_deployed_host_whose_release_is_unreadable_is_state_drift() {
let damaged = status("web-b", None); let rows = [status("web-a", Some("r1")), damaged];
let report = fleet_drift(&rows);
assert!(
!report.version_drift,
"unknown is still never VERSION drift: {:?}",
report.releases
);
assert_eq!(
report.state_drift,
vec![("web-b".to_owned(), DRIFT_RELEASE_UNREADABLE)],
"the damaged host is named with its own reason"
);
assert!(
report.drifted(),
"`deploy status --strict` must exit non-zero on it"
);
let alone = [status("web-a", None)];
assert!(fleet_drift(&alone).drifted());
let rendered = fleet_status_lines(&rows, &report).join("\n");
assert!(
rendered.contains(DRIFT_RELEASE_UNREADABLE),
"the drift reason must be named on the row:\n{rendered}"
);
assert!(
!rendered.contains("reported, not counted as drift"),
"the footer must not contradict the drift it is now counted as:\n{rendered}"
);
let outage = [
status("web-a", Some("r1")),
HostStatus::unreachable("web-b"),
];
let outage_report = fleet_drift(&outage);
assert!(outage_report.state_drift.is_empty());
assert!(
fleet_status_lines(&outage, &outage_report)
.join("\n")
.contains("reported, not counted as drift"),
"an unreachable host is still reported, not blamed",
);
}
#[test]
fn fleet_drift_never_flags_a_host_it_could_not_reach() {
let report = fleet_drift(&[
status("web-a", Some("r1")),
HostStatus::unreachable("web-b"),
]);
assert!(!report.version_drift);
assert!(
report.state_drift.is_empty(),
"an unreachable host contributes no state drift: {:?}",
report.state_drift
);
assert_eq!(report.unknown_releases(), vec!["web-b"]);
}
#[test]
fn fleet_status_table_shows_readiness_and_maintenance_as_separate_columns() {
let mut maintained = status("web-b", Some("r1"));
maintained.maintenance = exec::MaintenanceStatus::On;
let rows = [status("web-a", Some("r1")), maintained];
let report = fleet_drift(&rows);
let rendered = fleet_status_lines(&rows, &report).join("\n");
for host in ["web-a", "web-b"] {
assert!(rendered.contains(host), "every host is a row:\n{rendered}");
}
assert!(rendered.contains("r1"), "the release column:\n{rendered}");
assert!(rendered.contains("200"), "the ready column:\n{rendered}");
assert!(
rendered.contains(MAINTENANCE_DOES_NOT_DRAIN_NOTE),
"the table must state the orthogonality out loud:\n{rendered}"
);
}
#[test]
fn a_torn_down_host_never_renders_as_a_successful_deploy() {
let mut torn_down = status("web-a", None);
torn_down.mode = Some(HostMode::First);
torn_down.release = ReleaseId::Unknown;
torn_down.last_deploy = Some(exec::LastDeploy {
result: exec::LAST_DEPLOY_TORN_DOWN.to_owned(),
at: Some("2026-07-14T12:00:09Z".to_owned()),
});
let rows = [torn_down, status("web-b", Some("r1"))];
let report = fleet_drift(&rows);
let rendered = fleet_status_lines(&rows, &report).join("\n");
assert!(
rendered.contains("last deploy: torn down 2026-07-14T12:00:09Z"),
"the torn-down host must report what actually happened:\n{rendered}"
);
let web_a_row = rendered
.lines()
.find(|line| line.contains("web-a"))
.expect("web-a has a row");
assert!(
!web_a_row.contains("last deploy: deployed"),
"a host with nothing installed must never read as a successful \
deploy: {web_a_row}"
);
assert!(
web_a_row.contains("not deployed"),
"…and the mode cell still says so out loud: {web_a_row}"
);
}
#[test]
fn fleet_status_table_names_every_drift_reason() {
let mut drifted = status("web-b", Some("r2"));
drifted.proxy_options = exec::ProxyOptionsMarker::Unreadable;
let rows = [status("web-a", Some("r1")), drifted];
let report = fleet_drift(&rows);
let rendered = fleet_status_lines(&rows, &report).join("\n");
assert!(
rendered.contains(DRIFT_PROXY_OPTIONS_UNREADABLE),
"a state-drift reason must be spelled out, not abbreviated:\n{rendered}"
);
assert!(
rendered.contains("r1") && rendered.contains("r2"),
"version drift must show WHICH releases:\n{rendered}"
);
}
}