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