canic-cli 0.109.5

Operator CLI for Canic fleet setup, builds, evidence, catalog, backup, and restore workflows
Documentation
//! Module: canic_cli::install
//!
//! Responsibility: parse `canic install` and delegate fleet bootstrap to the
//! host install runner.
//! Does not own: install planning, controller mutation, canister lifecycle
//! side effects, or deployment state persistence.
//! Boundary: resolves local workspace context, builds host install options, and
//! adds CLI-facing diagnostics.

#[cfg(test)]
mod tests;

use crate::{
    cli::clap::{
        parse_matches, render_usage, required_string, string_option_or_else, typed_option,
        value_arg,
    },
    cli::defaults::{default_icp, local_environment},
    cli::globals::{internal_environment_arg, internal_icp_arg},
    cli::help::print_help_or_version,
    version_text,
};
use canic_core::ids::ReleaseBuildId;
use canic_host::canister_build::CanisterBuildProfile;
use canic_host::icp::{IcpDiagnostic, classify_icp_diagnostic};
use canic_host::icp_config::{IcpConfigError, resolve_current_canic_icp_root};
use canic_host::install_root::{
    InstallRootBlockedError, InstallRootError, InstallRootOptions, install_root,
};
use clap::Command as ClapCommand;
use std::{ffi::OsString, path::PathBuf};
use thiserror::Error as ThisError;

const DEFAULT_ROOT_TARGET: &str = "root";
const EXPECTED_PLAN_DIGEST_ARG: &str = "expected-plan-digest";
const FLEET_INPUT_ARG: &str = "fleet-input";
const RELEASE_BUILD_ARG: &str = "release-build";
const INSTALL_HELP_AFTER: &str = "\
Examples:
  canic install toko toko-local --fleet-input deployments/toko-local.toml
  canic install toko toko-test --fleet-input deployments/toko-test.toml --release-build <ID>

Creates a fresh Fleet from the App config and required operator-owned Fleet input.
Before building, Canic refreshes missing or invalid mainnet catalog evidence,
resolves the effective ICP identity, rejects anonymous or unusable credentials,
and requires that Principal to equal the Fleet input operator. For encrypted
non-interactive identities, set CANIC_ICP_IDENTITY_PASSWORD_FILE to an absolute
operator-owned password file.";

///
/// InstallCommandError
///

#[derive(Debug, ThisError)]
pub enum InstallCommandError {
    #[error("{0}")]
    Usage(String),

    #[error("failed to resolve ICP project root: {0}")]
    IcpRoot(#[from] IcpConfigError),

    #[error(transparent)]
    Install(#[from] InstallRootError),

    #[error("{source}\n\nHint: {hint}")]
    InstallHint {
        source: InstallRootError,
        hint: String,
    },
}

///
/// InstallOptions
///

#[derive(Clone, Debug, Eq, PartialEq)]
struct InstallOptions {
    app: String,
    fleet: String,
    icp: String,
    environment: String,
    expected_plan_digest: Option<String>,
    profile: Option<CanisterBuildProfile>,
    release_build_id: Option<ReleaseBuildId>,
    fleet_input: PathBuf,
}

impl InstallOptions {
    fn parse<I>(args: I) -> Result<Self, InstallCommandError>
    where
        I: IntoIterator<Item = OsString>,
    {
        let matches = parse_matches(install_command(), args)
            .map_err(|_| InstallCommandError::Usage(usage()))?;
        Ok(Self {
            app: required_string(&matches, "app"),
            fleet: required_string(&matches, "fleet"),
            icp: string_option_or_else(&matches, "icp", default_icp),
            environment: string_option_or_else(&matches, "environment", local_environment),
            expected_plan_digest: matches.get_one::<String>(EXPECTED_PLAN_DIGEST_ARG).cloned(),
            profile: typed_option(&matches, "profile"),
            release_build_id: typed_option(&matches, RELEASE_BUILD_ARG),
            fleet_input: PathBuf::from(required_string(&matches, FLEET_INPUT_ARG)),
        })
    }

    fn into_install_root_options_with_icp_root(
        self,
        icp_root: Option<PathBuf>,
    ) -> InstallRootOptions {
        let config_path = icp_root
            .as_deref()
            .map(|root| root.join(default_app_config_path(&self.app)))
            .filter(|path| path.is_file())
            .map_or_else(
                || default_app_config_path(&self.app),
                |path| path.display().to_string(),
            );
        InstallRootOptions {
            root_canister: DEFAULT_ROOT_TARGET.to_string(),
            root_build_target: DEFAULT_ROOT_TARGET.to_string(),
            icp_executable: self.icp,
            environment: self.environment,
            fleet_name: self.fleet,
            icp_root,
            build_profile: self.profile,
            release_build_id: self.release_build_id,
            config_path: Some(config_path),
            fleet_install_input_path: Some(self.fleet_input),
            expected_fresh_fleet_plan_digest: self.expected_plan_digest,
            admitted_fresh_fleet_plan_digest: None,
            expected_app: Some(self.app),
            interactive_config_selection: false,
            deployment_plan_override: None,
        }
    }
}

fn install_command() -> ClapCommand {
    ClapCommand::new("install")
        .bin_name("canic install")
        .about("Install and bootstrap a Canic fleet")
        .disable_help_flag(true)
        .override_usage("canic install <app> <fleet> --fleet-input <PATH>")
        .arg(
            value_arg("app")
                .value_name("app")
                .required(true)
                .help("Source App identity under apps/<app>/canic.toml"),
        )
        .arg(
            value_arg("fleet")
                .value_name("fleet")
                .required(true)
                .help("Operator-facing name for the installed Fleet"),
        )
        .arg(
            value_arg(EXPECTED_PLAN_DIGEST_ARG)
                .long(EXPECTED_PLAN_DIGEST_ARG)
                .value_name("SHA256")
                .num_args(1)
                .value_parser(parse_plan_digest)
                .help("Require the exact canonical pre-effect plan digest"),
        )
        .arg(
            value_arg(FLEET_INPUT_ARG)
                .long(FLEET_INPUT_ARG)
                .value_name("PATH")
                .required(true)
                .num_args(1)
                .help("Operator-owned Fleet placement, admission, limit, and funding input TOML"),
        )
        .arg(
            value_arg("profile")
                .long("profile")
                .value_name("debug|fast|release")
                .num_args(1)
                .value_parser(clap::value_parser!(CanisterBuildProfile))
                .help("Canister wasm build profile; defaults to release"),
        )
        .arg(
            value_arg(RELEASE_BUILD_ARG)
                .long(RELEASE_BUILD_ARG)
                .value_name("ID")
                .num_args(1)
                .value_parser(clap::value_parser!(ReleaseBuildId))
                .help("Reuse one finalized release build instead of compiling artifacts"),
        )
        .arg(internal_icp_arg())
        .arg(internal_environment_arg())
        .after_help(INSTALL_HELP_AFTER)
}

fn parse_plan_digest(value: &str) -> Result<String, String> {
    let valid = value.len() == 64
        && value
            .bytes()
            .all(|byte| byte.is_ascii_digit() || matches!(byte, b'a'..=b'f'));
    valid.then(|| value.to_string()).ok_or_else(|| {
        "plan digest must contain exactly 64 lowercase hexadecimal characters".to_string()
    })
}

/// Run the root install workflow.
pub fn run<I>(args: I) -> Result<(), InstallCommandError>
where
    I: IntoIterator<Item = OsString>,
{
    let args = args.into_iter().collect::<Vec<_>>();
    if print_help_or_version(&args, usage, version_text()) {
        return Ok(());
    }

    let options = InstallOptions::parse(args)?;
    let fleet = options.fleet.clone();
    let environment = options.environment.clone();
    let icp_root = Some(resolve_current_canic_icp_root()?);
    install_root(options.into_install_root_options_with_icp_root(icp_root))
        .map_err(|err| install_error_with_context(err, &fleet, &environment))
}

fn default_app_config_path(app: &str) -> String {
    format!("apps/{app}/canic.toml")
}

fn usage() -> String {
    render_usage(install_command)
}

fn install_error_with_context(
    err: InstallRootError,
    fleet: &str,
    environment: &str,
) -> InstallCommandError {
    if install_error_needs_existing_deployment_hint(&err) {
        return InstallCommandError::InstallHint {
            source: err,
            hint: format!(
                "If this Fleet or canister already exists, run `canic --environment {environment} info list {fleet}` and `canic --environment {environment} medic fleet {fleet}` before retrying. `canic install` is for fresh Fleet creation, not code-only updates."
            ),
        };
    }

    InstallCommandError::Install(err)
}

fn install_error_needs_existing_deployment_hint(error: &(dyn std::error::Error + 'static)) -> bool {
    let mut source = Some(error);
    while let Some(error) = source {
        if error.downcast_ref::<InstallRootBlockedError>().is_some() {
            return true;
        }
        source = error.source();
    }

    matches!(
        classify_icp_diagnostic(&error.to_string()),
        Some(IcpDiagnostic::AlreadyInstalled)
    )
}