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