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