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