canic-cli 0.58.7

Operator CLI for Canic fleet setup, builds, evidence, catalog, backup, and restore workflows
Documentation
use super::{
    DeployCommandError, current_observed_at,
    output_format::{CatalogOutputFormat, parse_catalog_output_format},
    value_arg,
};
use crate::{
    cli::{
        clap::{
            parse_matches, parse_subcommand, passthrough_subcommand, path_option, string_option,
        },
        defaults::local_network,
        globals::internal_network_arg,
        help::print_help_or_version,
    },
    output, version_text,
};
use canic_host::{
    deployment_catalog::{
        DeploymentCatalogReportV1, DeploymentCatalogRequest, build_deployment_catalog_report,
        deployment_catalog_report_text, inspect_deployment_catalog_report,
    },
    icp_config::resolve_current_canic_icp_root,
};
use clap::Command as ClapCommand;
use std::{ffi::OsString, path::PathBuf};

#[derive(Clone, Copy)]
struct CatalogCommand {
    name: &'static str,
    about: &'static str,
    bin_name: &'static str,
    help_after: &'static str,
}

const CATALOG_COMMANDS: &[CatalogCommand] = &[LIST_COMMAND, INSPECT_COMMAND];

const DEPLOY_CATALOG_HELP_AFTER: &str = "\
Examples:
  canic deploy catalog list
  canic deploy catalog inspect demo-local
  canic --network local deploy catalog list --format json --output catalog.json

Catalog commands are read-only local-state reports. They list or inspect
deployment targets recorded under .canic/<network>/deployments and do not query
live deployments, create deployment truth, mutate topology, change
controllers, install Wasm, or infer deployments from fleet-template names.";
const DEPLOY_CATALOG_LIST_HELP_AFTER: &str = "\
Examples:
  canic deploy catalog list
  canic deploy catalog list --format json
  canic --network local deploy catalog list --format json --output catalog.json

Lists deployment targets from existing local deployment-target state only. This
does not refresh live state or infer deployments from fleet-template names.";
const DEPLOY_CATALOG_INSPECT_HELP_AFTER: &str = "\
Examples:
  canic deploy catalog inspect demo-local
  canic deploy catalog inspect demo-local --format json
  canic --network local deploy catalog inspect demo-local --format json --output demo-local.json

Inspects one deployment target from existing local deployment-target state
only. The deployment argument is a deployment target, not a fleet template.";

const LIST_COMMAND: CatalogCommand = CatalogCommand {
    name: "list",
    about: "List known deployment targets from local state",
    bin_name: "canic deploy catalog list",
    help_after: DEPLOY_CATALOG_LIST_HELP_AFTER,
};
const INSPECT_COMMAND: CatalogCommand = CatalogCommand {
    name: "inspect",
    about: "Inspect one known deployment target from local state",
    bin_name: "canic deploy catalog inspect",
    help_after: DEPLOY_CATALOG_INSPECT_HELP_AFTER,
};

///
/// DeployCatalogOptions
///
#[derive(Debug)]
pub(super) struct DeployCatalogOptions {
    pub(super) deployment: Option<String>,
    pub(super) network: String,
    pub(super) format: CatalogOutputFormat,
    pub(super) output: Option<PathBuf>,
}

pub(super) 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(command(), args).map_err(|_| DeployCommandError::Usage(usage()))? {
        Some((command, args)) if command == "list" => run_list(args),
        Some((command, args)) if command == "inspect" => run_inspect(args),
        _ => {
            println!("{}", usage());
            Ok(())
        }
    }
}

fn run_list<I>(args: I) -> Result<(), DeployCommandError>
where
    I: IntoIterator<Item = OsString>,
{
    let args = args.into_iter().collect::<Vec<_>>();
    if print_help_or_version(&args, list_usage, version_text()) {
        return Ok(());
    }

    let options = DeployCatalogOptions::parse_list(args)?;
    let report = build_deployment_catalog_report(&request(&options)?)
        .map_err(Box::<dyn std::error::Error>::from)
        .map_err(DeployCommandError::from)?;
    write_report(&options, &report)
}

fn run_inspect<I>(args: I) -> Result<(), DeployCommandError>
where
    I: IntoIterator<Item = OsString>,
{
    let args = args.into_iter().collect::<Vec<_>>();
    if print_help_or_version(&args, inspect_usage, version_text()) {
        return Ok(());
    }

    let options = DeployCatalogOptions::parse_inspect(args)?;
    let report = inspect_deployment_catalog_report(
        &request(&options)?,
        options
            .deployment
            .as_deref()
            .expect("catalog inspect parser requires deployment"),
    )
    .map_err(Box::<dyn std::error::Error>::from)
    .map_err(DeployCommandError::from)?;
    write_report(&options, &report)
}

pub(super) fn write_report(
    options: &DeployCatalogOptions,
    report: &DeploymentCatalogReportV1,
) -> Result<(), DeployCommandError> {
    match options.format {
        CatalogOutputFormat::Text => output::write_text::<Box<dyn std::error::Error>>(
            options.output.as_ref(),
            &deployment_catalog_report_text(report),
        )
        .map_err(DeployCommandError::from),
        CatalogOutputFormat::Json => output::write_pretty_json::<_, Box<dyn std::error::Error>>(
            options.output.as_ref(),
            report,
        )
        .map_err(DeployCommandError::from),
    }
}

fn request(options: &DeployCatalogOptions) -> Result<DeploymentCatalogRequest, DeployCommandError> {
    let icp_root = resolve_current_canic_icp_root()
        .map_err(Box::<dyn std::error::Error>::from)
        .map_err(DeployCommandError::from)?;
    Ok(DeploymentCatalogRequest {
        icp_root,
        network: options.network.clone(),
        generated_at: current_observed_at()?,
    })
}

impl DeployCatalogOptions {
    #[cfg(test)]
    pub(super) fn parse_list_test<I>(args: I) -> Result<Self, DeployCommandError>
    where
        I: IntoIterator<Item = OsString>,
    {
        Self::parse_list(args)
    }

    #[cfg(test)]
    pub(super) fn parse_inspect_test<I>(args: I) -> Result<Self, DeployCommandError>
    where
        I: IntoIterator<Item = OsString>,
    {
        Self::parse_inspect(args)
    }

    fn parse_list<I>(args: I) -> Result<Self, DeployCommandError>
    where
        I: IntoIterator<Item = OsString>,
    {
        let matches = parse_matches(list_command(), args)
            .map_err(|_| DeployCommandError::Usage(list_usage()))?;
        Ok(Self {
            deployment: None,
            network: string_option(&matches, "network").unwrap_or_else(local_network),
            format: parse_catalog_output_format(
                string_option(&matches, "format").as_deref(),
                list_usage,
            )?,
            output: path_option(&matches, "output"),
        })
    }

    fn parse_inspect<I>(args: I) -> Result<Self, DeployCommandError>
    where
        I: IntoIterator<Item = OsString>,
    {
        let matches = parse_matches(inspect_command(), args)
            .map_err(|_| DeployCommandError::Usage(inspect_usage()))?;
        Ok(Self {
            deployment: Some(
                string_option(&matches, "deployment").expect("clap requires deployment"),
            ),
            network: string_option(&matches, "network").unwrap_or_else(local_network),
            format: parse_catalog_output_format(
                string_option(&matches, "format").as_deref(),
                inspect_usage,
            )?,
            output: path_option(&matches, "output"),
        })
    }
}

pub(super) fn command() -> ClapCommand {
    CATALOG_COMMANDS
        .iter()
        .fold(
            ClapCommand::new("catalog")
                .bin_name("canic deploy catalog")
                .about("List or inspect known deployment targets")
                .disable_help_flag(true),
            |command, subcommand| command.subcommand(catalog_passthrough_command(*subcommand)),
        )
        .after_help(DEPLOY_CATALOG_HELP_AFTER)
}

fn list_command() -> ClapCommand {
    catalog_leaf_command(LIST_COMMAND)
}

fn inspect_command() -> ClapCommand {
    catalog_leaf_command(INSPECT_COMMAND).arg(
        value_arg("deployment")
            .value_name("deployment")
            .required(true)
            .help("Deployment target name to inspect"),
    )
}

fn format_arg() -> clap::Arg {
    value_arg("format")
        .long("format")
        .value_name("text|json")
        .num_args(1)
        .help("Output format; defaults to text")
}

fn output_arg() -> clap::Arg {
    value_arg("output")
        .long("output")
        .value_name("path")
        .num_args(1)
        .help("Write the selected catalog output format to this path")
}

fn catalog_passthrough_command(spec: CatalogCommand) -> ClapCommand {
    passthrough_subcommand(
        ClapCommand::new(spec.name)
            .about(spec.about)
            .disable_help_flag(true),
    )
}

fn catalog_leaf_command(spec: CatalogCommand) -> ClapCommand {
    ClapCommand::new(spec.name)
        .bin_name(spec.bin_name)
        .about(spec.about)
        .disable_help_flag(true)
        .arg(format_arg())
        .arg(output_arg())
        .arg(internal_network_arg())
        .after_help(spec.help_after)
}

pub(super) fn usage() -> String {
    render_usage(command)
}

pub(super) fn list_usage() -> String {
    render_usage(list_command)
}

pub(super) fn inspect_usage() -> String {
    render_usage(inspect_command)
}

fn render_usage(command: fn() -> ClapCommand) -> String {
    let mut command = command();
    command.render_help().to_string()
}