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