canic-cli 0.111.0

Operator CLI for Canic fleet setup, builds, evidence, catalog, backup, and restore workflows
Documentation
use super::{
    ListCommandError,
    options::{ListOptions, ListSource},
    render::ConfigRoleRow,
};
use canic_contracts::ids::AppId;
use canic_host::{
    config_discovery::{discover_current_canic_config_choices, select_discovered_app_config_path},
    fleet_ensure::CurrentFleetResolution,
    registry::RegistryEntry,
    release_set::AppConfigSnapshot,
};
use std::{
    collections::{BTreeMap, BTreeSet},
    path::PathBuf,
};

pub(super) fn load_config_role_rows(
    options: &ListOptions,
) -> Result<Vec<ConfigRoleRow>, ListCommandError> {
    let config_path = selected_config_path(options)?;
    let config = AppConfigSnapshot::load(&config_path)?;
    let roles = config.deployable_roles();
    let kinds = config.role_kinds();
    let capabilities = config.role_capabilities()?;
    let auto_create = config.role_auto_create();
    let topups = config.role_topups();
    let metrics = config.role_metrics_profiles();
    let details = if options.verbose {
        config.role_details()
    } else {
        BTreeMap::new()
    };
    Ok(roles
        .into_iter()
        .map(|role| ConfigRoleRow {
            capabilities: capabilities
                .get(&role)
                .filter(|capabilities| !capabilities.is_empty())
                .map_or_else(|| "-".to_string(), |capabilities| capabilities.join(", ")),
            auto_create: auto_create_label(&role, &auto_create),
            topup: topups
                .get(&role)
                .cloned()
                .unwrap_or_else(|| "-".to_string()),
            metrics: metrics
                .get(&role)
                .cloned()
                .unwrap_or_else(|| "-".to_string()),
            details: details.get(&role).cloned().unwrap_or_default(),
            kind: kinds
                .get(&role)
                .cloned()
                .unwrap_or_else(|| "unknown".to_string()),
            role,
        })
        .collect())
}

fn auto_create_label(role: &str, auto_create: &BTreeSet<String>) -> String {
    if role == "root" {
        "-".to_string()
    } else if auto_create.contains(role) {
        "yes".to_string()
    } else {
        "no".to_string()
    }
}

pub(super) fn missing_config_roles(
    options: &ListOptions,
    fleet: &CurrentFleetResolution,
) -> Result<Vec<String>, ListCommandError> {
    if !matches!(options.source, ListSource::FleetInventory) || options.subtree.is_some() {
        return Ok(Vec::new());
    }

    let app = &fleet
        .initial_active_registry(&options.target)?
        .authority
        .binding
        .fleet
        .app;
    let choices = discover_current_canic_config_choices()?;
    missing_config_roles_for_app(app, &fleet.registry.entries, &choices)
}

fn missing_config_roles_for_app(
    app: &AppId,
    registry: &[RegistryEntry],
    choices: &[PathBuf],
) -> Result<Vec<String>, ListCommandError> {
    let config_path = selected_app_config_path(choices, app.as_ref())?;
    let config = AppConfigSnapshot::load(&config_path)?;
    let expected = config.deployable_roles();
    let deployed = registry
        .iter()
        .filter_map(|entry| entry.role.as_deref())
        .collect::<BTreeSet<_>>();
    Ok(expected
        .into_iter()
        .filter(|role| !deployed.contains(role.as_str()))
        .collect())
}

fn selected_config_path(options: &ListOptions) -> Result<PathBuf, ListCommandError> {
    let choices = discover_current_canic_config_choices()?;
    selected_app_config_path(&choices, &options.target)
}

fn selected_app_config_path(choices: &[PathBuf], app: &str) -> Result<PathBuf, ListCommandError> {
    select_discovered_app_config_path(choices, app)?
        .ok_or_else(|| ListCommandError::UnknownApp(app.to_string()))
}

#[cfg(test)]
mod tests {
    use super::*;
    use canic_host::{
        config_discovery::discover_workspace_canic_config_choices, release_set::AppConfigError,
    };
    use std::{
        fs,
        sync::atomic::{AtomicU64, Ordering},
    };

    const CONFIG: &str = r#"
[app]
name = "demo"

[roles.root]
kind = "root"

[roles.store]
kind = "canister"
package = "store"

[component_specs.storage]
component_role = "store"
maximum_instances = 1
"#;

    struct WorkspaceFixture(PathBuf);

    impl WorkspaceFixture {
        fn new() -> Self {
            static NEXT_FIXTURE: AtomicU64 = AtomicU64::new(0);
            let root = std::env::temp_dir().join(format!(
                "canic-list-config-{}-{}",
                std::process::id(),
                NEXT_FIXTURE.fetch_add(1, Ordering::Relaxed)
            ));
            fs::create_dir_all(root.join("apps/demo")).expect("create App config directory");
            Self(root)
        }

        fn write_config(&self, source: &str) -> PathBuf {
            let path = self.0.join("apps/demo/canic.toml");
            fs::write(&path, source).expect("write App config");
            path
        }
    }

    impl Drop for WorkspaceFixture {
        fn drop(&mut self) {
            fs::remove_dir_all(&self.0).expect("remove App config fixture");
        }
    }

    #[test]
    fn missing_roles_select_the_bound_app_for_a_differently_named_fleet() {
        let options = ListOptions::parse_info_list(["demo-local".into()])
            .expect("parse differently named Fleet");
        let app = AppId::from("demo");
        assert_ne!(options.target, app.as_ref());
        let fixture = WorkspaceFixture::new();
        fixture.write_config(CONFIG);
        fs::create_dir_all(fixture.0.join("apps/other")).expect("create unrelated App directory");
        fs::write(
            fixture.0.join("apps/other/canic.toml"),
            CONFIG
                .replace("demo", "other")
                .replace("store", "other_store"),
        )
        .expect("write unrelated App config");
        let choices =
            discover_workspace_canic_config_choices(&fixture.0).expect("discover configs");
        let registry = vec![RegistryEntry {
            pid: "aaaaa-aa".to_string(),
            role: Some("root".to_string()),
            parent_pid: None,
            module_hash: None,
            protocol_binding: None,
        }];

        assert_eq!(
            missing_config_roles_for_app(&app, &registry, &choices)
                .expect("compare configured roles"),
            vec!["store".to_string()]
        );
    }

    #[test]
    fn missing_roles_preserve_unknown_app_and_invalid_config_failures() {
        let app = AppId::from("demo");
        std::assert_matches!(
            missing_config_roles_for_app(&app, &[], &[]),
            Err(ListCommandError::UnknownApp(app)) if app == "demo"
        );

        let fixture = WorkspaceFixture::new();
        let path = fixture.write_config(&CONFIG.replace("maximum_instances = 1", "unknown = true"));
        let choices = discover_workspace_canic_config_choices(&fixture.0).expect("discover config");
        std::assert_matches!(
            missing_config_roles_for_app(&app, &[], &choices),
            Err(ListCommandError::AppConfig(AppConfigError::ConfigInvalid {
                path: actual,
                ..
            })) if actual == path
        );
    }
}