Skip to main content

canic_host/install_root/
mod.rs

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