use crate::git_root::find_git_root;
use std::fs;
use std::path::Path;
mod templates {
pub const DOCKERFILE: &str = include_str!("templates/release/Dockerfile.tmpl");
pub const DOCKERIGNORE: &str = include_str!("templates/release/.dockerignore.tmpl");
pub const PRODUCTION_CONFIG: &str =
include_str!("templates/release/autumn.production.toml.example.tmpl");
pub const FLY_TOML: &str = include_str!("templates/release/fly.toml.tmpl");
pub const DOCKER_COMPOSE: &str = include_str!("templates/release/docker-compose.yml.tmpl");
pub const AZURE_MAIN_TF: &str = include_str!("templates/release/main.tf.tmpl");
pub const AZURE_VARIABLES_TF: &str = include_str!("templates/release/variables.tf.tmpl");
pub const AZURE_OUTPUTS_TF: &str = include_str!("templates/release/outputs.tf.tmpl");
pub const AZURE_TFVARS_EXAMPLE: &str =
include_str!("templates/release/terraform.tfvars.example.tmpl");
pub const AZURE_DEPLOY_WORKFLOW: &str = include_str!("templates/release/azure-deploy.yml.tmpl");
pub const AZURE_CUTOVER_SCRIPT: &str = include_str!("templates/release/azure-cutover.sh.tmpl");
pub const AWS_APP_RUNNER_MAIN_TF: &str =
include_str!("templates/release/aws-app-runner-main.tf.tmpl");
pub const AWS_APP_RUNNER_VARIABLES_TF: &str =
include_str!("templates/release/aws-app-runner-variables.tf.tmpl");
pub const AWS_APP_RUNNER_OUTPUTS_TF: &str =
include_str!("templates/release/aws-app-runner-outputs.tf.tmpl");
pub const AWS_APP_RUNNER_TFVARS_EXAMPLE: &str =
include_str!("templates/release/aws-app-runner-terraform.tfvars.example.tmpl");
pub const AWS_ECS_MAIN_TF: &str = include_str!("templates/release/aws-ecs-main.tf.tmpl");
pub const AWS_ECS_VARIABLES_TF: &str =
include_str!("templates/release/aws-ecs-variables.tf.tmpl");
pub const AWS_ECS_OUTPUTS_TF: &str = include_str!("templates/release/aws-ecs-outputs.tf.tmpl");
pub const AWS_ECS_TFVARS_EXAMPLE: &str =
include_str!("templates/release/aws-ecs-terraform.tfvars.example.tmpl");
pub const AWS_DEPLOY_WORKFLOW: &str = include_str!("templates/release/aws-deploy.yml.tmpl");
pub const GCP_CLOUD_RUN_MAIN_TF: &str =
include_str!("templates/release/gcp-cloud-run-main.tf.tmpl");
pub const GCP_CLOUD_RUN_VARIABLES_TF: &str =
include_str!("templates/release/gcp-cloud-run-variables.tf.tmpl");
pub const GCP_CLOUD_RUN_OUTPUTS_TF: &str =
include_str!("templates/release/gcp-cloud-run-outputs.tf.tmpl");
pub const GCP_CLOUD_RUN_TFVARS_EXAMPLE: &str =
include_str!("templates/release/gcp-cloud-run-terraform.tfvars.example.tmpl");
pub const GCP_DEPLOY_WORKFLOW: &str = include_str!("templates/release/gcp-deploy.yml.tmpl");
pub const HELM_CHART: &str = include_str!("templates/release/kubernetes/helm/Chart.yaml.tmpl");
pub const HELM_VALUES: &str =
include_str!("templates/release/kubernetes/helm/values.yaml.tmpl");
pub const HELM_IGNORE: &str =
include_str!("templates/release/kubernetes/helm/.helmignore.tmpl");
pub const HELM_HELPERS: &str =
include_str!("templates/release/kubernetes/helm/templates/_helpers.tpl.tmpl");
pub const HELM_DEPLOYMENT: &str =
include_str!("templates/release/kubernetes/helm/templates/deployment.yaml.tmpl");
pub const HELM_ROLLOUT: &str =
include_str!("templates/release/kubernetes/helm/templates/rollout.yaml.tmpl");
pub const HELM_CANARY: &str =
include_str!("templates/release/kubernetes/helm/templates/canary.yaml.tmpl");
pub const HELM_SERVICE: &str =
include_str!("templates/release/kubernetes/helm/templates/service.yaml.tmpl");
pub const HELM_PDB: &str =
include_str!("templates/release/kubernetes/helm/templates/pdb.yaml.tmpl");
pub const HELM_POD_MONITOR: &str =
include_str!("templates/release/kubernetes/helm/templates/podmonitor.yaml.tmpl");
pub const HELM_NOTES: &str =
include_str!("templates/release/kubernetes/helm/templates/NOTES.txt.tmpl");
pub const KUSTOMIZATION: &str =
include_str!("templates/release/kubernetes/kustomize/kustomization.yaml.tmpl");
pub const KUSTOMIZE_DEPLOYMENT: &str =
include_str!("templates/release/kubernetes/kustomize/deployment.yaml.tmpl");
pub const KUSTOMIZE_SERVICE: &str =
include_str!("templates/release/kubernetes/kustomize/service.yaml.tmpl");
pub const KUSTOMIZE_PDB: &str =
include_str!("templates/release/kubernetes/kustomize/pdb.yaml.tmpl");
}
const SUPPLY_CHAIN_MIN_CLI_VERSION: &str = "0.7.1";
const SBOM_GENERATE_STEP: &str = r#"
# Machine-readable inventory of everything compiled into this image, generated
# from the very source tree that produced the binary above, resolving the same
# features that build used and filtered to this builder's own target triple.
# Without that filter the document lists target-specific dependencies for every
# platform — the whole `windows-*` family — none of which can be in a Linux
# image. Deliberately no `--locked`: a project whose Cargo.lock has drifted
# still builds (its own build would have updated the lock anyway), and the SBOM
# records what was actually resolved. Add `--locked` for the stricter posture.
RUN autumn sbom{{sbom_features}} \
--filter-platform "$(rustc -vV | grep '^host:' | cut -d' ' -f2)" \
--output /app/sbom.cdx.json
"#;
const SBOM_RUNTIME_COPY: &str = r"# Supply-chain inventory, at a fixed path so scanners and `docker cp` can find
# it without knowing anything about autumn. The binary itself independently
# carries the same list (see cargo-auditable above); docs/guide/supply-chain.md
# shows how to cross-check the two.
COPY --chown=autumn:autumn --from=builder /app/sbom.cdx.json /usr/share/autumn/sbom.cdx.json
";
const SBOM_LABELS: &str = concat!(
" \\\n io.autumn.sbom.format=\"CycloneDX\"",
" \\\n io.autumn.sbom.path=\"/usr/share/autumn/sbom.cdx.json\""
);
fn strip_sbom_block(rendered: &str, keep: bool) -> String {
if !rendered.contains("{{sbom_steps_begin}}") {
return rendered.to_owned();
}
let mut out = String::with_capacity(rendered.len());
let mut inside = false;
for line in rendered.lines() {
let trimmed = line.trim();
if trimmed == "{{sbom_steps_begin}}" {
inside = true;
continue;
}
if trimmed == "{{sbom_steps_end}}" {
inside = false;
continue;
}
if inside && !keep {
continue;
}
out.push_str(line);
out.push('\n');
}
out
}
fn semver_triple(version: &str) -> Option<(u64, u64, u64)> {
let core = version.split(['-', '+']).next().filter(|s| !s.is_empty())?;
let mut parts = core.split('.');
let major = parts.next()?.parse().ok()?;
let minor = parts.next()?.parse().ok()?;
let patch = parts.next()?.parse().ok()?;
if parts.next().is_some() {
return None;
}
Some((major, minor, patch))
}
fn cli_supports_supply_chain_flags(cli_version: &str) -> bool {
match (
semver_triple(cli_version),
semver_triple(SUPPLY_CHAIN_MIN_CLI_VERSION),
) {
(Some(have), Some(need)) => have >= need,
_ => true,
}
}
#[derive(Debug, thiserror::Error)]
pub enum ReleaseError {
#[error("'{0}' already exists — run with --force to overwrite")]
FileExists(String),
#[error("failed to read Cargo.toml: {0}")]
CargoToml(String),
#[error("I/O error: {0}")]
Io(#[from] std::io::Error),
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Target {
Default,
Fly,
DockerCompose,
AzureContainerApps,
AwsAppRunner,
AwsEcs,
GcpCloudRun,
Kubernetes,
}
impl std::str::FromStr for Target {
type Err = String;
fn from_str(s: &str) -> Result<Self, Self::Err> {
match s {
"fly" => Ok(Self::Fly),
"docker-compose" => Ok(Self::DockerCompose),
"azure-container-apps" => Ok(Self::AzureContainerApps),
"aws-app-runner" => Ok(Self::AwsAppRunner),
"aws-ecs" => Ok(Self::AwsEcs),
"gcp-cloud-run" => Ok(Self::GcpCloudRun),
"kubernetes" => Ok(Self::Kubernetes),
other => Err(format!(
"unknown target '{other}'; expected 'fly', 'docker-compose', \
'azure-container-apps', 'aws-app-runner', 'aws-ecs', 'gcp-cloud-run', \
or 'kubernetes'"
)),
}
}
}
const fn is_terraform_target(target: Target) -> bool {
matches!(
target,
Target::AzureContainerApps | Target::AwsAppRunner | Target::AwsEcs | Target::GcpCloudRun
)
}
#[derive(Clone, Copy)]
pub enum ReleaseAction {
Init {
force: bool,
target: Target,
split_workers: bool,
},
}
pub fn run(action: ReleaseAction) {
eprintln!("🍂 autumn release\n");
let cwd = std::env::current_dir().unwrap_or_else(|e| {
eprintln!("Error: cannot determine current directory: {e}");
std::process::exit(1);
});
match action {
ReleaseAction::Init {
force,
target,
split_workers,
} => {
let project_name = read_project_name(&cwd).unwrap_or_else(|e| {
eprintln!("Error: {e}");
std::process::exit(1);
});
match init(&cwd, &project_name, force, target, split_workers) {
Ok(files) => {
for f in &files {
println!(" Created {f}");
}
let workflow_file = planned_files(target)
.into_iter()
.map(|(name, _)| name)
.find(|name| name.starts_with(".github/workflows/"));
if let Some(workflow_file) = workflow_file
&& let Some(warning) =
nested_workflow_relocation_warning(&cwd, workflow_file)
{
eprintln!("\n{warning}");
}
let config_path = cwd.join("autumn.production.toml.example");
if let Ok(content) = std::fs::read_to_string(&config_path)
&& let Err(e) = check_production_config_signing_secret(&content)
{
eprintln!("Warning: smoke gate failed for generated config: {e}");
}
println!();
println!("Next steps:");
println!(
" 1. Generate and set your signing secret (REQUIRED before production boot):"
);
println!(
" export AUTUMN_SECURITY__SIGNING_SECRET=\"$(openssl rand -hex 32)\""
);
println!(" Smoke-gate check — the app must refuse to start without it:");
println!(" AUTUMN_ENV=prod docker run --rm \\");
println!(" -e AUTUMN_DATABASE__PRIMARY_URL=... \\");
println!(" {project_name} 2>&1 | grep -i 'signing secret'");
println!(" And must start with it:");
println!(" AUTUMN_ENV=prod docker run --rm \\");
println!(" -e AUTUMN_DATABASE__PRIMARY_URL=... \\");
println!(
" -e AUTUMN_SECURITY__SIGNING_SECRET=\"$AUTUMN_SECURITY__SIGNING_SECRET\" \\"
);
println!(" {project_name}");
println!();
println!(" 2. Build, migrate the primary once, then run web replicas:");
println!(" docker build -t {project_name} .");
println!(" AUTUMN_DATABASE__PRIMARY_URL=... autumn migrate");
println!(
" docker run --rm -p 3000:3000 -e AUTUMN_DATABASE__PRIMARY_URL=... \\"
);
println!(
" -e AUTUMN_SECURITY__SIGNING_SECRET=\"$AUTUMN_SECURITY__SIGNING_SECRET\" \\"
);
println!(" {project_name}");
println!();
println!(
" See docs/guide/deployment.md and docs/guide/signing-secrets.md for the full walkthrough."
);
if target == Target::Kubernetes {
println!("{}", kubernetes_next_steps(&project_name));
}
}
Err(e) => {
eprintln!("Error: {e}");
std::process::exit(1);
}
}
}
}
}
pub fn check_production_config_signing_secret(content: &str) -> Result<(), String> {
for (line_num, line) in content.lines().enumerate() {
let trimmed = line.trim();
if trimmed.starts_with('#') {
continue;
}
if let Some(rest) = trimmed.strip_prefix("secret") {
let rest = rest.trim_start();
if let Some(rest) = rest.strip_prefix('=') {
let value = rest.trim().trim_matches('"').trim_matches('\'');
if !value.is_empty() && value != "[]" {
return Err(format!(
"line {}: production config contains a committed signing secret literal \
at `secret = ...`; use AUTUMN_SECURITY__SIGNING_SECRET env var instead",
line_num + 1,
));
}
}
}
}
Ok(())
}
pub fn read_project_name(dir: &Path) -> Result<String, ReleaseError> {
let path = dir.join("Cargo.toml");
let content = fs::read_to_string(&path)
.map_err(|e| ReleaseError::CargoToml(format!("{}: {e}", path.display())))?;
if content.contains("[workspace]") && !content.contains("[package]") {
return Err(ReleaseError::CargoToml(
"found [workspace] but no [package] — run this command from a member crate directory, not the workspace root".into(),
));
}
let parsed: toml::Value = toml::from_str(&content)
.map_err(|e| ReleaseError::CargoToml(format!("parse error: {e}")))?;
parsed
.get("package")
.and_then(|p| p.get("name"))
.and_then(|n| n.as_str())
.map(str::to_owned)
.ok_or_else(|| ReleaseError::CargoToml("missing [package] name".into()))
}
fn nested_workflow_relocation_warning(dir: &Path, workflow_rel_path: &str) -> Option<String> {
let git_root = find_git_root(dir)?;
if git_root == dir {
return None;
}
let rel = dir.strip_prefix(&git_root).ok()?;
let rel_forward_slash = rel
.components()
.map(|c| c.as_os_str().to_string_lossy().into_owned())
.collect::<Vec<_>>()
.join("/");
Some(format!(
"Warning: this project lives inside a Git repository whose root is\n\
{}, but `{workflow_rel_path}` was written under\n\
{} — GitHub Actions only discovers workflow files under the\n\
repository ROOT's `.github/workflows/`, so this workflow will never\n\
run as-is. Move it to {}/{workflow_rel_path} and add\n\
the following so its `docker build` step still finds this crate's\n\
Dockerfile:\n\
\n\
defaults:\n run:\n working-directory: {rel_forward_slash}\n\
\n\
`hashFiles` and `subject-path` ignore `working-directory`. Also change\n\
`POSTURE_MANIFEST` in the top-level `env:` block. Set it to the manifest\n\
path from the repository root. If you do not, the posture attestation\n\
is skipped:\n\
\n\
POSTURE_MANIFEST: {rel_forward_slash}/security-posture.json\n",
git_root.display(),
dir.display(),
git_root.display(),
))
}
pub fn init(
dir: &Path,
project_name: &str,
force: bool,
target: Target,
split_workers: bool,
) -> Result<Vec<String>, ReleaseError> {
let files = planned_files(target);
if !force {
for (name, _) in &files {
if dir.join(name).exists() {
return Err(ReleaseError::FileExists(name.to_string()));
}
}
}
if is_terraform_target(target) {
ensure_terraform_gitignore_entries(dir)?;
}
let embed = project_has_embed_assets(dir);
let mut created = Vec::new();
for (name, template) in files {
let path = dir.join(name);
if let Some(parent) = path.parent() {
fs::create_dir_all(parent)?;
}
fs::write(&path, render(template, project_name, embed, split_workers))?;
created.push(name.to_string());
}
Ok(created)
}
const TERRAFORM_GITIGNORE_ENTRIES: &[&str] = &[
"# Terraform (autumn release init --target azure-container-apps / aws-app-runner / aws-ecs / gcp-cloud-run)",
".terraform/",
"*.tfstate",
"*.tfstate.*",
"terraform.tfvars",
];
fn gitignore_pattern_still_effective(existing: &str, pattern: &str) -> bool {
let mut effective = false;
for line in existing.lines() {
let line = line.trim();
if line == pattern {
effective = true;
} else if effective && line.starts_with('!') {
effective = false;
}
}
effective
}
fn ensure_terraform_gitignore_entries(dir: &Path) -> std::io::Result<()> {
let path = dir.join(".gitignore");
let existing = match fs::read_to_string(&path) {
Ok(contents) => contents,
Err(e) if e.kind() == std::io::ErrorKind::NotFound => String::new(),
Err(e) => return Err(e),
};
let missing: Vec<&str> = TERRAFORM_GITIGNORE_ENTRIES
.iter()
.copied()
.filter(|line| {
if line.starts_with('#') {
!existing.lines().any(|l| l.trim() == *line)
} else {
!gitignore_pattern_still_effective(&existing, line)
}
})
.collect();
if missing.is_empty() {
return Ok(());
}
let mut updated = existing;
if !updated.is_empty() {
if !updated.ends_with('\n') {
updated.push('\n');
}
updated.push('\n');
}
for line in missing {
updated.push_str(line);
updated.push('\n');
}
fs::write(path, updated)
}
fn project_has_embed_assets(dir: &Path) -> bool {
let Ok(contents) = fs::read_to_string(dir.join("Cargo.toml")) else {
return false;
};
let Ok(parsed) = toml::from_str::<toml::Table>(&contents) else {
return false;
};
parsed
.get("features")
.and_then(toml::Value::as_table)
.is_some_and(|features| features.contains_key("embed-assets"))
}
const WORKER_SERVICE_BLOCK: &str = "\n worker:\n build: .\n environment:\n \
AUTUMN_PROFILE: prod\n AUTUMN_ROLE: worker\n AUTUMN_JOBS__BACKEND: postgres\n \
AUTUMN_DATABASE__PRIMARY_URL: postgres://autumn:autumn@db:5432/{{project_name}}_prod\n \
AUTUMN_SECURITY__SIGNING_SECRET: \"${AUTUMN_SECURITY__SIGNING_SECRET:?set it first}\"\n \
depends_on:\n db:\n condition: service_healthy\n migrate:\n \
condition: service_completed_successfully\n restart: unless-stopped\n";
const WEB_ROLE_ENV: &str = "\n AUTUMN_ROLE: web\n AUTUMN_JOBS__BACKEND: postgres";
fn render(template: &str, project_name: &str, embed: bool, split_workers: bool) -> String {
render_for_cli(
template,
project_name,
embed,
split_workers,
env!("CARGO_PKG_VERSION"),
)
}
fn render_for_cli(
template: &str,
project_name: &str,
embed: bool,
split_workers: bool,
cli_version: &str,
) -> String {
let (embed_auditable_note, embed_auditable) = if cli_supports_supply_chain_flags(cli_version) {
(
"# `--auditable` routes BOTH compile phases through `cargo auditable`, so the\n\
# shipped binary carries its own dependency list.\n",
" --auditable",
)
} else {
("", "")
};
let embed_build_step = format!(
"# Single-binary build: fingerprint static assets, then compile with the\n\
# embed-assets feature so the binary serves static/ (incl. the fingerprint\n\
# manifest) and i18n locales from itself — no sidecar directories. The app\n\
# opts in via `.embedded_static()` / `.embedded_locales()` (see src/main.rs).\n\
{embed_auditable_note}\
RUN autumn build --embed{embed_auditable}"
);
let (build_step, static_copy) = if embed {
(
embed_build_step.as_str(),
"# Assets and locales are embedded in the binary (`autumn build --embed`);\n\
# only migrations/ is staged, for the one-shot `autumn migrate` job.\n",
)
} else {
(
"# `cargo auditable` embeds the resolved dependency list into the binary so it\n\
# can report its own crate versions with no source tree — see\n\
# docs/guide/supply-chain.md. Otherwise an ordinary release build.\n\
RUN cargo auditable build --release",
"COPY --chown=autumn:autumn --from=builder /app/static /app/static\n",
)
};
let emits_sbom = cli_supports_supply_chain_flags(cli_version);
let sbom_generate_step = if emits_sbom {
SBOM_GENERATE_STEP.replace(
"{{sbom_features}}",
if embed {
" --features embed-assets"
} else {
""
},
)
} else {
String::new()
};
let (sbom_runtime_copy, sbom_labels) = if emits_sbom {
(SBOM_RUNTIME_COPY, SBOM_LABELS)
} else {
("", "")
};
let (worker_service, web_role_env) = if split_workers {
(WORKER_SERVICE_BLOCK, WEB_ROLE_ENV)
} else {
("", "")
};
let rendered = template
.replace("{{worker_service}}", worker_service)
.replace("{{app_role_env}}", web_role_env)
.replace("{{k8s_name}}", &k8s_name(project_name))
.replace("{{project_name}}", project_name)
.replace(
"{{rust_version}}",
option_env!("CARGO_PKG_RUST_VERSION").unwrap_or("1.88.0"),
)
.replace("{{autumn_cli_version}}", cli_version)
.replace("{{diesel_cli_version}}", "2.3.8")
.replace("{{build_step}}", build_step)
.replace("{{static_copy}}", static_copy)
.replace("{{sbom_generate_step}}", &sbom_generate_step)
.replace("{{sbom_runtime_copy}}", sbom_runtime_copy)
.replace("{{sbom_labels}}", sbom_labels);
strip_sbom_block(&rendered, cli_supports_supply_chain_flags(cli_version))
}
fn planned_files(target: Target) -> Vec<(&'static str, &'static str)> {
let mut files: Vec<(&'static str, &'static str)> = vec![
("Dockerfile", templates::DOCKERFILE),
(".dockerignore", templates::DOCKERIGNORE),
(
"autumn.production.toml.example",
templates::PRODUCTION_CONFIG,
),
];
match target {
Target::Fly => files.push(("fly.toml", templates::FLY_TOML)),
Target::DockerCompose => files.push(("docker-compose.yml", templates::DOCKER_COMPOSE)),
Target::AzureContainerApps => {
files.push(("main.tf", templates::AZURE_MAIN_TF));
files.push(("variables.tf", templates::AZURE_VARIABLES_TF));
files.push(("outputs.tf", templates::AZURE_OUTPUTS_TF));
files.push(("terraform.tfvars.example", templates::AZURE_TFVARS_EXAMPLE));
files.push((
".github/workflows/azure-deploy.yml",
templates::AZURE_DEPLOY_WORKFLOW,
));
files.push(("azure-cutover.sh", templates::AZURE_CUTOVER_SCRIPT));
}
Target::AwsAppRunner => {
files.push(("main.tf", templates::AWS_APP_RUNNER_MAIN_TF));
files.push(("variables.tf", templates::AWS_APP_RUNNER_VARIABLES_TF));
files.push(("outputs.tf", templates::AWS_APP_RUNNER_OUTPUTS_TF));
files.push((
"terraform.tfvars.example",
templates::AWS_APP_RUNNER_TFVARS_EXAMPLE,
));
}
Target::AwsEcs => {
files.push(("main.tf", templates::AWS_ECS_MAIN_TF));
files.push(("variables.tf", templates::AWS_ECS_VARIABLES_TF));
files.push(("outputs.tf", templates::AWS_ECS_OUTPUTS_TF));
files.push((
"terraform.tfvars.example",
templates::AWS_ECS_TFVARS_EXAMPLE,
));
files.push((
".github/workflows/aws-deploy.yml",
templates::AWS_DEPLOY_WORKFLOW,
));
}
Target::GcpCloudRun => {
files.push(("main.tf", templates::GCP_CLOUD_RUN_MAIN_TF));
files.push(("variables.tf", templates::GCP_CLOUD_RUN_VARIABLES_TF));
files.push(("outputs.tf", templates::GCP_CLOUD_RUN_OUTPUTS_TF));
files.push((
"terraform.tfvars.example",
templates::GCP_CLOUD_RUN_TFVARS_EXAMPLE,
));
files.push((
".github/workflows/gcp-deploy.yml",
templates::GCP_DEPLOY_WORKFLOW,
));
}
Target::Kubernetes => files.extend(KUBERNETES_FILES),
Target::Default => {}
}
files
}
const KUBERNETES_FILES: [(&str, &str); 15] = [
("deploy/helm/Chart.yaml", templates::HELM_CHART),
("deploy/helm/values.yaml", templates::HELM_VALUES),
("deploy/helm/.helmignore", templates::HELM_IGNORE),
(
"deploy/helm/templates/_helpers.tpl",
templates::HELM_HELPERS,
),
(
"deploy/helm/templates/deployment.yaml",
templates::HELM_DEPLOYMENT,
),
(
"deploy/helm/templates/rollout.yaml",
templates::HELM_ROLLOUT,
),
("deploy/helm/templates/canary.yaml", templates::HELM_CANARY),
(
"deploy/helm/templates/service.yaml",
templates::HELM_SERVICE,
),
("deploy/helm/templates/pdb.yaml", templates::HELM_PDB),
(
"deploy/helm/templates/podmonitor.yaml",
templates::HELM_POD_MONITOR,
),
("deploy/helm/templates/NOTES.txt", templates::HELM_NOTES),
(
"deploy/kustomize/base/kustomization.yaml",
templates::KUSTOMIZATION,
),
(
"deploy/kustomize/base/deployment.yaml",
templates::KUSTOMIZE_DEPLOYMENT,
),
(
"deploy/kustomize/base/service.yaml",
templates::KUSTOMIZE_SERVICE,
),
("deploy/kustomize/base/pdb.yaml", templates::KUSTOMIZE_PDB),
];
fn kubernetes_next_steps(project_name: &str) -> String {
format!(
"\n Kubernetes:\n Push the image to your registry. Then:\n helm upgrade \
--install {name} deploy/helm --set image.repository=<registry>/{name} --set \
image.tag=<tag> --set 'trustedHosts[0]=<your-domain>'\n or, with your overlay of the base:\n kubectl apply -k \
<your-overlay>\n Run `autumn slo generate` for SLO alerts and canary analysis. See \
docs/guide/kubernetes.md.",
name = k8s_name(project_name)
)
}
fn k8s_name(project_name: &str) -> String {
let name: String = project_name
.to_ascii_lowercase()
.chars()
.map(|c| if c.is_ascii_alphanumeric() { c } else { '-' })
.take(53)
.collect();
name.trim_matches('-').to_owned()
}
#[cfg(test)]
mod tests {
use super::*;
use tempfile::TempDir;
fn make_project(tmp: &TempDir, name: &str) -> std::path::PathBuf {
let dir = tmp.path().to_path_buf();
fs::write(
dir.join("Cargo.toml"),
format!("[package]\nname = \"{name}\"\nversion = \"0.1.0\"\n"),
)
.unwrap();
dir
}
#[test]
fn init_creates_dockerfile() {
let tmp = TempDir::new().unwrap();
let dir = make_project(&tmp, "my-app");
init(&dir, "my-app", false, Target::Default, false).unwrap();
assert!(dir.join("Dockerfile").is_file(), "Dockerfile not created");
}
#[test]
fn init_creates_dockerignore() {
let tmp = TempDir::new().unwrap();
let dir = make_project(&tmp, "my-app");
init(&dir, "my-app", false, Target::Default, false).unwrap();
assert!(
dir.join(".dockerignore").is_file(),
".dockerignore not created"
);
}
#[test]
fn init_creates_production_config_example() {
let tmp = TempDir::new().unwrap();
let dir = make_project(&tmp, "my-app");
init(&dir, "my-app", false, Target::Default, false).unwrap();
assert!(
dir.join("autumn.production.toml.example").is_file(),
"autumn.production.toml.example not created"
);
}
#[test]
fn init_returns_list_of_created_files() {
let tmp = TempDir::new().unwrap();
let dir = make_project(&tmp, "my-app");
let files = init(&dir, "my-app", false, Target::Default, false).unwrap();
assert!(files.contains(&"Dockerfile".to_string()));
assert!(files.contains(&".dockerignore".to_string()));
assert!(files.contains(&"autumn.production.toml.example".to_string()));
}
#[test]
fn dockerfile_has_cargo_chef_stages() {
let tmp = TempDir::new().unwrap();
let dir = make_project(&tmp, "my-app");
init(&dir, "my-app", false, Target::Default, false).unwrap();
let content = fs::read_to_string(dir.join("Dockerfile")).unwrap();
assert!(
content.contains("cargo-chef"),
"Dockerfile must use cargo-chef for dependency caching"
);
assert!(
content.contains("cargo chef prepare"),
"Dockerfile must run 'cargo chef prepare'"
);
assert!(
content.contains("cargo chef cook"),
"Dockerfile must run 'cargo chef cook'"
);
}
#[test]
fn dockerfile_uses_declared_msrv_for_rust_build_image() {
let tmp = TempDir::new().unwrap();
let dir = make_project(&tmp, "my-app");
init(&dir, "my-app", false, Target::Default, false).unwrap();
let content = fs::read_to_string(dir.join("Dockerfile")).unwrap();
let msrv = option_env!("CARGO_PKG_RUST_VERSION").unwrap_or("1.88.0");
assert!(
content.contains(&format!("FROM rust:{msrv}-bookworm AS chef")),
"Dockerfile build stage must use the declared MSRV {msrv}: {content}"
);
assert!(
!content.contains("rust-1.86") && !content.contains("rust:1.86"),
"Dockerfile must not pin an older Rust image than the declared MSRV: {content}"
);
}
#[test]
fn dockerfile_passes_through_git_provenance_build_args() {
let tmp = TempDir::new().unwrap();
let dir = make_project(&tmp, "my-app");
init(&dir, "my-app", false, Target::Default, false).unwrap();
let content = fs::read_to_string(dir.join("Dockerfile")).unwrap();
for arg in [
"AUTUMN_BUILD_GIT_SHA",
"AUTUMN_BUILD_GIT_SHA_SHORT",
"AUTUMN_BUILD_GIT_BRANCH",
"AUTUMN_BUILD_GIT_DIRTY",
"AUTUMN_BUILD_TIMESTAMP",
] {
assert!(
content.contains(&format!("ARG {arg}")),
"Dockerfile must declare `ARG {arg}` for git provenance passthrough: {content}"
);
assert!(
content.contains(&format!("{arg}=${{{arg}}}")),
"Dockerfile must surface `{arg}` as ENV so build.rs bakes it: {content}"
);
}
let env_pos = content.find("AUTUMN_BUILD_GIT_SHA=${AUTUMN_BUILD_GIT_SHA}");
let build_pos = content.find("{{build_step}}").or_else(|| {
content
.find("RUN cargo auditable build --release")
.or_else(|| content.find("RUN autumn build --embed"))
});
assert!(
matches!((env_pos, build_pos), (Some(e), Some(b)) if e < b),
"provenance ENV must appear before the build step: {content}"
);
}
#[test]
fn an_embed_build_does_not_pass_auditable_to_a_cli_that_lacks_it() {
let rendered = render_for_cli(
include_str!("templates/release/Dockerfile.tmpl"),
"my-app",
true,
false,
"0.7.0",
);
assert!(
rendered.contains("RUN autumn build --embed"),
"the embed build must still happen: {rendered}"
);
assert!(
!rendered.contains("--auditable"),
"a CLI pinned at 0.7.0 has no --auditable flag; passing it fails the \
build with an unknown argument:\n{rendered}"
);
}
#[test]
fn an_embed_build_passes_auditable_to_a_cli_that_has_it() {
let rendered = render_for_cli(
include_str!("templates/release/Dockerfile.tmpl"),
"my-app",
true,
false,
"0.7.1",
);
assert!(
rendered.contains("RUN autumn build --embed --auditable"),
"{rendered}"
);
}
#[test]
fn a_non_embed_build_is_auditable_regardless_of_the_pin() {
for cli_version in ["0.7.0", "0.7.1"] {
let rendered = render_for_cli(
include_str!("templates/release/Dockerfile.tmpl"),
"my-app",
false,
false,
cli_version,
);
assert!(
rendered.contains("RUN cargo auditable build --release"),
"{cli_version}: {rendered}"
);
}
}
fn sbom_command(rendered: &str) -> String {
rendered
.replace("\r\n", "\n")
.replace("\\\n", " ")
.lines()
.find(|l| l.trim_start().starts_with("RUN autumn sbom"))
.unwrap_or_default()
.split_whitespace()
.collect::<Vec<_>>()
.join(" ")
}
#[test]
fn a_cli_pin_that_predates_autumn_sbom_omits_the_sbom_steps() {
let rendered = render_for_cli(
include_str!("templates/release/Dockerfile.tmpl"),
"my-app",
false,
false,
"0.7.0",
);
assert!(
!rendered.contains("autumn sbom"),
"must not call a subcommand the pinned CLI lacks:\n{rendered}"
);
assert!(
!rendered.contains("sbom.cdx.json"),
"and must not COPY a file that was never generated:\n{rendered}"
);
assert!(
rendered.contains("cargo auditable build --release"),
"{rendered}"
);
assert!(rendered.contains("RUN autumn setup"), "{rendered}");
}
#[test]
fn a_cli_pin_that_ships_autumn_sbom_emits_the_sbom_steps() {
let rendered = render_for_cli(
include_str!("templates/release/Dockerfile.tmpl"),
"my-app",
false,
false,
"0.7.1",
);
assert!(
sbom_command(&rendered).contains("--output /app/sbom.cdx.json"),
"{rendered}"
);
assert!(
rendered.contains("/usr/share/autumn/sbom.cdx.json"),
"{rendered}"
);
assert!(rendered.contains("io.autumn.sbom.path"), "{rendered}");
}
#[test]
fn the_rendered_dockerfile_has_no_stray_indentation() {
for cli_version in ["0.7.0", "0.7.1"] {
let rendered = render_for_cli(
include_str!("templates/release/Dockerfile.tmpl"),
"my-app",
false,
false,
cli_version,
);
for (n, line) in rendered.lines().enumerate() {
let leading = line.len() - line.trim_start().len();
let trimmed = line.trim_start();
if trimmed.starts_with('#') || trimmed.starts_with("COPY ") {
assert_eq!(
leading,
0,
"line {} of the {cli_version} render is indented: {line:?}",
n + 1
);
}
}
}
}
#[test]
fn the_embed_build_sbom_resolves_the_features_it_was_built_with() {
let embed = render_for_cli(
include_str!("templates/release/Dockerfile.tmpl"),
"my-app",
true,
false,
"0.7.1",
);
assert!(
sbom_command(&embed).contains("--features embed-assets"),
"{embed}"
);
let plain = render_for_cli(
include_str!("templates/release/Dockerfile.tmpl"),
"my-app",
false,
false,
"0.7.1",
);
assert!(
!sbom_command(&plain).contains("--features"),
"a non-embed build takes the default feature set: {plain}"
);
}
#[test]
fn the_supply_chain_gate_opens_for_every_future_version() {
for v in ["0.7.1", "0.8.0", "0.10.0", "1.0.0", "2.3.4"] {
assert!(
cli_supports_supply_chain_flags(v),
"{v} should support the #1615 CLI surface"
);
}
for v in ["0.7.0", "0.6.0", "0.1.0"] {
assert!(
!cli_supports_supply_chain_flags(v),
"{v} predates autumn sbom"
);
}
assert!(!cli_supports_supply_chain_flags("0.7.0-dev"));
assert!(cli_supports_supply_chain_flags("0.8.0-rc.1"));
assert!(cli_supports_supply_chain_flags("not-a-version"));
}
#[test]
fn a_gated_deploy_workflow_drops_only_the_sbom_steps() {
let with = render_for_cli(
include_str!("templates/release/gcp-deploy.yml.tmpl"),
"my-app",
false,
false,
"0.7.1",
);
let without = render_for_cli(
include_str!("templates/release/gcp-deploy.yml.tmpl"),
"my-app",
false,
false,
"0.7.0",
);
assert!(with.contains("actions/attest-sbom"), "{with}");
assert!(!without.contains("actions/attest-sbom"), "{without}");
for rendered in [&with, &without] {
assert!(
rendered.contains("actions/attest-build-provenance"),
"{rendered}"
);
}
assert!(
!with.contains("{{sbom_steps_") && !without.contains("{{sbom_steps_"),
"the markers must never reach a generated file"
);
}
#[test]
fn every_deploy_workflow_attests_the_security_posture_manifest() {
for template in [
include_str!("templates/release/gcp-deploy.yml.tmpl"),
include_str!("templates/release/aws-deploy.yml.tmpl"),
include_str!("templates/release/azure-deploy.yml.tmpl"),
] {
for cli_version in ["0.7.1", "0.7.0"] {
let rendered = render_for_cli(template, "my-app", false, false, cli_version);
assert!(
rendered.contains("Attest the security posture manifest"),
"posture attestation missing for CLI {cli_version}:\n{rendered}"
);
assert!(
rendered.contains("POSTURE_MANIFEST: security-posture.json"),
"the manifest path must be one variable a member can change:\n{rendered}"
);
assert!(
rendered.contains("subject-path: ${{ env.POSTURE_MANIFEST }}"),
"the attested subject must be the committed manifest:\n{rendered}"
);
assert!(
rendered.contains("if: hashFiles(env.POSTURE_MANIFEST) != ''"),
"an app with no committed manifest must not fail its deploy:\n{rendered}"
);
let warning = rendered
.split("- name: Warn when the security posture manifest is missing")
.nth(1)
.unwrap_or_else(|| panic!("no warning step:\n{rendered}"));
let step = warning.split("\n - ").next().unwrap();
assert!(
step.contains("if: hashFiles(env.POSTURE_MANIFEST) == ''"),
"{step}"
);
assert!(step.contains("::warning::"), "{step}");
assert!(!step.contains("exit 1"), "{step}");
}
}
}
#[test]
fn dockerfile_has_three_stages() {
let tmp = TempDir::new().unwrap();
let dir = make_project(&tmp, "my-app");
init(&dir, "my-app", false, Target::Default, false).unwrap();
let content = fs::read_to_string(dir.join("Dockerfile")).unwrap();
let from_count = content.lines().filter(|l| l.starts_with("FROM ")).count();
assert!(
from_count >= 3,
"Dockerfile must have at least 3 FROM stages (chef, planner, builder, runtime); got {from_count}"
);
}
#[test]
fn dockerfile_copies_production_config_as_runtime_autumn_toml() {
let tmp = TempDir::new().unwrap();
let dir = make_project(&tmp, "my-app");
init(&dir, "my-app", false, Target::Default, false).unwrap();
let content = fs::read_to_string(dir.join("Dockerfile")).unwrap();
assert!(
content.contains("autumn.production.toml.example"),
"Dockerfile must COPY autumn.production.toml.example into the runtime image so \
the container binds to 0.0.0.0 (not the dev 127.0.0.1) without manual edits"
);
}
#[test]
fn dockerfile_runtime_uses_debian_slim() {
let tmp = TempDir::new().unwrap();
let dir = make_project(&tmp, "my-app");
init(&dir, "my-app", false, Target::Default, false).unwrap();
let content = fs::read_to_string(dir.join("Dockerfile")).unwrap();
assert!(
content.contains("debian:bookworm-slim"),
"runtime stage must use debian:bookworm-slim"
);
}
#[test]
fn dockerfile_runtime_installs_libpq_and_tini() {
let tmp = TempDir::new().unwrap();
let dir = make_project(&tmp, "my-app");
init(&dir, "my-app", false, Target::Default, false).unwrap();
let content = fs::read_to_string(dir.join("Dockerfile")).unwrap();
assert!(
content.contains("libpq"),
"runtime must install libpq for Diesel"
);
assert!(
content.contains("tini"),
"runtime must install tini as init process"
);
assert!(
content.contains("ca-certificates"),
"runtime must install ca-certificates"
);
}
#[test]
fn dockerfile_uses_disk_build_without_embed_feature() {
let tmp = TempDir::new().unwrap();
let dir = make_project(&tmp, "my-app");
init(&dir, "my-app", false, Target::Default, false).unwrap();
let content = fs::read_to_string(dir.join("Dockerfile")).unwrap();
assert!(
content.contains("RUN cargo auditable build --release"),
"non-embed project must use the disk-based build: {content}"
);
assert!(
!content.contains("RUN autumn build --embed"),
"non-embed project must not require the embed-assets feature"
);
assert!(
content.contains("/app/static"),
"non-embed build must COPY the static/ sidecar into the runtime image"
);
}
#[test]
fn dockerfile_embeds_when_project_has_embed_feature() {
let tmp = TempDir::new().unwrap();
let dir = tmp.path().to_path_buf();
fs::write(
dir.join("Cargo.toml"),
"[package]\nname = \"my-app\"\nversion = \"0.1.0\"\n\n\
[features]\nembed-assets = [\"autumn-web/embed-assets\"]\n",
)
.unwrap();
init(&dir, "my-app", false, Target::Default, false).unwrap();
let content = fs::read_to_string(dir.join("Dockerfile")).unwrap();
assert!(
content.contains("autumn build --embed"),
"embed-feature project must build the embedded single binary: {content}"
);
assert!(
!content.contains("/app/static"),
"embedded build must not COPY a static/ sidecar into the runtime image"
);
}
#[test]
fn dockerfile_copies_migrations() {
let tmp = TempDir::new().unwrap();
let dir = make_project(&tmp, "my-app");
init(&dir, "my-app", false, Target::Default, false).unwrap();
let content = fs::read_to_string(dir.join("Dockerfile")).unwrap();
assert!(
content.contains("migrations"),
"Dockerfile must COPY migrations into runtime image"
);
}
#[test]
fn dockerfile_defers_migrations_to_one_shot_primary_job() {
let tmp = TempDir::new().unwrap();
let dir = make_project(&tmp, "my-app");
init(&dir, "my-app", false, Target::Default, false).unwrap();
let content = fs::read_to_string(dir.join("Dockerfile")).unwrap();
assert!(
!content.contains("auto_migrate_in_production = true"),
"Dockerfile must not enable startup migrations for every web replica"
);
assert!(
content.contains("autumn migrate"),
"Dockerfile must document the explicit primary-role migration job"
);
}
#[test]
fn dockerfile_installs_autumn_cli_for_migration_jobs() {
let tmp = TempDir::new().unwrap();
let dir = make_project(&tmp, "my-app");
init(&dir, "my-app", false, Target::Default, false).unwrap();
let content = fs::read_to_string(dir.join("Dockerfile")).unwrap();
assert!(
content.contains("cargo install")
&& content.contains("autumn-cli")
&& content.contains("/usr/local/bin/autumn"),
"Dockerfile must include the autumn CLI used by one-shot migration jobs"
);
}
#[test]
fn dockerfile_installs_diesel_cli_for_migration_jobs() {
let tmp = TempDir::new().unwrap();
let dir = make_project(&tmp, "my-app");
init(&dir, "my-app", false, Target::Default, false).unwrap();
let content = fs::read_to_string(dir.join("Dockerfile")).unwrap();
assert!(
content.contains("cargo install")
&& content.contains("diesel_cli")
&& content.contains("libpq-dev")
&& content.contains("--features postgres")
&& content.contains("/usr/local/bin/diesel"),
"Dockerfile must include the diesel CLI used by autumn migrate"
);
}
#[test]
fn dockerfile_has_healthcheck() {
let tmp = TempDir::new().unwrap();
let dir = make_project(&tmp, "my-app");
init(&dir, "my-app", false, Target::Default, false).unwrap();
let content = fs::read_to_string(dir.join("Dockerfile")).unwrap();
assert!(
content.contains("HEALTHCHECK"),
"Dockerfile must have a HEALTHCHECK directive"
);
assert!(
content.contains("localhost:3000/startup"),
"HEALTHCHECK must probe the /startup endpoint"
);
}
fn healthcheck_command(dockerfile: &str) -> String {
let mut lines = dockerfile
.lines()
.skip_while(|line| !line.starts_with("HEALTHCHECK"));
let mut instruction = String::new();
for line in &mut lines {
instruction.push_str(line);
instruction.push('\n');
if !line.trim_end().ends_with('\\') {
break;
}
}
let cmd_at = instruction
.find("CMD ")
.unwrap_or_else(|| panic!("HEALTHCHECK has no CMD: {instruction}"));
instruction[cmd_at + 4..].to_owned()
}
#[test]
fn dockerfile_healthcheck_url_is_overridable_for_direct_tls() {
let tmp = TempDir::new().unwrap();
let dir = make_project(&tmp, "my-app");
init(&dir, "my-app", false, Target::Default, false).unwrap();
let content = fs::read_to_string(dir.join("Dockerfile")).unwrap();
let healthcheck = healthcheck_command(&content);
assert!(
healthcheck.contains("AUTUMN_HEALTHCHECK_URL"),
"HEALTHCHECK must honor $AUTUMN_HEALTHCHECK_URL so an HTTPS-terminating \
image can be probed over https://, got: {healthcheck}"
);
assert!(
healthcheck.contains("http://localhost:3000/startup"),
"the default probe URL must stay today's plain-HTTP one, got: {healthcheck}"
);
assert!(
healthcheck.contains("AUTUMN_HEALTHCHECK_INSECURE"),
"an HTTPS-terminating image needs a way to skip verification on its own \
loopback probe, got: {healthcheck}"
);
}
#[cfg(unix)]
#[test]
fn dockerfile_healthcheck_skips_verification_only_on_explicit_opt_in() {
let tmp = TempDir::new().unwrap();
let dir = make_project(&tmp, "my-app");
init(&dir, "my-app", false, Target::Default, false).unwrap();
let content = fs::read_to_string(dir.join("Dockerfile")).unwrap();
let healthcheck = healthcheck_command(&content);
let harness = format!("curl() {{ printf '%s\\n' \"$*\" >&2; }}\n{healthcheck}");
let cases: [(Option<&str>, Option<&str>, bool); 7] = [
(None, None, false),
(Some("https://localhost:3000/startup"), None, false),
(Some("https://localhost:3000/startup"), Some("1"), true),
(Some("https://localhost:3000/startup"), Some("true"), true),
(Some("https://localhost:3000/startup"), Some(""), false),
(
Some("https://localhost:3000@remote.example/startup"),
None,
false,
),
(
Some("https://remote.example#@localhost/startup"),
None,
false,
),
];
for (url, insecure, expect_insecure) in cases {
let mut command = std::process::Command::new("sh");
command.arg("-c").arg(&harness);
command.env_remove("AUTUMN_HEALTHCHECK_URL");
command.env_remove("AUTUMN_HEALTHCHECK_INSECURE");
if let Some(url) = url {
command.env("AUTUMN_HEALTHCHECK_URL", url);
}
if let Some(insecure) = insecure {
command.env("AUTUMN_HEALTHCHECK_INSECURE", insecure);
}
let output = command.output().expect("run the probe under sh");
let invocation = String::from_utf8_lossy(&output.stderr).into_owned();
assert!(
output.status.success(),
"the probe should exit 0 for {url:?}/{insecure:?}, got {:?}: {invocation}",
output.status
);
assert_eq!(
invocation.contains("--insecure"),
expect_insecure,
"certificate verification for url={url:?} insecure={insecure:?} must {} be \
skipped; curl was called as: {invocation}",
if expect_insecure { "" } else { "NOT" }
);
let expected_url = url.unwrap_or("http://localhost:3000/startup");
assert!(
invocation.contains(expected_url),
"the probe must request {expected_url}; curl was called as: {invocation}"
);
}
}
#[test]
fn dockerfile_exposes_port_3000() {
let tmp = TempDir::new().unwrap();
let dir = make_project(&tmp, "my-app");
init(&dir, "my-app", false, Target::Default, false).unwrap();
let content = fs::read_to_string(dir.join("Dockerfile")).unwrap();
assert!(
content.contains("EXPOSE 3000"),
"Dockerfile must EXPOSE 3000"
);
}
#[test]
fn dockerfile_substitutes_project_name() {
let tmp = TempDir::new().unwrap();
let dir = make_project(&tmp, "my-blog");
init(&dir, "my-blog", false, Target::Default, false).unwrap();
let content = fs::read_to_string(dir.join("Dockerfile")).unwrap();
assert!(
content.contains("my-blog"),
"Dockerfile must contain the substituted project name"
);
assert!(
!content.contains("{{project_name}}"),
"Dockerfile must not contain unsubstituted {{{{project_name}}}}"
);
assert!(
!content.contains("{{"),
"Dockerfile must not contain any unsubstituted template placeholders"
);
}
#[test]
fn dockerignore_excludes_target() {
let tmp = TempDir::new().unwrap();
let dir = make_project(&tmp, "my-app");
init(&dir, "my-app", false, Target::Default, false).unwrap();
let content = fs::read_to_string(dir.join(".dockerignore")).unwrap();
assert!(
content.contains("target"),
".dockerignore must exclude target/"
);
}
#[test]
fn dockerignore_excludes_git() {
let tmp = TempDir::new().unwrap();
let dir = make_project(&tmp, "my-app");
init(&dir, "my-app", false, Target::Default, false).unwrap();
let content = fs::read_to_string(dir.join(".dockerignore")).unwrap();
assert!(content.contains(".git"), ".dockerignore must exclude .git");
}
#[test]
fn dockerignore_excludes_node_modules() {
let tmp = TempDir::new().unwrap();
let dir = make_project(&tmp, "my-app");
init(&dir, "my-app", false, Target::Default, false).unwrap();
let content = fs::read_to_string(dir.join(".dockerignore")).unwrap();
assert!(
content.contains("node_modules"),
".dockerignore must exclude node_modules"
);
}
#[test]
fn dockerignore_excludes_dist() {
let tmp = TempDir::new().unwrap();
let dir = make_project(&tmp, "my-app");
init(&dir, "my-app", false, Target::Default, false).unwrap();
let content = fs::read_to_string(dir.join(".dockerignore")).unwrap();
assert!(content.contains("dist"), ".dockerignore must exclude dist/");
}
#[test]
fn dockerignore_excludes_master_key() {
let tmp = TempDir::new().unwrap();
let dir = make_project(&tmp, "my-app");
init(&dir, "my-app", false, Target::Default, false).unwrap();
let content = fs::read_to_string(dir.join(".dockerignore")).unwrap();
assert!(
content.contains("/config/master.key") || content.contains("config/master.key"),
".dockerignore must exclude config/master.key"
);
}
#[test]
fn dockerignore_excludes_terraform_state() {
let tmp = TempDir::new().unwrap();
let dir = make_project(&tmp, "my-app");
init(&dir, "my-app", false, Target::AzureContainerApps, false).unwrap();
let content = fs::read_to_string(dir.join(".dockerignore")).unwrap();
for pattern in [".terraform/", "*.tfstate", "terraform.tfvars"] {
assert!(
content.contains(pattern),
".dockerignore must exclude {pattern:?} so terraform.tfstate is never sent \
to the Docker build context: {content}"
);
}
}
#[test]
fn production_config_template_documents_signing_secret_env_var() {
let tmp = TempDir::new().unwrap();
let dir = make_project(&tmp, "my-app");
init(&dir, "my-app", false, Target::Default, false).unwrap();
let content = fs::read_to_string(dir.join("autumn.production.toml.example")).unwrap();
assert!(
content.contains("AUTUMN_SECURITY__SIGNING_SECRET"),
"production config template must document the signing-secret env var"
);
}
#[test]
fn production_config_template_documents_openssl_rand_command() {
let tmp = TempDir::new().unwrap();
let dir = make_project(&tmp, "my-app");
init(&dir, "my-app", false, Target::Default, false).unwrap();
let content = fs::read_to_string(dir.join("autumn.production.toml.example")).unwrap();
assert!(
content.contains("openssl rand -hex 32"),
"production config template must show the secret generation command"
);
}
#[test]
fn production_config_template_mentions_signing_secrets_guide() {
let tmp = TempDir::new().unwrap();
let dir = make_project(&tmp, "my-app");
init(&dir, "my-app", false, Target::Default, false).unwrap();
let content = fs::read_to_string(dir.join("autumn.production.toml.example")).unwrap();
assert!(
content.contains("signing-secrets.md"),
"production config template must link to the signing-secrets guide"
);
}
#[test]
fn smoke_gate_passes_for_valid_config() {
let content = r#"
# This is a comment with secret = "ignored"
[server]
port = 3000
"#;
assert!(check_production_config_signing_secret(content).is_ok());
}
#[test]
fn smoke_gate_fails_when_secret_literal_committed() {
let content = r#"
[security.signing_secret]
secret = "my-actual-secret-value-here"
"#;
let err = check_production_config_signing_secret(content).unwrap_err();
assert!(err.contains("committed signing secret literal"));
}
#[test]
fn smoke_gate_ignores_commented_secret_lines() {
let content = r#"
# secret = "example-value-for-docs"
# Set AUTUMN_SECURITY__SIGNING_SECRET instead
"#;
assert!(check_production_config_signing_secret(content).is_ok());
}
#[test]
fn smoke_gate_passes_for_empty_previous_secrets() {
let content = "
[security.signing_secret]
previous_secrets = []
";
assert!(check_production_config_signing_secret(content).is_ok());
}
#[test]
fn production_config_has_placeholder_db_url_not_real_credentials() {
let tmp = TempDir::new().unwrap();
let dir = make_project(&tmp, "my-app");
init(&dir, "my-app", false, Target::Default, false).unwrap();
let content = fs::read_to_string(dir.join("autumn.production.toml.example")).unwrap();
assert!(
content.contains("DATABASE_URL") || content.contains("url"),
"production config must document the database URL setting"
);
for line in content.lines() {
let trimmed = line.trim();
if trimmed.starts_with('#') {
continue;
}
assert!(
!trimmed.to_lowercase().contains("secret"),
"production config must not contain real secrets"
);
}
}
#[test]
fn production_config_has_placeholder_for_project_name() {
let tmp = TempDir::new().unwrap();
let dir = make_project(&tmp, "my-blog");
init(&dir, "my-blog", false, Target::Default, false).unwrap();
let content = fs::read_to_string(dir.join("autumn.production.toml.example")).unwrap();
assert!(
content.contains("my-blog"),
"production config must substitute project name"
);
assert!(
!content.contains("{{project_name}}"),
"production config must not contain unsubstituted placeholders"
);
}
#[test]
fn production_config_documents_port() {
let tmp = TempDir::new().unwrap();
let dir = make_project(&tmp, "my-app");
init(&dir, "my-app", false, Target::Default, false).unwrap();
let content = fs::read_to_string(dir.join("autumn.production.toml.example")).unwrap();
assert!(
content.contains("port"),
"production config must document the port setting"
);
}
#[test]
fn init_without_force_errors_if_dockerfile_exists() {
let tmp = TempDir::new().unwrap();
let dir = make_project(&tmp, "my-app");
fs::write(dir.join("Dockerfile"), "existing content").unwrap();
let err = init(&dir, "my-app", false, Target::Default, false).unwrap_err();
assert!(
matches!(err, ReleaseError::FileExists(_)),
"expected FileExists, got: {err}"
);
assert!(
err.to_string().contains("Dockerfile"),
"error message must name the conflicting file"
);
}
#[test]
fn init_without_force_errors_if_any_file_exists() {
let tmp = TempDir::new().unwrap();
let dir = make_project(&tmp, "my-app");
fs::write(dir.join(".dockerignore"), "existing").unwrap();
let err = init(&dir, "my-app", false, Target::Default, false).unwrap_err();
assert!(matches!(err, ReleaseError::FileExists(_)));
}
#[test]
fn init_with_force_overwrites_existing_files() {
let tmp = TempDir::new().unwrap();
let dir = make_project(&tmp, "my-app");
fs::write(dir.join("Dockerfile"), "old content").unwrap();
init(&dir, "my-app", true, Target::Default, false).unwrap();
let content = fs::read_to_string(dir.join("Dockerfile")).unwrap();
assert_ne!(
content, "old content",
"Dockerfile must be overwritten with --force"
);
}
#[test]
fn fly_target_creates_fly_toml() {
let tmp = TempDir::new().unwrap();
let dir = make_project(&tmp, "my-app");
init(&dir, "my-app", false, Target::Fly, false).unwrap();
assert!(
dir.join("fly.toml").is_file(),
"fly.toml must be created for --target=fly"
);
}
#[test]
fn fly_toml_references_dockerfile() {
let tmp = TempDir::new().unwrap();
let dir = make_project(&tmp, "my-app");
init(&dir, "my-app", false, Target::Fly, false).unwrap();
let content = fs::read_to_string(dir.join("fly.toml")).unwrap();
assert!(
content.contains("Dockerfile"),
"fly.toml must reference the Dockerfile"
);
}
#[test]
fn fly_toml_has_app_name() {
let tmp = TempDir::new().unwrap();
let dir = make_project(&tmp, "my-blog");
init(&dir, "my-blog", false, Target::Fly, false).unwrap();
let content = fs::read_to_string(dir.join("fly.toml")).unwrap();
assert!(
content.contains("my-blog"),
"fly.toml must contain the app name"
);
assert!(
!content.contains("{{project_name}}"),
"fly.toml must not contain unsubstituted placeholders"
);
}
#[test]
fn fly_toml_has_liveness_probe() {
let tmp = TempDir::new().unwrap();
let dir = make_project(&tmp, "my-app");
init(&dir, "my-app", false, Target::Fly, false).unwrap();
let content = fs::read_to_string(dir.join("fly.toml")).unwrap();
assert!(
content.contains("path") && content.contains("/live"),
"fly.toml must wire /live as the liveness health check"
);
}
#[test]
fn fly_toml_has_readiness_probe() {
let tmp = TempDir::new().unwrap();
let dir = make_project(&tmp, "my-app");
init(&dir, "my-app", false, Target::Fly, false).unwrap();
let content = fs::read_to_string(dir.join("fly.toml")).unwrap();
assert!(
content.contains("path") && content.contains("/ready"),
"fly.toml must wire /ready as the readiness check so Fly stops routing \
traffic before the listener closes during graceful shutdown"
);
}
#[test]
fn fly_toml_has_kill_timeout() {
let tmp = TempDir::new().unwrap();
let dir = make_project(&tmp, "my-app");
init(&dir, "my-app", false, Target::Fly, false).unwrap();
let content = fs::read_to_string(dir.join("fly.toml")).unwrap();
assert!(
content.contains("kill_timeout"),
"fly.toml must set kill_timeout so Fly waits at least \
prestop_grace_secs + shutdown_timeout_secs before sending SIGKILL"
);
assert!(
content.contains("kill_signal"),
"fly.toml must set kill_signal = \"SIGTERM\" so Autumn receives the expected signal"
);
}
#[test]
fn fly_toml_has_metrics_endpoint() {
let tmp = TempDir::new().unwrap();
let dir = make_project(&tmp, "my-app");
init(&dir, "my-app", false, Target::Fly, false).unwrap();
let content = fs::read_to_string(dir.join("fly.toml")).unwrap();
assert!(
content.contains("[metrics]"),
"fly.toml must include a [metrics] section for Fly's Prometheus scraper"
);
assert!(
content.contains("/actuator/prometheus"),
"fly.toml [metrics] must point to /actuator/prometheus (Prometheus text format), \
not /actuator/metrics (JSON)"
);
}
#[test]
fn fly_toml_documents_deploy_release_command() {
let tmp = TempDir::new().unwrap();
let dir = make_project(&tmp, "my-app");
init(&dir, "my-app", false, Target::Fly, false).unwrap();
let content = fs::read_to_string(dir.join("fly.toml")).unwrap();
assert!(
content.contains("autumn migrate"),
"fly.toml must document the autumn migrate release command so DB users \
can uncomment it"
);
assert!(
content.contains("release_command"),
"fly.toml must document release_command so DB users know where to uncomment"
);
}
#[test]
fn default_target_does_not_create_fly_toml() {
let tmp = TempDir::new().unwrap();
let dir = make_project(&tmp, "my-app");
init(&dir, "my-app", false, Target::Default, false).unwrap();
assert!(
!dir.join("fly.toml").exists(),
"fly.toml must NOT be created for the default target"
);
}
#[test]
fn docker_compose_target_creates_docker_compose_yml() {
let tmp = TempDir::new().unwrap();
let dir = make_project(&tmp, "my-app");
init(&dir, "my-app", false, Target::DockerCompose, false).unwrap();
assert!(
dir.join("docker-compose.yml").is_file(),
"docker-compose.yml must be created for --target=docker-compose"
);
}
#[test]
fn docker_compose_has_app_service() {
let tmp = TempDir::new().unwrap();
let dir = make_project(&tmp, "my-app");
init(&dir, "my-app", false, Target::DockerCompose, false).unwrap();
let content = fs::read_to_string(dir.join("docker-compose.yml")).unwrap();
assert!(
content.contains("app:"),
"docker-compose.yml must have an 'app' service"
);
}
#[test]
fn docker_compose_has_postgres_service() {
let tmp = TempDir::new().unwrap();
let dir = make_project(&tmp, "my-app");
init(&dir, "my-app", false, Target::DockerCompose, false).unwrap();
let content = fs::read_to_string(dir.join("docker-compose.yml")).unwrap();
assert!(
content.contains("postgres") || content.contains("db:"),
"docker-compose.yml must have a Postgres service"
);
}
#[test]
fn docker_compose_app_depends_on_db() {
let tmp = TempDir::new().unwrap();
let dir = make_project(&tmp, "my-app");
init(&dir, "my-app", false, Target::DockerCompose, false).unwrap();
let content = fs::read_to_string(dir.join("docker-compose.yml")).unwrap();
assert!(
content.contains("depends_on"),
"docker-compose.yml app service must depend_on the db"
);
}
#[test]
fn docker_compose_app_requires_signing_secret_env() {
let tmp = TempDir::new().unwrap();
let dir = make_project(&tmp, "my-app");
init(&dir, "my-app", false, Target::DockerCompose, false).unwrap();
let content = fs::read_to_string(dir.join("docker-compose.yml")).unwrap();
assert!(
content.contains(
r#"AUTUMN_SECURITY__SIGNING_SECRET: "${AUTUMN_SECURITY__SIGNING_SECRET:?set it first}""#
),
"app service must pass the required production signing secret: {content}"
);
}
#[test]
fn docker_compose_runs_one_shot_migration_before_app() {
let tmp = TempDir::new().unwrap();
let dir = make_project(&tmp, "my-app");
init(&dir, "my-app", false, Target::DockerCompose, false).unwrap();
let content = fs::read_to_string(dir.join("docker-compose.yml")).unwrap();
assert!(
content.contains("migrate:"),
"docker-compose.yml must include a one-shot migration service"
);
assert!(
content.contains("autumn migrate"),
"migration service must run autumn migrate"
);
assert!(
content.contains("condition: service_completed_successfully"),
"app service must wait for the migration job to complete successfully"
);
}
#[test]
fn docker_compose_substitutes_project_name() {
let tmp = TempDir::new().unwrap();
let dir = make_project(&tmp, "my-blog");
init(&dir, "my-blog", false, Target::DockerCompose, false).unwrap();
let content = fs::read_to_string(dir.join("docker-compose.yml")).unwrap();
assert!(
content.contains("my-blog"),
"docker-compose.yml must substitute project name"
);
assert!(
!content.contains("{{project_name}}"),
"docker-compose.yml must not contain unsubstituted placeholders"
);
}
#[test]
fn default_target_does_not_create_docker_compose() {
let tmp = TempDir::new().unwrap();
let dir = make_project(&tmp, "my-app");
init(&dir, "my-app", false, Target::Default, false).unwrap();
assert!(
!dir.join("docker-compose.yml").exists(),
"docker-compose.yml must NOT be created for the default target"
);
}
#[test]
fn azure_target_creates_all_expected_files() {
let tmp = TempDir::new().unwrap();
let dir = make_project(&tmp, "my-app");
init(&dir, "my-app", false, Target::AzureContainerApps, false).unwrap();
for name in [
"main.tf",
"variables.tf",
"outputs.tf",
"terraform.tfvars.example",
".github/workflows/azure-deploy.yml",
"azure-cutover.sh",
] {
assert!(
dir.join(name).is_file(),
"{name} must be created for --target=azure-container-apps"
);
}
assert!(dir.join("Dockerfile").is_file());
assert!(dir.join(".dockerignore").is_file());
assert!(dir.join("autumn.production.toml.example").is_file());
}
#[test]
fn azure_target_returns_nested_workflow_path_in_created_list() {
let tmp = TempDir::new().unwrap();
let dir = make_project(&tmp, "my-app");
let files = init(&dir, "my-app", false, Target::AzureContainerApps, false).unwrap();
assert!(
files
.iter()
.any(|f| f == ".github/workflows/azure-deploy.yml"),
"created-files list must include the nested workflow path: {files:?}"
);
}
#[test]
fn default_target_does_not_create_azure_files() {
let tmp = TempDir::new().unwrap();
let dir = make_project(&tmp, "my-app");
init(&dir, "my-app", false, Target::Default, false).unwrap();
for name in [
"main.tf",
"variables.tf",
"outputs.tf",
"terraform.tfvars.example",
".github/workflows/azure-deploy.yml",
] {
assert!(
!dir.join(name).exists(),
"{name} must NOT be created for the default target"
);
}
}
#[test]
fn main_tf_has_resource_group_and_registry() {
let tmp = TempDir::new().unwrap();
let dir = make_project(&tmp, "my-app");
init(&dir, "my-app", false, Target::AzureContainerApps, false).unwrap();
let content = fs::read_to_string(dir.join("main.tf")).unwrap();
assert!(
content.contains("azurerm_resource_group"),
"main.tf must provision a resource group: {content}"
);
assert!(
content.contains("azurerm_container_registry"),
"main.tf must provision an Azure Container Registry: {content}"
);
}
#[test]
fn main_tf_has_container_app_environment_and_service() {
let tmp = TempDir::new().unwrap();
let dir = make_project(&tmp, "my-app");
init(&dir, "my-app", false, Target::AzureContainerApps, false).unwrap();
let content = fs::read_to_string(dir.join("main.tf")).unwrap();
assert!(
content.contains("azurerm_container_app_environment"),
"main.tf must provision a Container Apps environment: {content}"
);
assert!(
content.contains("resource \"azurerm_container_app\""),
"main.tf must provision the Container App service: {content}"
);
}
#[test]
fn main_tf_has_postgres_flexible_server() {
let tmp = TempDir::new().unwrap();
let dir = make_project(&tmp, "my-app");
init(&dir, "my-app", false, Target::AzureContainerApps, false).unwrap();
let content = fs::read_to_string(dir.join("main.tf")).unwrap();
assert!(
content.contains("azurerm_postgresql_flexible_server"),
"main.tf must provision Azure Database for PostgreSQL Flexible Server: {content}"
);
}
#[test]
fn main_tf_postgres_server_does_not_hardcode_availability_zone() {
let tmp = TempDir::new().unwrap();
let dir = make_project(&tmp, "my-app");
init(&dir, "my-app", false, Target::AzureContainerApps, false).unwrap();
let content = fs::read_to_string(dir.join("main.tf")).unwrap();
let server_block = content
.split("resource \"azurerm_postgresql_flexible_server\" \"this\"")
.nth(1)
.and_then(|rest| rest.split("\n}").next())
.expect("main.tf must declare the postgresql_flexible_server resource");
assert!(
!server_block.lines().any(|l| {
let t = l.trim_start();
!t.starts_with('#') && t.starts_with("zone ")
}),
"the Postgres Flexible Server must not pin an availability zone: {server_block}"
);
}
#[test]
fn main_tf_postgres_database_name_is_length_bounded() {
let tmp = TempDir::new().unwrap();
let dir = make_project(&tmp, "my-app");
init(&dir, "my-app", false, Target::AzureContainerApps, false).unwrap();
let content = fs::read_to_string(dir.join("main.tf")).unwrap();
let db_block = content
.split("resource \"azurerm_postgresql_flexible_server_database\" \"this\"")
.nth(1)
.and_then(|rest| rest.split("\n}").next())
.expect("main.tf must declare the postgresql_flexible_server_database resource");
let name_line = db_block
.lines()
.find(|l| l.trim_start().starts_with("name"))
.expect("the database resource must set a name");
assert!(
name_line.contains("local.postgres_database_name"),
"the database resource must use the bounded/reserved-name-guarded local: {name_line}"
);
let raw_local_line = content
.lines()
.find(|l| l.trim_start().starts_with("postgres_database_name_raw"))
.expect("main.tf must declare a postgres_database_name_raw local");
assert!(
raw_local_line.contains("substr(") && raw_local_line.contains(", 63)"),
"the Postgres database name must be truncated to 63 characters: {raw_local_line}"
);
}
#[test]
fn main_tf_postgres_database_name_avoids_reserved_names() {
let tmp = TempDir::new().unwrap();
let dir = make_project(&tmp, "my-app");
init(&dir, "my-app", false, Target::AzureContainerApps, false).unwrap();
let content = fs::read_to_string(dir.join("main.tf")).unwrap();
for reserved in [
"postgres",
"azure_maintenance",
"azure_sys",
"template0",
"template1",
] {
assert!(
content.contains(&format!("\"{reserved}\"")),
"the reserved-name guard must list {reserved:?}: {content}"
);
}
let database_name_local = content
.lines()
.skip_while(|l| !l.trim_start().starts_with("postgres_database_name ="))
.take_while(|l| !l.trim_start().starts_with('}'))
.collect::<Vec<_>>()
.join("\n");
assert!(
database_name_local.contains("contains(") && database_name_local.contains("_prod"),
"postgres_database_name must fall back to a suffixed name when the \
sanitized value collides with a reserved database name: {content}"
);
}
#[test]
fn main_tf_has_key_vault_with_database_and_signing_secrets() {
let tmp = TempDir::new().unwrap();
let dir = make_project(&tmp, "my-app");
init(&dir, "my-app", false, Target::AzureContainerApps, false).unwrap();
let content = fs::read_to_string(dir.join("main.tf")).unwrap();
assert!(
content.contains("azurerm_key_vault"),
"main.tf must provision a Key Vault secrets store: {content}"
);
assert!(
content.contains("AUTUMN_DATABASE__PRIMARY_URL"),
"main.tf must wire the primary DB URL env var from Key Vault: {content}"
);
assert!(
content.contains("AUTUMN_SECURITY__SIGNING_SECRET"),
"main.tf must wire the signing secret env var from Key Vault: {content}"
);
}
#[test]
fn main_tf_wires_trusted_hosts_so_prod_actually_binds() {
let tmp = TempDir::new().unwrap();
let dir = make_project(&tmp, "my-app");
init(&dir, "my-app", false, Target::AzureContainerApps, false).unwrap();
let content = fs::read_to_string(dir.join("main.tf")).unwrap();
assert!(
content.contains("AUTUMN_SECURITY__TRUSTED_HOSTS__HOSTS"),
"main.tf must set AUTUMN_SECURITY__TRUSTED_HOSTS__HOSTS on the Container \
App: {content}"
);
assert!(
content.contains("azurerm_container_app_environment.this.default_domain"),
"the trusted host must be derived from the environment's default_domain \
(known before the app is created), not the app's own \
latest_revision_fqdn (which would be a circular self-reference): {content}"
);
}
#[test]
fn main_tf_grants_key_vault_access_to_terraform_identity() {
let tmp = TempDir::new().unwrap();
let dir = make_project(&tmp, "my-app");
init(&dir, "my-app", false, Target::AzureContainerApps, false).unwrap();
let content = fs::read_to_string(dir.join("main.tf")).unwrap();
assert!(
content.contains("data.azurerm_client_config.current.object_id"),
"main.tf must grant Key Vault access to Terraform's own caller \
identity (data.azurerm_client_config.current.object_id), not \
just the container app's identity: {content}"
);
}
#[test]
fn main_tf_acr_pull_role_assignment_skips_aad_check() {
let tmp = TempDir::new().unwrap();
let dir = make_project(&tmp, "my-app");
init(&dir, "my-app", false, Target::AzureContainerApps, false).unwrap();
let content = fs::read_to_string(dir.join("main.tf")).unwrap();
let role_assignment = content
.split("resource \"azurerm_role_assignment\" \"acr_pull\"")
.nth(1)
.and_then(|rest| rest.split("\n}").next())
.expect("main.tf must declare the acr_pull role assignment");
assert!(
role_assignment.contains("skip_service_principal_aad_check = true"),
"the AcrPull role assignment on the freshly-created identity must set \
skip_service_principal_aad_check to avoid an intermittent \
PrincipalNotFound failure from AAD replication lag: {role_assignment}"
);
}
#[test]
fn main_tf_key_vault_name_never_exceeds_azure_length_limit() {
let tmp = TempDir::new().unwrap();
let dir = make_project(&tmp, "my-app");
init(&dir, "my-app", false, Target::AzureContainerApps, false).unwrap();
let content = fs::read_to_string(dir.join("main.tf")).unwrap();
let substr_bound: usize = content
.split("substr(local.app_name_alnum, 0, ")
.nth(1)
.and_then(|rest| rest.split(')').next())
.and_then(|n| n.trim().parse().ok())
.expect("main.tf must call substr(local.app_name_alnum, 0, N) for the vault name");
let byte_length: usize = content
.split("byte_length = ")
.nth(1)
.and_then(|rest| rest.lines().next())
.and_then(|n| n.trim().parse().ok())
.expect("main.tf must declare random_id.suffix's byte_length");
let hex_len = byte_length * 2;
let worst_case_len = substr_bound + "kv".len() + hex_len;
assert!(
worst_case_len <= 24,
"worst-case Key Vault name is {worst_case_len} chars (substr={substr_bound} + \
\"kv\" + {hex_len} hex chars), exceeding Azure's 24-char limit: {content}"
);
}
#[test]
fn main_tf_sanitizes_names_consistently_via_shared_local() {
let tmp = TempDir::new().unwrap();
let dir = make_project(&tmp, "my-app");
init(&dir, "my-app", false, Target::AzureContainerApps, false).unwrap();
let content = fs::read_to_string(dir.join("main.tf")).unwrap();
let postgres_block = content
.split("resource \"azurerm_postgresql_flexible_server\" \"this\"")
.nth(1)
.unwrap();
let postgres_name_line = postgres_block
.lines()
.find(|l| l.trim_start().starts_with("name"))
.unwrap();
assert!(
postgres_name_line.contains("local.app_name_alnum"),
"Postgres server name must use the sanitized local: {postgres_name_line}"
);
let redis_block = content
.split("resource \"azurerm_redis_cache\" \"this\"")
.nth(1)
.unwrap();
let redis_name_line = redis_block
.lines()
.find(|l| l.trim_start().starts_with("name"))
.unwrap();
assert!(
redis_name_line.contains("local.app_name_alnum"),
"Redis cache name must use the sanitized local: {redis_name_line}"
);
}
#[test]
fn main_tf_derives_database_url_from_created_postgres_server() {
let tmp = TempDir::new().unwrap();
let dir = make_project(&tmp, "my-app");
init(&dir, "my-app", false, Target::AzureContainerApps, false).unwrap();
let content = fs::read_to_string(dir.join("main.tf")).unwrap();
assert!(
content.contains("azurerm_postgresql_flexible_server.this.fqdn"),
"main.tf must derive the database URL from the Postgres server's own FQDN: {content}"
);
assert!(
!content.contains("var.database_url"),
"main.tf must not reference a database_url variable: {content}"
);
}
#[test]
fn main_tf_postgres_admin_login_is_alphanumeric() {
let tmp = TempDir::new().unwrap();
let dir = make_project(&tmp, "my-app");
init(&dir, "my-app", false, Target::AzureContainerApps, false).unwrap();
let content = fs::read_to_string(dir.join("main.tf")).unwrap();
let login_local_line = content
.lines()
.find(|l| l.trim_start().starts_with("postgres_admin_login"))
.expect("main.tf must declare a postgres_admin_login local");
let login_value = login_local_line
.split('=')
.nth(1)
.unwrap()
.trim()
.trim_matches('"');
assert!(
!login_value.is_empty() && login_value.chars().all(|c| c.is_ascii_alphanumeric()),
"postgres_admin_login must be alphanumeric-only, got {login_value:?}: \
{login_local_line}"
);
let admin_login_attr = content
.lines()
.find(|l| l.trim_start().starts_with("administrator_login"))
.expect("main.tf must set administrator_login on the Postgres server");
assert!(
admin_login_attr.contains("local.postgres_admin_login"),
"the Postgres server resource must set administrator_login from \
local.postgres_admin_login: {admin_login_attr}"
);
assert!(
content.contains("postgres://${local.postgres_admin_login}:"),
"the database_url secret must reuse the same admin-login local as the \
server resource, not a separately hardcoded username: {content}"
);
}
#[test]
fn main_tf_container_apps_family_resources_use_sanitized_name() {
let tmp = TempDir::new().unwrap();
let dir = make_project(&tmp, "my-app");
init(&dir, "my-app", false, Target::AzureContainerApps, false).unwrap();
let content = fs::read_to_string(dir.join("main.tf")).unwrap();
for resource in [
"azurerm_log_analytics_workspace\" \"this",
"azurerm_container_app_environment\" \"this",
"azurerm_container_app\" \"this",
"azurerm_container_app_job\" \"migrate",
] {
let block = content
.split(&format!("resource \"{resource}\""))
.nth(1)
.unwrap_or_else(|| {
panic!("main.tf must declare resource \"{resource}\": {content}")
});
let name_line = block
.lines()
.find(|l| l.trim_start().starts_with("name"))
.unwrap_or_else(|| panic!("{resource} must set a name: {block}"));
assert!(
name_line.contains("local.app_name_safe"),
"{resource} must use the sanitized local.app_name_safe, not raw \
var.app_name or a literal project name: {name_line}"
);
}
assert!(
!content.contains("\"{{project_name}}\""),
"main.tf must not hardcode the raw (unsanitized) project name as a \
resource identifier: {content}"
);
}
#[test]
fn main_tf_app_name_safe_collapses_and_trims_hyphens() {
let tmp = TempDir::new().unwrap();
let dir = make_project(&tmp, "my-app");
init(&dir, "my-app", false, Target::AzureContainerApps, false).unwrap();
let content = fs::read_to_string(dir.join("main.tf")).unwrap();
let locals_block = content
.split("locals {")
.nth(1)
.and_then(|rest| rest.split("\n}").next())
.expect("main.tf must declare a locals block");
assert!(
locals_block.contains("\"/-+/\""),
"app_name derivation must collapse runs of hyphens to one via a regex like \
/-+/: {locals_block}"
);
assert!(
locals_block.matches("trim(").count() >= 2,
"app_name derivation must trim leading/trailing hyphens both after collapsing \
and after any length truncation: {locals_block}"
);
}
#[test]
fn main_tf_app_name_safe_is_length_bounded() {
let tmp = TempDir::new().unwrap();
let dir = make_project(&tmp, "my-app");
init(&dir, "my-app", false, Target::AzureContainerApps, false).unwrap();
let content = fs::read_to_string(dir.join("main.tf")).unwrap();
let locals_block = content
.split("locals {")
.nth(1)
.and_then(|rest| rest.split("\n}").next())
.expect("main.tf must declare a locals block");
assert!(
locals_block.contains("substr("),
"app_name_safe must truncate to leave headroom for the longest suffix \
(-migrate, 8 chars) appended to any Container Apps-family resource: \
{locals_block}"
);
assert!(
locals_block.contains("length(local.app_name_hyphenated) < 2"),
"app_name_safe must pad a too-short base up to Azure's 2-character minimum: \
{locals_block}"
);
}
#[test]
fn main_tf_sanitized_locals_fall_back_when_input_sanitizes_to_nothing_or_a_digit() {
let tmp = TempDir::new().unwrap();
let dir = make_project(&tmp, "my-app");
init(&dir, "my-app", false, Target::AzureContainerApps, false).unwrap();
let content = fs::read_to_string(dir.join("main.tf")).unwrap();
let locals_block = content
.split("locals {")
.nth(1)
.and_then(|rest| rest.split("\n}").next())
.expect("main.tf must declare a locals block");
assert!(
locals_block.contains("app_name_alnum_raw == \"\" ? \"app\"")
&& locals_block.contains("app_name_hyphenated_raw == \"\" ? \"app\""),
"both base locals must fall back to a non-empty alphabetic value when \
sanitization leaves nothing: {locals_block}"
);
assert!(
locals_block.matches("regex(\"^[a-z]\"").count() >= 2,
"both base locals must check for (and fall back on) a non-letter-leading \
sanitized value, not just an empty one: {locals_block}"
);
}
#[test]
fn main_tf_app_name_alnum_is_length_bounded() {
let tmp = TempDir::new().unwrap();
let dir = make_project(&tmp, "my-app");
init(&dir, "my-app", false, Target::AzureContainerApps, false).unwrap();
let content = fs::read_to_string(dir.join("main.tf")).unwrap();
let locals_block = content
.split("locals {")
.nth(1)
.and_then(|rest| rest.split("\n}").next())
.expect("main.tf must declare a locals block");
let alnum_line = locals_block
.lines()
.find(|l| l.trim_start().starts_with("app_name_alnum ="))
.expect("locals must declare app_name_alnum");
assert!(
alnum_line.contains("substr("),
"app_name_alnum must be length-bounded so ACR's 50-character limit (after \
the fixed \"acr\" + 8-hex-char suffix) can never be exceeded: {alnum_line}"
);
}
#[test]
fn main_tf_resource_group_and_identity_use_bounded_name() {
let tmp = TempDir::new().unwrap();
let dir = make_project(&tmp, "my-app");
init(&dir, "my-app", false, Target::AzureContainerApps, false).unwrap();
let content = fs::read_to_string(dir.join("main.tf")).unwrap();
for resource in [
"azurerm_resource_group\" \"this",
"azurerm_user_assigned_identity\" \"this",
] {
let block = content
.split(&format!("resource \"{resource}\""))
.nth(1)
.unwrap_or_else(|| {
panic!("main.tf must declare resource \"{resource}\": {content}")
});
let name_line = block
.lines()
.find(|l| l.trim_start().starts_with("name"))
.unwrap_or_else(|| panic!("{resource} must set a name: {block}"));
assert!(
name_line.contains("local.app_name_safe"),
"{resource} must use the length-bounded local.app_name_safe, not raw \
var.app_name (unbounded — a long Cargo package name would overflow \
this resource's own name-length limit): {name_line}"
);
}
}
#[test]
fn main_tf_uses_bootstrap_image_and_ignores_later_image_drift() {
let tmp = TempDir::new().unwrap();
let dir = make_project(&tmp, "my-app");
init(&dir, "my-app", false, Target::AzureContainerApps, false).unwrap();
let content = fs::read_to_string(dir.join("main.tf")).unwrap();
assert!(
content.contains("image = var.bootstrap_image")
|| content.contains("image = var.bootstrap_image"),
"main.tf must set the container image to var.bootstrap_image: {content}"
);
assert_eq!(
content.matches("template[0].container[0].image").count(),
2,
"both the app and the migration job must ignore image drift after bootstrap: {content}"
);
assert!(
!content.contains("${var.image_tag}"),
"main.tf must not build the Terraform-managed image from var.image_tag — \
CI manages the real image out-of-band after bootstrap: {content}"
);
}
#[test]
fn main_tf_has_migration_job() {
let tmp = TempDir::new().unwrap();
let dir = make_project(&tmp, "my-app");
init(&dir, "my-app", false, Target::AzureContainerApps, false).unwrap();
let content = fs::read_to_string(dir.join("main.tf")).unwrap();
assert!(
content.contains("azurerm_container_app_job\" \"migrate\""),
"main.tf must provision a one-shot migration Container Apps Job: {content}"
);
assert!(
content.contains("manual_trigger_config"),
"the migration job must only run when CI explicitly starts it: {content}"
);
assert!(
content.contains("autumn migrate"),
"the migration job must run `autumn migrate`: {content}"
);
assert!(
content.contains("AUTUMN_DATABASE__PRIMARY_URL"),
"the migration job must be wired to the same database secret as the app: {content}"
);
}
#[test]
fn azure_cutover_wires_redis_url_when_the_job_has_it() {
let tmp = TempDir::new().unwrap();
let dir = make_project(&tmp, "my-app");
init(&dir, "my-app", false, Target::AzureContainerApps, false).unwrap();
let content = fs::read_to_string(dir.join("main.tf")).unwrap();
assert!(
content.contains("azurerm_key_vault_secret\" \"redis_url\""),
"main.tf must store the Redis connection string in Key Vault: {content}"
);
let script = fs::read_to_string(dir.join("azure-cutover.sh")).unwrap();
assert!(
script.contains(r#"{name: "AUTUMN_CACHE__BACKEND", value: "redis"}"#)
&& script.contains(r#"{name: "AUTUMN_CACHE__REDIS__URL", secretRef: "redis-url"}"#),
"the cutover must select the Redis backend and wire its URL: {script}"
);
assert!(
script.contains(
r#"any(($job.properties.configuration.secrets // [])[]; .name == "redis-url")"#
),
"the cutover must set the Redis env vars only when the job has redis-url: {script}"
);
assert!(
!script.contains("AUTUMN_CACHE__REDIS_URL"),
"the cutover must not use the single-underscore variant, which Autumn ignores: {script}"
);
}
#[test]
fn main_tf_urlencodes_redis_access_key() {
let tmp = TempDir::new().unwrap();
let dir = make_project(&tmp, "my-app");
init(&dir, "my-app", false, Target::AzureContainerApps, false).unwrap();
let content = fs::read_to_string(dir.join("main.tf")).unwrap();
assert!(
content.contains("urlencode(azurerm_redis_cache.this[0].primary_access_key)"),
"the Redis access key must be urlencode()'d before being interpolated into \
the rediss:// URL, the same way database_admin_password already is: {content}"
);
}
#[test]
fn main_tf_documents_redis_cache_requires_app_level_plugin() {
let tmp = TempDir::new().unwrap();
let dir = make_project(&tmp, "my-app");
init(&dir, "my-app", false, Target::AzureContainerApps, false).unwrap();
let main_tf = fs::read_to_string(dir.join("main.tf")).unwrap();
assert!(
main_tf.contains("RedisCachePlugin"),
"main.tf must document that the app needs RedisCachePlugin registered, not \
just the env vars set: {main_tf}"
);
let variables_tf = fs::read_to_string(dir.join("variables.tf")).unwrap();
assert!(
variables_tf.to_lowercase().contains("infrastructure only"),
"variables.tf's enable_redis_cache description must warn this is \
infrastructure-only: {variables_tf}"
);
}
#[test]
fn main_tf_has_optional_redis_cache_gated_by_variable() {
let tmp = TempDir::new().unwrap();
let dir = make_project(&tmp, "my-app");
init(&dir, "my-app", false, Target::AzureContainerApps, false).unwrap();
let content = fs::read_to_string(dir.join("main.tf")).unwrap();
assert!(
content.contains("azurerm_redis_cache"),
"main.tf must optionally provision a Redis Cache: {content}"
);
assert!(
content.contains("enable_redis_cache"),
"the Redis Cache must be gated by an opt-in feature-flag variable: {content}"
);
}
#[test]
fn main_tf_never_contains_secret_literals() {
let tmp = TempDir::new().unwrap();
let dir = make_project(&tmp, "my-app");
init(&dir, "my-app", false, Target::AzureContainerApps, false).unwrap();
let content = fs::read_to_string(dir.join("main.tf")).unwrap();
for line in content.lines() {
let trimmed = line.trim();
if trimmed.starts_with('#') {
continue;
}
let lower = trimmed.to_lowercase();
assert!(
!lower.contains("password = \"") && !lower.contains("password=\""),
"main.tf must never assign a literal secret value (only var./data. \
references are allowed): {trimmed}"
);
}
}
#[test]
fn main_tf_substitutes_project_name() {
let tmp = TempDir::new().unwrap();
let dir = make_project(&tmp, "my-blog");
init(&dir, "my-blog", false, Target::AzureContainerApps, false).unwrap();
let content = fs::read_to_string(dir.join("main.tf")).unwrap();
assert!(
content.contains("my-blog"),
"main.tf must substitute the project name: {content}"
);
assert!(
!content.contains("{{"),
"main.tf must not contain unsubstituted template placeholders: {content}"
);
}
#[test]
fn variables_tf_declares_expected_inputs() {
let tmp = TempDir::new().unwrap();
let dir = make_project(&tmp, "my-app");
init(&dir, "my-app", false, Target::AzureContainerApps, false).unwrap();
let content = fs::read_to_string(dir.join("variables.tf")).unwrap();
for var in [
"variable \"app_name\"",
"variable \"location\"",
"variable \"image_tag\"",
"variable \"db_sku\"",
"variable \"min_replicas\"",
"variable \"max_replicas\"",
"variable \"enable_redis_cache\"",
] {
assert!(
content.contains(var),
"variables.tf must declare {var}: {content}"
);
}
assert!(
!content.contains("{{"),
"variables.tf must not contain unsubstituted template placeholders: {content}"
);
}
#[test]
fn azure_container_apps_defaults_to_zero_replicas_for_bootstrap_safety() {
let tmp = TempDir::new().unwrap();
let dir = make_project(&tmp, "my-app");
init(&dir, "my-app", false, Target::AzureContainerApps, false).unwrap();
let variables_tf = fs::read_to_string(dir.join("variables.tf")).unwrap();
let min_replicas_block = variables_tf
.split("variable \"min_replicas\"")
.nth(1)
.and_then(|rest| rest.split("\n}").next())
.expect("variables.tf must declare min_replicas");
assert!(
min_replicas_block.contains("default = 0")
|| min_replicas_block.contains("default = 0"),
"min_replicas must default to 0 so the public bootstrap image is not run \
with production secrets before the first real deploy: {variables_tf}"
);
let tfvars = fs::read_to_string(dir.join("terraform.tfvars.example")).unwrap();
assert!(
tfvars.contains("min_replicas = 0") || tfvars.contains("min_replicas = 0"),
"terraform.tfvars.example must preserve the safe bootstrap default: {tfvars}"
);
}
#[test]
fn azure_bootstrap_keeps_external_ingress_disabled_until_first_deploy() {
let tmp = TempDir::new().unwrap();
let dir = make_project(&tmp, "my-app");
init(&dir, "my-app", false, Target::AzureContainerApps, false).unwrap();
let content = fs::read_to_string(dir.join("main.tf")).unwrap();
let ingress_block = content
.split("ingress {")
.nth(1)
.and_then(|rest| rest.split("\n }").next())
.expect("main.tf must declare the app ingress block");
assert!(
ingress_block.contains("external_enabled = false"),
"external ingress must stay disabled until the first real deploy: {ingress_block}"
);
assert!(
content.contains("ingress[0].external_enabled,"),
"the app's lifecycle must ignore external_enabled, or a later \
`terraform apply` closes the ingress the cutover opened"
);
assert!(
!ingress_block.contains("external_enabled = true"),
"external_enabled = true would let inbound traffic wake the bootstrap \
placeholder with production secrets: {ingress_block}"
);
let script = fs::read_to_string(dir.join("azure-cutover.sh")).unwrap();
assert!(
script.contains("open_ingress"),
"the cutover must enable external ingress: {script}"
);
assert!(
!script.contains("az containerapp ingress enable"),
"the cutover must restore the saved ingress, not rebuild it: {script}"
);
}
fn azure_app_block(main_tf: &str) -> &str {
main_tf
.split("resource \"azurerm_container_app\" \"this\"")
.nth(1)
.and_then(|rest| rest.split("\n}\n").next())
.expect("main.tf must declare azurerm_container_app.this")
}
#[test]
fn azure_bootstrap_app_has_no_production_secrets_or_identity() {
let tmp = TempDir::new().unwrap();
let dir = make_project(&tmp, "my-app");
init(&dir, "my-app", false, Target::AzureContainerApps, false).unwrap();
let content = fs::read_to_string(dir.join("main.tf"))
.unwrap()
.replace("\r\n", "\n");
let app = azure_app_block(&content);
for forbidden in [
"identity {",
"registry {",
"secret {",
"\"secret\"",
"secret_name",
"azurerm_user_assigned_identity",
"azurerm_key_vault",
] {
assert!(
!app.contains(forbidden),
"the bootstrap app must not contain `{forbidden}`: {app}"
);
}
let lifecycle = app
.split_once("ignore_changes = [")
.and_then(|(_, rest)| rest.split_once("\n ]"))
.expect("the app must declare lifecycle.ignore_changes")
.0;
for ignored in [
"identity",
"registry",
"secret",
"template[0].container[0].env",
] {
assert!(
lifecycle.contains(ignored),
"the app lifecycle must ignore `{ignored}`: {lifecycle}"
);
}
}
#[test]
fn azure_migrate_job_carries_the_full_app_secret_set() {
let tmp = TempDir::new().unwrap();
let dir = make_project(&tmp, "my-app");
init(&dir, "my-app", false, Target::AzureContainerApps, false).unwrap();
let content = fs::read_to_string(dir.join("main.tf"))
.unwrap()
.replace("\r\n", "\n");
let job = content
.split_once("resource \"azurerm_container_app_job\" \"migrate\"")
.and_then(|(_, rest)| rest.split_once("\n}\n"))
.expect("main.tf must declare the migration job")
.0;
for secret in [
"azurerm_key_vault_secret.database_url.versionless_id",
"azurerm_key_vault_secret.signing_secret.versionless_id",
"azurerm_key_vault_secret.redis_url[0].versionless_id",
] {
assert!(
job.contains(secret),
"the migration job must reference {secret}: {job}"
);
}
assert!(
job.contains("manual_trigger_config"),
"the job must run only when CI starts it: {job}"
);
}
#[test]
fn azure_target_scaffolds_the_cutover_script() {
let tmp = TempDir::new().unwrap();
let dir = make_project(&tmp, "my-app");
let files = init(&dir, "my-app", false, Target::AzureContainerApps, false).unwrap();
assert!(files.iter().any(|f| f == "azure-cutover.sh"));
let script = fs::read_to_string(dir.join("azure-cutover.sh")).unwrap();
assert!(script.starts_with("#!/usr/bin/env bash\n"));
assert!(script.contains("set -euo pipefail"));
assert!(
!script.contains("{{"),
"no template placeholder may reach the script: {script}"
);
}
#[test]
fn azure_cutover_attaches_credentials_and_image_in_one_write() {
let tmp = TempDir::new().unwrap();
let dir = make_project(&tmp, "my-app");
init(&dir, "my-app", false, Target::AzureContainerApps, false).unwrap();
let script = fs::read_to_string(dir.join("azure-cutover.sh")).unwrap();
for split in [
"az containerapp identity assign",
"az containerapp registry set",
"az containerapp secret set",
"az containerapp update",
] {
assert!(
!script.contains(split),
"`{split}` is a separate write; use the one PATCH: {script}"
);
}
assert_eq!(
script.matches("az rest --method patch").count(),
1,
"every write must go through patch_app(): {script}"
);
let patch = script
.split_once("PATCH=$(")
.and_then(|(_, rest)| rest.split_once("\n')"))
.expect("the script must build the cutover PATCH body")
.0;
for field in [
"userAssignedIdentities",
"registries:",
"secrets: $secrets",
"template: {containers: $containers}",
] {
assert!(
patch.contains(field),
"the cutover PATCH must set `{field}`: {patch}"
);
}
let containers = script
.split_once("CONTAINERS=$(")
.and_then(|(_, rest)| rest.split_once("\n')"))
.expect("the script must build the new containers")
.0;
for field in [
".image = $image",
".[$i] |= ",
"AUTUMN_DATABASE__PRIMARY_URL",
"AUTUMN_SECURITY__SIGNING_SECRET",
"AUTUMN_CACHE__BACKEND",
"AUTUMN_CACHE__REDIS__URL",
] {
assert!(
containers.contains(field),
"the new containers must set `{field}`: {containers}"
);
}
assert!(patch.contains("map(secret_ref)"), "{patch}");
assert!(
script.contains(
"def secret_ref: if .keyVaultUrl then {name, keyVaultUrl, identity} else {name, value} end;"
),
"secrets must stay Key Vault secret refs: {script}"
);
}
#[test]
fn azure_cutover_opens_ingress_only_after_the_real_revision_is_provisioned() {
let tmp = TempDir::new().unwrap();
let dir = make_project(&tmp, "my-app");
init(&dir, "my-app", false, Target::AzureContainerApps, false).unwrap();
let script = fs::read_to_string(dir.join("azure-cutover.sh")).unwrap();
let patch_at = script
.find("patch_app \"$PATCH\"")
.expect("the script must send the cutover PATCH");
let provisioned_at = script
.rfind("Provisioned)")
.expect("the script must wait for the new revision");
let ready_at = script
.find("[ -n \"$READY\" ] || fail")
.expect("a timeout must fail the cutover");
let enable_at = script
.rfind("\nopen_ingress")
.expect("the script must open external ingress");
assert!(patch_at < provisioned_at && provisioned_at < ready_at && ready_at < enable_at);
assert!(script.contains("set_ingress true"));
assert!(
script.contains("\"$REVISION_IMAGE\" = \"$IMAGE\""),
"ingress must open only when the new revision runs the real image: {script}"
);
}
#[test]
fn azure_cutover_keeps_secret_values_off_the_command_line() {
let tmp = TempDir::new().unwrap();
let dir = make_project(&tmp, "my-app");
init(&dir, "my-app", false, Target::AzureContainerApps, false).unwrap();
let script = fs::read_to_string(dir.join("azure-cutover.sh")).unwrap();
assert!(
!script.contains("--argjson app_secrets"),
"secret values must not be a jq argument: {script}"
);
assert!(
script.contains("--show-values \\\n --output json > \"$APP_SECRETS\""),
"secret values must go to a file: {script}"
);
assert!(
script.contains("--body \"@$BODY\""),
"a PATCH body must go to az from a file: {script}"
);
}
#[test]
fn azure_cutover_rolls_back_a_failed_first_cutover() {
let tmp = TempDir::new().unwrap();
let dir = make_project(&tmp, "my-app");
init(&dir, "my-app", false, Target::AzureContainerApps, false).unwrap();
let script = fs::read_to_string(dir.join("azure-cutover.sh")).unwrap();
let rollback = script
.split_once("rollback() {")
.and_then(|(_, rest)| rest.split_once("\n}\n"))
.expect("the script must define rollback()")
.0;
assert!(
rollback.contains("remove_credentials"),
"rollback must remove the credentials: {rollback}"
);
let remove = script
.split_once("remove_credentials() {")
.and_then(|(_, rest)| rest.split_once("\n}\n"))
.expect("the script must define remove_credentials()")
.0;
for field in [
"type: \"None\"",
"if . == {} then {($u): null} else . end) | add // {}) as $drop",
"select((.identity // \"\" | ascii_downcase) | IN($lids[]) | not)",
"IN(managed[]) | not",
"\"AUTUMN_DATABASE__PRIMARY_URL\", \"AUTUMN_SECURITY__SIGNING_SECRET\"",
"patch_app",
] {
assert!(
remove.contains(field),
"remove_credentials must set `{field}`: {remove}"
);
}
assert!(
script.contains(
r#"'. + ["database-url", "signing-secret", "redis-url"]
+ ($copied.secrets // [] | map(select(type == "string"))) | unique' <<< "$JOB_SECRET_NAMES""#
),
"{script}"
);
assert!(
rollback.contains("[ -n \"$RELEASED\" ] && return"),
"rollback must skip a later deploy, whose old revision is a real release: {rollback}"
);
assert!(
script.contains("fail() {\n echo \"::error::$1\" >&2\n rollback\n exit 1\n}"),
"fail() must roll back, then exit non-zero: {script}"
);
}
#[cfg(unix)]
const AZ_STUB: &str = r#"#!/usr/bin/env bash
id=/subscriptions/s/resourceGroups/rg/providers/Microsoft.ManagedIdentity/userAssignedIdentities/id
tsv() { case "$query" in "[["*) local IFS=$'\t'; echo "$*" ;; *) printf '%s\n' "$@" ;; esac; }
prev=""; query=""; body=""
for arg in "$@"; do
[ "$prev" = "--query" ] && query="$arg"
[ "$prev" = "--body" ] && body="$(cat "${arg#@}")"
prev="$arg"
done
# A PATCH that sets only tags (the ingress snapshot) logs as tags-patch
# with its body, and stays out of the PATCH bodies.
tags_only=""
if [ "$1 $2" = "rest --method" ] && jq -e 'keys - ["location", "tags"] == []' <<< "$body" > /dev/null 2>&1; then
tags_only=1
echo "az tags-patch $body" >> "$STUB_LOG"
else
echo "az $*" >> "$STUB_LOG"
fi
# The app as the GET shows it before any PATCH.
env='{"name":"AUTUMN_PROFILE","value":"prod"}'
# An operator env var with a plain value (not a secret ref).
[ -n "$STUB_PLAIN_ENV" ] && env="$env"',{"name":"PARTNER_KEY","value":"plain-value-7f3a"}'
refs=',{"name":"AUTUMN_DATABASE__PRIMARY_URL","secretRef":"database-url"},{"name":"AUTUMN_SECURITY__SIGNING_SECRET","secretRef":"signing-secret"}'
[ -n "$STUB_APP_ENV_FULL$STUB_APP_LEGACY" ] && [ -z "$STUB_APP_TEMPLATE_CLEAN" ] && env="$env$refs"
[ -n "$STUB_APP_REDIS" ] && env="$env"',{"name":"AUTUMN_CACHE__BACKEND","value":"redis"},{"name":"AUTUMN_CACHE__REDIS__URL","secretRef":"redis-url"}'
# A secret that the operator added to the job, and an env var that uses it.
[ -n "$STUB_JOB_CUSTOM_KV" ] && [ -n "$STUB_APP_LEGACY" ] && env="$env"',{"name":"QUEUE_KEY","secretRef":"queue-key"}'
# A placeholder app made by the old template has the job's credentials.
legacy=""
# The app's own system identity, from before any cutover.
[ -n "$STUB_APP_SYSTEM_IDENTITY" ] && legacy="\"identity\":{\"type\":\"SystemAssigned\",\"principalId\":\"s\"},"
[ -n "$STUB_APP_LEGACY" ] && legacy="\"identity\":{\"type\":\"UserAssigned\",\"userAssignedIdentities\":{\"$id\":{\"principalId\":\"p\"}}},"
# The cutover of an older run also attached the identity of a custom job
# secret.
[ -n "$STUB_APP_LEGACY" ] && { [ "${STUB_JOB_CUSTOM_KV_IDENTITY:-}" = /kv-id-2 ] || [ -n "$STUB_APP_COPIED" ]; } \
&& legacy="\"identity\":{\"type\":\"UserAssigned\",\"userAssignedIdentities\":{\"$id\":{\"principalId\":\"p\"},\"/kv-id-2\":{\"principalId\":\"q\"}}},"
# An identity that the operator added to the placeholder.
[ -n "$STUB_APP_OWN_IDENTITY" ] && legacy="\"identity\":{\"type\":\"UserAssigned\",\"userAssignedIdentities\":{\"/other\":{\"principalId\":\"o\"}}},"
registries=""
[ -n "$STUB_APP_LEGACY" ] && [ -z "$STUB_APP_NO_REGISTRY" ] && registries="{\"server\":\"acr.azurecr.io\",\"identity\":\"$id\"}"
# An operator registry whose password is a managed secret.
# STUB_APP_REGISTRY_PASSWORD_REF=redis-url: the password is redis-url.
password_ref=database-url
[ "$STUB_APP_REGISTRY_PASSWORD_REF" = redis-url ] && password_ref=redis-url
[ -n "$STUB_APP_REGISTRY_PASSWORD_REF" ] && registries="${registries:+$registries,}{\"server\":\"other.example.io\",\"username\":\"u\",\"passwordSecretRef\":\"$password_ref\"}"
secrets=""
sid="$id"
[ -n "$STUB_APP_STALE_SECRET_IDENTITY" ] && sid=/old-id
[ -n "$STUB_APP_LEGACY" ] && secrets="{\"name\":\"database-url\",\"keyVaultUrl\":\"https://kv/secrets/database-url\",\"identity\":\"$sid\"},{\"name\":\"signing-secret\",\"keyVaultUrl\":\"https://kv/secrets/signing-secret\",\"identity\":\"$sid\"}"
[ -n "$STUB_APP_LEGACY" ] && [ -n "$STUB_JOB_CUSTOM_KV" ] && secrets="$secrets,{\"name\":\"queue-key\",\"keyVaultUrl\":\"https://kv/secrets/queue-key\",\"identity\":\"$sid\"}"
sidecar='{"name":"sidecar","image":"busybox"}'
[ -n "$STUB_SIDECAR_SECRET_REF" ] && sidecar='{"name":"sidecar","image":"busybox","env":[{"name":"SIDECAR_DB","secretRef":"database-url"}]}'
[ -n "$STUB_SIDECAR_REDIS_REF" ] && sidecar='{"name":"sidecar","image":"busybox","env":[{"name":"SIDECAR_REDIS","secretRef":"redis-url"}]}'
# A sidecar whose image comes from the same ACR.
[ -n "$STUB_SIDECAR_ACR" ] && sidecar='{"name":"sidecar","image":"acr.azurecr.io/side:1"}'
scale=""
[ -n "$STUB_SCALE_SECRET_REF" ] && scale=',"scale":{"rules":[{"name":"q","custom":{"type":"azure-queue","auth":[{"secretRef":"'"$STUB_SCALE_SECRET_REF"'","triggerParameter":"connection"}]}}]}'
[ -n "$STUB_MIN_REPLICAS" ] && scale=',"scale":{"minReplicas":'"$STUB_MIN_REPLICAS"'}'
# An HTTP scale rule that authenticates with the job's identity.
[ -n "$STUB_APP_SCALE_IDENTITY" ] && scale=',"scale":{"rules":[{"name":"h","http":{"metadata":{"concurrentRequests":"10"},"identity":"'"$id"'"}}]}'
# A placeholder has closed ingress; a real release has open ingress. Both
# have a custom domain that the cutover must keep.
external=false
case "$STUB_OLD_IMAGE" in acr.azurecr.io/*) external=true ;; esac
[ -n "$STUB_INGRESS_INTERNAL" ] && external=false
[ -n "$STUB_INGRESS_EXTERNAL" ] && external=true
ingress="{\"external\":$external,\"targetPort\":3000,\"transport\":\"http\",\"fqdn\":\"app.example.internal\",\"customDomains\":[{\"name\":\"www.example.com\"}]}"
# An interrupted first cutover can leave ingress disabled.
[ -n "$STUB_INGRESS_NONE" ] && ingress=null
# Terraform can make a new, plain ingress after such a stop.
[ -n "$STUB_INGRESS_PLAIN" ] && ingress='{"external":false,"targetPort":3000,"transport":"http"}'
[ "$STUB_INGRESS_PLAIN" = external ] && ingress='{"external":true,"targetPort":3000,"transport":"http"}'
# The snapshot that an interrupted first cutover saved in the app's tags.
tags='{"team":"web"}'
# A large record of copied credentials from an interrupted run, in many
# parts.
if [ -n "$STUB_APP_COPIED_BIG" ]; then
tags=$(jq -cn --argjson n "$STUB_APP_COPIED_BIG" '
{secrets: [range(0; $n) | "secret-name-\(.)-padding-padding"], uids: []}
| tojson | @base64 | . as $b
| [range(0; length; 256) as $i | $b[$i:$i + 256]]
| to_entries | map({key: ("autumn-copied-" + (.key | tostring)), value}) | from_entries')
fi
# Many tags of the operator's own.
if [ -n "$STUB_APP_TAG_COUNT" ]; then
tags=$(jq -cn --argjson n "$STUB_APP_TAG_COUNT" '[range(0; $n) | {key: "t\(.)", value: "x"}] | from_entries')
fi
if [ -n "$STUB_APP_COPIED" ]; then
tags=$(jq -cn '{secrets: ["queue-key"], uids: ["/kv-id-2"]} | tojson | @base64
| {"autumn-copied-0": ., team: "web"}')
fi
if [ -n "$STUB_SAVED_INGRESS_TAGS" ]; then
saved_external=false
[ "$STUB_SAVED_INGRESS_TAGS" = external ] && saved_external=true
tags=$(jq -cn --argjson ext "$saved_external" '{external: $ext, targetPort: 3000, transport: "http",
customDomains: [{name: "www.example.com"}]}
| tojson | @base64 | . as $b
| [range(0; length; 256) as $i | $b[$i:$i + 256]]
| to_entries | map({key: ("autumn-ingress-" + (.key | tostring)), value})
| from_entries + {team: "web"}')
fi
containers="{\"name\":\"app\",\"image\":\"$STUB_OLD_IMAGE\",\"env\":[$env]},$sidecar"
# An operator can put a sidecar before the app container.
[ -n "$STUB_SIDECAR_FIRST" ] && containers="$sidecar,{\"name\":\"app\",\"image\":\"$STUB_OLD_IMAGE\",\"env\":[$env]}"
# An init container env var can also refer to a managed secret.
init=""
# STUB_INIT_SECRET_REF=redis-url: the ref names redis-url instead.
init_ref=database-url
[ "$STUB_INIT_SECRET_REF" = redis-url ] && init_ref=redis-url
[ -n "$STUB_INIT_SECRET_REF" ] && init=',"initContainers":[{"name":"migrate","image":"busybox","env":[{"name":"INIT_DB","secretRef":"'"$init_ref"'"}]}]'
app="{\"id\":\"/subscriptions/s/app\",\"location\":\"westeurope\",\"tags\":$tags,$legacy\"properties\":{\"provisioningState\":\"Succeeded\",\"latestRevisionName\":\"app--old\",\"configuration\":{\"ingress\":$ingress,\"registries\":[$registries],\"secrets\":[$secrets]},\"template\":{\"containers\":[$containers]$init$scale}}}"
# An older placeholder whose credentials use an identity that the job no
# longer uses.
[ -n "$STUB_APP_LEGACY_ID" ] && app="${app//$id/$STUB_APP_LEGACY_ID}"
case "$1 $2" in
"containerapp job")
secret() { echo "{\"name\":\"$1\",\"keyVaultUrl\":\"https://kv/secrets/$1\",\"identity\":\"${2:-$id}\"}"; }
secrets="$(secret database-url),$(secret signing-secret)"
[ -n "$STUB_REDIS" ] && secrets="$secrets,$(secret redis-url)"
[ -n "$STUB_JOB_NO_SECRETS" ] && secrets=""
[ -n "$STUB_JOB_CUSTOM_KV" ] && secrets="$secrets,$(secret queue-key "${STUB_JOB_CUSTOM_KV_IDENTITY:-}")"
# An inline secret that the operator added: job show omits its value,
# and `job secret list --show-values` returns it.
if [ -n "$STUB_JOB_INLINE_SECRET" ]; then
if [ "$3" = secret ]; then
secrets="$secrets,{\"name\":\"api-token\",\"value\":\"tok\"}"
else
secrets="$secrets,{\"name\":\"api-token\"}"
fi
fi
if [ "$3" = secret ]; then
echo "[$secrets]"
exit 0
fi
registries="{\"server\":\"acr.azurecr.io\",\"identity\":\"$id\"}"
# The job pulls from the ACR with its system identity.
[ -n "$STUB_JOB_ACR_SYSTEM" ] && registries='{"server":"acr.azurecr.io","identity":"system"}'
# An operator-added registry listed before the ACR.
[ -n "$STUB_JOB_EXTRA_REGISTRY" ] && registries="{\"server\":\"other.example.io\",\"identity\":\"/other-id\"},$registries"
echo "{\"properties\":{\"configuration\":{\"registries\":[$registries],\"secrets\":[$secrets]}}}"
;;
"containerapp show")
if [ -z "$query" ]; then
# A GET shows the app state with each PATCH merged in. Like ARM after
# a 202, the first STUB_PATCH_PENDING reads after a PATCH still show
# the state before it.
state="$STUB_LOG.state"
[ -f "$state" ] || echo "$app" > "$state"
if [ -f "$STUB_LOG.unapplied" ]; then
if [ "$(cat "$STUB_LOG.pending")" -le 0 ]; then
jq -cs '.[0] * .[1]' "$state" "$STUB_LOG.unapplied" > "$state.new"
mv "$state.new" "$state"
rm "$STUB_LOG.unapplied"
else
echo $(( $(cat "$STUB_LOG.pending") - 1 )) > "$STUB_LOG.pending"
fi
fi
cat "$state"
elif [ "$query" = properties.configuration.ingress.fqdn ]; then
echo app.example.internal
else
# STUB_STATUS_SEQ scripts the status reads, one per read, as
# state:revision. Then the reads are as usual.
if [ -n "$STUB_STATUS_SEQ" ]; then
[ -f "$STUB_LOG.seq" ] || echo "$STUB_STATUS_SEQ" > "$STUB_LOG.seq"
read -r next rest < "$STUB_LOG.seq" || true
if [ -n "$next" ]; then
echo "$rest" > "$STUB_LOG.seq"
tsv "${next%%:*}" "${next#*:}"
exit 0
fi
fi
# Each PATCH with a template makes a new revision.
n=$(grep -c '"template"' "$STUB_LOG.bodies" 2>/dev/null || true)
latest="app--old"
[ -n "$STUB_LATEST_FAILED" ] && latest="$STUB_LATEST_FAILED"
[ "${n:-0}" -gt 0 ] && latest="app--new$n"
tsv Succeeded "${STUB_LATEST:-$latest}"
fi
;;
"containerapp revision")
# The active revision. After a canceled first cutover, the template has
# the real image while the placeholder revision stays active.
# After a PATCH with a template, the new revision is active. The old one
# stays active for the first STUB_ACTIVE_LAG reads, like Azure while the
# new revision scales and passes its probes.
if [ "$3" = list ]; then
n=$(grep -c '"template"' "$STUB_LOG.bodies" 2>/dev/null || true)
if [ "${n:-0}" -eq 0 ]; then
# Azure can list no active revision during a handoff. The first
# STUB_ACTIVE_EMPTY_FIRST reads are empty ("always": every read).
if [ -n "$STUB_ACTIVE_EMPTY_FIRST" ]; then
[ -f "$STUB_LOG.empty" ] || echo "$STUB_ACTIVE_EMPTY_FIRST" > "$STUB_LOG.empty"
left=$(cat "$STUB_LOG.empty")
if [ "$left" = always ]; then exit 0; fi
if [ "$left" -gt 0 ]; then
echo $((left - 1)) > "$STUB_LOG.empty"
exit 0
fi
fi
# A handoff: the placeholder and a real revision that is not ready
# yet are both active.
if [ -n "$STUB_ACTIVE_BOTH" ]; then
printf 'app--old\t%s\napp--half\tacr.azurecr.io/app:t1\n' "$STUB_OLD_IMAGE"
exit 0
fi
# An interrupted run made the latest revision; the old one stays
# active for the first STUB_ACTIVE_LAG reads.
if [ -n "$STUB_ACTIVE_LAG" ] && [ -n "$STUB_LATEST" ]; then
[ -f "$STUB_LOG.lag" ] || echo "$STUB_ACTIVE_LAG" > "$STUB_LOG.lag"
lag=$(cat "$STUB_LOG.lag")
if [ "$lag" -gt 0 ]; then
echo $((lag - 1)) > "$STUB_LOG.lag"
printf 'app--old\t%s\n' "${STUB_ACTIVE_IMAGE:-$STUB_OLD_IMAGE}"
[ -n "$STUB_TWO_PLACEHOLDERS" ] && printf 'app--old2\t%s\n' "${STUB_ACTIVE_IMAGE:-$STUB_OLD_IMAGE}"
exit 0
fi
printf '%s\t%s\n' "$STUB_LATEST" "$STUB_OLD_IMAGE"
exit 0
fi
printf 'app--old\t%s\n' "${STUB_ACTIVE_IMAGE:-$STUB_OLD_IMAGE}"
exit 0
fi
# Azure can list no active revision during the handoff.
[ -n "$STUB_ACTIVE_EMPTY" ] && exit 0
[ -f "$STUB_LOG.lag" ] || echo "${STUB_ACTIVE_LAG:-0}" > "$STUB_LOG.lag"
lag=$(cat "$STUB_LOG.lag")
if [ "$lag" -gt 0 ]; then
echo $((lag - 1)) > "$STUB_LOG.lag"
printf 'app--old\t%s\n' "${STUB_ACTIVE_IMAGE:-$STUB_OLD_IMAGE}"
fi
printf '%s\t%s\n' "${STUB_LATEST:-app--new$n}" "$STUB_OLD_IMAGE"
exit 0
fi
case "$query" in
# The template of the active (old) revision runs the active image.
# With STUB_ACTIVE_SCALE_REF, its scale rule still refers to a
# managed secret that the template no longer has.
properties.template|properties.template.containers)
has_refs=false
[ -n "$STUB_ACTIVE_HAS_REFS" ] && has_refs=true
# The second placeholder still refers to the secrets; the first not.
[ -n "$STUB_TWO_PLACEHOLDERS" ] && [[ " $* " == *" --revision app--old2 "* ]] && has_refs=true
template=$(jq -c --arg image "${STUB_ACTIVE_IMAGE:-$STUB_OLD_IMAGE}" --argjson refs "[${refs#,}]" \
--argjson has_refs "$has_refs" --arg scale_ref "${STUB_ACTIVE_SCALE_REF:-}" \
--arg scale_id "${STUB_ACTIVE_SCALE_IDENTITY:+$id}" '
.properties.template
| .containers |= map(if .name == "app"
then .image = $image | (if $has_refs then .env += $refs else . end)
else . end)
| if $scale_ref == "" then .
else .scale = {rules: [{name: "q", custom: {type: "azure-queue",
auth: [{secretRef: "database-url", triggerParameter: "connection"}]}}]} end
| if $scale_id == "" then .
else .scale = {rules: [{name: "h", http: {metadata: {concurrentRequests: "10"},
identity: $scale_id}}]} end' <<< "$app")
# The active placeholder revision still has a sidecar from the ACR,
# which the template no longer has.
if [ -n "$STUB_ACTIVE_SIDECAR_ACR" ] && [[ " $* " == *" --revision app--old "* ]]; then
template=$(jq -c '.containers |= map(if .name == "sidecar" then .image = "acr.azurecr.io/side:1" else . end)' <<< "$template")
fi
# Azure can rewrite the secret refs of a new revision to a secret that
# does not exist (azure-container-apps issue 1705).
if [ -n "$STUB_REVISION_REWRITTEN" ] && [[ " $* " != *" --revision app--old "* ]]; then
template=$(jq -c '.containers |= map(.env = ((.env // []) + [{name: "DB", secretRef: "capp-app"}]))' <<< "$template")
fi
# The same rewrite in an init container of the new revision.
if [ -n "$STUB_REVISION_INIT_REWRITTEN" ] && [[ " $* " != *" --revision app--old "* ]]; then
template=$(jq -c '.initContainers = [{name: "migrate", image: "mcr.microsoft.com/k8se/quickstart:latest",
env: [{name: "DB", secretRef: "capp-app"}]}]' <<< "$template")
fi
if [ "$query" = properties.template ]; then
echo "$template"
else
jq -c '.containers' <<< "$template"
fi
;;
properties.active) echo false ;;
# The scale rules of an active revision. An operator can remove a
# custom rule from the template while the old revision stays active.
properties.template.scale)
rules=null
[ -n "$STUB_ACTIVE_SCALE_RULE" ] && rules='[{"name":"q","custom":{"type":"azure-queue"}}]'
echo "{\"minReplicas\":${STUB_ACTIVE_MIN_REPLICAS:-0},\"rules\":$rules}"
;;
# The placeholder image provisions, except a revision that the test
# names as failed.
properties.provisioningState)
if [ -n "$STUB_LATEST_FAILED" ] && [[ " $* " == *" --revision $STUB_LATEST_FAILED "* ]]; then
echo "${STUB_LATEST_STATE:-Failed}"
else
echo Provisioned
fi
;;
*) tsv "$STUB_REVISION_STATE" acr.azurecr.io/app:t1 ;;
esac
;;
"containerapp secret")
list=$(
[ -n "$STUB_APP_LEGACY_ID" ] && id="$STUB_APP_LEGACY_ID"
# An operator secret on the app that uses the job's identity.
if [ -n "$STUB_APP_SHARED_SECRET" ]; then
shared=",{\"name\":\"ops-key\",\"keyVaultUrl\":\"https://kv/secrets/ops-key\",\"identity\":\"$id\"}"
if [ -n "$STUB_APP_LEGACY" ]; then
echo "[{\"name\":\"api-key\",\"value\":\"user-value\"},{\"name\":\"database-url\",\"keyVaultUrl\":\"https://kv/secrets/database-url\",\"identity\":\"$id\"},{\"name\":\"signing-secret\",\"keyVaultUrl\":\"https://kv/secrets/signing-secret\",\"identity\":\"$id\"}$shared]"
else
echo "[{\"name\":\"api-key\",\"value\":\"user-value\"}$shared]"
fi
elif [ -n "$STUB_APP_REDIS" ]; then
echo "[{\"name\":\"api-key\",\"value\":\"user-value\"},{\"name\":\"redis-url\",\"keyVaultUrl\":\"https://kv/secrets/redis-url\",\"identity\":\"$id\"}]"
elif [ -n "$STUB_APP_LEGACY" ]; then
custom=""
[ -n "$STUB_JOB_CUSTOM_KV" ] && custom=",{\"name\":\"queue-key\",\"keyVaultUrl\":\"https://kv/secrets/queue-key\",\"identity\":\"$id\"}"
# An interrupted first cutover copied queue-key (on /kv-id-2); the job
# no longer has it.
[ -n "$STUB_APP_COPIED" ] && custom=",{\"name\":\"queue-key\",\"keyVaultUrl\":\"https://kv/secrets/queue-key\",\"identity\":\"/kv-id-2\"}"
echo "[{\"name\":\"api-key\",\"value\":\"user-value\"},{\"name\":\"database-url\",\"keyVaultUrl\":\"https://kv/secrets/database-url\",\"identity\":\"$id\"},{\"name\":\"signing-secret\",\"keyVaultUrl\":\"https://kv/secrets/signing-secret\",\"identity\":\"$id\"}$custom]"
else
echo '[{"name":"api-key","value":"user-value"}]'
fi
)
# The job's inline secret was rotated: the app has the old value until
# the PATCH with the new one applies, and then for STUB_INLINE_STALE
# more reads.
if [ -n "$STUB_JOB_INLINE_SECRET" ]; then
value=tok-old
if grep -q '"value":"tok"' "$STUB_LOG.bodies" 2>/dev/null; then
[ -f "$STUB_LOG.inline" ] || echo "${STUB_INLINE_STALE:-0}" > "$STUB_LOG.inline"
left=$(cat "$STUB_LOG.inline")
if [ "$left" -gt 0 ]; then
echo $((left - 1)) > "$STUB_LOG.inline"
else
value=tok
fi
fi
list=$(jq -c --arg v "$value" '. + [{name: "api-token", value: $v}]' <<< "$list")
fi
echo "$list"
;;
"containerapp replica")
if [ -n "$query" ]; then
echo "${STUB_REPLICAS:-0}"
elif ! grep -q "revision restart" "$STUB_LOG"; then
if [ -n "$STUB_RESTART_FROM_ZERO" ]; then echo '[]'; exit 0; fi
# A new revision at zero replicas: none starts until a request comes
# in (never: none starts at all).
if [ -n "$STUB_NEW_FROM_ZERO" ] && { [ "$STUB_NEW_FROM_ZERO" = never ] || ! grep -q "^curl " "$STUB_LOG"; }; then
echo '[]'; exit 0
fi
echo '[{"name":"r-old","properties":{"runningState":"Running","containers":[{"ready":true}]}}]'
elif [ -n "$STUB_RESTART_FROM_ZERO" ] && { [ "$STUB_RESTART_FROM_ZERO" = never ] || ! grep -q "^curl " "$STUB_LOG"; }; then
# Scaled to zero: no replica starts until a request comes in.
echo '[]'
else
# After a restart, the old replica stays for the first
# STUB_RESTART_STALE reads. The new one is ready unless
# STUB_RESTART_UNREADY is set.
[ -f "$STUB_LOG.stale" ] || echo "${STUB_RESTART_STALE:-0}" > "$STUB_LOG.stale"
stale=$(cat "$STUB_LOG.stale")
if [ "$stale" -gt 0 ]; then
echo $((stale - 1)) > "$STUB_LOG.stale"
echo '[{"name":"r-old","properties":{"runningState":"Running","containers":[{"ready":true}]}}]'
else
ready=true
[ -n "$STUB_RESTART_UNREADY" ] && ready=false
echo "[{\"name\":\"r-new\",\"properties\":{\"runningState\":\"Running\",\"containers\":[{\"ready\":$ready}]}}]"
fi
fi
;;
"rest --method")
# The modes of the script's temp files at the time of the PATCH.
if [ -n "$STUB_TMP_MODES" ]; then
ls -ld "$TMPDIR"/azure-cutover.* | sed 's/^/mode /' >> "$STUB_LOG"
fi
# Azure rejects a PATCH that removes tags.
if [ -n "$tags_only" ] && [ -n "$STUB_TAGS_CLEAR_FAILS" ] \
&& jq -e '[.tags[]] | any(. == null)' <<< "$body" > /dev/null; then
exit 1
fi
open_patch=""
if grep -q '"ingress"' <<< "$body"; then
echo "az ingress-patch external=$(jq -r '.properties.configuration.ingress.external' <<< "$body")" >> "$STUB_LOG"
jq -e '.properties.configuration.ingress.external == true' <<< "$body" > /dev/null && open_patch=1
fi
[ -n "$tags_only" ] || echo "$body" >> "$STUB_LOG.bodies"
echo "$body" > "$STUB_LOG.unapplied"
echo "${STUB_PATCH_PENDING:-0}" > "$STUB_LOG.pending"
# Azure accepts the PATCH that opens ingress, but the response is lost.
if [ -n "$open_patch" ] && [ -n "$STUB_INGRESS_OPEN_LOST" ]; then exit 1; fi
# Azure accepts the cutover PATCH, but the response is lost.
if [ -n "$STUB_CUTOVER_LOST" ] && grep -q '"acr.azurecr.io/app:t1"' <<< "$body"; then exit 1; fi
;;
"containerapp ingress")
# The disable fails once this run has opened ingress.
if [ -n "$STUB_INGRESS_DISABLE_FAILS" ] && grep -q "ingress-patch external=true" "$STUB_LOG"; then
exit 1
fi
# Azure disables ingress, but the response is lost.
if [ -n "$STUB_INGRESS_DISABLE_LOST" ]; then exit 1; fi
;;
*) echo "unexpected az call: $*" >&2; exit 2 ;;
esac
"#;
#[cfg(unix)]
fn run_azure_cutover(
old_image: &str,
revision_state: &str,
redis: bool,
placeholder_replicas: u32,
extra_env: &[(&str, &str)],
) -> Option<(std::process::ExitStatus, String, String)> {
run_azure_cutover_with_args(
&[],
old_image,
revision_state,
redis,
placeholder_replicas,
extra_env,
)
}
#[cfg(unix)]
const AZ_STUB_FLAGS: &[&str] = &[
"STUB_ACTIVE_SIDECAR_ACR",
"STUB_REVISION_REWRITTEN",
"STUB_REVISION_INIT_REWRITTEN",
"STUB_INIT_SECRET_REF",
"STUB_CUTOVER_LOST",
"STUB_TAGS_CLEAR_FAILS",
"STUB_PLAIN_ENV",
"STUB_NEW_FROM_ZERO",
"STUB_INGRESS_DISABLE_LOST",
"STUB_SIDECAR_ACR",
"STUB_APP_REGISTRY_PASSWORD_REF",
"STUB_INLINE_STALE",
"STUB_APP_COPIED_BIG",
"STUB_INGRESS_DISABLE_FAILS",
"STUB_INGRESS_OPEN_LOST",
"STUB_APP_TAG_COUNT",
"STUB_APP_LEGACY_ID",
"STUB_TWO_PLACEHOLDERS",
"STUB_APP_COPIED",
"STUB_ACTIVE_SCALE_IDENTITY",
"STUB_APP_SCALE_IDENTITY",
"STUB_JOB_ACR_SYSTEM",
"STUB_APP_SHARED_SECRET",
"STUB_LATEST",
"STUB_STATUS_SEQ",
"STUB_SIDECAR_FIRST",
"STUB_INGRESS_PLAIN",
"STUB_LATEST_STATE",
"STUB_APP_SYSTEM_IDENTITY",
"STUB_MIN_REPLICAS",
"STUB_ACTIVE_MIN_REPLICAS",
"STUB_JOB_CUSTOM_KV_IDENTITY",
"STUB_SAVED_INGRESS_TAGS",
"STUB_JOB_CUSTOM_KV",
"STUB_ACTIVE_EMPTY_FIRST",
"STUB_TMP_MODES",
"STUB_JOB_INLINE_SECRET",
"STUB_ACTIVE_SCALE_REF",
"STUB_ACTIVE_BOTH",
"STUB_INGRESS_NONE",
"STUB_RESTART_FROM_ZERO",
"STUB_ACTIVE_SCALE_RULE",
"STUB_JOB_NO_SECRETS",
"STUB_ACTIVE_EMPTY",
"STUB_RESTART_STALE",
"STUB_RESTART_UNREADY",
"STUB_APP_ENV_FULL",
"STUB_APP_LEGACY",
"STUB_APP_REDIS",
"STUB_APP_NO_REGISTRY",
"STUB_APP_OWN_IDENTITY",
"STUB_APP_STALE_SECRET_IDENTITY",
"STUB_SIDECAR_SECRET_REF",
"STUB_SCALE_SECRET_REF",
"STUB_ACTIVE_IMAGE",
"STUB_ACTIVE_LAG",
"STUB_APP_TEMPLATE_CLEAN",
"STUB_ACTIVE_HAS_REFS",
"STUB_SIDECAR_REDIS_REF",
"STUB_LATEST_FAILED",
"STUB_INGRESS_INTERNAL",
"STUB_INGRESS_EXTERNAL",
"STUB_JOB_EXTRA_REGISTRY",
"AZURE_BOOTSTRAP_IMAGE",
"STUB_PATCH_PENDING",
];
#[cfg(unix)]
fn run_azure_cutover_with_args(
args: &[&str],
old_image: &str,
revision_state: &str,
redis: bool,
placeholder_replicas: u32,
extra_env: &[(&str, &str)],
) -> Option<(std::process::ExitStatus, String, String)> {
use std::os::unix::fs::PermissionsExt;
if std::process::Command::new("jq")
.arg("--version")
.output()
.is_err()
{
eprintln!("skipping: jq is not installed");
return None;
}
let tmp = TempDir::new().unwrap();
let dir = make_project(&tmp, "my-app");
init(&dir, "my-app", false, Target::AzureContainerApps, false).unwrap();
let bin = tmp.path().join("bin");
fs::create_dir_all(&bin).unwrap();
let no_curl = extra_env.iter().any(|(name, _)| *name == "STUB_NO_CURL");
for (name, body) in [
("az", AZ_STUB),
("sleep", "#!/bin/sh\nexit 0\n"),
(
"curl",
"#!/bin/sh\necho \"curl $*\" >> \"$STUB_LOG\"\nexit 0\n",
),
] {
if no_curl && name == "curl" {
continue;
}
let path = bin.join(name);
fs::write(&path, body).unwrap();
fs::set_permissions(&path, fs::Permissions::from_mode(0o755)).unwrap();
}
let log = tmp.path().join("az.log");
let system_path = std::env::var("PATH").unwrap_or_default();
if extra_env.iter().any(|(name, _)| *name == "STUB_JQ_ARGS") {
let real = std::env::split_paths(&system_path)
.map(|dir| dir.join("jq"))
.find(|path| path.is_file())
.expect("jq on PATH");
let path = bin.join("jq");
fs::write(
&path,
format!(
"#!/bin/sh\nprintf 'jq %s\\n' \"$*\" >> \"$STUB_LOG\"\nexec '{}' \"$@\"\n",
real.display()
),
)
.unwrap();
fs::set_permissions(&path, fs::Permissions::from_mode(0o755)).unwrap();
}
let path = if no_curl {
let host = tmp.path().join("host");
fs::create_dir_all(&host).unwrap();
for dir in std::env::split_paths(&system_path) {
let Ok(entries) = fs::read_dir(&dir) else {
continue;
};
for entry in entries.flatten() {
let link = host.join(entry.file_name());
if entry.file_name() != "curl" && !link.exists() {
let _ = std::os::unix::fs::symlink(entry.path(), link);
}
}
}
format!("{}:{}", bin.display(), host.display())
} else {
format!("{}:{system_path}", bin.display())
};
let mut command = std::process::Command::new("bash");
command
.arg(dir.join("azure-cutover.sh"))
.args(args)
.env("PATH", path)
.env("STUB_LOG", &log)
.env("STUB_OLD_IMAGE", old_image)
.env("STUB_REVISION_STATE", revision_state)
.env("STUB_REPLICAS", placeholder_replicas.to_string())
.env("AZURE_APP_NAME", "app")
.env("AZURE_RESOURCE_GROUP", "rg")
.env("AZURE_MIGRATE_JOB_NAME", "job")
.env("ACR_LOGIN_SERVER", "acr.azurecr.io")
.env("IMAGE_TAG", "t1");
for name in AZ_STUB_FLAGS {
command.env_remove(name);
}
if args.contains(&"--remove-credentials") {
command.env_remove("IMAGE_TAG");
}
command.envs(extra_env.iter().copied());
if redis {
command.env("STUB_REDIS", "1");
} else {
command.env_remove("STUB_REDIS");
}
let status = command.output().expect("run azure-cutover.sh").status;
let read = |path: &std::path::Path| fs::read_to_string(path).unwrap_or_default();
Some((status, read(&log), read(&log.with_extension("log.bodies"))))
}
#[cfg(unix)]
#[test]
fn azure_cutover_script_stops_before_any_call_without_curl() {
for args in [&[][..], &["--remove-credentials"][..]] {
let Some((status, calls, _)) = run_azure_cutover_with_args(
args,
"acr.azurecr.io/app:t0",
"Provisioned",
false,
0,
&[("STUB_NO_CURL", "1")],
) else {
return;
};
assert!(!status.success(), "{args:?}: {calls}");
assert!(calls.is_empty(), "{args:?}: {calls}");
}
}
#[cfg(unix)]
#[test]
fn azure_cutover_script_keeps_env_values_out_of_jq_arguments() {
for (args, state, legacy) in [
(&[][..], "Provisioned", ""),
(&[][..], "Failed", ""),
(&["--remove-credentials"][..], "Provisioned", "1"),
] {
let mut env = vec![("STUB_JQ_ARGS", "1"), ("STUB_PLAIN_ENV", "1")];
if !legacy.is_empty() {
env.push(("STUB_APP_LEGACY", legacy));
}
let Some((_, calls, bodies)) = run_azure_cutover_with_args(
args,
"mcr.microsoft.com/k8se/quickstart:latest",
state,
false,
0,
&env,
) else {
return;
};
assert!(calls.contains("\njq "), "{args:?}: {calls}");
assert!(bodies.contains("plain-value-7f3a"), "{args:?}: {bodies}");
let leaked: Vec<&str> = calls
.lines()
.filter(|line| line.starts_with("jq ") && line.contains("plain-value-7f3a"))
.collect();
assert!(leaked.is_empty(), "{args:?} {state}: {leaked:#?}");
}
}
#[cfg(unix)]
#[test]
fn azure_cutover_script_wakes_a_new_revision_scaled_to_zero() {
let Some((status, calls, _)) = run_azure_cutover(
"mcr.microsoft.com/k8se/quickstart:latest",
"Provisioned",
false,
0,
&[("STUB_NEW_FROM_ZERO", "1")],
) else {
return;
};
assert!(status.success(), "{calls}");
let after_open = calls
.split("az ingress-patch external=true")
.nth(1)
.unwrap_or_else(|| panic!("{calls}"));
assert!(
after_open.contains(
"curl --silent --output /dev/null --max-time 10 https://app.example.internal/"
),
"{calls}"
);
}
#[cfg(unix)]
#[test]
fn azure_cutover_script_fails_when_a_new_revision_never_starts() {
let Some((status, calls, bodies)) = run_azure_cutover(
"acr.azurecr.io/app:t0",
"Provisioned",
false,
0,
&[("STUB_NEW_FROM_ZERO", "never")],
) else {
return;
};
assert!(!status.success(), "{calls}");
assert!(!bodies.contains("AUTUMN_CREDENTIAL_CLEANUP"), "{bodies}");
}
#[cfg(unix)]
#[test]
fn azure_cutover_script_sends_one_patch_then_opens_ingress() {
let Some((status, calls, bodies)) = run_azure_cutover(
"mcr.microsoft.com/k8se/quickstart:latest",
"Provisioned",
true,
0,
&[],
) else {
return;
};
assert!(status.success(), "the cutover must succeed: {calls}");
let replicas_at = calls
.find("az containerapp replica list")
.unwrap_or_else(|| panic!("the first cutover must check the placeholder: {calls}"));
assert_eq!(
calls.matches("az rest --method patch").count(),
2,
"{calls}"
);
let patch_at = calls.find("az rest --method patch").unwrap();
let ingress_at = calls
.find("az ingress-patch external=true")
.unwrap_or_else(|| panic!("the cutover must open ingress: {calls}"));
assert!(replicas_at < patch_at && patch_at < ingress_at, "{calls}");
let disable_at = calls
.find("az containerapp ingress disable")
.unwrap_or_else(|| panic!("the first cutover must disable ingress: {calls}"));
assert!(disable_at < replicas_at, "{calls}");
assert!(
!calls.contains("revision restart"),
"a new revision needs no restart: {calls}"
);
for field in [
"\"userAssignedIdentities\"",
"\"registries\"",
"\"signing-secret\"",
"\"redis-url\"",
"\"acr.azurecr.io/app:t1\"",
"\"AUTUMN_PROFILE\"",
"\"AUTUMN_CACHE__BACKEND\"",
"\"api-key\"",
"\"sidecar\"",
"\"location\":\"westeurope\"",
] {
assert!(
bodies.contains(field),
"the PATCH must carry {field}: {bodies}"
);
}
}
#[cfg(unix)]
#[test]
fn azure_cutover_script_rolls_back_a_failed_first_cutover() {
let Some((status, calls, bodies)) = run_azure_cutover(
"mcr.microsoft.com/k8se/quickstart:latest",
"Failed",
false,
0,
&[],
) else {
return;
};
assert!(
!status.success(),
"a failed revision must fail the cutover: {calls}"
);
assert_eq!(
calls.matches("az rest --method patch").count(),
4,
"{calls}"
);
assert!(
!calls.contains("ingress-patch external=true"),
"ingress must stay closed: {calls}"
);
let rollback = credentials_body(&bodies);
assert!(
rollback.contains("\"type\":\"None\""),
"the rollback must remove the job's identity: {rollback}"
);
assert!(
!rollback.contains("signing-secret") && !rollback.contains("database-url"),
"the rollback must remove the job's secret refs: {rollback}"
);
assert!(
rollback.contains("\"api-key\""),
"the rollback must keep the secrets that the operator added: {rollback}"
);
}
#[cfg(unix)]
#[test]
fn azure_cutover_script_keeps_credentials_when_a_later_deploy_fails() {
let Some((status, calls, _)) =
run_azure_cutover("acr.azurecr.io/app:t0", "Failed", false, 0, &[])
else {
return;
};
assert!(!status.success(), "{calls}");
assert_eq!(
calls.matches("az rest --method patch").count(),
1,
"{calls}"
);
assert!(!calls.contains("ingress-patch external=true"), "{calls}");
}
#[cfg(unix)]
#[test]
fn azure_cutover_script_waits_for_the_placeholder_to_stop() {
let Some((status, calls, _)) = run_azure_cutover(
"mcr.microsoft.com/k8se/quickstart:latest",
"Provisioned",
false,
1,
&[],
) else {
return;
};
assert!(!status.success(), "{calls}");
assert!(
!calls.contains("az rest --method patch")
|| calls.matches("az rest --method patch").count()
== calls.matches("az ingress-patch").count(),
"no credentials while the placeholder runs: {calls}"
);
assert!(!calls.contains("ingress-patch external=true"), "{calls}");
}
#[cfg(unix)]
#[test]
fn azure_cutover_script_restores_ingress_when_the_placeholder_does_not_stop() {
let Some((status, calls, bodies)) = run_azure_cutover(
"mcr.microsoft.com/k8se/quickstart:latest",
"Provisioned",
false,
1,
&[("STUB_INGRESS_EXTERNAL", "1")],
) else {
return;
};
assert!(!status.success(), "{calls}");
let disable_at = calls.find("ingress disable").expect("disable");
let restore_at = calls
.find("az ingress-patch external=true")
.unwrap_or_else(|| panic!("the script must restore the saved ingress: {calls}"));
assert!(disable_at < restore_at, "{calls}");
assert!(bodies.contains("www.example.com"), "{bodies}");
for field in ["userAssignedIdentities", "secrets", "template"] {
assert!(!bodies.contains(field), "no credentials: {bodies}");
}
}
#[cfg(unix)]
#[test]
fn azure_cutover_script_waits_out_a_stale_failed_state() {
let Some((status, calls, _)) = run_azure_cutover(
"acr.azurecr.io/app:t0",
"Provisioned",
false,
0,
&[("STUB_STATUS_SEQ", "Failed:app--old Canceled:app--old")],
) else {
return;
};
assert!(status.success(), "{calls}");
}
#[cfg(unix)]
#[test]
fn azure_cutover_script_waits_out_a_stale_failed_state_before_a_restart() {
let Some((status, calls, _)) = run_azure_cutover(
"acr.azurecr.io/app:t1",
"Provisioned",
false,
0,
&[
("STUB_LATEST", "app--old"),
("STUB_APP_ENV_FULL", "1"),
("STUB_STATUS_SEQ", "Failed:app--old"),
],
) else {
return;
};
assert!(status.success(), "{calls}");
assert!(calls.contains("revision restart"), "{calls}");
}
#[cfg(unix)]
#[test]
fn azure_cutover_script_needs_the_new_revision_in_the_active_list() {
let Some((status, calls, _)) = run_azure_cutover(
"acr.azurecr.io/app:t0",
"Provisioned",
false,
0,
&[("STUB_ACTIVE_EMPTY", "1")],
) else {
return;
};
assert!(!status.success(), "{calls}");
}
#[cfg(unix)]
#[test]
fn azure_cutover_script_waits_for_the_restarted_revision() {
let Some((status, calls, _)) = run_azure_cutover(
"acr.azurecr.io/app:t1",
"Provisioned",
false,
0,
&[
("STUB_LATEST", "app--old"),
("STUB_APP_ENV_FULL", "1"),
("STUB_RESTART_STALE", "2"),
],
) else {
return;
};
assert!(status.success(), "{calls}");
let restart_at = calls.find("revision restart").expect("restart");
assert!(
calls[restart_at..].matches("replica list").count() >= 3,
"{calls}"
);
}
#[cfg(unix)]
#[test]
fn azure_cutover_script_fails_when_the_restarted_revision_is_not_ready() {
let Some((status, calls, _)) = run_azure_cutover(
"acr.azurecr.io/app:t1",
"Provisioned",
false,
0,
&[
("STUB_LATEST", "app--old"),
("STUB_APP_ENV_FULL", "1"),
("STUB_RESTART_UNREADY", "1"),
],
) else {
return;
};
assert!(!status.success(), "{calls}");
assert!(calls.contains("revision restart"), "{calls}");
}
#[cfg(unix)]
#[test]
fn azure_cutover_script_wakes_a_revision_restarted_from_zero() {
let Some((status, calls, _)) = run_azure_cutover(
"acr.azurecr.io/app:t1",
"Provisioned",
false,
0,
&[
("STUB_LATEST", "app--old"),
("STUB_APP_ENV_FULL", "1"),
("STUB_RESTART_FROM_ZERO", "1"),
],
) else {
return;
};
assert!(status.success(), "{calls}");
let restart_at = calls.find("revision restart").expect("restart");
assert!(
calls[restart_at..].contains("curl ") && calls.contains("app.example.internal"),
"{calls}"
);
}
#[cfg(unix)]
#[test]
fn azure_cutover_script_fails_when_no_replica_starts_after_the_restart() {
let Some((status, calls, _)) = run_azure_cutover(
"acr.azurecr.io/app:t1",
"Provisioned",
false,
0,
&[
("STUB_LATEST", "app--old"),
("STUB_APP_ENV_FULL", "1"),
("STUB_RESTART_FROM_ZERO", "never"),
],
) else {
return;
};
assert!(!status.success(), "{calls}");
}
#[cfg(unix)]
#[test]
fn azure_cutover_script_keeps_the_first_cutover_while_a_placeholder_is_active() {
let Some((status, calls, _)) = run_azure_cutover(
"mcr.microsoft.com/k8se/quickstart:latest",
"Provisioned",
false,
0,
&[("STUB_ACTIVE_BOTH", "1")],
) else {
return;
};
assert!(status.success(), "{calls}");
assert!(calls.contains("ingress disable"), "{calls}");
assert!(
calls.contains("--revision app--old --query length(@)"),
"{calls}"
);
assert!(!calls.contains("--revision app--half --query"), "{calls}");
}
#[cfg(unix)]
#[test]
fn azure_cutover_script_opens_ingress_before_it_wakes_a_restarted_revision() {
let Some((status, calls, _)) = run_azure_cutover(
"acr.azurecr.io/app:t1",
"Provisioned",
false,
0,
&[
("STUB_LATEST", "app--old"),
("STUB_APP_ENV_FULL", "1"),
("STUB_INGRESS_NONE", "1"),
("STUB_RESTART_FROM_ZERO", "1"),
],
) else {
return;
};
assert!(status.success(), "{calls}");
let open_at = calls
.find("az ingress-patch external=true")
.unwrap_or_else(|| panic!("the script must open ingress: {calls}"));
let curl_at = calls.find("curl ").expect("a wake request");
assert!(open_at < curl_at, "{calls}");
}
#[cfg(unix)]
#[test]
fn azure_cutover_script_keeps_the_value_of_an_inline_job_secret() {
let Some((status, calls, bodies)) = run_azure_cutover(
"mcr.microsoft.com/k8se/quickstart:latest",
"Provisioned",
false,
0,
&[("STUB_JOB_INLINE_SECRET", "1")],
) else {
return;
};
assert!(status.success(), "{calls}");
assert!(calls.contains("az containerapp job secret list"), "{calls}");
assert!(
bodies.contains(r#"{"name":"api-token","value":"tok"}"#),
"{bodies}"
);
assert!(!calls.contains("tok\""), "{calls}");
}
#[test]
fn azure_sidecars_in_main_tf_do_not_refer_to_managed_secrets() {
let tmp = TempDir::new().unwrap();
let dir = make_project(&tmp, "my-app");
init(&dir, "my-app", false, Target::AzureContainerApps, false).unwrap();
let main_tf = fs::read_to_string(dir.join("main.tf")).unwrap();
assert!(
main_tf.contains("A sidecar in this file must not"),
"{main_tf}"
);
let docs = fs::read_to_string(
std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("../docs/guide/deployment.md"),
)
.unwrap();
let docs = docs.split_whitespace().collect::<Vec<_>>().join(" ");
assert!(
docs.contains("remove that env var from `main.tf` first"),
"the Redis off steps must cover a sidecar ref in main.tf"
);
}
#[cfg(unix)]
#[test]
fn azure_cutover_script_reads_the_active_revisions_again_when_none_is_listed() {
let Some((status, calls, _)) = run_azure_cutover(
"mcr.microsoft.com/k8se/quickstart:latest",
"Provisioned",
false,
0,
&[("STUB_ACTIVE_EMPTY_FIRST", "2")],
) else {
return;
};
assert!(status.success(), "{calls}");
let disable_at = calls.find("ingress disable").expect("first cutover");
assert!(
calls[..disable_at]
.matches("az containerapp revision list")
.count()
>= 3,
"{calls}"
);
}
#[cfg(unix)]
#[test]
fn azure_cutover_script_stops_when_no_active_revision_is_listed() {
for args in [&[][..], &["--remove-credentials"][..]] {
let Some((status, calls, _)) = run_azure_cutover_with_args(
args,
"acr.azurecr.io/app:t0",
"Provisioned",
false,
0,
&[
("STUB_ACTIVE_EMPTY_FIRST", "always"),
("STUB_APP_LEGACY", "1"),
],
) else {
return;
};
assert!(!status.success(), "{args:?}: {calls}");
assert!(!calls.contains("ingress disable"), "{args:?}: {calls}");
assert!(
!calls.contains("az rest --method patch"),
"{args:?}: {calls}"
);
}
}
#[cfg(unix)]
#[test]
fn azure_cutover_script_keeps_the_filtered_job_secrets_private() {
let tmpdir = TempDir::new().unwrap();
let tmpdir_path = tmpdir.path().to_str().unwrap().to_string();
let Some((status, calls, _)) = run_azure_cutover_with_args(
&["--without-redis"],
"acr.azurecr.io/app:t0",
"Provisioned",
true,
0,
&[
("STUB_JOB_INLINE_SECRET", "1"),
("STUB_TMP_MODES", "1"),
("TMPDIR", &tmpdir_path),
],
) else {
return;
};
assert!(status.success(), "{calls}");
let modes: Vec<&str> = calls.lines().filter(|l| l.starts_with("mode ")).collect();
assert!(!modes.is_empty(), "{calls}");
assert!(
modes.iter().any(|line| line.starts_with("mode drwx------")),
"{calls}"
);
for line in modes {
assert!(
line.starts_with("mode -rw-------") || line.starts_with("mode drwx------"),
"{line}\n{calls}"
);
}
}
#[cfg(unix)]
#[test]
fn azure_cutover_script_removes_every_job_secret_from_the_placeholder() {
let Some((status, calls, bodies)) = run_azure_cutover_with_args(
&["--remove-credentials"],
"mcr.microsoft.com/k8se/quickstart:latest",
"Provisioned",
false,
0,
&[("STUB_APP_LEGACY", "1"), ("STUB_JOB_CUSTOM_KV", "1")],
) else {
return;
};
assert!(status.success(), "{calls}");
let stage1 = bodies
.lines()
.find(|line| line.contains("\"template\""))
.unwrap_or_else(|| panic!("a stage 1 PATCH: {bodies}"));
assert!(!stage1.contains("queue-key"), "{stage1}");
let stage2 = bodies
.lines()
.find(|line| line.contains("\"secrets\""))
.unwrap_or_else(|| panic!("a stage 2 PATCH: {bodies}"));
assert!(!stage2.contains("queue-key"), "{stage2}");
assert!(
stage2.contains("api-key"),
"the app's own secret stays: {stage2}"
);
}
#[cfg(unix)]
#[test]
fn azure_cutover_script_saves_the_ingress_in_tags_before_it_disables_it() {
let Some((status, calls, bodies)) = run_azure_cutover(
"mcr.microsoft.com/k8se/quickstart:latest",
"Provisioned",
false,
0,
&[("STUB_INGRESS_EXTERNAL", "1")],
) else {
return;
};
assert!(status.success(), "{calls}");
let tags: Vec<&str> = calls
.lines()
.filter(|line| line.starts_with("az tags-patch "))
.collect();
assert!(tags.len() >= 2, "{calls}");
assert!(tags[0].contains("\"autumn-ingress-0\":\""), "{calls}");
let disable_at = calls.find("ingress disable").expect("disable");
let save_at = calls.find("az tags-patch ").unwrap();
assert!(save_at < disable_at, "{calls}");
let last = tags.last().unwrap();
assert!(last.contains("\"autumn-ingress-0\":null"), "{calls}");
assert!(!last.contains("team"), "{calls}");
assert!(!bodies.contains("autumn-ingress"), "{bodies}");
}
#[cfg(unix)]
#[test]
fn azure_cutover_script_restores_the_ingress_from_tags_after_an_interruption() {
let Some((status, calls, bodies)) = run_azure_cutover(
"mcr.microsoft.com/k8se/quickstart:latest",
"Provisioned",
false,
0,
&[("STUB_INGRESS_NONE", "1"), ("STUB_SAVED_INGRESS_TAGS", "1")],
) else {
return;
};
assert!(status.success(), "{calls}");
let open = bodies
.lines()
.find(|line| line.contains("\"external\":true"))
.unwrap_or_else(|| panic!("the script must open ingress: {bodies}"));
assert!(open.contains("www.example.com"), "{open}");
let last = calls
.lines()
.rfind(|line| line.starts_with("az tags-patch "))
.unwrap_or_default();
assert!(last.contains("\"autumn-ingress-0\":null"), "{calls}");
}
#[cfg(unix)]
#[test]
fn azure_cutover_script_opens_a_missing_ingress_on_a_released_app() {
let Some((status, calls, bodies)) = run_azure_cutover(
"acr.azurecr.io/app:t0",
"Provisioned",
false,
0,
&[
("STUB_INGRESS_NONE", "1"),
("STUB_SAVED_INGRESS_TAGS", "external"),
],
) else {
return;
};
assert!(status.success(), "{calls}");
assert!(calls.contains("az ingress-patch external=true"), "{calls}");
let open = bodies
.lines()
.find(|line| line.contains("\"external\":true"))
.unwrap_or_else(|| panic!("{bodies}"));
assert!(open.contains("www.example.com"), "{open}");
}
#[cfg(unix)]
#[test]
fn azure_cutover_script_restores_the_tagged_ingress_over_a_plain_open_one() {
let Some((status, calls, bodies)) = run_azure_cutover(
"acr.azurecr.io/app:t0",
"Provisioned",
false,
0,
&[
("STUB_INGRESS_PLAIN", "external"),
("STUB_SAVED_INGRESS_TAGS", "external"),
],
) else {
return;
};
assert!(status.success(), "{calls}");
assert!(calls.contains("az ingress-patch external=true"), "{calls}");
let open = bodies
.lines()
.find(|line| line.contains("\"external\":true"))
.unwrap_or_else(|| panic!("{bodies}"));
assert!(open.contains("www.example.com"), "{open}");
}
#[cfg(unix)]
#[test]
fn azure_cutover_script_waits_for_the_tagged_ingress_before_clearing_its_tags() {
let Some((status, calls, _)) = run_azure_cutover(
"acr.azurecr.io/app:t0",
"Provisioned",
false,
0,
&[
("STUB_INGRESS_PLAIN", "external"),
("STUB_SAVED_INGRESS_TAGS", "external"),
("STUB_PATCH_PENDING", "2"),
],
) else {
return;
};
assert!(status.success(), "{calls}");
let after_open = calls
.split("az ingress-patch external=true")
.nth(1)
.unwrap_or_else(|| panic!("{calls}"));
let before_clear = after_open.split("az tags-patch").next().unwrap_or_default();
assert!(
before_clear
.matches("az containerapp show --name app --resource-group rg --output json")
.count()
>= 3,
"{calls}"
);
}
#[cfg(unix)]
#[test]
fn azure_cutover_script_stops_on_min_replicas_above_zero() {
for flag in ["STUB_MIN_REPLICAS", "STUB_ACTIVE_MIN_REPLICAS"] {
let Some((status, calls, _)) = run_azure_cutover(
"mcr.microsoft.com/k8se/quickstart:latest",
"Provisioned",
false,
0,
&[(flag, "1")],
) else {
return;
};
assert!(!status.success(), "{flag}: {calls}");
assert!(!calls.contains("ingress disable"), "{flag}: {calls}");
assert!(!calls.contains("az rest --method patch"), "{flag}: {calls}");
}
}
#[cfg(unix)]
#[test]
fn azure_cutover_script_assigns_every_identity_of_the_job_secrets() {
let Some((status, calls, bodies)) = run_azure_cutover(
"mcr.microsoft.com/k8se/quickstart:latest",
"Provisioned",
false,
0,
&[
("STUB_JOB_CUSTOM_KV", "1"),
("STUB_JOB_CUSTOM_KV_IDENTITY", "/kv-id-2"),
],
) else {
return;
};
assert!(status.success(), "{calls}");
let cutover = bodies
.lines()
.find(|line| line.contains("\"template\""))
.unwrap_or_else(|| panic!("{bodies}"));
assert!(cutover.contains("\"/kv-id-2\":{}"), "{cutover}");
}
#[cfg(unix)]
#[test]
fn azure_cutover_script_removes_every_identity_of_the_job_secrets() {
let Some((status, calls, bodies)) = run_azure_cutover_with_args(
&["--remove-credentials"],
"mcr.microsoft.com/k8se/quickstart:latest",
"Provisioned",
false,
0,
&[
("STUB_APP_LEGACY", "1"),
("STUB_JOB_CUSTOM_KV", "1"),
("STUB_JOB_CUSTOM_KV_IDENTITY", "/kv-id-2"),
],
) else {
return;
};
assert!(status.success(), "{calls}");
let stage2 = bodies
.lines()
.find(|line| line.contains("\"identity\":{"))
.unwrap_or_else(|| panic!("{bodies}"));
assert!(!stage2.contains("\"/kv-id-2\":{}"), "{stage2}");
assert!(stage2.contains("\"type\":\"None\""), "{stage2}");
}
#[cfg(unix)]
#[test]
fn azure_cutover_script_forces_a_fresh_revision_after_a_canceled_one() {
let Some((status, calls, bodies)) = run_azure_cutover(
"acr.azurecr.io/app:t1",
"Provisioned",
false,
0,
&[
("STUB_APP_ENV_FULL", "1"),
("STUB_LATEST_FAILED", "app--old"),
("STUB_LATEST_STATE", "Canceled"),
],
) else {
return;
};
assert!(status.success(), "{calls}");
assert!(bodies.contains("AUTUMN_FORCE_REVISION"), "{bodies}");
assert!(!calls.contains("revision restart"), "{calls}");
}
#[cfg(unix)]
#[test]
fn azure_cutover_rollback_keeps_a_system_identity_that_was_there() {
let Some((status, calls, bodies)) = run_azure_cutover(
"mcr.microsoft.com/k8se/quickstart:latest",
"Failed",
false,
0,
&[
("STUB_JOB_CUSTOM_KV", "1"),
("STUB_JOB_CUSTOM_KV_IDENTITY", "/kv-id-2"),
("STUB_APP_SYSTEM_IDENTITY", "1"),
],
) else {
return;
};
assert!(!status.success(), "{calls}");
let removal = bodies
.lines()
.rfind(|line| line.contains("\"identity\":{") && !line.contains("\"template\""))
.unwrap_or_else(|| panic!("{bodies}"));
assert!(removal.contains("SystemAssigned"), "{removal}");
}
#[cfg(unix)]
#[test]
fn azure_cutover_script_prefers_the_ingress_snapshot_in_tags() {
let Some((status, calls, bodies)) = run_azure_cutover(
"mcr.microsoft.com/k8se/quickstart:latest",
"Provisioned",
false,
0,
&[
("STUB_INGRESS_PLAIN", "1"),
("STUB_SAVED_INGRESS_TAGS", "1"),
],
) else {
return;
};
assert!(status.success(), "{calls}");
let open = bodies
.lines()
.find(|line| line.contains("\"external\":true"))
.unwrap_or_else(|| panic!("{bodies}"));
assert!(open.contains("www.example.com"), "{open}");
}
#[cfg(unix)]
#[test]
fn azure_cutover_script_retries_after_a_canceled_cleanup_revision() {
let Some((status, calls, bodies)) = run_azure_cutover_with_args(
&["--remove-credentials"],
"mcr.microsoft.com/k8se/quickstart:latest",
"Provisioned",
false,
0,
&[
("STUB_APP_LEGACY", "1"),
("STUB_APP_TEMPLATE_CLEAN", "1"),
("STUB_ACTIVE_HAS_REFS", "1"),
("STUB_LATEST_FAILED", "app--clean"),
("STUB_LATEST_STATE", "Canceled"),
],
) else {
return;
};
assert!(status.success(), "{calls}");
let first = bodies.lines().next().unwrap_or_default();
assert!(first.contains("AUTUMN_CREDENTIAL_CLEANUP"), "{bodies}");
}
#[cfg(unix)]
#[test]
fn azure_cutover_script_rejects_a_job_secret_on_the_system_identity() {
let Some((status, calls, _)) = run_azure_cutover(
"mcr.microsoft.com/k8se/quickstart:latest",
"Provisioned",
false,
0,
&[
("STUB_JOB_CUSTOM_KV", "1"),
("STUB_JOB_CUSTOM_KV_IDENTITY", "system"),
("STUB_INGRESS_EXTERNAL", "1"),
],
) else {
return;
};
assert!(!status.success(), "{calls}");
assert!(!calls.contains("ingress disable"), "{calls}");
assert!(!calls.contains("az rest --method patch"), "{calls}");
assert!(!calls.contains("az tags-patch"), "{calls}");
}
#[cfg(unix)]
#[test]
fn azure_cutover_script_stops_when_an_operator_secret_shares_the_job_identity() {
for args in [&[][..], &["--remove-credentials"][..]] {
let Some((status, calls, _)) = run_azure_cutover_with_args(
args,
"mcr.microsoft.com/k8se/quickstart:latest",
"Provisioned",
false,
0,
&[("STUB_APP_SHARED_SECRET", "1"), ("STUB_APP_LEGACY", "1")],
) else {
return;
};
assert!(!status.success(), "{args:?}: {calls}");
assert!(!calls.contains("ingress disable"), "{args:?}: {calls}");
assert!(
!calls.contains("az rest --method patch"),
"{args:?}: {calls}"
);
assert!(!calls.contains("az tags-patch"), "{args:?}: {calls}");
}
}
#[cfg(unix)]
#[test]
fn azure_cutover_script_stops_when_a_scale_rule_uses_the_job_identity() {
for args in [&[][..], &["--remove-credentials"][..]] {
let Some((status, calls, _)) = run_azure_cutover_with_args(
args,
"mcr.microsoft.com/k8se/quickstart:latest",
"Provisioned",
false,
0,
&[("STUB_APP_SCALE_IDENTITY", "1"), ("STUB_APP_LEGACY", "1")],
) else {
return;
};
assert!(!status.success(), "{args:?}: {calls}");
assert!(!calls.contains("ingress disable"), "{args:?}: {calls}");
assert!(
!calls.contains("az rest --method patch"),
"{args:?}: {calls}"
);
}
}
#[cfg(unix)]
#[test]
fn azure_cutover_script_rejects_a_system_acr_identity() {
for old_image in [
"mcr.microsoft.com/k8se/quickstart:latest",
"acr.azurecr.io/app:t0",
] {
let Some((status, calls, bodies)) = run_azure_cutover(
old_image,
"Provisioned",
false,
0,
&[("STUB_JOB_ACR_SYSTEM", "1")],
) else {
return;
};
assert!(!status.success(), "{old_image}: {calls}");
assert!(!calls.contains("ingress disable"), "{old_image}: {calls}");
assert!(
!calls.contains("az rest --method patch"),
"{old_image}: {calls}"
);
assert!(!bodies.contains("\"system\":{}"), "{bodies}");
}
}
#[cfg(unix)]
#[test]
fn azure_cutover_script_checks_the_job_before_it_disables_ingress() {
let Some((status, calls, _)) = run_azure_cutover(
"mcr.microsoft.com/k8se/quickstart:latest",
"Provisioned",
false,
0,
&[("STUB_JOB_NO_SECRETS", "1"), ("STUB_INGRESS_EXTERNAL", "1")],
) else {
return;
};
assert!(!status.success(), "{calls}");
assert!(!calls.contains("ingress disable"), "{calls}");
assert!(!calls.contains("az rest --method patch"), "{calls}");
}
#[cfg(unix)]
#[test]
fn azure_cutover_script_stops_when_the_app_container_is_not_first() {
let Some((status, calls, _)) = run_azure_cutover(
"mcr.microsoft.com/k8se/quickstart:latest",
"Provisioned",
false,
0,
&[("STUB_SIDECAR_FIRST", "1"), ("STUB_INGRESS_EXTERNAL", "1")],
) else {
return;
};
assert!(!status.success(), "{calls}");
assert!(!calls.contains("ingress disable"), "{calls}");
assert!(!calls.contains("az rest --method patch"), "{calls}");
}
#[cfg(unix)]
#[test]
fn azure_cutover_script_reads_the_app_image_by_name() {
let Some((status, calls, _)) = run_azure_cutover(
"mcr.microsoft.com/k8se/quickstart:latest",
"Provisioned",
false,
0,
&[],
) else {
return;
};
assert!(status.success(), "{calls}");
assert!(
calls.matches("containers[?name=='app']").count() >= 2,
"{calls}"
);
}
#[cfg(unix)]
#[test]
fn azure_cutover_script_stops_without_an_app_container() {
let tmp = TempDir::new().unwrap();
let dir = make_project(&tmp, "my-app");
init(&dir, "my-app", false, Target::AzureContainerApps, false).unwrap();
let script = fs::read_to_string(dir.join("azure-cutover.sh")).unwrap();
let check = script
.find("no container named")
.unwrap_or_else(|| panic!("the script must check the app container: {script}"));
let disable = script.find("ingress disable").unwrap();
assert!(check < disable, "{script}");
}
#[cfg(unix)]
#[test]
fn azure_cutover_script_fails_fast_when_this_update_fails() {
for seq in ["Failed:app--new1", "InProgress:app--old Failed:app--old"] {
let Some((status, calls, _)) = run_azure_cutover(
"acr.azurecr.io/app:t0",
"Provisioned",
false,
0,
&[("STUB_STATUS_SEQ", seq)],
) else {
return;
};
assert!(!status.success(), "{seq}: {calls}");
assert_eq!(
calls.matches("latestRevisionName").count(),
seq.split(' ').count(),
"{seq}: {calls}"
);
}
}
#[cfg(unix)]
#[test]
fn azure_cutover_script_syncs_config_on_a_same_tag_redeploy() {
let Some((status, calls, bodies)) =
run_azure_cutover("acr.azurecr.io/app:t1", "Provisioned", true, 0, &[])
else {
return;
};
assert!(status.success(), "{calls}");
assert_eq!(
calls.matches("az rest --method patch").count(),
1,
"{calls}"
);
assert!(bodies.contains("\"redis-url\""), "{bodies}");
assert!(
!calls.contains("--query length(@)"),
"a later deploy has no placeholder to check: {calls}"
);
assert!(
!calls.contains("ingress disable"),
"a later deploy keeps serving: {calls}"
);
}
#[cfg(unix)]
#[test]
fn azure_cutover_script_forces_a_fresh_revision_after_a_failed_one() {
let Some((status, calls, bodies)) = run_azure_cutover(
"acr.azurecr.io/app:t1",
"Provisioned",
false,
0,
&[
("STUB_APP_ENV_FULL", "1"),
("STUB_LATEST_FAILED", "app--old"),
],
) else {
return;
};
assert!(status.success(), "{calls}");
assert!(bodies.contains("AUTUMN_FORCE_REVISION"), "{bodies}");
assert!(
!calls.contains("revision restart"),
"a fresh revision needs no restart: {calls}"
);
}
#[cfg(unix)]
#[test]
fn azure_cutover_script_waits_for_a_new_revision_when_same_tag_config_changes() {
let Some((status, calls, _)) = run_azure_cutover(
"acr.azurecr.io/app:t1",
"Provisioned",
true,
0,
&[("STUB_LATEST", "app--old")],
) else {
return;
};
assert!(!status.success(), "{calls}");
assert!(!calls.contains("ingress-patch external=true"), "{calls}");
}
#[cfg(unix)]
#[test]
fn azure_cutover_script_accepts_the_current_revision_when_nothing_changes() {
let Some((status, calls, _)) = run_azure_cutover(
"acr.azurecr.io/app:t1",
"Provisioned",
false,
0,
&[("STUB_LATEST", "app--old"), ("STUB_APP_ENV_FULL", "1")],
) else {
return;
};
assert!(status.success(), "{calls}");
let restart_at = calls
.find("az containerapp revision restart")
.unwrap_or_else(|| panic!("the current revision must restart: {calls}"));
assert!(
calls[restart_at..].contains("--revision app--old"),
"{calls}"
);
assert!(!calls.contains("az ingress-patch"), "{calls}");
}
#[cfg(unix)]
#[test]
fn azure_cutover_script_removes_legacy_credentials_from_the_placeholder() {
let Some((status, calls, bodies)) = run_azure_cutover_with_args(
&["--remove-credentials"],
"mcr.microsoft.com/k8se/quickstart:latest",
"Provisioned",
false,
0,
&[("STUB_APP_LEGACY", "1")],
) else {
return;
};
assert!(status.success(), "{calls}");
let patches: Vec<&str> = bodies
.lines()
.filter(|line| !line.contains("\"ingress\""))
.collect();
assert_eq!(patches.len(), 2, "{bodies}");
assert!(patches[0].contains("\"template\""), "{}", patches[0]);
assert!(!patches[0].contains("\"secrets\""), "{}", patches[0]);
assert!(!patches[1].contains("\"template\""), "{}", patches[1]);
let first_patch = calls.find("az rest --method patch").unwrap();
let second_patch = calls.rfind("az rest --method patch").unwrap();
assert!(
calls[first_patch..second_patch].contains("az containerapp revision list"),
"the old revision must stop first: {calls}"
);
assert!(!calls.contains("ingress-patch external=true"), "{calls}");
assert!(bodies.contains("\"type\":\"None\""), "{bodies}");
assert!(bodies.contains("\"registries\":[]"), "{bodies}");
for kept in [
"\"api-key\"",
"\"AUTUMN_PROFILE\"",
"\"sidecar\"",
"\"location\":\"westeurope\"",
] {
assert!(
bodies.contains(kept),
"the PATCH must keep {kept}: {bodies}"
);
}
for removed in [
"database-url",
"signing-secret",
"AUTUMN_DATABASE__PRIMARY_URL",
"AUTUMN_SECURITY__SIGNING_SECRET",
] {
assert!(
!bodies.contains(removed),
"the PATCH must remove {removed}: {bodies}"
);
}
}
#[cfg(unix)]
fn credentials_body(bodies: &str) -> &str {
bodies
.lines()
.rfind(|body| body.contains("\"identity\""))
.unwrap_or_default()
}
#[cfg(unix)]
#[test]
fn azure_cutover_script_keeps_the_ingress_settings_on_the_first_cutover() {
let Some((status, calls, bodies)) = run_azure_cutover(
"mcr.microsoft.com/k8se/quickstart:latest",
"Provisioned",
false,
0,
&[],
) else {
return;
};
assert!(status.success(), "{calls}");
let ingress = bodies
.lines()
.find(|body| body.contains("\"ingress\""))
.unwrap_or_else(|| panic!("{bodies}"));
assert!(ingress.contains("\"www.example.com\""), "{ingress}");
assert!(ingress.contains("\"external\":true"), "{ingress}");
assert!(!ingress.contains("fqdn"), "fqdn is read-only: {ingress}");
}
#[cfg(unix)]
#[test]
fn azure_cutover_script_leaves_open_ingress_alone_on_a_later_deploy() {
let Some((status, calls, bodies)) =
run_azure_cutover("acr.azurecr.io/app:t0", "Provisioned", false, 0, &[])
else {
return;
};
assert!(status.success(), "{calls}");
assert!(!bodies.contains("\"ingress\""), "{bodies}");
}
#[cfg(unix)]
#[test]
fn azure_cutover_script_waits_until_the_ingress_is_external() {
let Some((status, calls, _)) = run_azure_cutover(
"acr.azurecr.io/app:t0",
"Provisioned",
false,
0,
&[("STUB_INGRESS_INTERNAL", "1"), ("STUB_PATCH_PENDING", "2")],
) else {
return;
};
assert!(status.success(), "{calls}");
let ingress_at = calls
.find("az ingress-patch external=true")
.unwrap_or_else(|| panic!("{calls}"));
assert!(
calls[ingress_at..]
.matches("az containerapp show --name app --resource-group rg --output json")
.count()
>= 3,
"{calls}"
);
}
#[cfg(unix)]
#[test]
fn azure_cutover_script_stops_on_a_custom_scale_rule_before_the_first_cutover() {
let Some((status, calls, _)) = run_azure_cutover(
"mcr.microsoft.com/k8se/quickstart:latest",
"Provisioned",
false,
0,
&[("STUB_SCALE_SECRET_REF", "queue-connection")],
) else {
return;
};
assert!(!status.success(), "{calls}");
assert!(!calls.contains("az rest --method patch"), "{calls}");
assert!(!calls.contains("ingress disable"), "{calls}");
}
#[cfg(unix)]
#[test]
fn azure_cutover_script_checks_the_scale_rules_of_the_active_revision() {
let Some((status, calls, _)) = run_azure_cutover(
"mcr.microsoft.com/k8se/quickstart:latest",
"Provisioned",
false,
0,
&[("STUB_ACTIVE_SCALE_RULE", "1")],
) else {
return;
};
assert!(!status.success(), "{calls}");
assert!(
calls.contains("--revision app--old --query properties.template.scale --output json"),
"{calls}"
);
assert!(!calls.contains("az rest --method patch"), "{calls}");
assert!(!calls.contains("ingress disable"), "{calls}");
}
#[cfg(unix)]
#[test]
fn azure_cutover_script_uses_the_acr_registry_identity() {
let Some((status, calls, bodies)) = run_azure_cutover(
"mcr.microsoft.com/k8se/quickstart:latest",
"Provisioned",
false,
0,
&[("STUB_JOB_EXTRA_REGISTRY", "1")],
) else {
return;
};
assert!(status.success(), "{calls}");
let cutover = bodies.lines().next().unwrap_or_default();
assert!(!cutover.contains("/other-id"), "{cutover}");
assert!(
cutover.contains("\"server\":\"acr.azurecr.io\",\"identity\":\"/subscriptions/s/resourceGroups/rg/providers/Microsoft.ManagedIdentity/userAssignedIdentities/id\""),
"{cutover}"
);
}
#[cfg(unix)]
#[test]
fn azure_cutover_script_rollback_keeps_external_ingress_that_was_open() {
let Some((status, calls, bodies)) = run_azure_cutover(
"mcr.microsoft.com/k8se/quickstart:latest",
"Failed",
false,
0,
&[("STUB_INGRESS_EXTERNAL", "1")],
) else {
return;
};
assert!(!status.success(), "{calls}");
let last = bodies.lines().last().unwrap_or_default();
assert!(last.contains("\"external\":true"), "{last}");
}
#[cfg(unix)]
#[test]
fn azure_cutover_script_rollback_waits_for_its_own_cleanup_before_the_ingress() {
let Some((status, calls, _)) = run_azure_cutover(
"mcr.microsoft.com/k8se/quickstart:latest",
"Provisioned",
false,
0,
&[
("STUB_INGRESS_EXTERNAL", "1"),
("STUB_CUTOVER_LOST", "1"),
("STUB_PATCH_PENDING", "3"),
],
) else {
return;
};
assert!(!status.success(), "{calls}");
let before_open = calls
.split("az ingress-patch external=true")
.next()
.unwrap_or_default();
assert!(before_open.len() < calls.len(), "{calls}");
let after_cleanup = before_open
.rsplit("az rest --method patch")
.nth(1)
.unwrap_or_else(|| panic!("{calls}"));
assert!(
after_cleanup
.matches("az containerapp show --name app --resource-group rg --output json")
.count()
>= 4,
"{calls}"
);
}
#[cfg(unix)]
#[test]
fn azure_cutover_script_restores_the_ingress_when_the_disable_reports_a_failure() {
for (args, legacy) in [(&[][..], ""), (&["--remove-credentials"][..], "1")] {
let mut env = vec![
("STUB_INGRESS_EXTERNAL", "1"),
("STUB_INGRESS_DISABLE_LOST", "1"),
];
if !legacy.is_empty() {
env.push(("STUB_APP_LEGACY", legacy));
}
let Some((status, calls, bodies)) = run_azure_cutover_with_args(
args,
"mcr.microsoft.com/k8se/quickstart:latest",
"Provisioned",
false,
0,
&env,
) else {
return;
};
assert!(!status.success(), "{args:?}: {calls}");
let after_disable = calls
.split("az containerapp ingress disable")
.nth(1)
.unwrap_or_else(|| panic!("{args:?}: {calls}"));
assert!(
after_disable.contains("az ingress-patch external=true"),
"{args:?}: {calls}"
);
assert!(!bodies.contains("acr.azurecr.io/app:t1"), "{bodies}");
assert!(!bodies.contains("AUTUMN_CREDENTIAL_CLEANUP"), "{bodies}");
}
}
#[cfg(unix)]
#[test]
fn azure_cutover_script_fails_when_it_cannot_remove_the_recovery_tags() {
let Some((status, calls, bodies)) = run_azure_cutover(
"mcr.microsoft.com/k8se/quickstart:latest",
"Provisioned",
false,
0,
&[("STUB_TAGS_CLEAR_FAILS", "1")],
) else {
return;
};
assert!(!status.success(), "{calls}");
assert!(calls.contains("az ingress-patch external=true"), "{calls}");
assert!(!bodies.contains("AUTUMN_CREDENTIAL_CLEANUP"), "{bodies}");
assert!(!bodies.contains("\"type\":\"None\""), "{bodies}");
}
#[cfg(unix)]
#[test]
fn azure_cutover_script_restores_the_ingress_after_a_failed_first_cutover() {
let Some((status, calls, bodies)) = run_azure_cutover(
"mcr.microsoft.com/k8se/quickstart:latest",
"Failed",
false,
0,
&[],
) else {
return;
};
assert!(!status.success(), "{calls}");
let last = bodies.lines().last().unwrap_or_default();
assert!(last.contains("\"ingress\""), "{bodies}");
assert!(last.contains("\"external\":false"), "{last}");
assert!(last.contains("\"www.example.com\""), "{last}");
assert!(!calls.contains("ingress-patch external=true"), "{calls}");
}
#[cfg(unix)]
#[test]
fn azure_cutover_script_waits_for_activation_on_an_interrupted_retry() {
let Some((status, calls, _)) = run_azure_cutover(
"acr.azurecr.io/app:t1",
"Provisioned",
false,
0,
&[
("STUB_APP_ENV_FULL", "1"),
(
"STUB_ACTIVE_IMAGE",
"mcr.microsoft.com/k8se/quickstart:latest",
),
("STUB_LATEST", "app--real"),
("STUB_ACTIVE_LAG", "2"),
],
) else {
return;
};
assert!(status.success(), "{calls}");
let patch_at = calls.find("az rest --method patch").unwrap();
let ingress_at = calls
.find("az ingress-patch external=true")
.unwrap_or_else(|| panic!("{calls}"));
assert!(
calls[patch_at..ingress_at]
.matches("az containerapp revision list")
.count()
>= 2,
"the retry must wait until the real revision is the only active one: {calls}"
);
}
#[cfg(unix)]
fn full_reads_after_patch(calls: &str) -> usize {
let patch_at = calls.find("az rest --method patch").unwrap_or(calls.len());
calls[patch_at..]
.matches("az containerapp show --name app --resource-group rg --output json")
.count()
}
#[cfg(unix)]
#[test]
fn azure_cutover_script_waits_until_the_credentials_are_gone() {
let Some((status, calls, _)) = run_azure_cutover_with_args(
&["--remove-credentials"],
"mcr.microsoft.com/k8se/quickstart:latest",
"Provisioned",
false,
0,
&[("STUB_APP_LEGACY", "1"), ("STUB_PATCH_PENDING", "2")],
) else {
return;
};
assert!(status.success(), "{calls}");
assert!(full_reads_after_patch(&calls) >= 3, "{calls}");
}
#[cfg(unix)]
#[test]
fn azure_cutover_script_restarts_only_after_the_patch_applies() {
let Some((status, calls, _)) = run_azure_cutover(
"acr.azurecr.io/app:t1",
"Provisioned",
false,
0,
&[
("STUB_LATEST", "app--old"),
("STUB_APP_ENV_FULL", "1"),
("STUB_PATCH_PENDING", "2"),
],
) else {
return;
};
assert!(status.success(), "{calls}");
let restart_at = calls.find("revision restart").expect("restart");
assert!(full_reads_after_patch(&calls[..restart_at]) >= 3, "{calls}");
}
#[cfg(unix)]
#[test]
fn azure_cutover_script_checks_the_registry_before_a_restart() {
let Some((status, calls, _)) = run_azure_cutover(
"acr.azurecr.io/app:t1",
"Provisioned",
false,
0,
&[
("STUB_LATEST", "app--old"),
("STUB_APP_ENV_FULL", "1"),
("STUB_APP_LEGACY", "1"),
("STUB_APP_NO_REGISTRY", "1"),
("STUB_PATCH_PENDING", "2"),
],
) else {
return;
};
assert!(status.success(), "{calls}");
let restart_at = calls.find("revision restart").expect("restart");
assert!(full_reads_after_patch(&calls[..restart_at]) >= 3, "{calls}");
}
#[cfg(unix)]
#[test]
fn azure_cutover_script_checks_the_secret_identity_before_a_restart() {
let Some((status, calls, _)) = run_azure_cutover(
"acr.azurecr.io/app:t1",
"Provisioned",
false,
0,
&[
("STUB_LATEST", "app--old"),
("STUB_APP_ENV_FULL", "1"),
("STUB_APP_LEGACY", "1"),
("STUB_APP_STALE_SECRET_IDENTITY", "1"),
("STUB_PATCH_PENDING", "2"),
],
) else {
return;
};
assert!(status.success(), "{calls}");
let restart_at = calls.find("revision restart").expect("restart");
assert!(full_reads_after_patch(&calls[..restart_at]) >= 3, "{calls}");
}
#[cfg(unix)]
#[test]
fn azure_cutover_script_stops_using_redis_before_terraform_removes_it() {
let Some((status, calls, bodies)) = run_azure_cutover_with_args(
&["--without-redis"],
"acr.azurecr.io/app:t0",
"Provisioned",
true,
0,
&[("STUB_APP_REDIS", "1")],
) else {
return;
};
assert!(status.success(), "{calls}");
let patches: Vec<&str> = bodies.lines().collect();
assert_eq!(patches.len(), 2, "{bodies}");
assert!(!patches[0].contains("AUTUMN_CACHE__"), "{}", patches[0]);
assert!(
patches[0].contains("\"redis-url\""),
"the old revision still uses redis-url: {}",
patches[0]
);
assert!(!patches[1].contains("\"redis-url\""), "{}", patches[1]);
}
#[cfg(unix)]
#[test]
fn azure_cutover_script_removes_sidecar_redis_refs_without_redis() {
let Some((status, calls, bodies)) = run_azure_cutover_with_args(
&["--without-redis"],
"acr.azurecr.io/app:t0",
"Provisioned",
true,
0,
&[("STUB_APP_REDIS", "1"), ("STUB_SIDECAR_REDIS_REF", "1")],
) else {
return;
};
assert!(status.success(), "{calls}");
let patches: Vec<&str> = bodies.lines().collect();
assert_eq!(patches.len(), 2, "{bodies}");
assert!(!patches[0].contains("SIDECAR_REDIS"), "{}", patches[0]);
assert!(!patches[1].contains("\"redis-url\""), "{}", patches[1]);
}
#[cfg(unix)]
#[test]
fn azure_cutover_script_removes_init_container_redis_refs_without_redis() {
let Some((status, calls, bodies)) = run_azure_cutover_with_args(
&["--without-redis"],
"acr.azurecr.io/app:t0",
"Provisioned",
true,
0,
&[
("STUB_APP_REDIS", "1"),
("STUB_INIT_SECRET_REF", "redis-url"),
],
) else {
return;
};
assert!(status.success(), "{calls}");
let patches: Vec<&str> = bodies.lines().collect();
assert_eq!(patches.len(), 2, "{bodies}");
assert!(!patches[0].contains("INIT_DB"), "{}", patches[0]);
assert!(patches[0].contains("\"migrate\""), "{}", patches[0]);
assert!(!patches[1].contains("\"redis-url\""), "{}", patches[1]);
}
#[cfg(unix)]
#[test]
fn azure_cutover_script_stops_without_redis_on_a_scale_rule_ref() {
let Some((status, calls, _)) = run_azure_cutover_with_args(
&["--without-redis"],
"acr.azurecr.io/app:t0",
"Provisioned",
true,
0,
&[
("STUB_APP_REDIS", "1"),
("STUB_SCALE_SECRET_REF", "redis-url"),
],
) else {
return;
};
assert!(!status.success(), "{calls}");
assert!(!calls.contains("az rest --method patch"), "{calls}");
}
#[cfg(unix)]
#[test]
fn azure_cutover_script_stops_without_redis_on_a_registry_password_ref() {
let Some((status, calls, _)) = run_azure_cutover_with_args(
&["--without-redis"],
"acr.azurecr.io/app:t0",
"Provisioned",
true,
0,
&[
("STUB_APP_REDIS", "1"),
("STUB_APP_REGISTRY_PASSWORD_REF", "redis-url"),
],
) else {
return;
};
assert!(!status.success(), "{calls}");
assert!(!calls.contains("az rest --method patch"), "{calls}");
}
#[cfg(unix)]
#[test]
fn azure_cutover_script_waits_until_redis_url_is_gone() {
let Some((status, calls, _)) = run_azure_cutover(
"acr.azurecr.io/app:t0",
"Provisioned",
false,
0,
&[("STUB_APP_REDIS", "1"), ("STUB_PATCH_PENDING", "2")],
) else {
return;
};
assert!(status.success(), "{calls}");
let second_patch_at = calls.rfind("az rest --method patch").unwrap();
assert!(
full_reads_after_patch(&calls[second_patch_at..]) >= 3,
"{calls}"
);
}
#[cfg(unix)]
#[test]
fn azure_cutover_script_removes_redis_url_after_the_old_revision_stops() {
let Some((status, calls, bodies)) = run_azure_cutover(
"acr.azurecr.io/app:t0",
"Provisioned",
false,
0,
&[("STUB_APP_REDIS", "1")],
) else {
return;
};
assert!(status.success(), "{calls}");
let patches: Vec<&str> = bodies.lines().collect();
assert_eq!(patches.len(), 2, "{bodies}");
assert!(patches[0].contains("\"redis-url\""), "{}", patches[0]);
assert!(
!patches[0].contains("AUTUMN_CACHE__REDIS__URL"),
"{}",
patches[0]
);
assert!(!patches[1].contains("\"redis-url\""), "{}", patches[1]);
assert!(patches[1].contains("\"signing-secret\""), "{}", patches[1]);
let first_patch_at = calls.find("az rest --method patch").unwrap();
let second_patch_at = calls.rfind("az rest --method patch").unwrap();
assert!(
calls[first_patch_at..second_patch_at].contains("az containerapp revision list"),
"the script must check the active revisions: {calls}"
);
}
#[cfg(unix)]
#[test]
fn azure_cutover_script_removes_sidecar_refs_before_the_secrets() {
let Some((status, calls, bodies)) = run_azure_cutover_with_args(
&["--remove-credentials"],
"mcr.microsoft.com/k8se/quickstart:latest",
"Provisioned",
false,
0,
&[("STUB_APP_LEGACY", "1"), ("STUB_SIDECAR_SECRET_REF", "1")],
) else {
return;
};
assert!(status.success(), "{calls}");
let stage1 = bodies.lines().next().unwrap_or_default();
assert!(!stage1.contains("database-url"), "{stage1}");
assert!(stage1.contains("\"sidecar\""), "{stage1}");
}
#[cfg(unix)]
#[test]
fn azure_cutover_script_removes_init_container_refs_before_the_secrets() {
let Some((status, calls, bodies)) = run_azure_cutover_with_args(
&["--remove-credentials"],
"mcr.microsoft.com/k8se/quickstart:latest",
"Provisioned",
false,
0,
&[("STUB_APP_LEGACY", "1"), ("STUB_INIT_SECRET_REF", "1")],
) else {
return;
};
assert!(status.success(), "{calls}");
let stage1 = bodies.lines().next().unwrap_or_default();
assert!(!stage1.contains("database-url"), "{stage1}");
assert!(stage1.contains("\"migrate\""), "{stage1}");
}
#[cfg(unix)]
#[test]
fn azure_cutover_script_stops_on_a_scale_rule_secret_ref() {
let Some((status, calls, _)) = run_azure_cutover_with_args(
&["--remove-credentials"],
"mcr.microsoft.com/k8se/quickstart:latest",
"Provisioned",
false,
0,
&[
("STUB_APP_LEGACY", "1"),
("STUB_SCALE_SECRET_REF", "database-url"),
],
) else {
return;
};
assert!(!status.success(), "{calls}");
assert!(!calls.contains("az rest --method patch"), "{calls}");
}
#[cfg(unix)]
#[test]
fn azure_cutover_script_removes_credentials_after_a_canceled_first_cutover() {
let Some((status, calls, _)) = run_azure_cutover_with_args(
&["--remove-credentials"],
"acr.azurecr.io/app:t1",
"Provisioned",
false,
0,
&[
("STUB_APP_LEGACY", "1"),
(
"STUB_ACTIVE_IMAGE",
"mcr.microsoft.com/k8se/quickstart:latest",
),
],
) else {
return;
};
assert!(status.success(), "{calls}");
assert!(calls.contains("az rest --method patch"), "{calls}");
}
#[cfg(unix)]
#[test]
fn azure_cutover_script_deletes_secrets_only_when_no_other_revision_is_active() {
let Some((status, calls, _)) = run_azure_cutover_with_args(
&["--remove-credentials"],
"acr.azurecr.io/app:t1",
"Provisioned",
false,
0,
&[
("STUB_APP_LEGACY", "1"),
(
"STUB_ACTIVE_IMAGE",
"mcr.microsoft.com/k8se/quickstart:latest",
),
("STUB_ACTIVE_LAG", "2"),
],
) else {
return;
};
assert!(status.success(), "{calls}");
let first_patch = calls.find("az rest --method patch").unwrap();
let second_patch = calls.rfind("az rest --method patch").unwrap();
assert!(first_patch < second_patch, "{calls}");
assert!(
calls[first_patch..second_patch]
.matches("az containerapp revision list")
.count()
>= 3,
"stage 2 must wait until the old revision is inactive: {calls}"
);
}
#[cfg(unix)]
#[test]
fn azure_cutover_script_cleans_up_from_the_active_placeholder_template() {
let Some((status, calls, bodies)) = run_azure_cutover_with_args(
&["--remove-credentials"],
"acr.azurecr.io/app:t1",
"Provisioned",
false,
0,
&[
("STUB_APP_LEGACY", "1"),
(
"STUB_ACTIVE_IMAGE",
"mcr.microsoft.com/k8se/quickstart:latest",
),
],
) else {
return;
};
assert!(status.success(), "{calls}");
let stage1 = bodies.lines().next().unwrap_or_default();
assert!(
stage1.contains("\"mcr.microsoft.com/k8se/quickstart:latest\""),
"{stage1}"
);
assert!(!stage1.contains("acr.azurecr.io/app:t1"), "{stage1}");
}
#[cfg(unix)]
#[test]
fn azure_cutover_script_retries_an_interrupted_credential_removal() {
let Some((status, calls, bodies)) = run_azure_cutover_with_args(
&["--remove-credentials"],
"mcr.microsoft.com/k8se/quickstart:latest",
"Provisioned",
false,
0,
&[
("STUB_APP_LEGACY", "1"),
("STUB_APP_TEMPLATE_CLEAN", "1"),
("STUB_ACTIVE_HAS_REFS", "1"),
("STUB_LATEST", "app--clean"),
("STUB_ACTIVE_LAG", "3"),
],
) else {
return;
};
assert!(status.success(), "{calls}");
let credential: Vec<&str> = bodies
.lines()
.filter(|line| !line.contains("\"ingress\""))
.collect();
assert_eq!(credential.len(), 1, "{bodies}");
assert!(!credential[0].contains("\"template\""), "{bodies}");
let patch_at = calls.find("az rest --method patch").unwrap();
assert!(
calls[..patch_at]
.matches("az containerapp revision list")
.count()
>= 4,
"stage 2 must wait until the clean revision is the only active one: {calls}"
);
}
#[cfg(unix)]
#[test]
fn azure_cutover_script_waits_for_a_scale_rule_ref_on_the_active_revision() {
let Some((status, calls, _)) = run_azure_cutover_with_args(
&["--remove-credentials"],
"mcr.microsoft.com/k8se/quickstart:latest",
"Provisioned",
false,
0,
&[
("STUB_APP_LEGACY", "1"),
("STUB_APP_TEMPLATE_CLEAN", "1"),
("STUB_ACTIVE_SCALE_REF", "1"),
("STUB_LATEST", "app--clean"),
("STUB_ACTIVE_LAG", "3"),
],
) else {
return;
};
assert!(status.success(), "{calls}");
assert!(
calls.contains("--revision app--old --query properties.template --output json"),
"{calls}"
);
let patch_at = calls.find("az rest --method patch").unwrap();
assert!(
calls[..patch_at]
.matches("az containerapp revision list")
.count()
>= 4,
"stage 2 must wait until the clean revision is the only active one: {calls}"
);
}
#[cfg(unix)]
#[test]
fn azure_cutover_script_waits_for_a_scale_rule_identity_on_the_active_revision() {
let Some((status, calls, _)) = run_azure_cutover_with_args(
&["--remove-credentials"],
"mcr.microsoft.com/k8se/quickstart:latest",
"Provisioned",
false,
0,
&[
("STUB_APP_LEGACY", "1"),
("STUB_APP_TEMPLATE_CLEAN", "1"),
("STUB_ACTIVE_SCALE_IDENTITY", "1"),
("STUB_LATEST", "app--clean"),
("STUB_ACTIVE_LAG", "3"),
],
) else {
return;
};
assert!(status.success(), "{calls}");
let patch_at = calls.find("az rest --method patch").unwrap();
assert!(
calls[..patch_at]
.matches("az containerapp revision list")
.count()
>= 4,
"stage 2 must wait until the clean revision is the only active one: {calls}"
);
}
#[cfg(unix)]
#[test]
fn azure_cutover_script_closes_ingress_while_it_removes_credentials() {
let Some((status, calls, _)) = run_azure_cutover_with_args(
&["--remove-credentials"],
"mcr.microsoft.com/k8se/quickstart:latest",
"Provisioned",
false,
0,
&[("STUB_APP_LEGACY", "1"), ("STUB_INGRESS_EXTERNAL", "1")],
) else {
return;
};
assert!(status.success(), "{calls}");
let save_at = calls.find("az tags-patch ").expect("save");
let disable_at = calls.find("ingress disable").expect("disable");
let first_patch = calls.find("az rest --method patch").expect("patch");
let restore_at = calls
.find("az ingress-patch external=true")
.expect("restore");
assert!(save_at < disable_at && disable_at < first_patch, "{calls}");
assert!(
calls.rfind("az rest --method patch").unwrap() < restore_at,
"{calls}"
);
}
#[cfg(unix)]
#[test]
fn azure_cutover_script_keeps_ingress_closed_when_the_removal_fails() {
let Some((status, calls, _)) = run_azure_cutover_with_args(
&["--remove-credentials"],
"mcr.microsoft.com/k8se/quickstart:latest",
"Provisioned",
false,
0,
&[
("STUB_APP_LEGACY", "1"),
("STUB_INGRESS_EXTERNAL", "1"),
("STUB_SCALE_SECRET_REF", "database-url"),
],
) else {
return;
};
assert!(!status.success(), "{calls}");
assert!(calls.contains("ingress disable"), "{calls}");
assert!(!calls.contains("az ingress-patch external=true"), "{calls}");
}
#[cfg(unix)]
#[test]
fn azure_cutover_script_records_the_copied_credentials_for_a_rollback() {
let Some((status, calls, bodies)) = run_azure_cutover(
"mcr.microsoft.com/k8se/quickstart:latest",
"Provisioned",
false,
0,
&[],
) else {
return;
};
assert!(status.success(), "{calls}");
let cutover = bodies
.lines()
.find(|line| line.contains("\"template\""))
.unwrap_or_else(|| panic!("{bodies}"));
assert!(cutover.contains("\"autumn-copied-0\":\""), "{cutover}");
let last = calls
.lines()
.rfind(|line| line.starts_with("az tags-patch "))
.unwrap_or_default();
assert!(last.contains("\"autumn-copied-0\":null"), "{calls}");
}
#[cfg(unix)]
#[test]
fn azure_cutover_script_removes_the_recorded_credentials() {
let Some((status, calls, bodies)) = run_azure_cutover_with_args(
&["--remove-credentials"],
"mcr.microsoft.com/k8se/quickstart:latest",
"Provisioned",
false,
0,
&[("STUB_APP_LEGACY", "1"), ("STUB_APP_COPIED", "1")],
) else {
return;
};
assert!(status.success(), "{calls}");
let stage2 = bodies
.lines()
.rfind(|line| line.contains("\"secrets\""))
.unwrap_or_else(|| panic!("{bodies}"));
assert!(!stage2.contains("queue-key"), "{stage2}");
assert!(!stage2.contains("\"/kv-id-2\":{}"), "{stage2}");
assert!(stage2.contains("\"autumn-copied-0\":null"), "{stage2}");
}
#[cfg(unix)]
#[test]
fn azure_cutover_script_removes_a_legacy_identity_of_the_managed_credentials() {
let Some((status, calls, bodies)) = run_azure_cutover_with_args(
&["--remove-credentials"],
"mcr.microsoft.com/k8se/quickstart:latest",
"Provisioned",
false,
0,
&[("STUB_APP_LEGACY", "1"), ("STUB_APP_LEGACY_ID", "/old-id")],
) else {
return;
};
assert!(status.success(), "{calls}");
let stage2 = bodies
.lines()
.rfind(|line| line.contains("\"secrets\""))
.unwrap_or_else(|| panic!("{bodies}"));
assert!(!stage2.contains("\"/old-id\":{}"), "{stage2}");
assert!(stage2.contains("\"registries\":[]"), "{stage2}");
}
#[cfg(unix)]
#[test]
fn azure_cutover_script_closes_ingress_again_before_a_rollback() {
let Some((status, calls, _)) = run_azure_cutover(
"acr.azurecr.io/app:t1",
"Provisioned",
false,
0,
&[
(
"STUB_ACTIVE_IMAGE",
"mcr.microsoft.com/k8se/quickstart:latest",
),
("STUB_APP_ENV_FULL", "1"),
("STUB_APP_LEGACY", "1"),
("STUB_LATEST", "app--old"),
("STUB_RESTART_UNREADY", "1"),
],
) else {
return;
};
assert!(!status.success(), "{calls}");
let restart_at = calls.find("revision restart").expect("restart");
let after = &calls[restart_at..];
let disable_at = after
.find("ingress disable")
.unwrap_or_else(|| panic!("the rollback must close ingress: {calls}"));
if let Some(patch_at) = after.find("az rest --method patch") {
assert!(disable_at < patch_at, "{calls}");
}
}
#[cfg(unix)]
#[test]
fn azure_cutover_script_reads_every_active_placeholder_before_cleanup() {
let Some((status, calls, _)) = run_azure_cutover_with_args(
&["--remove-credentials"],
"mcr.microsoft.com/k8se/quickstart:latest",
"Provisioned",
false,
0,
&[
("STUB_APP_LEGACY", "1"),
("STUB_APP_TEMPLATE_CLEAN", "1"),
("STUB_TWO_PLACEHOLDERS", "1"),
("STUB_LATEST", "app--clean"),
("STUB_ACTIVE_LAG", "3"),
],
) else {
return;
};
assert!(status.success(), "{calls}");
assert!(
calls.contains("--revision app--old2 --query properties.template --output json"),
"{calls}"
);
let patch_at = calls.find("az rest --method patch").unwrap();
assert!(
calls[..patch_at]
.matches("az containerapp revision list")
.count()
>= 4,
"stage 2 must wait until the clean revision is the only active one: {calls}"
);
}
#[cfg(unix)]
#[test]
fn azure_cutover_rollback_closes_ingress_after_a_lost_open_response() {
let Some((status, calls, _)) = run_azure_cutover(
"mcr.microsoft.com/k8se/quickstart:latest",
"Provisioned",
false,
0,
&[("STUB_INGRESS_OPEN_LOST", "1")],
) else {
return;
};
assert!(!status.success(), "{calls}");
let open_at = calls
.find("az ingress-patch external=true")
.expect("an open");
let after = &calls[open_at..];
let disable_at = after
.find("ingress disable")
.unwrap_or_else(|| panic!("the rollback must close ingress: {calls}"));
let patch_at = after[1..]
.find("az rest --method patch")
.map_or(usize::MAX, |i| i + 1);
assert!(disable_at < patch_at, "{calls}");
}
#[cfg(unix)]
#[test]
fn azure_cutover_script_stops_when_the_snapshots_exceed_the_tag_limit() {
for args in [&[][..], &["--remove-credentials"][..]] {
let Some((status, calls, _)) = run_azure_cutover_with_args(
args,
"mcr.microsoft.com/k8se/quickstart:latest",
"Provisioned",
false,
0,
&[("STUB_APP_TAG_COUNT", "50"), ("STUB_APP_LEGACY", "1")],
) else {
return;
};
assert!(!status.success(), "{args:?}: {calls}");
assert!(!calls.contains("az tags-patch"), "{args:?}: {calls}");
assert!(!calls.contains("ingress disable"), "{args:?}: {calls}");
assert!(
!calls.contains("az rest --method patch"),
"{args:?}: {calls}"
);
}
}
#[cfg(unix)]
#[test]
fn azure_cutover_script_fits_the_snapshots_into_the_tag_limit() {
let Some((status, calls, _)) = run_azure_cutover(
"mcr.microsoft.com/k8se/quickstart:latest",
"Provisioned",
false,
0,
&[("STUB_APP_TAG_COUNT", "48")],
) else {
return;
};
assert!(status.success(), "{calls}");
}
#[cfg(unix)]
#[test]
fn azure_cutover_rollback_stops_when_ingress_cannot_close() {
let Some((status, calls, _)) = run_azure_cutover(
"acr.azurecr.io/app:t1",
"Provisioned",
false,
0,
&[
(
"STUB_ACTIVE_IMAGE",
"mcr.microsoft.com/k8se/quickstart:latest",
),
("STUB_APP_ENV_FULL", "1"),
("STUB_APP_LEGACY", "1"),
("STUB_LATEST", "app--old"),
("STUB_RESTART_UNREADY", "1"),
("STUB_INGRESS_DISABLE_FAILS", "1"),
],
) else {
return;
};
assert!(!status.success(), "{calls}");
let restart_at = calls.find("revision restart").expect("restart");
let after = &calls[restart_at..];
let disable_at = after.find("ingress disable").expect("a disable");
assert!(
!after[disable_at..].contains("az rest --method patch"),
"no rollback PATCH after a failed disable: {calls}"
);
}
#[cfg(unix)]
#[test]
fn azure_cutover_script_fails_when_ingress_does_not_come_back_after_removal() {
let Some((status, calls, _)) = run_azure_cutover_with_args(
&["--remove-credentials"],
"mcr.microsoft.com/k8se/quickstart:latest",
"Provisioned",
false,
0,
&[
("STUB_APP_LEGACY", "1"),
("STUB_INGRESS_EXTERNAL", "1"),
("STUB_INGRESS_OPEN_LOST", "1"),
],
) else {
return;
};
assert!(!status.success(), "{calls}");
assert!(calls.contains("az ingress-patch external=true"), "{calls}");
}
#[cfg(unix)]
#[test]
fn azure_cutover_script_writes_a_copied_record_of_more_than_40_parts() {
let Some((status, calls, bodies)) = run_azure_cutover(
"mcr.microsoft.com/k8se/quickstart:latest",
"Provisioned",
false,
0,
&[("STUB_APP_COPIED_BIG", "250")],
) else {
return;
};
assert!(status.success(), "{calls}");
let cutover = bodies
.lines()
.find(|line| line.contains("\"template\""))
.unwrap_or_else(|| panic!("{bodies}"));
assert!(cutover.contains("\"autumn-copied-40\""), "{cutover}");
}
#[cfg(unix)]
#[test]
fn azure_cutover_script_refuses_removal_while_a_real_revision_is_active() {
let Some((status, calls, _)) = run_azure_cutover_with_args(
&["--remove-credentials"],
"mcr.microsoft.com/k8se/quickstart:latest",
"Provisioned",
false,
0,
&[("STUB_APP_LEGACY", "1"), ("STUB_ACTIVE_BOTH", "1")],
) else {
return;
};
assert!(!status.success(), "{calls}");
assert!(!calls.contains("az tags-patch"), "{calls}");
assert!(!calls.contains("ingress disable"), "{calls}");
assert!(!calls.contains("az rest --method patch"), "{calls}");
}
#[cfg(unix)]
#[test]
fn azure_cutover_script_waits_for_a_rotated_inline_secret_before_a_restart() {
let Some((status, calls, _)) = run_azure_cutover(
"acr.azurecr.io/app:t1",
"Provisioned",
false,
0,
&[
("STUB_LATEST", "app--old"),
("STUB_APP_ENV_FULL", "1"),
("STUB_JOB_INLINE_SECRET", "1"),
("STUB_INLINE_STALE", "3"),
],
) else {
return;
};
assert!(status.success(), "{calls}");
let patch_at = calls.find("az rest --method patch").expect("a PATCH");
let restart_at = calls.find("revision restart").expect("a restart");
assert!(
calls[patch_at..restart_at]
.matches("az containerapp secret list")
.count()
>= 4,
"the restart must wait for the new value: {calls}"
);
assert!(!calls.contains("tok-old"), "{calls}");
}
#[cfg(unix)]
#[test]
fn azure_cutover_script_stops_when_a_registry_password_is_a_managed_secret() {
for args in [&[][..], &["--remove-credentials"][..]] {
let Some((status, calls, _)) = run_azure_cutover_with_args(
args,
"mcr.microsoft.com/k8se/quickstart:latest",
"Provisioned",
false,
0,
&[
("STUB_APP_LEGACY", "1"),
("STUB_APP_REGISTRY_PASSWORD_REF", "1"),
],
) else {
return;
};
assert!(!status.success(), "{args:?}: {calls}");
assert!(!calls.contains("az tags-patch"), "{args:?}: {calls}");
assert!(!calls.contains("ingress disable"), "{args:?}: {calls}");
assert!(
!calls.contains("az rest --method patch"),
"{args:?}: {calls}"
);
}
}
#[cfg(unix)]
#[test]
fn azure_cutover_script_stops_when_a_sidecar_pulls_from_the_acr() {
for args in [&[][..], &["--remove-credentials"][..]] {
let Some((status, calls, _)) = run_azure_cutover_with_args(
args,
"mcr.microsoft.com/k8se/quickstart:latest",
"Provisioned",
false,
0,
&[("STUB_APP_LEGACY", "1"), ("STUB_SIDECAR_ACR", "1")],
) else {
return;
};
assert!(!status.success(), "{args:?}: {calls}");
assert!(!calls.contains("az tags-patch"), "{args:?}: {calls}");
assert!(!calls.contains("ingress disable"), "{args:?}: {calls}");
assert!(
!calls.contains("az rest --method patch"),
"{args:?}: {calls}"
);
}
}
#[cfg(unix)]
#[test]
fn azure_cutover_script_rejects_a_revision_with_rewritten_secret_refs() {
let Some((status, calls, _)) = run_azure_cutover(
"mcr.microsoft.com/k8se/quickstart:latest",
"Provisioned",
false,
0,
&[("STUB_REVISION_REWRITTEN", "1")],
) else {
return;
};
assert!(!status.success(), "{calls}");
assert!(!calls.contains("az ingress-patch external=true"), "{calls}");
}
#[cfg(unix)]
#[test]
fn azure_cutover_script_rejects_a_revision_with_rewritten_init_container_refs() {
let Some((status, calls, _)) = run_azure_cutover(
"mcr.microsoft.com/k8se/quickstart:latest",
"Provisioned",
false,
0,
&[("STUB_REVISION_INIT_REWRITTEN", "1")],
) else {
return;
};
assert!(!status.success(), "{calls}");
assert!(!calls.contains("az ingress-patch external=true"), "{calls}");
}
#[cfg(unix)]
#[test]
fn azure_cutover_script_stops_when_an_active_placeholder_has_an_acr_sidecar() {
for args in [&[][..], &["--remove-credentials"][..]] {
let Some((status, calls, _)) = run_azure_cutover_with_args(
args,
"mcr.microsoft.com/k8se/quickstart:latest",
"Provisioned",
false,
0,
&[("STUB_APP_LEGACY", "1"), ("STUB_ACTIVE_SIDECAR_ACR", "1")],
) else {
return;
};
assert!(!status.success(), "{args:?}: {calls}");
assert!(!calls.contains("az tags-patch"), "{args:?}: {calls}");
assert!(!calls.contains("ingress disable"), "{args:?}: {calls}");
assert!(
!calls.contains("az rest --method patch"),
"{args:?}: {calls}"
);
}
}
#[cfg(unix)]
#[test]
fn azure_cutover_script_opens_ingress_only_when_the_placeholder_is_inactive() {
let Some((status, calls, _)) = run_azure_cutover(
"mcr.microsoft.com/k8se/quickstart:latest",
"Provisioned",
false,
0,
&[("STUB_ACTIVE_LAG", "2")],
) else {
return;
};
assert!(status.success(), "{calls}");
let patch_at = calls.find("az rest --method patch").unwrap();
let ingress_at = calls
.find("az ingress-patch external=true")
.unwrap_or_else(|| panic!("{calls}"));
assert!(
calls[patch_at..ingress_at]
.matches("az containerapp revision list")
.count()
>= 3,
"ingress must wait until the new revision is the only active one: {calls}"
);
}
#[cfg(unix)]
#[test]
fn azure_cutover_script_retries_after_a_failed_cleanup_revision() {
let Some((status, calls, bodies)) = run_azure_cutover_with_args(
&["--remove-credentials"],
"mcr.microsoft.com/k8se/quickstart:latest",
"Provisioned",
false,
0,
&[
("STUB_APP_LEGACY", "1"),
("STUB_APP_TEMPLATE_CLEAN", "1"),
("STUB_ACTIVE_HAS_REFS", "1"),
("STUB_LATEST_FAILED", "app--clean"),
],
) else {
return;
};
assert!(status.success(), "{calls}");
let patches: Vec<&str> = bodies
.lines()
.filter(|line| !line.contains("\"ingress\""))
.collect();
assert_eq!(patches.len(), 2, "{bodies}");
assert!(
patches[0].contains("AUTUMN_CREDENTIAL_CLEANUP"),
"the template must change, so Azure makes a new revision: {}",
patches[0]
);
}
#[cfg(unix)]
#[test]
fn azure_cutover_script_reports_a_later_deploy_only_when_it_serves() {
let Some((status, calls, _)) = run_azure_cutover(
"acr.azurecr.io/app:t0",
"Provisioned",
false,
0,
&[("STUB_ACTIVE_LAG", "2")],
) else {
return;
};
assert!(status.success(), "{calls}");
let patch_at = calls.find("az rest --method patch").unwrap();
assert!(
calls[patch_at..]
.matches("az containerapp revision list")
.count()
>= 3,
"a later deploy must wait until the new revision is the only active one: {calls}"
);
}
#[cfg(unix)]
#[test]
fn azure_cutover_script_keeps_credentials_on_a_release_from_another_registry() {
let Some((status, calls, _)) = run_azure_cutover_with_args(
&["--remove-credentials"],
"ghcr.io/acme/app:v1",
"Provisioned",
false,
0,
&[("STUB_APP_LEGACY", "1")],
) else {
return;
};
assert!(!status.success(), "{calls}");
assert!(!calls.contains("az rest --method patch"), "{calls}");
}
#[cfg(unix)]
#[test]
fn azure_cutover_script_treats_a_release_from_another_registry_as_released() {
let Some((status, calls, _)) =
run_azure_cutover("ghcr.io/acme/app:v1", "Provisioned", false, 0, &[])
else {
return;
};
assert!(status.success(), "{calls}");
assert!(
!calls.contains("ingress disable"),
"a real release keeps serving: {calls}"
);
}
#[cfg(unix)]
#[test]
fn azure_cutover_script_honors_a_custom_bootstrap_image() {
let Some((status, calls, _)) = run_azure_cutover(
"example.io/placeholder:1",
"Provisioned",
false,
0,
&[("AZURE_BOOTSTRAP_IMAGE", "example.io/placeholder:1")],
) else {
return;
};
assert!(status.success(), "{calls}");
assert!(calls.contains("ingress disable"), "{calls}");
}
#[cfg(unix)]
#[test]
fn azure_cutover_script_retries_a_canceled_first_cutover_as_a_first_cutover() {
let Some((status, calls, _)) = run_azure_cutover(
"acr.azurecr.io/app:t0",
"Provisioned",
false,
0,
&[(
"STUB_ACTIVE_IMAGE",
"mcr.microsoft.com/k8se/quickstart:latest",
)],
) else {
return;
};
assert!(status.success(), "{calls}");
assert!(calls.contains("ingress disable"), "{calls}");
assert!(
calls.contains("replica list --name app --resource-group rg --revision app--old"),
"the script must wait for the active placeholder revision: {calls}"
);
}
#[cfg(unix)]
#[test]
fn azure_cutover_script_keeps_credentials_on_a_released_app() {
let Some((status, calls, _)) = run_azure_cutover_with_args(
&["--remove-credentials"],
"acr.azurecr.io/app:t0",
"Provisioned",
false,
0,
&[("STUB_APP_LEGACY", "1")],
) else {
return;
};
assert!(!status.success(), "{calls}");
assert!(!calls.contains("az rest --method patch"), "{calls}");
}
#[cfg(unix)]
#[test]
fn azure_cutover_script_rollback_removes_the_job_identity_next_to_an_own_one() {
let Some((status, calls, bodies)) = run_azure_cutover(
"mcr.microsoft.com/k8se/quickstart:latest",
"Failed",
false,
0,
&[("STUB_APP_OWN_IDENTITY", "1")],
) else {
return;
};
assert!(!status.success(), "{calls}");
let rollback = credentials_body(&bodies);
assert!(
rollback.contains(
"\"/subscriptions/s/resourceGroups/rg/providers/Microsoft.ManagedIdentity/userAssignedIdentities/id\":null"
),
"the rollback must remove the job identity: {rollback}"
);
assert!(
rollback.contains("\"/other\":{}"),
"the rollback must keep the own identity: {rollback}"
);
}
#[cfg(unix)]
#[test]
fn azure_cutover_script_rollback_removes_legacy_credentials() {
let Some((status, calls, bodies)) = run_azure_cutover(
"mcr.microsoft.com/k8se/quickstart:latest",
"Failed",
false,
0,
&[("STUB_APP_LEGACY", "1")],
) else {
return;
};
assert!(!status.success(), "{calls}");
let rollback = credentials_body(&bodies);
assert!(rollback.contains("\"type\":\"None\""), "{rollback}");
assert!(
!rollback.contains("signing-secret")
&& !rollback.contains("AUTUMN_DATABASE__PRIMARY_URL"),
"{rollback}"
);
}
#[test]
fn azure_workflow_runs_the_cutover_script_after_migrations() {
let tmp = TempDir::new().unwrap();
let dir = make_project(&tmp, "my-app");
init(&dir, "my-app", false, Target::AzureContainerApps, false).unwrap();
let workflow = fs::read_to_string(dir.join(".github/workflows/azure-deploy.yml")).unwrap();
let migrate_at = workflow
.find("az containerapp job start \\")
.expect("workflow must start the migration job");
let cutover_at = workflow
.find("run: bash azure-cutover.sh")
.expect("workflow must run the cutover script");
assert!(migrate_at < cutover_at);
assert!(
workflow.contains("AZURE_BOOTSTRAP_IMAGE: ${{ vars.AZURE_BOOTSTRAP_IMAGE }}"),
"the workflow must pass a custom bootstrap image: {workflow}"
);
let cutover_step = &workflow[cutover_at..];
let cutover_step = cutover_step
.split("\n - name:")
.next()
.unwrap_or(cutover_step);
for split in [
"az containerapp identity assign",
"az containerapp registry set",
"az containerapp secret set",
"az containerapp update \\",
"az containerapp ingress enable",
] {
assert!(
!workflow.contains(split),
"only azure-cutover.sh may write the app: {split}"
);
}
for var in [
"AZURE_APP_NAME:",
"AZURE_RESOURCE_GROUP:",
"AZURE_MIGRATE_JOB_NAME:",
"ACR_LOGIN_SERVER:",
"IMAGE_TAG:",
] {
assert!(
cutover_step.contains(var),
"the cutover step must pass {var}: {cutover_step}"
);
}
}
#[test]
fn variables_tf_marks_secret_inputs_sensitive_with_no_default() {
let tmp = TempDir::new().unwrap();
let dir = make_project(&tmp, "my-app");
init(&dir, "my-app", false, Target::AzureContainerApps, false).unwrap();
let content = fs::read_to_string(dir.join("variables.tf")).unwrap();
assert!(
content.contains("variable \"database_admin_password\""),
"variables.tf must declare a database_admin_password secret variable: {content}"
);
assert!(
content.contains("variable \"signing_secret\""),
"variables.tf must declare a signing_secret secret variable: {content}"
);
assert!(
content.contains("sensitive = true") || content.contains("sensitive = true"),
"secret variables must be marked sensitive so Terraform redacts them in \
plan/apply output: {content}"
);
assert!(
!content.to_lowercase().contains("default = \"postgres")
&& !content.contains("default = \"CHANGE_ME\""),
"secret variables must not ship a literal default value: {content}"
);
}
#[test]
fn variables_tf_does_not_declare_database_url() {
let tmp = TempDir::new().unwrap();
let dir = make_project(&tmp, "my-app");
init(&dir, "my-app", false, Target::AzureContainerApps, false).unwrap();
let content = fs::read_to_string(dir.join("variables.tf")).unwrap();
assert!(
!content.contains("variable \"database_url\""),
"variables.tf must NOT declare a database_url variable — it must be derived \
in main.tf from the Postgres server the same apply creates: {content}"
);
}
#[test]
fn variables_tf_declares_bootstrap_image() {
let tmp = TempDir::new().unwrap();
let dir = make_project(&tmp, "my-app");
init(&dir, "my-app", false, Target::AzureContainerApps, false).unwrap();
let content = fs::read_to_string(dir.join("variables.tf")).unwrap();
assert!(
content.contains("variable \"bootstrap_image\""),
"variables.tf must declare bootstrap_image, the placeholder image Terraform \
uses before any real image has been pushed to ACR: {content}"
);
}
#[test]
fn main_tf_provider_sets_subscription_id() {
let tmp = TempDir::new().unwrap();
let dir = make_project(&tmp, "my-app");
init(&dir, "my-app", false, Target::AzureContainerApps, false).unwrap();
let main_tf = fs::read_to_string(dir.join("main.tf")).unwrap();
let provider_block = main_tf
.split("provider \"azurerm\"")
.nth(1)
.and_then(|rest| rest.split("\n}").next())
.expect("main.tf must declare the azurerm provider block");
assert!(
provider_block.contains("subscription_id = var.subscription_id"),
"the azurerm provider block must set subscription_id: {provider_block}"
);
let variables_tf = fs::read_to_string(dir.join("variables.tf")).unwrap();
assert!(
variables_tf.contains("variable \"subscription_id\""),
"variables.tf must declare subscription_id: {variables_tf}"
);
let tfvars = fs::read_to_string(dir.join("terraform.tfvars.example")).unwrap();
assert!(
tfvars.contains("subscription_id"),
"terraform.tfvars.example must mention subscription_id so operators don't \
discover the AzureRM v4 requirement only after `terraform apply` fails: {tfvars}"
);
}
#[test]
fn tfvars_example_generates_postgres_compliant_admin_password() {
let tmp = TempDir::new().unwrap();
let dir = make_project(&tmp, "my-app");
init(&dir, "my-app", false, Target::AzureContainerApps, false).unwrap();
let content = fs::read_to_string(dir.join("terraform.tfvars.example")).unwrap();
assert!(
content.contains("TF_VAR_database_admin_password=\"$(openssl rand -base64 18)Aa1!\""),
"the documented database_admin_password generator must append a fixed \
upper/lower/digit/symbol suffix so all 4 character classes are guaranteed, \
not merely probable: {content}"
);
}
#[test]
fn outputs_tf_has_app_fqdn_and_acr_login_server() {
let tmp = TempDir::new().unwrap();
let dir = make_project(&tmp, "my-app");
init(&dir, "my-app", false, Target::AzureContainerApps, false).unwrap();
let content = fs::read_to_string(dir.join("outputs.tf")).unwrap();
assert!(
content.contains("output \"app_fqdn\""),
"outputs.tf must expose the app's FQDN: {content}"
);
assert!(
content.contains("output \"acr_login_server\""),
"outputs.tf must expose the ACR login server: {content}"
);
assert!(
content.contains("output \"migrate_job_name\""),
"outputs.tf must expose the migration job's name so CI can start it: {content}"
);
assert!(
content.contains("output \"app_name\""),
"outputs.tf must expose the sanitized Container App name so CI never has to \
hardcode it: {content}"
);
assert!(
!content.contains("{{"),
"outputs.tf must not contain unsubstituted template placeholders: {content}"
);
}
#[test]
fn outputs_tf_app_fqdn_is_the_stable_ingress_hostname_not_revision_specific() {
let tmp = TempDir::new().unwrap();
let dir = make_project(&tmp, "my-app");
init(&dir, "my-app", false, Target::AzureContainerApps, false).unwrap();
let content = fs::read_to_string(dir.join("outputs.tf")).unwrap();
let app_fqdn_block = content
.split("output \"app_fqdn\"")
.nth(1)
.and_then(|rest| rest.split("\n}").next())
.expect("outputs.tf must declare the app_fqdn output");
assert!(
app_fqdn_block.contains("local.app_fqdn"),
"app_fqdn must be local.app_fqdn, not a revision-specific attribute: \
{app_fqdn_block}"
);
assert!(
!app_fqdn_block.contains("latest_revision_fqdn"),
"app_fqdn must not use latest_revision_fqdn, which names a specific \
revision rather than the stable ingress hostname: {app_fqdn_block}"
);
}
#[test]
fn tfvars_example_documents_non_secret_defaults_without_committing_secrets() {
let tmp = TempDir::new().unwrap();
let dir = make_project(&tmp, "my-blog");
init(&dir, "my-blog", false, Target::AzureContainerApps, false).unwrap();
let content = fs::read_to_string(dir.join("terraform.tfvars.example")).unwrap();
assert!(
content.contains("my-blog"),
"terraform.tfvars.example must substitute the project name: {content}"
);
assert!(
content.contains("app_name"),
"terraform.tfvars.example must document app_name: {content}"
);
for line in content.lines() {
let trimmed = line.trim();
if trimmed.starts_with('#') || trimmed.is_empty() {
continue;
}
assert!(
!trimmed.starts_with("database_url") && !trimmed.starts_with("signing_secret"),
"terraform.tfvars.example must never assign a literal secret value: {trimmed}"
);
}
assert!(
!content.contains("{{"),
"terraform.tfvars.example must not contain unsubstituted placeholders: {content}"
);
}
#[test]
fn azure_workflow_triggers_on_tag_push_and_manual_dispatch() {
let tmp = TempDir::new().unwrap();
let dir = make_project(&tmp, "my-app");
init(&dir, "my-app", false, Target::AzureContainerApps, false).unwrap();
let content = fs::read_to_string(dir.join(".github/workflows/azure-deploy.yml")).unwrap();
assert!(
content.contains("tags:"),
"azure-deploy.yml must trigger on tag push: {content}"
);
assert!(
content.contains("workflow_dispatch:"),
"azure-deploy.yml must also support manual dispatch: {content}"
);
}
#[test]
fn azure_workflow_documents_resource_group_scope_rbac() {
let tmp = TempDir::new().unwrap();
let dir = make_project(&tmp, "my-app");
init(&dir, "my-app", false, Target::AzureContainerApps, false).unwrap();
let content = fs::read_to_string(dir.join(".github/workflows/azure-deploy.yml")).unwrap();
let header = content
.split("\nname: azure-deploy")
.next()
.expect("azure-deploy.yml must have a header comment before `name:`");
let header_lower = header.to_lowercase();
assert!(
header_lower.contains("resource-group") || header_lower.contains("resource group"),
"azure-deploy.yml's header must document resource-group-scoped Contributor \
access, covering both the app and the migration job: {header}"
);
assert!(
header_lower.contains("contributor"),
"azure-deploy.yml's header must mention the Contributor role: {header}"
);
}
#[test]
fn azure_workflow_builds_pushes_to_acr_and_deploys() {
let tmp = TempDir::new().unwrap();
let dir = make_project(&tmp, "my-app");
init(&dir, "my-app", false, Target::AzureContainerApps, false).unwrap();
let content = fs::read_to_string(dir.join(".github/workflows/azure-deploy.yml")).unwrap();
assert!(
content.contains("docker build") || content.contains("docker/build-push-action"),
"azure-deploy.yml must build the release image: {content}"
);
assert!(
content.contains("azurecr.io"),
"azure-deploy.yml must push to the Azure Container Registry: {content}"
);
assert!(
content.contains("run: bash azure-cutover.sh"),
"azure-deploy.yml must deploy the new image with the cutover script: {content}"
);
}
#[test]
fn azure_workflow_passes_git_provenance_build_args_to_docker() {
let tmp = TempDir::new().unwrap();
let dir = make_project(&tmp, "my-app");
init(&dir, "my-app", false, Target::AzureContainerApps, false).unwrap();
let content = fs::read_to_string(dir.join(".github/workflows/azure-deploy.yml")).unwrap();
for arg in [
"AUTUMN_BUILD_GIT_SHA",
"AUTUMN_BUILD_GIT_SHA_SHORT",
"AUTUMN_BUILD_GIT_BRANCH",
"AUTUMN_BUILD_GIT_DIRTY",
"AUTUMN_BUILD_TIMESTAMP",
] {
assert!(
content.contains(&format!("--build-arg {arg}=")),
"azure-deploy.yml's docker build must pass --build-arg {arg}: {content}"
);
}
}
#[test]
fn azure_workflow_updates_migration_job_image_before_starting_it() {
let tmp = TempDir::new().unwrap();
let dir = make_project(&tmp, "my-app");
init(&dir, "my-app", false, Target::AzureContainerApps, false).unwrap();
let content = fs::read_to_string(dir.join(".github/workflows/azure-deploy.yml")).unwrap();
let migration_step = content
.split("Run database migrations")
.nth(1)
.and_then(|rest| rest.split("- name:").next())
.expect("a 'Run database migrations' step must exist");
let update_pos = migration_step
.find("az containerapp job update \\")
.expect("the migration job's image must be persisted via `job update` first");
let start_pos = migration_step
.find("az containerapp job start \\")
.expect("`job start` must follow to actually run the now-updated template");
assert!(
update_pos < start_pos,
"the job's image must be updated BEFORE it's started: {migration_step}"
);
let update_block = &migration_step[update_pos..start_pos];
assert!(
update_block.contains("--image"),
"`job update` must be the one that carries --image: {update_block}"
);
let start_block_end = migration_step[start_pos..]
.find("--query name -o tsv)")
.map(|i| start_pos + i)
.expect("job start must capture the execution name via --query");
let start_block = &migration_step[start_pos..start_block_end];
assert!(
!start_block.contains("--image"),
"`job start` must not carry --image — that overrides (not merges) the \
execution template, dropping the command/secret env `job update` just set: \
{start_block}"
);
}
#[test]
fn azure_workflow_migration_poll_budget_exceeds_job_timeout() {
let tmp = TempDir::new().unwrap();
let dir = make_project(&tmp, "my-app");
init(&dir, "my-app", false, Target::AzureContainerApps, false).unwrap();
let main_tf = fs::read_to_string(dir.join("main.tf")).unwrap();
let job_timeout: u64 = main_tf
.split("replica_timeout_in_seconds = ")
.nth(1)
.and_then(|rest| rest.lines().next())
.and_then(|n| n.trim().parse().ok())
.expect("main.tf must declare the migration job's replica_timeout_in_seconds");
let workflow = fs::read_to_string(dir.join(".github/workflows/azure-deploy.yml")).unwrap();
let iterations: u64 = workflow
.split("seq 1 ")
.nth(1)
.and_then(|rest| rest.split([')', ' ']).next())
.and_then(|n| n.trim().parse().ok())
.expect("the migration poll loop must use `seq 1 N`");
let sleep_secs: u64 = workflow
.split("sleep ")
.nth(1)
.and_then(|rest| rest.lines().next())
.and_then(|s| s.trim().parse().ok())
.expect("the migration poll loop must sleep a fixed number of seconds per iteration");
let poll_budget = iterations * sleep_secs;
assert!(
poll_budget > job_timeout,
"the poll budget ({poll_budget}s = {iterations} x {sleep_secs}s) must exceed \
the migration job's own replica_timeout_in_seconds ({job_timeout}s), or a \
still-valid migration can be falsely reported as timed out: {workflow}"
);
}
#[test]
fn azure_workflow_never_hardcodes_credentials() {
let tmp = TempDir::new().unwrap();
let dir = make_project(&tmp, "my-app");
init(&dir, "my-app", false, Target::AzureContainerApps, false).unwrap();
let content = fs::read_to_string(dir.join(".github/workflows/azure-deploy.yml")).unwrap();
assert!(
content.contains("secrets."),
"azure-deploy.yml must source credentials from GitHub Actions secrets, \
never hardcode them: {content}"
);
}
#[test]
fn azure_workflow_runs_migrations_before_updating_the_app() {
let tmp = TempDir::new().unwrap();
let dir = make_project(&tmp, "my-app");
init(&dir, "my-app", false, Target::AzureContainerApps, false).unwrap();
let content = fs::read_to_string(dir.join(".github/workflows/azure-deploy.yml")).unwrap();
assert!(
content.contains("az containerapp job start"),
"azure-deploy.yml must start the one-shot migration job: {content}"
);
assert!(
content.contains("AZURE_MIGRATE_JOB_NAME"),
"azure-deploy.yml must reference the migration job by its Terraform output: {content}"
);
let job_pos = content
.find("az containerapp job start \\")
.expect("migration job start must be present");
let deploy_pos = content
.find("run: bash azure-cutover.sh")
.expect("deploy step must be present");
assert!(
job_pos < deploy_pos,
"the migration job must run BEFORE the app is updated to the new image: {content}"
);
}
#[test]
fn azure_workflow_aborts_deploy_on_migration_failure() {
let tmp = TempDir::new().unwrap();
let dir = make_project(&tmp, "my-app");
init(&dir, "my-app", false, Target::AzureContainerApps, false).unwrap();
let content = fs::read_to_string(dir.join(".github/workflows/azure-deploy.yml")).unwrap();
let migration_step = content
.split("Run database migrations")
.nth(1)
.and_then(|rest| rest.split("- name:").next())
.expect("a 'Run database migrations' step must exist");
assert!(
migration_step.contains("set -euo pipefail") || migration_step.contains("exit 1"),
"the migration step must fail the job (and therefore never reach the deploy \
step) when the job execution doesn't succeed: {migration_step}"
);
}
#[test]
fn azure_workflow_sources_app_name_from_terraform_not_hardcoded() {
let tmp = TempDir::new().unwrap();
let dir = make_project(&tmp, "My_Test_App");
init(
&dir,
"My_Test_App",
false,
Target::AzureContainerApps,
false,
)
.unwrap();
let content = fs::read_to_string(dir.join(".github/workflows/azure-deploy.yml")).unwrap();
assert!(
content.contains("AZURE_APP_NAME"),
"azure-deploy.yml must reference AZURE_APP_NAME: {content}"
);
assert!(
content.contains("vars.AZURE_APP_NAME"),
"AZURE_APP_NAME must be sourced from a repository variable \
(terraform output app_name), not hardcoded: {content}"
);
assert!(
!content.contains("My_Test_App") && !content.contains("my-test-app"),
"azure-deploy.yml must not bake in any form of the project name as an \
Azure resource identifier: {content}"
);
assert!(
!content.contains("{{azure_app_name}}") && !content.contains("{{project_name}}"),
"azure-deploy.yml must not contain unsubstituted placeholders: {content}"
);
}
#[test]
fn azure_workflow_sanitizes_ref_name_for_docker_tag() {
let tmp = TempDir::new().unwrap();
let dir = make_project(&tmp, "my-app");
init(&dir, "my-app", false, Target::AzureContainerApps, false).unwrap();
let content = fs::read_to_string(dir.join(".github/workflows/azure-deploy.yml")).unwrap();
assert!(
content.contains("tr -c 'A-Za-z0-9_.-' '-'"),
"azure-deploy.yml must map every character outside Docker's tag charset \
(not just \"/\") to \"-\": {content}"
);
assert!(
!content.contains(":${GITHUB_REF_NAME}") && !content.contains(":$GITHUB_REF_NAME"),
"no docker/az command may use the raw, unsanitized ref as an image tag: {content}"
);
}
#[test]
fn azure_workflow_image_tag_never_starts_with_invalid_character() {
let tmp = TempDir::new().unwrap();
let dir = make_project(&tmp, "my-app");
init(&dir, "my-app", false, Target::AzureContainerApps, false).unwrap();
let content = fs::read_to_string(dir.join(".github/workflows/azure-deploy.yml")).unwrap();
assert!(
content.contains("sed -E 's/^[.-]+//'"),
"azure-deploy.yml must strip a leading \".\"/\"-\" left by sanitization: {content}"
);
assert!(
content.contains("[ -z \"$SAFE_REF\" ] && SAFE_REF=\"build\""),
"azure-deploy.yml must fall back to a valid literal if stripping leaves an \
empty ref (a ref made entirely of invalid characters): {content}"
);
}
#[test]
fn azure_workflow_image_tag_is_unique_per_execution() {
let tmp = TempDir::new().unwrap();
let dir = make_project(&tmp, "my-app");
init(&dir, "my-app", false, Target::AzureContainerApps, false).unwrap();
let content = fs::read_to_string(dir.join(".github/workflows/azure-deploy.yml")).unwrap();
assert!(
content.contains("${GITHUB_SHA:0:12}"),
"the computed image tag must include the commit SHA: {content}"
);
assert!(
content.contains("${GITHUB_RUN_ID}") && content.contains("${GITHUB_RUN_ATTEMPT}"),
"the computed image tag must also include the run ID and run attempt, so \
a re-run of the same trigger (same ref, same commit) never collides with \
the original run's tag: {content}"
);
assert!(
content.contains("cut -c1-80"),
"the sanitized ref portion must leave headroom for the rest of the tag \
within Docker's 128-character limit: {content}"
);
}
#[test]
fn azure_workflow_serializes_overlapping_runs() {
let tmp = TempDir::new().unwrap();
let dir = make_project(&tmp, "my-app");
init(&dir, "my-app", false, Target::AzureContainerApps, false).unwrap();
let content = fs::read_to_string(dir.join(".github/workflows/azure-deploy.yml")).unwrap();
assert!(
content.contains("concurrency:"),
"azure-deploy.yml must define a concurrency group so overlapping runs \
queue instead of racing: {content}"
);
assert!(
content.contains("cancel-in-progress: false"),
"cancel-in-progress must be false — killing a run mid-migration or \
mid-cutover is worse than making the next run wait: {content}"
);
}
#[test]
fn azure_workflow_guards_against_superseded_run_before_migrating() {
let tmp = TempDir::new().unwrap();
let dir = make_project(&tmp, "my-app");
init(&dir, "my-app", false, Target::AzureContainerApps, false).unwrap();
let content = fs::read_to_string(dir.join(".github/workflows/azure-deploy.yml")).unwrap();
assert!(
content.contains("actions: read"),
"azure-deploy.yml must grant actions: read to query other workflow runs: {content}"
);
assert!(
content.contains("run_number > ${{ github.run_number }}"),
"the guard must compare against other runs' run_number, not just whether \
this run's own ref has moved: {content}"
);
let guard_step = content
.split("Abort if a newer run of this workflow exists")
.nth(1)
.and_then(|rest| rest.split("- name:").next())
.expect("the run_number staleness guard step must be present");
assert!(
!guard_step.contains("in_progress")
&& !guard_step.contains("queued")
&& !guard_step.contains("conclusion"),
"the guard must not filter by status or conclusion — any newer run_number \
must disqualify this run regardless of outcome: {guard_step}"
);
let guard_pos = content
.find("gh api")
.expect("the run_number staleness guard must be present");
let job_pos = content
.find("az containerapp job start \\")
.expect("migration job start must be present");
let deploy_pos = content
.find("run: bash azure-cutover.sh")
.expect("deploy step must be present");
assert!(
guard_pos < job_pos && job_pos < deploy_pos,
"the staleness guard must run BEFORE migration, which must run BEFORE \
deploy: {content}"
);
}
#[test]
fn init_without_force_errors_if_azure_workflow_file_exists() {
let tmp = TempDir::new().unwrap();
let dir = make_project(&tmp, "my-app");
fs::create_dir_all(dir.join(".github/workflows")).unwrap();
fs::write(dir.join(".github/workflows/azure-deploy.yml"), "existing").unwrap();
let err = init(&dir, "my-app", false, Target::AzureContainerApps, false).unwrap_err();
assert!(matches!(err, ReleaseError::FileExists(_)));
}
#[test]
fn azure_target_adds_terraform_gitignore_entries_when_missing() {
let tmp = TempDir::new().unwrap();
let dir = make_project(&tmp, "my-app");
init(&dir, "my-app", false, Target::AzureContainerApps, false).unwrap();
let content = fs::read_to_string(dir.join(".gitignore")).unwrap();
for line in TERRAFORM_GITIGNORE_ENTRIES {
assert!(
content.lines().any(|l| l.trim() == *line),
"azure target must add `{line}` to .gitignore: {content}"
);
}
}
#[test]
fn azure_target_gitignore_merge_preserves_existing_lines_and_is_idempotent() {
let tmp = TempDir::new().unwrap();
let dir = make_project(&tmp, "my-app");
fs::write(dir.join(".gitignore"), "/target\n.env\n").unwrap();
init(&dir, "my-app", false, Target::AzureContainerApps, false).unwrap();
let after_first = fs::read_to_string(dir.join(".gitignore")).unwrap();
assert!(after_first.contains("/target"), "{after_first}");
assert!(after_first.contains(".env"), "{after_first}");
for line in TERRAFORM_GITIGNORE_ENTRIES {
assert!(
after_first.lines().any(|l| l.trim() == *line),
"{after_first}"
);
}
init(&dir, "my-app", true, Target::AzureContainerApps, false).unwrap();
let after_second = fs::read_to_string(dir.join(".gitignore")).unwrap();
for line in TERRAFORM_GITIGNORE_ENTRIES {
let count = after_second.lines().filter(|l| l.trim() == *line).count();
assert_eq!(
count, 1,
"`{line}` must appear exactly once: {after_second}"
);
}
}
#[test]
fn azure_target_reasserts_a_gitignore_entry_negated_after_its_earlier_occurrence() {
let tmp = TempDir::new().unwrap();
let dir = make_project(&tmp, "my-app");
fs::write(
dir.join(".gitignore"),
"terraform.tfvars\nsome-other-line\n!terraform.tfvars\n",
)
.unwrap();
init(&dir, "my-app", false, Target::AzureContainerApps, false).unwrap();
let content = fs::read_to_string(dir.join(".gitignore")).unwrap();
assert!(content.contains("!terraform.tfvars"), "{content}");
let after_negation = content.find("!terraform.tfvars").unwrap() + "!terraform.tfvars".len();
assert!(
content[after_negation..].contains("terraform.tfvars"),
"terraform.tfvars must be re-added after the negation that defeated it: {content}"
);
}
#[test]
fn azure_target_reasserts_entry_after_a_broader_wildcard_negation() {
let tmp = TempDir::new().unwrap();
let dir = make_project(&tmp, "my-app");
fs::write(dir.join(".gitignore"), "terraform.tfvars\n!*.tfvars\n").unwrap();
init(&dir, "my-app", false, Target::AzureContainerApps, false).unwrap();
let content = fs::read_to_string(dir.join(".gitignore")).unwrap();
let after_negation = content.find("!*.tfvars").unwrap() + "!*.tfvars".len();
assert!(
content[after_negation..].contains("terraform.tfvars"),
"terraform.tfvars must be re-added after a broader wildcard negation too: \
{content}"
);
}
#[test]
fn azure_target_propagates_gitignore_read_errors_instead_of_clobbering_it() {
let tmp = TempDir::new().unwrap();
let dir = make_project(&tmp, "my-app");
fs::write(dir.join(".gitignore"), [0xFF, 0xFE, 0x00, 0xFF]).unwrap();
let err = init(&dir, "my-app", false, Target::AzureContainerApps, false).unwrap_err();
assert!(
matches!(err, ReleaseError::Io(_)),
"an unreadable .gitignore must surface as an I/O error, not be silently \
replaced: {err}"
);
let raw = fs::read(dir.join(".gitignore")).unwrap();
assert_eq!(raw, vec![0xFF, 0xFE, 0x00, 0xFF]);
}
#[test]
fn azure_target_gitignore_failure_leaves_no_partial_scaffold() {
let tmp = TempDir::new().unwrap();
let dir = make_project(&tmp, "my-app");
fs::write(dir.join(".gitignore"), [0xFF, 0xFE, 0x00, 0xFF]).unwrap();
init(&dir, "my-app", false, Target::AzureContainerApps, false).unwrap_err();
for name in [
"Dockerfile",
".dockerignore",
"autumn.production.toml.example",
"main.tf",
"variables.tf",
"outputs.tf",
"terraform.tfvars.example",
".github/workflows/azure-deploy.yml",
] {
assert!(
!dir.join(name).exists(),
"{name} must not exist after init() fails on the .gitignore merge \
(found a partial scaffold, which blocks retrying without --force)"
);
}
}
#[test]
fn non_azure_targets_do_not_modify_gitignore() {
let tmp = TempDir::new().unwrap();
let dir = make_project(&tmp, "my-app");
fs::write(dir.join(".gitignore"), "/target\n").unwrap();
init(&dir, "my-app", false, Target::DockerCompose, false).unwrap();
let content = fs::read_to_string(dir.join(".gitignore")).unwrap();
assert_eq!(
content, "/target\n",
"non-azure targets must leave .gitignore untouched"
);
}
#[test]
fn init_with_force_overwrites_nested_azure_workflow_file() {
let tmp = TempDir::new().unwrap();
let dir = make_project(&tmp, "my-app");
fs::create_dir_all(dir.join(".github/workflows")).unwrap();
fs::write(
dir.join(".github/workflows/azure-deploy.yml"),
"old content",
)
.unwrap();
init(&dir, "my-app", true, Target::AzureContainerApps, false).unwrap();
let content = fs::read_to_string(dir.join(".github/workflows/azure-deploy.yml")).unwrap();
assert_ne!(content, "old content");
}
#[test]
fn docker_compose_default_omits_worker_service() {
let tmp = TempDir::new().unwrap();
let dir = make_project(&tmp, "my-app");
init(&dir, "my-app", false, Target::DockerCompose, false).unwrap();
let content = fs::read_to_string(dir.join("docker-compose.yml")).unwrap();
assert!(
!content.contains("worker:"),
"default docker-compose.yml must NOT scaffold a worker service: {content}"
);
assert!(
!content.contains("AUTUMN_ROLE"),
"default (combined) docker-compose.yml must not pin a process role: {content}"
);
assert!(
!content.contains("{{"),
"docker-compose.yml must not contain unsubstituted placeholders: {content}"
);
}
#[test]
fn docker_compose_split_workers_emits_worker_service() {
let tmp = TempDir::new().unwrap();
let dir = make_project(&tmp, "my-app");
init(&dir, "my-app", false, Target::DockerCompose, true).unwrap();
let content = fs::read_to_string(dir.join("docker-compose.yml")).unwrap();
assert!(
content.contains("worker:"),
"--split-workers must scaffold a worker service: {content}"
);
assert_eq!(
content.matches("build: .").count(),
3,
"app, worker, and migrate services must all build the same image: {content}"
);
assert!(
content.contains("AUTUMN_ROLE: worker"),
"worker service must run the worker role: {content}"
);
assert!(
content.contains("AUTUMN_ROLE: web"),
"app service must run the web role when split: {content}"
);
assert_eq!(
content.matches("AUTUMN_JOBS__BACKEND: postgres").count(),
2,
"both app and worker must share the durable postgres jobs backend: {content}"
);
assert!(
content.contains("condition: service_completed_successfully"),
"worker must wait for the one-shot migration job: {content}"
);
assert!(
content.contains(
r#"AUTUMN_SECURITY__SIGNING_SECRET: "${AUTUMN_SECURITY__SIGNING_SECRET:?set it first}""#
),
"worker must pass the required signing secret like the app service: {content}"
);
assert!(
!content.contains("{{"),
"split docker-compose.yml must not contain unsubstituted placeholders: {content}"
);
assert!(
content.contains("my-app_prod"),
"worker DB URL must substitute the project name: {content}"
);
}
#[test]
fn split_workers_only_affects_docker_compose_output() {
let default_dockerfile = render(templates::DOCKERFILE, "my-app", false, false);
let split_dockerfile = render(templates::DOCKERFILE, "my-app", false, true);
assert_eq!(
default_dockerfile, split_dockerfile,
"--split-workers must not change the Dockerfile"
);
}
#[test]
fn workspace_root_gives_actionable_hint() {
let tmp = TempDir::new().unwrap();
let dir = tmp.path().to_path_buf();
fs::write(
dir.join("Cargo.toml"),
"[workspace]\nmembers = [\"my-app\"]\n",
)
.unwrap();
let err = read_project_name(&dir).unwrap_err();
let msg = err.to_string();
assert!(
msg.contains("workspace"),
"error must mention workspace: {msg}"
);
assert!(
msg.contains("member"),
"error must hint to run from a member directory: {msg}"
);
}
#[test]
fn nested_workflow_relocation_warning_is_silent_at_the_git_root() {
let tmp = TempDir::new().unwrap();
let dir = tmp.path().to_path_buf();
fs::create_dir_all(dir.join(".git")).unwrap();
assert_eq!(
nested_workflow_relocation_warning(&dir, ".github/workflows/azure-deploy.yml"),
None
);
}
#[test]
fn nested_workflow_relocation_warning_flags_a_nested_workspace_member() {
let tmp = TempDir::new().unwrap();
let git_root = tmp.path().to_path_buf();
fs::create_dir_all(git_root.join(".git")).unwrap();
let member_dir = git_root.join("examples").join("blog");
fs::create_dir_all(&member_dir).unwrap();
let warning =
nested_workflow_relocation_warning(&member_dir, ".github/workflows/azure-deploy.yml")
.expect("a workflow nested under a workspace member must be flagged");
assert!(
warning.contains(&git_root.display().to_string()),
"the warning must name the actual git root: {warning}"
);
assert!(
warning.contains("working-directory: examples/blog"),
"the warning must give the exact working-directory override needed so \
the relocated workflow's docker build step still finds this crate's \
Dockerfile: {warning}"
);
assert!(
warning.contains("POSTURE_MANIFEST: examples/blog/security-posture.json")
&& !warning.contains("env:\n POSTURE_MANIFEST"),
"the warning must give the manifest path from the git root, because \
`hashFiles` and `subject-path` ignore working-directory: {warning}"
);
}
#[test]
fn production_config_disables_startup_migrations_by_default() {
let tmp = TempDir::new().unwrap();
let dir = make_project(&tmp, "my-app");
init(&dir, "my-app", false, Target::Default, false).unwrap();
let content = fs::read_to_string(dir.join("autumn.production.toml.example")).unwrap();
assert!(
content.contains("auto_migrate_in_production = false"),
"production config must leave web replicas out of migration ownership"
);
assert!(
content.contains("primary_url"),
"production config must name the primary/write database role"
);
assert!(
content.contains("autumn migrate"),
"production config must point operators at the one-shot migration command"
);
}
#[test]
fn parse_target_fly() {
assert_eq!("fly".parse::<Target>().unwrap(), Target::Fly);
}
#[test]
fn parse_target_docker_compose() {
assert_eq!(
"docker-compose".parse::<Target>().unwrap(),
Target::DockerCompose
);
}
#[test]
fn parse_target_azure_container_apps() {
assert_eq!(
"azure-container-apps".parse::<Target>().unwrap(),
Target::AzureContainerApps
);
}
#[test]
fn parse_target_aws_app_runner() {
assert_eq!(
"aws-app-runner".parse::<Target>().unwrap(),
Target::AwsAppRunner
);
}
#[test]
fn parse_target_aws_ecs() {
assert_eq!("aws-ecs".parse::<Target>().unwrap(), Target::AwsEcs);
}
#[test]
fn parse_target_gcp_cloud_run() {
assert_eq!(
"gcp-cloud-run".parse::<Target>().unwrap(),
Target::GcpCloudRun
);
}
#[test]
fn parse_target_unknown_is_error() {
assert!("nomad".parse::<Target>().is_err());
}
#[test]
fn parse_target_unknown_error_mentions_all_targets() {
let err = "nomad".parse::<Target>().unwrap_err();
for name in [
"fly",
"docker-compose",
"azure-container-apps",
"aws-app-runner",
"aws-ecs",
"gcp-cloud-run",
"kubernetes",
] {
assert!(err.contains(name), "error must mention '{name}': {err}");
}
}
#[test]
fn aws_app_runner_target_creates_all_expected_files() {
let tmp = TempDir::new().unwrap();
let dir = make_project(&tmp, "my-app");
init(&dir, "my-app", false, Target::AwsAppRunner, false).unwrap();
for name in [
"main.tf",
"variables.tf",
"outputs.tf",
"terraform.tfvars.example",
] {
assert!(
dir.join(name).is_file(),
"{name} must be created for --target=aws-app-runner"
);
}
assert!(dir.join("Dockerfile").is_file());
assert!(dir.join(".dockerignore").is_file());
assert!(dir.join("autumn.production.toml.example").is_file());
}
#[test]
fn aws_app_runner_target_does_not_create_a_workflow() {
let tmp = TempDir::new().unwrap();
let dir = make_project(&tmp, "my-app");
let files = init(&dir, "my-app", false, Target::AwsAppRunner, false).unwrap();
assert!(
!files.iter().any(|f| f.contains(".github/workflows")),
"aws-app-runner must not emit a CI workflow: {files:?}"
);
assert!(!dir.join(".github").exists());
}
#[test]
fn default_target_does_not_create_aws_app_runner_files() {
let tmp = TempDir::new().unwrap();
let dir = make_project(&tmp, "my-app");
init(&dir, "my-app", false, Target::Default, false).unwrap();
for name in [
"main.tf",
"variables.tf",
"outputs.tf",
"terraform.tfvars.example",
] {
assert!(
!dir.join(name).exists(),
"{name} must NOT be created for the default target"
);
}
}
#[test]
fn aws_app_runner_main_tf_has_ecr_and_app_runner_service() {
let tmp = TempDir::new().unwrap();
let dir = make_project(&tmp, "my-app");
init(&dir, "my-app", false, Target::AwsAppRunner, false).unwrap();
let content = fs::read_to_string(dir.join("main.tf")).unwrap();
assert!(
content.contains("aws_ecr_repository"),
"main.tf must provision an ECR repository: {content}"
);
assert!(
content.contains("resource \"aws_apprunner_service\""),
"main.tf must provision the App Runner service: {content}"
);
}
#[test]
fn aws_app_runner_main_tf_has_vpc_and_vpc_connector() {
let tmp = TempDir::new().unwrap();
let dir = make_project(&tmp, "my-app");
init(&dir, "my-app", false, Target::AwsAppRunner, false).unwrap();
let content = fs::read_to_string(dir.join("main.tf")).unwrap();
assert!(
content.contains("resource \"aws_vpc\""),
"main.tf must provision a VPC: {content}"
);
assert!(
content.contains("aws_apprunner_vpc_connector"),
"main.tf must provision an App Runner VPC connector so App Runner can reach RDS \
privately: {content}"
);
}
#[test]
fn aws_app_runner_main_tf_has_nat_gateway_for_general_egress() {
let tmp = TempDir::new().unwrap();
let dir = make_project(&tmp, "my-app");
init(&dir, "my-app", false, Target::AwsAppRunner, false).unwrap();
let content = fs::read_to_string(dir.join("main.tf")).unwrap();
assert!(
content.contains("aws_nat_gateway"),
"main.tf must provision a NAT gateway so the app's own outbound traffic \
(not just RDS-bound traffic) keeps working once egress_type = \"VPC\": {content}"
);
assert!(
content.contains("egress_type = \"VPC\"")
|| content.contains("egress_type = \"VPC\""),
"main.tf must route App Runner egress through the VPC connector: {content}"
);
}
#[test]
fn aws_app_runner_main_tf_has_rds_postgres() {
let tmp = TempDir::new().unwrap();
let dir = make_project(&tmp, "my-app");
init(&dir, "my-app", false, Target::AwsAppRunner, false).unwrap();
let content = fs::read_to_string(dir.join("main.tf")).unwrap();
assert!(
content.contains("resource \"aws_db_instance\""),
"main.tf must provision an RDS Postgres instance: {content}"
);
assert!(
content.contains("engine = \"postgres\"")
|| content.contains("engine = \"postgres\""),
"the RDS instance must use the postgres engine: {content}"
);
assert!(
content.contains("publicly_accessible = false")
|| content.contains("publicly_accessible = false"),
"RDS must not be publicly accessible: {content}"
);
}
#[test]
fn aws_app_runner_provisions_a_reachable_one_shot_migration_task() {
let tmp = TempDir::new().unwrap();
let dir = make_project(&tmp, "my-app");
init(&dir, "my-app", false, Target::AwsAppRunner, false).unwrap();
let content = fs::read_to_string(dir.join("main.tf")).unwrap();
for resource in [
"resource \"aws_ecs_cluster\" \"migrate\"",
"resource \"aws_ecs_task_definition\" \"migrate\"",
"resource \"aws_iam_role\" \"migrate_execution\"",
"resource \"aws_iam_role\" \"migrate_task\"",
] {
assert!(
content.contains(resource),
"main.tf must declare {resource} so the migration task has somewhere \
to run: {content}"
);
}
let task_def_block = content
.split("resource \"aws_ecs_task_definition\" \"migrate\"")
.nth(1)
.expect("main.tf must declare the migrate task definition");
assert!(
task_def_block.contains("autumn migrate"),
"the migrate task must actually run `autumn migrate`: {task_def_block}"
);
assert!(
task_def_block.contains("aws_secretsmanager_secret.database_url.arn"),
"the migrate task must be able to read the derived database URL: {task_def_block}"
);
}
#[test]
fn aws_app_runner_migration_task_reuses_the_vpc_connector_security_group() {
let tmp = TempDir::new().unwrap();
let dir = make_project(&tmp, "my-app");
init(&dir, "my-app", false, Target::AwsAppRunner, false).unwrap();
let outputs = fs::read_to_string(dir.join("outputs.tf")).unwrap();
for name in [
"migrate_cluster_name",
"migrate_task_family",
"private_subnet_ids",
"vpc_connector_security_group_id",
] {
assert!(
outputs.contains(&format!("output \"{name}\"")),
"outputs.tf must declare output \"{name}\" for the migration task's \
`aws ecs run-task` call: {outputs}"
);
}
assert!(
outputs.contains("aws_security_group.vpc_connector.id"),
"vpc_connector_security_group_id must reuse the SAME security group RDS's \
ingress rule trusts, not a new one: {outputs}"
);
}
#[test]
fn aws_app_runner_main_tf_derives_database_url_not_a_variable() {
let tmp = TempDir::new().unwrap();
let dir = make_project(&tmp, "my-app");
init(&dir, "my-app", false, Target::AwsAppRunner, false).unwrap();
let main_tf = fs::read_to_string(dir.join("main.tf")).unwrap();
let variables_tf = fs::read_to_string(dir.join("variables.tf")).unwrap();
assert!(
main_tf.contains("AUTUMN_DATABASE__PRIMARY_URL"),
"main.tf must wire the primary DB URL: {main_tf}"
);
assert!(
main_tf.contains("aws_db_instance.this.address"),
"the database URL must be derived from the RDS instance this apply creates, \
not pre-computed: {main_tf}"
);
assert!(
!variables_tf.contains("variable \"database_url\""),
"there must be no database_url variable — a single apply must be able to \
derive it end-to-end: {variables_tf}"
);
}
#[test]
fn aws_app_runner_secrets_have_no_default_and_are_sensitive() {
let tmp = TempDir::new().unwrap();
let dir = make_project(&tmp, "my-app");
init(&dir, "my-app", false, Target::AwsAppRunner, false).unwrap();
let content = fs::read_to_string(dir.join("variables.tf")).unwrap();
for var in ["database_admin_password", "signing_secret"] {
let block = content
.split(&format!("variable \"{var}\""))
.nth(1)
.and_then(|rest| rest.split('}').next())
.unwrap_or_else(|| {
panic!("variables.tf must declare variable \"{var}\": {content}")
});
assert!(
block.contains("sensitive = true") || block.contains("sensitive = true"),
"{var} must be sensitive: {block}"
);
assert!(
!block.contains("default"),
"{var} must not have a default (never commit a real secret value): {block}"
);
}
}
#[test]
fn aws_app_runner_no_committed_secret_literals() {
let tmp = TempDir::new().unwrap();
let dir = make_project(&tmp, "my-app");
init(&dir, "my-app", false, Target::AwsAppRunner, false).unwrap();
for name in ["main.tf", "variables.tf", "terraform.tfvars.example"] {
let content = fs::read_to_string(dir.join(name)).unwrap();
assert!(
!content.to_lowercase().contains("password =\""),
"{name} must not contain a literal password assignment: {content}"
);
}
}
#[test]
fn aws_app_runner_service_bootstraps_from_public_placeholder_and_ignores_drift() {
let tmp = TempDir::new().unwrap();
let dir = make_project(&tmp, "my-app");
init(&dir, "my-app", false, Target::AwsAppRunner, false).unwrap();
let content = fs::read_to_string(dir.join("main.tf")).unwrap();
let service_block = content
.split("resource \"aws_apprunner_service\" \"this\"")
.nth(1)
.expect("main.tf must declare the App Runner service");
assert!(
service_block.contains("var.bootstrap_image"),
"the App Runner service must start from the bootstrap placeholder image: {service_block}"
);
assert!(
service_block.contains(
"ignore_changes = [source_configuration, instance_configuration[0].instance_role_arn, health_check_configuration]"
),
"the App Runner service must ignore source_configuration drift once CI/the manual \
walkthrough deploys the real image: {service_block}"
);
}
#[test]
fn aws_app_runner_instance_sizing_vars_stay_terraform_managed_after_cutover() {
let tmp = TempDir::new().unwrap();
let dir = make_project(&tmp, "my-app");
init(&dir, "my-app", false, Target::AwsAppRunner, false).unwrap();
let content = fs::read_to_string(dir.join("main.tf")).unwrap();
let service_block = content
.split("resource \"aws_apprunner_service\" \"this\"")
.nth(1)
.expect("main.tf must declare the App Runner service");
let lifecycle_block = service_block
.split("lifecycle {")
.nth(1)
.expect("the App Runner service must declare a lifecycle block");
assert!(
lifecycle_block.contains("ignore_changes = [source_configuration, instance_configuration[0].instance_role_arn, health_check_configuration]"),
"only instance_role_arn must be ignored within instance_configuration, so \
var.instance_cpu/var.instance_memory changes still reach AWS on \
`terraform apply`: {lifecycle_block}"
);
assert!(
!lifecycle_block.contains("instance_configuration,")
&& !lifecycle_block.contains("instance_configuration]"),
"instance_configuration must not be ignored as a whole block — that would \
also suppress cpu/memory resizing: {lifecycle_block}"
);
}
#[test]
fn aws_app_runner_instance_sizing_vars_are_declared_and_wired() {
let tmp = TempDir::new().unwrap();
let dir = make_project(&tmp, "my-app");
init(&dir, "my-app", false, Target::AwsAppRunner, false).unwrap();
let variables = fs::read_to_string(dir.join("variables.tf")).unwrap();
assert!(
variables.contains("variable \"instance_cpu\""),
"variables.tf must declare instance_cpu: {variables}"
);
assert!(
variables.contains("variable \"instance_memory\""),
"variables.tf must declare instance_memory: {variables}"
);
let main_tf = fs::read_to_string(dir.join("main.tf")).unwrap();
let instance_block = main_tf
.split("instance_configuration {")
.nth(1)
.and_then(|block| block.split('}').next())
.expect("service must declare instance_configuration");
assert!(
instance_block.contains("cpu = var.instance_cpu"),
"instance_configuration must wire cpu from var.instance_cpu: {instance_block}"
);
assert!(
instance_block.contains("memory = var.instance_memory"),
"instance_configuration must wire memory from var.instance_memory: {instance_block}"
);
}
#[test]
fn aws_app_runner_bootstrap_health_check_matches_the_placeholder_not_the_real_app() {
let tmp = TempDir::new().unwrap();
let dir = make_project(&tmp, "my-app");
init(&dir, "my-app", false, Target::AwsAppRunner, false).unwrap();
let content = fs::read_to_string(dir.join("main.tf")).unwrap();
assert!(
content.contains("port = \"80\""),
"the bootstrap image_configuration must declare port 80 (nginx's real default), \
not 3000: {content}"
);
let health_check_block = content
.split("health_check_configuration {")
.nth(1)
.and_then(|rest| rest.split('}').next())
.expect("main.tf must declare health_check_configuration");
assert!(
health_check_block.contains("path = \"/\""),
"the bootstrap health check must probe \"/\" (nginx's default 200 response), \
not \"/ready\": {health_check_block}"
);
}
#[test]
fn aws_app_runner_ignores_health_check_drift_after_cutover_restores_it() {
let tmp = TempDir::new().unwrap();
let dir = make_project(&tmp, "my-app");
init(&dir, "my-app", false, Target::AwsAppRunner, false).unwrap();
let content = fs::read_to_string(dir.join("main.tf")).unwrap();
let service_block = content
.split("resource \"aws_apprunner_service\" \"this\"")
.nth(1)
.expect("main.tf must declare the App Runner service");
assert!(
service_block.contains(
"ignore_changes = [source_configuration, instance_configuration[0].instance_role_arn, health_check_configuration]"
),
"the App Runner service must ignore health_check_configuration drift alongside \
source_configuration: {service_block}"
);
}
#[test]
fn aws_app_runner_instance_role_secrets_policy_is_scoped_not_wildcard() {
let tmp = TempDir::new().unwrap();
let dir = make_project(&tmp, "my-app");
init(&dir, "my-app", false, Target::AwsAppRunner, false).unwrap();
let content = fs::read_to_string(dir.join("main.tf")).unwrap();
let policy_block = content
.split("data \"aws_iam_policy_document\" \"apprunner_instance_secrets\"")
.nth(1)
.and_then(|rest| rest.split("\n}\n").next())
.expect("main.tf must declare the instance role's secrets policy document");
assert!(
!policy_block.contains("resources = [\"*\"]") && !policy_block.contains("\"*\""),
"the instance role's secrets access must be scoped to specific secret ARNs, \
never a wildcard: {policy_block}"
);
assert!(
policy_block.contains("aws_secretsmanager_secret.database_url.arn"),
"the secrets policy must reference the database_url secret ARN: {policy_block}"
);
}
#[test]
fn aws_app_runner_public_bootstrap_has_no_production_secrets_or_runtime_role() {
let tmp = TempDir::new().unwrap();
let dir = make_project(&tmp, "my-app");
init(&dir, "my-app", false, Target::AwsAppRunner, false).unwrap();
let content = fs::read_to_string(dir.join("main.tf")).unwrap();
let service = content
.split("resource \"aws_apprunner_service\" \"this\"")
.nth(1)
.expect("main.tf must declare the App Runner service");
assert!(!service.contains("runtime_environment_secrets ="));
let instance = service
.split("instance_configuration {")
.nth(1)
.and_then(|block| block.split('}').next())
.expect("service must configure its bootstrap instance size");
assert!(!instance.contains("instance_role_arn"));
let outputs = fs::read_to_string(dir.join("outputs.tf")).unwrap();
assert!(outputs.contains("output \"apprunner_instance_role_arn\""));
}
#[test]
fn aws_app_runner_public_bootstrap_does_not_wait_for_or_access_secrets() {
let tmp = TempDir::new().unwrap();
let dir = make_project(&tmp, "my-app");
init(&dir, "my-app", false, Target::AwsAppRunner, false).unwrap();
let content = fs::read_to_string(dir.join("main.tf")).unwrap();
let service_block = content
.split("resource \"aws_apprunner_service\" \"this\"")
.nth(1)
.expect("main.tf must declare aws_apprunner_service.this");
assert!(!service_block.contains("aws_iam_role_policy.apprunner_instance_secrets"));
assert!(!service_block.contains("aws_secretsmanager_secret_version.database_url"));
assert!(!service_block.contains("aws_secretsmanager_secret_version.signing_secret"));
}
#[test]
fn aws_app_runner_names_are_sanitized_for_underscored_uppercase_project_name() {
let tmp = TempDir::new().unwrap();
let dir = make_project(&tmp, "My_Test_App");
init(&dir, "My_Test_App", false, Target::AwsAppRunner, false).unwrap();
let content = fs::read_to_string(dir.join("main.tf")).unwrap();
assert!(
content.contains("lower(replace(var.app_name"),
"main.tf must lowercase and sanitize app_name for AWS resource names: {content}"
);
}
#[test]
fn aws_app_runner_postgres_database_name_avoids_reserved_names() {
let tmp = TempDir::new().unwrap();
let dir = make_project(&tmp, "my-app");
init(&dir, "my-app", false, Target::AwsAppRunner, false).unwrap();
let content = fs::read_to_string(dir.join("main.tf")).unwrap();
for reserved in ["postgres", "rdsadmin", "template0", "template1"] {
assert!(
content.contains(&format!("\"{reserved}\"")),
"the reserved-name guard must list {reserved:?}: {content}"
);
}
}
#[test]
fn aws_app_runner_target_adds_terraform_gitignore_entries() {
let tmp = TempDir::new().unwrap();
let dir = make_project(&tmp, "my-app");
init(&dir, "my-app", false, Target::AwsAppRunner, false).unwrap();
let content = fs::read_to_string(dir.join(".gitignore")).unwrap();
for line in TERRAFORM_GITIGNORE_ENTRIES {
assert!(content.lines().any(|l| l.trim() == *line), "{content}");
}
}
#[test]
fn aws_app_runner_dockerignore_excludes_terraform_state() {
let tmp = TempDir::new().unwrap();
let dir = make_project(&tmp, "my-app");
init(&dir, "my-app", false, Target::AwsAppRunner, false).unwrap();
let content = fs::read_to_string(dir.join(".dockerignore")).unwrap();
for pattern in [".terraform/", "*.tfstate", "terraform.tfvars"] {
assert!(content.contains(pattern), "{content}");
}
}
#[test]
fn aws_app_runner_outputs_tf_declares_expected_outputs_with_no_unsubstituted_placeholders() {
let tmp = TempDir::new().unwrap();
let dir = make_project(&tmp, "my-app");
init(&dir, "my-app", false, Target::AwsAppRunner, false).unwrap();
let content = fs::read_to_string(dir.join("outputs.tf")).unwrap();
for name in [
"region",
"app_url",
"service_arn",
"service_name",
"ecr_repository_url",
"apprunner_access_role_arn",
"database_url_secret_arn",
"signing_secret_secret_arn",
] {
assert!(
content.contains(&format!("output \"{name}\"")),
"outputs.tf must declare output \"{name}\": {content}"
);
}
assert!(
!content.contains("{{"),
"outputs.tf must not contain any unsubstituted template placeholders: {content}"
);
}
#[test]
fn aws_app_runner_region_output_reflects_the_configured_region_variable() {
let tmp = TempDir::new().unwrap();
let dir = make_project(&tmp, "my-app");
init(&dir, "my-app", false, Target::AwsAppRunner, false).unwrap();
let content = fs::read_to_string(dir.join("outputs.tf")).unwrap();
assert!(content.contains("value = var.region"), "{content}");
}
#[test]
fn aws_app_runner_secret_arn_outputs_reference_the_real_secrets() {
let tmp = TempDir::new().unwrap();
let dir = make_project(&tmp, "my-app");
init(&dir, "my-app", false, Target::AwsAppRunner, false).unwrap();
let content = fs::read_to_string(dir.join("outputs.tf")).unwrap();
assert!(
content.contains("aws_secretsmanager_secret.database_url.arn"),
"{content}"
);
assert!(
content.contains("aws_secretsmanager_secret.signing_secret.arn"),
"{content}"
);
}
#[test]
fn aws_app_runner_gitignore_merge_is_idempotent_on_repeat_init() {
let tmp = TempDir::new().unwrap();
let dir = make_project(&tmp, "my-app");
fs::write(dir.join(".gitignore"), "/target\n.env\n").unwrap();
init(&dir, "my-app", false, Target::AwsAppRunner, false).unwrap();
init(&dir, "my-app", true, Target::AwsAppRunner, false).unwrap();
let content = fs::read_to_string(dir.join(".gitignore")).unwrap();
assert!(
content.contains("/target") && content.contains(".env"),
"{content}"
);
for line in TERRAFORM_GITIGNORE_ENTRIES {
let count = content.lines().filter(|l| l.trim() == *line).count();
assert_eq!(count, 1, "`{line}` must appear exactly once: {content}");
}
}
#[test]
fn aws_ecs_target_creates_all_expected_files() {
let tmp = TempDir::new().unwrap();
let dir = make_project(&tmp, "my-app");
init(&dir, "my-app", false, Target::AwsEcs, false).unwrap();
for name in [
"main.tf",
"variables.tf",
"outputs.tf",
"terraform.tfvars.example",
".github/workflows/aws-deploy.yml",
] {
assert!(
dir.join(name).is_file(),
"{name} must be created for --target=aws-ecs"
);
}
assert!(dir.join("Dockerfile").is_file());
assert!(dir.join(".dockerignore").is_file());
assert!(dir.join("autumn.production.toml.example").is_file());
}
#[test]
fn aws_ecs_target_returns_nested_workflow_path_in_created_list() {
let tmp = TempDir::new().unwrap();
let dir = make_project(&tmp, "my-app");
let files = init(&dir, "my-app", false, Target::AwsEcs, false).unwrap();
assert!(
files
.iter()
.any(|f| f == ".github/workflows/aws-deploy.yml"),
"created-files list must include the nested workflow path: {files:?}"
);
}
#[test]
fn default_target_does_not_create_aws_ecs_files() {
let tmp = TempDir::new().unwrap();
let dir = make_project(&tmp, "my-app");
init(&dir, "my-app", false, Target::Default, false).unwrap();
for name in [
"main.tf",
"variables.tf",
"outputs.tf",
"terraform.tfvars.example",
".github/workflows/aws-deploy.yml",
] {
assert!(
!dir.join(name).exists(),
"{name} must NOT be created for the default target"
);
}
}
#[test]
fn aws_ecs_main_tf_has_vpc_with_public_and_private_subnets_across_two_azs() {
let tmp = TempDir::new().unwrap();
let dir = make_project(&tmp, "my-app");
init(&dir, "my-app", false, Target::AwsEcs, false).unwrap();
let content = fs::read_to_string(dir.join("main.tf")).unwrap();
assert!(content.contains("resource \"aws_vpc\""), "{content}");
assert!(
content.contains("resource \"aws_subnet\" \"public\"")
&& content.contains("count = 2"),
"main.tf must provision 2 public subnets: {content}"
);
assert!(
content.contains("resource \"aws_subnet\" \"private\"")
&& content.contains("count = 2"),
"main.tf must provision 2 private subnets: {content}"
);
assert!(
content.contains("data \"aws_availability_zones\""),
"subnets must be spread across AZs discovered at apply time, not hardcoded: {content}"
);
}
#[test]
fn aws_ecs_main_tf_has_alb_with_https_redirect_and_dns_validated_cert() {
let tmp = TempDir::new().unwrap();
let dir = make_project(&tmp, "my-app");
init(&dir, "my-app", false, Target::AwsEcs, false).unwrap();
let content = fs::read_to_string(dir.join("main.tf")).unwrap();
assert!(
content.contains("resource \"aws_lb\" \"this\""),
"main.tf must provision an ALB: {content}"
);
assert!(
content.contains("resource \"aws_acm_certificate\""),
"main.tf must provision an ACM certificate: {content}"
);
assert!(
content.contains("validation_method = \"DNS\""),
"the ACM certificate must use DNS validation: {content}"
);
assert!(
content.contains("aws_acm_certificate_validation"),
"main.tf must wait for certificate validation before wiring the HTTPS listener: {content}"
);
let http_listener = content
.split("resource \"aws_lb_listener\" \"http\"")
.nth(1)
.and_then(|rest| rest.split("\nresource").next())
.expect("main.tf must declare the HTTP listener");
assert!(
http_listener.contains("HTTP_301") && http_listener.contains("HTTPS"),
"the HTTP listener must redirect to HTTPS: {http_listener}"
);
}
#[test]
fn aws_ecs_alb_and_target_group_names_never_exceed_32_char_limit() {
let tmp = TempDir::new().unwrap();
let dir = make_project(&tmp, "my-app");
init(&dir, "my-app", false, Target::AwsEcs, false).unwrap();
let content = fs::read_to_string(dir.join("main.tf")).unwrap();
let base_cap_line = content
.lines()
.find(|l| l.trim_start().starts_with("app_name_safe = trim(substr("))
.expect("main.tf must declare app_name_safe via a bounded substr()");
assert!(
base_cap_line.contains(", 20)"),
"expected a 20-char base cap: {base_cap_line}"
);
assert!(20 + "-migrate-tg".len() <= 32);
assert!(
content.contains("\"${local.app_name_safe}-alb\""),
"the ALB must be named from the sanitized/capped local: {content}"
);
assert!(
content.contains("\"${local.app_name_safe}-tg\""),
"the target group must be named from the sanitized/capped local: {content}"
);
}
#[test]
fn aws_ecs_main_tf_has_ecs_fargate_cluster_task_and_service_with_circuit_breaker() {
let tmp = TempDir::new().unwrap();
let dir = make_project(&tmp, "my-app");
init(&dir, "my-app", false, Target::AwsEcs, false).unwrap();
let content = fs::read_to_string(dir.join("main.tf")).unwrap();
assert!(
content.contains("resource \"aws_ecs_cluster\""),
"{content}"
);
assert!(
content.contains("resource \"aws_ecs_task_definition\" \"app\"")
&& content.contains("FARGATE"),
"{content}"
);
assert!(
content.contains("resource \"aws_ecs_service\" \"this\""),
"{content}"
);
let service_block = content
.split("resource \"aws_ecs_service\" \"this\"")
.nth(1)
.expect("main.tf must declare the ECS service");
assert!(
service_block.contains("deployment_circuit_breaker")
&& service_block.contains("rollback = true"),
"the ECS service must enable circuit-breaker rollback: {service_block}"
);
}
#[test]
fn aws_ecs_service_waits_for_secret_versions_before_starting() {
let tmp = TempDir::new().unwrap();
let dir = make_project(&tmp, "my-app");
init(&dir, "my-app", false, Target::AwsEcs, false).unwrap();
let content = fs::read_to_string(dir.join("main.tf")).unwrap();
let service_block = content
.split("resource \"aws_ecs_service\" \"this\"")
.nth(1)
.expect("main.tf must declare the ECS service");
for dep in [
"aws_secretsmanager_secret_version.database_url",
"aws_secretsmanager_secret_version.signing_secret",
"aws_secretsmanager_secret_version.redis_url",
] {
assert!(
service_block.contains(dep),
"aws_ecs_service.this must depend on {dep}, not just the secret \
container it references by ARN: {service_block}"
);
}
}
#[test]
fn aws_ecs_service_waits_for_execution_role_policies_before_starting() {
let tmp = TempDir::new().unwrap();
let dir = make_project(&tmp, "my-app");
init(&dir, "my-app", false, Target::AwsEcs, false).unwrap();
let content = fs::read_to_string(dir.join("main.tf")).unwrap();
let service_block = content
.split("resource \"aws_ecs_service\" \"this\"")
.nth(1)
.expect("main.tf must declare the ECS service");
for dep in [
"aws_iam_role_policy_attachment.execution_managed",
"aws_iam_role_policy.execution_secrets",
] {
assert!(
service_block.contains(dep),
"aws_ecs_service.this must depend on {dep}, not just the execution \
role itself: {service_block}"
);
}
}
#[test]
fn aws_ecs_task_definitions_bootstrap_from_placeholder_and_ignore_drift() {
let tmp = TempDir::new().unwrap();
let dir = make_project(&tmp, "my-app");
init(&dir, "my-app", false, Target::AwsEcs, false).unwrap();
let content = fs::read_to_string(dir.join("main.tf")).unwrap();
for family in ["app", "migrate"] {
let block = content
.split(&format!(
"resource \"aws_ecs_task_definition\" \"{family}\""
))
.nth(1)
.and_then(|rest| rest.split("\nresource").next())
.unwrap_or_else(|| panic!("main.tf must declare the {family} task definition"));
assert!(
block.contains("var.bootstrap_image"),
"{family} task must use the bootstrap image: {block}"
);
assert!(
block.contains("ignore_changes = [container_definitions]"),
"{family} task definition must ignore container_definitions drift once CI \
registers real revisions: {block}"
);
}
let service_block = content
.split("resource \"aws_ecs_service\" \"this\"")
.nth(1)
.expect("main.tf must declare the ECS service");
assert!(
service_block.contains("ignore_changes = [task_definition, desired_count]"),
"the ECS service must ignore task_definition drift so CI-registered revisions \
aren't reverted by a later `terraform apply`: {service_block}"
);
}
#[test]
fn aws_ecs_public_app_bootstrap_has_no_secrets_and_deploy_restores_them() {
let tmp = TempDir::new().unwrap();
let dir = make_project(&tmp, "my-app");
init(&dir, "my-app", false, Target::AwsEcs, true).unwrap();
let content = fs::read_to_string(dir.join("main.tf")).unwrap();
let app = content
.split("resource \"aws_ecs_task_definition\" \"app\"")
.nth(1)
.and_then(|block| block.split("\nresource").next())
.expect("main.tf must declare the app task definition");
assert!(app.contains("secrets = []"));
let workflow = fs::read_to_string(dir.join(".github/workflows/aws-deploy.yml")).unwrap();
assert!(workflow.contains("--argjson SECRETS"));
assert!(workflow.contains(".containerDefinitions[0].secrets = $SECRETS"));
}
#[test]
fn aws_ecs_migrate_task_carries_the_full_app_secret_set() {
let tmp = TempDir::new().unwrap();
let dir = make_project(&tmp, "my-app");
init(&dir, "my-app", false, Target::AwsEcs, false).unwrap();
let content = fs::read_to_string(dir.join("main.tf")).unwrap();
let migrate = content
.split("resource \"aws_ecs_task_definition\" \"migrate\"")
.nth(1)
.and_then(|block| block.split("\nresource").next())
.expect("main.tf must declare the migrate task definition");
assert!(
migrate.contains("secrets = local.container_secrets"),
"the migrate task must reuse the full app secret list, not a \
narrower hand-picked one: {migrate}"
);
assert!(
!migrate.contains("AUTUMN_DATABASE__PRIMARY_URL\", valueFrom"),
"the migrate task must not hardcode a partial secret list \
inline: {migrate}"
);
let container_secrets_local = content
.split("container_secrets = concat(")
.nth(1)
.and_then(|block| block.split("secrets_manager_arns").next())
.expect("main.tf must declare local.container_secrets");
for secret in [
"AUTUMN_DATABASE__PRIMARY_URL",
"AUTUMN_SECURITY__SIGNING_SECRET",
] {
assert!(
container_secrets_local.contains(secret),
"local.container_secrets must include {secret}: \
{container_secrets_local}"
);
}
assert!(
container_secrets_local.contains("var.enable_redis_cache")
&& container_secrets_local.contains("AUTUMN_CACHE__REDIS__URL"),
"local.container_secrets must include AUTUMN_CACHE__REDIS__URL, \
gated by enable_redis_cache: {container_secrets_local}"
);
}
#[test]
fn aws_ecs_ignores_desired_count_drift_managed_by_autoscaling() {
let tmp = TempDir::new().unwrap();
let dir = make_project(&tmp, "my-app");
init(&dir, "my-app", false, Target::AwsEcs, false).unwrap();
let content = fs::read_to_string(dir.join("main.tf")).unwrap();
let service_block = content
.split("resource \"aws_ecs_service\" \"this\"")
.nth(1)
.expect("main.tf must declare the ECS service");
assert!(
service_block.contains("desired_count]"),
"the ECS service must ignore desired_count drift once Application Auto Scaling \
owns it: {service_block}"
);
}
#[test]
fn aws_ecs_app_bootstrap_container_actually_listens_on_the_alb_health_check_port() {
let tmp = TempDir::new().unwrap();
let dir = make_project(&tmp, "my-app");
init(&dir, "my-app", false, Target::AwsEcs, false).unwrap();
let content = fs::read_to_string(dir.join("main.tf")).unwrap();
let app_block = content
.split("resource \"aws_ecs_task_definition\" \"app\"")
.nth(1)
.and_then(|rest| rest.split("\nresource").next())
.expect("main.tf must declare the app task definition");
assert!(
app_block.contains("entryPoint"),
"the app container must override entryPoint so it runs regardless of the \
placeholder image's own default command: {app_block}"
);
assert!(
app_block.contains("listen 3000"),
"the app container's bootstrap command must configure the placeholder to listen \
on port 3000, matching the ALB target group: {app_block}"
);
assert!(
app_block.contains("location /ready"),
"the app container's bootstrap command must serve the same /ready path the \
target group checks: {app_block}"
);
let migrate_block = content
.split("resource \"aws_ecs_task_definition\" \"migrate\"")
.nth(1)
.and_then(|rest| rest.split("\nresource").next())
.expect("main.tf must declare the migrate task definition");
assert!(migrate_block.contains("autumn migrate"), "{migrate_block}");
}
#[test]
fn aws_ecs_main_tf_derives_database_url_not_a_variable() {
let tmp = TempDir::new().unwrap();
let dir = make_project(&tmp, "my-app");
init(&dir, "my-app", false, Target::AwsEcs, false).unwrap();
let main_tf = fs::read_to_string(dir.join("main.tf")).unwrap();
let variables_tf = fs::read_to_string(dir.join("variables.tf")).unwrap();
assert!(
main_tf.contains("AUTUMN_DATABASE__PRIMARY_URL"),
"{main_tf}"
);
assert!(
main_tf.contains("aws_db_instance.this.address"),
"{main_tf}"
);
assert!(
!variables_tf.contains("variable \"database_url\""),
"there must be no database_url variable: {variables_tf}"
);
}
#[test]
fn aws_ecs_trusted_hosts_uses_the_required_domain_name_variable() {
let tmp = TempDir::new().unwrap();
let dir = make_project(&tmp, "my-app");
init(&dir, "my-app", false, Target::AwsEcs, false).unwrap();
let content = fs::read_to_string(dir.join("main.tf")).unwrap();
assert!(
content.contains("AUTUMN_SECURITY__TRUSTED_HOSTS__HOSTS")
&& content.contains("var.domain_name"),
"{content}"
);
}
#[test]
fn aws_ecs_execution_role_secrets_policy_is_scoped_not_wildcard() {
let tmp = TempDir::new().unwrap();
let dir = make_project(&tmp, "my-app");
init(&dir, "my-app", false, Target::AwsEcs, false).unwrap();
let content = fs::read_to_string(dir.join("main.tf")).unwrap();
let policy_block = content
.split("data \"aws_iam_policy_document\" \"execution_secrets\"")
.nth(1)
.and_then(|rest| rest.split("\n}\n").next())
.expect("main.tf must declare the execution role's secrets policy document");
assert!(
!policy_block.contains("\"*\""),
"the execution role's secrets access must be scoped, never a wildcard: {policy_block}"
);
assert!(
policy_block.contains("local.secrets_manager_arns"),
"{policy_block}"
);
}
#[test]
fn aws_ecs_task_role_and_execution_role_are_distinct() {
let tmp = TempDir::new().unwrap();
let dir = make_project(&tmp, "my-app");
init(&dir, "my-app", false, Target::AwsEcs, false).unwrap();
let content = fs::read_to_string(dir.join("main.tf")).unwrap();
assert!(
content.contains("resource \"aws_iam_role\" \"execution\""),
"{content}"
);
assert!(
content.contains("resource \"aws_iam_role\" \"task\""),
"{content}"
);
}
#[test]
fn aws_ecs_redis_is_off_by_default_and_gated() {
let tmp = TempDir::new().unwrap();
let dir = make_project(&tmp, "my-app");
init(&dir, "my-app", false, Target::AwsEcs, false).unwrap();
let variables_tf = fs::read_to_string(dir.join("variables.tf")).unwrap();
let main_tf = fs::read_to_string(dir.join("main.tf")).unwrap();
let redis_var_block = variables_tf
.split("variable \"enable_redis_cache\"")
.nth(1)
.and_then(|rest| rest.split('}').next())
.expect("variables.tf must declare variable \"enable_redis_cache\"");
assert!(
redis_var_block.contains("default = false"),
"enable_redis_cache must default to false: {redis_var_block}"
);
assert!(
main_tf.contains("count = var.enable_redis_cache ? 1 : 0")
|| main_tf.contains("count = var.enable_redis_cache ? 1 : 0"),
"ElastiCache resources must be gated behind enable_redis_cache: {main_tf}"
);
assert!(
main_tf.contains("autumn-cache-redis"),
"main.tf must document that Redis wiring alone isn't enough — the app must \
also depend on autumn-cache-redis: {main_tf}"
);
}
#[test]
fn aws_ecs_scale_defaults_match_the_issue() {
let tmp = TempDir::new().unwrap();
let dir = make_project(&tmp, "my-app");
init(&dir, "my-app", false, Target::AwsEcs, false).unwrap();
let content = fs::read_to_string(dir.join("variables.tf")).unwrap();
let defaults = [
("desired_count", "2"),
("min_count", "1"),
("max_count", "10"),
];
for (var, expected) in defaults {
let block = content
.split(&format!("variable \"{var}\""))
.nth(1)
.and_then(|rest| rest.split('}').next())
.unwrap_or_else(|| {
panic!("variables.tf must declare variable \"{var}\": {content}")
});
assert!(
block.contains(&format!("default = {expected}")),
"{var} must default to {expected}: {block}"
);
}
}
#[test]
fn aws_ecs_secrets_have_no_default_and_are_sensitive() {
let tmp = TempDir::new().unwrap();
let dir = make_project(&tmp, "my-app");
init(&dir, "my-app", false, Target::AwsEcs, false).unwrap();
let content = fs::read_to_string(dir.join("variables.tf")).unwrap();
for var in ["database_admin_password", "signing_secret"] {
let block = content
.split(&format!("variable \"{var}\""))
.nth(1)
.and_then(|rest| rest.split('}').next())
.unwrap_or_else(|| {
panic!("variables.tf must declare variable \"{var}\": {content}")
});
assert!(
block.contains("sensitive = true") || block.contains("sensitive = true"),
"{block}"
);
assert!(
!block.contains("default"),
"{var} must not have a default: {block}"
);
}
}
#[test]
fn aws_ecs_no_committed_secret_literals() {
let tmp = TempDir::new().unwrap();
let dir = make_project(&tmp, "my-app");
init(&dir, "my-app", false, Target::AwsEcs, false).unwrap();
for name in ["main.tf", "variables.tf", "terraform.tfvars.example"] {
let content = fs::read_to_string(dir.join(name)).unwrap();
assert!(
!content.to_lowercase().contains("password =\""),
"{name} must not contain a literal password assignment: {content}"
);
}
}
#[test]
fn aws_ecs_target_adds_terraform_gitignore_entries() {
let tmp = TempDir::new().unwrap();
let dir = make_project(&tmp, "my-app");
init(&dir, "my-app", false, Target::AwsEcs, false).unwrap();
let content = fs::read_to_string(dir.join(".gitignore")).unwrap();
for line in TERRAFORM_GITIGNORE_ENTRIES {
assert!(content.lines().any(|l| l.trim() == *line), "{content}");
}
}
#[test]
fn aws_ecs_dockerignore_excludes_terraform_state() {
let tmp = TempDir::new().unwrap();
let dir = make_project(&tmp, "my-app");
init(&dir, "my-app", false, Target::AwsEcs, false).unwrap();
let content = fs::read_to_string(dir.join(".dockerignore")).unwrap();
for pattern in [".terraform/", "*.tfstate", "terraform.tfvars"] {
assert!(content.contains(pattern), "{content}");
}
}
#[test]
fn aws_ecs_outputs_tf_declares_expected_outputs_with_no_unsubstituted_placeholders() {
let tmp = TempDir::new().unwrap();
let dir = make_project(&tmp, "my-app");
init(&dir, "my-app", false, Target::AwsEcs, false).unwrap();
let content = fs::read_to_string(dir.join("outputs.tf")).unwrap();
for name in [
"region",
"app_url",
"alb_dns_name",
"ecr_repository_url",
"ecs_cluster_name",
"ecs_service_name",
"app_task_family",
"migrate_task_family",
"private_subnet_ids",
"ecs_tasks_security_group_id",
"execution_role_arn",
"task_role_arn",
] {
assert!(
content.contains(&format!("output \"{name}\"")),
"outputs.tf must declare output \"{name}\" — aws-deploy.yml's header \
comment tells operators to source CI config/IAM grants from it: {content}"
);
}
assert!(
!content.contains("{{"),
"outputs.tf must not contain any unsubstituted template placeholders: {content}"
);
}
#[test]
fn aws_ecs_region_output_reflects_the_configured_region_variable() {
let tmp = TempDir::new().unwrap();
let dir = make_project(&tmp, "my-app");
init(&dir, "my-app", false, Target::AwsEcs, false).unwrap();
let content = fs::read_to_string(dir.join("outputs.tf")).unwrap();
assert!(content.contains("value = var.region"), "{content}");
}
#[test]
fn aws_ecs_execution_and_task_role_arns_are_both_exported_for_ci_pass_role_grants() {
let tmp = TempDir::new().unwrap();
let dir = make_project(&tmp, "my-app");
init(&dir, "my-app", false, Target::AwsEcs, false).unwrap();
let outputs_tf = fs::read_to_string(dir.join("outputs.tf")).unwrap();
assert!(
outputs_tf.contains("aws_iam_role.execution.arn"),
"{outputs_tf}"
);
assert!(outputs_tf.contains("aws_iam_role.task.arn"), "{outputs_tf}");
}
#[test]
fn aws_ecs_gitignore_merge_is_idempotent_on_repeat_init() {
let tmp = TempDir::new().unwrap();
let dir = make_project(&tmp, "my-app");
fs::write(dir.join(".gitignore"), "/target\n.env\n").unwrap();
init(&dir, "my-app", false, Target::AwsEcs, false).unwrap();
init(&dir, "my-app", true, Target::AwsEcs, false).unwrap();
let content = fs::read_to_string(dir.join(".gitignore")).unwrap();
assert!(
content.contains("/target") && content.contains(".env"),
"{content}"
);
for line in TERRAFORM_GITIGNORE_ENTRIES {
let count = content.lines().filter(|l| l.trim() == *line).count();
assert_eq!(count, 1, "`{line}` must appear exactly once: {content}");
}
}
#[test]
fn init_without_force_errors_if_aws_workflow_file_exists() {
let tmp = TempDir::new().unwrap();
let dir = make_project(&tmp, "my-app");
fs::create_dir_all(dir.join(".github/workflows")).unwrap();
fs::write(dir.join(".github/workflows/aws-deploy.yml"), "existing").unwrap();
let err = init(&dir, "my-app", false, Target::AwsEcs, false).unwrap_err();
assert!(matches!(err, ReleaseError::FileExists(_)));
}
#[test]
fn init_with_force_overwrites_nested_aws_workflow_file() {
let tmp = TempDir::new().unwrap();
let dir = make_project(&tmp, "my-app");
fs::create_dir_all(dir.join(".github/workflows")).unwrap();
fs::write(dir.join(".github/workflows/aws-deploy.yml"), "old content").unwrap();
init(&dir, "my-app", true, Target::AwsEcs, false).unwrap();
let content = fs::read_to_string(dir.join(".github/workflows/aws-deploy.yml")).unwrap();
assert_ne!(content, "old content");
}
#[test]
fn aws_workflow_triggers_on_tag_push_and_manual_dispatch() {
let tmp = TempDir::new().unwrap();
let dir = make_project(&tmp, "my-app");
init(&dir, "my-app", false, Target::AwsEcs, false).unwrap();
let content = fs::read_to_string(dir.join(".github/workflows/aws-deploy.yml")).unwrap();
assert!(
content.contains("tags:") && content.contains("\"v*\""),
"{content}"
);
assert!(content.contains("workflow_dispatch"), "{content}");
}
#[test]
fn aws_workflow_never_hardcodes_credentials() {
let tmp = TempDir::new().unwrap();
let dir = make_project(&tmp, "my-app");
init(&dir, "my-app", false, Target::AwsEcs, false).unwrap();
let content = fs::read_to_string(dir.join(".github/workflows/aws-deploy.yml")).unwrap();
assert!(
content.contains("secrets.AWS_ROLE_ARN"),
"the workflow must source the deploy role from a repository secret: {content}"
);
assert!(
content.contains("id-token: write"),
"the workflow must request OIDC token permission for AWS login: {content}"
);
assert!(
!content.to_lowercase().contains("aws_secret_access_key"),
"the workflow must not use long-lived AWS access keys: {content}"
);
}
#[test]
fn aws_workflow_never_uses_the_invalid_output_none_value() {
let tmp = TempDir::new().unwrap();
let dir = make_project(&tmp, "my-app");
init(&dir, "my-app", false, Target::AwsEcs, false).unwrap();
let content = fs::read_to_string(dir.join(".github/workflows/aws-deploy.yml")).unwrap();
assert!(
!content.contains("--output none"),
"the workflow must not pass the invalid --output none value to the AWS CLI: {content}"
);
}
#[test]
fn aws_workflow_runs_migrations_before_updating_the_service() {
let tmp = TempDir::new().unwrap();
let dir = make_project(&tmp, "my-app");
init(&dir, "my-app", false, Target::AwsEcs, false).unwrap();
let content = fs::read_to_string(dir.join(".github/workflows/aws-deploy.yml")).unwrap();
let migrate_pos = content
.find("Run database migrations")
.expect("workflow must run migrations");
let deploy_pos = content
.find("Deploy new image to the ECS service")
.expect("workflow must deploy to ECS");
assert!(
migrate_pos < deploy_pos,
"migrations must run BEFORE the ECS service is updated: {content}"
);
}
#[test]
fn aws_workflow_aborts_deploy_on_migration_failure() {
let tmp = TempDir::new().unwrap();
let dir = make_project(&tmp, "my-app");
init(&dir, "my-app", false, Target::AwsEcs, false).unwrap();
let content = fs::read_to_string(dir.join(".github/workflows/aws-deploy.yml")).unwrap();
let migrate_step = content
.split("Run database migrations")
.nth(1)
.and_then(|rest| rest.split("- name:").next())
.expect("must find the migration step");
assert!(
migrate_step.contains("exit 1"),
"the migration step must exit non-zero on failure so the job (and therefore \
the deploy step after it) aborts: {migrate_step}"
);
}
#[test]
fn aws_workflow_serializes_overlapping_runs() {
let tmp = TempDir::new().unwrap();
let dir = make_project(&tmp, "my-app");
init(&dir, "my-app", false, Target::AwsEcs, false).unwrap();
let content = fs::read_to_string(dir.join(".github/workflows/aws-deploy.yml")).unwrap();
assert!(content.contains("concurrency:"), "{content}");
assert!(content.contains("cancel-in-progress: false"), "{content}");
}
#[test]
fn aws_workflow_guards_against_superseded_run_before_migrating() {
let tmp = TempDir::new().unwrap();
let dir = make_project(&tmp, "my-app");
init(&dir, "my-app", false, Target::AwsEcs, false).unwrap();
let content = fs::read_to_string(dir.join(".github/workflows/aws-deploy.yml")).unwrap();
let guard_pos = content
.find("Abort if a newer run of this workflow exists")
.expect("workflow must guard against superseded runs");
let migrate_pos = content.find("Run database migrations").unwrap();
assert!(
guard_pos < migrate_pos,
"the staleness guard must run BEFORE migrating: {content}"
);
}
#[test]
fn aws_workflow_image_tag_is_unique_per_execution() {
let tmp = TempDir::new().unwrap();
let dir = make_project(&tmp, "my-app");
init(&dir, "my-app", false, Target::AwsEcs, false).unwrap();
let content = fs::read_to_string(dir.join(".github/workflows/aws-deploy.yml")).unwrap();
assert!(content.contains("GITHUB_RUN_ID"), "{content}");
assert!(content.contains("GITHUB_RUN_ATTEMPT"), "{content}");
}
#[test]
fn aws_workflow_sanitizes_ref_name_for_docker_tag() {
let tmp = TempDir::new().unwrap();
let dir = make_project(&tmp, "my-app");
init(&dir, "my-app", false, Target::AwsEcs, false).unwrap();
let content = fs::read_to_string(dir.join(".github/workflows/aws-deploy.yml")).unwrap();
assert!(
content.contains("tr -c 'A-Za-z0-9_.-' '-'"),
"the workflow must map every character outside Docker's tag charset to '-': {content}"
);
}
#[test]
fn aws_workflow_image_tag_never_starts_with_invalid_character() {
let tmp = TempDir::new().unwrap();
let dir = make_project(&tmp, "my-app");
init(&dir, "my-app", false, Target::AwsEcs, false).unwrap();
let content = fs::read_to_string(dir.join(".github/workflows/aws-deploy.yml")).unwrap();
assert!(
content.contains("sed -E 's/^[.-]+//'"),
"the workflow must strip a leading '.'/'-' left over from ref sanitization — \
Docker's tag grammar requires the first character to be a word character: {content}"
);
}
#[test]
fn aws_workflow_grants_actions_read_for_the_staleness_guard() {
let tmp = TempDir::new().unwrap();
let dir = make_project(&tmp, "my-app");
init(&dir, "my-app", false, Target::AwsEcs, false).unwrap();
let content = fs::read_to_string(dir.join(".github/workflows/aws-deploy.yml")).unwrap();
let permissions_block = content
.split("permissions:")
.nth(1)
.and_then(|rest| rest.split("\n\n").next())
.expect("workflow must declare a permissions block");
assert!(
permissions_block.contains("actions: read"),
"the permissions block must grant actions: read for the staleness-guard \
step's `gh api .../actions/workflows/.../runs` call: {permissions_block}"
);
}
#[test]
fn aws_workflow_documents_describe_task_definition_permission() {
let tmp = TempDir::new().unwrap();
let dir = make_project(&tmp, "my-app");
init(&dir, "my-app", false, Target::AwsEcs, false).unwrap();
let content = fs::read_to_string(dir.join(".github/workflows/aws-deploy.yml")).unwrap();
let header = content
.split("\nname: aws-deploy")
.next()
.expect("workflow must have a header comment before `name:`");
assert!(
header.contains("DescribeTaskDefinition"),
"the header's documented IAM policy must include ecs:DescribeTaskDefinition: {header}"
);
assert!(
!header.contains("granted ecr:*"),
"the header must not recommend granting account-wide ecr:* — scope ECR access \
to the repository ARN instead: {header}"
);
}
#[test]
fn aws_workflow_subnet_ids_env_is_used_as_json_directly_not_reparsed_from_csv() {
let tmp = TempDir::new().unwrap();
let dir = make_project(&tmp, "my-app");
init(&dir, "my-app", false, Target::AwsEcs, false).unwrap();
let content = fs::read_to_string(dir.join(".github/workflows/aws-deploy.yml")).unwrap();
assert!(
content.contains("SUBNETS=$(echo \"$ECS_PRIVATE_SUBNET_IDS\" | jq -c .)"),
"the migration step must treat ECS_PRIVATE_SUBNET_IDS as JSON already, not \
reconstruct it from a comma-separated split: {content}"
);
assert!(
!content.contains("tr ',' '\\n' | jq -R ."),
"the migration step must not reconstruct a JSON array via comma-split: {content}"
);
}
#[test]
fn aws_workflow_passes_git_provenance_build_args_to_docker() {
let tmp = TempDir::new().unwrap();
let dir = make_project(&tmp, "my-app");
init(&dir, "my-app", false, Target::AwsEcs, false).unwrap();
let content = fs::read_to_string(dir.join(".github/workflows/aws-deploy.yml")).unwrap();
for arg in [
"AUTUMN_BUILD_GIT_SHA",
"AUTUMN_BUILD_GIT_SHA_SHORT",
"AUTUMN_BUILD_GIT_BRANCH",
"AUTUMN_BUILD_GIT_DIRTY",
"AUTUMN_BUILD_TIMESTAMP",
] {
assert!(content.contains(arg), "{content}");
}
}
#[test]
fn aws_workflow_registers_new_task_definitions_before_running_or_deploying() {
let tmp = TempDir::new().unwrap();
let dir = make_project(&tmp, "my-app");
init(&dir, "my-app", false, Target::AwsEcs, false).unwrap();
let content = fs::read_to_string(dir.join(".github/workflows/aws-deploy.yml")).unwrap();
let register_app_pos = content
.find("register_app")
.expect("must register the app task def");
let register_migrate_pos = content
.find("register_migrate")
.expect("must register the migrate task def");
let migrate_pos = content.find("Run database migrations").unwrap();
let deploy_pos = content.find("Deploy new image to the ECS service").unwrap();
assert!(register_migrate_pos < migrate_pos, "{content}");
assert!(register_app_pos < deploy_pos, "{content}");
}
#[test]
fn aws_workflow_strips_bootstrap_entrypoint_only_from_the_app_registration() {
let tmp = TempDir::new().unwrap();
let dir = make_project(&tmp, "my-app");
init(&dir, "my-app", false, Target::AwsEcs, false).unwrap();
let content = fs::read_to_string(dir.join(".github/workflows/aws-deploy.yml")).unwrap();
let app_step = content
.split("Register a new \"app\" task definition revision")
.nth(1)
.and_then(|rest| rest.split("- name:").next())
.expect("must find the app registration step");
assert!(
app_step.contains(
"del(.containerDefinitions[0].entryPoint, .containerDefinitions[0].command)"
),
"the app registration step must strip the bootstrap entryPoint/command before \
registering the real image: {app_step}"
);
let migrate_step = content
.split("Register a new \"migrate\" task definition revision")
.nth(1)
.and_then(|rest| rest.split("- name:").next())
.expect("must find the migrate registration step");
assert!(
!migrate_step.contains("del(.containerDefinitions[0].entryPoint"),
"the migrate registration step must NOT strip its own (intentional, permanent) \
command: {migrate_step}"
);
}
#[test]
fn aws_workflow_waits_for_service_stability_after_deploying() {
let tmp = TempDir::new().unwrap();
let dir = make_project(&tmp, "my-app");
init(&dir, "my-app", false, Target::AwsEcs, false).unwrap();
let content = fs::read_to_string(dir.join(".github/workflows/aws-deploy.yml")).unwrap();
assert!(
content.contains("aws ecs wait services-stable"),
"the workflow must wait for the new deployment to stabilize (so the circuit \
breaker's rollback has a chance to be observed) rather than exiting immediately \
after force-new-deployment: {content}"
);
}
#[test]
fn aws_workflow_detects_circuit_breaker_rollback_after_stabilizing() {
let tmp = TempDir::new().unwrap();
let dir = make_project(&tmp, "my-app");
init(&dir, "my-app", false, Target::AwsEcs, false).unwrap();
let content = fs::read_to_string(dir.join(".github/workflows/aws-deploy.yml")).unwrap();
let deploy_step = content
.split("Deploy new image to the ECS service")
.nth(1)
.and_then(|rest| rest.split("env:").next())
.expect("must find the deploy step");
assert!(
deploy_step.contains("deployments[?status=='PRIMARY']")
|| deploy_step.contains("deployments[?status==`PRIMARY`]"),
"the deploy step must inspect the PRIMARY deployment's task definition after \
waiting: {deploy_step}"
);
assert!(
deploy_step.contains("DEPLOYED_TASK_DEF") && deploy_step.contains("APP_TASK_DEF_ARN"),
"the deploy step must compare the deployed task definition against the requested \
one and fail if they differ: {deploy_step}"
);
}
#[test]
fn aws_workflow_stops_migration_task_on_timeout() {
let tmp = TempDir::new().unwrap();
let dir = make_project(&tmp, "my-app");
init(&dir, "my-app", false, Target::AwsEcs, false).unwrap();
let content = fs::read_to_string(dir.join(".github/workflows/aws-deploy.yml")).unwrap();
let migrate_step = content
.split("Run database migrations")
.nth(1)
.and_then(|rest| rest.split("- name:").next())
.expect("must find the migration step");
let timeout_block = migrate_step
.split("did not finish within the time budget")
.nth(1)
.expect("must find the timeout-budget-exceeded branch");
assert!(
timeout_block.contains("aws ecs stop-task"),
"the timeout branch must explicitly stop the migration task, not just exit: \
{timeout_block}"
);
assert!(
timeout_block.contains("wait tasks-stopped"),
"the timeout branch must wait for the stop to actually take effect before this \
job (and its concurrency group) releases: {timeout_block}"
);
}
#[test]
fn aws_workflow_documents_stop_task_permission() {
let tmp = TempDir::new().unwrap();
let dir = make_project(&tmp, "my-app");
init(&dir, "my-app", false, Target::AwsEcs, false).unwrap();
let content = fs::read_to_string(dir.join(".github/workflows/aws-deploy.yml")).unwrap();
let header = content
.split("\nname: aws-deploy")
.next()
.expect("workflow must have a header comment before `name:`");
assert!(
header.contains("StopTask"),
"the header's documented IAM policy must include ecs:StopTask, used to kill a \
timed-out migration task: {header}"
);
}
#[test]
fn aws_ecs_workflow_relocation_warning_flags_a_nested_workspace_member() {
let tmp = TempDir::new().unwrap();
let git_root = tmp.path().to_path_buf();
fs::create_dir_all(git_root.join(".git")).unwrap();
let member_dir = git_root.join("examples").join("blog");
fs::create_dir_all(&member_dir).unwrap();
let warning =
nested_workflow_relocation_warning(&member_dir, ".github/workflows/aws-deploy.yml")
.expect("a workflow nested under a workspace member must be flagged");
assert!(warning.contains("aws-deploy.yml"), "{warning}");
assert!(
warning.contains("working-directory: examples/blog"),
"{warning}"
);
}
#[test]
fn is_terraform_target_covers_all_terraform_targets() {
assert!(is_terraform_target(Target::AzureContainerApps));
assert!(is_terraform_target(Target::AwsAppRunner));
assert!(is_terraform_target(Target::AwsEcs));
assert!(is_terraform_target(Target::GcpCloudRun));
assert!(!is_terraform_target(Target::Default));
assert!(!is_terraform_target(Target::Fly));
assert!(!is_terraform_target(Target::DockerCompose));
}
#[test]
fn gcp_cloud_run_target_creates_all_expected_files() {
let tmp = TempDir::new().unwrap();
let dir = make_project(&tmp, "my-app");
init(&dir, "my-app", false, Target::GcpCloudRun, false).unwrap();
for name in [
"main.tf",
"variables.tf",
"outputs.tf",
"terraform.tfvars.example",
".github/workflows/gcp-deploy.yml",
] {
assert!(
dir.join(name).is_file(),
"{name} must be created for --target=gcp-cloud-run"
);
}
assert!(dir.join("Dockerfile").is_file());
assert!(dir.join(".dockerignore").is_file());
assert!(dir.join("autumn.production.toml.example").is_file());
}
#[test]
fn gcp_cloud_run_target_returns_nested_workflow_path_in_created_list() {
let tmp = TempDir::new().unwrap();
let dir = make_project(&tmp, "my-app");
let files = init(&dir, "my-app", false, Target::GcpCloudRun, false).unwrap();
assert!(
files
.iter()
.any(|f| f == ".github/workflows/gcp-deploy.yml"),
"created-files list must include the nested workflow path: {files:?}"
);
}
#[test]
fn default_target_does_not_create_gcp_files() {
let tmp = TempDir::new().unwrap();
let dir = make_project(&tmp, "my-app");
init(&dir, "my-app", false, Target::Default, false).unwrap();
for name in [
"main.tf",
"variables.tf",
"outputs.tf",
"terraform.tfvars.example",
".github/workflows/gcp-deploy.yml",
] {
assert!(
!dir.join(name).exists(),
"{name} must NOT be created for the default target"
);
}
}
#[test]
fn gcp_main_tf_has_artifact_registry_and_cloud_run_service() {
let tmp = TempDir::new().unwrap();
let dir = make_project(&tmp, "my-app");
init(&dir, "my-app", false, Target::GcpCloudRun, false).unwrap();
let content = fs::read_to_string(dir.join("main.tf")).unwrap();
assert!(
content.contains("google_artifact_registry_repository"),
"main.tf must provision an Artifact Registry repository: {content}"
);
assert!(
content.contains("resource \"google_cloud_run_v2_service\""),
"main.tf must provision the Cloud Run service: {content}"
);
}
#[test]
fn gcp_main_tf_has_cloud_sql_with_private_ip_only() {
let tmp = TempDir::new().unwrap();
let dir = make_project(&tmp, "my-app");
init(&dir, "my-app", false, Target::GcpCloudRun, false).unwrap();
let content = fs::read_to_string(dir.join("main.tf")).unwrap();
assert!(
content.contains("google_sql_database_instance"),
"main.tf must provision a Cloud SQL PostgreSQL instance: {content}"
);
let ip_config = content
.split("ip_configuration {")
.nth(1)
.and_then(|rest| rest.split('}').next())
.expect("main.tf must declare an ip_configuration block");
assert!(
ip_config.contains("ipv4_enabled") && ip_config.contains("false"),
"Cloud SQL must not have a public IPv4 address — private IP only via the \
VPC connector: {ip_config}"
);
assert!(
ip_config.contains("private_network"),
"Cloud SQL's ip_configuration must set private_network: {ip_config}"
);
}
#[test]
fn gcp_main_tf_has_vpc_access_connector_wired_into_cloud_run() {
let tmp = TempDir::new().unwrap();
let dir = make_project(&tmp, "my-app");
init(&dir, "my-app", false, Target::GcpCloudRun, false).unwrap();
let content = fs::read_to_string(dir.join("main.tf")).unwrap();
assert!(
content.contains("google_vpc_access_connector"),
"main.tf must provision a Serverless VPC Access connector: {content}"
);
assert!(
content.contains("google_vpc_access_connector.this.id"),
"the Cloud Run service must reference the VPC connector: {content}"
);
assert!(
content.contains("egress = \"PRIVATE_RANGES_ONLY\"")
|| content.contains("egress = \"PRIVATE_RANGES_ONLY\""),
"vpc_access egress should stay scoped to private ranges so general internet \
egress doesn't route through the connector: {content}"
);
}
#[test]
fn gcp_vpc_connector_name_respects_the_weighted_21_char_limit() {
let tmp = TempDir::new().unwrap();
let dir = make_project(&tmp, "my-app");
init(&dir, "my-app", false, Target::GcpCloudRun, false).unwrap();
let content = fs::read_to_string(dir.join("main.tf")).unwrap();
assert!(
content.contains(
"connector_name_safe = substr(replace(local.app_name_hyphenated, \"-\", \"\"), 0, 9)"
),
"connector_name_safe must strip hyphens from the base before capping it (so \
weighted length equals raw length), and cap the hyphen-free base at 9 \
characters — 9 (base) + 11 (the \"-connector\" suffix's weighted length: \
2 for its hyphen + 9 for \"connector\") = 20, just under the 21-character \
weighted limit: {content}"
);
}
#[test]
fn gcp_main_tf_vpc_network_waits_for_compute_api_activation() {
let tmp = TempDir::new().unwrap();
let dir = make_project(&tmp, "my-app");
init(&dir, "my-app", false, Target::GcpCloudRun, false).unwrap();
let content = fs::read_to_string(dir.join("main.tf")).unwrap();
let network_block = content
.split("resource \"google_compute_network\" \"this\"")
.nth(1)
.and_then(|rest| rest.split("\n}").next())
.expect("main.tf must declare the google_compute_network resource");
assert!(
network_block.contains("depends_on")
&& network_block.contains("google_project_service.apis"),
"google_compute_network.this must depend on google_project_service.apis: \
{network_block}"
);
}
#[test]
fn gcp_redis_instance_name_stays_under_memorystores_40_char_limit() {
let tmp = TempDir::new().unwrap();
let dir = make_project(&tmp, "my-app");
init(&dir, "my-app", false, Target::GcpCloudRun, false).unwrap();
let content = fs::read_to_string(dir.join("main.tf")).unwrap();
assert!(
content.contains("redis_name_safe = trim(substr(local.app_name_hyphenated, 0, 34)"),
"main.tf must derive a length-bounded local for the Redis instance name, \
capped so the fixed \"-redis\" suffix still fits under Memorystore's \
40-character limit: {content}"
);
assert!(
content.contains("\"${local.redis_name_safe}-redis\""),
"the Redis instance must use the length-bounded local, not app_name_safe \
directly: {content}"
);
}
#[test]
fn gcp_service_account_waits_for_iam_api_activation() {
let tmp = TempDir::new().unwrap();
let dir = make_project(&tmp, "my-app");
init(&dir, "my-app", false, Target::GcpCloudRun, false).unwrap();
let content = fs::read_to_string(dir.join("main.tf")).unwrap();
let sa_block = content
.split("resource \"google_service_account\" \"cloud_run\"")
.nth(1)
.and_then(|rest| rest.split("\n}").next())
.expect("main.tf must declare the google_service_account resource");
assert!(
sa_block.contains("depends_on") && sa_block.contains("google_project_service.apis"),
"google_service_account.cloud_run must depend on \
google_project_service.apis: {sa_block}"
);
}
#[test]
fn gcp_main_tf_postgres_database_name_is_length_bounded() {
let tmp = TempDir::new().unwrap();
let dir = make_project(&tmp, "my-app");
init(&dir, "my-app", false, Target::GcpCloudRun, false).unwrap();
let content = fs::read_to_string(dir.join("main.tf")).unwrap();
let raw_local_line = content
.lines()
.find(|l| l.trim_start().starts_with("postgres_database_name_raw"))
.expect("main.tf must declare a postgres_database_name_raw local");
assert!(
raw_local_line.contains("substr(") && raw_local_line.contains(", 63)"),
"the Postgres database name must be truncated to 63 characters: {raw_local_line}"
);
}
#[test]
fn gcp_main_tf_postgres_database_name_avoids_reserved_names() {
let tmp = TempDir::new().unwrap();
let dir = make_project(&tmp, "my-app");
init(&dir, "my-app", false, Target::GcpCloudRun, false).unwrap();
let content = fs::read_to_string(dir.join("main.tf")).unwrap();
for reserved in ["postgres", "cloudsqladmin", "template0", "template1"] {
assert!(
content.contains(&format!("\"{reserved}\"")),
"the reserved-name guard must list {reserved:?}: {content}"
);
}
let database_name_local = content
.lines()
.skip_while(|l| !l.trim_start().starts_with("postgres_database_name ="))
.take_while(|l| !l.trim_start().starts_with('}'))
.collect::<Vec<_>>()
.join("\n");
assert!(
database_name_local.contains("contains(") && database_name_local.contains("_prod"),
"postgres_database_name must fall back to a suffixed name when the \
sanitized value collides with a reserved database name: {content}"
);
}
#[test]
fn gcp_main_tf_has_secret_manager_with_database_and_signing_secrets() {
let tmp = TempDir::new().unwrap();
let dir = make_project(&tmp, "my-app");
init(&dir, "my-app", false, Target::GcpCloudRun, false).unwrap();
let content = fs::read_to_string(dir.join("main.tf")).unwrap();
assert!(
content.contains("google_secret_manager_secret"),
"main.tf must provision Secret Manager secrets: {content}"
);
assert!(
content.contains("AUTUMN_DATABASE__PRIMARY_URL"),
"main.tf must wire the primary DB URL env var from Secret Manager: {content}"
);
assert!(
content.contains("AUTUMN_SECURITY__SIGNING_SECRET"),
"main.tf must wire the signing secret env var from Secret Manager: {content}"
);
}
#[test]
fn gcp_main_tf_secret_refs_pin_the_created_version_not_latest() {
let tmp = TempDir::new().unwrap();
let dir = make_project(&tmp, "my-app");
init(&dir, "my-app", false, Target::GcpCloudRun, false).unwrap();
let content = fs::read_to_string(dir.join("main.tf")).unwrap();
assert!(
!content.contains("version = \"latest\""),
"no secret_key_ref may pin to the \"latest\" alias — Cloud Run only \
resolves it once per revision, so a Terraform-driven secret rotation \
would silently fail to roll out to already-running instances: {content}"
);
for version_ref in [
"version = google_secret_manager_secret_version.database_url.version",
"version = google_secret_manager_secret_version.signing_secret.version",
"version = google_secret_manager_secret_version.redis_url[0].version",
] {
assert!(
content.contains(version_ref),
"main.tf must pin every secret_key_ref to its version resource's \
`.version` attribute: expected to find `{version_ref}`: {content}"
);
}
}
#[test]
fn gcp_database_url_secret_version_waits_for_sql_user_password_update() {
let tmp = TempDir::new().unwrap();
let dir = make_project(&tmp, "my-app");
init(&dir, "my-app", false, Target::GcpCloudRun, false).unwrap();
let content = fs::read_to_string(dir.join("main.tf")).unwrap();
let secret_version_block = content
.split("resource \"google_secret_manager_secret_version\" \"database_url\"")
.nth(1)
.and_then(|rest| rest.split("\n}").next())
.expect("main.tf must declare the database_url secret version resource");
assert!(
secret_version_block.contains("depends_on")
&& secret_version_block.contains("google_sql_user.this"),
"google_secret_manager_secret_version.database_url must depend on \
google_sql_user.this so a password rotation updates Cloud SQL before the \
secret version (and the Cloud Run revision it triggers) picks up the new \
value: {secret_version_block}"
);
}
#[test]
fn gcp_main_tf_secret_access_is_scoped_per_secret_not_project_wide() {
let tmp = TempDir::new().unwrap();
let dir = make_project(&tmp, "my-app");
init(&dir, "my-app", false, Target::GcpCloudRun, false).unwrap();
let content = fs::read_to_string(dir.join("main.tf")).unwrap();
assert!(
content.contains("google_secret_manager_secret_iam_member"),
"secret access must be granted per-secret via \
google_secret_manager_secret_iam_member: {content}"
);
assert!(
!content.contains("google_project_iam_member\" \"secret"),
"must not grant a project-wide secretAccessor role: {content}"
);
}
#[test]
fn gcp_main_tf_wires_trusted_hosts_so_prod_actually_binds() {
let tmp = TempDir::new().unwrap();
let dir = make_project(&tmp, "my-app");
init(&dir, "my-app", false, Target::GcpCloudRun, false).unwrap();
let content = fs::read_to_string(dir.join("main.tf")).unwrap();
assert!(
content.contains("AUTUMN_SECURITY__TRUSTED_HOSTS__HOSTS"),
"main.tf must set AUTUMN_SECURITY__TRUSTED_HOSTS__HOSTS on the Cloud Run \
service: {content}"
);
assert!(
content.contains("local.service_url_host"),
"the trusted host must be derived from local.service_url_host (known at \
plan time from the project number), not require a second apply: {content}"
);
assert!(
content.contains("data.google_project.this.number"),
"service_url_host must be derived from the project NUMBER (Cloud Run's \
default URL format), not the project ID: {content}"
);
}
#[test]
fn gcp_main_tf_grants_public_invoker_access() {
let tmp = TempDir::new().unwrap();
let dir = make_project(&tmp, "my-app");
init(&dir, "my-app", false, Target::GcpCloudRun, false).unwrap();
let content = fs::read_to_string(dir.join("main.tf")).unwrap();
assert!(
content.contains("google_cloud_run_v2_service_iam_member"),
"main.tf must grant an IAM invoker binding on the Cloud Run service: {content}"
);
assert!(
content.contains("roles/run.invoker") && content.contains("allUsers"),
"main.tf must grant roles/run.invoker to allUsers for public ingress: {content}"
);
}
#[test]
fn gcp_main_tf_service_account_scoped_to_cloudsql_client_not_broader() {
let tmp = TempDir::new().unwrap();
let dir = make_project(&tmp, "my-app");
init(&dir, "my-app", false, Target::GcpCloudRun, false).unwrap();
let content = fs::read_to_string(dir.join("main.tf")).unwrap();
assert!(
content.contains("google_service_account"),
"main.tf must provision a dedicated runtime service account: {content}"
);
assert!(
content.contains("roles/cloudsql.client"),
"the runtime service account must be granted roles/cloudsql.client: {content}"
);
assert!(
!content.contains("roles/editor") && !content.contains("roles/owner"),
"the runtime service account must never be granted a broad primitive role: \
{content}"
);
}
#[test]
fn gcp_main_tf_has_one_shot_migration_job() {
let tmp = TempDir::new().unwrap();
let dir = make_project(&tmp, "my-app");
init(&dir, "my-app", false, Target::GcpCloudRun, false).unwrap();
let content = fs::read_to_string(dir.join("main.tf")).unwrap();
assert!(
content.contains("google_cloud_run_v2_job") && content.contains("\"migrate\""),
"main.tf must provision a one-shot Cloud Run Job for migrations: {content}"
);
assert!(
content.contains("autumn migrate"),
"the migration job must run `autumn migrate`: {content}"
);
}
#[test]
fn gcp_main_tf_redis_api_is_gated_behind_enable_redis_cache() {
let tmp = TempDir::new().unwrap();
let dir = make_project(&tmp, "my-app");
init(&dir, "my-app", false, Target::GcpCloudRun, false).unwrap();
let content = fs::read_to_string(dir.join("main.tf")).unwrap();
assert!(
content.contains("var.enable_redis_cache ? [\"redis.googleapis.com\"] : []"),
"redis.googleapis.com must only be added to required_apis when \
enable_redis_cache is true: {content}"
);
}
#[test]
fn gcp_main_tf_psa_range_is_explicit_and_wont_collide_with_connector_cidr() {
let tmp = TempDir::new().unwrap();
let dir = make_project(&tmp, "my-app");
init(&dir, "my-app", false, Target::GcpCloudRun, false).unwrap();
let content = fs::read_to_string(dir.join("main.tf")).unwrap();
let psa_block = content
.split("resource \"google_compute_global_address\" \"private_service_access\"")
.nth(1)
.and_then(|rest| rest.split("\n}").next())
.expect("main.tf must declare the private_service_access resource");
assert!(
psa_block.contains("address = \"10.100.0.0\""),
"the PSA range must be pinned to an explicit address, not left for \
Google to auto-allocate: {psa_block}"
);
let variables_tf = fs::read_to_string(dir.join("variables.tf")).unwrap();
let default_line = variables_tf
.lines()
.skip_while(|l| !l.contains("variable \"vpc_connector_cidr\""))
.find(|l| l.trim_start().starts_with("default"))
.expect("vpc_connector_cidr must declare a default");
assert!(
default_line.contains("10.8.0.0/28"),
"the default connector CIDR must not have silently changed to \
something that could overlap the pinned 10.100.0.0/16 PSA range: \
{default_line}"
);
}
#[test]
fn gcp_main_tf_redis_is_off_by_default_and_gated() {
let tmp = TempDir::new().unwrap();
let dir = make_project(&tmp, "my-app");
init(&dir, "my-app", false, Target::GcpCloudRun, false).unwrap();
let main_tf = fs::read_to_string(dir.join("main.tf")).unwrap();
assert!(
main_tf.contains("google_redis_instance"),
"main.tf must provision an optional Memorystore Redis instance: {main_tf}"
);
assert!(
main_tf.contains("var.enable_redis_cache ? 1 : 0"),
"the Redis instance must be gated behind enable_redis_cache via count: {main_tf}"
);
let variables_tf = fs::read_to_string(dir.join("variables.tf")).unwrap();
let default_line = variables_tf
.lines()
.skip_while(|l| !l.contains("variable \"enable_redis_cache\""))
.find(|l| l.trim_start().starts_with("default"))
.expect("enable_redis_cache must declare a default");
assert!(
default_line.contains("false"),
"enable_redis_cache must default to false: {default_line}"
);
}
#[test]
fn gcp_main_tf_wires_redis_env_vars_into_cloud_run_service() {
let tmp = TempDir::new().unwrap();
let dir = make_project(&tmp, "my-app");
init(&dir, "my-app", false, Target::GcpCloudRun, false).unwrap();
let content = fs::read_to_string(dir.join("main.tf")).unwrap();
assert!(
content.contains("AUTUMN_CACHE__BACKEND"),
"main.tf must wire AUTUMN_CACHE__BACKEND into the Cloud Run service when \
Redis is enabled: {content}"
);
assert!(
content.contains("AUTUMN_CACHE__REDIS__URL")
&& content.contains("google_secret_manager_secret.redis_url[0].secret_id"),
"main.tf must wire AUTUMN_CACHE__REDIS__URL from the redis_url secret: {content}"
);
}
#[test]
fn gcp_main_tf_has_private_services_access_peering_for_cloud_sql() {
let tmp = TempDir::new().unwrap();
let dir = make_project(&tmp, "my-app");
init(&dir, "my-app", false, Target::GcpCloudRun, false).unwrap();
let content = fs::read_to_string(dir.join("main.tf")).unwrap();
assert!(
content.contains("google_compute_global_address") && content.contains("VPC_PEERING"),
"main.tf must reserve a VPC peering range for private services access: \
{content}"
);
assert!(
content.contains("google_service_networking_connection"),
"main.tf must establish the private services access peering connection: \
{content}"
);
}
#[test]
fn gcp_main_tf_enables_required_apis() {
let tmp = TempDir::new().unwrap();
let dir = make_project(&tmp, "my-app");
init(&dir, "my-app", false, Target::GcpCloudRun, false).unwrap();
let content = fs::read_to_string(dir.join("main.tf")).unwrap();
assert!(
content.contains("google_project_service"),
"main.tf must enable the GCP APIs this scaffold's resources depend on, so a \
fresh project works without a manual `gcloud services enable` step: {content}"
);
for api in [
"run.googleapis.com",
"sqladmin.googleapis.com",
"secretmanager.googleapis.com",
"vpcaccess.googleapis.com",
"artifactregistry.googleapis.com",
"servicenetworking.googleapis.com",
"compute.googleapis.com",
"iam.googleapis.com",
"cloudresourcemanager.googleapis.com",
] {
assert!(
content.contains(api),
"main.tf must enable {api}: {content}"
);
}
}
#[test]
fn gcp_main_tf_project_data_source_waits_for_resourcemanager_api_activation() {
let tmp = TempDir::new().unwrap();
let dir = make_project(&tmp, "my-app");
init(&dir, "my-app", false, Target::GcpCloudRun, false).unwrap();
let content = fs::read_to_string(dir.join("main.tf")).unwrap();
let project_data_block = content
.split("data \"google_project\" \"this\"")
.nth(1)
.and_then(|rest| rest.split("\n}").next())
.expect("main.tf must declare the google_project data source");
assert!(
project_data_block.contains("depends_on")
&& project_data_block.contains("google_project_service.apis"),
"data.google_project.this must depend on google_project_service.apis: \
{project_data_block}"
);
}
#[test]
fn gcp_main_tf_redis_avoids_a_tls_mode_the_client_cant_verify() {
let tmp = TempDir::new().unwrap();
let dir = make_project(&tmp, "my-app");
init(&dir, "my-app", false, Target::GcpCloudRun, false).unwrap();
let content = fs::read_to_string(dir.join("main.tf")).unwrap();
assert!(
content.contains("transit_encryption_mode = \"DISABLED\""),
"google_redis_instance must not claim SERVER_AUTHENTICATION — the client \
can't verify Memorystore's private CA: {content}"
);
assert!(
content.contains("auth_enabled = true"),
"google_redis_instance must still require AUTH: {content}"
);
let redis_url_version = content
.split("resource \"google_secret_manager_secret_version\" \"redis_url\"")
.nth(1)
.and_then(|rest| rest.split("\n}").next())
.expect("main.tf must declare the redis_url secret version");
assert!(
redis_url_version.contains("\"redis://:"),
"the derived redis_url secret must use the redis:// scheme, matching \
transit_encryption_mode = DISABLED, not rediss://: {redis_url_version}"
);
}
#[test]
fn gcp_scale_defaults_match_the_issue() {
let tmp = TempDir::new().unwrap();
let dir = make_project(&tmp, "my-app");
init(&dir, "my-app", false, Target::GcpCloudRun, false).unwrap();
let variables_tf = fs::read_to_string(dir.join("variables.tf")).unwrap();
for (var_name, expected) in [("min_instances", "1"), ("max_instances", "10")] {
let default_line = variables_tf
.lines()
.skip_while(|l| !l.contains(&format!("variable \"{var_name}\"")))
.find(|l| l.trim_start().starts_with("default"))
.unwrap_or_else(|| panic!("{var_name} must declare a default"));
assert!(
default_line.contains(expected),
"{var_name} must default to {expected}: {default_line}"
);
}
let main_tf = fs::read_to_string(dir.join("main.tf")).unwrap();
assert!(
main_tf.contains("max_instance_request_concurrency = 80"),
"main.tf must set concurrency to 80 requests per instance (Cloud Run's own \
default, made explicit and tunable): {main_tf}"
);
}
#[test]
fn gcp_variables_tf_documents_db_tier_connection_budget_vs_max_instances() {
let tmp = TempDir::new().unwrap();
let dir = make_project(&tmp, "my-app");
init(&dir, "my-app", false, Target::GcpCloudRun, false).unwrap();
let variables_tf = fs::read_to_string(dir.join("variables.tf")).unwrap();
assert!(
variables_tf.contains("max_connections") && variables_tf.contains("pool_size"),
"variables.tf must document the relationship between db_tier's \
max_connections ceiling and max_instances * pool_size, so operators know \
to resize db_tier before scaling out in production: {variables_tf}"
);
}
#[test]
fn gcp_main_tf_documents_always_allocated_cpu_option() {
let tmp = TempDir::new().unwrap();
let dir = make_project(&tmp, "my-app");
init(&dir, "my-app", false, Target::GcpCloudRun, false).unwrap();
let content = fs::read_to_string(dir.join("main.tf")).unwrap();
let resources_block = content
.split("resources {")
.nth(1)
.and_then(|rest| rest.split("\n }").next())
.expect("the Cloud Run service container must declare a resources block");
assert!(
resources_block.contains("cpu_idle = true"),
"cpu_idle must default to true (cost-optimized, scale-to-zero-friendly): \
{resources_block}"
);
assert!(
resources_block
.lines()
.any(|l| l.trim_start().starts_with("# cpu_idle = false")),
"an always-allocated-CPU option (cpu_idle = false) must be present, \
commented out, for latency-sensitive workloads: {resources_block}"
);
}
#[test]
fn gcp_secrets_have_no_default_and_are_sensitive() {
let tmp = TempDir::new().unwrap();
let dir = make_project(&tmp, "my-app");
init(&dir, "my-app", false, Target::GcpCloudRun, false).unwrap();
let content = fs::read_to_string(dir.join("variables.tf")).unwrap();
for var_name in ["database_admin_password", "signing_secret"] {
let block = content
.split(&format!("variable \"{var_name}\""))
.nth(1)
.and_then(|rest| rest.split('}').next())
.unwrap_or_else(|| panic!("variables.tf must declare {var_name}"));
assert!(
block.contains("sensitive") && block.contains("true"),
"{var_name} must be marked sensitive: {block}"
);
assert!(
!block.contains("default"),
"{var_name} must have no default value: {block}"
);
}
}
#[test]
fn gcp_no_committed_secret_literals() {
let tmp = TempDir::new().unwrap();
let dir = make_project(&tmp, "my-app");
init(&dir, "my-app", false, Target::GcpCloudRun, false).unwrap();
let tfvars = fs::read_to_string(dir.join("terraform.tfvars.example")).unwrap();
for line in tfvars.lines() {
let trimmed = line.trim();
if trimmed.starts_with('#') {
continue;
}
assert!(
!trimmed.starts_with("database_admin_password")
&& !trimmed.starts_with("signing_secret"),
"terraform.tfvars.example must not assign the secret variables directly \
on a non-comment line: {line:?} in {tfvars}"
);
}
assert!(
tfvars.contains("TF_VAR_database_admin_password")
&& tfvars.contains("TF_VAR_signing_secret"),
"terraform.tfvars.example must document the TF_VAR_* env var pattern instead: \
{tfvars}"
);
}
#[test]
fn gcp_target_adds_terraform_gitignore_entries() {
let tmp = TempDir::new().unwrap();
let dir = make_project(&tmp, "my-app");
init(&dir, "my-app", false, Target::GcpCloudRun, false).unwrap();
let gitignore = fs::read_to_string(dir.join(".gitignore")).unwrap();
for pattern in [
".terraform/",
"*.tfstate",
"*.tfstate.*",
"terraform.tfvars",
] {
assert!(
gitignore.contains(pattern),
".gitignore must contain {pattern:?} for the gcp-cloud-run target: {gitignore}"
);
}
}
#[test]
fn gcp_dockerignore_excludes_terraform_state() {
let tmp = TempDir::new().unwrap();
let dir = make_project(&tmp, "my-app");
init(&dir, "my-app", false, Target::GcpCloudRun, false).unwrap();
let content = fs::read_to_string(dir.join(".dockerignore")).unwrap();
for pattern in [".terraform/", "*.tfstate", "terraform.tfvars"] {
assert!(
content.contains(pattern),
".dockerignore must exclude {pattern:?} so terraform.tfstate is never sent \
to the Docker build context: {content}"
);
}
}
#[test]
fn gcp_outputs_tf_declares_expected_outputs_with_no_unsubstituted_placeholders() {
let tmp = TempDir::new().unwrap();
let dir = make_project(&tmp, "my-blog");
init(&dir, "my-blog", false, Target::GcpCloudRun, false).unwrap();
let content = fs::read_to_string(dir.join("outputs.tf")).unwrap();
for output in [
"service_url",
"service_name",
"artifact_registry_repository_url",
"migrate_job_name",
"service_account_email",
"project_id",
] {
assert!(
content.contains(&format!("output \"{output}\"")),
"outputs.tf must declare output {output:?}: {content}"
);
}
assert!(
content.contains("my-blog"),
"outputs.tf must substitute the project name: {content}"
);
assert!(
!content.contains("{{"),
"outputs.tf must not contain unsubstituted placeholders: {content}"
);
}
#[test]
fn init_without_force_errors_if_gcp_workflow_file_exists() {
let tmp = TempDir::new().unwrap();
let dir = make_project(&tmp, "my-app");
fs::create_dir_all(dir.join(".github/workflows")).unwrap();
fs::write(dir.join(".github/workflows/gcp-deploy.yml"), "existing").unwrap();
let err = init(&dir, "my-app", false, Target::GcpCloudRun, false).unwrap_err();
assert!(matches!(err, ReleaseError::FileExists(_)));
}
#[test]
fn gcp_target_gitignore_merge_is_idempotent_on_repeat_init() {
let tmp = TempDir::new().unwrap();
let dir = make_project(&tmp, "my-app");
init(&dir, "my-app", false, Target::GcpCloudRun, false).unwrap();
init(&dir, "my-app", true, Target::GcpCloudRun, false).unwrap();
let gitignore = fs::read_to_string(dir.join(".gitignore")).unwrap();
let terraform_dir_count = gitignore
.lines()
.filter(|l| l.trim() == ".terraform/")
.count();
assert_eq!(
terraform_dir_count, 1,
"re-running init with --force must not duplicate gitignore entries: {gitignore}"
);
}
#[test]
fn gcp_workflow_triggers_on_tag_push_and_manual_dispatch() {
let tmp = TempDir::new().unwrap();
let dir = make_project(&tmp, "my-app");
init(&dir, "my-app", false, Target::GcpCloudRun, false).unwrap();
let content = fs::read_to_string(dir.join(".github/workflows/gcp-deploy.yml")).unwrap();
assert!(
content.contains("tags:"),
"gcp-deploy.yml must trigger on tag push: {content}"
);
assert!(
content.contains("workflow_dispatch:"),
"gcp-deploy.yml must also support manual dispatch: {content}"
);
}
#[test]
fn gcp_workflow_authenticates_via_workload_identity_federation() {
let tmp = TempDir::new().unwrap();
let dir = make_project(&tmp, "my-app");
init(&dir, "my-app", false, Target::GcpCloudRun, false).unwrap();
let content = fs::read_to_string(dir.join(".github/workflows/gcp-deploy.yml")).unwrap();
assert!(
content.contains("google-github-actions/auth@v2"),
"gcp-deploy.yml must authenticate via google-github-actions/auth: {content}"
);
assert!(
content.contains("workload_identity_provider"),
"gcp-deploy.yml must use Workload Identity Federation, not a static key: {content}"
);
assert!(
!content.to_lowercase().contains("credentials_json"),
"gcp-deploy.yml must not authenticate via a downloaded service-account key: \
{content}"
);
}
#[test]
fn gcp_workflow_selects_the_configured_target_project() {
let tmp = TempDir::new().unwrap();
let dir = make_project(&tmp, "my-app");
init(&dir, "my-app", false, Target::GcpCloudRun, false).unwrap();
let content = fs::read_to_string(dir.join(".github/workflows/gcp-deploy.yml")).unwrap();
let auth_step = content
.split("uses: google-github-actions/auth@v2")
.nth(1)
.and_then(|rest| rest.split("- name:").next())
.expect("the auth step must be present");
assert!(
auth_step.contains("project_id: ${{ vars.GCP_PROJECT_ID }}"),
"the auth step must pass project_id so gcloud targets the configured \
project, not wherever the deployer service account happens to live: \
{auth_step}"
);
}
#[test]
fn gcp_workflow_sets_up_gcloud_and_configures_docker_before_building() {
let tmp = TempDir::new().unwrap();
let dir = make_project(&tmp, "my-app");
init(&dir, "my-app", false, Target::GcpCloudRun, false).unwrap();
let content = fs::read_to_string(dir.join(".github/workflows/gcp-deploy.yml")).unwrap();
let auth_pos = content
.find("google-github-actions/auth@v2")
.expect("auth step must be present");
let setup_pos = content
.find("google-github-actions/setup-gcloud@v2")
.expect("gcp-deploy.yml must set up the gcloud CLI");
let configure_docker_pos = content
.find("run: gcloud auth configure-docker")
.expect("gcp-deploy.yml must configure Docker for Artifact Registry");
let build_pos = content
.find("docker build \\")
.expect("build step must be present");
assert!(
auth_pos < setup_pos
&& setup_pos < configure_docker_pos
&& configure_docker_pos < build_pos,
"auth, then gcloud setup, then Docker configuration must all precede the \
build step: {content}"
);
}
#[test]
fn gcp_workflow_builds_pushes_to_artifact_registry_and_deploys() {
let tmp = TempDir::new().unwrap();
let dir = make_project(&tmp, "my-app");
init(&dir, "my-app", false, Target::GcpCloudRun, false).unwrap();
let content = fs::read_to_string(dir.join(".github/workflows/gcp-deploy.yml")).unwrap();
assert!(
content.contains("docker build"),
"gcp-deploy.yml must build the release image: {content}"
);
assert!(
content.contains("docker push"),
"gcp-deploy.yml must push to Artifact Registry: {content}"
);
assert!(
content.contains("gcloud run services update"),
"gcp-deploy.yml must deploy the new image to the Cloud Run service: {content}"
);
}
#[test]
fn gcp_workflow_passes_git_provenance_build_args_to_docker() {
let tmp = TempDir::new().unwrap();
let dir = make_project(&tmp, "my-app");
init(&dir, "my-app", false, Target::GcpCloudRun, false).unwrap();
let content = fs::read_to_string(dir.join(".github/workflows/gcp-deploy.yml")).unwrap();
for arg in [
"AUTUMN_BUILD_GIT_SHA",
"AUTUMN_BUILD_GIT_SHA_SHORT",
"AUTUMN_BUILD_GIT_BRANCH",
"AUTUMN_BUILD_GIT_DIRTY",
"AUTUMN_BUILD_TIMESTAMP",
] {
assert!(
content.contains(&format!("--build-arg {arg}=")),
"gcp-deploy.yml's docker build must pass --build-arg {arg}: {content}"
);
}
}
#[test]
fn gcp_workflow_updates_migration_job_image_before_executing_it() {
let tmp = TempDir::new().unwrap();
let dir = make_project(&tmp, "my-app");
init(&dir, "my-app", false, Target::GcpCloudRun, false).unwrap();
let content = fs::read_to_string(dir.join(".github/workflows/gcp-deploy.yml")).unwrap();
let migration_step = content
.split("Run database migrations")
.nth(1)
.and_then(|rest| rest.split("- name:").next())
.expect("a 'Run database migrations' step must exist");
let update_pos = migration_step
.find("gcloud run jobs update")
.expect("the migration job's image must be updated first");
let execute_pos = migration_step
.find("gcloud run jobs execute")
.expect("`jobs execute` must follow to actually run the now-updated job");
assert!(
update_pos < execute_pos,
"the job's image must be updated BEFORE it's executed: {migration_step}"
);
let update_block = &migration_step[update_pos..execute_pos];
assert!(
update_block.contains("--image"),
"`jobs update` must be the one that carries --image: {update_block}"
);
let execute_block = &migration_step[execute_pos..];
assert!(
!execute_block.contains("--image"),
"`jobs execute` must not carry --image: {execute_block}"
);
assert!(
execute_block.contains("--wait"),
"`jobs execute` must block until the execution finishes via --wait, so the \
deploy step below never runs against an unmigrated schema: {execute_block}"
);
}
#[test]
fn gcp_workflow_never_hardcodes_credentials() {
let tmp = TempDir::new().unwrap();
let dir = make_project(&tmp, "my-app");
init(&dir, "my-app", false, Target::GcpCloudRun, false).unwrap();
let content = fs::read_to_string(dir.join(".github/workflows/gcp-deploy.yml")).unwrap();
assert!(
content.contains("secrets."),
"gcp-deploy.yml must source credentials from GitHub Actions secrets, never \
hardcode them: {content}"
);
}
#[test]
fn gcp_workflow_runs_migrations_before_updating_the_service() {
let tmp = TempDir::new().unwrap();
let dir = make_project(&tmp, "my-app");
init(&dir, "my-app", false, Target::GcpCloudRun, false).unwrap();
let content = fs::read_to_string(dir.join(".github/workflows/gcp-deploy.yml")).unwrap();
let migrate_pos = content
.find("gcloud run jobs execute \"$GCP_MIGRATE_JOB_NAME\"")
.expect("migration job execute must be present");
let deploy_pos = content
.find("gcloud run services update \"$GCP_SERVICE_NAME\"")
.expect("deploy step must be present");
assert!(
migrate_pos < deploy_pos,
"the migration job must run BEFORE the service is updated to the new image: \
{content}"
);
}
#[test]
fn gcp_workflow_sanitizes_ref_name_for_docker_tag() {
let tmp = TempDir::new().unwrap();
let dir = make_project(&tmp, "my-app");
init(&dir, "my-app", false, Target::GcpCloudRun, false).unwrap();
let content = fs::read_to_string(dir.join(".github/workflows/gcp-deploy.yml")).unwrap();
assert!(
content.contains("tr -c 'A-Za-z0-9_.-' '-'"),
"gcp-deploy.yml must map every character outside Docker's tag charset to \"-\": \
{content}"
);
assert!(
!content.contains(":${GITHUB_REF_NAME}") && !content.contains(":$GITHUB_REF_NAME"),
"no docker/gcloud command may use the raw, unsanitized ref as an image tag: \
{content}"
);
}
#[test]
fn gcp_workflow_image_tag_is_unique_per_execution() {
let tmp = TempDir::new().unwrap();
let dir = make_project(&tmp, "my-app");
init(&dir, "my-app", false, Target::GcpCloudRun, false).unwrap();
let content = fs::read_to_string(dir.join(".github/workflows/gcp-deploy.yml")).unwrap();
assert!(
content.contains("${GITHUB_SHA:0:12}"),
"the computed image tag must include the commit SHA: {content}"
);
assert!(
content.contains("${GITHUB_RUN_ID}") && content.contains("${GITHUB_RUN_ATTEMPT}"),
"the computed image tag must also include the run ID and run attempt: {content}"
);
}
#[test]
fn gcp_workflow_serializes_overlapping_runs() {
let tmp = TempDir::new().unwrap();
let dir = make_project(&tmp, "my-app");
init(&dir, "my-app", false, Target::GcpCloudRun, false).unwrap();
let content = fs::read_to_string(dir.join(".github/workflows/gcp-deploy.yml")).unwrap();
assert!(
content.contains("concurrency:"),
"gcp-deploy.yml must define a concurrency group so overlapping runs queue \
instead of racing: {content}"
);
assert!(
content.contains("cancel-in-progress: false"),
"cancel-in-progress must be false — killing a run mid-migration or \
mid-cutover is worse than making the next run wait: {content}"
);
}
#[test]
fn gcp_workflow_guards_against_superseded_run_before_migrating() {
let tmp = TempDir::new().unwrap();
let dir = make_project(&tmp, "my-app");
init(&dir, "my-app", false, Target::GcpCloudRun, false).unwrap();
let content = fs::read_to_string(dir.join(".github/workflows/gcp-deploy.yml")).unwrap();
assert!(
content.contains("actions: read"),
"gcp-deploy.yml must grant actions: read to query other workflow runs: {content}"
);
assert!(
content.contains("run_number > ${{ github.run_number }}"),
"the guard must compare against other runs' run_number: {content}"
);
let guard_pos = content
.find("gh api")
.expect("the run_number staleness guard must be present");
let migrate_pos = content
.find("gcloud run jobs execute \"$GCP_MIGRATE_JOB_NAME\"")
.expect("migration job execute must be present");
let deploy_pos = content
.find("gcloud run services update \"$GCP_SERVICE_NAME\"")
.expect("deploy step must be present");
assert!(
guard_pos < migrate_pos && migrate_pos < deploy_pos,
"the staleness guard must run BEFORE migration, which must run BEFORE \
deploy: {content}"
);
}
#[test]
fn gcp_workflow_documents_that_repo_variables_need_manual_resync() {
let tmp = TempDir::new().unwrap();
let dir = make_project(&tmp, "my-app");
init(&dir, "my-app", false, Target::GcpCloudRun, false).unwrap();
let content = fs::read_to_string(dir.join(".github/workflows/gcp-deploy.yml")).unwrap();
assert!(
content.contains("not a live link to Terraform") && content.contains("manually re-run"),
"gcp-deploy.yml's header comment must warn that the repository variables \
are a manual snapshot that goes stale after an app_name/region change, \
not something kept in sync automatically: {content}"
);
}
#[test]
fn gcp_workflow_sources_service_name_from_terraform_not_hardcoded() {
let tmp = TempDir::new().unwrap();
let dir = make_project(&tmp, "My_Test_App");
init(&dir, "My_Test_App", false, Target::GcpCloudRun, false).unwrap();
let content = fs::read_to_string(dir.join(".github/workflows/gcp-deploy.yml")).unwrap();
assert!(
content.contains("vars.GCP_SERVICE_NAME"),
"GCP_SERVICE_NAME must be sourced from a repository variable (terraform \
output service_name), not hardcoded: {content}"
);
assert!(
!content.contains("My_Test_App") && !content.contains("my-test-app"),
"gcp-deploy.yml must not bake in any form of the project name as a GCP \
resource identifier: {content}"
);
assert!(
!content.contains("{{project_name}}"),
"gcp-deploy.yml must not contain unsubstituted placeholders: {content}"
);
}
const ALL_TARGETS: [Target; 8] = [
Target::Default,
Target::Fly,
Target::DockerCompose,
Target::AzureContainerApps,
Target::AwsAppRunner,
Target::AwsEcs,
Target::GcpCloudRun,
Target::Kubernetes,
];
fn probe_expectations(target: Target) -> &'static [(&'static str, &'static str, &'static str)] {
const DOCKER_HEALTHCHECK: (&str, &str, &str) = (
"Dockerfile",
"${AUTUMN_HEALTHCHECK_URL:-",
"localhost:3000/startup",
);
match target {
Target::Default | Target::DockerCompose => &[DOCKER_HEALTHCHECK],
Target::Fly => &[
DOCKER_HEALTHCHECK,
("fly.toml", "[[http_service.checks]]", r#"path = "/ready""#),
("fly.toml", "[checks.live]", r#"path = "/live""#),
],
Target::AwsEcs => &[
DOCKER_HEALTHCHECK,
("main.tf", "health_check {", r#"path = "/ready""#),
("main.tf", "command = [", "location /ready"),
],
Target::AwsAppRunner => &[
DOCKER_HEALTHCHECK,
("main.tf", "health_check_configuration {", r#"path = "/""#),
],
Target::GcpCloudRun => &[
DOCKER_HEALTHCHECK,
("main.tf", "startup_probe {", r#"path = "/ready""#),
("main.tf", "liveness_probe {", r#"path = "/live""#),
],
Target::AzureContainerApps => &[
DOCKER_HEALTHCHECK,
("main.tf", "startup_probe {", r#"path = "/startup""#),
("main.tf", "readiness_probe {", r#"path = "/ready""#),
("main.tf", "liveness_probe {", r#"path = "/live""#),
],
Target::Kubernetes => &[
DOCKER_HEALTHCHECK,
(KUSTOMIZE_DEPLOYMENT, "startupProbe:", "path: /startup"),
(KUSTOMIZE_DEPLOYMENT, "readinessProbe:", "path: /ready"),
(KUSTOMIZE_DEPLOYMENT, "livenessProbe:", "path: /live"),
(HELM_VALUES, "probes:", "startupPath: /startup"),
(HELM_VALUES, "probes:", "readyPath: /ready"),
(HELM_VALUES, "probes:", "livePath: /live"),
],
}
}
fn render_target(target: Target, split_workers: bool) -> Vec<(String, String)> {
let tmp = TempDir::new().unwrap();
let dir = make_project(&tmp, "my-app");
init(&dir, "my-app", false, target, split_workers).unwrap();
planned_files(target)
.into_iter()
.map(|(name, _)| {
let content = fs::read_to_string(dir.join(name))
.unwrap_or_else(|err| panic!("read generated {name}: {err}"))
.replace("\r\n", "\n");
(name.to_owned(), content)
})
.collect()
}
fn probe_block(content: &str, marker: &str) -> Option<String> {
let start = content.find(marker)?;
let rest = &content[start..];
let end = [rest.find('}'), rest.find("\n\n")]
.into_iter()
.flatten()
.min()
.unwrap_or(rest.len());
Some(rest[..end].split_whitespace().collect::<Vec<_>>().join(" "))
}
#[test]
fn release_templates_probe_ready_for_traffic_and_live_for_liveness() {
for target in ALL_TARGETS {
let files = render_target(target, false);
for &(file, marker, needle) in probe_expectations(target) {
let content = &files
.iter()
.find(|(name, _)| name == file)
.unwrap_or_else(|| panic!("{target:?} must generate {file}"))
.1;
let block = probe_block(content, marker).unwrap_or_else(|| {
panic!("{target:?}: {file} must contain `{marker}`:\n{content}")
});
assert!(
block.contains(needle),
"{target:?}: the `{marker}` block in {file} must contain `{needle}`, \
got: {block}"
);
}
for (name, content) in &files {
for marker in ["liveness_probe {", "[checks."] {
for (at, _) in content.match_indices(marker) {
let block = probe_block(&content[at..], marker).unwrap_or_default();
assert!(
!block.contains("/ready") && !block.contains("/startup"),
"{target:?}: a liveness probe in {name} must use /live, got: {block}"
);
}
}
}
}
}
#[test]
fn all_targets_lists_every_cli_target() {
let err = "not-a-target".parse::<Target>().unwrap_err();
let names: Vec<&str> = err.split('\'').skip(3).step_by(2).collect();
assert!(names.len() >= 6, "cannot read target names from: {err}");
for name in &names {
let target: Target = name
.parse()
.unwrap_or_else(|e| panic!("`{name}` from the error must parse: {e}"));
assert!(
ALL_TARGETS.contains(&target),
"ALL_TARGETS must contain {target:?}"
);
}
assert_eq!(
ALL_TARGETS.len(),
names.len() + 1,
"ALL_TARGETS must hold each target once"
);
}
#[test]
fn release_templates_never_probe_the_health_alias() {
let mut renders: Vec<_> = ALL_TARGETS
.into_iter()
.map(|target| (target, render_target(target, false)))
.collect();
renders.push((
Target::DockerCompose,
render_target(Target::DockerCompose, true),
));
for (target, files) in renders {
for (name, content) in files {
if name == "autumn.production.toml.example" {
continue;
}
assert!(
!content.contains("/health"),
"{target:?}: {name} must not use the /health alias for a probe:\n{content}"
);
}
}
}
const KUSTOMIZE_DEPLOYMENT: &str = "deploy/kustomize/base/deployment.yaml";
const HELM_VALUES: &str = "deploy/helm/values.yaml";
fn kubernetes_file(files: &[(String, String)], name: &str) -> String {
files
.iter()
.find(|(n, _)| n == name)
.unwrap_or_else(|| panic!("kubernetes target must generate {name}"))
.1
.clone()
}
#[test]
fn kubernetes_parses_from_the_cli_name() {
assert_eq!("kubernetes".parse::<Target>(), Ok(Target::Kubernetes));
assert!(!is_terraform_target(Target::Kubernetes));
}
#[test]
fn kubernetes_target_writes_a_helm_chart_and_a_kustomize_base() {
let names: Vec<_> = render_target(Target::Kubernetes, false)
.into_iter()
.map(|(name, _)| name)
.collect();
for expected in [
"Dockerfile",
"deploy/helm/Chart.yaml",
"deploy/helm/values.yaml",
"deploy/helm/.helmignore",
"deploy/helm/templates/_helpers.tpl",
"deploy/helm/templates/deployment.yaml",
"deploy/helm/templates/rollout.yaml",
"deploy/helm/templates/canary.yaml",
"deploy/helm/templates/service.yaml",
"deploy/helm/templates/pdb.yaml",
"deploy/helm/templates/podmonitor.yaml",
"deploy/helm/templates/NOTES.txt",
"deploy/kustomize/base/kustomization.yaml",
KUSTOMIZE_DEPLOYMENT,
"deploy/kustomize/base/service.yaml",
"deploy/kustomize/base/pdb.yaml",
] {
assert!(
names.iter().any(|n| n == expected),
"missing {expected}: {names:?}"
);
}
}
#[test]
fn kubernetes_next_steps_use_a_helm_safe_release_name() {
let steps = kubernetes_next_steps("my_shop");
assert!(
steps.contains("helm upgrade --install my-shop deploy/helm"),
"{steps}"
);
assert!(!steps.contains("my_shop"), "{steps}");
}
#[test]
fn kubernetes_names_are_dns_safe() {
assert_eq!(k8s_name("My_App"), "my-app");
assert_eq!(k8s_name("app_"), "app");
assert_eq!(k8s_name(&"a".repeat(80)).len(), 53);
let tmp = TempDir::new().unwrap();
let dir = make_project(&tmp, "my_shop");
init(&dir, "my_shop", false, Target::Kubernetes, false).unwrap();
let chart = fs::read_to_string(dir.join("deploy/helm/Chart.yaml")).unwrap();
assert!(chart.lines().any(|l| l == "name: my-shop"), "{chart}");
let pdb = fs::read_to_string(dir.join("deploy/kustomize/base/pdb.yaml")).unwrap();
assert!(pdb.lines().any(|l| l.trim() == "name: my-shop"), "{pdb}");
}
#[test]
fn kubernetes_templates_use_only_placeholders_the_ci_check_knows() {
for (name, template) in planned_files(Target::Kubernetes)
.into_iter()
.filter(|(name, _)| name.starts_with("deploy/"))
{
let mut rest = template;
while let Some(start) = rest.find("{{") {
let after = &rest[start + 2..];
let end = after.find("}}").unwrap_or(after.len());
let token = &after[..end];
if token.chars().all(|c| c.is_ascii_lowercase() || c == '_') {
assert!(
token == "k8s_name" || token == "project_name",
"{name}: unknown placeholder {{{{{token}}}}}"
);
}
rest = &after[end..];
}
}
for (name, content) in render_target(Target::Kubernetes, false) {
assert!(
!content.contains("{{k8s_name}}"),
"{name} kept a placeholder"
);
assert!(
!content.contains("{{project_name}}"),
"{name} kept a placeholder"
);
}
}
#[test]
fn kubernetes_grace_period_is_longer_than_the_drain_window() {
let files = render_target(Target::Kubernetes, false);
let deployment = kubernetes_file(&files, KUSTOMIZE_DEPLOYMENT);
let number_after = |needle: &str| -> u64 {
let at = deployment
.find(needle)
.unwrap_or_else(|| panic!("{needle} missing:\n{deployment}"));
deployment[at + needle.len()..]
.trim_start_matches(|c: char| !c.is_ascii_digit())
.chars()
.take_while(char::is_ascii_digit)
.collect::<String>()
.parse()
.unwrap()
};
let grace = number_after("terminationGracePeriodSeconds:");
let pre_stop = number_after("command: [\"sleep\",");
let prestop_grace = number_after("AUTUMN_SERVER__PRESTOP_GRACE_SECS\n");
let shutdown = number_after("AUTUMN_SERVER__SHUTDOWN_TIMEOUT_SECS\n");
assert!(
grace > pre_stop + prestop_grace + shutdown,
"grace {grace} must exceed {pre_stop} + {prestop_grace} + {shutdown}"
);
assert_eq!(grace, 50);
let helpers = kubernetes_file(&files, "deploy/helm/templates/_helpers.tpl");
assert!(helpers.contains(
"add $s.preStopSleepSeconds $s.prestopGraceSeconds $s.shutdownTimeoutSeconds $s.bufferSeconds"
));
assert!(helpers.contains("fail \"shutdown.bufferSeconds must be 1 or more"));
assert!(
helpers.contains("terminationGracePeriodSeconds: {{ include \"app.gracePeriod\" . }}")
);
assert!(helpers.contains("AUTUMN_SERVER__PRESTOP_GRACE_SECS"));
assert!(helpers.contains("AUTUMN_SERVER__SHUTDOWN_TIMEOUT_SECS"));
assert!(helpers.contains("preStop:"));
}
#[test]
fn kubernetes_manifests_set_trusted_hosts_and_trust_the_pod_ip() {
let files = render_target(Target::Kubernetes, false);
let helpers = kubernetes_file(&files, "deploy/helm/templates/_helpers.tpl");
assert!(helpers.contains("fail \"set trustedHosts"));
assert!(helpers.contains("AUTUMN_SECURITY__TRUSTED_HOSTS__HOSTS"));
assert!(helpers.contains("fieldPath: status.podIP"));
assert!(helpers.contains("$(POD_IP)"));
let deployment = kubernetes_file(&files, KUSTOMIZE_DEPLOYMENT);
assert!(deployment.contains("value: \"my-app.example.com,$(POD_IP)\""));
let pod_ip = deployment.find("name: POD_IP").expect("POD_IP");
let hosts = deployment
.find("AUTUMN_SECURITY__TRUSTED_HOSTS__HOSTS")
.expect("hosts");
assert!(
pod_ip < hosts,
"POD_IP must come first, so $(POD_IP) expands"
);
assert!(kubernetes_next_steps("my_shop").contains("trustedHosts[0]=<your-domain>"));
}
#[test]
fn kubernetes_manifests_have_a_pod_disruption_budget() {
let files = render_target(Target::Kubernetes, false);
for name in [
"deploy/helm/templates/pdb.yaml",
"deploy/kustomize/base/pdb.yaml",
] {
let pdb = kubernetes_file(&files, name);
assert!(pdb.contains("kind: PodDisruptionBudget"), "{name}");
assert!(pdb.contains("maxUnavailable:"), "{name}");
}
let kustomization = kubernetes_file(&files, "deploy/kustomize/base/kustomization.yaml");
assert!(kustomization.contains("- pdb.yaml"), "{kustomization}");
}
#[test]
fn kubernetes_chart_wires_rollout_analysis_to_the_slo_templates() {
let files = render_target(Target::Kubernetes, false);
let values = kubernetes_file(&files, HELM_VALUES);
for key in ["templateName:", "maxBurnRate: 14.4", "metricTemplates:"] {
assert!(values.contains(key), "values.yaml needs analysis {key}");
}
let rollout = kubernetes_file(&files, "deploy/helm/templates/rollout.yaml");
assert!(rollout.contains("kind: Rollout"));
assert!(rollout.contains(".Values.analysis.templateName"));
assert!(rollout.contains("name: canary-hash"));
assert!(rollout.contains("podTemplateHashValue: Latest"));
let helpers = kubernetes_file(&files, "deploy/helm/templates/_helpers.tpl");
assert!(helpers.contains("fieldPath: metadata.labels['rollouts-pod-template-hash']"));
let canary = kubernetes_file(&files, "deploy/helm/templates/canary.yaml");
assert!(canary.contains("kind: Canary"));
assert!(canary.contains("range .Values.analysis.metricTemplates"));
assert!(canary.contains("max: {{ $.Values.analysis.maxBurnRate }}"));
let deployment = kubernetes_file(&files, "deploy/helm/templates/deployment.yaml");
assert!(deployment.contains("fail \"set rollout.enabled or flagger.enabled, not both\""));
}
}