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