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    CurrentCliDeploymentExecutor, DeploymentCheckV1, DeploymentCommandResultV1,
7    DeploymentExecutionContextV1, DeploymentExecutionStatusV1, DeploymentExecutor,
8    DeploymentExecutorCapabilityV1, DeploymentReceiptV1, LocalDeploymentCheckRequest,
9    SafetyFindingV1, artifact_gate_phase_receipt, artifact_gate_role_phase_receipts,
10    check_local_deployment, deployment_receipt_from_check_with_status,
11    missing_executor_capabilities, phase_receipt,
12};
13use crate::format::wasm_size_label;
14use crate::icp::{self, CANIC_ICP_LOCAL_NETWORK_URL_ENV, CANIC_ICP_LOCAL_ROOT_KEY_ENV};
15use crate::release_set::{
16    LOCAL_ROOT_MIN_READY_CYCLES, configured_fleet_name, configured_install_targets,
17    configured_local_root_create_cycles, emit_root_release_set_manifest_with_config,
18    icp_query_on_network, icp_root, load_root_release_set_manifest, resolve_artifact_root,
19    resume_root_bootstrap, stage_root_release_set, workspace_root,
20};
21use crate::replica_query;
22use crate::response_parse::parse_cycle_balance_response;
23use crate::table::{ColumnAlign, render_separator, render_table, render_table_row, table_widths};
24use canic_core::{
25    cdk::{types::Principal, utils::hash::wasm_hash},
26    protocol,
27};
28use config_selection::resolve_install_config_path;
29use serde_json::Value as JsonValue;
30use std::{
31    env,
32    ffi::OsString,
33    fs,
34    path::{Path, PathBuf},
35    process::Command,
36    time::{Duration, Instant, SystemTime, UNIX_EPOCH},
37};
38
39mod config_selection;
40mod readiness;
41mod state;
42
43pub use config_selection::{
44    current_canic_project_root, discover_canic_config_choices, discover_canic_project_root_from,
45    discover_project_canic_config_choices, project_fleet_roots,
46};
47use readiness::wait_for_root_ready;
48use state::{
49    INSTALL_STATE_SCHEMA_VERSION, validate_fleet_name, validate_network_name, write_install_state,
50};
51pub use state::{
52    InstallState, read_named_fleet_install_state, read_named_fleet_install_state_from_root,
53};
54
55#[cfg(test)]
56mod tests;
57
58#[cfg(test)]
59use config_selection::config_selection_error;
60#[cfg(test)]
61use readiness::{parse_bootstrap_status_value, parse_root_ready_value};
62#[cfg(test)]
63use state::{fleet_install_state_path, read_fleet_install_state};
64
65///
66/// InstallRootOptions
67///
68
69#[derive(Clone, Debug)]
70pub struct InstallRootOptions {
71    pub root_canister: String,
72    pub root_build_target: String,
73    pub network: String,
74    pub icp_root: Option<PathBuf>,
75    pub build_profile: Option<CanisterBuildProfile>,
76    pub ready_timeout_seconds: u64,
77    pub config_path: Option<String>,
78    pub expected_fleet: Option<String>,
79    pub interactive_config_selection: bool,
80}
81
82///
83/// InstallTimingSummary
84///
85
86#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)]
87struct InstallTimingSummary {
88    create_canisters: Duration,
89    build_all: Duration,
90    emit_manifest: Duration,
91    install_root: Duration,
92    fund_root: Duration,
93    stage_release_set: Duration,
94    resume_bootstrap: Duration,
95    wait_ready: Duration,
96    finalize_root_funding: Duration,
97}
98
99const CURRENT_INSTALL_REQUIRED_CAPABILITIES: &[DeploymentExecutorCapabilityV1] = &[
100    DeploymentExecutorCapabilityV1::CreateCanister,
101    DeploymentExecutorCapabilityV1::InstallCode,
102    DeploymentExecutorCapabilityV1::Call,
103    DeploymentExecutorCapabilityV1::Query,
104    DeploymentExecutorCapabilityV1::StageArtifact,
105];
106
107/// Discover installable Canic config choices under the current workspace.
108pub fn discover_current_canic_config_choices() -> Result<Vec<PathBuf>, Box<dyn std::error::Error>> {
109    let project_root = current_canic_project_root()?;
110    let choices = config_selection::discover_workspace_canic_config_choices(&project_root)?;
111    if !choices.is_empty() {
112        return Ok(choices);
113    }
114
115    if let Ok(icp_root) = icp_root()
116        && icp_root != project_root
117    {
118        return config_selection::discover_workspace_canic_config_choices(&icp_root);
119    }
120
121    Ok(choices)
122}
123
124// Execute the local thin-root install flow against an already running replica.
125pub fn install_root(options: InstallRootOptions) -> Result<(), Box<dyn std::error::Error>> {
126    let workspace_root = workspace_root()?;
127    let icp_root = match &options.icp_root {
128        Some(path) => path.canonicalize()?,
129        None => icp_root()?,
130    };
131    let config_path = resolve_install_config_path(
132        &icp_root,
133        options.config_path.as_deref(),
134        options.interactive_config_selection,
135    )?;
136    let _install_env = BuildEnvGuard::apply(&options.network, &config_path, &icp_root);
137    let fleet_name = configured_fleet_name(&config_path)?;
138    validate_expected_fleet_name(options.expected_fleet.as_deref(), &fleet_name, &config_path)?;
139    validate_fleet_name(&fleet_name)?;
140    let total_started_at = Instant::now();
141    let mut timings = InstallTimingSummary::default();
142    let network = options.network.as_str();
143    let execution_context = current_install_execution_context(&workspace_root, &icp_root, network);
144
145    println!("Installing fleet {fleet_name}");
146    println!();
147    let prepared = prepare_install_deployment_truth(
148        &options,
149        &workspace_root,
150        &icp_root,
151        &config_path,
152        &fleet_name,
153        &execution_context,
154    )?;
155    timings.create_canisters = prepared.timings.create_canisters;
156    timings.build_all = prepared.timings.build_all;
157
158    let (manifest_path, emit_manifest_duration) = emit_manifest_with_deployment_truth_receipt(
159        &workspace_root,
160        &icp_root,
161        &options,
162        &config_path,
163        &fleet_name,
164        &prepared.deployment_truth_check,
165        &execution_context,
166    )?;
167    timings.emit_manifest = emit_manifest_duration;
168    let activation_timings = run_root_activation_phases(
169        InstallReceiptScope {
170            icp_root: &icp_root,
171            network,
172            fleet_name: &fleet_name,
173            check: &prepared.deployment_truth_check,
174            execution_context: Some(&execution_context),
175        },
176        &options,
177        &prepared.root_canister_id,
178        &manifest_path,
179        total_started_at,
180    )?;
181    timings.install_root = activation_timings.install_root;
182    timings.fund_root = activation_timings.fund_root;
183    timings.stage_release_set = activation_timings.stage_release_set;
184    timings.resume_bootstrap = activation_timings.resume_bootstrap;
185    timings.wait_ready = activation_timings.wait_ready;
186    timings.finalize_root_funding = activation_timings.finalize_root_funding;
187
188    print_install_timing_summary(&timings, total_started_at.elapsed());
189    let state = build_install_state(
190        &options,
191        &workspace_root,
192        &icp_root,
193        &config_path,
194        &manifest_path,
195        &fleet_name,
196        &prepared.root_canister_id,
197    )?;
198    let state_path = write_install_state_with_deployment_truth_receipt(
199        InstallReceiptScope {
200            icp_root: &icp_root,
201            network,
202            fleet_name: &fleet_name,
203            check: &prepared.deployment_truth_check,
204            execution_context: Some(&execution_context),
205        },
206        &options.network,
207        &state,
208    )?;
209    print_install_result_summary(&options.network, &state.fleet, &state_path);
210    Ok(())
211}
212
213struct PreparedInstallTruth {
214    root_canister_id: String,
215    deployment_truth_check: DeploymentCheckV1,
216    timings: InstallTimingSummary,
217}
218
219fn prepare_install_deployment_truth(
220    options: &InstallRootOptions,
221    workspace_root: &Path,
222    icp_root: &Path,
223    config_path: &Path,
224    fleet_name: &str,
225    execution_context: &DeploymentExecutionContextV1,
226) -> Result<PreparedInstallTruth, Box<dyn std::error::Error>> {
227    let mut timings = InstallTimingSummary::default();
228    ensure_current_install_executor_capabilities(execution_context)?;
229    ensure_icp_environment_ready(icp_root, &options.network)?;
230    let (root_canister_id, create_phase, create_duration) =
231        resolve_root_canister_with_phase(options, icp_root, config_path)?;
232    timings.create_canisters = create_duration;
233
234    let (build_phase, build_duration) =
235        build_install_targets_with_phase(options, icp_root, config_path)?;
236    timings.build_all = build_duration;
237
238    let deployment_truth_check = run_install_deployment_truth_safety_gate(
239        options,
240        workspace_root,
241        icp_root,
242        config_path,
243        fleet_name,
244        execution_context,
245    )?;
246    let receipt_scope = InstallReceiptScope {
247        icp_root,
248        network: &options.network,
249        fleet_name,
250        check: &deployment_truth_check,
251        execution_context: Some(execution_context),
252    };
253    write_completed_install_phase_receipt(receipt_scope, create_phase)?;
254    write_completed_install_phase_receipt(receipt_scope, build_phase)?;
255
256    Ok(PreparedInstallTruth {
257        root_canister_id,
258        deployment_truth_check,
259        timings,
260    })
261}
262
263fn resolve_root_canister_with_phase(
264    options: &InstallRootOptions,
265    icp_root: &Path,
266    config_path: &Path,
267) -> Result<(String, CompletedInstallPhase, Duration), Box<dyn std::error::Error>> {
268    let started_at = current_unix_timestamp_label()?;
269    let started = Instant::now();
270    let root_canister_id = ensure_root_canister_id(
271        icp_root,
272        &options.network,
273        &options.root_canister,
274        config_path,
275    )?;
276    let duration = started.elapsed();
277    let phase = CompletedInstallPhase {
278        phase: "resolve_root_canister",
279        attempted_action: "resolve or create root canister id",
280        started_at,
281        finished_at: Some(current_unix_timestamp_label()?),
282        evidence: vec![
283            format!("root_target:{}", options.root_canister),
284            format!("root_canister:{root_canister_id}"),
285        ],
286        role_names: Vec::new(),
287    };
288    Ok((root_canister_id, phase, duration))
289}
290
291fn build_install_targets_with_phase(
292    options: &InstallRootOptions,
293    icp_root: &Path,
294    config_path: &Path,
295) -> Result<(CompletedInstallPhase, Duration), Box<dyn std::error::Error>> {
296    let build_targets = configured_install_targets(config_path, &options.root_build_target)?;
297    let started_at = current_unix_timestamp_label()?;
298    let started = Instant::now();
299    run_canic_build_targets(
300        &options.network,
301        &build_targets,
302        options.build_profile,
303        config_path,
304        icp_root,
305    )?;
306    let duration = started.elapsed();
307    let phase = CompletedInstallPhase {
308        phase: "build_artifacts",
309        attempted_action: "build configured install targets",
310        started_at,
311        finished_at: Some(current_unix_timestamp_label()?),
312        evidence: build_targets
313            .iter()
314            .map(|target| format!("build_target:{target}"))
315            .collect(),
316        role_names: build_targets,
317    };
318    Ok((phase, duration))
319}
320
321fn emit_manifest_with_deployment_truth_receipt(
322    workspace_root: &Path,
323    icp_root: &Path,
324    options: &InstallRootOptions,
325    config_path: &Path,
326    fleet_name: &str,
327    deployment_truth_check: &DeploymentCheckV1,
328    execution_context: &DeploymentExecutionContextV1,
329) -> Result<(PathBuf, Duration), Box<dyn std::error::Error>> {
330    let emit_manifest_started_at_label = current_unix_timestamp_label()?;
331    let emit_manifest_started_at = Instant::now();
332    let manifest_path = emit_root_release_set_manifest_with_config(
333        workspace_root,
334        icp_root,
335        &options.network,
336        config_path,
337    )?;
338    let emit_manifest_duration = emit_manifest_started_at.elapsed();
339    let emit_manifest_receipt = receipt_with_execution_context(
340        install_deployment_truth_phase_receipt(
341            deployment_truth_check,
342            "emit_manifest",
343            emit_manifest_started_at_label,
344            Some(current_unix_timestamp_label()?),
345            "emit root release-set manifest",
346            crate::deployment_truth::ObservationStatusV1::Observed,
347            vec![format!("manifest_path:{}", manifest_path.display())],
348        ),
349        execution_context,
350    );
351    let emit_manifest_receipt_path = write_install_deployment_truth_receipt(
352        icp_root,
353        &options.network,
354        fleet_name,
355        &emit_manifest_receipt,
356    )?;
357    println!(
358        "Deployment truth receipt JSON: {}",
359        emit_manifest_receipt_path.display()
360    );
361    Ok((manifest_path, emit_manifest_duration))
362}
363
364fn run_root_activation_phases(
365    receipt_scope: InstallReceiptScope<'_>,
366    options: &InstallRootOptions,
367    root_canister_id: &str,
368    manifest_path: &Path,
369    total_started_at: Instant,
370) -> Result<InstallTimingSummary, Box<dyn std::error::Error>> {
371    let mut timings = InstallTimingSummary::default();
372    let root_wasm = resolve_artifact_root(receipt_scope.icp_root, receipt_scope.network)?
373        .join(&options.root_build_target)
374        .join(format!("{}.wasm", options.root_build_target));
375    timings.install_root = receipt_scope.run_phase(
376        "install_root",
377        "install root wasm",
378        vec![
379            format!("root_canister:{root_canister_id}"),
380            format!("root_wasm:{}", root_wasm.display()),
381        ],
382        || {
383            reinstall_root_wasm(
384                receipt_scope.icp_root,
385                receipt_scope.network,
386                root_canister_id,
387                &root_wasm,
388            )
389        },
390    )?;
391    timings.fund_root = receipt_scope.run_phase(
392        "fund_root_pre_bootstrap",
393        "ensure local root minimum cycles before bootstrap",
394        vec![
395            format!("root_canister:{root_canister_id}"),
396            format!("minimum_cycles:{LOCAL_ROOT_MIN_READY_CYCLES}"),
397        ],
398        || {
399            ensure_local_root_min_cycles(
400                receipt_scope.icp_root,
401                receipt_scope.network,
402                root_canister_id,
403                "pre-bootstrap",
404            )
405        },
406    )?;
407    let manifest = load_root_release_set_manifest(manifest_path)?;
408    timings.stage_release_set = receipt_scope.run_phase(
409        "stage_release_set",
410        "stage root release set",
411        vec![
412            format!("root_canister:{root_canister_id}"),
413            format!("manifest_path:{}", manifest_path.display()),
414        ],
415        || {
416            stage_root_release_set(
417                receipt_scope.icp_root,
418                receipt_scope.network,
419                root_canister_id,
420                &manifest,
421            )
422        },
423    )?;
424    timings.resume_bootstrap = receipt_scope.run_phase(
425        "resume_bootstrap",
426        "resume root bootstrap",
427        vec![format!("root_canister:{root_canister_id}")],
428        || resume_root_bootstrap(receipt_scope.network, root_canister_id),
429    )?;
430    let wait_ready_result = receipt_scope.run_phase(
431        "wait_ready",
432        "wait for root bootstrap readiness",
433        vec![
434            format!("root_canister:{root_canister_id}"),
435            format!("timeout_seconds:{}", options.ready_timeout_seconds),
436        ],
437        || {
438            wait_for_root_ready(
439                receipt_scope.network,
440                root_canister_id,
441                options.ready_timeout_seconds,
442            )
443        },
444    );
445    match wait_ready_result {
446        Ok(duration) => timings.wait_ready = duration,
447        Err(err) => {
448            print_install_timing_summary(&timings, total_started_at.elapsed());
449            return Err(err);
450        }
451    }
452    timings.finalize_root_funding = receipt_scope.run_phase(
453        "fund_root_post_ready",
454        "ensure local root minimum cycles after ready",
455        vec![
456            format!("root_canister:{root_canister_id}"),
457            format!("minimum_cycles:{LOCAL_ROOT_MIN_READY_CYCLES}"),
458        ],
459        || {
460            ensure_local_root_min_cycles(
461                receipt_scope.icp_root,
462                receipt_scope.network,
463                root_canister_id,
464                "post-ready",
465            )
466        },
467    )?;
468    Ok(timings)
469}
470
471#[derive(Clone, Copy)]
472struct InstallReceiptScope<'a> {
473    icp_root: &'a Path,
474    network: &'a str,
475    fleet_name: &'a str,
476    check: &'a DeploymentCheckV1,
477    execution_context: Option<&'a DeploymentExecutionContextV1>,
478}
479
480struct CompletedInstallPhase {
481    phase: &'static str,
482    attempted_action: &'static str,
483    started_at: String,
484    finished_at: Option<String>,
485    evidence: Vec<String>,
486    role_names: Vec<String>,
487}
488
489fn write_completed_install_phase_receipt(
490    receipt_scope: InstallReceiptScope<'_>,
491    completed: CompletedInstallPhase,
492) -> Result<PathBuf, Box<dyn std::error::Error>> {
493    let role_phase_receipts = completed
494        .role_names
495        .iter()
496        .filter_map(|role| {
497            completed_phase_role_receipt(
498                receipt_scope.check,
499                completed.phase,
500                role,
501                crate::deployment_truth::RolePhaseResultV1::Applied,
502                None,
503            )
504        })
505        .collect();
506    let receipt =
507        receipt_scope.with_execution_context(install_deployment_truth_phase_receipt_with_result(
508            receipt_scope.check,
509            PhaseReceiptInput {
510                phase: completed.phase,
511                started_at: completed.started_at,
512                finished_at: completed.finished_at,
513                attempted_action: completed.attempted_action,
514                status: crate::deployment_truth::ObservationStatusV1::Observed,
515                evidence: completed.evidence,
516                role_phase_receipts,
517                operation_status: DeploymentExecutionStatusV1::Complete,
518                command_result: DeploymentCommandResultV1::Succeeded,
519            },
520        ));
521    receipt_scope.write_receipt(&receipt)
522}
523
524fn completed_phase_role_receipt(
525    check: &DeploymentCheckV1,
526    phase: &str,
527    role: &str,
528    result: crate::deployment_truth::RolePhaseResultV1,
529    error: Option<String>,
530) -> Option<crate::deployment_truth::RolePhaseReceiptV1> {
531    let planned = check
532        .plan
533        .role_artifacts
534        .iter()
535        .find(|artifact| artifact.role == role)?;
536    let observed = check
537        .inventory
538        .observed_artifacts
539        .iter()
540        .find(|artifact| artifact.role == role);
541    let artifact_digest = observed
542        .and_then(|artifact| artifact.file_sha256.clone())
543        .or_else(|| observed.and_then(|artifact| artifact.payload_sha256.clone()))
544        .or_else(|| planned.observed_wasm_gz_file_sha256.clone())
545        .or_else(|| planned.wasm_gz_sha256.clone());
546
547    Some(crate::deployment_truth::RolePhaseReceiptV1 {
548        role: role.to_string(),
549        phase: phase.to_string(),
550        result,
551        previous_module_hash: None,
552        target_module_hash: planned.installed_module_hash.clone(),
553        observed_module_hash_after: None,
554        artifact_digest,
555        canonical_embedded_config_sha256: planned.canonical_embedded_config_sha256.clone(),
556        error,
557    })
558}
559
560fn write_install_state_with_deployment_truth_receipt(
561    receipt_scope: InstallReceiptScope<'_>,
562    network: &str,
563    state: &InstallState,
564) -> Result<PathBuf, Box<dyn std::error::Error>> {
565    let started_at = current_unix_timestamp_label()?;
566    let state_path = write_install_state(receipt_scope.icp_root, network, state)?;
567    let completed = CompletedInstallPhase {
568        phase: "write_install_state",
569        attempted_action: "write local install state",
570        started_at,
571        finished_at: Some(current_unix_timestamp_label()?),
572        evidence: vec![
573            format!("install_state:{}", state_path.display()),
574            format!("fleet:{}", state.fleet),
575            format!("root_canister:{}", state.root_canister_id),
576        ],
577        role_names: Vec::new(),
578    };
579    write_completed_install_phase_receipt(receipt_scope, completed)?;
580    Ok(state_path)
581}
582
583impl InstallReceiptScope<'_> {
584    fn run_phase(
585        self,
586        phase: &str,
587        attempted_action: &str,
588        evidence: Vec<String>,
589        run: impl FnOnce() -> Result<(), Box<dyn std::error::Error>>,
590    ) -> Result<Duration, Box<dyn std::error::Error>> {
591        let started_at = current_unix_timestamp_label()?;
592        let started = Instant::now();
593        match run() {
594            Ok(()) => {
595                let duration = started.elapsed();
596                let receipt = self.with_execution_context(install_deployment_truth_phase_receipt(
597                    self.check,
598                    phase,
599                    started_at,
600                    Some(current_unix_timestamp_label()?),
601                    attempted_action,
602                    crate::deployment_truth::ObservationStatusV1::Observed,
603                    evidence,
604                ));
605                self.write_receipt(&receipt)?;
606                Ok(duration)
607            }
608            Err(err) => {
609                self.try_write_failed_phase_receipt(
610                    phase,
611                    started_at,
612                    attempted_action,
613                    evidence,
614                    err.as_ref(),
615                );
616                Err(err)
617            }
618        }
619    }
620
621    fn with_execution_context(self, receipt: DeploymentReceiptV1) -> DeploymentReceiptV1 {
622        match self.execution_context {
623            Some(context) => receipt_with_execution_context(receipt, context),
624            None => receipt,
625        }
626    }
627
628    fn write_receipt(
629        self,
630        receipt: &DeploymentReceiptV1,
631    ) -> Result<PathBuf, Box<dyn std::error::Error>> {
632        let path = write_install_deployment_truth_receipt(
633            self.icp_root,
634            self.network,
635            self.fleet_name,
636            receipt,
637        )?;
638        println!("Deployment truth receipt JSON: {}", path.display());
639        Ok(path)
640    }
641
642    fn try_write_failed_phase_receipt(
643        self,
644        phase: &str,
645        started_at: String,
646        attempted_action: &str,
647        evidence: Vec<String>,
648        err: &dyn std::error::Error,
649    ) {
650        let receipt = install_deployment_truth_phase_receipt_with_result(
651            self.check,
652            PhaseReceiptInput {
653                phase,
654                started_at,
655                finished_at: Some(
656                    current_unix_timestamp_label().unwrap_or_else(|_| "unknown".to_string()),
657                ),
658                attempted_action,
659                status: crate::deployment_truth::ObservationStatusV1::Inconclusive,
660                evidence,
661                role_phase_receipts: Vec::new(),
662                operation_status: DeploymentExecutionStatusV1::FailedAfterMutation,
663                command_result: DeploymentCommandResultV1::Failed {
664                    code: format!("{phase}_failed"),
665                    message: err.to_string(),
666                },
667            },
668        );
669        let receipt = self.with_execution_context(receipt);
670        if let Err(write_err) = self.write_receipt(&receipt) {
671            eprintln!("Deployment truth receipt JSON write failed: {write_err}");
672        }
673    }
674}
675
676/// Build the same read-only deployment truth check that can be used as a
677/// preflight for the current install inputs without mutating deployment state.
678pub fn check_install_deployment_truth(
679    options: &InstallRootOptions,
680    observed_at: impl Into<String>,
681) -> Result<DeploymentCheckV1, Box<dyn std::error::Error>> {
682    let workspace_root = workspace_root()?;
683    let icp_root = match &options.icp_root {
684        Some(path) => path.canonicalize()?,
685        None => icp_root()?,
686    };
687    let config_path = resolve_install_config_path(
688        &icp_root,
689        options.config_path.as_deref(),
690        options.interactive_config_selection,
691    )?;
692    let fleet_name = configured_fleet_name(&config_path)?;
693    validate_expected_fleet_name(options.expected_fleet.as_deref(), &fleet_name, &config_path)?;
694    validate_fleet_name(&fleet_name)?;
695    current_install_deployment_truth_check_at(
696        options,
697        &workspace_root,
698        &icp_root,
699        &config_path,
700        &fleet_name,
701        observed_at.into(),
702    )
703}
704
705fn current_install_deployment_truth_check_at(
706    options: &InstallRootOptions,
707    workspace_root: &Path,
708    icp_root: &Path,
709    config_path: &Path,
710    fleet_name: &str,
711    observed_at: String,
712) -> Result<DeploymentCheckV1, Box<dyn std::error::Error>> {
713    let build_profile = options
714        .build_profile
715        .unwrap_or_else(CanisterBuildProfile::current)
716        .target_dir_name()
717        .to_string();
718
719    check_local_deployment(&LocalDeploymentCheckRequest {
720        deployment_name: fleet_name.to_string(),
721        network: options.network.clone(),
722        workspace_root: workspace_root.to_path_buf(),
723        icp_root: icp_root.to_path_buf(),
724        config_path: Some(config_path.to_path_buf()),
725        observed_at,
726        runtime_variant: options.network.clone(),
727        build_profile,
728    })
729    .map_err(Into::into)
730}
731
732fn current_install_execution_context(
733    workspace_root: &Path,
734    icp_root: &Path,
735    network: &str,
736) -> DeploymentExecutionContextV1 {
737    CurrentCliDeploymentExecutor::new(
738        Some(workspace_root.display().to_string()),
739        Some(icp_root.display().to_string()),
740        current_install_artifact_roots(icp_root, network),
741    )
742    .execution_context()
743}
744
745fn ensure_current_install_executor_capabilities(
746    execution_context: &DeploymentExecutionContextV1,
747) -> Result<(), Box<dyn std::error::Error>> {
748    let missing = current_install_executor_missing_capabilities(execution_context);
749    if missing.is_empty() {
750        return Ok(());
751    }
752
753    Err(format!(
754        "current install executor backend {:?} is missing required capabilities: {missing:?}",
755        execution_context.backend
756    )
757    .into())
758}
759
760fn current_install_executor_missing_capabilities(
761    execution_context: &DeploymentExecutionContextV1,
762) -> Vec<DeploymentExecutorCapabilityV1> {
763    missing_executor_capabilities(
764        &execution_context.backend_capabilities,
765        CURRENT_INSTALL_REQUIRED_CAPABILITIES,
766    )
767}
768
769fn current_install_artifact_roots(icp_root: &Path, network: &str) -> Vec<String> {
770    let planned_root = planned_build_artifact_root(icp_root);
771    let mut roots = vec![planned_root.display().to_string()];
772    if let Ok(resolved_root) = resolve_artifact_root(icp_root, network)
773        && resolved_root != planned_root
774    {
775        roots.push(resolved_root.display().to_string());
776    }
777    roots
778}
779
780fn run_install_deployment_truth_safety_gate(
781    options: &InstallRootOptions,
782    workspace_root: &Path,
783    icp_root: &Path,
784    config_path: &Path,
785    fleet_name: &str,
786    execution_context: &DeploymentExecutionContextV1,
787) -> Result<DeploymentCheckV1, Box<dyn std::error::Error>> {
788    let truth_gate_started_at = current_unix_timestamp_label()?;
789    let deployment_truth_check = current_install_deployment_truth_check_at(
790        options,
791        workspace_root,
792        icp_root,
793        config_path,
794        fleet_name,
795        truth_gate_started_at.clone(),
796    )?;
797    let artifact_gate_receipt = artifact_gate_phase_receipt(
798        &deployment_truth_check,
799        truth_gate_started_at.clone(),
800        Some(current_unix_timestamp_label()?),
801    );
802    let role_receipts = artifact_gate_role_phase_receipts(&deployment_truth_check);
803    let deployment_receipt = receipt_with_execution_context(
804        install_deployment_truth_gate_receipt(
805            &deployment_truth_check,
806            truth_gate_started_at,
807            vec![artifact_gate_receipt],
808            role_receipts,
809        ),
810        execution_context,
811    );
812    let receipt_write = write_install_deployment_truth_receipt(
813        icp_root,
814        &options.network,
815        fleet_name,
816        &deployment_receipt,
817    );
818    match &receipt_write {
819        Ok(path) => println!("Deployment truth receipt JSON: {}", path.display()),
820        Err(err) => eprintln!("Deployment truth receipt JSON write failed: {err}"),
821    }
822    print_install_deployment_truth_gate(&deployment_truth_check, &deployment_receipt);
823    enforce_install_deployment_truth_gate(&deployment_truth_check)?;
824    receipt_write?;
825    Ok(deployment_truth_check)
826}
827
828fn enforce_install_deployment_truth_gate(
829    check: &DeploymentCheckV1,
830) -> Result<(), Box<dyn std::error::Error>> {
831    let blockers = install_deployment_truth_gate_blockers(check);
832    if blockers.is_empty() {
833        return Ok(());
834    }
835
836    let details = blockers
837        .iter()
838        .map(|finding| deployment_truth_finding_label(finding))
839        .collect::<Vec<_>>()
840        .join("; ");
841    Err(format!("deployment truth safety gate blocked install: {details}").into())
842}
843
844fn install_deployment_truth_gate_blockers(check: &DeploymentCheckV1) -> Vec<&SafetyFindingV1> {
845    check.report.hard_failures.iter().collect()
846}
847
848fn print_install_deployment_truth_gate(check: &DeploymentCheckV1, receipt: &DeploymentReceiptV1) {
849    for line in install_deployment_truth_gate_lines(check, receipt) {
850        println!("{line}");
851    }
852}
853
854fn install_deployment_truth_gate_lines(
855    check: &DeploymentCheckV1,
856    receipt: &DeploymentReceiptV1,
857) -> Vec<String> {
858    let mut lines = vec![
859        format!("Deployment truth: {}", check.report.summary),
860        format!(
861            "Deployment truth receipt: operation={} status={:?}",
862            receipt.operation_id, receipt.operation_status
863        ),
864    ];
865    for phase_receipt in &receipt.phase_receipts {
866        lines.push(format!(
867            "Deployment truth phase receipt: phase={} postcondition={:?}",
868            phase_receipt.phase, phase_receipt.verified_postcondition.status
869        ));
870    }
871    if !receipt.role_phase_receipts.is_empty() {
872        lines.push(format!(
873            "Deployment truth role receipts: {}",
874            receipt.role_phase_receipts.len()
875        ));
876    }
877    for role_receipt in &receipt.role_phase_receipts {
878        lines.push(format!(
879            "Deployment truth role receipt: phase={} role={} result={:?}",
880            role_receipt.phase, role_receipt.role, role_receipt.result
881        ));
882    }
883
884    if !check.report.hard_failures.is_empty() {
885        lines.push(format!(
886            "Deployment truth hard failures: {}",
887            check.report.hard_failures.len()
888        ));
889    }
890    for finding in install_deployment_truth_gate_blockers(check) {
891        lines.push(format!(
892            "Deployment truth blocker: {}",
893            deployment_truth_finding_label(finding)
894        ));
895    }
896    if !check.report.warnings.is_empty() {
897        lines.push(format!(
898            "Deployment truth warnings: {}",
899            check.report.warnings.len()
900        ));
901    }
902    for finding in &check.report.warnings {
903        lines.push(format!(
904            "Deployment truth warning: {}",
905            deployment_truth_finding_label(finding)
906        ));
907    }
908    lines
909}
910
911fn install_deployment_truth_gate_receipt(
912    check: &DeploymentCheckV1,
913    started_at: String,
914    phase_receipts: Vec<crate::deployment_truth::PhaseReceiptV1>,
915    role_phase_receipts: Vec<crate::deployment_truth::RolePhaseReceiptV1>,
916) -> DeploymentReceiptV1 {
917    let blockers = install_deployment_truth_gate_blockers(check);
918    let (operation_status, command_result) = if blockers.is_empty() {
919        (
920            DeploymentExecutionStatusV1::Complete,
921            DeploymentCommandResultV1::Succeeded,
922        )
923    } else {
924        (
925            DeploymentExecutionStatusV1::FailedBeforeMutation,
926            DeploymentCommandResultV1::Failed {
927                code: "deployment_truth_blocked".to_string(),
928                message: check.report.summary.clone(),
929            },
930        )
931    };
932    deployment_receipt_from_check_with_status(
933        check,
934        format!("{}:materialize_artifacts", check.check_id),
935        operation_status,
936        started_at,
937        Some(current_unix_timestamp_label().unwrap_or_else(|_| "unknown".to_string())),
938        phase_receipts,
939        role_phase_receipts,
940        command_result,
941    )
942}
943
944fn install_deployment_truth_phase_receipt(
945    check: &DeploymentCheckV1,
946    phase: &str,
947    started_at: String,
948    finished_at: Option<String>,
949    attempted_action: &str,
950    status: crate::deployment_truth::ObservationStatusV1,
951    evidence: Vec<String>,
952) -> DeploymentReceiptV1 {
953    install_deployment_truth_phase_receipt_with_result(
954        check,
955        PhaseReceiptInput {
956            phase,
957            started_at,
958            finished_at,
959            attempted_action,
960            status,
961            evidence,
962            role_phase_receipts: Vec::new(),
963            operation_status: DeploymentExecutionStatusV1::Complete,
964            command_result: DeploymentCommandResultV1::Succeeded,
965        },
966    )
967}
968
969fn install_deployment_truth_phase_receipt_with_result(
970    check: &DeploymentCheckV1,
971    input: PhaseReceiptInput<'_>,
972) -> DeploymentReceiptV1 {
973    deployment_receipt_from_check_with_status(
974        check,
975        format!("{}:{}", check.check_id, input.phase),
976        input.operation_status,
977        input.started_at.clone(),
978        input.finished_at.clone(),
979        vec![phase_receipt(
980            input.phase,
981            input.started_at,
982            input.finished_at,
983            input.attempted_action,
984            input.status,
985            input.evidence,
986        )],
987        input.role_phase_receipts,
988        input.command_result,
989    )
990}
991
992fn receipt_with_execution_context(
993    mut receipt: DeploymentReceiptV1,
994    execution_context: &DeploymentExecutionContextV1,
995) -> DeploymentReceiptV1 {
996    receipt.execution_context = Some(execution_context.clone());
997    receipt
998}
999
1000struct PhaseReceiptInput<'a> {
1001    phase: &'a str,
1002    started_at: String,
1003    finished_at: Option<String>,
1004    attempted_action: &'a str,
1005    status: crate::deployment_truth::ObservationStatusV1,
1006    evidence: Vec<String>,
1007    role_phase_receipts: Vec<crate::deployment_truth::RolePhaseReceiptV1>,
1008    operation_status: DeploymentExecutionStatusV1,
1009    command_result: DeploymentCommandResultV1,
1010}
1011
1012fn write_install_deployment_truth_receipt(
1013    icp_root: &Path,
1014    network: &str,
1015    fleet_name: &str,
1016    receipt: &DeploymentReceiptV1,
1017) -> Result<PathBuf, Box<dyn std::error::Error>> {
1018    let path = install_deployment_truth_receipt_path(icp_root, network, fleet_name, receipt)?;
1019    if let Some(parent) = path.parent() {
1020        fs::create_dir_all(parent)?;
1021    }
1022    let mut bytes = serde_json::to_vec_pretty(receipt)?;
1023    bytes.push(b'\n');
1024    fs::write(&path, bytes)?;
1025    Ok(path)
1026}
1027
1028fn install_deployment_truth_receipt_path(
1029    icp_root: &Path,
1030    network: &str,
1031    fleet_name: &str,
1032    receipt: &DeploymentReceiptV1,
1033) -> Result<PathBuf, Box<dyn std::error::Error>> {
1034    validate_network_name(network)?;
1035    validate_fleet_name(fleet_name)?;
1036    let file_stem = format!(
1037        "{}-{}",
1038        safe_deployment_truth_path_label(&receipt.started_at),
1039        safe_deployment_truth_path_label(&receipt.operation_id)
1040    );
1041    Ok(
1042        install_deployment_truth_receipts_dir(icp_root, network, fleet_name)?
1043            .join(format!("{file_stem}.json")),
1044    )
1045}
1046
1047/// Find the latest persisted deployment-truth receipt for one local fleet.
1048pub fn latest_deployment_truth_receipt_path_from_root(
1049    icp_root: &Path,
1050    network: &str,
1051    fleet_name: &str,
1052) -> Result<Option<PathBuf>, Box<dyn std::error::Error>> {
1053    let dir = install_deployment_truth_receipts_dir(icp_root, network, fleet_name)?;
1054    if !dir.is_dir() {
1055        return Ok(None);
1056    }
1057
1058    let mut latest = None;
1059    for entry in fs::read_dir(dir)? {
1060        let path = entry?.path();
1061        if !path.is_file()
1062            || path
1063                .extension()
1064                .is_none_or(|ext| !ext.eq_ignore_ascii_case("json"))
1065        {
1066            continue;
1067        }
1068        if latest.as_ref().is_none_or(|current| path > *current) {
1069            latest = Some(path);
1070        }
1071    }
1072    Ok(latest)
1073}
1074
1075fn install_deployment_truth_receipts_dir(
1076    icp_root: &Path,
1077    network: &str,
1078    fleet_name: &str,
1079) -> Result<PathBuf, Box<dyn std::error::Error>> {
1080    validate_network_name(network)?;
1081    validate_fleet_name(fleet_name)?;
1082    Ok(icp_root
1083        .join(".canic")
1084        .join(network)
1085        .join("deployment-receipts")
1086        .join(fleet_name))
1087}
1088
1089fn safe_deployment_truth_path_label(value: &str) -> String {
1090    let label = value
1091        .chars()
1092        .map(|ch| {
1093            if ch.is_ascii_alphanumeric() || matches!(ch, '-' | '_' | '.') {
1094                ch
1095            } else {
1096                '_'
1097            }
1098        })
1099        .collect::<String>();
1100    if label.is_empty() {
1101        "unknown".to_string()
1102    } else {
1103        label
1104    }
1105}
1106
1107fn deployment_truth_finding_label(finding: &SafetyFindingV1) -> String {
1108    let subject = finding
1109        .subject
1110        .as_ref()
1111        .map_or_else(|| "<none>".to_string(), Clone::clone);
1112    format!(
1113        "{}:{}:{}: {}",
1114        deployment_truth_finding_source(&finding.code),
1115        finding.code,
1116        subject,
1117        finding.message
1118    )
1119}
1120
1121fn deployment_truth_finding_source(code: &str) -> &'static str {
1122    match code {
1123        "plan_assumption" => "plan",
1124        "observation_gap" => "inventory",
1125        _ => "diff",
1126    }
1127}
1128
1129fn validate_expected_fleet_name(
1130    expected: Option<&str>,
1131    actual: &str,
1132    config_path: &Path,
1133) -> Result<(), Box<dyn std::error::Error>> {
1134    let Some(expected) = expected else {
1135        return Ok(());
1136    };
1137    if expected == actual {
1138        return Ok(());
1139    }
1140    Err(format!(
1141        "install requested fleet {expected}, but {} declares [fleet].name = {actual:?}",
1142        config_path.display()
1143    )
1144    .into())
1145}
1146
1147fn ensure_root_canister_id(
1148    icp_root: &Path,
1149    network: &str,
1150    root_canister: &str,
1151    config_path: &Path,
1152) -> Result<String, Box<dyn std::error::Error>> {
1153    if Principal::from_text(root_canister).is_ok() {
1154        return Ok(root_canister.to_string());
1155    }
1156
1157    match resolve_root_canister_id(icp_root, network, root_canister) {
1158        Ok(canister_id) => return Ok(canister_id),
1159        Err(err) if !is_missing_canister_id_error(&err.to_string()) => return Err(err),
1160        Err(_) => {}
1161    }
1162
1163    let mut create = icp_canister_command_in_network(icp_root);
1164    add_create_root_target(&mut create, root_canister);
1165    add_local_root_create_cycles_arg(&mut create, config_path, network)?;
1166    add_icp_environment_target(&mut create, network);
1167    let output = run_command_stdout(&mut create)?;
1168    if let Some(canister_id) = parse_created_canister_id(&output) {
1169        return Ok(canister_id);
1170    }
1171
1172    resolve_root_canister_id(icp_root, network, root_canister).map_err(|_| {
1173        format!(
1174            "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.",
1175            icp_root.display(),
1176            icp_root.display(),
1177        )
1178        .into()
1179    })
1180}
1181
1182fn parse_created_canister_id(output: &str) -> Option<String> {
1183    if let Ok(value) = serde_json::from_str::<JsonValue>(output) {
1184        return parse_canister_id_json(&value);
1185    }
1186
1187    output
1188        .lines()
1189        .map(str::trim)
1190        .find(|line| Principal::from_text(*line).is_ok())
1191        .map(ToString::to_string)
1192}
1193
1194fn parse_canister_id_json(value: &JsonValue) -> Option<String> {
1195    match value {
1196        JsonValue::String(text) if Principal::from_text(text).is_ok() => Some(text.clone()),
1197        JsonValue::Array(values) => values.iter().find_map(parse_canister_id_json),
1198        JsonValue::Object(object) => ["canister_id", "id", "principal"]
1199            .iter()
1200            .filter_map(|key| object.get(*key))
1201            .find_map(parse_canister_id_json),
1202        _ => None,
1203    }
1204}
1205
1206fn add_create_root_target(command: &mut Command, root_canister: &str) {
1207    if env::var_os(CANIC_ICP_LOCAL_NETWORK_URL_ENV).is_some() {
1208        command.args(["create", "--detached", "--json"]);
1209    } else {
1210        command.args(["create", root_canister, "--json"]);
1211    }
1212}
1213
1214fn is_missing_canister_id_error(message: &str) -> bool {
1215    message.contains("failed to lookup canister ID")
1216        || message.contains("could not find ID for canister")
1217        || message.contains("Canister ID is missing")
1218}
1219
1220fn reinstall_root_wasm(
1221    icp_root: &Path,
1222    network: &str,
1223    root_canister: &str,
1224    root_wasm: &Path,
1225) -> Result<(), Box<dyn std::error::Error>> {
1226    let mut install = icp_canister_command_in_network(icp_root);
1227    install.args(["install", root_canister, "--mode=reinstall", "-y", "--wasm"]);
1228    install.arg(root_wasm);
1229    install.args(["--args", &root_init_args(root_wasm)?]);
1230    add_icp_environment_target(&mut install, network);
1231    run_command(&mut install)
1232}
1233
1234fn root_init_args(root_wasm: &Path) -> Result<String, Box<dyn std::error::Error>> {
1235    let wasm = std::fs::read(root_wasm)?;
1236    Ok(format!(
1237        "(variant {{ PrimeWithModuleHash = {} }})",
1238        idl_blob(&wasm_hash(&wasm))
1239    ))
1240}
1241
1242fn idl_blob(bytes: &[u8]) -> String {
1243    let mut encoded = String::from("blob \"");
1244    for byte in bytes {
1245        use std::fmt::Write as _;
1246        let _ = write!(encoded, "\\{byte:02X}");
1247    }
1248    encoded.push('"');
1249    encoded
1250}
1251
1252// Build the persisted project-local install state from a completed install.
1253fn build_install_state(
1254    options: &InstallRootOptions,
1255    workspace_root: &Path,
1256    icp_root: &Path,
1257    config_path: &Path,
1258    release_set_manifest_path: &Path,
1259    fleet_name: &str,
1260    root_canister_id: &str,
1261) -> Result<InstallState, Box<dyn std::error::Error>> {
1262    Ok(InstallState {
1263        schema_version: INSTALL_STATE_SCHEMA_VERSION,
1264        fleet: fleet_name.to_string(),
1265        installed_at_unix_secs: current_unix_secs()?,
1266        network: options.network.clone(),
1267        root_target: options.root_canister.clone(),
1268        root_canister_id: root_canister_id.to_string(),
1269        root_build_target: options.root_build_target.clone(),
1270        workspace_root: workspace_root.display().to_string(),
1271        icp_root: icp_root.display().to_string(),
1272        config_path: config_path.display().to_string(),
1273        release_set_manifest_path: release_set_manifest_path.display().to_string(),
1274    })
1275}
1276
1277// Resolve the installed root id, accepting principal targets without a icp lookup.
1278fn resolve_root_canister_id(
1279    icp_root: &Path,
1280    network: &str,
1281    root_canister: &str,
1282) -> Result<String, Box<dyn std::error::Error>> {
1283    if Principal::from_text(root_canister).is_ok() {
1284        return Ok(root_canister.to_string());
1285    }
1286
1287    let mut command = icp_canister_command_in_network(icp_root);
1288    command.args(["status", root_canister, "--json"]);
1289    add_icp_environment_target(&mut command, network);
1290    let output = run_command_stdout(&mut command)?;
1291    parse_created_canister_id(&output).ok_or_else(|| {
1292        format!("could not parse root canister id from ICP status JSON output: {output}").into()
1293    })
1294}
1295
1296// Read the current host clock as a unix timestamp for install state.
1297fn current_unix_secs() -> Result<u64, Box<dyn std::error::Error>> {
1298    Ok(SystemTime::now().duration_since(UNIX_EPOCH)?.as_secs())
1299}
1300
1301fn current_unix_timestamp_label() -> Result<String, Box<dyn std::error::Error>> {
1302    Ok(format!("unix:{}", current_unix_secs()?))
1303}
1304
1305// Build each configured local install target through the host builder.
1306fn run_canic_build_targets(
1307    network: &str,
1308    targets: &[String],
1309    build_profile: Option<CanisterBuildProfile>,
1310    config_path: &Path,
1311    icp_root: &Path,
1312) -> Result<(), Box<dyn std::error::Error>> {
1313    let _env = BuildEnvGuard::apply(network, config_path, icp_root);
1314    let profile = build_profile.unwrap_or_else(CanisterBuildProfile::current);
1315    if let Some(context) = current_workspace_build_context_once(profile)? {
1316        for line in context.lines() {
1317            println!("{line}");
1318        }
1319        println!("config: {}", config_path.display());
1320        println!(
1321            "artifacts: {}",
1322            planned_build_artifact_root(icp_root).display()
1323        );
1324        println!();
1325    }
1326
1327    fs::create_dir_all(planned_build_artifact_root(icp_root))?;
1328    println!("Building {} canisters", targets.len());
1329    println!();
1330    let headers = ["CANISTER", "PROGRESS", "WASM", "ELAPSED"];
1331    let planned_rows = targets
1332        .iter()
1333        .map(|target| {
1334            [
1335                target.clone(),
1336                progress_bar(targets.len(), targets.len(), 10),
1337                "000.00 MiB (gz 000.00 MiB)".to_string(),
1338                "0.00s".to_string(),
1339            ]
1340        })
1341        .collect::<Vec<_>>();
1342    let alignments = [
1343        ColumnAlign::Left,
1344        ColumnAlign::Left,
1345        ColumnAlign::Right,
1346        ColumnAlign::Right,
1347    ];
1348    let widths = table_widths(&headers, &planned_rows);
1349    println!("{}", render_table_row(&headers, &widths, &alignments));
1350    println!("{}", render_separator(&widths));
1351
1352    for (index, target) in targets.iter().enumerate() {
1353        let started_at = Instant::now();
1354        let output = build_current_workspace_canister_artifact(target, profile)
1355            .map_err(|err| format!("artifact build failed for {target}: {err}"))?;
1356        let elapsed = started_at.elapsed();
1357        let artifact_size = wasm_artifact_size(&output.wasm_path, &output.wasm_gz_path)?;
1358
1359        let row = [
1360            target.clone(),
1361            progress_bar(index + 1, targets.len(), 10),
1362            artifact_size,
1363            format!("{:.2}s", elapsed.as_secs_f64()),
1364        ];
1365        println!("{}", render_table_row(&row, &widths, &alignments));
1366    }
1367
1368    println!();
1369    Ok(())
1370}
1371
1372fn planned_build_artifact_root(icp_root: &Path) -> PathBuf {
1373    icp_root.join(".icp/local/canisters")
1374}
1375
1376fn wasm_artifact_size(
1377    wasm_path: &Path,
1378    wasm_gz_path: &Path,
1379) -> Result<String, Box<dyn std::error::Error>> {
1380    let wasm_bytes = Some(std::fs::metadata(wasm_path)?.len());
1381    let gzip_bytes = std::fs::metadata(wasm_gz_path)
1382        .ok()
1383        .map(|metadata| metadata.len());
1384    Ok(wasm_size_label(wasm_bytes, gzip_bytes))
1385}
1386
1387struct BuildEnvGuard {
1388    previous_network: Option<OsString>,
1389    previous_config_path: Option<OsString>,
1390    previous_icp_root: Option<OsString>,
1391    previous_local_network_url: Option<OsString>,
1392    previous_local_root_key: Option<OsString>,
1393}
1394
1395impl BuildEnvGuard {
1396    fn apply(network: &str, config_path: &Path, icp_root: &Path) -> Self {
1397        let guard = Self {
1398            previous_network: env::var_os("ICP_ENVIRONMENT"),
1399            previous_config_path: env::var_os("CANIC_CONFIG_PATH"),
1400            previous_icp_root: env::var_os("CANIC_ICP_ROOT"),
1401            previous_local_network_url: env::var_os(CANIC_ICP_LOCAL_NETWORK_URL_ENV),
1402            previous_local_root_key: env::var_os(CANIC_ICP_LOCAL_ROOT_KEY_ENV),
1403        };
1404        set_env("ICP_ENVIRONMENT", network);
1405        set_env("CANIC_CONFIG_PATH", config_path);
1406        set_env("CANIC_ICP_ROOT", icp_root);
1407        if let Some(target) = local_replica_icp_target(network, icp_root) {
1408            set_env(CANIC_ICP_LOCAL_NETWORK_URL_ENV, target.url);
1409            set_env(CANIC_ICP_LOCAL_ROOT_KEY_ENV, target.root_key);
1410        } else {
1411            remove_env(CANIC_ICP_LOCAL_NETWORK_URL_ENV);
1412            remove_env(CANIC_ICP_LOCAL_ROOT_KEY_ENV);
1413        }
1414        guard
1415    }
1416}
1417
1418impl Drop for BuildEnvGuard {
1419    fn drop(&mut self) {
1420        restore_env("ICP_ENVIRONMENT", self.previous_network.take());
1421        restore_env("CANIC_CONFIG_PATH", self.previous_config_path.take());
1422        restore_env("CANIC_ICP_ROOT", self.previous_icp_root.take());
1423        restore_env(
1424            CANIC_ICP_LOCAL_NETWORK_URL_ENV,
1425            self.previous_local_network_url.take(),
1426        );
1427        restore_env(
1428            CANIC_ICP_LOCAL_ROOT_KEY_ENV,
1429            self.previous_local_root_key.take(),
1430        );
1431    }
1432}
1433
1434struct LocalReplicaIcpTarget {
1435    url: String,
1436    root_key: String,
1437}
1438
1439fn local_replica_icp_target(network: &str, icp_root: &Path) -> Option<LocalReplicaIcpTarget> {
1440    if !replica_query::should_use_local_replica_query(Some(network)) {
1441        return None;
1442    }
1443    if icp_ping(icp_root, network).unwrap_or(false) {
1444        return None;
1445    }
1446    let root_key = replica_query::local_replica_root_key_from_root(Some(network), icp_root)
1447        .ok()
1448        .flatten()?;
1449    Some(LocalReplicaIcpTarget {
1450        url: replica_query::local_replica_endpoint_from_root(Some(network), icp_root),
1451        root_key,
1452    })
1453}
1454
1455fn set_env<K, V>(key: K, value: V)
1456where
1457    K: AsRef<std::ffi::OsStr>,
1458    V: AsRef<std::ffi::OsStr>,
1459{
1460    // Install builds are single-threaded host orchestration. The environment is
1461    // scoped by BuildEnvGuard so Cargo build scripts see the selected fleet.
1462    unsafe {
1463        env::set_var(key, value);
1464    }
1465}
1466
1467fn remove_env<K>(key: K)
1468where
1469    K: AsRef<std::ffi::OsStr>,
1470{
1471    // Install builds are single-threaded host orchestration. The environment is
1472    // scoped by BuildEnvGuard so Cargo build scripts see the selected fleet.
1473    unsafe {
1474        env::remove_var(key);
1475    }
1476}
1477
1478fn restore_env(key: &str, value: Option<OsString>) {
1479    // See set_env: this restores the single-threaded install build context.
1480    if let Some(value) = value {
1481        set_env(key, value);
1482    } else {
1483        remove_env(key);
1484    }
1485}
1486
1487fn add_local_root_create_cycles_arg(
1488    command: &mut Command,
1489    config_path: &Path,
1490    network: &str,
1491) -> Result<(), Box<dyn std::error::Error>> {
1492    if network != "local" {
1493        return Ok(());
1494    }
1495
1496    let cycles = configured_local_root_create_cycles(config_path)?;
1497    command.args(["--cycles", &cycles.to_string()]);
1498    Ok(())
1499}
1500
1501fn ensure_local_root_min_cycles(
1502    icp_root: &Path,
1503    network: &str,
1504    root_canister: &str,
1505    phase: &str,
1506) -> Result<(), Box<dyn std::error::Error>> {
1507    if network != "local" {
1508        return Ok(());
1509    }
1510
1511    let current = query_root_cycle_balance(network, root_canister)?;
1512    if current >= LOCAL_ROOT_MIN_READY_CYCLES {
1513        return Ok(());
1514    }
1515
1516    let amount = LOCAL_ROOT_MIN_READY_CYCLES.saturating_sub(current);
1517    let mut command = icp_canister_command_in_network(icp_root);
1518    command
1519        .args(["top-up", "--amount"])
1520        .arg(amount.to_string())
1521        .arg(root_canister);
1522    add_icp_environment_target(&mut command, network);
1523    run_command(&mut command)?;
1524    println!(
1525        "Local root cycles ({phase}): topped up {} ({} -> {} target)",
1526        crate::format::cycles_tc(amount),
1527        crate::format::cycles_tc(current),
1528        crate::format::cycles_tc(LOCAL_ROOT_MIN_READY_CYCLES)
1529    );
1530    Ok(())
1531}
1532
1533fn query_root_cycle_balance(
1534    network: &str,
1535    root_canister: &str,
1536) -> Result<u128, Box<dyn std::error::Error>> {
1537    let output = icp_query_on_network(
1538        network,
1539        root_canister,
1540        protocol::CANIC_CYCLE_BALANCE,
1541        None,
1542        Some("json"),
1543    )?;
1544    parse_cycle_balance_response(&output).ok_or_else(|| {
1545        format!(
1546            "could not parse {root_canister} {} response: {output}",
1547            protocol::CANIC_CYCLE_BALANCE
1548        )
1549        .into()
1550    })
1551}
1552
1553fn progress_bar(current: usize, total: usize, width: usize) -> String {
1554    if total == 0 || width == 0 {
1555        return "[] 0/0".to_string();
1556    }
1557
1558    let filled = current.saturating_mul(width).div_ceil(total);
1559    let filled = filled.min(width);
1560    format!(
1561        "[{}{}] {current}/{total}",
1562        "#".repeat(filled),
1563        " ".repeat(width - filled)
1564    )
1565}
1566
1567// Ensure the requested replica is reachable before the local install flow begins.
1568fn ensure_icp_environment_ready(
1569    icp_root: &Path,
1570    network: &str,
1571) -> Result<(), Box<dyn std::error::Error>> {
1572    if icp_ping(icp_root, network)? {
1573        return Ok(());
1574    }
1575    if replica_query::should_use_local_replica_query(Some(network))
1576        && replica_query::local_replica_status_reachable_from_root(Some(network), icp_root)
1577    {
1578        println!(
1579            "Replica reachable via HTTP status endpoint even though ICP CLI reports network '{network}' stopped; continuing from ICP root {}.",
1580            icp_root.display()
1581        );
1582        return Ok(());
1583    }
1584
1585    Err(format!(
1586        "icp environment is not running for network '{network}'\nStart the target replica in another terminal with `canic replica start` and rerun."
1587    )
1588    .into())
1589}
1590
1591// Check whether `icp network ping <network>` currently succeeds.
1592fn icp_ping(icp_root: &Path, network: &str) -> Result<bool, Box<dyn std::error::Error>> {
1593    Ok(icp::default_command_in(icp_root)
1594        .args(["network", "ping", network])
1595        .output()?
1596        .status
1597        .success())
1598}
1599
1600fn print_install_timing_summary(timings: &InstallTimingSummary, total: Duration) {
1601    println!("Install timing summary:");
1602    println!("{}", render_install_timing_summary(timings, total));
1603}
1604
1605fn render_install_timing_summary(timings: &InstallTimingSummary, total: Duration) -> String {
1606    let rows = [
1607        timing_row("create_canisters", timings.create_canisters),
1608        timing_row("build_all", timings.build_all),
1609        timing_row("emit_manifest", timings.emit_manifest),
1610        timing_row("install_root", timings.install_root),
1611        timing_row("fund_root", timings.fund_root),
1612        timing_row("stage_release_set", timings.stage_release_set),
1613        timing_row("resume_bootstrap", timings.resume_bootstrap),
1614        timing_row("wait_ready", timings.wait_ready),
1615        timing_row("finalize_root_funding", timings.finalize_root_funding),
1616        timing_row("total", total),
1617    ];
1618    render_table(
1619        &["PHASE", "ELAPSED"],
1620        &rows,
1621        &[ColumnAlign::Left, ColumnAlign::Right],
1622    )
1623}
1624
1625fn timing_row(label: &str, duration: Duration) -> [String; 2] {
1626    [label.to_string(), format!("{:.2}s", duration.as_secs_f64())]
1627}
1628
1629// Print the final install result as a compact whitespace table.
1630fn print_install_result_summary(network: &str, fleet: &str, state_path: &Path) {
1631    println!("Install result:");
1632    println!("{:<14} success", "status");
1633    println!("{:<14} {}", "fleet", fleet);
1634    println!("{:<14} {}", "install_state", state_path.display());
1635    println!(
1636        "{:<14} canic list {} --network {}",
1637        "smoke_check", fleet, network
1638    );
1639}
1640
1641// Run one command and require a zero exit status.
1642fn run_command(command: &mut Command) -> Result<(), Box<dyn std::error::Error>> {
1643    icp::run_status(command).map_err(Into::into)
1644}
1645
1646// Run one command, require success, and return stdout.
1647fn run_command_stdout(command: &mut Command) -> Result<String, Box<dyn std::error::Error>> {
1648    icp::run_output(command).map_err(Into::into)
1649}
1650
1651// Build an icp command with the selected install environment exported
1652// for Rust build scripts that inspect ICP_ENVIRONMENT at compile time.
1653fn icp_command_on_network(network: &str) -> Command {
1654    let mut command = icp::default_command();
1655    command.env("ICP_ENVIRONMENT", network);
1656    command
1657}
1658
1659// Build an icp command in one project directory with ICP_ENVIRONMENT applied.
1660fn icp_command_in_network(icp_root: &Path, network: &str) -> Command {
1661    let mut command = icp::default_command_in(icp_root);
1662    command.env("ICP_ENVIRONMENT", network);
1663    command
1664}
1665
1666// Build an icp canister command in one project directory.
1667fn icp_canister_command_in_network(icp_root: &Path) -> Command {
1668    let mut command = icp::default_command_in(icp_root);
1669    command.arg("canister");
1670    command
1671}
1672
1673fn add_icp_environment_target(command: &mut Command, network: &str) {
1674    icp::add_target_args(command, Some(network), None);
1675}