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