Skip to main content

canic_host/install_root/
mod.rs

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