Skip to main content

canic_host/install_root/
mod.rs

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