use std::path::{Path, PathBuf};
use std::process::ExitCode;
use link_assistant_router::cli::DeployArgs;
use link_assistant_router::config::Config;
use link_assistant_router::deploy::{self, Plan, runtime::Docker};
fn default_image() -> String {
format!(
"ghcr.io/link-assistant/router:{}",
link_assistant_router::VERSION
)
}
fn default_root(data_dir: &Path) -> PathBuf {
data_dir.join("deploy")
}
fn plan_for(args: &DeployArgs, data_dir: &Path, token_secret: &str) -> Plan {
let root = args
.root
.as_deref()
.map_or_else(|| default_root(data_dir), PathBuf::from);
let mut plan = Plan::local(
&root,
&args
.image
.as_deref()
.map_or_else(default_image, str::to_string),
token_secret,
);
plan.port = args.port;
plan.status_only = args.status;
plan.build_context = args.build.as_deref().map(PathBuf::from);
plan
}
fn down_code(removed: bool) -> ExitCode {
if removed {
ExitCode::SUCCESS
} else {
ExitCode::from(2)
}
}
fn ready_line(plan: &Plan, skipped: bool) -> String {
if plan.status_only {
format!(
"{} on 127.0.0.1:{}",
if skipped {
"deployment is up with steps skipped"
} else {
"deployment is converged"
},
plan.port
)
} else {
format!(
"deployment is ready: `router with claude --server http://127.0.0.1:{}`",
plan.port
)
}
}
pub fn run(config: &Config, args: &DeployArgs) -> ExitCode {
let runtime = Docker;
if args.down {
return match deploy::down(&runtime, args.yes) {
Ok(message) => {
println!("{message}");
down_code(true)
}
Err(error) => {
eprintln!("error: {error}");
down_code(false)
}
};
}
let plan = plan_for(args, &config.data_dir, &config.token_secret);
let report = deploy::converge(&runtime, &plan);
report.print();
if report.converged() {
println!("\n{}", ready_line(&plan, !report.skips().is_empty()));
ExitCode::SUCCESS
} else {
ExitCode::from(1)
}
}
#[cfg(test)]
mod tests {
use super::*;
fn args() -> DeployArgs {
DeployArgs {
status: false,
down: false,
yes: false,
port: link_assistant_router::deploy::DEFAULT_PORT,
image: None,
build: None,
root: None,
}
}
#[test]
fn the_default_image_is_this_binarys_own_version_not_a_moving_tag() {
let plan = plan_for(&args(), Path::new("/tmp/state"), "secret");
assert!(
plan.image.ends_with(link_assistant_router::VERSION),
"{}",
plan.image
);
deploy::immutable_ref(&plan.image).expect("the default is deployable");
}
#[test]
fn a_named_image_and_root_are_used_verbatim() {
let mut args = args();
args.image = Some("ghcr.io/link-assistant/router@sha256:abc".to_string());
args.root = Some("/srv/router".to_string());
args.port = 19000;
let plan = plan_for(&args, Path::new("/tmp/state"), "secret");
assert_eq!(plan.image, "ghcr.io/link-assistant/router@sha256:abc");
assert_eq!(plan.credential_home, Path::new("/srv/router/credentials"));
assert_eq!(plan.data_home, Path::new("/srv/router/data"));
assert_eq!(plan.port, 19000);
}
#[test]
fn the_default_root_lives_under_the_data_directory() {
let plan = plan_for(&args(), Path::new("/var/lib/router"), "secret");
assert_eq!(
plan.credential_home,
Path::new("/var/lib/router/deploy/credentials")
);
assert_eq!(plan.data_home, Path::new("/var/lib/router/deploy/data"));
assert_ne!(plan.credential_home, plan.data_home);
}
#[test]
fn status_and_build_flags_reach_the_plan() {
let mut args = args();
args.status = true;
args.build = Some("/src/router".to_string());
let plan = plan_for(&args, Path::new("/tmp/state"), "secret");
assert!(plan.status_only);
assert_eq!(
plan.build_context.as_deref(),
Some(Path::new("/src/router"))
);
}
#[test]
fn the_signing_secret_is_passed_through_rather_than_invented() {
let plan = plan_for(&args(), Path::new("/tmp/state"), "the-deployments-secret");
assert_eq!(plan.token_secret, "the-deployments-secret");
}
#[test]
fn a_refused_removal_exits_two_rather_than_one() {
assert_eq!(
format!("{:?}", down_code(false)),
format!("{:?}", ExitCode::from(2))
);
assert_eq!(
format!("{:?}", down_code(true)),
format!("{:?}", ExitCode::SUCCESS)
);
}
#[test]
fn the_closing_line_tells_the_operator_what_to_do_next() {
let plan = plan_for(&args(), Path::new("/tmp/state"), "secret");
let ready = ready_line(&plan, false);
assert!(
ready.contains("router with claude"),
"a converged deploy names the next command: {ready}"
);
assert!(ready.contains(&plan.port.to_string()), "{ready}");
let mut reporting = plan;
reporting.status_only = true;
assert!(ready_line(&reporting, false).contains("converged"));
assert!(ready_line(&reporting, true).contains("skipped"));
}
}