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