Skip to main content

canic_host/install_root/
mod.rs

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