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