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