canic-cli 0.94.8

Operator CLI for Canic fleet setup, builds, evidence, catalog, backup, and restore workflows
Documentation
use super::{ListCommandError, options::ListOptions, render::ReadyStatus, state_environment};
use crate::cli::defaults::local_environment;
use crate::support::candid::registry_entry_candid_path;
use crate::support::registry_tree::visible_entries;
use canic_host::{
    canic_metadata::query_canic_metadata_version,
    canister_ready::{query_canister_ready, query_local_canister_ready},
    cycle_balance::query_cycle_balance,
    format::{cycles_tc, wasm_size_label},
    icp::{IcpCli, IcpDiagnostic, classify_icp_diagnostic},
    icp_config::resolve_current_canic_icp_root,
    installed_deployment::{
        InstalledDeploymentError, InstalledDeploymentRequest, InstalledDeploymentResolution,
        read_installed_deployment_state_from_root, resolve_installed_deployment_from_root,
    },
    registry::RegistryEntry,
    release_set::artifact_root_path,
    replica_query,
};
use std::{
    collections::{BTreeMap, BTreeSet},
    fs,
    path::{Path, PathBuf},
    sync::Arc,
    thread,
};

use super::options::ListSource;

const OBSERVATION_ERROR: &str = "error";

pub(super) fn load_registry_entries(
    options: &ListOptions,
) -> Result<Vec<RegistryEntry>, ListCommandError> {
    let registry = match options.source {
        ListSource::RootRegistry => resolve_list_deployment(options)?.registry.entries,
        ListSource::Config => {
            unreachable!("config source does not use registry entries")
        }
    };

    Ok(registry)
}

pub(super) fn list_ready_statuses(
    options: &ListOptions,
    registry: &[RegistryEntry],
    canister: Option<&str>,
) -> Result<BTreeMap<String, ReadyStatus>, ListCommandError> {
    if replica_query::should_use_local_replica_query(options.environment.as_deref()) {
        return local_ready_statuses(options, registry, canister);
    }

    let icp_root = resolve_live_icp_root()?;
    let mut statuses = BTreeMap::new();
    for entry in visible_entries(registry, canister)? {
        statuses.insert(
            entry.pid.clone(),
            check_ready_status(options, Some(&icp_root), entry),
        );
    }
    Ok(statuses)
}

pub(super) fn list_cycle_balances(
    options: &ListOptions,
    registry: &[RegistryEntry],
    canister: Option<&str>,
) -> Result<BTreeMap<String, String>, ListCommandError> {
    let icp = options.icp.clone();
    let environment = options.environment.clone();
    let icp_root = resolve_live_icp_root()?;
    collect_visible_entry_values(
        registry,
        canister,
        OBSERVATION_ERROR.to_string(),
        move |entry| {
            cycle_balance_label_endpoint(&icp, environment.clone(), Some(&icp_root), &entry)
        },
    )
}

pub(super) fn list_canic_versions(
    options: &ListOptions,
    registry: &[RegistryEntry],
    canister: Option<&str>,
) -> Result<BTreeMap<String, String>, ListCommandError> {
    let icp = options.icp.clone();
    let environment = options.environment.clone();
    let icp_root = resolve_live_icp_root()?;
    collect_visible_entry_values(
        registry,
        canister,
        OBSERVATION_ERROR.to_string(),
        move |entry| {
            canic_version_label_endpoint(&icp, environment.clone(), Some(&icp_root), &entry)
        },
    )
}

pub(super) fn list_module_hashes(
    registry: &[RegistryEntry],
    canister: Option<&str>,
) -> Result<BTreeMap<String, String>, ListCommandError> {
    Ok(visible_entries(registry, canister)?
        .into_iter()
        .filter_map(|entry| {
            entry
                .module_hash
                .as_ref()
                .map(|hash| (entry.pid.clone(), hash.clone()))
        })
        .collect())
}

pub(super) fn resolve_wasm_sizes(
    options: &ListOptions,
    registry: &[RegistryEntry],
) -> Result<BTreeMap<String, String>, ListCommandError> {
    let root = resolve_icp_artifact_root(options)?;
    let artifact_environment = state_environment(options);
    let artifact_root = artifact_root_path(&root, &artifact_environment);
    Ok(registry
        .iter()
        .filter_map(|entry| entry.role.as_deref())
        .collect::<BTreeSet<_>>()
        .into_iter()
        .filter_map(|role| {
            let artifact_dir = artifact_root.join(role);
            let wasm_bytes = fs::metadata(artifact_dir.join(format!("{role}.wasm")))
                .ok()
                .map(|metadata| metadata.len());
            let gzip_bytes = fs::metadata(artifact_dir.join(format!("{role}.wasm.gz")))
                .ok()
                .map(|metadata| metadata.len());
            if wasm_bytes.is_none() && gzip_bytes.is_none() {
                None
            } else {
                Some((role.to_string(), wasm_size_label(wasm_bytes, gzip_bytes)))
            }
        })
        .collect())
}

fn check_ready_status(
    options: &ListOptions,
    icp_root: Option<&Path>,
    entry: &RegistryEntry,
) -> ReadyStatus {
    let icp = live_icp(&options.icp, options.environment.clone(), icp_root);
    let candid_path = registry_entry_candid_path(icp_root, &state_environment(options), entry);
    let Ok(ready) = query_canister_ready(
        &icp,
        &entry.pid,
        &state_environment(options),
        icp_root,
        candid_path.as_deref(),
    ) else {
        return ReadyStatus::Error;
    };
    if ready {
        ReadyStatus::Ready
    } else {
        ReadyStatus::NotReady
    }
}

fn local_ready_statuses(
    options: &ListOptions,
    registry: &[RegistryEntry],
    canister: Option<&str>,
) -> Result<BTreeMap<String, ReadyStatus>, ListCommandError> {
    let environment = options.environment.clone();
    let icp_root = resolve_live_icp_root()?;
    collect_visible_entry_values(registry, canister, ReadyStatus::Error, move |entry| {
        match query_local_canister_ready(
            environment.as_deref().unwrap_or("local"),
            &entry.pid,
            Some(&icp_root),
        ) {
            Ok(true) => ReadyStatus::Ready,
            Ok(false) => ReadyStatus::NotReady,
            Err(_) => ReadyStatus::Error,
        }
    })
}

fn collect_visible_entry_values<T, F>(
    registry: &[RegistryEntry],
    canister: Option<&str>,
    worker_panic_value: T,
    query: F,
) -> Result<BTreeMap<String, T>, ListCommandError>
where
    T: Clone + Send + 'static,
    F: Fn(RegistryEntry) -> T + Send + Sync + 'static,
{
    let query = Arc::new(query);
    let mut handles = Vec::new();
    for entry in visible_entries(registry, canister)? {
        let entry = entry.clone();
        let pid = entry.pid.clone();
        let query = Arc::clone(&query);
        handles.push((pid, thread::spawn(move || query(entry))));
    }

    let mut values = BTreeMap::new();
    for (pid, handle) in handles {
        let value = handle.join().unwrap_or_else(|_| worker_panic_value.clone());
        values.insert(pid, value);
    }
    Ok(values)
}

fn cycle_balance_label_endpoint(
    icp: &str,
    environment: Option<String>,
    icp_root: Option<&Path>,
    entry: &RegistryEntry,
) -> String {
    let environment = environment.unwrap_or_else(local_environment);
    let candid_path = registry_entry_candid_path(icp_root, &environment, entry);
    let icp = live_icp(icp, Some(environment.clone()), icp_root);
    query_cycle_balance(
        &icp,
        &entry.pid,
        &environment,
        icp_root,
        candid_path.as_deref(),
    )
    .map_or_else(|_| OBSERVATION_ERROR.to_string(), cycles_tc)
}

fn canic_version_label_endpoint(
    icp: &str,
    environment: Option<String>,
    icp_root: Option<&Path>,
    entry: &RegistryEntry,
) -> String {
    let environment = environment.unwrap_or_else(local_environment);
    let candid_path = registry_entry_candid_path(icp_root, &environment, entry);
    let icp = live_icp(icp, Some(environment), icp_root);
    query_canic_metadata_version(&icp, &entry.pid, candid_path.as_deref())
        .unwrap_or_else(|_| OBSERVATION_ERROR.to_string())
}

fn live_icp(icp: &str, environment: Option<String>, icp_root: Option<&Path>) -> IcpCli {
    let icp = IcpCli::new(icp, environment);
    if let Some(root) = icp_root {
        icp.with_cwd(root)
    } else {
        icp
    }
}

fn resolve_icp_artifact_root(options: &ListOptions) -> Result<PathBuf, ListCommandError> {
    let icp_root = resolve_live_icp_root()?;
    if let Ok(state) = read_installed_deployment_state_from_root(
        &state_environment(options),
        &options.target,
        &icp_root,
    ) {
        return Ok(PathBuf::from(state.icp_root));
    }
    Ok(icp_root)
}

fn resolve_list_deployment(
    options: &ListOptions,
) -> Result<InstalledDeploymentResolution, ListCommandError> {
    let icp_root = resolve_live_icp_root()?;
    resolve_installed_deployment_from_root(
        &InstalledDeploymentRequest {
            deployment: options.target.clone(),
            environment: state_environment(options),
            icp: options.icp.clone(),
            detect_lost_local_root: true,
        },
        &icp_root,
    )
    .map_err(ListCommandError::from)
    .map_err(add_root_registry_hint)
}

fn resolve_live_icp_root() -> Result<PathBuf, ListCommandError> {
    resolve_current_canic_icp_root().map_err(ListCommandError::from)
}

fn add_root_registry_hint(error: ListCommandError) -> ListCommandError {
    match error {
        ListCommandError::Icp(source) => {
            let Some(hint) = source.external_output().and_then(root_registry_hint) else {
                return ListCommandError::Icp(source);
            };
            ListCommandError::IcpHint { source, hint }
        }
        ListCommandError::InstalledDeployment(InstalledDeploymentError::Icp(source)) => {
            let Some(hint) = source.external_output().and_then(root_registry_hint) else {
                return ListCommandError::InstalledDeployment(InstalledDeploymentError::Icp(
                    source,
                ));
            };
            ListCommandError::InstalledDeploymentHint {
                source: InstalledDeploymentError::Icp(source),
                hint,
            }
        }
        error => error,
    }
}

fn root_registry_hint(stderr: &str) -> Option<&'static str> {
    match classify_icp_diagnostic(stderr) {
        Some(IcpDiagnostic::CanisterIdMissing) => Some(
            "no root canister id exists for this deployment target. Use `canic fleet config <fleet-template>` for the selected fleet config, or run `canic install <fleet-template>` before querying the root registry.",
        ),
        Some(IcpDiagnostic::CanisterWasmMissing) => Some(
            "the root canister id exists but no Canic root code is installed. Run `canic install <name>`, then use `canic info list <name>`.",
        ),
        _ => None,
    }
}

#[cfg(test)]
mod tests;