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