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