Skip to main content

canic_host/install_root/
mod.rs

1use crate::canister_build::{
2    CanisterBuildProfile, build_current_workspace_canister_artifact,
3    current_workspace_build_context_once,
4};
5use crate::format::wasm_size_label;
6use crate::icp::{self, CANIC_ICP_LOCAL_NETWORK_URL_ENV, CANIC_ICP_LOCAL_ROOT_KEY_ENV};
7use crate::release_set::{
8    LOCAL_ROOT_MIN_READY_CYCLES, configured_fleet_name, configured_install_targets,
9    configured_local_root_create_cycles, emit_root_release_set_manifest_with_config,
10    icp_call_on_network, icp_root, load_root_release_set_manifest, resolve_artifact_root,
11    resume_root_bootstrap, stage_root_release_set, workspace_root,
12};
13use crate::replica_query;
14use crate::response_parse::parse_cycle_balance_response;
15use crate::table::{ColumnAlign, render_separator, render_table, render_table_row, table_widths};
16use canic_core::{
17    cdk::{types::Principal, utils::hash::wasm_hash},
18    protocol,
19};
20use config_selection::resolve_install_config_path;
21use serde_json::Value as JsonValue;
22use std::{
23    env,
24    ffi::OsString,
25    fs,
26    path::{Path, PathBuf},
27    process::Command,
28    time::{Duration, Instant, SystemTime, UNIX_EPOCH},
29};
30
31mod config_selection;
32mod readiness;
33mod state;
34
35pub use config_selection::{
36    current_canic_project_root, discover_canic_config_choices, discover_canic_project_root_from,
37    discover_project_canic_config_choices, project_fleet_roots,
38};
39use readiness::wait_for_root_ready;
40use state::{INSTALL_STATE_SCHEMA_VERSION, validate_fleet_name, write_install_state};
41pub use state::{
42    InstallState, read_named_fleet_install_state, read_named_fleet_install_state_from_root,
43};
44
45#[cfg(test)]
46mod tests;
47
48#[cfg(test)]
49use config_selection::config_selection_error;
50#[cfg(test)]
51use readiness::{parse_bootstrap_status_value, parse_root_ready_value};
52#[cfg(test)]
53use state::{fleet_install_state_path, read_fleet_install_state};
54
55///
56/// InstallRootOptions
57///
58
59#[derive(Clone, Debug)]
60pub struct InstallRootOptions {
61    pub root_canister: String,
62    pub root_build_target: String,
63    pub network: String,
64    pub icp_root: Option<PathBuf>,
65    pub build_profile: Option<CanisterBuildProfile>,
66    pub ready_timeout_seconds: u64,
67    pub config_path: Option<String>,
68    pub expected_fleet: Option<String>,
69    pub interactive_config_selection: bool,
70}
71
72///
73/// InstallTimingSummary
74///
75
76#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)]
77struct InstallTimingSummary {
78    create_canisters: Duration,
79    build_all: Duration,
80    emit_manifest: Duration,
81    install_root: Duration,
82    fund_root: Duration,
83    stage_release_set: Duration,
84    resume_bootstrap: Duration,
85    wait_ready: Duration,
86    finalize_root_funding: Duration,
87}
88
89/// Discover installable Canic config choices under the current workspace.
90pub fn discover_current_canic_config_choices() -> Result<Vec<PathBuf>, Box<dyn std::error::Error>> {
91    let project_root = current_canic_project_root()?;
92    let choices = config_selection::discover_workspace_canic_config_choices(&project_root)?;
93    if !choices.is_empty() {
94        return Ok(choices);
95    }
96
97    if let Ok(icp_root) = icp_root()
98        && icp_root != project_root
99    {
100        return config_selection::discover_workspace_canic_config_choices(&icp_root);
101    }
102
103    Ok(choices)
104}
105
106// Execute the local thin-root install flow against an already running replica.
107pub fn install_root(options: InstallRootOptions) -> Result<(), Box<dyn std::error::Error>> {
108    let workspace_root = workspace_root()?;
109    let icp_root = match &options.icp_root {
110        Some(path) => path.canonicalize()?,
111        None => icp_root()?,
112    };
113    let config_path = resolve_install_config_path(
114        &icp_root,
115        options.config_path.as_deref(),
116        options.interactive_config_selection,
117    )?;
118    let _install_env = BuildEnvGuard::apply(&options.network, &config_path, &icp_root);
119    let fleet_name = configured_fleet_name(&config_path)?;
120    validate_expected_fleet_name(options.expected_fleet.as_deref(), &fleet_name, &config_path)?;
121    validate_fleet_name(&fleet_name)?;
122    let total_started_at = Instant::now();
123    let mut timings = InstallTimingSummary::default();
124    let network = options.network.as_str();
125
126    println!("Installing fleet {fleet_name}");
127    println!();
128    ensure_icp_environment_ready(&icp_root, &options.network)?;
129    let create_started_at = Instant::now();
130    let root_canister_id = ensure_root_canister_id(
131        &icp_root,
132        &options.network,
133        &options.root_canister,
134        &config_path,
135    )?;
136    timings.create_canisters = create_started_at.elapsed();
137
138    let build_targets = configured_install_targets(&config_path, &options.root_build_target)?;
139    let build_started_at = Instant::now();
140    run_canic_build_targets(
141        &options.network,
142        &build_targets,
143        options.build_profile,
144        &config_path,
145        &icp_root,
146    )?;
147    timings.build_all = build_started_at.elapsed();
148
149    let emit_manifest_started_at = Instant::now();
150    let manifest_path = emit_root_release_set_manifest_with_config(
151        &workspace_root,
152        &icp_root,
153        &options.network,
154        &config_path,
155    )?;
156    timings.emit_manifest = emit_manifest_started_at.elapsed();
157
158    let root_wasm = resolve_artifact_root(&icp_root, &options.network)?
159        .join(&options.root_build_target)
160        .join(format!("{}.wasm", options.root_build_target));
161    let install_started_at = Instant::now();
162    reinstall_root_wasm(&icp_root, &options.network, &root_canister_id, &root_wasm)?;
163    timings.install_root = install_started_at.elapsed();
164    let fund_root_started_at = Instant::now();
165    ensure_local_root_min_cycles(&icp_root, network, &root_canister_id, "pre-bootstrap")?;
166    timings.fund_root = fund_root_started_at.elapsed();
167
168    let manifest = load_root_release_set_manifest(&manifest_path)?;
169    let stage_started_at = Instant::now();
170    stage_root_release_set(&icp_root, &options.network, &root_canister_id, &manifest)?;
171    timings.stage_release_set = stage_started_at.elapsed();
172    let resume_started_at = Instant::now();
173    resume_root_bootstrap(&options.network, &root_canister_id)?;
174    timings.resume_bootstrap = resume_started_at.elapsed();
175    let ready_started_at = Instant::now();
176    let ready_result = wait_for_root_ready(
177        &options.network,
178        &root_canister_id,
179        options.ready_timeout_seconds,
180    );
181    timings.wait_ready = ready_started_at.elapsed();
182    if let Err(err) = ready_result {
183        print_install_timing_summary(&timings, total_started_at.elapsed());
184        return Err(err);
185    }
186    let finalize_funding_started_at = Instant::now();
187    ensure_local_root_min_cycles(&icp_root, network, &root_canister_id, "post-ready")?;
188    timings.finalize_root_funding = finalize_funding_started_at.elapsed();
189
190    print_install_timing_summary(&timings, total_started_at.elapsed());
191    let state = build_install_state(
192        &options,
193        &workspace_root,
194        &icp_root,
195        &config_path,
196        &manifest_path,
197        &fleet_name,
198        &root_canister_id,
199    )?;
200    let state_path = write_install_state(&icp_root, &options.network, &state)?;
201    print_install_result_summary(&options.network, &state.fleet, &state_path);
202    Ok(())
203}
204
205fn validate_expected_fleet_name(
206    expected: Option<&str>,
207    actual: &str,
208    config_path: &Path,
209) -> Result<(), Box<dyn std::error::Error>> {
210    let Some(expected) = expected else {
211        return Ok(());
212    };
213    if expected == actual {
214        return Ok(());
215    }
216    Err(format!(
217        "install requested fleet {expected}, but {} declares [fleet].name = {actual:?}",
218        config_path.display()
219    )
220    .into())
221}
222
223fn ensure_root_canister_id(
224    icp_root: &Path,
225    network: &str,
226    root_canister: &str,
227    config_path: &Path,
228) -> Result<String, Box<dyn std::error::Error>> {
229    if Principal::from_text(root_canister).is_ok() {
230        return Ok(root_canister.to_string());
231    }
232
233    match resolve_root_canister_id(icp_root, network, root_canister) {
234        Ok(canister_id) => return Ok(canister_id),
235        Err(err) if !is_missing_canister_id_error(&err.to_string()) => return Err(err),
236        Err(_) => {}
237    }
238
239    let mut create = icp_canister_command_in_network(icp_root);
240    add_create_root_target(&mut create, root_canister);
241    add_local_root_create_cycles_arg(&mut create, config_path, network)?;
242    add_icp_environment_target(&mut create, network);
243    let output = run_command_stdout(&mut create)?;
244    if let Some(canister_id) = parse_created_canister_id(&output) {
245        return Ok(canister_id);
246    }
247
248    resolve_root_canister_id(icp_root, network, root_canister).map_err(|_| {
249        format!(
250            "created root canister target '{root_canister}', but ICP CLI still has no canister ID for environment '{network}' under ICP root {}\nExpected project-local state under {}/.icp/{network}. If another foreground replica is reachable, stop it and restart with `canic replica start --background` from this Canic project.",
251            icp_root.display(),
252            icp_root.display(),
253        )
254        .into()
255    })
256}
257
258fn parse_created_canister_id(output: &str) -> Option<String> {
259    if let Ok(value) = serde_json::from_str::<JsonValue>(output) {
260        return parse_canister_id_json(&value);
261    }
262
263    output
264        .lines()
265        .map(str::trim)
266        .find(|line| Principal::from_text(*line).is_ok())
267        .map(ToString::to_string)
268}
269
270fn parse_canister_id_json(value: &JsonValue) -> Option<String> {
271    match value {
272        JsonValue::String(text) if Principal::from_text(text).is_ok() => Some(text.clone()),
273        JsonValue::Array(values) => values.iter().find_map(parse_canister_id_json),
274        JsonValue::Object(object) => ["canister_id", "id", "principal"]
275            .iter()
276            .filter_map(|key| object.get(*key))
277            .find_map(parse_canister_id_json),
278        _ => None,
279    }
280}
281
282fn add_create_root_target(command: &mut Command, root_canister: &str) {
283    if env::var_os(CANIC_ICP_LOCAL_NETWORK_URL_ENV).is_some() {
284        command.args(["create", "--detached", "--json"]);
285    } else {
286        command.args(["create", root_canister, "--json"]);
287    }
288}
289
290fn is_missing_canister_id_error(message: &str) -> bool {
291    message.contains("failed to lookup canister ID")
292        || message.contains("could not find ID for canister")
293        || message.contains("Canister ID is missing")
294}
295
296fn reinstall_root_wasm(
297    icp_root: &Path,
298    network: &str,
299    root_canister: &str,
300    root_wasm: &Path,
301) -> Result<(), Box<dyn std::error::Error>> {
302    let mut install = icp_canister_command_in_network(icp_root);
303    install.args(["install", root_canister, "--mode=reinstall", "-y", "--wasm"]);
304    install.arg(root_wasm);
305    install.args(["--args", &root_init_args(root_wasm)?]);
306    add_icp_environment_target(&mut install, network);
307    run_command(&mut install)
308}
309
310fn root_init_args(root_wasm: &Path) -> Result<String, Box<dyn std::error::Error>> {
311    let wasm = std::fs::read(root_wasm)?;
312    Ok(format!(
313        "(variant {{ PrimeWithModuleHash = {} }})",
314        idl_blob(&wasm_hash(&wasm))
315    ))
316}
317
318fn idl_blob(bytes: &[u8]) -> String {
319    let mut encoded = String::from("blob \"");
320    for byte in bytes {
321        use std::fmt::Write as _;
322        let _ = write!(encoded, "\\{byte:02X}");
323    }
324    encoded.push('"');
325    encoded
326}
327
328// Build the persisted project-local install state from a completed install.
329fn build_install_state(
330    options: &InstallRootOptions,
331    workspace_root: &Path,
332    icp_root: &Path,
333    config_path: &Path,
334    release_set_manifest_path: &Path,
335    fleet_name: &str,
336    root_canister_id: &str,
337) -> Result<InstallState, Box<dyn std::error::Error>> {
338    Ok(InstallState {
339        schema_version: INSTALL_STATE_SCHEMA_VERSION,
340        fleet: fleet_name.to_string(),
341        installed_at_unix_secs: current_unix_secs()?,
342        network: options.network.clone(),
343        root_target: options.root_canister.clone(),
344        root_canister_id: root_canister_id.to_string(),
345        root_build_target: options.root_build_target.clone(),
346        workspace_root: workspace_root.display().to_string(),
347        icp_root: icp_root.display().to_string(),
348        config_path: config_path.display().to_string(),
349        release_set_manifest_path: release_set_manifest_path.display().to_string(),
350    })
351}
352
353// Resolve the installed root id, accepting principal targets without a icp lookup.
354fn resolve_root_canister_id(
355    icp_root: &Path,
356    network: &str,
357    root_canister: &str,
358) -> Result<String, Box<dyn std::error::Error>> {
359    if Principal::from_text(root_canister).is_ok() {
360        return Ok(root_canister.to_string());
361    }
362
363    let mut command = icp_canister_command_in_network(icp_root);
364    command.args(["status", root_canister, "--json"]);
365    add_icp_environment_target(&mut command, network);
366    let output = run_command_stdout(&mut command)?;
367    parse_created_canister_id(&output).ok_or_else(|| {
368        format!("could not parse root canister id from ICP status JSON output: {output}").into()
369    })
370}
371
372// Read the current host clock as a unix timestamp for install state.
373fn current_unix_secs() -> Result<u64, Box<dyn std::error::Error>> {
374    Ok(SystemTime::now().duration_since(UNIX_EPOCH)?.as_secs())
375}
376
377// Build each configured local install target through the host builder.
378fn run_canic_build_targets(
379    network: &str,
380    targets: &[String],
381    build_profile: Option<CanisterBuildProfile>,
382    config_path: &Path,
383    icp_root: &Path,
384) -> Result<(), Box<dyn std::error::Error>> {
385    let _env = BuildEnvGuard::apply(network, config_path, icp_root);
386    let profile = build_profile.unwrap_or_else(CanisterBuildProfile::current);
387    if let Some(context) = current_workspace_build_context_once(profile)? {
388        for line in context.lines() {
389            println!("{line}");
390        }
391        println!("config: {}", config_path.display());
392        println!(
393            "artifacts: {}",
394            planned_build_artifact_root(icp_root).display()
395        );
396        println!();
397    }
398
399    fs::create_dir_all(planned_build_artifact_root(icp_root))?;
400    println!("Building {} canisters", targets.len());
401    println!();
402    let headers = ["CANISTER", "PROGRESS", "WASM", "ELAPSED"];
403    let planned_rows = targets
404        .iter()
405        .map(|target| {
406            [
407                target.clone(),
408                progress_bar(targets.len(), targets.len(), 10),
409                "000.00 MiB (gz 000.00 MiB)".to_string(),
410                "0.00s".to_string(),
411            ]
412        })
413        .collect::<Vec<_>>();
414    let alignments = [
415        ColumnAlign::Left,
416        ColumnAlign::Left,
417        ColumnAlign::Right,
418        ColumnAlign::Right,
419    ];
420    let widths = table_widths(&headers, &planned_rows);
421    println!("{}", render_table_row(&headers, &widths, &alignments));
422    println!("{}", render_separator(&widths));
423
424    for (index, target) in targets.iter().enumerate() {
425        let started_at = Instant::now();
426        let output = build_current_workspace_canister_artifact(target, profile)
427            .map_err(|err| format!("artifact build failed for {target}: {err}"))?;
428        let elapsed = started_at.elapsed();
429        let artifact_size = wasm_artifact_size(&output.wasm_path, &output.wasm_gz_path)?;
430
431        let row = [
432            target.clone(),
433            progress_bar(index + 1, targets.len(), 10),
434            artifact_size,
435            format!("{:.2}s", elapsed.as_secs_f64()),
436        ];
437        println!("{}", render_table_row(&row, &widths, &alignments));
438    }
439
440    println!();
441    Ok(())
442}
443
444fn planned_build_artifact_root(icp_root: &Path) -> PathBuf {
445    icp_root.join(".icp/local/canisters")
446}
447
448fn wasm_artifact_size(
449    wasm_path: &Path,
450    wasm_gz_path: &Path,
451) -> Result<String, Box<dyn std::error::Error>> {
452    let wasm_bytes = Some(std::fs::metadata(wasm_path)?.len());
453    let gzip_bytes = std::fs::metadata(wasm_gz_path)
454        .ok()
455        .map(|metadata| metadata.len());
456    Ok(wasm_size_label(wasm_bytes, gzip_bytes))
457}
458
459struct BuildEnvGuard {
460    previous_network: Option<OsString>,
461    previous_config_path: Option<OsString>,
462    previous_icp_root: Option<OsString>,
463    previous_local_network_url: Option<OsString>,
464    previous_local_root_key: Option<OsString>,
465}
466
467impl BuildEnvGuard {
468    fn apply(network: &str, config_path: &Path, icp_root: &Path) -> Self {
469        let guard = Self {
470            previous_network: env::var_os("ICP_ENVIRONMENT"),
471            previous_config_path: env::var_os("CANIC_CONFIG_PATH"),
472            previous_icp_root: env::var_os("CANIC_ICP_ROOT"),
473            previous_local_network_url: env::var_os(CANIC_ICP_LOCAL_NETWORK_URL_ENV),
474            previous_local_root_key: env::var_os(CANIC_ICP_LOCAL_ROOT_KEY_ENV),
475        };
476        set_env("ICP_ENVIRONMENT", network);
477        set_env("CANIC_CONFIG_PATH", config_path);
478        set_env("CANIC_ICP_ROOT", icp_root);
479        if let Some(target) = local_replica_icp_target(network, icp_root) {
480            set_env(CANIC_ICP_LOCAL_NETWORK_URL_ENV, target.url);
481            set_env(CANIC_ICP_LOCAL_ROOT_KEY_ENV, target.root_key);
482        } else {
483            remove_env(CANIC_ICP_LOCAL_NETWORK_URL_ENV);
484            remove_env(CANIC_ICP_LOCAL_ROOT_KEY_ENV);
485        }
486        guard
487    }
488}
489
490impl Drop for BuildEnvGuard {
491    fn drop(&mut self) {
492        restore_env("ICP_ENVIRONMENT", self.previous_network.take());
493        restore_env("CANIC_CONFIG_PATH", self.previous_config_path.take());
494        restore_env("CANIC_ICP_ROOT", self.previous_icp_root.take());
495        restore_env(
496            CANIC_ICP_LOCAL_NETWORK_URL_ENV,
497            self.previous_local_network_url.take(),
498        );
499        restore_env(
500            CANIC_ICP_LOCAL_ROOT_KEY_ENV,
501            self.previous_local_root_key.take(),
502        );
503    }
504}
505
506struct LocalReplicaIcpTarget {
507    url: String,
508    root_key: String,
509}
510
511fn local_replica_icp_target(network: &str, icp_root: &Path) -> Option<LocalReplicaIcpTarget> {
512    if !replica_query::should_use_local_replica_query(Some(network)) {
513        return None;
514    }
515    if icp_ping(icp_root, network).unwrap_or(false) {
516        return None;
517    }
518    let root_key = replica_query::local_replica_root_key_from_root(Some(network), icp_root)
519        .ok()
520        .flatten()?;
521    Some(LocalReplicaIcpTarget {
522        url: replica_query::local_replica_endpoint_from_root(Some(network), icp_root),
523        root_key,
524    })
525}
526
527fn set_env<K, V>(key: K, value: V)
528where
529    K: AsRef<std::ffi::OsStr>,
530    V: AsRef<std::ffi::OsStr>,
531{
532    // Install builds are single-threaded host orchestration. The environment is
533    // scoped by BuildEnvGuard so Cargo build scripts see the selected fleet.
534    unsafe {
535        env::set_var(key, value);
536    }
537}
538
539fn remove_env<K>(key: K)
540where
541    K: AsRef<std::ffi::OsStr>,
542{
543    // Install builds are single-threaded host orchestration. The environment is
544    // scoped by BuildEnvGuard so Cargo build scripts see the selected fleet.
545    unsafe {
546        env::remove_var(key);
547    }
548}
549
550fn restore_env(key: &str, value: Option<OsString>) {
551    // See set_env: this restores the single-threaded install build context.
552    if let Some(value) = value {
553        set_env(key, value);
554    } else {
555        remove_env(key);
556    }
557}
558
559fn add_local_root_create_cycles_arg(
560    command: &mut Command,
561    config_path: &Path,
562    network: &str,
563) -> Result<(), Box<dyn std::error::Error>> {
564    if network != "local" {
565        return Ok(());
566    }
567
568    let cycles = configured_local_root_create_cycles(config_path)?;
569    command.args(["--cycles", &cycles.to_string()]);
570    Ok(())
571}
572
573fn ensure_local_root_min_cycles(
574    icp_root: &Path,
575    network: &str,
576    root_canister: &str,
577    phase: &str,
578) -> Result<(), Box<dyn std::error::Error>> {
579    if network != "local" {
580        return Ok(());
581    }
582
583    let current = query_root_cycle_balance(network, root_canister)?;
584    if current >= LOCAL_ROOT_MIN_READY_CYCLES {
585        return Ok(());
586    }
587
588    let amount = LOCAL_ROOT_MIN_READY_CYCLES.saturating_sub(current);
589    let mut command = icp_canister_command_in_network(icp_root);
590    command
591        .args(["top-up", "--amount"])
592        .arg(amount.to_string())
593        .arg(root_canister);
594    add_icp_environment_target(&mut command, network);
595    run_command(&mut command)?;
596    println!(
597        "Local root cycles ({phase}): topped up {} ({} -> {} target)",
598        crate::format::cycles_tc(amount),
599        crate::format::cycles_tc(current),
600        crate::format::cycles_tc(LOCAL_ROOT_MIN_READY_CYCLES)
601    );
602    Ok(())
603}
604
605fn query_root_cycle_balance(
606    network: &str,
607    root_canister: &str,
608) -> Result<u128, Box<dyn std::error::Error>> {
609    let output = icp_call_on_network(
610        network,
611        root_canister,
612        protocol::CANIC_CYCLE_BALANCE,
613        None,
614        Some("json"),
615    )?;
616    parse_cycle_balance_response(&output).ok_or_else(|| {
617        format!(
618            "could not parse {root_canister} {} response: {output}",
619            protocol::CANIC_CYCLE_BALANCE
620        )
621        .into()
622    })
623}
624
625fn progress_bar(current: usize, total: usize, width: usize) -> String {
626    if total == 0 || width == 0 {
627        return "[] 0/0".to_string();
628    }
629
630    let filled = current.saturating_mul(width).div_ceil(total);
631    let filled = filled.min(width);
632    format!(
633        "[{}{}] {current}/{total}",
634        "#".repeat(filled),
635        " ".repeat(width - filled)
636    )
637}
638
639// Ensure the requested replica is reachable before the local install flow begins.
640fn ensure_icp_environment_ready(
641    icp_root: &Path,
642    network: &str,
643) -> Result<(), Box<dyn std::error::Error>> {
644    if icp_ping(icp_root, network)? {
645        return Ok(());
646    }
647    if replica_query::should_use_local_replica_query(Some(network))
648        && replica_query::local_replica_status_reachable_from_root(Some(network), icp_root)
649    {
650        println!(
651            "Replica reachable via HTTP status endpoint even though ICP CLI reports network '{network}' stopped; continuing from ICP root {}.",
652            icp_root.display()
653        );
654        return Ok(());
655    }
656
657    Err(format!(
658        "icp environment is not running for network '{network}'\nStart the target replica in another terminal with `canic replica start` and rerun."
659    )
660    .into())
661}
662
663// Check whether `icp network ping <network>` currently succeeds.
664fn icp_ping(icp_root: &Path, network: &str) -> Result<bool, Box<dyn std::error::Error>> {
665    Ok(icp::default_command_in(icp_root)
666        .args(["network", "ping", network])
667        .output()?
668        .status
669        .success())
670}
671
672fn print_install_timing_summary(timings: &InstallTimingSummary, total: Duration) {
673    println!("Install timing summary:");
674    println!("{}", render_install_timing_summary(timings, total));
675}
676
677fn render_install_timing_summary(timings: &InstallTimingSummary, total: Duration) -> String {
678    let rows = [
679        timing_row("create_canisters", timings.create_canisters),
680        timing_row("build_all", timings.build_all),
681        timing_row("emit_manifest", timings.emit_manifest),
682        timing_row("install_root", timings.install_root),
683        timing_row("fund_root", timings.fund_root),
684        timing_row("stage_release_set", timings.stage_release_set),
685        timing_row("resume_bootstrap", timings.resume_bootstrap),
686        timing_row("wait_ready", timings.wait_ready),
687        timing_row("finalize_root_funding", timings.finalize_root_funding),
688        timing_row("total", total),
689    ];
690    render_table(
691        &["PHASE", "ELAPSED"],
692        &rows,
693        &[ColumnAlign::Left, ColumnAlign::Right],
694    )
695}
696
697fn timing_row(label: &str, duration: Duration) -> [String; 2] {
698    [label.to_string(), format!("{:.2}s", duration.as_secs_f64())]
699}
700
701// Print the final install result as a compact whitespace table.
702fn print_install_result_summary(network: &str, fleet: &str, state_path: &Path) {
703    println!("Install result:");
704    println!("{:<14} success", "status");
705    println!("{:<14} {}", "fleet", fleet);
706    println!("{:<14} {}", "install_state", state_path.display());
707    println!(
708        "{:<14} canic list {} --network {}",
709        "smoke_check", fleet, network
710    );
711}
712
713// Run one command and require a zero exit status.
714fn run_command(command: &mut Command) -> Result<(), Box<dyn std::error::Error>> {
715    icp::run_status(command).map_err(Into::into)
716}
717
718// Run one command, require success, and return stdout.
719fn run_command_stdout(command: &mut Command) -> Result<String, Box<dyn std::error::Error>> {
720    icp::run_output(command).map_err(Into::into)
721}
722
723// Build an icp command with the selected install environment exported
724// for Rust build scripts that inspect ICP_ENVIRONMENT at compile time.
725fn icp_command_on_network(network: &str) -> Command {
726    let mut command = icp::default_command();
727    command.env("ICP_ENVIRONMENT", network);
728    command
729}
730
731// Build an icp command in one project directory with ICP_ENVIRONMENT applied.
732fn icp_command_in_network(icp_root: &Path, network: &str) -> Command {
733    let mut command = icp::default_command_in(icp_root);
734    command.env("ICP_ENVIRONMENT", network);
735    command
736}
737
738// Build an icp canister command in one project directory.
739fn icp_canister_command_in_network(icp_root: &Path) -> Command {
740    let mut command = icp::default_command_in(icp_root);
741    command.arg("canister");
742    command
743}
744
745fn add_icp_environment_target(command: &mut Command, network: &str) {
746    icp::add_target_args(command, Some(network), None);
747}