Skip to main content

canic_host/deployment_truth/
plan.rs

1use super::*;
2use crate::{
3    fleet_catalog::{FleetCatalogEntryV1, read_fleet_catalog_entry_from_root},
4    network::resolve_canonical_network_id_from_root,
5    release_set::{AppConfigSnapshot, ConfiguredPoolExpectation, artifact_root_path},
6};
7use canic_core::ids::CanonicalNetworkId;
8use std::path::{Path, PathBuf};
9
10///
11/// LocalDeploymentPlanRequest
12///
13#[derive(Clone, Debug, Eq, PartialEq)]
14pub struct LocalDeploymentPlanRequest {
15    pub fleet_name: String,
16    pub app: String,
17    pub environment: String,
18    pub artifact_environment: String,
19    pub workspace_root: PathBuf,
20    pub icp_root: PathBuf,
21    pub config_path: Option<PathBuf>,
22    pub runtime_variant: String,
23    pub build_profile: String,
24}
25
26/// Build a local deployment plan from resolved host config and local artifact
27/// observations without querying or mutating IC state.
28#[must_use]
29pub fn build_local_deployment_plan(request: &LocalDeploymentPlanRequest) -> DeploymentPlanV1 {
30    let artifact_root = artifact_root_path(&request.icp_root, &request.artifact_environment);
31    build_local_deployment_plan_at_root(request, &artifact_root)
32}
33
34pub fn build_local_deployment_plan_at_root(
35    request: &LocalDeploymentPlanRequest,
36    artifact_root: &Path,
37) -> DeploymentPlanV1 {
38    let config = deployment_config_path(&request.workspace_root, request.config_path.as_deref());
39    let mut unresolved_assumptions = Vec::new();
40    let (roles, expected_pool) = match AppConfigSnapshot::load(&config) {
41        Ok(snapshot) => {
42            if snapshot.app_id() != request.app {
43                unresolved_assumptions.push(assumption(
44                    "local_config.app",
45                    format!(
46                        "{} declares App {}, not requested App {}",
47                        config.display(),
48                        snapshot.app_id(),
49                        request.app
50                    ),
51                ));
52            }
53            (
54                deployment_truth_roles_with_built_in_infrastructure(snapshot.bootstrap_roles()),
55                local_expected_pool(snapshot.pool_expectations()),
56            )
57        }
58        Err(err) => {
59            for (code, subject) in [
60                ("local_config.app", "App identity"),
61                ("local_config.roles", "configured roles"),
62                ("local_config.pools", "configured pool expectations"),
63            ] {
64                unresolved_assumptions.push(assumption(
65                    code,
66                    format!(
67                        "could not resolve {subject} from {}: {err}",
68                        config.display()
69                    ),
70                ));
71            }
72            (Vec::new(), Vec::new())
73        }
74    };
75    let resolved_fleet = local_fleet_identity(request, &mut unresolved_assumptions);
76    // The Coordinator-anchored Fleet catalog no longer identifies one
77    // deployment root. Root-specific deployment truth must come from the
78    // multi-root Registry rather than discovery metadata.
79    let root_canister_id = None;
80    let raw_config_sha256 = config_sha256_assumption(&config, &mut unresolved_assumptions);
81    let canonical_runtime_config_digest =
82        canonical_runtime_config_assumption(&config, &mut unresolved_assumptions);
83    let deployment_manifest_digest =
84        deployment_manifest_digest_assumption(request, artifact_root, &mut unresolved_assumptions);
85    let artifact_manifest = local_artifact_manifest(request, artifact_root, config);
86    extend_artifact_assumptions(
87        &mut unresolved_assumptions,
88        artifact_manifest.unresolved_artifacts,
89    );
90    let authority_profile = local_authority_profile(request);
91    let role_artifacts = local_plan_role_artifacts(
92        artifact_manifest.role_artifacts,
93        &request.build_profile,
94        raw_config_sha256.as_ref(),
95    );
96    let expected_canisters = local_expected_canisters(roles, root_canister_id.as_deref());
97    let identity = local_plan_identity(
98        request,
99        PlanIdentityFacts {
100            root_canister_id: root_canister_id.clone(),
101            resolved_fleet: &resolved_fleet,
102            deployment_manifest_digest,
103            canonical_runtime_config_digest,
104            authority_profile: &authority_profile,
105            expected_canisters: &expected_canisters,
106            role_artifacts: &role_artifacts,
107            expected_pool: &expected_pool,
108        },
109    );
110    DeploymentPlanV1 {
111        schema_version: DEPLOYMENT_TRUTH_SCHEMA_VERSION,
112        plan_id: local_plan_id(request),
113        deployment_identity: identity,
114        trust_domain: TrustDomainV1 {
115            root_trust_anchor: root_canister_id,
116        },
117        runtime_variant: request.runtime_variant.clone(),
118        authority_profile,
119        role_artifacts,
120        expected_canisters,
121        expected_pool,
122        expected_verifier_readiness: VerifierReadinessExpectationV1 {
123            required: false,
124            expected_role_epochs: Vec::new(),
125        },
126        unresolved_assumptions,
127    }
128}
129
130fn local_plan_id(request: &LocalDeploymentPlanRequest) -> String {
131    format!("local:{}:{}:plan", request.environment, request.fleet_name)
132}
133
134struct PlanIdentityFacts<'a> {
135    root_canister_id: Option<String>,
136    resolved_fleet: &'a ResolvedPlanFleet,
137    deployment_manifest_digest: Option<String>,
138    canonical_runtime_config_digest: Option<String>,
139    authority_profile: &'a AuthorityProfileV1,
140    expected_canisters: &'a [ExpectedCanisterV1],
141    role_artifacts: &'a [RoleArtifactV1],
142    expected_pool: &'a [ExpectedPoolCanisterV1],
143}
144
145fn local_artifact_manifest(
146    request: &LocalDeploymentPlanRequest,
147    artifact_root: &Path,
148    config: PathBuf,
149) -> RoleArtifactManifestV1 {
150    collect_local_role_artifact_manifest_at_root(
151        &LocalArtifactManifestRequest {
152            environment: request.environment.clone(),
153            artifact_environment: request.artifact_environment.clone(),
154            workspace_root: request.workspace_root.clone(),
155            icp_root: request.icp_root.clone(),
156            config_path: Some(config),
157        },
158        artifact_root,
159    )
160}
161
162fn local_plan_identity(
163    request: &LocalDeploymentPlanRequest,
164    facts: PlanIdentityFacts<'_>,
165) -> DeploymentIdentityV1 {
166    local_deployment_identity(
167        request,
168        PlanIdentityInput {
169            root_canister_id: facts.root_canister_id,
170            canonical_network_id: facts.resolved_fleet.canonical_network_id,
171            fleet_id: facts
172                .resolved_fleet
173                .catalog
174                .as_ref()
175                .map(|fleet| fleet.fleet_id),
176            deployment_manifest_digest: facts.deployment_manifest_digest,
177            canonical_runtime_config_digest: facts.canonical_runtime_config_digest,
178            authority_profile_hash: Some(stable_json_sha256_hex(facts.authority_profile)),
179            role_topology_hash: Some(stable_json_sha256_hex(&facts.expected_canisters)),
180            artifact_set_digest: Some(stable_json_sha256_hex(&facts.role_artifacts)),
181            pool_identity_set_digest: Some(stable_json_sha256_hex(&facts.expected_pool)),
182        },
183    )
184}
185
186struct ResolvedPlanFleet {
187    canonical_network_id: Option<CanonicalNetworkId>,
188    catalog: Option<FleetCatalogEntryV1>,
189}
190
191fn local_fleet_identity(
192    request: &LocalDeploymentPlanRequest,
193    assumptions: &mut Vec<DeploymentAssumptionV1>,
194) -> ResolvedPlanFleet {
195    let canonical_network_id =
196        match resolve_canonical_network_id_from_root(&request.icp_root, &request.environment) {
197            Ok(network) => Some(network),
198            Err(error) => {
199                assumptions.push(assumption(
200                    DeploymentAssumptionKindV1::FleetCatalogReadFailed.key(),
201                    format!(
202                        "could not resolve canonical network identity for {}: {error}",
203                        request.environment
204                    ),
205                ));
206                None
207            }
208        };
209    let catalog = if canonical_network_id.is_none() {
210        None
211    } else {
212        match read_fleet_catalog_entry_from_root(
213            &request.icp_root,
214            &request.environment,
215            &request.fleet_name,
216        ) {
217            Ok(Some(fleet)) => Some(fleet),
218            Ok(None) => {
219                assumptions.push(assumption(
220                DeploymentAssumptionKindV1::FleetCatalogMissing.key(),
221                format!(
222                    "no installed Fleet catalog entry exists for {}; Coordinator identity is unknown until installation completes",
223                    request.fleet_name
224                ),
225            ));
226                None
227            }
228            Err(error) => {
229                assumptions.push(assumption(
230                    DeploymentAssumptionKindV1::FleetCatalogReadFailed.key(),
231                    format!(
232                        "could not read Fleet catalog for {}: {error}",
233                        request.fleet_name,
234                    ),
235                ));
236                None
237            }
238        }
239    };
240    ResolvedPlanFleet {
241        canonical_network_id,
242        catalog,
243    }
244}
245
246struct PlanIdentityInput {
247    canonical_network_id: Option<CanonicalNetworkId>,
248    fleet_id: Option<canic_core::ids::FleetId>,
249    root_canister_id: Option<String>,
250    deployment_manifest_digest: Option<String>,
251    canonical_runtime_config_digest: Option<String>,
252    authority_profile_hash: Option<String>,
253    role_topology_hash: Option<String>,
254    artifact_set_digest: Option<String>,
255    pool_identity_set_digest: Option<String>,
256}
257
258fn local_deployment_identity(
259    request: &LocalDeploymentPlanRequest,
260    input: PlanIdentityInput,
261) -> DeploymentIdentityV1 {
262    DeploymentIdentityV1 {
263        canonical_network_id: input.canonical_network_id,
264        fleet_id: input.fleet_id,
265        fleet_name: request.fleet_name.clone(),
266        app: request.app.clone(),
267        environment: request.environment.clone(),
268        root_principal: input.root_canister_id,
269        authority_profile_hash: input.authority_profile_hash,
270        role_topology_hash: input.role_topology_hash,
271        deployment_manifest_digest: input.deployment_manifest_digest,
272        canonical_runtime_config_digest: input.canonical_runtime_config_digest,
273        role_embedded_config_set_digest: None,
274        artifact_set_digest: input.artifact_set_digest,
275        pool_identity_set_digest: input.pool_identity_set_digest,
276        canic_version: Some(env!("CARGO_PKG_VERSION").to_string()),
277        ic_memory_version: None,
278    }
279}
280
281fn local_authority_profile(request: &LocalDeploymentPlanRequest) -> AuthorityProfileV1 {
282    // One App-level controller set cannot represent the role-specific authority
283    // retained by Coordinator/root/Store install journals and verified live.
284    AuthorityProfileV1 {
285        profile_id: format!(
286            "local:{}:{}:authority",
287            request.environment, request.fleet_name
288        ),
289        expected_controllers: Vec::new(),
290        staging_controllers: Vec::new(),
291        emergency_controllers: Vec::new(),
292    }
293}
294
295fn local_expected_canisters(
296    roles: Vec<String>,
297    root_canister_id: Option<&str>,
298) -> Vec<ExpectedCanisterV1> {
299    roles
300        .into_iter()
301        .map(|role| ExpectedCanisterV1 {
302            canister_id: if role == "root" {
303                root_canister_id.map(str::to_string)
304            } else {
305                None
306            },
307            role,
308            control_class: CanisterControlClassV1::DeploymentControlled,
309        })
310        .collect()
311}
312
313fn local_expected_pool(pools: Vec<ConfiguredPoolExpectation>) -> Vec<ExpectedPoolCanisterV1> {
314    pools
315        .into_iter()
316        .map(|pool| ExpectedPoolCanisterV1 {
317            pool: pool.pool,
318            canister_id: None,
319            role: Some(pool.canister_role),
320        })
321        .collect()
322}
323
324fn local_plan_role_artifacts(
325    artifacts: Vec<RoleArtifactV1>,
326    build_profile: &str,
327    raw_config_sha256: Option<&String>,
328) -> Vec<RoleArtifactV1> {
329    artifacts
330        .into_iter()
331        .map(|mut artifact| {
332            artifact.build_profile = build_profile.to_string();
333            artifact.raw_config_sha256 = raw_config_sha256.cloned();
334            artifact
335        })
336        .collect()
337}
338
339fn extend_artifact_assumptions(
340    assumptions: &mut Vec<DeploymentAssumptionV1>,
341    gaps: Vec<DeploymentObservationGapV1>,
342) {
343    assumptions.extend(
344        gaps.into_iter()
345            .map(|gap| assumption(gap.key, gap.description)),
346    );
347}
348
349fn assumption(key: impl Into<String>, description: impl Into<String>) -> DeploymentAssumptionV1 {
350    DeploymentAssumptionV1 {
351        key: key.into(),
352        description: description.into(),
353    }
354}
355
356fn config_sha256_assumption(
357    path: &std::path::Path,
358    assumptions: &mut Vec<DeploymentAssumptionV1>,
359) -> Option<String> {
360    match file_sha256_hex(path) {
361        Ok(hash) => Some(hash),
362        Err(err) => {
363            assumptions.push(assumption(
364                "local_config.raw_sha256",
365                format!("could not hash config {}: {err}", path.display()),
366            ));
367            None
368        }
369    }
370}
371
372fn canonical_runtime_config_assumption(
373    path: &std::path::Path,
374    assumptions: &mut Vec<DeploymentAssumptionV1>,
375) -> Option<String> {
376    match canonical_runtime_config_sha256_hex(path) {
377        Ok(hash) => Some(hash),
378        Err(err) => {
379            assumptions.push(assumption(
380                "local_config.canonical_runtime_config_sha256",
381                format!(
382                    "could not hash canonical runtime config {}: {err}",
383                    path.display()
384                ),
385            ));
386            None
387        }
388    }
389}
390
391fn deployment_manifest_digest_assumption(
392    request: &LocalDeploymentPlanRequest,
393    artifact_root: &Path,
394    assumptions: &mut Vec<DeploymentAssumptionV1>,
395) -> Option<String> {
396    let mut gaps = Vec::new();
397    let digest =
398        super::observe::release_set_manifest_digest(&request.icp_root, artifact_root, &mut gaps);
399    assumptions.extend(
400        gaps.into_iter()
401            .map(|gap| assumption(gap.key, gap.description)),
402    );
403    digest
404}