Skip to main content

canic_host/deployment_truth/
plan.rs

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