pub mod exec;
pub mod media;
pub mod proxy;
use std::net::{TcpStream, ToSocketAddrs};
use std::path::{Path, PathBuf};
use std::time::Duration;
use autumn_web::config::{AutumnConfig, DeployConfig, Env};
const SSH_PROBE_TIMEOUT: Duration = Duration::from_secs(5);
const MIGRATIONS_DIR: &str = "migrations";
const DEPLOY_HOST_MISSING_DETAIL: &str = "no target host configured";
const DEPLOY_HOST_MISSING_HINT: &str =
"Set `[deploy] host` in autumn.toml to your server's SSH-reachable address";
#[derive(Debug, thiserror::Error)]
pub enum DeployError {
#[error("failed to load configuration: {0}")]
Config(String),
#[error(
"preflight failed: {0} check(s) did not pass — resolve the issues above and re-run \
`autumn deploy check`"
)]
PreflightFailed(usize),
#[error("release binary not found at {0} — run `autumn build --embed` first")]
BinaryMissing(String),
#[error("deploy execution failed: {0}")]
Exec(String),
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum DeployAction {
Check,
Plan,
Rollback,
Up,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ResolvedDeployConfig {
pub host: Option<String>,
pub user: String,
pub ssh_port: u16,
pub app_name: String,
pub app_dir: String,
pub service_name: String,
pub readiness_timeout_secs: u64,
pub keep_releases: u32,
pub profile: String,
pub tls_enabled: bool,
pub tls_host: Option<String>,
}
#[allow(clippy::redundant_pub_crate)]
pub(crate) fn canonicalize_deploy_profile(raw: &str) -> String {
let trimmed = raw.trim();
if trimmed.is_empty() {
return "prod".to_owned();
}
if trimmed.eq_ignore_ascii_case("production") {
return "prod".to_owned();
}
if trimmed.eq_ignore_ascii_case("development") {
return "dev".to_owned();
}
if trimmed.eq_ignore_ascii_case("prod") {
return "prod".to_owned();
}
if trimmed.eq_ignore_ascii_case("dev") {
return "dev".to_owned();
}
trimmed.to_owned()
}
#[allow(clippy::redundant_pub_crate)]
pub(crate) fn trimmed_deploy_profile(raw: &str) -> String {
let trimmed = raw.trim();
if trimmed.is_empty() {
return "prod".to_owned();
}
trimmed.to_owned()
}
impl ResolvedDeployConfig {
pub fn resolve(cfg: &DeployConfig, project_name: &str) -> Result<Self, String> {
let non_blank = |s: &Option<String>| {
s.as_ref()
.map(|v| v.trim().to_owned())
.filter(|v| !v.is_empty())
};
let app_name = non_blank(&cfg.app_name).unwrap_or_else(|| project_name.to_owned());
let app_dir = non_blank(&cfg.app_dir).unwrap_or_else(|| format!("/srv/autumn/{app_name}"));
let service_name = non_blank(&cfg.service_name).unwrap_or_else(|| app_name.clone());
let tls_host = if cfg.tls.enabled {
let host = non_blank(&cfg.tls.host).ok_or_else(|| {
"[deploy.tls] requires a non-empty `host` when enabled: set \
`[deploy.tls] host = \"<public-hostname>\"` in autumn.toml to the DNS \
name the TLS certificate should be issued for"
.to_owned()
})?;
Some(host)
} else {
None
};
Ok(Self {
host: non_blank(&cfg.host),
user: cfg.user.clone(),
ssh_port: cfg.ssh_port,
app_name,
app_dir,
service_name,
readiness_timeout_secs: cfg.readiness_timeout_secs,
keep_releases: cfg.keep_releases,
profile: trimmed_deploy_profile(&cfg.profile),
tls_enabled: cfg.tls.enabled,
tls_host,
})
}
#[must_use]
pub fn shared_dir(&self) -> String {
format!("{}/shared", self.app_dir)
}
#[must_use]
pub fn env_file(&self) -> String {
format!("{}/autumn.env", self.shared_dir())
}
#[must_use]
pub fn releases_dir(&self) -> String {
format!("{}/releases", self.app_dir)
}
#[must_use]
pub fn current_symlink(&self) -> String {
format!("{}/current", self.app_dir)
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct DeployStep {
pub label: &'static str,
pub description: String,
}
impl DeployStep {
fn new(label: &'static str, description: impl Into<String>) -> Self {
Self {
label,
description: description.into(),
}
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct PreflightCheck {
pub name: &'static str,
pub passed: bool,
pub deferred: bool,
pub detail: String,
pub hint: Option<&'static str>,
}
impl PreflightCheck {
fn pass(name: &'static str, detail: impl Into<String>) -> Self {
Self {
name,
passed: true,
deferred: false,
detail: detail.into(),
hint: None,
}
}
fn fail(name: &'static str, detail: impl Into<String>, hint: &'static str) -> Self {
Self {
name,
passed: false,
deferred: false,
detail: detail.into(),
hint: Some(hint),
}
}
#[must_use]
pub const fn blocking(&self) -> bool {
!self.passed && !self.deferred
}
}
#[must_use]
pub fn grade_deploy_host_present(host: Option<&str>) -> PreflightCheck {
match host.map(str::trim).filter(|h| !h.is_empty()) {
Some(_) => PreflightCheck::pass("deploy_host", "deploy target host is configured"),
None => PreflightCheck::fail(
"deploy_host",
DEPLOY_HOST_MISSING_DETAIL,
DEPLOY_HOST_MISSING_HINT,
),
}
}
#[must_use]
pub fn grade_ssh_reachability(
host: Option<&str>,
ssh_port: u16,
timeout: Duration,
) -> PreflightCheck {
let Some(host) = host.map(str::trim).filter(|h| !h.is_empty()) else {
return PreflightCheck::fail(
"ssh_reachability",
DEPLOY_HOST_MISSING_DETAIL,
DEPLOY_HOST_MISSING_HINT,
);
};
let addrs = match (host, ssh_port).to_socket_addrs() {
Ok(addrs) => addrs.collect::<Vec<_>>(),
Err(e) => {
return PreflightCheck::fail(
"ssh_reachability",
format!("could not resolve {host}:{ssh_port}: {e}"),
"Check that `[deploy] host` is a resolvable hostname or a valid IP address",
);
}
};
if addrs.is_empty() {
return PreflightCheck::fail(
"ssh_reachability",
format!("{host}:{ssh_port} resolved to no addresses"),
"Check that `[deploy] host` is a resolvable hostname or a valid IP address",
);
}
let mut last_err = None;
for addr in &addrs {
match TcpStream::connect_timeout(addr, timeout) {
Ok(_) => {
return PreflightCheck::pass(
"ssh_reachability",
format!("SSH port reachable at {host}:{ssh_port}"),
);
}
Err(e) => last_err = Some(e),
}
}
let detail = last_err.map_or_else(
|| format!("cannot reach {host}:{ssh_port}"),
|e| format!("cannot reach {host}:{ssh_port}: {e}"),
);
PreflightCheck::fail(
"ssh_reachability",
detail,
"Confirm the server is up, the SSH port is open, and any firewall allows your IP",
)
}
#[must_use]
pub fn grade_signing_secret(
secret: Option<&str>,
previous_secrets: &[String],
is_production: bool,
) -> PreflightCheck {
let Some(value) = secret.map(str::trim).filter(|s| !s.is_empty()) else {
return PreflightCheck::fail(
"signing_secret",
"no signing secret configured",
"Set AUTUMN_SECURITY__SIGNING_SECRET (generate with `openssl rand -hex 32`)",
);
};
if is_production {
use autumn_web::security::{SigningSecretError, validate_signing_secret};
if let Err(error) = validate_signing_secret(Some(value), true) {
let detail = match error {
SigningSecretError::MissingInProduction => {
"signing secret is required in production".to_owned()
}
SigningSecretError::TooShort { actual, required } => format!(
"signing secret is too short ({actual} bytes, minimum {required}) for production"
),
SigningSecretError::KnownWeakValue(_) => {
"signing secret is a known demo/template value not allowed in production"
.to_owned()
}
};
return PreflightCheck::fail(
"signing_secret",
detail,
"Set a strong AUTUMN_SECURITY__SIGNING_SECRET (generate with `openssl rand -hex 32`)",
);
}
for (index, previous) in previous_secrets.iter().enumerate() {
if let Err(error) = validate_signing_secret(Some(previous.as_str()), true) {
let position = index + 1;
let detail = match error {
SigningSecretError::MissingInProduction => format!(
"previous (rotation) signing secret #{position} is empty and not allowed in production"
),
SigningSecretError::TooShort { actual, required } => format!(
"previous (rotation) signing secret #{position} is too short ({actual} bytes, minimum {required}) for production"
),
SigningSecretError::KnownWeakValue(_) => format!(
"previous (rotation) signing secret #{position} is a known demo/template value not allowed in production"
),
};
return PreflightCheck::fail(
"signing_secret",
detail,
"Remove or rotate out the weak entry in security.signing_secret.previous_secrets (each must be as strong as the current secret)",
);
}
}
}
PreflightCheck::pass("signing_secret", "signing secret is configured")
}
#[must_use]
pub fn grade_database_url(
url: Option<&str>,
migrations_dir: &Path,
database_configured: bool,
db_backed_runtime: bool,
) -> PreflightCheck {
match url.map(str::trim).filter(|u| !u.is_empty()) {
Some(_) => PreflightCheck::pass("database_url", "database URL is configured"),
None if !migrations_dir.exists() && !database_configured && !db_backed_runtime => {
PreflightCheck::pass(
"database_url",
"no database configured (nothing to connect to)",
)
}
None => PreflightCheck::fail(
"database_url",
"no writable database URL: this app is database-backed (migrations, a \
`[database]` section, or a Postgres-backed runtime feature such as \
`jobs.backend`/`scheduler.backend`) and needs a primary/control \
(`database.primary_url`) or shard-primary URL; `database.replica_url` \
alone is not a writable target",
"Set database.primary_url (or database.url) in autumn.toml, or AUTUMN_DATABASE__URL",
),
}
}
#[must_use]
pub fn grade_migrate_check(migrations_dir: &Path) -> PreflightCheck {
if !migrations_dir.exists() {
return PreflightCheck::pass(
"migrate_check",
"no migrations directory (nothing to check)",
);
}
match crate::migrate::check_migrations_in_dir(migrations_dir) {
Ok(reports) => {
let unsafe_names: Vec<&str> = reports
.iter()
.filter(|r| crate::migrate::safety::has_unsafe_findings(&r.up))
.map(|r| r.name.as_str())
.collect();
if unsafe_names.is_empty() {
PreflightCheck::pass(
"migrate_check",
format!("{} migration(s) safe for a rolling deploy", reports.len()),
)
} else {
PreflightCheck::fail(
"migrate_check",
format!(
"migration(s) unsafe for a rolling deploy: {}",
unsafe_names.join(", ")
),
"Run `autumn migrate check` and apply the expand/contract pattern before deploying",
)
}
}
Err(e) => PreflightCheck::fail(
"migrate_check",
format!("could not scan migrations: {e}"),
"Run `autumn migrate check` to see the full report",
),
}
}
#[must_use]
pub fn render_systemd_unit(cfg: &ResolvedDeployConfig) -> String {
format!(
"[Unit]\n\
Description=Autumn application: {app}\n\
After=network-online.target\n\
Wants=network-online.target\n\
\n\
[Service]\n\
Type=simple\n\
User={user}\n\
WorkingDirectory={current}\n\
EnvironmentFile={env_file}\n\
Environment=AUTUMN_MANIFEST_DIR={manifest_dir}\n\
ExecStart={current}/{app}\n\
Restart=on-failure\n\
RestartSec=2\n\
\n\
[Install]\n\
WantedBy=multi-user.target\n",
app = cfg.app_name,
user = cfg.user,
current = cfg.current_symlink(),
env_file = cfg.env_file(),
manifest_dir = cfg.current_symlink(),
)
}
#[must_use]
pub fn render_app_unit(
cfg: &ResolvedDeployConfig,
release_dir: &str,
app_port: u16,
slot: &str,
) -> String {
format!(
"[Unit]\n\
Description=Autumn application: {app} ({slot})\n\
After=network-online.target\n\
Wants=network-online.target\n\
\n\
[Service]\n\
Type=simple\n\
User={user}\n\
WorkingDirectory={release_dir}\n\
EnvironmentFile={env_file}\n\
Environment=AUTUMN_MANIFEST_DIR={manifest_dir}\n\
Environment=AUTUMN_SERVER__HOST=127.0.0.1\n\
Environment=AUTUMN_SERVER__PORT={app_port}\n\
ExecStart={release_dir}/{app}\n\
Restart=on-failure\n\
RestartSec=2\n\
\n\
[Install]\n\
WantedBy=multi-user.target\n",
app = cfg.app_name,
user = cfg.user,
env_file = cfg.env_file(),
manifest_dir = release_dir,
)
}
#[must_use]
pub fn build_deploy_plan(cfg: &ResolvedDeployConfig) -> Vec<DeployStep> {
vec![
DeployStep::new(
"build",
"Build the embedded single-binary release locally (`autumn build --embed`)",
),
DeployStep::new(
"upload",
format!(
"Upload the binary to a new timestamped release dir under {}",
cfg.releases_dir()
),
),
DeployStep::new(
"migrate",
"Run pending migrations BEFORE cutover — abort here leaves the current version serving",
),
DeployStep::new(
"start-candidate",
"Start the new release as a candidate on a SEPARATE listener (a distinct \
port/socket from the live service) — it does NOT bind the live \
`server.port`, so the old release keeps serving traffic uninterrupted",
),
DeployStep::new(
"readiness-gate",
format!(
"Poll the candidate's /ready on its separate listener within {}s — roll back on \
timeout",
cfg.readiness_timeout_secs
),
),
DeployStep::new(
"cutover",
format!(
"Hand live traffic over to the candidate — reverse-proxy upstream swap or systemd \
socket-activation handoff (mechanism finalized in the execution slice) — then \
promote it by pointing the {} symlink at the new release",
cfg.current_symlink(),
),
),
DeployStep::new(
"drain",
"Drain and stop the previous release once traffic has moved to the candidate",
),
DeployStep::new(
"prune",
format!(
"Prune old releases, retaining the most recent {} — always keeping the releases \
the current symlink and previous-release marker point at (rollback targets)",
cfg.keep_releases
),
),
]
}
#[must_use]
pub fn build_rollback_plan(cfg: &ResolvedDeployConfig) -> Vec<DeployStep> {
vec![
DeployStep::new(
"write-target-unit",
format!(
"Re-render the previous release's {} slot unit from the persisted \
marker (its dir + port) so the restart never relaunches a slot unit \
an earlier failed redeploy left pointing at a removed candidate",
cfg.service_name
),
),
DeployStep::new(
"daemon-reload",
"Reload systemd so the restart below loads the freshly re-rendered unit",
),
DeployStep::new(
"restart-previous",
format!(
"Restart the previous release's {} slot unit so it is healthy \
before the proxy flips traffic back to it",
cfg.service_name
),
),
DeployStep::new(
"proxy-flip",
"Flip the reverse-proxy upstream back to the previous release \
(health-gated on /ready before traffic moves)",
),
DeployStep::new(
"record-previous",
"Record the release being rolled back FROM (its dir + former-live slot) \
as the new previous-release marker so a subsequent rollback returns to it",
),
DeployStep::new(
"repoint",
format!(
"Point the {} symlink back at the previous release",
cfg.current_symlink()
),
),
DeployStep::new(
"record-live-slot",
"Record the previous slot as the live slot so the next deploy's mode \
detection sees it serving",
),
DeployStep::new(
"readiness-gate",
format!(
"Re-probe /ready within {}s to confirm the rollback is healthy",
cfg.readiness_timeout_secs
),
),
DeployStep::new(
"drain-rolled-back-slot",
"Disable the slot that was live before the rollback (the one traffic \
moved away from) so the next deploy sees it genuinely idle and starts \
the new binary fresh",
),
]
}
pub fn run(action: DeployAction) -> Result<(), DeployError> {
let ambient_config = AutumnConfig::load_lenient_unknown_roots()
.map_err(|e| DeployError::Config(e.to_string()))?;
let deploy_cfg = ambient_config.deploy.unwrap_or_default();
let resolved = ResolvedDeployConfig::resolve(&deploy_cfg, &resolve_project_name())
.map_err(DeployError::Config)?;
match action {
DeployAction::Plan => {
let (media_cfg, _ffmpeg_bin) = load_media_host_config(&resolved)?;
print_plan(&resolved, &media_cfg);
Ok(())
}
DeployAction::Check => run_check(&load_runtime_config(&resolved)?, &resolved),
DeployAction::Rollback => run_rollback(&load_runtime_config(&resolved)?, &resolved),
DeployAction::Up => {
let (media_cfg, ffmpeg_bin) = load_media_host_config(&resolved)?;
run_up(
&load_runtime_config(&resolved)?,
&resolved,
&media_cfg,
&ffmpeg_bin,
)
}
}
}
fn load_media_host_config(
resolved: &ResolvedDeployConfig,
) -> Result<(media::MediaMtxHostConfig, String), DeployError> {
load_media_host_config_in(&manifest_project_dirs(), &resolved.profile)
}
fn load_media_host_config_in(
dirs: &[PathBuf],
profile_raw: &str,
) -> Result<(media::MediaMtxHostConfig, String), DeployError> {
let mut merged = toml::Value::Table(toml::map::Map::new());
let base_toml: Option<toml::Value> = match first_dir_with_file(dirs, "autumn.toml") {
Some(base_path) => match std::fs::read_to_string(&base_path) {
Ok(base_str) => {
let base: toml::Value = toml::from_str(&base_str).map_err(|e| {
DeployError::Config(format!("invalid config in {}: {e}", base_path.display()))
})?;
deep_merge_toml(&mut merged, base.clone());
Some(base)
}
Err(_) => None,
},
None => None,
};
let canonical = canonicalize_deploy_profile(profile_raw);
if let Some(base) = &base_toml {
for name in profile_inline_lookup_names(&canonical) {
if let Some(section) = profile_section_from_base_toml(base, name) {
deep_merge_toml(&mut merged, section);
}
}
}
for name in autumn_web::config::profile_override_file_lookup_names(&canonical, profile_raw) {
let basename = format!("autumn-{name}.toml");
if let Some(path) = first_dir_with_file(dirs, &basename) {
let overlay_str = std::fs::read_to_string(&path).map_err(|e| {
DeployError::Config(format!("could not read {}: {e}", path.display()))
})?;
let overlay: toml::Value = toml::from_str(&overlay_str).map_err(|e| {
DeployError::Config(format!("invalid config in {}: {e}", path.display()))
})?;
deep_merge_toml(&mut merged, overlay);
break;
}
}
media::media_host_config_from_value(merged).map_err(|e| {
DeployError::Config(format!(
"invalid [media] config for deploy profile `{profile_raw}`: {e}"
))
})
}
fn profile_inline_lookup_names(canonical: &str) -> Vec<&str> {
match canonical {
"prod" => vec!["production", "prod"],
"dev" => vec!["development", "dev"],
other => vec![other],
}
}
fn profile_section_from_base_toml(base: &toml::Value, profile: &str) -> Option<toml::Value> {
base.get("profile")
.and_then(toml::Value::as_table)
.and_then(|profiles| profiles.get(profile))
.and_then(toml::Value::as_table)
.map(|table| toml::Value::Table(table.clone()))
}
fn deep_merge_toml(base: &mut toml::Value, overlay: toml::Value) {
deep_merge_toml_depth(base, overlay, 0);
}
fn deep_merge_toml_depth(base: &mut toml::Value, overlay: toml::Value, depth: usize) {
const MAX_MERGE_DEPTH: usize = 16;
if depth > MAX_MERGE_DEPTH {
return;
}
let toml::Value::Table(overlay_table) = overlay else {
return;
};
let Some(base_table) = base.as_table_mut() else {
return;
};
for (key, overlay_val) in overlay_table {
let is_recursive_merge =
overlay_val.is_table() && base_table.get(&key).is_some_and(toml::Value::is_table);
if is_recursive_merge {
if let Some(base_val) = base_table.get_mut(&key) {
deep_merge_toml_depth(base_val, overlay_val, depth + 1);
}
} else {
base_table.insert(key, overlay_val);
}
}
}
struct ForcedProfileEnv<E: Env> {
profile: String,
inner: E,
}
impl<E: Env> Env for ForcedProfileEnv<E> {
fn var(&self, key: &str) -> Result<String, std::env::VarError> {
if key == "AUTUMN_ENV" {
return Ok(self.profile.clone());
}
if key == "AUTUMN_DOTENV" {
return self
.inner
.var("AUTUMN_DOTENV")
.or_else(|_| Ok("1".to_owned()));
}
self.inner.var(key)
}
}
fn load_runtime_config(resolved: &ResolvedDeployConfig) -> Result<AutumnConfig, DeployError> {
let forced = deploy_profile_env_overlay(&resolved.profile)?;
AutumnConfig::load_with_env_lenient_unknown_roots(&forced)
.map_err(|e| DeployError::Config(e.to_string()))
}
#[allow(clippy::redundant_pub_crate)]
pub(crate) fn deploy_profile_env_overlay(profile_raw: &str) -> Result<impl Env, DeployError> {
use autumn_web::config::OsEnv;
let gating = ForcedProfileEnv {
profile: profile_raw.to_owned(),
inner: OsEnv,
};
let dotenv_profile = canonicalize_deploy_profile(profile_raw);
let inner = autumn_web::dotenv::os_env_with_dotenv_for_profile_using(&gating, &dotenv_profile)
.map_err(|e| DeployError::Config(e.to_string()))?;
Ok(ForcedProfileEnv {
profile: profile_raw.to_owned(),
inner,
})
}
fn resolve_project_name() -> String {
let cwd = std::env::current_dir().unwrap_or_else(|_| PathBuf::from("."));
crate::release::read_project_name(&cwd)
.ok()
.or_else(|| {
cwd.file_name()
.map(|n| n.to_string_lossy().into_owned())
.filter(|n| !n.is_empty())
})
.unwrap_or_else(|| "app".to_owned())
}
fn resolve_writable_db_url(db: &autumn_web::config::DatabaseConfig) -> Option<&str> {
db.effective_primary_url()
.or_else(|| db.shards.first().map(|shard| shard.primary_url.as_str()))
}
fn collect_preflight(
config: &AutumnConfig,
resolved: &ResolvedDeployConfig,
) -> Vec<PreflightCheck> {
let database_configured = config.database.url.is_some()
|| config.database.primary_url.is_some()
|| config.database.replica_url.is_some()
|| config.database.has_shards();
vec![
grade_ssh_reachability(
resolved.host.as_deref(),
resolved.ssh_port,
SSH_PROBE_TIMEOUT,
),
grade_signing_secret(
config.security.signing_secret.secret.as_deref(),
&config.security.signing_secret.previous_secrets,
is_production_profile(Some(&canonicalize_deploy_profile(&resolved.profile))),
),
grade_database_url(
resolve_writable_db_url(&config.database),
Path::new(MIGRATIONS_DIR),
database_configured,
requires_database_pool(config),
),
grade_migrate_check(Path::new(MIGRATIONS_DIR)),
]
}
#[must_use]
#[allow(clippy::redundant_pub_crate)]
pub(crate) fn is_production_profile(profile: Option<&str>) -> bool {
matches!(profile, Some("prod" | "production"))
}
#[must_use]
fn requires_database_pool(config: &AutumnConfig) -> bool {
config.jobs.backend == "postgres"
|| config.scheduler.backend == autumn_web::config::SchedulerBackend::Postgres
}
fn report_preflight(checks: &[PreflightCheck]) -> usize {
let mut failed = 0_usize;
for check in checks {
if check.passed {
eprintln!("\u{2705} {}: {}", check.name, check.detail);
} else if check.deferred {
eprintln!("\u{26A0}\u{FE0F} {}: {}", check.name, check.detail);
if let Some(hint) = check.hint {
eprintln!(" \u{2192} {hint}");
}
} else {
failed += 1;
eprintln!("\u{274C} {}: {}", check.name, check.detail);
if let Some(hint) = check.hint {
eprintln!(" \u{2192} {hint}");
}
}
}
eprintln!();
failed
}
fn run_check(config: &AutumnConfig, resolved: &ResolvedDeployConfig) -> Result<(), DeployError> {
eprintln!("\u{1F342} autumn deploy check\n");
let checks = collect_preflight(config, resolved);
let failed = report_preflight(&checks);
if failed == 0 {
eprintln!("\u{2705} All {} preflight check(s) passed.", checks.len());
Ok(())
} else {
eprintln!(
"\u{274C} {failed} of {} preflight check(s) failed.",
checks.len()
);
Err(DeployError::PreflightFailed(failed))
}
}
fn print_plan(resolved: &ResolvedDeployConfig, media_cfg: &media::MediaMtxHostConfig) {
println!("\u{1F342} autumn deploy plan (dry-run)\n");
println!("systemd unit ({}.service):\n", resolved.service_name);
println!("{}", render_systemd_unit(resolved));
println!("Deploy steps (zero-downtime):");
for (i, step) in build_deploy_plan(resolved).iter().enumerate() {
println!(" {}. [{}] {}", i + 1, step.label, step.description);
}
if media_cfg.enabled {
print_media_plan(media_cfg);
}
}
fn print_media_plan(media_cfg: &media::MediaMtxHostConfig) {
let controller = media::MediaMtxController::new(media_cfg.clone());
println!(
"\nMediaMTX host provisioning (media unit {}.service):\n",
media_cfg.unit_name
);
println!("{}", media::render_mediamtx_unit(media_cfg));
println!("MediaMTX provisioning steps:");
for (i, op) in controller.ensure_installed_ops().iter().enumerate() {
println!(" {}. [{}]", i + 1, op.label());
}
println!(
"\nMediaMTX doctor checks at `deploy up`: {}, {}, {}, {}, {}",
media::CHECK_MEDIAMTX_PORTS_DISTINCT,
media::CHECK_FFMPEG_PREFLIGHT,
media::CHECK_MEDIAMTX_BINARY,
media::CHECK_RECORDINGS_DIR_WRITABLE,
media::CHECK_MEDIAMTX_PORTS_AVAILABLE,
);
println!(
"CSP: the app must allow these MediaMTX origins in connect-src/media-src \
(frame-src for WebRTC): {}",
media_cfg.required_csp_origins().join(", "),
);
}
fn check_media_host_preflight(
media_cfg: &media::MediaMtxHostConfig,
ffmpeg_bin: &str,
executor: &impl exec::DeployExecutor,
) -> Result<(), DeployError> {
if !media_cfg.enabled {
return Ok(());
}
eprintln!("\u{1F3A5} media host preflight (autumn-media)\n");
let checks = media::collect_media_doctor_checks(executor, media_cfg, ffmpeg_bin);
let failed = report_preflight(&checks);
if failed > 0 {
return Err(DeployError::PreflightFailed(failed));
}
Ok(())
}
fn provision_media_host(
media_cfg: &media::MediaMtxHostConfig,
executor: &impl exec::DeployExecutor,
) -> Result<(), DeployError> {
if !media_cfg.enabled {
return Ok(());
}
eprintln!("\u{1F3A5} provisioning MediaMTX host (autumn-media)\n");
let controller = media::MediaMtxController::new(media_cfg.clone());
exec::run_ops(&controller.ensure_installed_ops(), executor)
.map_err(|e| DeployError::Exec(e.to_string()))?;
eprintln!(
"\u{2705} MediaMTX provisioned ({}.service)",
media_cfg.unit_name
);
Ok(())
}
fn build_env_file(config: &AutumnConfig, resolved: &ResolvedDeployConfig) -> exec::Secret {
let mut body = String::new();
body.push_str("AUTUMN_ENV=");
body.push_str(&resolved.profile);
body.push('\n');
if let Some(secret) = config
.security
.signing_secret
.secret
.as_deref()
.map(str::trim)
.filter(|s| !s.is_empty())
{
body.push_str("AUTUMN_SECURITY__SIGNING_SECRET=");
body.push_str(secret);
body.push('\n');
}
if let Some(url) = resolve_writable_db_url(&config.database) {
body.push_str("AUTUMN_DATABASE__URL=");
body.push_str(url);
body.push('\n');
}
exec::Secret::new(body)
}
fn resolve_release_binary(resolved: &ResolvedDeployConfig) -> Result<PathBuf, DeployError> {
let path = PathBuf::from("target")
.join("release")
.join(&resolved.app_name);
if path.exists() {
Ok(path)
} else {
Err(DeployError::BinaryMissing(path.display().to_string()))
}
}
fn default_release_id() -> String {
chrono::Utc::now().format("%Y%m%dT%H%M%SZ").to_string()
}
fn manifest_project_dirs() -> Vec<PathBuf> {
let cwd = std::env::current_dir().unwrap_or_else(|_| PathBuf::from("."));
if let Ok(dir) = std::env::var("AUTUMN_MANIFEST_DIR")
&& !dir.trim().is_empty()
{
let manifest_dir = PathBuf::from(dir);
if manifest_dir == cwd {
return vec![cwd];
}
return vec![manifest_dir, cwd];
}
vec![cwd]
}
fn first_dir_with_file(dirs: &[PathBuf], filename: &str) -> Option<PathBuf> {
dirs.iter().map(|d| d.join(filename)).find(|p| p.is_file())
}
fn manifest_uploads_in(dirs: &[PathBuf], profile: &str) -> Vec<exec::ManifestUpload> {
let mut uploads = Vec::new();
if let Some(base) = first_dir_with_file(dirs, "autumn.toml") {
uploads.push(exec::ManifestUpload {
local: base,
remote_basename: "autumn.toml".to_owned(),
});
}
let normalized =
autumn_web::config::normalize_profile_name(profile).unwrap_or_else(|| "prod".to_owned());
for name in autumn_web::config::profile_override_file_lookup_names(&normalized, profile) {
let basename = format!("autumn-{name}.toml");
if let Some(local) = first_dir_with_file(dirs, &basename) {
uploads.push(exec::ManifestUpload {
local,
remote_basename: basename,
});
break;
}
}
uploads
}
fn locate_manifest_uploads(resolved: &ResolvedDeployConfig) -> Vec<exec::ManifestUpload> {
manifest_uploads_in(&manifest_project_dirs(), &resolved.profile)
}
fn manifest_preflight_notice(uploads: &[exec::ManifestUpload]) -> String {
if uploads.is_empty() {
return "\u{26A0}\u{FE0F} warning: no autumn.toml found in the project directory; the \
deployed app will run built-in defaults for all non-secret settings (secrets \
still come from the env file). Add an autumn.toml to control the deployed \
configuration."
.to_owned();
}
let names: Vec<&str> = uploads.iter().map(|u| u.remote_basename.as_str()).collect();
format!(
"Uploading project config to the release dir: {}",
names.join(", ")
)
}
const PROXY_HTTPS_PORT: u16 = 443;
fn validate_public_port(public_port: u16) -> Result<(), String> {
let blue_port = exec::slot_app_port(public_port, exec::SLOT_BLUE);
let green_port = exec::slot_app_port(public_port, exec::SLOT_GREEN);
let collision = if public_port == PROXY_HTTPS_PORT {
Some("the proxy's HTTP listener")
} else if blue_port == PROXY_HTTPS_PORT {
Some("the blue app slot")
} else if green_port == PROXY_HTTPS_PORT {
Some("the green app slot")
} else {
None
};
if let Some(which) = collision {
return Err(format!(
"deploy public/HTTP port {public_port} is invalid — {which} would land on \
{PROXY_HTTPS_PORT}, which kamal-proxy reserves for its HTTPS listener \
(blue/green app slots bind `public+1`/`public+2`); use 80 (the default) or \
another port outside {}\u{2013}{PROXY_HTTPS_PORT}",
PROXY_HTTPS_PORT - 2,
));
}
Ok(())
}
fn refuse_concurrent_public_port_change(
installed: &exec::InstalledProxyPort,
new_public_port: u16,
) -> Result<(), String> {
match installed {
exec::InstalledProxyPort::Absent => Ok(()),
exec::InstalledProxyPort::Port(current) if *current == new_public_port => Ok(()),
exec::InstalledProxyPort::Port(current) => Err(format!(
"Changing server.port on an existing deployment isn't supported here yet \
(the installed kamal-proxy is on port {current}, config requests \
{new_public_port}): first redeploy with server.port unchanged (adopts the \
reboot-durability upgrade), then change the port in a separate deploy. \
Tracked in #2073."
)),
exec::InstalledProxyPort::Unreadable => Err(format!(
"Cannot verify the installed kamal-proxy's HTTP port before a redeploy \
(its systemd unit is present but its `--http-port` could not be read), so \
a concurrent server.port change (config requests {new_public_port}) cannot \
be ruled out and is refused to avoid stranding public traffic mid-cutover. \
Re-provision the host (or repair the kamal-proxy unit) and retry. Live-safe \
server.port changes are tracked in #2073."
)),
}
}
#[allow(clippy::too_many_lines)]
fn run_up(
config: &AutumnConfig,
resolved: &ResolvedDeployConfig,
media_cfg: &media::MediaMtxHostConfig,
ffmpeg_bin: &str,
) -> Result<(), DeployError> {
eprintln!("\u{1F342} autumn deploy up\n");
let checks = collect_preflight(config, resolved);
let failed = report_preflight(&checks);
if failed > 0 {
return Err(DeployError::PreflightFailed(failed));
}
let target = exec::SshTarget::from_resolved(resolved)
.ok_or_else(|| DeployError::Config(DEPLOY_HOST_MISSING_DETAIL.to_owned()))?;
let binary = resolve_release_binary(resolved)?;
let env_file = build_env_file(config, resolved);
let manifests = locate_manifest_uploads(resolved);
eprintln!("{}", manifest_preflight_notice(&manifests));
let release_id = default_release_id();
let public_port = config.server.port;
validate_public_port(public_port).map_err(DeployError::Config)?;
let proxy = proxy::KamalProxyController::new(resolved.readiness_timeout_secs)
.with_tls_host(resolved.tls_host.clone());
let executor = exec::SshExecutor::new(target);
check_media_host_preflight(media_cfg, ffmpeg_bin, &executor)?;
exec::probe_proxy_compat(&proxy, &executor).map_err(|e| DeployError::Exec(e.to_string()))?;
let release_dir = format!("{}/{release_id}", resolved.releases_dir());
let probe = exec::probe_deploy_state(resolved, &executor)
.map_err(|e| DeployError::Exec(e.to_string()))?;
let (ops, teardown, banner, is_first) = match probe.mode {
exec::DeployMode::First => {
let plan = exec::SlotPlan::first(public_port);
let unit = render_app_unit(
resolved,
&release_dir,
plan.candidate_port,
plan.candidate_slot,
);
let ops = exec::first_deploy_ops(
resolved,
&proxy,
&unit,
env_file,
&binary,
&manifests,
&release_id,
&plan,
);
let teardown = exec::first_deploy_teardown_ops(resolved, &release_id, &plan);
(ops, teardown, "first deploy".to_owned(), true)
}
exec::DeployMode::Redeploy { live_slot } => {
refuse_concurrent_public_port_change(&probe.installed_proxy_port, public_port)
.map_err(DeployError::Config)?;
let reconcile = exec::reconcile_live_slot(
live_slot,
&probe.proxy_list,
&resolved.service_name,
public_port,
);
if let Some(warn) = &reconcile.warn {
eprintln!("\u{26A0}\u{FE0F} {warn}");
}
let plan = exec::SlotPlan::redeploy(public_port, reconcile.live_slot);
let unit = render_app_unit(
resolved,
&release_dir,
plan.candidate_port,
plan.candidate_slot,
);
let mut ops = exec::cutover_ops(
resolved,
&proxy,
&unit,
env_file,
&binary,
&manifests,
&release_id,
&plan,
);
if reconcile.repair {
ops.insert(
0,
exec::live_slot_marker_repair_op(resolved, plan.live_slot, public_port),
);
}
let teardown = exec::candidate_teardown_ops(resolved, &release_id, &plan);
(
ops,
teardown,
format!(
"zero-downtime redeploy ({} \u{2192} {})",
plan.live_slot, plan.candidate_slot
),
false,
)
}
};
eprintln!(
"Deploying release {release_id} to {} ({banner})\u{2026}\n",
resolved.host.as_deref().unwrap_or_default()
);
let result = if is_first {
exec::execute_first_deploy(&checks, &ops, &teardown, &executor)
} else {
exec::execute_redeploy(&checks, &ops, &teardown, &executor)
};
result.map_err(|e| DeployError::Exec(e.to_string()))?;
provision_media_host(media_cfg, &executor)?;
eprintln!("\n\u{2705} Deploy complete. Roll back with `autumn deploy rollback`.");
Ok(())
}
fn run_rollback(config: &AutumnConfig, resolved: &ResolvedDeployConfig) -> Result<(), DeployError> {
eprintln!("\u{1F342} autumn deploy rollback\n");
let checks = collect_preflight(config, resolved);
let failed = report_preflight(&checks);
if failed > 0 {
return Err(DeployError::PreflightFailed(failed));
}
eprintln!("Rollback steps:");
for (i, step) in build_rollback_plan(resolved).iter().enumerate() {
eprintln!(" {}. [{}] {}", i + 1, step.label, step.description);
}
eprintln!();
let target = exec::SshTarget::from_resolved(resolved)
.ok_or_else(|| DeployError::Config(DEPLOY_HOST_MISSING_DETAIL.to_owned()))?;
let public_port = config.server.port;
let proxy = proxy::KamalProxyController::new(resolved.readiness_timeout_secs)
.with_tls_host(resolved.tls_host.clone());
let executor = exec::SshExecutor::new(target);
let rollback_target = exec::resolve_rollback_target(resolved, public_port, &executor)
.map_err(|e| DeployError::Exec(e.to_string()))?;
eprintln!(
"Rolling back {} to {} ({})\u{2026}\n",
resolved.host.as_deref().unwrap_or_default(),
rollback_target.release_dir,
rollback_target.slot,
);
let ops = exec::rollback_ops(resolved, &proxy, &rollback_target);
let teardown = exec::rollback_teardown_ops(resolved, &rollback_target);
exec::execute_rollback(&checks, &ops, &teardown, &executor)
.map_err(|e| DeployError::Exec(e.to_string()))?;
eprintln!("\n\u{2705} Rollback complete.");
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
fn resolved_defaults() -> ResolvedDeployConfig {
ResolvedDeployConfig::resolve(&DeployConfig::default(), "myapp")
.expect("deploy config resolves")
}
#[test]
fn public_port_443_is_rejected_as_a_proxy_https_collision() {
let err = validate_public_port(443).expect_err("port 443 must be rejected");
assert!(
err.contains("443")
&& err.contains("HTTPS listener")
&& err.contains("proxy's HTTP listener"),
"collision message must name 443/the HTTPS listener/the proxy HTTP port, got: {err}",
);
assert!(validate_public_port(80).is_ok());
assert!(validate_public_port(8080).is_ok());
}
#[test]
fn public_ports_whose_slots_reach_443_are_rejected() {
let green = validate_public_port(441).expect_err("441 (green slot=443) rejected");
assert!(
green.contains("green app slot") && green.contains("443"),
"441 message must name the green slot landing on 443, got: {green}",
);
let blue = validate_public_port(442).expect_err("442 (blue slot=443) rejected");
assert!(
blue.contains("blue app slot") && blue.contains("443"),
"442 message must name the blue slot landing on 443, got: {blue}",
);
assert!(validate_public_port(443).is_err());
assert!(validate_public_port(440).is_ok());
assert!(validate_public_port(444).is_ok());
assert!(validate_public_port(8080).is_ok());
}
#[test]
fn tls_does_not_gate_the_public_port() {
assert!(validate_public_port(80).is_ok());
assert!(validate_public_port(3000).is_ok());
assert!(validate_public_port(8080).is_ok());
}
#[test]
fn redeploy_refuses_a_concurrent_server_port_change() {
let err = refuse_concurrent_public_port_change(&exec::InstalledProxyPort::Port(80), 8080)
.expect_err("a changed server.port must be refused on the redeploy path");
assert!(
err.contains("80") && err.contains("8080"),
"the refuse message must name the installed (80) and requested (8080) ports: {err}",
);
assert!(
err.contains("server.port unchanged") && err.contains("#2073"),
"the refuse message must spell out the operator sequence and reference the \
tracking issue: {err}",
);
}
#[test]
fn redeploy_allows_an_unchanged_server_port() {
assert!(
refuse_concurrent_public_port_change(&exec::InstalledProxyPort::Port(80), 80).is_ok(),
"an unchanged-port redeploy must be allowed",
);
assert!(
refuse_concurrent_public_port_change(&exec::InstalledProxyPort::Port(3000), 3000)
.is_ok(),
"an unchanged non-default port must also be allowed",
);
}
#[test]
fn redeploy_fails_closed_on_an_unreadable_installed_port() {
let err = refuse_concurrent_public_port_change(&exec::InstalledProxyPort::Unreadable, 80)
.expect_err("an unreadable installed proxy port must fail closed");
assert!(
err.contains("could not be read") && err.contains("#2073"),
"the fail-closed message must explain the unreadable unit and reference the \
tracking issue: {err}",
);
}
#[test]
fn redeploy_allows_when_no_proxy_unit_is_installed() {
assert!(
refuse_concurrent_public_port_change(&exec::InstalledProxyPort::Absent, 8080).is_ok(),
"an absent installed proxy unit must not trigger the refuse",
);
}
#[test]
fn resolve_fills_default_chain_from_project_name() {
let resolved = resolved_defaults();
assert_eq!(resolved.app_name, "myapp");
assert_eq!(resolved.app_dir, "/srv/autumn/myapp");
assert_eq!(resolved.service_name, "myapp");
assert_eq!(resolved.user, "root");
assert_eq!(resolved.ssh_port, 22);
assert_eq!(resolved.readiness_timeout_secs, 60);
assert_eq!(resolved.keep_releases, 3);
assert_eq!(resolved.host, None);
assert_eq!(resolved.profile, "prod");
assert!(!resolved.tls_enabled);
assert_eq!(resolved.tls_host, None);
}
#[test]
fn tls_absent_table_leaves_tls_disabled() {
let resolved = resolved_defaults();
assert!(!resolved.tls_enabled);
assert_eq!(resolved.tls_host, None);
}
#[test]
fn tls_enabled_with_host_resolves_tls_host() {
let cfg = DeployConfig {
tls: autumn_web::config::DeployTlsConfig {
enabled: true,
host: Some("app.example.com".to_owned()),
},
..DeployConfig::default()
};
let resolved =
ResolvedDeployConfig::resolve(&cfg, "myapp").expect("enabled + host resolves");
assert!(resolved.tls_enabled);
assert_eq!(resolved.tls_host.as_deref(), Some("app.example.com"));
}
#[test]
fn tls_enabled_without_host_is_a_resolve_error() {
for host in [None, Some(String::new()), Some(" ".to_owned())] {
let cfg = DeployConfig {
tls: autumn_web::config::DeployTlsConfig {
enabled: true,
host,
},
..DeployConfig::default()
};
let err = ResolvedDeployConfig::resolve(&cfg, "myapp")
.expect_err("enabled TLS without a host must be rejected");
assert!(
err.contains("[deploy.tls]") && err.contains("host"),
"error should name the section and the missing key, got: {err}",
);
}
}
#[test]
fn build_env_file_sets_production_profile_by_default() {
let config = AutumnConfig::default();
let resolved = ResolvedDeployConfig::resolve(&DeployConfig::default(), "myapp")
.expect("deploy config resolves");
let env_file = build_env_file(&config, &resolved);
assert!(
env_file.expose().lines().any(|l| l == "AUTUMN_ENV=prod"),
"env file should pin AUTUMN_ENV=prod by default, got: {:?}",
env_file.expose()
);
}
#[test]
fn build_env_file_honors_deploy_profile_override() {
let config = AutumnConfig::default();
let cfg = DeployConfig {
profile: "staging".to_owned(),
..DeployConfig::default()
};
let resolved =
ResolvedDeployConfig::resolve(&cfg, "myapp").expect("deploy config resolves");
let env_file = build_env_file(&config, &resolved);
assert!(
env_file.expose().lines().any(|l| l == "AUTUMN_ENV=staging"),
"env file should honor the [deploy] profile override, got: {:?}",
env_file.expose()
);
}
#[test]
fn runtime_config_loads_profile_scoped_prod_values_for_env_file_and_grading() {
use autumn_web::config::MockEnv;
let dir = tempfile::TempDir::new().expect("temp project dir");
let prod_secret = "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef";
let prod_db_url = "postgres://prod_user:prod_pw@proddb.internal/app";
std::fs::write(
dir.path().join("autumn.toml"),
format!(
"[deploy]\n\
host = \"deploy.example.test\"\n\
\n\
[database]\n\
primary_url = \"postgres://dev:dev@localhost/devapp\"\n\
\n\
[security.signing_secret]\n\
secret = \"changeme\"\n\
\n\
[profile.prod.database]\n\
primary_url = \"{prod_db_url}\"\n\
\n\
[profile.prod.security.signing_secret]\n\
secret = \"{prod_secret}\"\n"
),
)
.expect("write autumn.toml");
let resolved =
ResolvedDeployConfig::resolve(&DeployConfig::default(), "demoapp").expect("resolves");
assert_eq!(resolved.profile, "prod");
let forced = ForcedProfileEnv {
profile: resolved.profile.clone(),
inner: MockEnv::new().with("AUTUMN_MANIFEST_DIR", dir.path().to_str().unwrap()),
};
let runtime_config =
AutumnConfig::load_with_env(&forced).expect("load config under prod profile");
let env_file = build_env_file(&runtime_config, &resolved);
let body = env_file.expose();
assert!(
body.lines().any(|l| l == "AUTUMN_ENV=prod"),
"env file must pin AUTUMN_ENV to the deploy profile, got: {body:?}"
);
assert!(
body.lines()
.any(|l| l == format!("AUTUMN_SECURITY__SIGNING_SECRET={prod_secret}")),
"env file must carry the PROD signing secret, got: {body:?}"
);
assert!(
body.lines()
.any(|l| l == format!("AUTUMN_DATABASE__URL={prod_db_url}")),
"env file must carry the PROD database URL, got: {body:?}"
);
assert!(
!body.contains("changeme"),
"env file must not ship the dev/base signing secret"
);
assert!(
!body.contains("devapp"),
"env file must not ship the dev/base database URL"
);
let signing = collect_preflight(&runtime_config, &resolved)
.into_iter()
.find(|c| c.name == "signing_secret")
.expect("preflight includes a signing_secret check");
assert!(
signing.passed,
"the strong PROD signing secret must pass preflight: {}",
signing.detail
);
}
#[test]
fn forced_profile_env_overrides_only_autumn_env() {
use autumn_web::config::MockEnv;
let forced = ForcedProfileEnv {
profile: "prod".to_owned(),
inner: MockEnv::new()
.with("AUTUMN_ENV", "dev")
.with("SOME_OTHER_KEY", "kept"),
};
assert_eq!(forced.var("AUTUMN_ENV").as_deref(), Ok("prod"));
assert_eq!(forced.var("SOME_OTHER_KEY").as_deref(), Ok("kept"));
assert!(forced.var("UNSET_KEY").is_err());
}
#[test]
fn forced_profile_env_honors_explicit_autumn_dotenv() {
use autumn_web::config::MockEnv;
let off = ForcedProfileEnv {
profile: "prod".to_owned(),
inner: MockEnv::new().with("AUTUMN_DOTENV", "0"),
};
assert_eq!(off.var("AUTUMN_DOTENV").as_deref(), Ok("0"));
assert_eq!(off.var("AUTUMN_ENV").as_deref(), Ok("prod"));
let unset = ForcedProfileEnv {
profile: "prod".to_owned(),
inner: MockEnv::new(),
};
assert_eq!(unset.var("AUTUMN_DOTENV").as_deref(), Ok("1"));
let on = ForcedProfileEnv {
profile: "prod".to_owned(),
inner: MockEnv::new().with("AUTUMN_DOTENV", "true"),
};
assert_eq!(on.var("AUTUMN_DOTENV").as_deref(), Ok("true"));
}
#[test]
fn preflight_grades_signing_secret_against_resolved_deploy_profile() {
let find_signing = |checks: &[PreflightCheck]| {
checks
.iter()
.find(|c| c.name == "signing_secret")
.expect("preflight includes a signing_secret check")
.clone()
};
let mut config = AutumnConfig::default();
config.security.signing_secret.secret = Some("changeme".to_owned());
assert!(
!is_production_profile(config.profile.as_deref()),
"guard: the local runtime profile is non-production in this test"
);
let prod_resolved =
ResolvedDeployConfig::resolve(&DeployConfig::default(), "myapp").expect("resolves");
assert_eq!(prod_resolved.profile, "prod");
let prod_check = find_signing(&collect_preflight(&config, &prod_resolved));
assert!(
!prod_check.passed,
"weak secret must fail preflight under the default (prod) deploy \
profile: {}",
prod_check.detail
);
assert!(!prod_check.detail.contains("changeme"));
let staging_resolved = ResolvedDeployConfig::resolve(
&DeployConfig {
profile: "staging".to_owned(),
..DeployConfig::default()
},
"myapp",
)
.expect("deploy config resolves");
let staging_check = find_signing(&collect_preflight(&config, &staging_resolved));
assert!(
staging_check.passed,
"weak secret should pass preflight under a non-production deploy \
profile: {}",
staging_check.detail
);
}
#[test]
fn resolve_preserves_raw_profile_alias_while_grading_normalized() {
let find_signing = |checks: &[PreflightCheck]| {
checks
.iter()
.find(|c| c.name == "signing_secret")
.expect("preflight includes a signing_secret check")
.clone()
};
let mut config = AutumnConfig::default();
config.security.signing_secret.secret = Some("changeme".to_owned());
for (raw, expected) in [
("PROD", "PROD"),
("Production", "Production"),
(" production ", "production"),
] {
let resolved = ResolvedDeployConfig::resolve(
&DeployConfig {
profile: raw.to_owned(),
..DeployConfig::default()
},
"myapp",
)
.expect("deploy config resolves");
assert_eq!(
resolved.profile, expected,
"profile {raw:?} should preserve the trimmed raw spelling"
);
let expected_env = format!("AUTUMN_ENV={expected}");
assert!(
build_env_file(&config, &resolved)
.expose()
.lines()
.any(|l| l == expected_env),
"env file should pin {expected_env:?} for raw profile {raw:?}"
);
assert!(
is_production_profile(Some(&canonicalize_deploy_profile(&resolved.profile))),
"normalized profile must grade as production for raw profile {raw:?}"
);
let check = find_signing(&collect_preflight(&config, &resolved));
assert!(
!check.passed,
"weak secret must fail preflight for production spelling {raw:?}: {}",
check.detail
);
assert!(!check.detail.contains("changeme"));
}
let staging = ResolvedDeployConfig::resolve(
&DeployConfig {
profile: "staging".to_owned(),
..DeployConfig::default()
},
"myapp",
)
.expect("deploy config resolves");
assert_eq!(staging.profile, "staging");
assert!(!is_production_profile(Some(&staging.profile)));
let staging_check = find_signing(&collect_preflight(&config, &staging));
assert!(
staging_check.passed,
"weak secret should pass preflight under the custom `staging` profile: {}",
staging_check.detail
);
}
#[test]
fn resolve_honors_explicit_overrides() {
let cfg = DeployConfig {
host: Some("203.0.113.10".to_owned()),
app_name: Some("web".to_owned()),
app_dir: Some("/opt/web".to_owned()),
service_name: Some("web-svc".to_owned()),
..DeployConfig::default()
};
let resolved =
ResolvedDeployConfig::resolve(&cfg, "ignored").expect("deploy config resolves");
assert_eq!(resolved.app_name, "web");
assert_eq!(resolved.app_dir, "/opt/web");
assert_eq!(resolved.service_name, "web-svc");
assert_eq!(resolved.host.as_deref(), Some("203.0.113.10"));
}
#[test]
fn resolve_blank_overrides_fall_back_to_defaults() {
let cfg = DeployConfig {
app_name: Some(" ".to_owned()),
app_dir: Some(String::new()),
..DeployConfig::default()
};
let resolved =
ResolvedDeployConfig::resolve(&cfg, "fallback").expect("deploy config resolves");
assert_eq!(resolved.app_name, "fallback");
assert_eq!(resolved.app_dir, "/srv/autumn/fallback");
}
#[test]
fn systemd_unit_contains_service_paths_and_env_file() {
let cfg = ResolvedDeployConfig::resolve(
&DeployConfig {
app_name: Some("shop".to_owned()),
service_name: Some("shop-web".to_owned()),
user: "deploy".to_owned(),
..DeployConfig::default()
},
"shop",
)
.expect("deploy config resolves");
let unit = render_systemd_unit(&cfg);
assert!(unit.contains("Description=Autumn application: shop"));
assert!(unit.contains("User=deploy"));
assert!(unit.contains("WorkingDirectory=/srv/autumn/shop/current"));
assert!(unit.contains("ExecStart=/srv/autumn/shop/current/shop"));
assert!(!unit.contains("autumn serve --release"));
assert!(unit.contains("Restart=on-failure"));
assert!(unit.contains("WantedBy=multi-user.target"));
assert!(unit.contains("EnvironmentFile=/srv/autumn/shop/shared/autumn.env"));
assert!(!unit.contains("Environment=SECRET"));
assert!(unit.contains("Environment=AUTUMN_MANIFEST_DIR=/srv/autumn/shop/current"));
}
#[test]
fn app_unit_sets_manifest_dir_to_release_for_uploaded_config() {
let cfg = ResolvedDeployConfig::resolve(
&DeployConfig {
app_name: Some("shop".to_owned()),
..DeployConfig::default()
},
"shop",
)
.expect("deploy config resolves");
let release_dir = "/srv/autumn/shop/releases/r1";
let unit = render_app_unit(&cfg, release_dir, 3001, "blue");
assert!(
unit.contains(&format!("Environment=AUTUMN_MANIFEST_DIR={release_dir}")),
"slot unit must set AUTUMN_MANIFEST_DIR to the release dir: {unit}"
);
assert!(unit.contains(&format!("WorkingDirectory={release_dir}")));
assert!(unit.contains("EnvironmentFile=/srv/autumn/shop/shared/autumn.env"));
}
#[test]
fn render_systemd_unit_sets_manifest_dir_to_current_symlink() {
let cfg = ResolvedDeployConfig::resolve(
&DeployConfig {
app_name: Some("shop".to_owned()),
..DeployConfig::default()
},
"shop",
)
.expect("deploy config resolves");
let unit = render_systemd_unit(&cfg);
let current = cfg.current_symlink();
assert!(
unit.contains(&format!("Environment=AUTUMN_MANIFEST_DIR={current}")),
"render_systemd_unit must set AUTUMN_MANIFEST_DIR to the current symlink: {unit}"
);
assert!(unit.contains(&format!("WorkingDirectory={current}")));
}
#[test]
fn manifest_uploads_base_only_when_no_profile_sibling() {
let dir = tempfile::tempdir().expect("tempdir");
std::fs::write(dir.path().join("autumn.toml"), "[server]\nport = 8080\n")
.expect("write autumn.toml");
let dirs = vec![dir.path().to_path_buf()];
let uploads = manifest_uploads_in(&dirs, "prod");
assert_eq!(uploads.len(), 1, "only autumn.toml is present");
assert_eq!(uploads[0].remote_basename, "autumn.toml");
assert_eq!(uploads[0].local, dir.path().join("autumn.toml"));
}
#[test]
fn manifest_uploads_include_profile_sibling_when_present() {
let dir = tempfile::tempdir().expect("tempdir");
std::fs::write(dir.path().join("autumn.toml"), "[server]\n").expect("write base");
std::fs::write(dir.path().join("autumn-prod.toml"), "[server]\n").expect("write sibling");
let dirs = vec![dir.path().to_path_buf()];
let uploads = manifest_uploads_in(&dirs, "prod");
let names: Vec<&str> = uploads.iter().map(|u| u.remote_basename.as_str()).collect();
assert_eq!(names, vec!["autumn.toml", "autumn-prod.toml"]);
}
#[test]
fn manifest_uploads_normalize_production_to_canonical_sibling() {
let dir = tempfile::tempdir().expect("tempdir");
std::fs::write(dir.path().join("autumn.toml"), "[server]\n").expect("write base");
std::fs::write(dir.path().join("autumn-production.toml"), "[server]\n")
.expect("write sibling");
let dirs = vec![dir.path().to_path_buf()];
let uploads = manifest_uploads_in(&dirs, "Production");
let names: Vec<&str> = uploads.iter().map(|u| u.remote_basename.as_str()).collect();
assert_eq!(names, vec!["autumn.toml", "autumn-production.toml"]);
assert!(
!uploads
.iter()
.any(|u| u.remote_basename == "autumn-Production.toml"),
"must not upload the raw-cased spelling"
);
}
#[test]
fn manifest_uploads_check_canonical_profile_sibling() {
let dir = tempfile::tempdir().expect("tempdir");
std::fs::write(dir.path().join("autumn.toml"), "[server]\n").expect("write base");
std::fs::write(dir.path().join("autumn-prod.toml"), "[server]\n").expect("write sibling");
let dirs = vec![dir.path().to_path_buf()];
let uploads = manifest_uploads_in(&dirs, "production");
assert!(
uploads
.iter()
.any(|u| u.remote_basename == "autumn-prod.toml"),
"canonical prod sibling is uploaded for raw profile `production`"
);
}
#[test]
fn manifest_uploads_raw_prod_prefers_prod_sibling_first() {
let dir = tempfile::tempdir().expect("tempdir");
std::fs::write(dir.path().join("autumn.toml"), "[server]\n").expect("write base");
std::fs::write(dir.path().join("autumn-prod.toml"), "[server]\n").expect("write sibling");
let dirs = vec![dir.path().to_path_buf()];
let uploads = manifest_uploads_in(&dirs, "prod");
let names: Vec<&str> = uploads.iter().map(|u| u.remote_basename.as_str()).collect();
assert_eq!(names, vec!["autumn.toml", "autumn-prod.toml"]);
}
#[test]
fn manifest_uploads_custom_profile_uses_verbatim_sibling() {
let dir = tempfile::tempdir().expect("tempdir");
std::fs::write(dir.path().join("autumn.toml"), "[server]\n").expect("write base");
std::fs::write(dir.path().join("autumn-staging.toml"), "[server]\n")
.expect("write sibling");
let dirs = vec![dir.path().to_path_buf()];
let uploads = manifest_uploads_in(&dirs, "staging");
let names: Vec<&str> = uploads.iter().map(|u| u.remote_basename.as_str()).collect();
assert_eq!(names, vec!["autumn.toml", "autumn-staging.toml"]);
}
#[test]
fn manifest_uploads_first_lookup_name_wins_when_both_present() {
let dir = tempfile::tempdir().expect("tempdir");
std::fs::write(dir.path().join("autumn.toml"), "[server]\n").expect("write base");
std::fs::write(dir.path().join("autumn-production.toml"), "[server]\n")
.expect("write production");
std::fs::write(dir.path().join("autumn-prod.toml"), "[server]\n").expect("write prod");
let dirs = vec![dir.path().to_path_buf()];
let uploads = manifest_uploads_in(&dirs, "production");
let siblings: Vec<&str> = uploads
.iter()
.map(|u| u.remote_basename.as_str())
.filter(|n| n.starts_with("autumn-"))
.collect();
assert_eq!(
siblings,
vec!["autumn-production.toml"],
"only the first-lookup-name sibling is uploaded, matching the runtime"
);
}
#[test]
fn manifest_uploads_base_falls_back_to_cwd_dir() {
let manifest_dir = tempfile::tempdir().expect("manifest dir");
let cwd = tempfile::tempdir().expect("cwd dir");
std::fs::write(cwd.path().join("autumn.toml"), "[server]\n").expect("write base");
std::fs::write(cwd.path().join("autumn-prod.toml"), "[server]\n").expect("write sibling");
let dirs = vec![manifest_dir.path().to_path_buf(), cwd.path().to_path_buf()];
let uploads = manifest_uploads_in(&dirs, "prod");
assert_eq!(
uploads.len(),
2,
"base + sibling picked up from the CWD fallback"
);
assert_eq!(uploads[0].remote_basename, "autumn.toml");
assert_eq!(uploads[0].local, cwd.path().join("autumn.toml"));
assert_eq!(uploads[1].remote_basename, "autumn-prod.toml");
assert_eq!(uploads[1].local, cwd.path().join("autumn-prod.toml"));
}
#[test]
fn manifest_uploads_primary_dir_wins_over_fallback() {
let manifest_dir = tempfile::tempdir().expect("manifest dir");
let cwd = tempfile::tempdir().expect("cwd dir");
std::fs::write(manifest_dir.path().join("autumn.toml"), "[server]\n").expect("primary");
std::fs::write(cwd.path().join("autumn.toml"), "[server]\n").expect("fallback");
let dirs = vec![manifest_dir.path().to_path_buf(), cwd.path().to_path_buf()];
let uploads = manifest_uploads_in(&dirs, "prod");
assert_eq!(uploads[0].local, manifest_dir.path().join("autumn.toml"));
}
#[test]
fn manifest_uploads_empty_when_no_manifest() {
let dir = tempfile::tempdir().expect("tempdir");
let dirs = vec![dir.path().to_path_buf()];
let uploads = manifest_uploads_in(&dirs, "prod");
assert!(uploads.is_empty(), "no autumn.toml → nothing to upload");
}
#[test]
fn media_config_no_manifest_is_disabled_default() {
let dir = tempfile::tempdir().expect("tempdir");
let dirs = vec![dir.path().to_path_buf()];
let (cfg, bin) = load_media_host_config_in(&dirs, "prod").expect("loads");
assert!(!cfg.enabled, "no autumn.toml → controller disabled");
assert_eq!(bin, media::DEFAULT_FFMPEG_BIN);
}
#[test]
fn media_config_honors_inline_profile_section() {
let dir = tempfile::tempdir().expect("tempdir");
std::fs::write(
dir.path().join("autumn.toml"),
"[media.ffmpeg]\nbin = \"/usr/bin/ffmpeg\"\n\
[profile.prod.media.mediamtx]\nenabled = true\napi_port = 19997\n",
)
.expect("write base");
let dirs = vec![dir.path().to_path_buf()];
let (prod, _) = load_media_host_config_in(&dirs, "prod").expect("loads prod");
assert!(prod.enabled, "prod profile enables media");
assert_eq!(prod.api_port, 19997);
let (dev, _) = load_media_host_config_in(&dirs, "dev").expect("loads dev");
assert!(
!dev.enabled,
"dev has no profile media section → base default"
);
}
#[test]
fn media_config_honors_profile_override_file() {
let dir = tempfile::tempdir().expect("tempdir");
std::fs::write(dir.path().join("autumn.toml"), "[server]\nport = 3000\n")
.expect("write base");
std::fs::write(
dir.path().join("autumn-prod.toml"),
"[media.mediamtx]\nenabled = true\nrtmp_port = 11935\n",
)
.expect("write prod override");
let dirs = vec![dir.path().to_path_buf()];
let (prod, _) = load_media_host_config_in(&dirs, "prod").expect("loads prod");
assert!(prod.enabled, "autumn-prod.toml enables media");
assert_eq!(prod.rtmp_port, 11935);
}
#[test]
fn media_config_honors_profile_override_file_without_base_autumn_toml() {
let dir = tempfile::tempdir().expect("tempdir");
std::fs::write(
dir.path().join("autumn-prod.toml"),
"[media.mediamtx]\nenabled = true\nrtmp_port = 11935\n",
)
.expect("write prod override");
let dirs = vec![dir.path().to_path_buf()];
let (prod, _) = load_media_host_config_in(&dirs, "prod").expect("loads prod");
assert!(
prod.enabled,
"profile-only media config is provisioned with no base autumn.toml"
);
assert_eq!(prod.rtmp_port, 11935);
let (dev, _) = load_media_host_config_in(&dirs, "dev").expect("loads dev");
assert!(!dev.enabled, "dev has no override file → disabled default");
}
#[test]
fn media_config_profile_layer_overrides_base_value() {
let dir = tempfile::tempdir().expect("tempdir");
std::fs::write(
dir.path().join("autumn.toml"),
"[media.mediamtx]\nenabled = true\napi_port = 9997\n",
)
.expect("write base");
std::fs::write(
dir.path().join("autumn-prod.toml"),
"[media.mediamtx]\napi_port = 29997\n",
)
.expect("write prod override");
let dirs = vec![dir.path().to_path_buf()];
let (prod, _) = load_media_host_config_in(&dirs, "prod").expect("loads prod");
assert!(
prod.enabled,
"base enablement is preserved under deep merge"
);
assert_eq!(
prod.api_port, 29997,
"profile override wins over the base value"
);
}
#[test]
fn media_config_base_only_when_profile_has_no_media_layer() {
let dir = tempfile::tempdir().expect("tempdir");
std::fs::write(
dir.path().join("autumn.toml"),
"[media.mediamtx]\nenabled = true\napi_port = 9001\n",
)
.expect("write base");
let dirs = vec![dir.path().to_path_buf()];
let (prod, _) = load_media_host_config_in(&dirs, "prod").expect("loads prod");
assert!(prod.enabled);
assert_eq!(prod.api_port, 9001);
}
#[test]
fn media_config_fails_closed_on_invalid_value_in_active_profile_layer() {
let dir = tempfile::tempdir().expect("tempdir");
std::fs::write(
dir.path().join("autumn.toml"),
"[media.mediamtx]\nenabled = true\n",
)
.expect("write base");
std::fs::write(
dir.path().join("autumn-prod.toml"),
"[media.mediamtx]\napi_port = \"nope\"\n",
)
.expect("write bad prod override");
let dirs = vec![dir.path().to_path_buf()];
let err = load_media_host_config_in(&dirs, "prod").expect_err("must fail closed");
assert!(
matches!(err, DeployError::Config(_)),
"invalid profile-layer media config aborts the deploy: {err:?}"
);
}
#[test]
fn manifest_notice_warns_loudly_when_no_manifest() {
let notice = manifest_preflight_notice(&[]);
assert!(
notice.contains("no autumn.toml") && notice.contains("built-in defaults"),
"no-manifest notice must loudly warn about silent defaults: {notice}"
);
}
#[test]
fn manifest_notice_confirms_uploaded_files() {
let uploads = vec![
exec::ManifestUpload {
local: PathBuf::from("/x/autumn.toml"),
remote_basename: "autumn.toml".to_owned(),
},
exec::ManifestUpload {
local: PathBuf::from("/x/autumn-prod.toml"),
remote_basename: "autumn-prod.toml".to_owned(),
},
];
let notice = manifest_preflight_notice(&uploads);
assert!(notice.contains("autumn.toml"));
assert!(notice.contains("autumn-prod.toml"));
assert!(!notice.contains("no autumn.toml"));
}
#[test]
fn deploy_plan_runs_migrations_before_cutover_with_bounded_readiness_gate() {
let resolved = resolved_defaults();
let plan = build_deploy_plan(&resolved);
let labels: Vec<&str> = plan.iter().map(|s| s.label).collect();
let migrate = labels
.iter()
.position(|&l| l == "migrate")
.expect("migrate step");
let candidate = labels
.iter()
.position(|&l| l == "start-candidate")
.expect("start-candidate step");
let readiness = labels
.iter()
.position(|&l| l == "readiness-gate")
.expect("readiness-gate step");
let cutover = labels
.iter()
.position(|&l| l == "cutover")
.expect("cutover step");
let drain = labels
.iter()
.position(|&l| l == "drain")
.expect("drain step");
assert!(
migrate < readiness,
"migrations must precede the readiness gate"
);
assert!(
migrate < cutover,
"migrations must precede cutover (a failed migration leaves the old version serving)"
);
assert!(readiness < cutover, "readiness gate must precede cutover");
let candidate_desc = &plan[candidate].description;
let candidate_lc = candidate_desc.to_lowercase();
assert!(
candidate_lc.contains("separate") || candidate_lc.contains("distinct"),
"candidate must start on a separate/distinct listener: {candidate_desc}"
);
assert!(
candidate_lc.contains("does not bind") || candidate_lc.contains("not bind"),
"candidate step must state it does NOT bind the live port: {candidate_desc}"
);
assert!(
readiness < cutover,
"handoff must follow the readiness gate"
);
assert!(
cutover < drain,
"traffic handoff must precede draining the old release"
);
let cutover_desc = &plan[cutover].description.to_lowercase();
assert!(
cutover_desc.contains("hand") || cutover_desc.contains("traffic"),
"cutover must describe a traffic handoff: {}",
plan[cutover].description
);
let gate = &plan[readiness].description;
assert!(
gate.contains("60s"),
"readiness gate should be bounded: {gate}"
);
assert!(
gate.to_lowercase().contains("roll back"),
"gate should roll back on timeout: {gate}"
);
assert_eq!(plan.last().expect("non-empty plan").label, "prune");
assert!(plan.last().unwrap().description.contains('3'));
}
#[test]
fn rollback_plan_matches_rollback_ops_sequence() {
let resolved = resolved_defaults();
let plan = build_rollback_plan(&resolved);
let labels: Vec<&str> = plan.iter().map(|s| s.label).collect();
assert_eq!(
labels,
vec![
"write-target-unit",
"daemon-reload",
"restart-previous",
"proxy-flip",
"record-previous",
"repoint",
"record-live-slot",
"readiness-gate",
"drain-rolled-back-slot",
],
"printed rollback plan must match rollback_ops' execution order"
);
let pos = |l: &str| labels.iter().position(|&x| x == l).expect("step present");
assert!(
pos("write-target-unit") < pos("daemon-reload")
&& pos("daemon-reload") < pos("restart-previous"),
"the target unit is re-rendered and daemon-reloaded before the restart"
);
assert!(
pos("restart-previous") < pos("proxy-flip"),
"previous unit must be up before the health-gated flip"
);
assert!(
pos("proxy-flip") < pos("record-previous"),
"flip must precede recording the previous-release marker"
);
assert!(
pos("record-previous") < pos("repoint"),
"the previous-release marker is recorded before `current` moves off it"
);
assert!(
pos("proxy-flip") < pos("repoint"),
"flip must precede the current-symlink repoint"
);
assert!(
pos("restart-previous") < pos("readiness-gate"),
"restart must precede the /ready re-probe"
);
assert!(
pos("readiness-gate") < pos("drain-rolled-back-slot"),
"the former-live slot is drained last, after the /ready re-probe"
);
}
#[test]
fn deploy_host_present_grader_is_offline_and_flags_missing_host() {
let ok = grade_deploy_host_present(Some("203.0.113.10"));
assert!(ok.passed);
assert_eq!(ok.name, "deploy_host");
let missing = grade_deploy_host_present(None);
assert!(!missing.passed, "missing host must fail offline");
assert_eq!(missing.name, "deploy_host");
assert_eq!(missing.detail, DEPLOY_HOST_MISSING_DETAIL);
assert!(missing.hint.unwrap().contains("[deploy] host"));
let blank = grade_deploy_host_present(Some(" "));
assert!(!blank.passed, "blank host must fail offline");
assert_eq!(blank.detail, DEPLOY_HOST_MISSING_DETAIL);
let probe = grade_ssh_reachability(None, 22, Duration::from_millis(50));
assert_eq!(probe.detail, missing.detail);
assert_eq!(probe.hint, missing.hint);
}
#[test]
fn ssh_reachability_fails_fast_without_host() {
let check = grade_ssh_reachability(None, 22, Duration::from_millis(50));
assert!(!check.passed);
assert_eq!(check.name, "ssh_reachability");
assert!(check.hint.unwrap().contains("[deploy] host"));
let blank = grade_ssh_reachability(Some(" "), 22, Duration::from_millis(50));
assert!(!blank.passed);
}
#[test]
fn ssh_reachability_fails_when_all_addresses_unreachable() {
let check = grade_ssh_reachability(Some("127.0.0.1"), 1, Duration::from_millis(100));
assert!(!check.passed, "closed port should fail: {}", check.detail);
assert_eq!(check.name, "ssh_reachability");
assert!(check.detail.contains("cannot reach"));
}
#[test]
fn signing_secret_grader_never_echoes_value() {
let present = grade_signing_secret(Some("super-secret-value"), &[], false);
assert!(present.passed);
assert!(!present.detail.contains("super-secret-value"));
let missing = grade_signing_secret(None, &[], false);
assert!(!missing.passed);
assert!(!grade_signing_secret(Some(" "), &[], false).passed);
}
#[test]
fn signing_secret_grader_non_production_accepts_weak_secret() {
assert!(grade_signing_secret(Some("changeme"), &[], false).passed);
assert!(grade_signing_secret(Some("short"), &[], false).passed);
assert!(grade_signing_secret(Some("changeme"), &["also-weak".to_owned()], false).passed);
}
#[test]
fn signing_secret_grader_production_rejects_weak_secret() {
let demo = grade_signing_secret(Some("changeme"), &[], true);
assert!(!demo.passed, "demo value must fail in production");
assert!(demo.hint.is_some());
assert!(!demo.detail.contains("changeme"));
let short = grade_signing_secret(Some("too-short"), &[], true);
assert!(!short.passed, "too-short secret must fail in production");
assert!(!short.detail.contains("too-short"));
}
#[test]
fn signing_secret_grader_production_accepts_strong_secret() {
let strong = "a".repeat(64);
let check = grade_signing_secret(Some(&strong), &[], true);
assert!(
check.passed,
"strong production secret should pass: {}",
check.detail
);
assert!(!check.detail.contains(&strong));
}
#[test]
fn signing_secret_grader_production_missing_fails() {
let check = grade_signing_secret(None, &[], true);
assert!(!check.passed);
assert!(check.hint.is_some());
}
#[test]
fn signing_secret_grader_production_rejects_weak_previous_secret() {
let strong = "a".repeat(64);
let check = grade_signing_secret(Some(&strong), &["changeme".to_owned()], true);
assert!(
!check.passed,
"weak previous_secrets entry must fail in production"
);
assert!(check.hint.is_some());
assert!(!check.detail.contains("changeme"));
assert!(
check.detail.contains("previous"),
"detail should name the rotation secret: {}",
check.detail
);
}
#[test]
fn signing_secret_grader_production_accepts_strong_previous_secrets() {
let strong = "a".repeat(64);
let previous = vec!["b".repeat(64), "c".repeat(64)];
let check = grade_signing_secret(Some(&strong), &previous, true);
assert!(
check.passed,
"strong current + strong previous should pass: {}",
check.detail
);
}
#[test]
fn signing_secret_grader_non_production_ignores_weak_previous_secret() {
let check = grade_signing_secret(Some("dev-secret"), &["changeme".to_owned()], false);
assert!(check.passed);
}
#[test]
fn database_url_grader_never_echoes_value() {
let absent = Path::new("autumn-deploy-no-such-migrations-dir-echo-guard");
let present = grade_database_url(Some("postgres://user:pw@host/db"), absent, true, false);
assert!(present.passed);
assert!(!present.detail.contains("pw"));
assert!(!grade_database_url(None, absent, true, false).passed);
}
#[test]
fn database_url_grader_passes_for_db_free_app() {
let tmp = tempfile::TempDir::new().unwrap();
let absent_migrations = tmp.path().join("migrations");
assert!(!absent_migrations.exists());
let check = grade_database_url(None, &absent_migrations, false, false);
assert!(
check.passed,
"DB-free app should pass the DB-URL preflight: {}",
check.detail
);
}
#[test]
fn database_url_grader_fails_for_db_backed_runtime_without_url() {
let tmp = tempfile::TempDir::new().unwrap();
let absent_migrations = tmp.path().join("migrations");
assert!(!absent_migrations.exists());
let check = grade_database_url(None, &absent_migrations, false, true);
assert!(
!check.passed,
"DB-backed runtime feature without a URL must fail preflight"
);
assert!(
check.hint.is_some(),
"the failure should carry an actionable remediation hint"
);
}
#[test]
fn database_url_grader_passes_for_db_backed_runtime_with_url() {
let tmp = tempfile::TempDir::new().unwrap();
let absent_migrations = tmp.path().join("migrations");
assert!(!absent_migrations.exists());
let check = grade_database_url(
Some("postgres://user:pw@host/db"),
&absent_migrations,
false,
true,
);
assert!(
check.passed,
"DB-backed runtime feature with a URL should pass: {}",
check.detail
);
assert!(!check.detail.contains("pw"));
}
#[test]
fn database_url_grader_fails_for_db_backed_app_without_url() {
let tmp = tempfile::TempDir::new().unwrap();
let migrations = tmp.path().join("migrations");
std::fs::create_dir(&migrations).unwrap();
let check = grade_database_url(None, &migrations, false, false);
assert!(
!check.passed,
"DB-backed app without a URL should fail preflight"
);
assert!(
check.hint.is_some(),
"the failure should carry an actionable remediation hint"
);
}
#[test]
fn shard_only_config_resolves_a_usable_db_url() {
use autumn_web::config::{DatabaseConfig, ShardConfig};
let db = DatabaseConfig {
shards: vec![ShardConfig {
name: "shard0".to_owned(),
primary_url: "postgres://user:pw@shard0/app".to_owned(),
..Default::default()
}],
..Default::default()
};
assert!(db.effective_primary_url().is_none());
let url = resolve_writable_db_url(&db);
assert_eq!(url, Some("postgres://user:pw@shard0/app"));
let tmp = tempfile::TempDir::new().unwrap();
let migrations = tmp.path().join("migrations");
std::fs::create_dir(&migrations).unwrap();
let check = grade_database_url(url, &migrations, true, false);
assert!(
check.passed,
"shard-only app with migrations should pass the DB-URL preflight: {}",
check.detail
);
assert!(!check.detail.contains("pw"));
}
#[test]
fn replica_only_config_resolves_no_writable_url_and_fails_with_migrations() {
use autumn_web::config::DatabaseConfig;
let db = DatabaseConfig {
replica_url: Some("postgres://user:pw@replica/app".to_owned()),
..Default::default()
};
assert!(db.effective_primary_url().is_none());
let url = resolve_writable_db_url(&db);
assert_eq!(
url, None,
"replica_url alone is not a writable migration target"
);
let tmp = tempfile::TempDir::new().unwrap();
let migrations = tmp.path().join("migrations");
std::fs::create_dir(&migrations).unwrap();
let check = grade_database_url(url, &migrations, true, false);
assert!(
!check.passed,
"replica-only app with migrations must fail the DB-URL preflight"
);
assert!(
check.hint.is_some(),
"the failure should carry an actionable remediation hint"
);
assert!(!check.detail.contains("pw"));
}
#[test]
fn migrate_check_passes_when_no_migrations_dir() {
let dir = std::env::temp_dir().join("autumn-deploy-no-such-migrations-dir-xyz");
let check = grade_migrate_check(&dir);
assert!(
check.passed,
"absent migrations dir should pass: {}",
check.detail
);
}
#[test]
fn is_production_profile_matches_runtime_rule() {
assert!(is_production_profile(Some("prod")));
assert!(is_production_profile(Some("production")));
assert!(!is_production_profile(Some("dev")));
assert!(!is_production_profile(Some("staging")));
assert!(!is_production_profile(None));
}
#[test]
fn requires_database_pool_detects_postgres_backends() {
use autumn_web::config::{AutumnConfig, SchedulerBackend};
let mut config = AutumnConfig::default();
assert!(!requires_database_pool(&config));
config.jobs.backend = "postgres".to_owned();
assert!(requires_database_pool(&config));
let mut config = AutumnConfig::default();
config.scheduler.backend = SchedulerBackend::Postgres;
assert!(requires_database_pool(&config));
let mut config = AutumnConfig::default();
config.jobs.backend = "redis".to_owned();
assert!(!requires_database_pool(&config));
}
#[test]
fn rollback_dispatch_does_not_load_media_config() {
let src = include_str!("deploy.rs");
let needle = ["load_media_host_config", "(&resolved)?"].concat();
let call_sites = src.matches(needle.as_str()).count();
assert_eq!(
call_sites, 2,
"expected exactly two fallible load-media call sites (Plan + Up), found {call_sites}",
);
let run_body = src
.split("pub fn run(action: DeployAction)")
.nth(1)
.expect("run() present");
let rollback_arm = run_body
.split("DeployAction::Rollback =>")
.nth(1)
.expect("Rollback arm present");
let rollback_line = rollback_arm
.split("DeployAction::Up")
.next()
.expect("Up arm follows Rollback");
assert!(
!rollback_line.contains("load_media_host_config"),
"the Rollback arm must not load the media config",
);
let check_arm = run_body
.split("DeployAction::Check =>")
.nth(1)
.and_then(|s| s.split("DeployAction::Rollback").next())
.expect("Check arm present");
assert!(
!check_arm.contains("load_media_host_config"),
"the Check arm must not load the media config",
);
}
#[test]
fn media_provisioning_is_deferred_past_app_cutover_in_up() {
let src = include_str!("deploy.rs");
let up_body = src
.split("fn run_up(")
.nth(1)
.and_then(|s| s.split("\nfn run_rollback(").next())
.expect("run_up body present");
let preflight_at = up_body
.find("check_media_host_preflight(media_cfg")
.expect("preflight call present in run_up");
let commit_at = up_body
.find("result.map_err(|e| DeployError::Exec(e.to_string()))?;")
.expect("app deploy commit line present in run_up");
let provision_at = up_body
.find("provision_media_host(media_cfg, &executor)?;")
.expect("deferred provisioning call present in run_up");
assert!(
preflight_at < commit_at,
"media preflight must precede the app deploy commit",
);
assert!(
commit_at < provision_at,
"MediaMTX provisioning must run only AFTER the app deploy commits — so a \
rolled-back app deploy (which returns at the commit line) never reaches it",
);
let preflight_fn = src
.split("fn check_media_host_preflight(")
.nth(1)
.and_then(|s| s.split("\nfn provision_media_host(").next())
.expect("check_media_host_preflight present");
assert!(
preflight_fn.contains("collect_media_doctor_checks"),
"preflight must run the doctor checks",
);
assert!(
!preflight_fn.contains("ensure_installed_ops"),
"preflight must NOT drive the mutating controller ops (write/restart)",
);
let after_commit = &up_body[commit_at..];
assert!(
after_commit.contains("provision_media_host(media_cfg, &executor)?;"),
"provisioning must sit on the post-commit path (unreachable when the app \
deploy errors out at the commit line)",
);
}
}