canic-cli 0.104.2

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 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.";

///
/// 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,
    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),
            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_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(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(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(
            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(internal_icp_arg())
        .arg(internal_environment_arg())
        .after_help(INSTALL_HELP_AFTER)
}

/// 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)
    )
}