Skip to main content

canic_host/install_root/
mod.rs

1use crate::icp;
2use crate::release_set::{
3    configured_fleet_name, configured_install_targets, configured_local_root_create_cycles,
4    emit_root_release_set_manifest_with_config, icp_call_on_network, icp_root,
5    load_root_release_set_manifest, resolve_artifact_root, resume_root_bootstrap,
6    stage_root_release_set, workspace_root,
7};
8use canic_core::{cdk::types::Principal, protocol};
9use config_selection::resolve_install_config_path;
10use serde::Deserialize;
11use serde_json::Value;
12use std::{
13    env,
14    path::{Path, PathBuf},
15    process::Command,
16    thread,
17    time::{Duration, Instant, SystemTime, UNIX_EPOCH},
18};
19
20mod config_selection;
21mod state;
22
23pub use config_selection::discover_canic_config_choices;
24use state::{INSTALL_STATE_SCHEMA_VERSION, validate_fleet_name, write_install_state};
25pub use state::{InstallState, read_named_fleet_install_state};
26
27#[cfg(test)]
28mod tests;
29
30#[cfg(test)]
31use config_selection::config_selection_error;
32#[cfg(test)]
33use state::{fleet_install_state_path, read_fleet_install_state};
34
35///
36/// InstallRootOptions
37///
38
39#[derive(Clone, Debug)]
40pub struct InstallRootOptions {
41    pub root_canister: String,
42    pub root_build_target: String,
43    pub network: String,
44    pub ready_timeout_seconds: u64,
45    pub config_path: Option<String>,
46    pub interactive_config_selection: bool,
47}
48
49///
50/// BootstrapStatusSnapshot
51///
52
53#[derive(Clone, Debug, Deserialize, Eq, PartialEq)]
54struct BootstrapStatusSnapshot {
55    ready: bool,
56    phase: String,
57    last_error: Option<String>,
58}
59
60///
61/// InstallTimingSummary
62///
63
64#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)]
65struct InstallTimingSummary {
66    create_canisters: Duration,
67    build_all: Duration,
68    emit_manifest: Duration,
69    install_root: Duration,
70    stage_release_set: Duration,
71    resume_bootstrap: Duration,
72    wait_ready: Duration,
73}
74
75const LOCAL_ICP_READY_TIMEOUT_SECONDS: u64 = 30;
76
77/// Discover installable Canic config choices under the current workspace.
78pub fn discover_current_canic_config_choices() -> Result<Vec<PathBuf>, Box<dyn std::error::Error>> {
79    let workspace_root = workspace_root()?;
80    config_selection::discover_workspace_canic_config_choices(&workspace_root)
81}
82
83// Execute the local thin-root install flow against an already running replica.
84pub fn install_root(options: InstallRootOptions) -> Result<(), Box<dyn std::error::Error>> {
85    let workspace_root = workspace_root()?;
86    let icp_root = icp_root()?;
87    let config_path = resolve_install_config_path(
88        &workspace_root,
89        options.config_path.as_deref(),
90        options.interactive_config_selection,
91    )?;
92    let fleet_name = configured_fleet_name(&config_path)?;
93    validate_fleet_name(&fleet_name)?;
94    let total_started_at = Instant::now();
95    let mut timings = InstallTimingSummary::default();
96
97    println!(
98        "Installing fleet {} against ICP_ENVIRONMENT={}",
99        fleet_name, options.network
100    );
101    ensure_icp_environment_ready(&icp_root, &options.network)?;
102    let create_started_at = Instant::now();
103    if Principal::from_text(&options.root_canister).is_err() {
104        let mut create = icp_canister_command_in_network(&icp_root);
105        create.args(["create", &options.root_canister, "-q"]);
106        add_local_root_create_cycles_arg(&mut create, &config_path, &options.network)?;
107        add_icp_environment_target(&mut create, &options.network);
108        run_command(&mut create)?;
109    }
110    timings.create_canisters = create_started_at.elapsed();
111
112    let build_targets = configured_install_targets(&config_path, &options.root_build_target)?;
113    let build_session_id = install_build_session_id();
114    let build_started_at = Instant::now();
115    run_canic_build_targets(
116        &icp_root,
117        &options.network,
118        &build_targets,
119        &build_session_id,
120        &config_path,
121    )?;
122    timings.build_all = build_started_at.elapsed();
123
124    let emit_manifest_started_at = Instant::now();
125    let manifest_path = emit_root_release_set_manifest_with_config(
126        &workspace_root,
127        &icp_root,
128        &options.network,
129        &config_path,
130    )?;
131    timings.emit_manifest = emit_manifest_started_at.elapsed();
132
133    let root_wasm = resolve_artifact_root(&icp_root, &options.network)?
134        .join(&options.root_build_target)
135        .join(format!("{}.wasm", options.root_build_target));
136    let mut install = icp_canister_command_in_network(&icp_root);
137    install.args([
138        "install",
139        &options.root_canister,
140        "--mode=reinstall",
141        "-y",
142        "--wasm",
143    ]);
144    install.arg(&root_wasm);
145    install.args(["--args", "(variant { Prime })"]);
146    add_icp_environment_target(&mut install, &options.network);
147    let install_started_at = Instant::now();
148    run_command(&mut install)?;
149    timings.install_root = install_started_at.elapsed();
150
151    let manifest = load_root_release_set_manifest(&manifest_path)?;
152    let stage_started_at = Instant::now();
153    stage_root_release_set(
154        &icp_root,
155        &options.network,
156        &options.root_canister,
157        &manifest,
158    )?;
159    timings.stage_release_set = stage_started_at.elapsed();
160    let resume_started_at = Instant::now();
161    resume_root_bootstrap(&options.network, &options.root_canister)?;
162    timings.resume_bootstrap = resume_started_at.elapsed();
163    let ready_started_at = Instant::now();
164    let ready_result = wait_for_root_ready(
165        &options.network,
166        &options.root_canister,
167        options.ready_timeout_seconds,
168    );
169    timings.wait_ready = ready_started_at.elapsed();
170    if let Err(err) = ready_result {
171        print_install_timing_summary(&timings, total_started_at.elapsed());
172        return Err(err);
173    }
174
175    print_install_timing_summary(&timings, total_started_at.elapsed());
176    let state = build_install_state(
177        &options,
178        &workspace_root,
179        &icp_root,
180        &config_path,
181        &manifest_path,
182        &fleet_name,
183    )?;
184    let state_path = write_install_state(&icp_root, &options.network, &state)?;
185    print_install_result_summary(&options.network, &state.fleet, &state_path);
186    Ok(())
187}
188
189// Build the persisted project-local install state from a completed install.
190fn build_install_state(
191    options: &InstallRootOptions,
192    workspace_root: &Path,
193    icp_root: &Path,
194    config_path: &Path,
195    release_set_manifest_path: &Path,
196    fleet_name: &str,
197) -> Result<InstallState, Box<dyn std::error::Error>> {
198    Ok(InstallState {
199        schema_version: INSTALL_STATE_SCHEMA_VERSION,
200        fleet: fleet_name.to_string(),
201        installed_at_unix_secs: current_unix_secs()?,
202        network: options.network.clone(),
203        root_target: options.root_canister.clone(),
204        root_canister_id: resolve_root_canister_id(
205            icp_root,
206            &options.network,
207            &options.root_canister,
208        )?,
209        root_build_target: options.root_build_target.clone(),
210        workspace_root: workspace_root.display().to_string(),
211        icp_root: icp_root.display().to_string(),
212        config_path: config_path.display().to_string(),
213        release_set_manifest_path: release_set_manifest_path.display().to_string(),
214    })
215}
216
217// Resolve the installed root id, accepting principal targets without a icp lookup.
218fn resolve_root_canister_id(
219    icp_root: &Path,
220    network: &str,
221    root_canister: &str,
222) -> Result<String, Box<dyn std::error::Error>> {
223    if Principal::from_text(root_canister).is_ok() {
224        return Ok(root_canister.to_string());
225    }
226
227    let mut command = icp_canister_command_in_network(icp_root);
228    command.args(["status", root_canister, "-i"]);
229    add_icp_environment_target(&mut command, network);
230    Ok(run_command_stdout(&mut command)?.trim().to_string())
231}
232
233// Read the current host clock as a unix timestamp for install state.
234fn current_unix_secs() -> Result<u64, Box<dyn std::error::Error>> {
235    Ok(SystemTime::now().duration_since(UNIX_EPOCH)?.as_secs())
236}
237
238// Run one `canic build <canister>` call per configured local install target.
239fn run_canic_build_targets(
240    icp_root: &Path,
241    network: &str,
242    targets: &[String],
243    build_session_id: &str,
244    config_path: &Path,
245) -> Result<(), Box<dyn std::error::Error>> {
246    println!("Build artifacts:");
247    println!("{:<16} {:<18} {:>10}", "CANISTER", "PROGRESS", "ELAPSED");
248
249    for (index, target) in targets.iter().enumerate() {
250        let mut command = canic_build_target_command(icp_root, network, target, build_session_id);
251        command.env("CANIC_CONFIG_PATH", config_path);
252        let started_at = Instant::now();
253        let output = command.output()?;
254        let elapsed = started_at.elapsed();
255
256        if !output.status.success() {
257            return Err(format!(
258                "canic build failed for {target}: {}\nstdout:\n{}\nstderr:\n{}",
259                output.status,
260                String::from_utf8_lossy(&output.stdout).trim(),
261                String::from_utf8_lossy(&output.stderr).trim()
262            )
263            .into());
264        }
265
266        println!(
267            "{:<16} {:<18} {:>9.2}s",
268            target,
269            progress_bar(index + 1, targets.len(), 10),
270            elapsed.as_secs_f64()
271        );
272    }
273
274    println!();
275    Ok(())
276}
277
278// Spawn one local `canic build <canister>` step without overriding the caller's
279// selected build profile environment.
280fn canic_build_target_command(
281    _icp_root: &Path,
282    network: &str,
283    target: &str,
284    build_session_id: &str,
285) -> Command {
286    let mut command = canic_command();
287    command
288        .env("CANIC_BUILD_CONTEXT_SESSION", build_session_id)
289        .env("ICP_ENVIRONMENT", network)
290        .args(["build", target]);
291    command
292}
293
294// Re-enter the current Canic CLI binary so install builds use the same public
295// build path operators can run directly.
296fn canic_command() -> Command {
297    std::env::current_exe().map_or_else(|_| Command::new("canic"), Command::new)
298}
299
300fn install_build_session_id() -> String {
301    let unique = SystemTime::now()
302        .duration_since(UNIX_EPOCH)
303        .map_or(0, |duration| duration.as_nanos());
304    format!("install-root-{}-{unique}", std::process::id())
305}
306
307fn add_local_root_create_cycles_arg(
308    command: &mut Command,
309    config_path: &Path,
310    network: &str,
311) -> Result<(), Box<dyn std::error::Error>> {
312    if network != "local" {
313        return Ok(());
314    }
315
316    let cycles = configured_local_root_create_cycles(config_path)?;
317    command.args(["--cycles", &cycles.to_string()]);
318    Ok(())
319}
320
321fn progress_bar(current: usize, total: usize, width: usize) -> String {
322    if total == 0 || width == 0 {
323        return "[] 0/0".to_string();
324    }
325
326    let filled = current.saturating_mul(width).div_ceil(total);
327    let filled = filled.min(width);
328    format!(
329        "[{}{}] {current}/{total}",
330        "#".repeat(filled),
331        " ".repeat(width - filled)
332    )
333}
334
335// Ensure the requested replica is reachable before the local install flow begins.
336fn ensure_icp_environment_ready(
337    icp_root: &Path,
338    network: &str,
339) -> Result<(), Box<dyn std::error::Error>> {
340    if icp_ping(network)? {
341        return Ok(());
342    }
343
344    if network == "local" && local_icp_autostart_enabled() {
345        println!("Local icp environment is not reachable; starting a clean local replica");
346        let mut stop = icp_stop_command(icp_root);
347        let _ = run_command_allow_failure(&mut stop)?;
348
349        let mut start = icp_start_local_command(icp_root);
350        run_command(&mut start)?;
351        wait_for_icp_ping(
352            network,
353            Duration::from_secs(LOCAL_ICP_READY_TIMEOUT_SECONDS),
354        )?;
355        return Ok(());
356    }
357
358    Err(format!(
359        "icp environment is not running for network '{network}'\nStart the target replica externally and rerun."
360    )
361    .into())
362}
363
364// Check whether `icp network ping <network>` currently succeeds.
365fn icp_ping(network: &str) -> Result<bool, Box<dyn std::error::Error>> {
366    Ok(icp::default_command()
367        .args(["network", "ping", network])
368        .output()?
369        .status
370        .success())
371}
372
373// Return true when the local install flow should auto-start a clean local replica.
374fn local_icp_autostart_enabled() -> bool {
375    parse_local_icp_autostart(env::var("CANIC_AUTO_START_LOCAL_ICP").ok().as_deref())
376}
377
378fn parse_local_icp_autostart(value: Option<&str>) -> bool {
379    !matches!(
380        value.map(str::trim).map(str::to_ascii_lowercase).as_deref(),
381        Some("0" | "false" | "no" | "off")
382    )
383}
384
385// Spawn one local `icp network stop` command for cleanup before a restart.
386fn icp_stop_command(icp_root: &Path) -> Command {
387    let mut command = icp_command_in_network(icp_root, "local");
388    command.args(["network", "stop", "local"]);
389    command
390}
391
392// Spawn one background `icp network start` command for local install/test flows.
393fn icp_start_local_command(icp_root: &Path) -> Command {
394    let mut command = icp_command_in_network(icp_root, "local");
395    command.args(["network", "start", "local", "--background"]);
396    command
397}
398
399// Poll `icp network ping` until the requested network responds or the timeout expires.
400fn wait_for_icp_ping(network: &str, timeout: Duration) -> Result<(), Box<dyn std::error::Error>> {
401    let start = Instant::now();
402    while start.elapsed() < timeout {
403        if icp_ping(network)? {
404            return Ok(());
405        }
406        thread::sleep(Duration::from_millis(500));
407    }
408
409    Err(format!(
410        "icp environment did not become ready for network '{network}' within {}s",
411        timeout.as_secs()
412    )
413    .into())
414}
415
416// Wait until root reports ready, printing periodic progress and diagnostics.
417fn wait_for_root_ready(
418    network: &str,
419    root_canister: &str,
420    timeout_seconds: u64,
421) -> Result<(), Box<dyn std::error::Error>> {
422    let start = std::time::Instant::now();
423    let mut next_report = 0_u64;
424
425    println!("Waiting for {root_canister} to report canic_ready (timeout {timeout_seconds}s)");
426
427    loop {
428        if root_ready(network, root_canister)? {
429            println!(
430                "{root_canister} reported canic_ready after {}s",
431                start.elapsed().as_secs()
432            );
433            return Ok(());
434        }
435
436        if let Some(status) = root_bootstrap_status(network, root_canister)?
437            && let Some(last_error) = status.last_error.as_deref()
438        {
439            eprintln!(
440                "root bootstrap reported failure during phase '{}' : {}",
441                status.phase, last_error
442            );
443            eprintln!(
444                "Diagnostic: icp canister -n {network} call {root_canister} canic_bootstrap_status"
445            );
446            print_raw_call(network, root_canister, protocol::CANIC_BOOTSTRAP_STATUS);
447            eprintln!(
448                "Diagnostic: icp canister -n {network} call {root_canister} canic_subnet_registry"
449            );
450            print_raw_call(network, root_canister, "canic_subnet_registry");
451            eprintln!(
452                "Diagnostic: icp canister -n {network} call {root_canister} canic_wasm_store_bootstrap_debug"
453            );
454            print_raw_call(network, root_canister, "canic_wasm_store_bootstrap_debug");
455            eprintln!(
456                "Diagnostic: icp canister -n {network} call {root_canister} canic_wasm_store_overview"
457            );
458            print_raw_call(network, root_canister, "canic_wasm_store_overview");
459            eprintln!("Diagnostic: icp canister -n {network} call {root_canister} canic_log");
460            print_recent_root_logs(network, root_canister);
461            return Err(format!(
462                "root bootstrap failed during phase '{}' : {}",
463                status.phase, last_error
464            )
465            .into());
466        }
467
468        let elapsed = start.elapsed().as_secs();
469        if elapsed >= timeout_seconds {
470            eprintln!("root did not report canic_ready within {timeout_seconds}s");
471            eprintln!(
472                "Diagnostic: icp canister -n {network} call {root_canister} canic_bootstrap_status"
473            );
474            print_raw_call(network, root_canister, protocol::CANIC_BOOTSTRAP_STATUS);
475            eprintln!(
476                "Diagnostic: icp canister -n {network} call {root_canister} canic_subnet_registry"
477            );
478            print_raw_call(network, root_canister, "canic_subnet_registry");
479            eprintln!(
480                "Diagnostic: icp canister -n {network} call {root_canister} canic_wasm_store_bootstrap_debug"
481            );
482            print_raw_call(network, root_canister, "canic_wasm_store_bootstrap_debug");
483            eprintln!(
484                "Diagnostic: icp canister -n {network} call {root_canister} canic_wasm_store_overview"
485            );
486            print_raw_call(network, root_canister, "canic_wasm_store_overview");
487            eprintln!("Diagnostic: icp canister -n {network} call {root_canister} canic_log");
488            print_recent_root_logs(network, root_canister);
489            return Err("root did not become ready".into());
490        }
491
492        if elapsed >= next_report {
493            println!("Still waiting for {root_canister} canic_ready ({elapsed}s elapsed)");
494            if let Some(status) = root_bootstrap_status(network, root_canister)? {
495                match status.last_error.as_deref() {
496                    Some(last_error) => println!(
497                        "Current bootstrap status: phase={} ready={} error={}",
498                        status.phase, status.ready, last_error
499                    ),
500                    None => println!(
501                        "Current bootstrap status: phase={} ready={}",
502                        status.phase, status.ready
503                    ),
504                }
505            }
506            if let Ok(registry_json) = icp_call_on_network(
507                network,
508                root_canister,
509                "canic_subnet_registry",
510                None,
511                Some("json"),
512            ) {
513                println!("Current subnet registry roles:");
514                println!("  {}", registry_roles(&registry_json));
515            }
516            next_report = elapsed + 5;
517        }
518
519        thread::sleep(Duration::from_secs(1));
520    }
521}
522
523// Return true once root reports `canic_ready == true`.
524fn root_ready(network: &str, root_canister: &str) -> Result<bool, Box<dyn std::error::Error>> {
525    let output = icp_call_on_network(network, root_canister, "canic_ready", None, Some("json"))?;
526    let data = serde_json::from_str::<Value>(&output)?;
527    Ok(parse_root_ready_value(&data))
528}
529
530// Return the current root bootstrap diagnostic state when the query is available.
531fn root_bootstrap_status(
532    network: &str,
533    root_canister: &str,
534) -> Result<Option<BootstrapStatusSnapshot>, Box<dyn std::error::Error>> {
535    let output = match icp_call_on_network(
536        network,
537        root_canister,
538        protocol::CANIC_BOOTSTRAP_STATUS,
539        None,
540        Some("json"),
541    ) {
542        Ok(output) => output,
543        Err(err) => {
544            let message = err.to_string();
545            if message.contains("has no query method")
546                || message.contains("method not found")
547                || message.contains("Canister has no query method")
548            {
549                return Ok(None);
550            }
551            return Err(err);
552        }
553    };
554    let data = serde_json::from_str::<Value>(&output)?;
555    Ok(parse_bootstrap_status_value(&data))
556}
557
558// Accept both plain-bool and wrapped-result JSON shapes from `icp --output json`.
559fn parse_root_ready_value(data: &Value) -> bool {
560    matches!(data, Value::Bool(true))
561        || matches!(data.get("Ok"), Some(Value::Bool(true)))
562        || data
563            .get("response_candid")
564            .and_then(Value::as_str)
565            .is_some_and(|value| value.trim() == "(true)")
566}
567
568fn parse_bootstrap_status_value(data: &Value) -> Option<BootstrapStatusSnapshot> {
569    serde_json::from_value::<BootstrapStatusSnapshot>(data.clone())
570        .ok()
571        .or_else(|| {
572            data.get("Ok")
573                .cloned()
574                .and_then(|ok| serde_json::from_value::<BootstrapStatusSnapshot>(ok).ok())
575        })
576        .or_else(|| {
577            data.get("response_candid")
578                .and_then(Value::as_str)
579                .and_then(parse_bootstrap_status_candid)
580        })
581}
582
583fn parse_bootstrap_status_candid(candid: &str) -> Option<BootstrapStatusSnapshot> {
584    let ready = if candid.contains("3_870_990_435 = true") || candid.contains("ready = true") {
585        true
586    } else if candid.contains("3_870_990_435 = false") || candid.contains("ready = false") {
587        false
588    } else {
589        return None;
590    };
591
592    let phase = extract_candid_text_field(candid, "3_253_282_875")
593        .or_else(|| extract_candid_text_field(candid, "phase"))
594        .unwrap_or_else(|| {
595            if ready {
596                "ready".to_string()
597            } else {
598                "unknown".to_string()
599            }
600        });
601    let last_error = extract_candid_text_field(candid, "89_620_959")
602        .or_else(|| extract_candid_text_field(candid, "last_error"));
603
604    Some(BootstrapStatusSnapshot {
605        ready,
606        phase,
607        last_error,
608    })
609}
610
611fn extract_candid_text_field(candid: &str, label: &str) -> Option<String> {
612    let (_, tail) = candid.split_once(&format!("{label} = "))?;
613    let tail = tail.trim_start();
614    let quoted = tail
615        .strip_prefix("opt \"")
616        .or_else(|| tail.strip_prefix('"'))?;
617    let mut value = String::new();
618    let mut escaped = false;
619    for ch in quoted.chars() {
620        if escaped {
621            value.push(ch);
622            escaped = false;
623            continue;
624        }
625        if ch == '\\' {
626            escaped = true;
627            continue;
628        }
629        if ch == '"' {
630            return Some(value);
631        }
632        value.push(ch);
633    }
634    None
635}
636
637fn print_install_timing_summary(timings: &InstallTimingSummary, total: Duration) {
638    println!("Install timing summary:");
639    println!("{:<20} {:>10}", "phase", "elapsed");
640    println!("{:<20} {:>10}", "--------------------", "----------");
641    print_timing_row("create_canisters", timings.create_canisters);
642    print_timing_row("build_all", timings.build_all);
643    print_timing_row("emit_manifest", timings.emit_manifest);
644    print_timing_row("install_root", timings.install_root);
645    print_timing_row("stage_release_set", timings.stage_release_set);
646    print_timing_row("resume_bootstrap", timings.resume_bootstrap);
647    print_timing_row("wait_ready", timings.wait_ready);
648    print_timing_row("total", total);
649}
650
651fn print_timing_row(label: &str, duration: Duration) {
652    println!("{label:<20} {:>9.2}s", duration.as_secs_f64());
653}
654
655// Print the final install result as a compact whitespace table.
656fn print_install_result_summary(network: &str, fleet: &str, state_path: &Path) {
657    println!("Install result:");
658    println!("{:<14} success", "status");
659    println!("{:<14} {}", "fleet", fleet);
660    println!("{:<14} {}", "install_state", state_path.display());
661    println!(
662        "{:<14} canic list {} --network {}",
663        "smoke_check", fleet, network
664    );
665}
666
667// Print recent structured root log entries without raw byte dumps.
668fn print_recent_root_logs(network: &str, root_canister: &str) {
669    let page_args = r"(null, null, null, record { limit = 8; offset = 0 })";
670    let Ok(logs_json) = icp_call_on_network(
671        network,
672        root_canister,
673        "canic_log",
674        Some(page_args),
675        Some("json"),
676    ) else {
677        return;
678    };
679    let Ok(data) = serde_json::from_str::<Value>(&logs_json) else {
680        return;
681    };
682    let entries = data
683        .get("Ok")
684        .and_then(|ok| ok.get("entries"))
685        .and_then(Value::as_array)
686        .cloned()
687        .unwrap_or_default();
688
689    if entries.is_empty() {
690        println!("  <no runtime log entries>");
691        return;
692    }
693
694    for entry in entries.iter().rev() {
695        let level = entry.get("level").and_then(Value::as_str).unwrap_or("Info");
696        let topic = entry.get("topic").and_then(Value::as_str).unwrap_or("");
697        let message = entry
698            .get("message")
699            .and_then(Value::as_str)
700            .unwrap_or("")
701            .replace('\n', "\\n");
702        let topic_prefix = if topic.is_empty() {
703            String::new()
704        } else {
705            format!("[{topic}] ")
706        };
707        println!("  {level} {topic_prefix}{message}");
708    }
709}
710
711// Render the current subnet registry roles from one JSON response.
712fn registry_roles(registry_json: &str) -> String {
713    serde_json::from_str::<Value>(registry_json)
714        .ok()
715        .and_then(|data| {
716            data.get("Ok").and_then(Value::as_array).map(|entries| {
717                entries
718                    .iter()
719                    .filter_map(|entry| {
720                        entry
721                            .get("role")
722                            .and_then(Value::as_str)
723                            .map(str::to_string)
724                    })
725                    .collect::<Vec<_>>()
726            })
727        })
728        .map_or_else(
729            || "<unavailable>".to_string(),
730            |roles| {
731                if roles.is_empty() {
732                    "<empty>".to_string()
733                } else {
734                    roles.join(", ")
735                }
736            },
737        )
738}
739
740// Run one command and require a zero exit status.
741fn run_command(command: &mut Command) -> Result<(), Box<dyn std::error::Error>> {
742    icp::run_status(command).map_err(Into::into)
743}
744
745// Run one command, require success, and return stdout.
746fn run_command_stdout(command: &mut Command) -> Result<String, Box<dyn std::error::Error>> {
747    icp::run_output(command).map_err(Into::into)
748}
749
750// Run one command and return its status without failing the caller on non-zero exit.
751fn run_command_allow_failure(
752    command: &mut Command,
753) -> Result<std::process::ExitStatus, Box<dyn std::error::Error>> {
754    Ok(command.status()?)
755}
756
757// Print one raw `icp canister call` result to stderr for diagnostics.
758fn print_raw_call(network: &str, root_canister: &str, method: &str) {
759    let mut command = icp_root().map_or_else(
760        |_| icp_command_on_network(network),
761        |root| icp_command_in_network(&root, network),
762    );
763    let _ = command
764        .arg("canister")
765        .args(["call", root_canister, method, "()", "-e", network])
766        .status();
767}
768
769// Build an icp command with the selected install environment exported
770// for Rust build scripts that inspect ICP_ENVIRONMENT at compile time.
771fn icp_command_on_network(network: &str) -> Command {
772    let mut command = icp::default_command();
773    command.env("ICP_ENVIRONMENT", network);
774    command
775}
776
777// Build an icp command in one project directory with ICP_ENVIRONMENT applied.
778fn icp_command_in_network(icp_root: &Path, network: &str) -> Command {
779    let mut command = icp::default_command_in(icp_root);
780    command.env("ICP_ENVIRONMENT", network);
781    command
782}
783
784// Build an icp canister command in one project directory.
785fn icp_canister_command_in_network(icp_root: &Path) -> Command {
786    let mut command = icp::default_command_in(icp_root);
787    command.arg("canister");
788    command
789}
790
791fn add_icp_environment_target(command: &mut Command, network: &str) {
792    icp::add_target_args(command, Some(network), None);
793}