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