Skip to main content

canic_host/install_root/
mod.rs

1use crate::{
2    canister_build::cache::DefaultCanisterBuildCacheCleanup,
3    deployment_truth::DeploymentReceiptV1,
4    fleet_install_input::{ResolvedFleetInstallInput, load_and_resolve_fleet_install_input},
5    fleet_install_plan::{
6        FleetInstallPlanRequest, PersistedFleetInstallPlan, compile_and_persist_fleet_install_plan,
7    },
8    network::resolve_canonical_network_id_from_root,
9    release_set::{AppConfigSnapshot, icp_root, workspace_root},
10};
11use config_selection::resolve_install_config_path;
12use std::{
13    fmt,
14    path::{Path, PathBuf},
15    time::{Duration, Instant},
16};
17use thiserror::Error as ThisError;
18
19mod build_network;
20mod build_snapshot;
21mod build_targets;
22mod capabilities;
23mod clock;
24mod commands;
25mod config_selection;
26mod coordinator_install;
27mod coordinator_install_journal;
28mod current_execution;
29mod deployment_truth_gate;
30mod execution_preflight;
31mod fleet_install_session;
32mod fleet_subnet_root_install;
33mod fleet_subnet_root_install_journal;
34mod identity;
35mod operations;
36mod options;
37mod output;
38mod phase_receipts;
39mod plan_artifacts;
40mod preparation;
41mod receipt_io;
42mod timing;
43mod truth_check;
44
45use crate::release_build::{ReleaseBuildPlanError, plan_release_build};
46use build_network::resolve_install_build_context;
47use build_snapshot::resolve_install_snapshot;
48pub use config_selection::{
49    ConfigDiscoveryError, current_canic_project_root, discover_canic_config_choices,
50    discover_canic_project_root_from, discover_project_canic_config_choices, project_app_roots,
51    select_discovered_app_config_path,
52};
53use coordinator_install::install_and_verify_fleet_coordinator;
54use current_execution::current_install_execution_context;
55use fleet_subnet_root_install::install_and_verify_fleet_subnet_roots;
56use identity::resolve_install_identity;
57pub use options::InstallRootOptions;
58use output::print_install_timing_summary;
59use phase_receipts::{
60    CompletedInstallPhase, InstallReceiptScope, write_completed_install_phase_receipt,
61};
62use plan_artifacts::emit_manifest_with_phase;
63use preparation::prepare_install_deployment_truth;
64pub use receipt_io::latest_deployment_truth_receipt_path_from_root;
65use timing::InstallTimingSummary as CurrentInstallTimingSummary;
66pub use truth_check::{check_install_deployment_truth, check_install_execution_preflight};
67
68#[cfg(test)]
69mod tests;
70
71///
72/// InstallRootBlockKind
73///
74/// Machine-readable reason that a fresh root install stopped before mutation.
75///
76
77#[derive(Clone, Copy, Debug, Eq, PartialEq)]
78pub enum InstallRootBlockKind {
79    DeploymentExecutionPreflight,
80    DeploymentTruth,
81}
82
83///
84/// InstallRootBlockedError
85///
86/// Typed install block retained through the host/CLI error boundary.
87///
88
89#[derive(Debug, ThisError)]
90#[error("{message}")]
91pub struct InstallRootBlockedError {
92    kind: InstallRootBlockKind,
93    message: String,
94}
95
96impl InstallRootBlockedError {
97    pub(super) const fn new(kind: InstallRootBlockKind, message: String) -> Self {
98        Self { kind, message }
99    }
100
101    #[must_use]
102    pub const fn kind(&self) -> InstallRootBlockKind {
103        self.kind
104    }
105}
106
107/// Stable phase in which a root install failed.
108#[derive(Clone, Copy, Debug, Eq, PartialEq)]
109pub enum InstallRootPhase {
110    WorkspaceDiscovery,
111    ProjectDiscovery,
112    Configuration,
113    BuildInputs,
114    Identity,
115    Preparation,
116    Manifest,
117    Planning,
118    Activation,
119}
120
121impl fmt::Display for InstallRootPhase {
122    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
123        formatter.write_str(match self {
124            Self::WorkspaceDiscovery => "workspace discovery",
125            Self::ProjectDiscovery => "ICP project discovery",
126            Self::Configuration => "configuration selection",
127            Self::BuildInputs => "build input validation",
128            Self::Identity => "deployment identity resolution",
129            Self::Preparation => "deployment preparation",
130            Self::Manifest => "manifest emission",
131            Self::Planning => "Fleet installation planning",
132            Self::Activation => "root activation",
133        })
134    }
135}
136
137/// Typed public failure for the root-install workflow.
138#[derive(Debug, ThisError)]
139#[error("root install failed during {phase}: {source}")]
140pub struct InstallRootError {
141    phase: InstallRootPhase,
142    #[source]
143    source: Box<dyn std::error::Error>,
144}
145
146impl InstallRootError {
147    /// Preserve a concrete cause while assigning it to a stable install phase.
148    pub fn new<E>(phase: InstallRootPhase, source: E) -> Self
149    where
150        E: std::error::Error + 'static,
151    {
152        Self {
153            phase,
154            source: Box::new(source),
155        }
156    }
157
158    fn from_boxed(phase: InstallRootPhase, source: Box<dyn std::error::Error>) -> Self {
159        Self { phase, source }
160    }
161
162    fn in_phase(phase: InstallRootPhase) -> impl FnOnce(Box<dyn std::error::Error>) -> Self {
163        move |source| Self::from_boxed(phase, source)
164    }
165
166    #[must_use]
167    pub const fn phase(&self) -> InstallRootPhase {
168        self.phase
169    }
170}
171
172#[derive(Debug, ThisError)]
173#[error(
174    "Fleet Coordinator {coordinator} and {verified_roots} planned Fleet Subnet Root(s) are installed and independently verified from the durable plan at {}; local Wasm Store bootstrap and Fleet Registry registration remain blocked until their journalled lifecycle is implemented",
175    plan_path.display(),
176)]
177struct FleetRootBootstrapUnavailableError {
178    plan_path: PathBuf,
179    coordinator: canic_core::cdk::types::Principal,
180    verified_roots: usize,
181}
182
183#[derive(Debug, ThisError)]
184#[error("fresh Fleet installation requires --fleet-input <PATH>")]
185struct MissingFleetInstallInputError;
186
187/// Discover installable Canic config choices under the current workspace.
188pub fn discover_current_canic_config_choices() -> Result<Vec<PathBuf>, ConfigDiscoveryError> {
189    let project_root = current_canic_project_root()?;
190    let choices = config_selection::discover_workspace_canic_config_choices(&project_root)?;
191    if !choices.is_empty() {
192        return Ok(choices);
193    }
194
195    let icp_root = icp_root()?;
196    if icp_root != project_root {
197        return config_selection::discover_workspace_canic_config_choices(&icp_root);
198    }
199
200    Ok(choices)
201}
202
203// Execute fresh Fleet planning and the Coordinator-first installation workflow.
204pub fn install_root(options: InstallRootOptions) -> Result<(), InstallRootError> {
205    let (workspace_root, icp_root) = resolve_current_install_roots(&options)?;
206    let _build_cache_cleanup = DefaultCanisterBuildCacheCleanup::for_install(&workspace_root);
207    let config_path = current_install_config_path(&icp_root, &options)?;
208    let (build_context, install_snapshot) =
209        current_install_build_inputs(&workspace_root, &icp_root, &config_path, &options)
210            .map_err(InstallRootError::in_phase(InstallRootPhase::BuildInputs))?;
211    let (app_id, fleet_name) =
212        resolve_install_identity(&options, &config_path, &install_snapshot.app_id)
213            .map_err(InstallRootError::in_phase(InstallRootPhase::Identity))?;
214    let total_started_at = Instant::now();
215    let mut timings = CurrentInstallTimingSummary::default();
216    let environment = options.environment.as_str();
217    let execution_context = current_install_execution_context(
218        &workspace_root,
219        &icp_root,
220        options.artifact_environment(),
221    );
222    let resolved_fleet_install_input =
223        resolve_current_fleet_install_input(&icp_root, environment, &options)
224            .map_err(InstallRootError::in_phase(InstallRootPhase::Planning))?;
225
226    print_install_identity(&app_id, &fleet_name);
227    let prepared = prepare_install_deployment_truth(
228        &options,
229        &icp_root,
230        &config_path,
231        &fleet_name,
232        &execution_context,
233        &build_context,
234        &install_snapshot,
235    )
236    .map_err(InstallRootError::in_phase(InstallRootPhase::Preparation))?;
237    timings.build_all = prepared.timings.build_all;
238    let emitted_manifest = emit_manifest_with_phase(
239        &icp_root,
240        &install_snapshot,
241        &prepared.build_outputs,
242        &prepared.infrastructure_build_outputs,
243        prepared.plan_artifacts.as_ref(),
244    )
245    .map_err(InstallRootError::in_phase(InstallRootPhase::Manifest))?;
246    timings.emit_manifest = emitted_manifest.duration;
247    let finalized_release_build =
248        require_finalized_release_build(emitted_manifest.finalized_release_build)?;
249    let planned_install = plan_current_fleet_install(
250        &icp_root,
251        environment,
252        &fleet_name,
253        &app_id,
254        &config_path,
255        &finalized_release_build,
256        resolved_fleet_install_input,
257    )?;
258    let receipt_scope = InstallReceiptScope {
259        icp_root: &icp_root,
260        fleet: planned_install.fleet(),
261        check: &prepared.deployment_truth_check,
262        execution_context: Some(&execution_context),
263    };
264    persist_current_pre_root_receipts(
265        receipt_scope,
266        &prepared.pre_activation_receipts,
267        prepared.build_phase,
268        emitted_manifest.phase,
269    )?;
270    let (coordinator, coordinator_duration) = install_current_fleet_coordinator(
271        &icp_root,
272        environment,
273        build_context.local_replica.as_ref(),
274        &config_path,
275        &planned_install.plan,
276    )?;
277    timings.create_canisters = coordinator_duration;
278    let (roots, roots_duration) = install_current_fleet_subnet_roots(
279        &icp_root,
280        environment,
281        build_context.local_replica.as_ref(),
282        &config_path,
283        &planned_install,
284        coordinator.coordinator,
285    )?;
286    timings.create_canisters += roots_duration;
287    require_fleet_subnet_root_bootstrap(
288        &planned_install.plan.path,
289        coordinator.coordinator,
290        roots.roots.len(),
291    )
292    .map_err(|source| InstallRootError::new(InstallRootPhase::Activation, source))?;
293
294    print_install_timing_summary(&timings, total_started_at.elapsed());
295    Ok(())
296}
297
298fn resolve_current_install_roots(
299    options: &InstallRootOptions,
300) -> Result<(PathBuf, PathBuf), InstallRootError> {
301    let workspace_root = workspace_root()
302        .map_err(|source| InstallRootError::new(InstallRootPhase::WorkspaceDiscovery, source))?;
303    let icp_root = match &options.icp_root {
304        Some(path) => path
305            .canonicalize()
306            .map_err(|source| InstallRootError::new(InstallRootPhase::ProjectDiscovery, source))?,
307        None => icp_root()
308            .map_err(|source| InstallRootError::new(InstallRootPhase::ProjectDiscovery, source))?,
309    };
310    Ok((workspace_root, icp_root))
311}
312
313fn plan_current_fleet_install(
314    icp_root: &Path,
315    environment: &str,
316    fleet_name: &str,
317    app_id: &str,
318    config_path: &Path,
319    finalized_release_build: &crate::release_build::FinalizedReleaseBuild,
320    input: ResolvedFleetInstallInput,
321) -> Result<PlannedCurrentFleetInstall, InstallRootError> {
322    let session = plan_current_fleet_install_session(
323        icp_root,
324        environment,
325        fleet_name,
326        app_id,
327        finalized_release_build,
328    )?;
329    let plan = persist_current_fleet_install_plan(
330        icp_root,
331        config_path,
332        session.fleet.clone(),
333        finalized_release_build,
334        input,
335    )
336    .map_err(InstallRootError::in_phase(InstallRootPhase::Planning))?;
337    Ok(PlannedCurrentFleetInstall { session, plan })
338}
339
340struct PlannedCurrentFleetInstall {
341    session: fleet_install_session::FleetInstallSession,
342    plan: PersistedFleetInstallPlan,
343}
344
345impl PlannedCurrentFleetInstall {
346    const fn fleet(&self) -> canic_core::ids::FleetKey {
347        self.session.fleet.fleet
348    }
349}
350
351fn require_finalized_release_build(
352    finalized: Option<crate::release_build::FinalizedReleaseBuild>,
353) -> Result<crate::release_build::FinalizedReleaseBuild, InstallRootError> {
354    finalized.ok_or_else(|| {
355        InstallRootError::new(
356            InstallRootPhase::Manifest,
357            ReleaseBuildPlanError::MissingFinalizedAuthority,
358        )
359    })
360}
361
362fn resolve_current_fleet_install_input(
363    icp_root: &Path,
364    environment: &str,
365    options: &InstallRootOptions,
366) -> Result<ResolvedFleetInstallInput, Box<dyn std::error::Error>> {
367    let input_path = options
368        .fleet_install_input_path
369        .as_ref()
370        .ok_or(MissingFleetInstallInputError)?;
371    let input_path = if input_path.is_absolute() {
372        input_path.clone()
373    } else {
374        icp_root.join(input_path)
375    };
376    load_and_resolve_fleet_install_input(icp_root, environment, &input_path).map_err(Into::into)
377}
378
379fn persist_current_fleet_install_plan(
380    icp_root: &Path,
381    config_path: &Path,
382    fleet: canic_core::ids::FleetBinding,
383    finalized_release_build: &crate::release_build::FinalizedReleaseBuild,
384    input: ResolvedFleetInstallInput,
385) -> Result<PersistedFleetInstallPlan, Box<dyn std::error::Error>> {
386    let config = AppConfigSnapshot::load(config_path)?;
387    compile_and_persist_fleet_install_plan(FleetInstallPlanRequest {
388        root: icp_root,
389        config: config.model(),
390        fleet,
391        release_build_id: finalized_release_build.record.release_build_id,
392        coordinator: input.coordinator,
393        fleet_subnet_roots: input.fleet_subnet_roots,
394    })
395    .map_err(Into::into)
396}
397
398fn require_fleet_subnet_root_bootstrap(
399    plan_path: &Path,
400    coordinator: canic_core::cdk::types::Principal,
401    verified_roots: usize,
402) -> Result<(), FleetRootBootstrapUnavailableError> {
403    Err(FleetRootBootstrapUnavailableError {
404        plan_path: plan_path.to_path_buf(),
405        coordinator,
406        verified_roots,
407    })
408}
409
410fn current_install_config_path(
411    icp_root: &Path,
412    options: &InstallRootOptions,
413) -> Result<PathBuf, InstallRootError> {
414    resolve_install_config_path(
415        icp_root,
416        options.config_path.as_deref(),
417        options.interactive_config_selection,
418    )
419    .map_err(InstallRootError::in_phase(InstallRootPhase::Configuration))
420}
421
422fn plan_current_fleet_install_session(
423    icp_root: &Path,
424    environment: &str,
425    fleet_name: &str,
426    app_id: &str,
427    finalized_release_build: &crate::release_build::FinalizedReleaseBuild,
428) -> Result<fleet_install_session::FleetInstallSession, InstallRootError> {
429    let canonical_network_id = resolve_canonical_network_id_from_root(icp_root, environment)
430        .map_err(|source| InstallRootError::new(InstallRootPhase::Activation, source))?;
431    let fleet_name = fleet_name
432        .parse()
433        .map_err(|source| InstallRootError::new(InstallRootPhase::Identity, source))?;
434    fleet_install_session::plan_fleet_install_session(
435        fleet_install_session::PlanFleetInstallSessionRequest {
436            root: icp_root,
437            canonical_network_id,
438            fleet_name,
439            app: app_id.into(),
440            finalized_release_build,
441        },
442    )
443    .map_err(|source| InstallRootError::new(InstallRootPhase::Activation, source))
444}
445
446fn print_install_identity(app: &str, fleet_name: &str) {
447    println!("Installing Fleet {fleet_name}");
448    println!("Source App {app}");
449    println!();
450}
451
452fn persist_current_pre_root_receipts(
453    receipt_scope: InstallReceiptScope<'_>,
454    prepared_receipts: &[DeploymentReceiptV1],
455    build_phase: CompletedInstallPhase,
456    manifest_phase: CompletedInstallPhase,
457) -> Result<(), InstallRootError> {
458    persist_pre_root_receipts(
459        receipt_scope,
460        prepared_receipts,
461        build_phase,
462        manifest_phase,
463    )
464    .map_err(InstallRootError::in_phase(InstallRootPhase::Activation))
465}
466
467fn install_current_fleet_coordinator(
468    icp_root: &Path,
469    environment: &str,
470    local_replica: Option<&crate::icp::LocalReplicaTarget>,
471    config_path: &Path,
472    plan: &PersistedFleetInstallPlan,
473) -> Result<(coordinator_install::VerifiedFleetCoordinator, Duration), InstallRootError> {
474    let started = Instant::now();
475    let coordinator = install_and_verify_fleet_coordinator(
476        icp_root,
477        environment,
478        local_replica,
479        config_path,
480        plan,
481    )
482    .map_err(InstallRootError::in_phase(InstallRootPhase::Activation))?;
483    Ok((coordinator, started.elapsed()))
484}
485
486fn install_current_fleet_subnet_roots(
487    icp_root: &Path,
488    environment: &str,
489    local_replica: Option<&crate::icp::LocalReplicaTarget>,
490    config_path: &Path,
491    planned: &PlannedCurrentFleetInstall,
492    coordinator: canic_core::cdk::types::Principal,
493) -> Result<
494    (
495        fleet_subnet_root_install::VerifiedFleetSubnetRoots,
496        Duration,
497    ),
498    InstallRootError,
499> {
500    let started = Instant::now();
501    let roots = install_and_verify_fleet_subnet_roots(
502        icp_root,
503        environment,
504        local_replica,
505        config_path,
506        &planned.plan,
507        coordinator,
508        planned.session.operation_id,
509    )
510    .map_err(InstallRootError::in_phase(InstallRootPhase::Activation))?;
511    Ok((roots, started.elapsed()))
512}
513
514fn persist_pre_root_receipts(
515    receipt_scope: InstallReceiptScope<'_>,
516    prepared_receipts: &[DeploymentReceiptV1],
517    build_phase: CompletedInstallPhase,
518    manifest_phase: CompletedInstallPhase,
519) -> Result<(), Box<dyn std::error::Error>> {
520    for receipt in prepared_receipts {
521        receipt_scope.write_receipt(receipt)?;
522    }
523    write_completed_install_phase_receipt(receipt_scope, build_phase)?;
524    write_completed_install_phase_receipt(receipt_scope, manifest_phase)?;
525    Ok(())
526}
527
528fn current_install_build_inputs(
529    workspace_root: &std::path::Path,
530    icp_root: &std::path::Path,
531    config_path: &std::path::Path,
532    options: &InstallRootOptions,
533) -> Result<
534    (
535        crate::canister_build::WorkspaceBuildContext,
536        build_snapshot::ValidatedInstallSnapshot,
537    ),
538    Box<dyn std::error::Error>,
539> {
540    let mut context = resolve_install_build_context(
541        workspace_root,
542        icp_root,
543        config_path,
544        &options.environment,
545        &options.root_build_target,
546        options.build_profile,
547    )?;
548    let mut snapshot = resolve_install_snapshot(
549        &context,
550        &options.root_build_target,
551        options.deployment_plan_override.is_some(),
552    )?;
553    if snapshot.complete_build.is_some() {
554        let release_build = plan_release_build(icp_root)?;
555        context = context.with_release_build_id(release_build.record.release_build_id);
556        snapshot.release_build = Some(release_build);
557    }
558    Ok((context, snapshot))
559}