use crate::GeneratedFile;
use crate::Step;
use crate::registry::service_def::Color;
pub fn color_unit(service_name: &str, color: Color) -> String {
format!("{service_name}-{color}")
}
pub fn expand_color_quadlets(files: Vec<GeneratedFile>, service_name: &str) -> Vec<GeneratedFile> {
let main = format!("{service_name}.container");
let mut out = Vec::with_capacity(files.len() + 1);
for f in files {
let is_main = f.path.file_name().and_then(|n| n.to_str()) == Some(main.as_str());
if is_main {
for color in [Color::Blue, Color::Green] {
out.push(GeneratedFile {
path: f
.path
.with_file_name(color_quadlet_filename(service_name, color)),
content: podman_color_quadlet(&f.content, service_name, color),
});
}
} else {
out.push(f);
}
}
out
}
pub fn color_quadlet_filename(service_name: &str, color: Color) -> String {
format!("{service_name}-{color}.container")
}
pub fn color_port_var(base_port_var: &str, color: Color) -> String {
format!("{base_port_var}_{}", color.as_str().to_uppercase())
}
pub fn podman_color_quadlet(content: &str, service_name: &str, color: Color) -> String {
let mut out = String::with_capacity(content.len() + 16);
for line in content.lines() {
let trimmed = line.trim_start();
if let Some(rest) = trimmed.strip_prefix("ContainerName=") {
let indent = &line[..line.len() - trimmed.len()];
if rest.trim() == service_name {
out.push_str(&format!("{indent}ContainerName={service_name}-{color}\n"));
continue;
}
}
out.push_str(&colorize_port_vars(line, color));
out.push('\n');
}
out
}
fn colorize_port_vars(line: &str, color: Color) -> String {
const MARKER: &str = "${SERVICE_PORT_";
let suffix = format!("_{}", color.as_str().to_uppercase());
let mut out = String::with_capacity(line.len() + suffix.len());
let mut rest = line;
while let Some(pos) = rest.find(MARKER) {
let (before, from_marker) = rest.split_at(pos);
out.push_str(before);
match from_marker[MARKER.len()..].find('}') {
Some(close_rel) => {
let close = MARKER.len() + close_rel;
out.push_str(&from_marker[..close]); if !from_marker[..close].ends_with(&suffix) {
out.push_str(&suffix);
}
out.push('}');
rest = &from_marker[close + 1..];
}
None => {
out.push_str(from_marker);
return out;
}
}
}
out.push_str(rest);
out
}
pub fn native_color_unit(p: &NativeColorUnit) -> String {
format!(
"[Unit]\n\
Description={description} ({color})\n\
After=network.target\n\
\n\
[Service]\n\
Type=simple\n\
WorkingDirectory={workdir}\n\
EnvironmentFile={home}/.env\n\
Environment=SERVICE_HOME={home}\n\
Environment={port_var}={port}\n\
Environment=PATH=%h/.local/bin:%h/.cargo/bin:%h/.bun/bin:%h/.deno/bin:%h/go/bin:/usr/local/bin:/usr/bin:/bin\n\
ExecStart=/bin/sh -c 'exec {run}'\n\
Restart=always\n\
RestartSec=5\n\
\n\
[Install]\n\
WantedBy=default.target\n",
description = p.description,
color = p.color,
workdir = p.workdir,
home = p.home,
port_var = p.port_var,
port = p.port,
run = p.run,
)
}
pub struct NativeColorUnit<'a> {
pub description: &'a str,
pub color: Color,
pub workdir: &'a str,
pub home: &'a str,
pub port_var: &'a str,
pub port: u16,
pub run: &'a str,
}
pub struct ColorSwap {
pub service_name: String,
pub live: Color,
pub prepare: Option<Step>,
pub health_url: String,
pub health_timeout_secs: u32,
pub caddy_rewrite: Option<Step>,
}
impl ColorSwap {
pub fn target(&self) -> Color {
self.live.other()
}
}
pub fn color_swap_steps(swap: ColorSwap) -> Vec<Step> {
let target = swap.target();
let start_unit = color_unit(&swap.service_name, target);
let stop_unit = color_unit(&swap.service_name, swap.live);
let mut steps = Vec::new();
if let Some(prepare) = swap.prepare {
steps.push(prepare);
}
steps.push(Step::StartService { unit: start_unit });
steps.push(Step::WaitForHttpHealthy {
url: swap.health_url,
expect_status: 200,
timeout_secs: swap.health_timeout_secs,
});
if let Some(rewrite) = swap.caddy_rewrite {
steps.push(rewrite);
steps.push(Step::ReloadCaddy);
}
steps.push(Step::StopService { unit: stop_unit });
steps
}
#[cfg(test)]
mod tests {
use super::*;
use crate::GeneratedFile;
use std::path::PathBuf;
fn caddy_write() -> Step {
Step::WriteFile(GeneratedFile {
path: PathBuf::from("/etc/caddy/Caddyfile"),
content: "reverse_proxy app-green:8080".into(),
})
}
#[test]
fn target_is_the_other_color() {
let swap = ColorSwap {
service_name: "app".into(),
live: Color::Blue,
prepare: None,
health_url: "http://127.0.0.1:9001/healthz".into(),
health_timeout_secs: 60,
caddy_rewrite: None,
};
assert_eq!(swap.target(), Color::Green);
}
#[test]
fn podman_swap_has_canonical_order() {
let steps = color_swap_steps(ColorSwap {
service_name: "app".into(),
live: Color::Green,
prepare: Some(Step::PullImage {
image: "ghcr.io/me/app:v2".into(),
}),
health_url: "http://127.0.0.1:9001/healthz".into(),
health_timeout_secs: 60,
caddy_rewrite: Some(caddy_write()),
});
assert!(matches!(steps[0], Step::PullImage { .. }));
assert!(matches!(&steps[1], Step::StartService { unit } if unit == "app-blue"));
assert!(matches!(steps[2], Step::WaitForHttpHealthy { .. }));
assert!(matches!(steps[3], Step::WriteFile(_)));
assert!(matches!(steps[4], Step::ReloadCaddy));
assert!(matches!(&steps[5], Step::StopService { unit } if unit == "app-green"));
assert_eq!(steps.len(), 6);
}
#[test]
fn native_swap_builds_the_idle_slot_first() {
let steps = color_swap_steps(ColorSwap {
service_name: "api".into(),
live: Color::Blue,
prepare: Some(Step::Build {
dir: PathBuf::from("/srv/api/colors/green"),
command: "cargo build --release".into(),
}),
health_url: "http://127.0.0.1:9002/healthz".into(),
health_timeout_secs: 120,
caddy_rewrite: Some(caddy_write()),
});
match &steps[0] {
Step::Build { dir, .. } => assert!(dir.ends_with("colors/green")),
_ => panic!("expected Build step first"),
}
assert!(matches!(&steps[1], Step::StartService { unit } if unit == "api-green"));
assert!(matches!(&steps[5], Step::StopService { unit } if unit == "api-blue"));
}
const AUTHELIA_QUADLET: &str = "\
[Container]
Image=docker.io/authelia/authelia:4.39
ContainerName=authelia
Network=authelia.network
PublishPort=${SERVICE_PORT_HTTP}:9091
Volume=${SERVICE_HOME}/config:/config:Z
EnvironmentFile=%h/.local/share/services/authelia/.env
";
#[test]
fn podman_quadlet_renames_container_and_colorizes_port() {
let blue = podman_color_quadlet(AUTHELIA_QUADLET, "authelia", Color::Blue);
assert!(blue.contains("ContainerName=authelia-blue"));
assert!(!blue.contains("ContainerName=authelia\n"));
assert!(blue.contains("PublishPort=${SERVICE_PORT_HTTP_BLUE}:9091"));
assert!(blue.contains("Image=docker.io/authelia/authelia:4.39"));
assert!(blue.contains("Network=authelia.network"));
assert!(blue.contains("services/authelia/.env"));
let green = podman_color_quadlet(AUTHELIA_QUADLET, "authelia", Color::Green);
assert!(green.contains("ContainerName=authelia-green"));
assert!(green.contains("PublishPort=${SERVICE_PORT_HTTP_GREEN}:9091"));
}
#[test]
fn podman_quadlet_render_is_idempotent() {
let once = podman_color_quadlet(AUTHELIA_QUADLET, "authelia", Color::Blue);
let twice = podman_color_quadlet(&once, "authelia", Color::Blue);
assert_eq!(once, twice);
}
#[test]
fn color_port_var_appends_uppercased_color() {
assert_eq!(
color_port_var("SERVICE_PORT_HTTP", Color::Blue),
"SERVICE_PORT_HTTP_BLUE"
);
assert_eq!(
color_port_var("SERVICE_PORT_HTTP", Color::Green),
"SERVICE_PORT_HTTP_GREEN"
);
}
#[test]
fn native_color_unit_isolates_workdir_and_overrides_port() {
let unit = native_color_unit(&NativeColorUnit {
description: "Demo API",
color: Color::Green,
workdir: "/home/u/.local/share/services/api/colors/green",
home: "/home/u/.local/share/services/api",
port_var: "SERVICE_PORT_HTTP",
port: 9002,
run: "python -m app",
});
assert!(unit.contains("WorkingDirectory=/home/u/.local/share/services/api/colors/green"));
let envfile = unit.find("EnvironmentFile=").unwrap();
let port_override = unit.find("Environment=SERVICE_PORT_HTTP=9002").unwrap();
assert!(
port_override > envfile,
"port override must follow EnvironmentFile"
);
assert!(unit.contains("ExecStart=/bin/sh -c 'exec python -m app'"));
assert!(unit.contains("Description=Demo API (green)"));
}
#[test]
fn e2e_podman_service_plan_matches_rendered_slots() {
let svc = "authelia";
let live = Color::Blue;
let blue_file = color_quadlet_filename(svc, Color::Blue);
let green_file = color_quadlet_filename(svc, Color::Green);
assert_eq!(blue_file, "authelia-blue.container");
assert_eq!(green_file, "authelia-green.container");
let swap = ColorSwap {
service_name: svc.into(),
live,
prepare: Some(Step::PullImage {
image: "authelia:4.40".into(),
}),
health_url: "http://127.0.0.1:9002/api/health".into(),
health_timeout_secs: 60,
caddy_rewrite: Some(caddy_write()),
};
let target = swap.target();
let steps = color_swap_steps(swap);
let started = match &steps[1] {
Step::StartService { unit } => unit.clone(),
_ => panic!("expected StartService at index 1"),
};
assert_eq!(started, color_unit(svc, target));
assert_eq!(format!("{started}.container"), green_file);
}
#[test]
fn e2e_native_python_service_plan_matches_rendered_slots() {
let svc = "api";
let green_unit = native_color_unit(&NativeColorUnit {
description: "API",
color: Color::Green,
workdir: "/srv/api/colors/green",
home: "/srv/api",
port_var: "SERVICE_PORT_HTTP",
port: 9002,
run: "python -m app",
});
assert!(green_unit.contains("Environment=SERVICE_PORT_HTTP=9002"));
let steps = color_swap_steps(ColorSwap {
service_name: svc.into(),
live: Color::Blue,
prepare: Some(Step::Build {
dir: "/srv/api/colors/green".into(),
command: "pip install -r requirements.txt".into(),
}),
health_url: "http://127.0.0.1:9002/healthz".into(),
health_timeout_secs: 90,
caddy_rewrite: None,
});
assert!(matches!(&steps[1], Step::StartService { unit } if unit == "api-green"));
match &steps[0] {
Step::Build { dir, .. } => assert_eq!(dir.to_str().unwrap(), "/srv/api/colors/green"),
_ => panic!("expected Build in the green slot dir"),
}
}
#[test]
fn loopback_install_skips_caddy_but_still_swaps() {
let steps = color_swap_steps(ColorSwap {
service_name: "app".into(),
live: Color::Blue,
prepare: None,
health_url: "http://127.0.0.1:9002/healthz".into(),
health_timeout_secs: 30,
caddy_rewrite: None,
});
assert!(matches!(&steps[0], Step::StartService { unit } if unit == "app-green"));
assert!(matches!(steps[1], Step::WaitForHttpHealthy { .. }));
assert!(matches!(&steps[2], Step::StopService { unit } if unit == "app-blue"));
assert!(!steps.iter().any(|s| matches!(s, Step::ReloadCaddy)));
assert_eq!(steps.len(), 3);
}
}