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    CurrentCliDeploymentExecutor, DeploymentCheckV1, DeploymentCommandResultV1,
7    DeploymentExecutionContextV1, DeploymentExecutionPreflightV1, DeploymentExecutionStatusV1,
8    DeploymentExecutor, DeploymentExecutorCapabilityV1, DeploymentReceiptV1,
9    LocalDeploymentCheckRequest, SafetyFindingV1, artifact_gate_phase_receipt,
10    artifact_gate_role_phase_receipts, check_local_deployment,
11    deployment_execution_preflight_from_check, deployment_receipt_from_check_with_status,
12    missing_executor_capabilities, phase_receipt,
13    validate_deployment_execution_preflight_for_check,
14};
15use crate::format::wasm_size_label;
16use crate::icp::{self, CANIC_ICP_LOCAL_NETWORK_URL_ENV, CANIC_ICP_LOCAL_ROOT_KEY_ENV};
17use crate::release_set::{
18    LOCAL_ROOT_MIN_READY_CYCLES, configured_fleet_name, configured_install_targets,
19    configured_local_root_create_cycles, emit_root_release_set_manifest_with_config,
20    icp_query_on_network, icp_root, load_root_release_set_manifest, resolve_artifact_root,
21    resume_root_bootstrap, stage_root_release_set, workspace_root,
22};
23use crate::replica_query;
24use crate::response_parse::parse_cycle_balance_response;
25use crate::table::{ColumnAlign, render_separator, render_table, render_table_row, table_widths};
26use canic_core::{
27    cdk::{types::Principal, utils::hash::wasm_hash},
28    protocol,
29};
30use config_selection::resolve_install_config_path;
31use serde_json::Value as JsonValue;
32use std::{
33    env,
34    ffi::OsString,
35    fs,
36    path::{Path, PathBuf},
37    process::Command,
38    time::{Duration, Instant, SystemTime, UNIX_EPOCH},
39};
40
41mod config_selection;
42mod readiness;
43mod state;
44
45pub use config_selection::{
46    current_canic_project_root, discover_canic_config_choices, discover_canic_project_root_from,
47    discover_project_canic_config_choices, project_fleet_roots,
48};
49use readiness::wait_for_root_ready;
50use state::{
51    INSTALL_STATE_SCHEMA_VERSION, validate_fleet_name, validate_network_name, write_install_state,
52};
53pub use state::{
54    InstallState, read_named_fleet_install_state, read_named_fleet_install_state_from_root,
55};
56
57#[cfg(test)]
58mod tests;
59
60#[cfg(test)]
61use config_selection::config_selection_error;
62#[cfg(test)]
63use readiness::{parse_bootstrap_status_value, parse_root_ready_value};
64#[cfg(test)]
65use state::{fleet_install_state_path, read_fleet_install_state};
66
67///
68/// InstallRootOptions
69///
70
71#[derive(Clone, Debug)]
72pub struct InstallRootOptions {
73    pub root_canister: String,
74    pub root_build_target: String,
75    pub network: String,
76    pub icp_root: Option<PathBuf>,
77    pub build_profile: Option<CanisterBuildProfile>,
78    pub ready_timeout_seconds: u64,
79    pub config_path: Option<String>,
80    pub expected_fleet: Option<String>,
81    pub interactive_config_selection: bool,
82}
83
84///
85/// InstallTimingSummary
86///
87
88#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)]
89struct InstallTimingSummary {
90    create_canisters: Duration,
91    build_all: Duration,
92    emit_manifest: Duration,
93    install_root: Duration,
94    fund_root: Duration,
95    stage_release_set: Duration,
96    resume_bootstrap: Duration,
97    wait_ready: Duration,
98    finalize_root_funding: Duration,
99}
100
101const CURRENT_INSTALL_REQUIRED_CAPABILITIES: &[DeploymentExecutorCapabilityV1] = &[
102    DeploymentExecutorCapabilityV1::CreateCanister,
103    DeploymentExecutorCapabilityV1::InstallCode,
104    DeploymentExecutorCapabilityV1::Call,
105    DeploymentExecutorCapabilityV1::Query,
106    DeploymentExecutorCapabilityV1::StageArtifact,
107];
108
109/// Discover installable Canic config choices under the current workspace.
110pub fn discover_current_canic_config_choices() -> Result<Vec<PathBuf>, Box<dyn std::error::Error>> {
111    let project_root = current_canic_project_root()?;
112    let choices = config_selection::discover_workspace_canic_config_choices(&project_root)?;
113    if !choices.is_empty() {
114        return Ok(choices);
115    }
116
117    if let Ok(icp_root) = icp_root()
118        && icp_root != project_root
119    {
120        return config_selection::discover_workspace_canic_config_choices(&icp_root);
121    }
122
123    Ok(choices)
124}
125
126// Execute the local thin-root install flow against an already running replica.
127pub fn install_root(options: InstallRootOptions) -> Result<(), Box<dyn std::error::Error>> {
128    let workspace_root = workspace_root()?;
129    let icp_root = match &options.icp_root {
130        Some(path) => path.canonicalize()?,
131        None => icp_root()?,
132    };
133    let config_path = resolve_install_config_path(
134        &icp_root,
135        options.config_path.as_deref(),
136        options.interactive_config_selection,
137    )?;
138    let _install_env = BuildEnvGuard::apply(&options.network, &config_path, &icp_root);
139    let fleet_name = configured_fleet_name(&config_path)?;
140    validate_expected_fleet_name(options.expected_fleet.as_deref(), &fleet_name, &config_path)?;
141    validate_fleet_name(&fleet_name)?;
142    let total_started_at = Instant::now();
143    let mut timings = InstallTimingSummary::default();
144    let network = options.network.as_str();
145    let execution_context = current_install_execution_context(&workspace_root, &icp_root, network);
146
147    println!("Installing fleet {fleet_name}");
148    println!();
149    let prepared = prepare_install_deployment_truth(
150        &options,
151        &workspace_root,
152        &icp_root,
153        &config_path,
154        &fleet_name,
155        &execution_context,
156    )?;
157    timings.create_canisters = prepared.timings.create_canisters;
158    timings.build_all = prepared.timings.build_all;
159
160    let (manifest_path, emit_manifest_duration) = emit_manifest_with_deployment_truth_receipt(
161        &workspace_root,
162        &icp_root,
163        &options,
164        &config_path,
165        &fleet_name,
166        &prepared.deployment_truth_check,
167        &execution_context,
168    )?;
169    timings.emit_manifest = emit_manifest_duration;
170    let activation_timings = run_root_activation_phases(
171        InstallReceiptScope {
172            icp_root: &icp_root,
173            network,
174            fleet_name: &fleet_name,
175            check: &prepared.deployment_truth_check,
176            execution_context: Some(&execution_context),
177        },
178        &options,
179        &prepared.root_canister_id,
180        &manifest_path,
181        total_started_at,
182    )?;
183    timings.install_root = activation_timings.install_root;
184    timings.fund_root = activation_timings.fund_root;
185    timings.stage_release_set = activation_timings.stage_release_set;
186    timings.resume_bootstrap = activation_timings.resume_bootstrap;
187    timings.wait_ready = activation_timings.wait_ready;
188    timings.finalize_root_funding = activation_timings.finalize_root_funding;
189
190    print_install_timing_summary(&timings, total_started_at.elapsed());
191    let state = build_install_state(
192        &options,
193        &workspace_root,
194        &icp_root,
195        &config_path,
196        &manifest_path,
197        &fleet_name,
198        &prepared.root_canister_id,
199    )?;
200    let state_path = write_install_state_with_deployment_truth_receipt(
201        InstallReceiptScope {
202            icp_root: &icp_root,
203            network,
204            fleet_name: &fleet_name,
205            check: &prepared.deployment_truth_check,
206            execution_context: Some(&execution_context),
207        },
208        &options.network,
209        &state,
210    )?;
211    print_install_result_summary(&options.network, &state.fleet, &state_path);
212    Ok(())
213}
214
215struct PreparedInstallTruth {
216    root_canister_id: String,
217    deployment_truth_check: DeploymentCheckV1,
218    timings: InstallTimingSummary,
219}
220
221struct CurrentInstallTruthInputs {
222    workspace_root: PathBuf,
223    icp_root: PathBuf,
224    config_path: PathBuf,
225    fleet_name: String,
226}
227
228fn prepare_install_deployment_truth(
229    options: &InstallRootOptions,
230    workspace_root: &Path,
231    icp_root: &Path,
232    config_path: &Path,
233    fleet_name: &str,
234    execution_context: &DeploymentExecutionContextV1,
235) -> Result<PreparedInstallTruth, Box<dyn std::error::Error>> {
236    let mut timings = InstallTimingSummary::default();
237    ensure_current_install_executor_capabilities(execution_context)?;
238    ensure_icp_environment_ready(icp_root, &options.network)?;
239    let (root_canister_id, create_phase, create_duration) =
240        resolve_root_canister_with_phase(options, icp_root, config_path)?;
241    timings.create_canisters = create_duration;
242
243    let (build_phase, build_duration) =
244        build_install_targets_with_phase(options, icp_root, config_path)?;
245    timings.build_all = build_duration;
246
247    let deployment_truth_check = run_install_deployment_truth_safety_gate(
248        options,
249        workspace_root,
250        icp_root,
251        config_path,
252        fleet_name,
253        execution_context,
254    )?;
255    let receipt_scope = InstallReceiptScope {
256        icp_root,
257        network: &options.network,
258        fleet_name,
259        check: &deployment_truth_check,
260        execution_context: Some(execution_context),
261    };
262    write_completed_install_phase_receipt(receipt_scope, create_phase)?;
263    write_completed_install_phase_receipt(receipt_scope, build_phase)?;
264
265    Ok(PreparedInstallTruth {
266        root_canister_id,
267        deployment_truth_check,
268        timings,
269    })
270}
271
272fn resolve_root_canister_with_phase(
273    options: &InstallRootOptions,
274    icp_root: &Path,
275    config_path: &Path,
276) -> Result<(String, CompletedInstallPhase, Duration), Box<dyn std::error::Error>> {
277    let started_at = current_unix_timestamp_label()?;
278    let started = Instant::now();
279    let root_canister_id = ensure_root_canister_id(
280        icp_root,
281        &options.network,
282        &options.root_canister,
283        config_path,
284    )?;
285    let duration = started.elapsed();
286    let phase = CompletedInstallPhase {
287        phase: "resolve_root_canister",
288        attempted_action: "resolve or create root canister id",
289        started_at,
290        finished_at: Some(current_unix_timestamp_label()?),
291        evidence: vec![
292            format!("root_target:{}", options.root_canister),
293            format!("root_canister:{root_canister_id}"),
294        ],
295        role_names: Vec::new(),
296    };
297    Ok((root_canister_id, phase, duration))
298}
299
300fn build_install_targets_with_phase(
301    options: &InstallRootOptions,
302    icp_root: &Path,
303    config_path: &Path,
304) -> Result<(CompletedInstallPhase, Duration), Box<dyn std::error::Error>> {
305    let build_targets = configured_install_targets(config_path, &options.root_build_target)?;
306    let started_at = current_unix_timestamp_label()?;
307    let started = Instant::now();
308    run_canic_build_targets(
309        &options.network,
310        &build_targets,
311        options.build_profile,
312        config_path,
313        icp_root,
314    )?;
315    let duration = started.elapsed();
316    let phase = CompletedInstallPhase {
317        phase: "build_artifacts",
318        attempted_action: "build configured install targets",
319        started_at,
320        finished_at: Some(current_unix_timestamp_label()?),
321        evidence: build_targets
322            .iter()
323            .map(|target| format!("build_target:{target}"))
324            .collect(),
325        role_names: build_targets,
326    };
327    Ok((phase, duration))
328}
329
330fn emit_manifest_with_deployment_truth_receipt(
331    workspace_root: &Path,
332    icp_root: &Path,
333    options: &InstallRootOptions,
334    config_path: &Path,
335    fleet_name: &str,
336    deployment_truth_check: &DeploymentCheckV1,
337    execution_context: &DeploymentExecutionContextV1,
338) -> Result<(PathBuf, Duration), Box<dyn std::error::Error>> {
339    let emit_manifest_started_at_label = current_unix_timestamp_label()?;
340    let emit_manifest_started_at = Instant::now();
341    let manifest_path = emit_root_release_set_manifest_with_config(
342        workspace_root,
343        icp_root,
344        &options.network,
345        config_path,
346    )?;
347    let emit_manifest_duration = emit_manifest_started_at.elapsed();
348    let emit_manifest_receipt = receipt_with_execution_context(
349        install_deployment_truth_phase_receipt(
350            deployment_truth_check,
351            "emit_manifest",
352            emit_manifest_started_at_label,
353            Some(current_unix_timestamp_label()?),
354            "emit root release-set manifest",
355            crate::deployment_truth::ObservationStatusV1::Observed,
356            vec![format!("manifest_path:{}", manifest_path.display())],
357        ),
358        execution_context,
359    );
360    let emit_manifest_receipt_path = write_install_deployment_truth_receipt(
361        icp_root,
362        &options.network,
363        fleet_name,
364        &emit_manifest_receipt,
365    )?;
366    println!(
367        "Deployment truth receipt JSON: {}",
368        emit_manifest_receipt_path.display()
369    );
370    Ok((manifest_path, emit_manifest_duration))
371}
372
373fn run_root_activation_phases(
374    receipt_scope: InstallReceiptScope<'_>,
375    options: &InstallRootOptions,
376    root_canister_id: &str,
377    manifest_path: &Path,
378    total_started_at: Instant,
379) -> Result<InstallTimingSummary, Box<dyn std::error::Error>> {
380    let mut timings = InstallTimingSummary::default();
381    let root_wasm = resolve_artifact_root(receipt_scope.icp_root, receipt_scope.network)?
382        .join(&options.root_build_target)
383        .join(format!("{}.wasm", options.root_build_target));
384    timings.install_root = receipt_scope.run_phase(
385        "install_root",
386        "install root wasm",
387        vec![
388            format!("root_canister:{root_canister_id}"),
389            format!("root_wasm:{}", root_wasm.display()),
390        ],
391        || {
392            reinstall_root_wasm(
393                receipt_scope.icp_root,
394                receipt_scope.network,
395                root_canister_id,
396                &root_wasm,
397            )
398        },
399    )?;
400    timings.fund_root = receipt_scope.run_phase(
401        "fund_root_pre_bootstrap",
402        "ensure local root minimum cycles before bootstrap",
403        vec![
404            format!("root_canister:{root_canister_id}"),
405            format!("minimum_cycles:{LOCAL_ROOT_MIN_READY_CYCLES}"),
406        ],
407        || {
408            ensure_local_root_min_cycles(
409                receipt_scope.icp_root,
410                receipt_scope.network,
411                root_canister_id,
412                "pre-bootstrap",
413            )
414        },
415    )?;
416    let manifest = load_root_release_set_manifest(manifest_path)?;
417    timings.stage_release_set = receipt_scope.run_phase(
418        "stage_release_set",
419        "stage root release set",
420        vec![
421            format!("root_canister:{root_canister_id}"),
422            format!("manifest_path:{}", manifest_path.display()),
423        ],
424        || {
425            stage_root_release_set(
426                receipt_scope.icp_root,
427                receipt_scope.network,
428                root_canister_id,
429                &manifest,
430            )
431        },
432    )?;
433    timings.resume_bootstrap = receipt_scope.run_phase(
434        "resume_bootstrap",
435        "resume root bootstrap",
436        vec![format!("root_canister:{root_canister_id}")],
437        || resume_root_bootstrap(receipt_scope.network, root_canister_id),
438    )?;
439    let wait_ready_result = receipt_scope.run_phase(
440        "wait_ready",
441        "wait for root bootstrap readiness",
442        vec![
443            format!("root_canister:{root_canister_id}"),
444            format!("timeout_seconds:{}", options.ready_timeout_seconds),
445        ],
446        || {
447            wait_for_root_ready(
448                receipt_scope.network,
449                root_canister_id,
450                options.ready_timeout_seconds,
451            )
452        },
453    );
454    match wait_ready_result {
455        Ok(duration) => timings.wait_ready = duration,
456        Err(err) => {
457            print_install_timing_summary(&timings, total_started_at.elapsed());
458            return Err(err);
459        }
460    }
461    timings.finalize_root_funding = receipt_scope.run_phase(
462        "fund_root_post_ready",
463        "ensure local root minimum cycles after ready",
464        vec![
465            format!("root_canister:{root_canister_id}"),
466            format!("minimum_cycles:{LOCAL_ROOT_MIN_READY_CYCLES}"),
467        ],
468        || {
469            ensure_local_root_min_cycles(
470                receipt_scope.icp_root,
471                receipt_scope.network,
472                root_canister_id,
473                "post-ready",
474            )
475        },
476    )?;
477    Ok(timings)
478}
479
480#[derive(Clone, Copy)]
481struct InstallReceiptScope<'a> {
482    icp_root: &'a Path,
483    network: &'a str,
484    fleet_name: &'a str,
485    check: &'a DeploymentCheckV1,
486    execution_context: Option<&'a DeploymentExecutionContextV1>,
487}
488
489struct CompletedInstallPhase {
490    phase: &'static str,
491    attempted_action: &'static str,
492    started_at: String,
493    finished_at: Option<String>,
494    evidence: Vec<String>,
495    role_names: Vec<String>,
496}
497
498fn write_completed_install_phase_receipt(
499    receipt_scope: InstallReceiptScope<'_>,
500    completed: CompletedInstallPhase,
501) -> Result<PathBuf, Box<dyn std::error::Error>> {
502    let role_phase_receipts = completed
503        .role_names
504        .iter()
505        .filter_map(|role| {
506            completed_phase_role_receipt(
507                receipt_scope.check,
508                completed.phase,
509                role,
510                crate::deployment_truth::RolePhaseResultV1::Applied,
511                None,
512            )
513        })
514        .collect();
515    let receipt =
516        receipt_scope.with_execution_context(install_deployment_truth_phase_receipt_with_result(
517            receipt_scope.check,
518            PhaseReceiptInput {
519                phase: completed.phase,
520                started_at: completed.started_at,
521                finished_at: completed.finished_at,
522                attempted_action: completed.attempted_action,
523                status: crate::deployment_truth::ObservationStatusV1::Observed,
524                evidence: completed.evidence,
525                role_phase_receipts,
526                operation_status: DeploymentExecutionStatusV1::Complete,
527                command_result: DeploymentCommandResultV1::Succeeded,
528            },
529        ));
530    receipt_scope.write_receipt(&receipt)
531}
532
533fn completed_phase_role_receipt(
534    check: &DeploymentCheckV1,
535    phase: &str,
536    role: &str,
537    result: crate::deployment_truth::RolePhaseResultV1,
538    error: Option<String>,
539) -> Option<crate::deployment_truth::RolePhaseReceiptV1> {
540    let planned = check
541        .plan
542        .role_artifacts
543        .iter()
544        .find(|artifact| artifact.role == role)?;
545    let observed = check
546        .inventory
547        .observed_artifacts
548        .iter()
549        .find(|artifact| artifact.role == role);
550    let artifact_digest = observed
551        .and_then(|artifact| artifact.file_sha256.clone())
552        .or_else(|| observed.and_then(|artifact| artifact.payload_sha256.clone()))
553        .or_else(|| planned.observed_wasm_gz_file_sha256.clone())
554        .or_else(|| planned.wasm_gz_sha256.clone());
555
556    Some(crate::deployment_truth::RolePhaseReceiptV1 {
557        role: role.to_string(),
558        phase: phase.to_string(),
559        result,
560        previous_module_hash: None,
561        target_module_hash: planned.installed_module_hash.clone(),
562        observed_module_hash_after: None,
563        artifact_digest,
564        canonical_embedded_config_sha256: planned.canonical_embedded_config_sha256.clone(),
565        error,
566    })
567}
568
569fn write_install_state_with_deployment_truth_receipt(
570    receipt_scope: InstallReceiptScope<'_>,
571    network: &str,
572    state: &InstallState,
573) -> Result<PathBuf, Box<dyn std::error::Error>> {
574    let started_at = current_unix_timestamp_label()?;
575    let state_path = write_install_state(receipt_scope.icp_root, network, state)?;
576    let completed = CompletedInstallPhase {
577        phase: "write_install_state",
578        attempted_action: "write local install state",
579        started_at,
580        finished_at: Some(current_unix_timestamp_label()?),
581        evidence: vec![
582            format!("install_state:{}", state_path.display()),
583            format!("fleet:{}", state.fleet),
584            format!("root_canister:{}", state.root_canister_id),
585        ],
586        role_names: Vec::new(),
587    };
588    write_completed_install_phase_receipt(receipt_scope, completed)?;
589    Ok(state_path)
590}
591
592impl InstallReceiptScope<'_> {
593    fn run_phase(
594        self,
595        phase: &str,
596        attempted_action: &str,
597        evidence: Vec<String>,
598        run: impl FnOnce() -> Result<(), Box<dyn std::error::Error>>,
599    ) -> Result<Duration, Box<dyn std::error::Error>> {
600        let started_at = current_unix_timestamp_label()?;
601        let started = Instant::now();
602        match run() {
603            Ok(()) => {
604                let duration = started.elapsed();
605                let receipt = self.with_execution_context(install_deployment_truth_phase_receipt(
606                    self.check,
607                    phase,
608                    started_at,
609                    Some(current_unix_timestamp_label()?),
610                    attempted_action,
611                    crate::deployment_truth::ObservationStatusV1::Observed,
612                    evidence,
613                ));
614                self.write_receipt(&receipt)?;
615                Ok(duration)
616            }
617            Err(err) => {
618                self.try_write_failed_phase_receipt(
619                    phase,
620                    started_at,
621                    attempted_action,
622                    evidence,
623                    err.as_ref(),
624                );
625                Err(err)
626            }
627        }
628    }
629
630    fn with_execution_context(self, receipt: DeploymentReceiptV1) -> DeploymentReceiptV1 {
631        match self.execution_context {
632            Some(context) => receipt_with_execution_context(receipt, context),
633            None => receipt,
634        }
635    }
636
637    fn write_receipt(
638        self,
639        receipt: &DeploymentReceiptV1,
640    ) -> Result<PathBuf, Box<dyn std::error::Error>> {
641        let path = write_install_deployment_truth_receipt(
642            self.icp_root,
643            self.network,
644            self.fleet_name,
645            receipt,
646        )?;
647        println!("Deployment truth receipt JSON: {}", path.display());
648        Ok(path)
649    }
650
651    fn try_write_failed_phase_receipt(
652        self,
653        phase: &str,
654        started_at: String,
655        attempted_action: &str,
656        evidence: Vec<String>,
657        err: &dyn std::error::Error,
658    ) {
659        let receipt = install_deployment_truth_phase_receipt_with_result(
660            self.check,
661            PhaseReceiptInput {
662                phase,
663                started_at,
664                finished_at: Some(
665                    current_unix_timestamp_label().unwrap_or_else(|_| "unknown".to_string()),
666                ),
667                attempted_action,
668                status: crate::deployment_truth::ObservationStatusV1::Inconclusive,
669                evidence,
670                role_phase_receipts: Vec::new(),
671                operation_status: DeploymentExecutionStatusV1::FailedAfterMutation,
672                command_result: DeploymentCommandResultV1::Failed {
673                    code: format!("{phase}_failed"),
674                    message: err.to_string(),
675                },
676            },
677        );
678        let receipt = self.with_execution_context(receipt);
679        if let Err(write_err) = self.write_receipt(&receipt) {
680            eprintln!("Deployment truth receipt JSON write failed: {write_err}");
681        }
682    }
683}
684
685/// Build the same read-only deployment truth check that can be used as a
686/// preflight for the current install inputs without mutating deployment state.
687pub fn check_install_deployment_truth(
688    options: &InstallRootOptions,
689    observed_at: impl Into<String>,
690) -> Result<DeploymentCheckV1, Box<dyn std::error::Error>> {
691    let inputs = resolve_current_install_truth_inputs(options)?;
692    current_install_deployment_truth_check_at(
693        options,
694        &inputs.workspace_root,
695        &inputs.icp_root,
696        &inputs.config_path,
697        &inputs.fleet_name,
698        observed_at.into(),
699    )
700}
701
702/// Build a read-only execution preflight for the current install inputs.
703///
704/// This validates the current plan, safety report, authority reconciliation,
705/// and executor capabilities without opening the mutating install path or
706/// writing local receipt state.
707pub fn check_install_execution_preflight(
708    options: &InstallRootOptions,
709    observed_at: impl Into<String>,
710) -> Result<DeploymentExecutionPreflightV1, Box<dyn std::error::Error>> {
711    let inputs = resolve_current_install_truth_inputs(options)?;
712    let check = current_install_deployment_truth_check_at(
713        options,
714        &inputs.workspace_root,
715        &inputs.icp_root,
716        &inputs.config_path,
717        &inputs.fleet_name,
718        observed_at.into(),
719    )?;
720    let execution_context = current_install_execution_context(
721        &inputs.workspace_root,
722        &inputs.icp_root,
723        &options.network,
724    );
725    let executor = CurrentCliDeploymentExecutor::new(
726        execution_context.workspace_root,
727        execution_context.icp_root,
728        execution_context.artifact_roots,
729    );
730    let preflight = deployment_execution_preflight_from_check(
731        &check,
732        &executor,
733        CURRENT_INSTALL_REQUIRED_CAPABILITIES,
734    );
735    validate_deployment_execution_preflight_for_check(&check, &preflight)?;
736    Ok(preflight)
737}
738
739fn resolve_current_install_truth_inputs(
740    options: &InstallRootOptions,
741) -> Result<CurrentInstallTruthInputs, Box<dyn std::error::Error>> {
742    let workspace_root = workspace_root()?;
743    let icp_root = match &options.icp_root {
744        Some(path) => path.canonicalize()?,
745        None => icp_root()?,
746    };
747    let config_path = resolve_install_config_path(
748        &icp_root,
749        options.config_path.as_deref(),
750        options.interactive_config_selection,
751    )?;
752    let fleet_name = configured_fleet_name(&config_path)?;
753    validate_expected_fleet_name(options.expected_fleet.as_deref(), &fleet_name, &config_path)?;
754    validate_fleet_name(&fleet_name)?;
755    Ok(CurrentInstallTruthInputs {
756        workspace_root,
757        icp_root,
758        config_path,
759        fleet_name,
760    })
761}
762
763fn current_install_deployment_truth_check_at(
764    options: &InstallRootOptions,
765    workspace_root: &Path,
766    icp_root: &Path,
767    config_path: &Path,
768    fleet_name: &str,
769    observed_at: String,
770) -> Result<DeploymentCheckV1, Box<dyn std::error::Error>> {
771    let build_profile = options
772        .build_profile
773        .unwrap_or_else(CanisterBuildProfile::current)
774        .target_dir_name()
775        .to_string();
776
777    check_local_deployment(&LocalDeploymentCheckRequest {
778        deployment_name: fleet_name.to_string(),
779        network: options.network.clone(),
780        workspace_root: workspace_root.to_path_buf(),
781        icp_root: icp_root.to_path_buf(),
782        config_path: Some(config_path.to_path_buf()),
783        observed_at,
784        runtime_variant: options.network.clone(),
785        build_profile,
786    })
787    .map_err(Into::into)
788}
789
790fn current_install_execution_context(
791    workspace_root: &Path,
792    icp_root: &Path,
793    network: &str,
794) -> DeploymentExecutionContextV1 {
795    CurrentCliDeploymentExecutor::new(
796        Some(workspace_root.display().to_string()),
797        Some(icp_root.display().to_string()),
798        current_install_artifact_roots(icp_root, network),
799    )
800    .execution_context()
801}
802
803fn ensure_current_install_executor_capabilities(
804    execution_context: &DeploymentExecutionContextV1,
805) -> Result<(), Box<dyn std::error::Error>> {
806    let missing = current_install_executor_missing_capabilities(execution_context);
807    if missing.is_empty() {
808        return Ok(());
809    }
810
811    Err(format!(
812        "current install executor backend {:?} is missing required capabilities: {missing:?}",
813        execution_context.backend
814    )
815    .into())
816}
817
818fn current_install_executor_missing_capabilities(
819    execution_context: &DeploymentExecutionContextV1,
820) -> Vec<DeploymentExecutorCapabilityV1> {
821    missing_executor_capabilities(
822        &execution_context.backend_capabilities,
823        CURRENT_INSTALL_REQUIRED_CAPABILITIES,
824    )
825}
826
827fn current_install_artifact_roots(icp_root: &Path, network: &str) -> Vec<String> {
828    let planned_root = planned_build_artifact_root(icp_root);
829    let mut roots = vec![planned_root.display().to_string()];
830    if let Ok(resolved_root) = resolve_artifact_root(icp_root, network)
831        && resolved_root != planned_root
832    {
833        roots.push(resolved_root.display().to_string());
834    }
835    roots
836}
837
838fn run_install_deployment_truth_safety_gate(
839    options: &InstallRootOptions,
840    workspace_root: &Path,
841    icp_root: &Path,
842    config_path: &Path,
843    fleet_name: &str,
844    execution_context: &DeploymentExecutionContextV1,
845) -> Result<DeploymentCheckV1, Box<dyn std::error::Error>> {
846    let truth_gate_started_at = current_unix_timestamp_label()?;
847    let deployment_truth_check = current_install_deployment_truth_check_at(
848        options,
849        workspace_root,
850        icp_root,
851        config_path,
852        fleet_name,
853        truth_gate_started_at.clone(),
854    )?;
855    let artifact_gate_receipt = artifact_gate_phase_receipt(
856        &deployment_truth_check,
857        truth_gate_started_at.clone(),
858        Some(current_unix_timestamp_label()?),
859    );
860    let role_receipts = artifact_gate_role_phase_receipts(&deployment_truth_check);
861    let deployment_receipt = receipt_with_execution_context(
862        install_deployment_truth_gate_receipt(
863            &deployment_truth_check,
864            truth_gate_started_at,
865            vec![artifact_gate_receipt],
866            role_receipts,
867        ),
868        execution_context,
869    );
870    let receipt_write = write_install_deployment_truth_receipt(
871        icp_root,
872        &options.network,
873        fleet_name,
874        &deployment_receipt,
875    );
876    match &receipt_write {
877        Ok(path) => println!("Deployment truth receipt JSON: {}", path.display()),
878        Err(err) => eprintln!("Deployment truth receipt JSON write failed: {err}"),
879    }
880    print_install_deployment_truth_gate(&deployment_truth_check, &deployment_receipt);
881    enforce_install_deployment_truth_gate(&deployment_truth_check)?;
882    receipt_write?;
883    write_current_install_execution_preflight_receipt(
884        icp_root,
885        &options.network,
886        fleet_name,
887        &deployment_truth_check,
888        execution_context,
889    )?;
890    Ok(deployment_truth_check)
891}
892
893fn enforce_install_deployment_truth_gate(
894    check: &DeploymentCheckV1,
895) -> Result<(), Box<dyn std::error::Error>> {
896    let blockers = install_deployment_truth_gate_blockers(check);
897    if blockers.is_empty() {
898        return Ok(());
899    }
900
901    let details = blockers
902        .iter()
903        .map(|finding| deployment_truth_finding_label(finding))
904        .collect::<Vec<_>>()
905        .join("; ");
906    Err(format!("deployment truth safety gate blocked install: {details}").into())
907}
908
909fn install_deployment_truth_gate_blockers(check: &DeploymentCheckV1) -> Vec<&SafetyFindingV1> {
910    check.report.hard_failures.iter().collect()
911}
912
913fn print_install_deployment_truth_gate(check: &DeploymentCheckV1, receipt: &DeploymentReceiptV1) {
914    for line in install_deployment_truth_gate_lines(check, receipt) {
915        println!("{line}");
916    }
917}
918
919fn install_deployment_truth_gate_lines(
920    check: &DeploymentCheckV1,
921    receipt: &DeploymentReceiptV1,
922) -> Vec<String> {
923    let mut lines = vec![
924        format!("Deployment truth: {}", check.report.summary),
925        format!(
926            "Deployment truth receipt: operation={} status={:?}",
927            receipt.operation_id, receipt.operation_status
928        ),
929    ];
930    for phase_receipt in &receipt.phase_receipts {
931        lines.push(format!(
932            "Deployment truth phase receipt: phase={} postcondition={:?}",
933            phase_receipt.phase, phase_receipt.verified_postcondition.status
934        ));
935    }
936    if !receipt.role_phase_receipts.is_empty() {
937        lines.push(format!(
938            "Deployment truth role receipts: {}",
939            receipt.role_phase_receipts.len()
940        ));
941    }
942    for role_receipt in &receipt.role_phase_receipts {
943        lines.push(format!(
944            "Deployment truth role receipt: phase={} role={} result={:?}",
945            role_receipt.phase, role_receipt.role, role_receipt.result
946        ));
947    }
948
949    if !check.report.hard_failures.is_empty() {
950        lines.push(format!(
951            "Deployment truth hard failures: {}",
952            check.report.hard_failures.len()
953        ));
954    }
955    for finding in install_deployment_truth_gate_blockers(check) {
956        lines.push(format!(
957            "Deployment truth blocker: {}",
958            deployment_truth_finding_label(finding)
959        ));
960    }
961    if !check.report.warnings.is_empty() {
962        lines.push(format!(
963            "Deployment truth warnings: {}",
964            check.report.warnings.len()
965        ));
966    }
967    for finding in &check.report.warnings {
968        lines.push(format!(
969            "Deployment truth warning: {}",
970            deployment_truth_finding_label(finding)
971        ));
972    }
973    lines
974}
975
976fn install_deployment_truth_gate_receipt(
977    check: &DeploymentCheckV1,
978    started_at: String,
979    phase_receipts: Vec<crate::deployment_truth::PhaseReceiptV1>,
980    role_phase_receipts: Vec<crate::deployment_truth::RolePhaseReceiptV1>,
981) -> DeploymentReceiptV1 {
982    let blockers = install_deployment_truth_gate_blockers(check);
983    let (operation_status, command_result) = if blockers.is_empty() {
984        (
985            DeploymentExecutionStatusV1::Complete,
986            DeploymentCommandResultV1::Succeeded,
987        )
988    } else {
989        (
990            DeploymentExecutionStatusV1::FailedBeforeMutation,
991            DeploymentCommandResultV1::Failed {
992                code: "deployment_truth_blocked".to_string(),
993                message: check.report.summary.clone(),
994            },
995        )
996    };
997    deployment_receipt_from_check_with_status(
998        check,
999        format!("{}:materialize_artifacts", check.check_id),
1000        operation_status,
1001        started_at,
1002        Some(current_unix_timestamp_label().unwrap_or_else(|_| "unknown".to_string())),
1003        phase_receipts,
1004        role_phase_receipts,
1005        command_result,
1006    )
1007}
1008
1009fn write_current_install_execution_preflight_receipt(
1010    icp_root: &Path,
1011    network: &str,
1012    fleet_name: &str,
1013    check: &DeploymentCheckV1,
1014    execution_context: &DeploymentExecutionContextV1,
1015) -> Result<PathBuf, Box<dyn std::error::Error>> {
1016    let started_at = current_unix_timestamp_label()?;
1017    let executor = CurrentCliDeploymentExecutor::new(
1018        execution_context.workspace_root.clone(),
1019        execution_context.icp_root.clone(),
1020        execution_context.artifact_roots.clone(),
1021    );
1022    let preflight = deployment_execution_preflight_from_check(
1023        check,
1024        &executor,
1025        CURRENT_INSTALL_REQUIRED_CAPABILITIES,
1026    );
1027    validate_deployment_execution_preflight_for_check(check, &preflight)?;
1028    let blockers = preflight.blockers.clone();
1029    let (operation_status, command_result) = if blockers.is_empty() {
1030        (
1031            DeploymentExecutionStatusV1::Complete,
1032            DeploymentCommandResultV1::Succeeded,
1033        )
1034    } else {
1035        (
1036            DeploymentExecutionStatusV1::FailedBeforeMutation,
1037            DeploymentCommandResultV1::Failed {
1038                code: "execution_preflight_blocked".to_string(),
1039                message: "deployment execution preflight blocked current install".to_string(),
1040            },
1041        )
1042    };
1043    let finished_at = current_unix_timestamp_label()?;
1044    let receipt = receipt_with_execution_context(
1045        deployment_receipt_from_check_with_status(
1046            check,
1047            format!("{}:execution_preflight", check.check_id),
1048            operation_status,
1049            started_at.clone(),
1050            Some(finished_at.clone()),
1051            vec![phase_receipt(
1052                "execution_preflight",
1053                started_at,
1054                Some(finished_at),
1055                "validate deployment plan, authority, and executor capability readiness",
1056                crate::deployment_truth::ObservationStatusV1::Observed,
1057                current_install_execution_preflight_evidence(&preflight),
1058            )],
1059            Vec::new(),
1060            command_result,
1061        ),
1062        execution_context,
1063    );
1064    let path = write_install_deployment_truth_receipt(icp_root, network, fleet_name, &receipt)?;
1065    println!("Deployment truth receipt JSON: {}", path.display());
1066    if !blockers.is_empty() {
1067        let details = blockers
1068            .iter()
1069            .map(deployment_truth_finding_label)
1070            .collect::<Vec<_>>()
1071            .join("; ");
1072        return Err(format!("deployment execution preflight blocked install: {details}").into());
1073    }
1074    Ok(path)
1075}
1076
1077fn current_install_execution_preflight_evidence(
1078    preflight: &crate::deployment_truth::DeploymentExecutionPreflightV1,
1079) -> Vec<String> {
1080    let mut evidence = vec![
1081        format!("execution_preflight_status:{:?}", preflight.status),
1082        format!("authority_plan:{}", preflight.authority_plan_id),
1083        format!("planned_phases:{}", preflight.planned_phases.len()),
1084        format!(
1085            "required_capabilities:{}",
1086            preflight.required_capabilities.len()
1087        ),
1088        format!(
1089            "missing_capabilities:{}",
1090            preflight.missing_capabilities.len()
1091        ),
1092        format!("blockers:{}", preflight.blockers.len()),
1093    ];
1094    evidence.extend(
1095        preflight
1096            .missing_capabilities
1097            .iter()
1098            .map(|capability| format!("missing_capability:{capability:?}")),
1099    );
1100    evidence.extend(
1101        preflight
1102            .blockers
1103            .iter()
1104            .map(|finding| format!("blocker:{}:{}", finding.code, finding.message)),
1105    );
1106    evidence
1107}
1108
1109fn install_deployment_truth_phase_receipt(
1110    check: &DeploymentCheckV1,
1111    phase: &str,
1112    started_at: String,
1113    finished_at: Option<String>,
1114    attempted_action: &str,
1115    status: crate::deployment_truth::ObservationStatusV1,
1116    evidence: Vec<String>,
1117) -> DeploymentReceiptV1 {
1118    install_deployment_truth_phase_receipt_with_result(
1119        check,
1120        PhaseReceiptInput {
1121            phase,
1122            started_at,
1123            finished_at,
1124            attempted_action,
1125            status,
1126            evidence,
1127            role_phase_receipts: Vec::new(),
1128            operation_status: DeploymentExecutionStatusV1::Complete,
1129            command_result: DeploymentCommandResultV1::Succeeded,
1130        },
1131    )
1132}
1133
1134fn install_deployment_truth_phase_receipt_with_result(
1135    check: &DeploymentCheckV1,
1136    input: PhaseReceiptInput<'_>,
1137) -> DeploymentReceiptV1 {
1138    deployment_receipt_from_check_with_status(
1139        check,
1140        format!("{}:{}", check.check_id, input.phase),
1141        input.operation_status,
1142        input.started_at.clone(),
1143        input.finished_at.clone(),
1144        vec![phase_receipt(
1145            input.phase,
1146            input.started_at,
1147            input.finished_at,
1148            input.attempted_action,
1149            input.status,
1150            input.evidence,
1151        )],
1152        input.role_phase_receipts,
1153        input.command_result,
1154    )
1155}
1156
1157fn receipt_with_execution_context(
1158    mut receipt: DeploymentReceiptV1,
1159    execution_context: &DeploymentExecutionContextV1,
1160) -> DeploymentReceiptV1 {
1161    receipt.execution_context = Some(execution_context.clone());
1162    receipt
1163}
1164
1165struct PhaseReceiptInput<'a> {
1166    phase: &'a str,
1167    started_at: String,
1168    finished_at: Option<String>,
1169    attempted_action: &'a str,
1170    status: crate::deployment_truth::ObservationStatusV1,
1171    evidence: Vec<String>,
1172    role_phase_receipts: Vec<crate::deployment_truth::RolePhaseReceiptV1>,
1173    operation_status: DeploymentExecutionStatusV1,
1174    command_result: DeploymentCommandResultV1,
1175}
1176
1177fn write_install_deployment_truth_receipt(
1178    icp_root: &Path,
1179    network: &str,
1180    fleet_name: &str,
1181    receipt: &DeploymentReceiptV1,
1182) -> Result<PathBuf, Box<dyn std::error::Error>> {
1183    let path = install_deployment_truth_receipt_path(icp_root, network, fleet_name, receipt)?;
1184    if let Some(parent) = path.parent() {
1185        fs::create_dir_all(parent)?;
1186    }
1187    let mut bytes = serde_json::to_vec_pretty(receipt)?;
1188    bytes.push(b'\n');
1189    fs::write(&path, bytes)?;
1190    Ok(path)
1191}
1192
1193fn install_deployment_truth_receipt_path(
1194    icp_root: &Path,
1195    network: &str,
1196    fleet_name: &str,
1197    receipt: &DeploymentReceiptV1,
1198) -> Result<PathBuf, Box<dyn std::error::Error>> {
1199    validate_network_name(network)?;
1200    validate_fleet_name(fleet_name)?;
1201    let file_stem = format!(
1202        "{}-{}",
1203        safe_deployment_truth_path_label(&receipt.started_at),
1204        safe_deployment_truth_path_label(&receipt.operation_id)
1205    );
1206    Ok(
1207        install_deployment_truth_receipts_dir(icp_root, network, fleet_name)?
1208            .join(format!("{file_stem}.json")),
1209    )
1210}
1211
1212/// Find the latest persisted deployment-truth receipt for one local fleet.
1213pub fn latest_deployment_truth_receipt_path_from_root(
1214    icp_root: &Path,
1215    network: &str,
1216    fleet_name: &str,
1217) -> Result<Option<PathBuf>, Box<dyn std::error::Error>> {
1218    let dir = install_deployment_truth_receipts_dir(icp_root, network, fleet_name)?;
1219    if !dir.is_dir() {
1220        return Ok(None);
1221    }
1222
1223    let mut latest = None;
1224    for entry in fs::read_dir(dir)? {
1225        let path = entry?.path();
1226        if !path.is_file()
1227            || path
1228                .extension()
1229                .is_none_or(|ext| !ext.eq_ignore_ascii_case("json"))
1230        {
1231            continue;
1232        }
1233        if latest.as_ref().is_none_or(|current| path > *current) {
1234            latest = Some(path);
1235        }
1236    }
1237    Ok(latest)
1238}
1239
1240fn install_deployment_truth_receipts_dir(
1241    icp_root: &Path,
1242    network: &str,
1243    fleet_name: &str,
1244) -> Result<PathBuf, Box<dyn std::error::Error>> {
1245    validate_network_name(network)?;
1246    validate_fleet_name(fleet_name)?;
1247    Ok(icp_root
1248        .join(".canic")
1249        .join(network)
1250        .join("deployment-receipts")
1251        .join(fleet_name))
1252}
1253
1254fn safe_deployment_truth_path_label(value: &str) -> String {
1255    let label = value
1256        .chars()
1257        .map(|ch| {
1258            if ch.is_ascii_alphanumeric() || matches!(ch, '-' | '_' | '.') {
1259                ch
1260            } else {
1261                '_'
1262            }
1263        })
1264        .collect::<String>();
1265    if label.is_empty() {
1266        "unknown".to_string()
1267    } else {
1268        label
1269    }
1270}
1271
1272fn deployment_truth_finding_label(finding: &SafetyFindingV1) -> String {
1273    let subject = finding
1274        .subject
1275        .as_ref()
1276        .map_or_else(|| "<none>".to_string(), Clone::clone);
1277    format!(
1278        "{}:{}:{}: {}",
1279        deployment_truth_finding_source(&finding.code),
1280        finding.code,
1281        subject,
1282        finding.message
1283    )
1284}
1285
1286fn deployment_truth_finding_source(code: &str) -> &'static str {
1287    match code {
1288        "plan_assumption" => "plan",
1289        "observation_gap" => "inventory",
1290        _ => "diff",
1291    }
1292}
1293
1294fn validate_expected_fleet_name(
1295    expected: Option<&str>,
1296    actual: &str,
1297    config_path: &Path,
1298) -> Result<(), Box<dyn std::error::Error>> {
1299    let Some(expected) = expected else {
1300        return Ok(());
1301    };
1302    if expected == actual {
1303        return Ok(());
1304    }
1305    Err(format!(
1306        "install requested fleet {expected}, but {} declares [fleet].name = {actual:?}",
1307        config_path.display()
1308    )
1309    .into())
1310}
1311
1312fn ensure_root_canister_id(
1313    icp_root: &Path,
1314    network: &str,
1315    root_canister: &str,
1316    config_path: &Path,
1317) -> Result<String, Box<dyn std::error::Error>> {
1318    if Principal::from_text(root_canister).is_ok() {
1319        return Ok(root_canister.to_string());
1320    }
1321
1322    match resolve_root_canister_id(icp_root, network, root_canister) {
1323        Ok(canister_id) => return Ok(canister_id),
1324        Err(err) if !is_missing_canister_id_error(&err.to_string()) => return Err(err),
1325        Err(_) => {}
1326    }
1327
1328    let mut create = icp_canister_command_in_network(icp_root);
1329    add_create_root_target(&mut create, root_canister);
1330    add_local_root_create_cycles_arg(&mut create, config_path, network)?;
1331    add_icp_environment_target(&mut create, network);
1332    let output = run_command_stdout(&mut create)?;
1333    if let Some(canister_id) = parse_created_canister_id(&output) {
1334        return Ok(canister_id);
1335    }
1336
1337    resolve_root_canister_id(icp_root, network, root_canister).map_err(|_| {
1338        format!(
1339            "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.",
1340            icp_root.display(),
1341            icp_root.display(),
1342        )
1343        .into()
1344    })
1345}
1346
1347fn parse_created_canister_id(output: &str) -> Option<String> {
1348    if let Ok(value) = serde_json::from_str::<JsonValue>(output) {
1349        return parse_canister_id_json(&value);
1350    }
1351
1352    output
1353        .lines()
1354        .map(str::trim)
1355        .find(|line| Principal::from_text(*line).is_ok())
1356        .map(ToString::to_string)
1357}
1358
1359fn parse_canister_id_json(value: &JsonValue) -> Option<String> {
1360    match value {
1361        JsonValue::String(text) if Principal::from_text(text).is_ok() => Some(text.clone()),
1362        JsonValue::Array(values) => values.iter().find_map(parse_canister_id_json),
1363        JsonValue::Object(object) => ["canister_id", "id", "principal"]
1364            .iter()
1365            .filter_map(|key| object.get(*key))
1366            .find_map(parse_canister_id_json),
1367        _ => None,
1368    }
1369}
1370
1371fn add_create_root_target(command: &mut Command, root_canister: &str) {
1372    if env::var_os(CANIC_ICP_LOCAL_NETWORK_URL_ENV).is_some() {
1373        command.args(["create", "--detached", "--json"]);
1374    } else {
1375        command.args(["create", root_canister, "--json"]);
1376    }
1377}
1378
1379fn is_missing_canister_id_error(message: &str) -> bool {
1380    message.contains("failed to lookup canister ID")
1381        || message.contains("could not find ID for canister")
1382        || message.contains("Canister ID is missing")
1383}
1384
1385fn reinstall_root_wasm(
1386    icp_root: &Path,
1387    network: &str,
1388    root_canister: &str,
1389    root_wasm: &Path,
1390) -> Result<(), Box<dyn std::error::Error>> {
1391    let mut install = icp_canister_command_in_network(icp_root);
1392    install.args(["install", root_canister, "--mode=reinstall", "-y", "--wasm"]);
1393    install.arg(root_wasm);
1394    install.args(["--args", &root_init_args(root_wasm)?]);
1395    add_icp_environment_target(&mut install, network);
1396    run_command(&mut install)
1397}
1398
1399fn root_init_args(root_wasm: &Path) -> Result<String, Box<dyn std::error::Error>> {
1400    let wasm = std::fs::read(root_wasm)?;
1401    Ok(format!(
1402        "(variant {{ PrimeWithModuleHash = {} }})",
1403        idl_blob(&wasm_hash(&wasm))
1404    ))
1405}
1406
1407fn idl_blob(bytes: &[u8]) -> String {
1408    let mut encoded = String::from("blob \"");
1409    for byte in bytes {
1410        use std::fmt::Write as _;
1411        let _ = write!(encoded, "\\{byte:02X}");
1412    }
1413    encoded.push('"');
1414    encoded
1415}
1416
1417// Build the persisted project-local install state from a completed install.
1418fn build_install_state(
1419    options: &InstallRootOptions,
1420    workspace_root: &Path,
1421    icp_root: &Path,
1422    config_path: &Path,
1423    release_set_manifest_path: &Path,
1424    fleet_name: &str,
1425    root_canister_id: &str,
1426) -> Result<InstallState, Box<dyn std::error::Error>> {
1427    Ok(InstallState {
1428        schema_version: INSTALL_STATE_SCHEMA_VERSION,
1429        fleet: fleet_name.to_string(),
1430        installed_at_unix_secs: current_unix_secs()?,
1431        network: options.network.clone(),
1432        root_target: options.root_canister.clone(),
1433        root_canister_id: root_canister_id.to_string(),
1434        root_build_target: options.root_build_target.clone(),
1435        workspace_root: workspace_root.display().to_string(),
1436        icp_root: icp_root.display().to_string(),
1437        config_path: config_path.display().to_string(),
1438        release_set_manifest_path: release_set_manifest_path.display().to_string(),
1439    })
1440}
1441
1442// Resolve the installed root id, accepting principal targets without a icp lookup.
1443fn resolve_root_canister_id(
1444    icp_root: &Path,
1445    network: &str,
1446    root_canister: &str,
1447) -> Result<String, Box<dyn std::error::Error>> {
1448    if Principal::from_text(root_canister).is_ok() {
1449        return Ok(root_canister.to_string());
1450    }
1451
1452    let mut command = icp_canister_command_in_network(icp_root);
1453    command.args(["status", root_canister, "--json"]);
1454    add_icp_environment_target(&mut command, network);
1455    let output = run_command_stdout(&mut command)?;
1456    parse_created_canister_id(&output).ok_or_else(|| {
1457        format!("could not parse root canister id from ICP status JSON output: {output}").into()
1458    })
1459}
1460
1461// Read the current host clock as a unix timestamp for install state.
1462fn current_unix_secs() -> Result<u64, Box<dyn std::error::Error>> {
1463    Ok(SystemTime::now().duration_since(UNIX_EPOCH)?.as_secs())
1464}
1465
1466fn current_unix_timestamp_label() -> Result<String, Box<dyn std::error::Error>> {
1467    Ok(format!("unix:{}", current_unix_secs()?))
1468}
1469
1470// Build each configured local install target through the host builder.
1471fn run_canic_build_targets(
1472    network: &str,
1473    targets: &[String],
1474    build_profile: Option<CanisterBuildProfile>,
1475    config_path: &Path,
1476    icp_root: &Path,
1477) -> Result<(), Box<dyn std::error::Error>> {
1478    let _env = BuildEnvGuard::apply(network, config_path, icp_root);
1479    let profile = build_profile.unwrap_or_else(CanisterBuildProfile::current);
1480    if let Some(context) = current_workspace_build_context_once(profile)? {
1481        for line in context.lines() {
1482            println!("{line}");
1483        }
1484        println!("config: {}", config_path.display());
1485        println!(
1486            "artifacts: {}",
1487            planned_build_artifact_root(icp_root).display()
1488        );
1489        println!();
1490    }
1491
1492    fs::create_dir_all(planned_build_artifact_root(icp_root))?;
1493    println!("Building {} canisters", targets.len());
1494    println!();
1495    let headers = ["CANISTER", "PROGRESS", "WASM", "ELAPSED"];
1496    let planned_rows = targets
1497        .iter()
1498        .map(|target| {
1499            [
1500                target.clone(),
1501                progress_bar(targets.len(), targets.len(), 10),
1502                "000.00 MiB (gz 000.00 MiB)".to_string(),
1503                "0.00s".to_string(),
1504            ]
1505        })
1506        .collect::<Vec<_>>();
1507    let alignments = [
1508        ColumnAlign::Left,
1509        ColumnAlign::Left,
1510        ColumnAlign::Right,
1511        ColumnAlign::Right,
1512    ];
1513    let widths = table_widths(&headers, &planned_rows);
1514    println!("{}", render_table_row(&headers, &widths, &alignments));
1515    println!("{}", render_separator(&widths));
1516
1517    for (index, target) in targets.iter().enumerate() {
1518        let started_at = Instant::now();
1519        let output = build_current_workspace_canister_artifact(target, profile)
1520            .map_err(|err| format!("artifact build failed for {target}: {err}"))?;
1521        let elapsed = started_at.elapsed();
1522        let artifact_size = wasm_artifact_size(&output.wasm_path, &output.wasm_gz_path)?;
1523
1524        let row = [
1525            target.clone(),
1526            progress_bar(index + 1, targets.len(), 10),
1527            artifact_size,
1528            format!("{:.2}s", elapsed.as_secs_f64()),
1529        ];
1530        println!("{}", render_table_row(&row, &widths, &alignments));
1531    }
1532
1533    println!();
1534    Ok(())
1535}
1536
1537fn planned_build_artifact_root(icp_root: &Path) -> PathBuf {
1538    icp_root.join(".icp/local/canisters")
1539}
1540
1541fn wasm_artifact_size(
1542    wasm_path: &Path,
1543    wasm_gz_path: &Path,
1544) -> Result<String, Box<dyn std::error::Error>> {
1545    let wasm_bytes = Some(std::fs::metadata(wasm_path)?.len());
1546    let gzip_bytes = std::fs::metadata(wasm_gz_path)
1547        .ok()
1548        .map(|metadata| metadata.len());
1549    Ok(wasm_size_label(wasm_bytes, gzip_bytes))
1550}
1551
1552struct BuildEnvGuard {
1553    previous_network: Option<OsString>,
1554    previous_config_path: Option<OsString>,
1555    previous_icp_root: Option<OsString>,
1556    previous_local_network_url: Option<OsString>,
1557    previous_local_root_key: Option<OsString>,
1558}
1559
1560impl BuildEnvGuard {
1561    fn apply(network: &str, config_path: &Path, icp_root: &Path) -> Self {
1562        let guard = Self {
1563            previous_network: env::var_os("ICP_ENVIRONMENT"),
1564            previous_config_path: env::var_os("CANIC_CONFIG_PATH"),
1565            previous_icp_root: env::var_os("CANIC_ICP_ROOT"),
1566            previous_local_network_url: env::var_os(CANIC_ICP_LOCAL_NETWORK_URL_ENV),
1567            previous_local_root_key: env::var_os(CANIC_ICP_LOCAL_ROOT_KEY_ENV),
1568        };
1569        set_env("ICP_ENVIRONMENT", network);
1570        set_env("CANIC_CONFIG_PATH", config_path);
1571        set_env("CANIC_ICP_ROOT", icp_root);
1572        if let Some(target) = local_replica_icp_target(network, icp_root) {
1573            set_env(CANIC_ICP_LOCAL_NETWORK_URL_ENV, target.url);
1574            set_env(CANIC_ICP_LOCAL_ROOT_KEY_ENV, target.root_key);
1575        } else {
1576            remove_env(CANIC_ICP_LOCAL_NETWORK_URL_ENV);
1577            remove_env(CANIC_ICP_LOCAL_ROOT_KEY_ENV);
1578        }
1579        guard
1580    }
1581}
1582
1583impl Drop for BuildEnvGuard {
1584    fn drop(&mut self) {
1585        restore_env("ICP_ENVIRONMENT", self.previous_network.take());
1586        restore_env("CANIC_CONFIG_PATH", self.previous_config_path.take());
1587        restore_env("CANIC_ICP_ROOT", self.previous_icp_root.take());
1588        restore_env(
1589            CANIC_ICP_LOCAL_NETWORK_URL_ENV,
1590            self.previous_local_network_url.take(),
1591        );
1592        restore_env(
1593            CANIC_ICP_LOCAL_ROOT_KEY_ENV,
1594            self.previous_local_root_key.take(),
1595        );
1596    }
1597}
1598
1599struct LocalReplicaIcpTarget {
1600    url: String,
1601    root_key: String,
1602}
1603
1604fn local_replica_icp_target(network: &str, icp_root: &Path) -> Option<LocalReplicaIcpTarget> {
1605    if !replica_query::should_use_local_replica_query(Some(network)) {
1606        return None;
1607    }
1608    if icp_ping(icp_root, network).unwrap_or(false) {
1609        return None;
1610    }
1611    let root_key = replica_query::local_replica_root_key_from_root(Some(network), icp_root)
1612        .ok()
1613        .flatten()?;
1614    Some(LocalReplicaIcpTarget {
1615        url: replica_query::local_replica_endpoint_from_root(Some(network), icp_root),
1616        root_key,
1617    })
1618}
1619
1620fn set_env<K, V>(key: K, value: V)
1621where
1622    K: AsRef<std::ffi::OsStr>,
1623    V: AsRef<std::ffi::OsStr>,
1624{
1625    // Install builds are single-threaded host orchestration. The environment is
1626    // scoped by BuildEnvGuard so Cargo build scripts see the selected fleet.
1627    unsafe {
1628        env::set_var(key, value);
1629    }
1630}
1631
1632fn remove_env<K>(key: K)
1633where
1634    K: AsRef<std::ffi::OsStr>,
1635{
1636    // Install builds are single-threaded host orchestration. The environment is
1637    // scoped by BuildEnvGuard so Cargo build scripts see the selected fleet.
1638    unsafe {
1639        env::remove_var(key);
1640    }
1641}
1642
1643fn restore_env(key: &str, value: Option<OsString>) {
1644    // See set_env: this restores the single-threaded install build context.
1645    if let Some(value) = value {
1646        set_env(key, value);
1647    } else {
1648        remove_env(key);
1649    }
1650}
1651
1652fn add_local_root_create_cycles_arg(
1653    command: &mut Command,
1654    config_path: &Path,
1655    network: &str,
1656) -> Result<(), Box<dyn std::error::Error>> {
1657    if network != "local" {
1658        return Ok(());
1659    }
1660
1661    let cycles = configured_local_root_create_cycles(config_path)?;
1662    command.args(["--cycles", &cycles.to_string()]);
1663    Ok(())
1664}
1665
1666fn ensure_local_root_min_cycles(
1667    icp_root: &Path,
1668    network: &str,
1669    root_canister: &str,
1670    phase: &str,
1671) -> Result<(), Box<dyn std::error::Error>> {
1672    if network != "local" {
1673        return Ok(());
1674    }
1675
1676    let current = query_root_cycle_balance(network, root_canister)?;
1677    if current >= LOCAL_ROOT_MIN_READY_CYCLES {
1678        return Ok(());
1679    }
1680
1681    let amount = LOCAL_ROOT_MIN_READY_CYCLES.saturating_sub(current);
1682    let mut command = icp_canister_command_in_network(icp_root);
1683    command
1684        .args(["top-up", "--amount"])
1685        .arg(amount.to_string())
1686        .arg(root_canister);
1687    add_icp_environment_target(&mut command, network);
1688    run_command(&mut command)?;
1689    println!(
1690        "Local root cycles ({phase}): topped up {} ({} -> {} target)",
1691        crate::format::cycles_tc(amount),
1692        crate::format::cycles_tc(current),
1693        crate::format::cycles_tc(LOCAL_ROOT_MIN_READY_CYCLES)
1694    );
1695    Ok(())
1696}
1697
1698fn query_root_cycle_balance(
1699    network: &str,
1700    root_canister: &str,
1701) -> Result<u128, Box<dyn std::error::Error>> {
1702    let output = icp_query_on_network(
1703        network,
1704        root_canister,
1705        protocol::CANIC_CYCLE_BALANCE,
1706        None,
1707        Some("json"),
1708    )?;
1709    parse_cycle_balance_response(&output).ok_or_else(|| {
1710        format!(
1711            "could not parse {root_canister} {} response: {output}",
1712            protocol::CANIC_CYCLE_BALANCE
1713        )
1714        .into()
1715    })
1716}
1717
1718fn progress_bar(current: usize, total: usize, width: usize) -> String {
1719    if total == 0 || width == 0 {
1720        return "[] 0/0".to_string();
1721    }
1722
1723    let filled = current.saturating_mul(width).div_ceil(total);
1724    let filled = filled.min(width);
1725    format!(
1726        "[{}{}] {current}/{total}",
1727        "#".repeat(filled),
1728        " ".repeat(width - filled)
1729    )
1730}
1731
1732// Ensure the requested replica is reachable before the local install flow begins.
1733fn ensure_icp_environment_ready(
1734    icp_root: &Path,
1735    network: &str,
1736) -> Result<(), Box<dyn std::error::Error>> {
1737    if icp_ping(icp_root, network)? {
1738        return Ok(());
1739    }
1740    if replica_query::should_use_local_replica_query(Some(network))
1741        && replica_query::local_replica_status_reachable_from_root(Some(network), icp_root)
1742    {
1743        println!(
1744            "Replica reachable via HTTP status endpoint even though ICP CLI reports network '{network}' stopped; continuing from ICP root {}.",
1745            icp_root.display()
1746        );
1747        return Ok(());
1748    }
1749
1750    Err(format!(
1751        "icp environment is not running for network '{network}'\nStart the target replica in another terminal with `canic replica start` and rerun."
1752    )
1753    .into())
1754}
1755
1756// Check whether `icp network ping <network>` currently succeeds.
1757fn icp_ping(icp_root: &Path, network: &str) -> Result<bool, Box<dyn std::error::Error>> {
1758    Ok(icp::default_command_in(icp_root)
1759        .args(["network", "ping", network])
1760        .output()?
1761        .status
1762        .success())
1763}
1764
1765fn print_install_timing_summary(timings: &InstallTimingSummary, total: Duration) {
1766    println!("Install timing summary:");
1767    println!("{}", render_install_timing_summary(timings, total));
1768}
1769
1770fn render_install_timing_summary(timings: &InstallTimingSummary, total: Duration) -> String {
1771    let rows = [
1772        timing_row("create_canisters", timings.create_canisters),
1773        timing_row("build_all", timings.build_all),
1774        timing_row("emit_manifest", timings.emit_manifest),
1775        timing_row("install_root", timings.install_root),
1776        timing_row("fund_root", timings.fund_root),
1777        timing_row("stage_release_set", timings.stage_release_set),
1778        timing_row("resume_bootstrap", timings.resume_bootstrap),
1779        timing_row("wait_ready", timings.wait_ready),
1780        timing_row("finalize_root_funding", timings.finalize_root_funding),
1781        timing_row("total", total),
1782    ];
1783    render_table(
1784        &["PHASE", "ELAPSED"],
1785        &rows,
1786        &[ColumnAlign::Left, ColumnAlign::Right],
1787    )
1788}
1789
1790fn timing_row(label: &str, duration: Duration) -> [String; 2] {
1791    [label.to_string(), format!("{:.2}s", duration.as_secs_f64())]
1792}
1793
1794// Print the final install result as a compact whitespace table.
1795fn print_install_result_summary(network: &str, fleet: &str, state_path: &Path) {
1796    println!("Install result:");
1797    println!("{:<14} success", "status");
1798    println!("{:<14} {}", "fleet", fleet);
1799    println!("{:<14} {}", "install_state", state_path.display());
1800    println!(
1801        "{:<14} canic list {} --network {}",
1802        "smoke_check", fleet, network
1803    );
1804}
1805
1806// Run one command and require a zero exit status.
1807fn run_command(command: &mut Command) -> Result<(), Box<dyn std::error::Error>> {
1808    icp::run_status(command).map_err(Into::into)
1809}
1810
1811// Run one command, require success, and return stdout.
1812fn run_command_stdout(command: &mut Command) -> Result<String, Box<dyn std::error::Error>> {
1813    icp::run_output(command).map_err(Into::into)
1814}
1815
1816// Build an icp command with the selected install environment exported
1817// for Rust build scripts that inspect ICP_ENVIRONMENT at compile time.
1818fn icp_command_on_network(network: &str) -> Command {
1819    let mut command = icp::default_command();
1820    command.env("ICP_ENVIRONMENT", network);
1821    command
1822}
1823
1824// Build an icp command in one project directory with ICP_ENVIRONMENT applied.
1825fn icp_command_in_network(icp_root: &Path, network: &str) -> Command {
1826    let mut command = icp::default_command_in(icp_root);
1827    command.env("ICP_ENVIRONMENT", network);
1828    command
1829}
1830
1831// Build an icp canister command in one project directory.
1832fn icp_canister_command_in_network(icp_root: &Path) -> Command {
1833    let mut command = icp::default_command_in(icp_root);
1834    command.arg("canister");
1835    command
1836}
1837
1838fn add_icp_environment_target(command: &mut Command, network: &str) {
1839    icp::add_target_args(command, Some(network), None);
1840}