use crate::{
cli::{
clap::{parse_matches, parse_subcommand, passthrough_subcommand, string_option, value_arg},
defaults::local_network,
globals::internal_network_arg,
help::print_help_or_version,
},
version_text,
};
use canic_host::{
canister_build::CanisterBuildProfile,
deployment_truth::{DeploymentCheckV1, SafetyReportV1, SafetyStatusV1},
icp_config::resolve_current_canic_icp_root,
install_root::{InstallRootOptions, check_install_deployment_truth},
};
use clap::Command as ClapCommand;
use std::{
ffi::OsString,
time::{SystemTime, UNIX_EPOCH},
};
use thiserror::Error as ThisError;
const DEFAULT_ROOT_TARGET: &str = "root";
const DEFAULT_READY_TIMEOUT_SECONDS: u64 = 120;
const DEPLOY_HELP_AFTER: &str = "\
Examples:
canic deploy plan demo
canic deploy inventory demo
canic deploy diff demo
canic deploy report demo
canic deploy check demo
canic deploy check --profile fast demo
0.41 deploy commands are read-only deployment truth checks. Mutation still flows
through `canic install`.";
const DEPLOY_PLAN_HELP_AFTER: &str = "\
Examples:
canic deploy plan demo
canic --network local deploy plan --profile fast demo
Prints the local DeploymentPlanV1 JSON without installing or mutating state.";
const DEPLOY_INVENTORY_HELP_AFTER: &str = "\
Examples:
canic deploy inventory demo
canic --network local deploy inventory --profile fast demo
Prints the local DeploymentInventoryV1 JSON without installing or mutating state.";
const DEPLOY_DIFF_HELP_AFTER: &str = "\
Examples:
canic deploy diff demo
canic --network local deploy diff --profile fast demo
Prints the local DeploymentDiffV1 JSON without installing or mutating state.";
const DEPLOY_REPORT_HELP_AFTER: &str = "\
Examples:
canic deploy report demo
canic --network local deploy report --profile fast demo
Prints the local SafetyReportV1 JSON without installing or mutating state.";
const DEPLOY_CHECK_HELP_AFTER: &str = "\
Examples:
canic deploy check demo
canic --network local deploy check --profile fast demo
Prints the local DeploymentCheckV1 JSON without installing or mutating state.";
#[derive(Debug, ThisError)]
pub enum DeployCommandError {
#[error("{0}")]
Usage(String),
#[error(transparent)]
Check(#[from] Box<dyn std::error::Error>),
#[error("deployment truth check blocked: {0}")]
Blocked(String),
}
#[derive(Clone, Debug, Eq, PartialEq)]
struct DeployTruthOptions {
fleet: String,
network: String,
profile: Option<CanisterBuildProfile>,
}
pub fn run<I>(args: I) -> Result<(), DeployCommandError>
where
I: IntoIterator<Item = OsString>,
{
let args = args.into_iter().collect::<Vec<_>>();
if print_help_or_version(&args, usage, version_text()) {
return Ok(());
}
match parse_subcommand(deploy_command(), args)
.map_err(|_| DeployCommandError::Usage(usage()))?
{
None => {
println!("{}", usage());
Ok(())
}
Some((command, args)) => match command.as_str() {
"plan" => run_plan(args),
"inventory" => run_inventory(args),
"diff" => run_diff(args),
"report" => run_report(args),
"check" => run_check(args),
_ => unreachable!("deploy dispatch command only defines known commands"),
},
}
}
fn run_plan<I>(args: I) -> Result<(), DeployCommandError>
where
I: IntoIterator<Item = OsString>,
{
let args = args.into_iter().collect::<Vec<_>>();
if print_help_or_version(&args, plan_usage, version_text()) {
return Ok(());
}
let check = load_deployment_check(DeployTruthOptions::parse(
args,
deploy_plan_command,
plan_usage,
)?)?;
print_json(&check.plan)?;
Ok(())
}
fn run_inventory<I>(args: I) -> Result<(), DeployCommandError>
where
I: IntoIterator<Item = OsString>,
{
let args = args.into_iter().collect::<Vec<_>>();
if print_help_or_version(&args, inventory_usage, version_text()) {
return Ok(());
}
let check = load_deployment_check(DeployTruthOptions::parse(
args,
deploy_inventory_command,
inventory_usage,
)?)?;
print_json(&check.inventory)?;
Ok(())
}
fn run_diff<I>(args: I) -> Result<(), DeployCommandError>
where
I: IntoIterator<Item = OsString>,
{
let args = args.into_iter().collect::<Vec<_>>();
if print_help_or_version(&args, diff_usage, version_text()) {
return Ok(());
}
let check = load_deployment_check(DeployTruthOptions::parse(
args,
deploy_diff_command,
diff_usage,
)?)?;
print_json(&check.diff)?;
Ok(())
}
fn run_report<I>(args: I) -> Result<(), DeployCommandError>
where
I: IntoIterator<Item = OsString>,
{
let args = args.into_iter().collect::<Vec<_>>();
if print_help_or_version(&args, report_usage, version_text()) {
return Ok(());
}
let check = load_deployment_check(DeployTruthOptions::parse(
args,
deploy_report_command,
report_usage,
)?)?;
print_json(&check.report)?;
Ok(())
}
fn run_check<I>(args: I) -> Result<(), DeployCommandError>
where
I: IntoIterator<Item = OsString>,
{
let args = args.into_iter().collect::<Vec<_>>();
if print_help_or_version(&args, check_usage, version_text()) {
return Ok(());
}
let check = load_deployment_check(DeployTruthOptions::parse(
args,
deploy_check_command,
check_usage,
)?)?;
print_json(&check)?;
enforce_deployment_check_status(&check.report)
}
fn load_deployment_check(
options: DeployTruthOptions,
) -> Result<DeploymentCheckV1, DeployCommandError> {
let icp_root = resolve_current_canic_icp_root().ok();
check_install_deployment_truth(
&options.into_install_root_options_with_icp_root(icp_root),
current_observed_at()?,
)
.map_err(DeployCommandError::from)
}
fn print_json<T>(value: &T) -> Result<(), DeployCommandError>
where
T: serde::Serialize,
{
let json = serde_json::to_string_pretty(value).map_err(Box::<dyn std::error::Error>::from)?;
println!("{json}");
Ok(())
}
fn enforce_deployment_check_status(report: &SafetyReportV1) -> Result<(), DeployCommandError> {
if report.status == SafetyStatusV1::Blocked {
return Err(DeployCommandError::Blocked(report.summary.clone()));
}
Ok(())
}
impl DeployTruthOptions {
fn parse<I>(
args: I,
command: impl FnOnce() -> ClapCommand,
usage: fn() -> String,
) -> Result<Self, DeployCommandError>
where
I: IntoIterator<Item = OsString>,
{
let matches =
parse_matches(command(), args).map_err(|_| DeployCommandError::Usage(usage()))?;
Ok(Self {
fleet: string_option(&matches, "fleet").expect("clap requires fleet"),
network: string_option(&matches, "network").unwrap_or_else(local_network),
profile: string_option(&matches, "profile")
.as_deref()
.map(|profile| parse_profile(profile, usage))
.transpose()?,
})
}
fn into_install_root_options_with_icp_root(
self,
icp_root: Option<std::path::PathBuf>,
) -> InstallRootOptions {
InstallRootOptions {
root_canister: DEFAULT_ROOT_TARGET.to_string(),
root_build_target: DEFAULT_ROOT_TARGET.to_string(),
network: self.network,
icp_root,
build_profile: self.profile,
ready_timeout_seconds: DEFAULT_READY_TIMEOUT_SECONDS,
config_path: Some(default_fleet_config_path(&self.fleet)),
expected_fleet: Some(self.fleet),
interactive_config_selection: false,
}
}
}
fn deploy_command() -> ClapCommand {
ClapCommand::new("deploy")
.bin_name("canic deploy")
.about("Check deployment truth before mutation")
.disable_help_flag(true)
.subcommand(passthrough_subcommand(
ClapCommand::new("check")
.about("Print the local deployment truth check JSON")
.disable_help_flag(true),
))
.subcommand(passthrough_subcommand(
ClapCommand::new("diff")
.about("Print the local deployment diff JSON")
.disable_help_flag(true),
))
.subcommand(passthrough_subcommand(
ClapCommand::new("inventory")
.about("Print the local deployment inventory JSON")
.disable_help_flag(true),
))
.subcommand(passthrough_subcommand(
ClapCommand::new("plan")
.about("Print the local deployment plan JSON")
.disable_help_flag(true),
))
.subcommand(passthrough_subcommand(
ClapCommand::new("report")
.about("Print the local deployment safety report JSON")
.disable_help_flag(true),
))
.after_help(DEPLOY_HELP_AFTER)
}
fn deploy_plan_command() -> ClapCommand {
deploy_truth_leaf_command("plan", "Print the local deployment plan JSON")
.after_help(DEPLOY_PLAN_HELP_AFTER)
}
fn deploy_inventory_command() -> ClapCommand {
deploy_truth_leaf_command("inventory", "Print the local deployment inventory JSON")
.after_help(DEPLOY_INVENTORY_HELP_AFTER)
}
fn deploy_diff_command() -> ClapCommand {
deploy_truth_leaf_command("diff", "Print the local deployment diff JSON")
.after_help(DEPLOY_DIFF_HELP_AFTER)
}
fn deploy_report_command() -> ClapCommand {
deploy_truth_leaf_command("report", "Print the local deployment safety report JSON")
.after_help(DEPLOY_REPORT_HELP_AFTER)
}
fn deploy_check_command() -> ClapCommand {
deploy_truth_leaf_command("check", "Print the local deployment truth check JSON")
.after_help(DEPLOY_CHECK_HELP_AFTER)
}
fn deploy_truth_leaf_command(name: &'static str, about: &'static str) -> ClapCommand {
ClapCommand::new(name)
.bin_name(format!("canic deploy {name}"))
.about(about)
.disable_help_flag(true)
.arg(
value_arg("fleet")
.value_name("fleet")
.required(true)
.help("Config-defined fleet name to check"),
)
.arg(
value_arg("profile")
.long("profile")
.value_name("debug|fast|release")
.num_args(1)
.help("Expected canister wasm build profile"),
)
.arg(internal_network_arg())
}
fn usage() -> String {
let mut command = deploy_command();
command.render_help().to_string()
}
fn plan_usage() -> String {
let mut command = deploy_plan_command();
command.render_help().to_string()
}
fn inventory_usage() -> String {
let mut command = deploy_inventory_command();
command.render_help().to_string()
}
fn diff_usage() -> String {
let mut command = deploy_diff_command();
command.render_help().to_string()
}
fn report_usage() -> String {
let mut command = deploy_report_command();
command.render_help().to_string()
}
fn check_usage() -> String {
let mut command = deploy_check_command();
command.render_help().to_string()
}
fn parse_profile(
value: &str,
usage: fn() -> String,
) -> Result<CanisterBuildProfile, DeployCommandError> {
match value {
"debug" => Ok(CanisterBuildProfile::Debug),
"fast" => Ok(CanisterBuildProfile::Fast),
"release" => Ok(CanisterBuildProfile::Release),
_ => Err(DeployCommandError::Usage(format!(
"invalid build profile: {value}\n\n{}",
usage()
))),
}
}
fn default_fleet_config_path(fleet: &str) -> String {
format!("fleets/{fleet}/canic.toml")
}
fn current_observed_at() -> Result<String, Box<dyn std::error::Error>> {
Ok(format!(
"unix:{}",
SystemTime::now().duration_since(UNIX_EPOCH)?.as_secs()
))
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn deploy_check_parses_required_fleet() {
let options =
DeployTruthOptions::parse([OsString::from("demo")], deploy_check_command, check_usage)
.expect("parse deploy check");
assert_eq!(options.fleet, "demo");
assert_eq!(options.network, "local");
assert_eq!(options.profile, None);
}
#[test]
fn deploy_check_accepts_internal_network_and_profile() {
let options = DeployTruthOptions::parse(
[
OsString::from("--profile"),
OsString::from("fast"),
OsString::from("demo"),
OsString::from("--__canic-network"),
OsString::from("ic"),
],
deploy_check_command,
check_usage,
)
.expect("parse deploy check");
assert_eq!(options.network, "ic");
assert_eq!(options.profile, Some(CanisterBuildProfile::Fast));
}
#[test]
fn deploy_check_rejects_invalid_profile() {
assert!(matches!(
DeployTruthOptions::parse(
[
OsString::from("--profile"),
OsString::from("turbo"),
OsString::from("demo"),
],
deploy_check_command,
check_usage,
),
Err(DeployCommandError::Usage(_))
));
}
#[test]
fn deploy_check_status_rejects_blocked_report() {
let report = SafetyReportV1 {
schema_version: 1,
report_id: "report-1".to_string(),
diff_id: None,
status: SafetyStatusV1::Blocked,
summary: "deployment inventory has 1 blocking issue(s) and 0 warning(s)".to_string(),
hard_failures: Vec::new(),
warnings: Vec::new(),
next_actions: Vec::new(),
};
assert!(matches!(
enforce_deployment_check_status(&report),
Err(DeployCommandError::Blocked(message))
if message == "deployment inventory has 1 blocking issue(s) and 0 warning(s)"
));
}
#[test]
fn deploy_check_status_allows_warning_report() {
let report = SafetyReportV1 {
schema_version: 1,
report_id: "report-1".to_string(),
diff_id: None,
status: SafetyStatusV1::Warning,
summary: "deployment inventory has 1 warning(s)".to_string(),
hard_failures: Vec::new(),
warnings: Vec::new(),
next_actions: Vec::new(),
};
enforce_deployment_check_status(&report).expect("warning report should not fail check");
}
#[test]
fn deploy_leaf_commands_parse_like_check() {
let plan =
DeployTruthOptions::parse([OsString::from("demo")], deploy_plan_command, plan_usage)
.expect("parse deploy plan");
let inventory = DeployTruthOptions::parse(
[OsString::from("demo")],
deploy_inventory_command,
inventory_usage,
)
.expect("parse deploy inventory");
let diff =
DeployTruthOptions::parse([OsString::from("demo")], deploy_diff_command, diff_usage)
.expect("parse deploy diff");
let report = DeployTruthOptions::parse(
[OsString::from("demo")],
deploy_report_command,
report_usage,
)
.expect("parse deploy report");
assert_eq!(plan.fleet, "demo");
assert_eq!(inventory.fleet, "demo");
assert_eq!(diff.fleet, "demo");
assert_eq!(report.fleet, "demo");
}
#[test]
fn deploy_check_builds_current_install_options() {
let options = DeployTruthOptions {
fleet: "demo".to_string(),
network: "local".to_string(),
profile: Some(CanisterBuildProfile::Fast),
}
.into_install_root_options_with_icp_root(Some(std::path::PathBuf::from("/tmp/icp")));
assert_eq!(options.root_canister, "root");
assert_eq!(options.root_build_target, "root");
assert_eq!(options.network, "local");
assert_eq!(options.build_profile, Some(CanisterBuildProfile::Fast));
assert_eq!(
options.config_path.as_deref(),
Some("fleets/demo/canic.toml")
);
assert_eq!(options.expected_fleet.as_deref(), Some("demo"));
}
}