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