canic-cli 0.58.2

Operator CLI for Canic fleet setup, builds, evidence, catalog, backup, and restore workflows
Documentation
use super::{
    DeployCommandError, DeployTruthOptions, deploy_truth_leaf_command, load_deployment_check,
    output_format::{CheckOutputFormat, parse_check_output_format},
    print_json, value_arg,
};
use crate::{
    cli::{
        clap::{parse_matches, path_option, string_option},
        defaults::local_network,
        help::print_help_or_version,
    },
    evidence_support, version_text,
};
use canic_host::{
    build_provenance::build_provenance_schema,
    deployment_truth::{DeploymentCheckV1, SafetyReportV1, SafetyStatusV1},
    evidence_envelope::{
        CommandProvenanceV1, EvidenceEnvelopeV1, EvidenceMessageSeverityV1, EvidenceMessageV1,
        EvidenceSummaryV1, EvidenceTargetKindV1, EvidenceTargetV1, ExitClassV1, InputFingerprintV1,
        InputPathDisplayV1, PayloadSchemaRefV1, deployment_check_schema, evidence_envelope_schema,
        evidence_summary_exit_class, file_input_fingerprint, json_payload_sha256,
    },
};
use clap::Command as ClapCommand;
use std::{
    ffi::OsString,
    fs,
    path::{Path, PathBuf},
};

const DEPLOY_CHECK_HELP_AFTER: &str = "\
Examples:
  canic deploy check demo
  canic --network local deploy check --profile fast demo
  canic deploy check demo --format envelope-json
  canic deploy check demo --format envelope-json --build-provenance build-provenance.json

Prints the local DeploymentCheckV1 JSON without installing or mutating state.
Use --format envelope-json for the stable CI/GitOps evidence envelope.
--build-provenance is fingerprinted only in envelope output.";

const CHECK_COMMAND_NAME: &str = "check";
const FORMAT_ARG: &str = "format";
const BUILD_PROVENANCE_ARG: &str = "build-provenance";
const BUILD_PROVENANCE_FLAG: &str = "--build-provenance";

///
/// DeployCheckOptions
///
#[derive(Clone, Debug, Eq, PartialEq)]
pub(super) struct DeployCheckOptions {
    pub(super) truth: DeployTruthOptions,
    pub(super) format: CheckOutputFormat,
    pub(super) build_provenance: 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(());
    }

    let options = DeployCheckOptions::parse(args)?;
    let check = load_deployment_check(options.truth.clone())?;
    write_deployment_check(&options, &check)?;
    enforce_deployment_check_status(&check.report)
}

fn write_deployment_check(
    options: &DeployCheckOptions,
    check: &DeploymentCheckV1,
) -> Result<(), DeployCommandError> {
    match options.format {
        CheckOutputFormat::Json => print_json(check),
        CheckOutputFormat::EnvelopeJson => {
            let envelope = build_deployment_check_envelope(options, check)?;
            print_json(&envelope)
        }
    }
}

pub(super) fn build_deployment_check_envelope(
    options: &DeployCheckOptions,
    check: &DeploymentCheckV1,
) -> Result<EvidenceEnvelopeV1, DeployCommandError> {
    let payload = serde_json::to_value(check).map_err(Box::<dyn std::error::Error>::from)?;
    let payload_sha256 =
        Some(json_payload_sha256(check).map_err(Box::<dyn std::error::Error>::from)?);
    let config_root = deployment_check_config_root(check);
    let source_config = deployment_check_source_config_fingerprint(check)?;
    let summary = deployment_check_evidence_summary(check);
    let exit_class = combine_deployment_check_exit_class(check.report.status, &summary);

    Ok(EvidenceEnvelopeV1 {
        envelope_schema: evidence_envelope_schema(),
        canic_version: env!("CARGO_PKG_VERSION").to_string(),
        command: deployment_check_command_provenance(options, &config_root),
        target: EvidenceTargetV1 {
            kind: EvidenceTargetKindV1::Deployment,
            deployment: Some(check.plan.deployment_identity.deployment_name.clone()),
            fleet: Some(check.plan.fleet_template.clone()),
            role: None,
            profile: options
                .truth
                .profile
                .map(|profile| profile.target_dir_name().to_string()),
            network: Some(check.plan.deployment_identity.network.clone()),
        },
        generated_at: check.inventory.observed_at.clone(),
        source_config,
        inputs: deployment_check_input_fingerprints(options, &config_root)?,
        payload_schema: deployment_check_schema(),
        payload_sha256,
        payload,
        summary,
        exit_class,
    })
}

fn deployment_check_command_provenance(
    options: &DeployCheckOptions,
    config_root: &Path,
) -> CommandProvenanceV1 {
    let mut argv_normalized = vec![
        "canic".to_string(),
        "deploy".to_string(),
        "check".to_string(),
        options.truth.deployment.clone(),
        "--format".to_string(),
        "envelope-json".to_string(),
    ];
    if let Some(profile) = options.truth.profile {
        argv_normalized.push("--profile".to_string());
        argv_normalized.push(profile.target_dir_name().to_string());
    }
    if options.truth.network != local_network() {
        argv_normalized.push("--network".to_string());
        argv_normalized.push(options.truth.network.clone());
    }
    let mut argv_redactions = Vec::new();
    evidence_support::push_optional_path_arg(
        &mut argv_normalized,
        &mut argv_redactions,
        BUILD_PROVENANCE_FLAG,
        options.build_provenance.as_ref(),
        config_root,
    );

    CommandProvenanceV1 {
        name: "canic deploy check".to_string(),
        argv_normalized,
        argv_redactions,
        format: "envelope-json".to_string(),
    }
}

fn deployment_check_config_root(check: &DeploymentCheckV1) -> PathBuf {
    check
        .inventory
        .local_config
        .config_path
        .as_deref()
        .and_then(|path| Path::new(path).parent())
        .unwrap_or_else(|| Path::new("."))
        .to_path_buf()
}

fn deployment_check_input_fingerprints(
    options: &DeployCheckOptions,
    config_root: &Path,
) -> Result<Vec<InputFingerprintV1>, DeployCommandError> {
    let mut inputs = Vec::new();
    if let Some(path) = &options.build_provenance {
        inputs.push(
            file_input_fingerprint(
                "build_provenance",
                path,
                config_root,
                Some(build_provenance_schema()),
                None,
            )
            .map_err(Box::<dyn std::error::Error>::from)
            .map_err(DeployCommandError::from)?,
        );
    }
    Ok(inputs)
}

fn deployment_check_source_config_fingerprint(
    check: &DeploymentCheckV1,
) -> Result<Option<InputFingerprintV1>, DeployCommandError> {
    let Some(config_path) = &check.inventory.local_config.config_path else {
        return Ok(None);
    };
    let path = Path::new(config_path);
    let config_root = path.parent().unwrap_or_else(|| Path::new("."));
    let mut fingerprint = match fs::metadata(path) {
        Ok(_) => file_input_fingerprint(
            "canic_config",
            path,
            config_root,
            Some(PayloadSchemaRefV1::internal("canic.config.toml", "1")),
            None,
        )
        .map_err(Box::<dyn std::error::Error>::from)
        .map_err(DeployCommandError::from)?,
        Err(err) if err.kind() == std::io::ErrorKind::NotFound => InputFingerprintV1 {
            kind: "canic_config".to_string(),
            path: None,
            path_display: InputPathDisplayV1::Omitted,
            sha256: None,
            size_bytes: None,
            modified_unix_secs: None,
            schema: Some(PayloadSchemaRefV1::internal("canic.config.toml", "1")),
            note: Some("source config path was recorded but file is not available".to_string()),
        },
        Err(err) => {
            return Err(DeployCommandError::from(
                Box::<dyn std::error::Error>::from(err),
            ));
        }
    };

    if let Some(raw_config_sha256) = &check.inventory.local_config.raw_config_sha256 {
        fingerprint.sha256 = Some(raw_config_sha256.clone());
    } else if fingerprint.note.is_none() {
        fingerprint.note = Some("raw config hash was not observed by deployment check".to_string());
    }

    Ok(Some(fingerprint))
}

fn deployment_check_evidence_summary(check: &DeploymentCheckV1) -> EvidenceSummaryV1 {
    EvidenceSummaryV1 {
        warnings: check
            .report
            .warnings
            .iter()
            .map(|finding| {
                EvidenceMessageV1::new(
                    &format!("deploy.warning.{}", finding.code),
                    finding.message.clone(),
                    EvidenceMessageSeverityV1::Warning,
                )
            })
            .collect(),
        blocked_actions: check
            .report
            .hard_failures
            .iter()
            .map(|finding| {
                EvidenceMessageV1::new(
                    &format!("deploy.blocked.{}", finding.code),
                    finding.message.clone(),
                    EvidenceMessageSeverityV1::Error,
                )
            })
            .collect(),
        missing_or_stale_evidence: deployment_check_missing_or_stale_evidence(check),
        evidence_conflicts: deployment_check_evidence_conflicts(check),
    }
}

fn deployment_check_missing_or_stale_evidence(check: &DeploymentCheckV1) -> Vec<EvidenceMessageV1> {
    check
        .inventory
        .unresolved_observations
        .iter()
        .map(|gap| {
            EvidenceMessageV1::new(
                "deploy.missing_or_stale.observation",
                gap.description.clone(),
                EvidenceMessageSeverityV1::Warning,
            )
        })
        .chain(check.plan.unresolved_assumptions.iter().map(|assumption| {
            EvidenceMessageV1::new(
                "deploy.missing_or_stale.assumption",
                assumption.description.clone(),
                EvidenceMessageSeverityV1::Warning,
            )
        }))
        .collect()
}

fn deployment_check_evidence_conflicts(check: &DeploymentCheckV1) -> Vec<EvidenceMessageV1> {
    check
        .report
        .hard_failures
        .iter()
        .chain(check.report.warnings.iter())
        .filter(|finding| finding.code.contains("conflict"))
        .map(|finding| {
            EvidenceMessageV1::new(
                &format!("deploy.evidence_conflict.{}", finding.code),
                finding.message.clone(),
                EvidenceMessageSeverityV1::Error,
            )
        })
        .collect()
}

const fn combine_deployment_check_exit_class(
    status: SafetyStatusV1,
    summary: &EvidenceSummaryV1,
) -> ExitClassV1 {
    let status_class = deployment_check_status_exit_class(status);
    let summary_class =
        evidence_summary_exit_class(summary, matches!(status, SafetyStatusV1::NotEvaluated));

    if summary_class.dominates(status_class) {
        summary_class
    } else {
        status_class
    }
}

const fn deployment_check_status_exit_class(status: SafetyStatusV1) -> ExitClassV1 {
    match status {
        SafetyStatusV1::Safe => ExitClassV1::Success,
        SafetyStatusV1::Warning => ExitClassV1::SuccessWithWarnings,
        SafetyStatusV1::Blocked => ExitClassV1::BlockedByPolicy,
        SafetyStatusV1::NotEvaluated => ExitClassV1::MissingRequiredEvidence,
    }
}

pub(super) fn enforce_deployment_check_status(
    report: &SafetyReportV1,
) -> Result<(), DeployCommandError> {
    if report.status == SafetyStatusV1::Blocked {
        return Err(DeployCommandError::Blocked(report.summary.clone()));
    }
    Ok(())
}

impl DeployCheckOptions {
    pub(super) fn parse<I>(args: I) -> Result<Self, DeployCommandError>
    where
        I: IntoIterator<Item = OsString>,
    {
        let matches =
            parse_matches(command(), args).map_err(|_| DeployCommandError::Usage(usage()))?;
        let format =
            parse_check_output_format(string_option(&matches, FORMAT_ARG).as_deref(), usage)?;
        let build_provenance = path_option(&matches, BUILD_PROVENANCE_ARG);
        if build_provenance.is_some() && format != CheckOutputFormat::EnvelopeJson {
            return Err(DeployCommandError::Usage(format!(
                "{BUILD_PROVENANCE_FLAG} requires --format envelope-json\n\n{}",
                usage()
            )));
        }

        Ok(Self {
            truth: DeployTruthOptions::from_matches(&matches, usage)?,
            format,
            build_provenance,
        })
    }
}

pub(super) fn command() -> ClapCommand {
    deploy_truth_leaf_command(
        CHECK_COMMAND_NAME,
        "Print the local deployment truth check JSON",
    )
    .arg(check_format_arg())
    .arg(build_provenance_input_arg())
    .after_help(DEPLOY_CHECK_HELP_AFTER)
}

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

fn build_provenance_input_arg() -> clap::Arg {
    value_arg(BUILD_PROVENANCE_ARG)
        .long(BUILD_PROVENANCE_ARG)
        .value_name("path")
        .num_args(1)
        .help("Fingerprint a BuildProvenanceV1 evidence envelope; requires --format envelope-json")
}

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

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