Skip to main content

canic_host/deployment_truth/report/
mod.rs

1use super::*;
2use std::collections::{BTreeMap, BTreeSet};
3
4mod artifacts;
5mod canisters;
6mod config_digests;
7mod controllers;
8mod module_hashes;
9mod pools;
10mod receipt_resume;
11mod root_subnet;
12mod safety;
13mod verifier_readiness;
14
15use artifacts::compare_artifacts;
16#[cfg(test)]
17pub(super) use artifacts::{
18    ARTIFACT_DUPLICATE_DIFF_CATEGORY, ARTIFACT_FILE_DIGEST_MISMATCH_CODE,
19    ARTIFACT_FILE_SHA256_DIFF_CATEGORY, ARTIFACT_MISSING_CODE, ARTIFACT_ROLE_CONFLICT_CODE,
20    ARTIFACT_ROLE_CONFLICT_DIFF_CATEGORY, DUPLICATE_ARTIFACT_OBSERVED_CODE,
21    DUPLICATE_PLANNED_ARTIFACT_ROLE_CODE, PLANNED_ARTIFACT_DUPLICATE_DIFF_CATEGORY,
22    PLANNED_ARTIFACT_ROLE_CONFLICT_CODE, PLANNED_ARTIFACT_ROLE_CONFLICT_DIFF_CATEGORY,
23};
24#[cfg(test)]
25pub(super) use canisters::{
26    CANISTER_DUPLICATE_DIFF_CATEGORY, CANISTER_EXTRA_DIFF_CATEGORY, CANISTER_ID_ROLE_CONFLICT_CODE,
27    CANISTER_ID_ROLE_CONFLICT_DIFF_CATEGORY, CANISTER_ROLE_AMBIGUOUS_CODE,
28    CANISTER_ROLE_AMBIGUOUS_DIFF_CATEGORY, CANISTER_ROLE_MISMATCH_CODE, CANISTER_UNOBSERVED_CODE,
29    DUPLICATE_CANISTER_OBSERVED_CODE, DUPLICATE_PLANNED_CANISTER_ROLE_CODE,
30    EXTRA_CANISTER_OBSERVED_CODE, PLANNED_CANISTER_DUPLICATE_DIFF_CATEGORY,
31    PLANNED_CANISTER_ID_CONFLICT_CODE, PLANNED_CANISTER_ID_CONFLICT_DIFF_CATEGORY,
32    PLANNED_CANISTER_ROLE_CONFLICT_CODE, PLANNED_CANISTER_ROLE_CONFLICT_DIFF_CATEGORY,
33    ROLE_MISMATCH_DIFF_CATEGORY, UNSAFE_CONTROL_CLASS_CODE,
34};
35use canisters::{compare_canisters, compare_observed_canister_id_conflicts};
36#[cfg(test)]
37pub(super) use config_digests::{RAW_CONFIG_DIGEST_MISMATCH_CODE, RAW_CONFIG_SHA256_DIFF_CATEGORY};
38use config_digests::{compare_embedded_config, compare_raw_config};
39use controllers::compare_authority_profile;
40#[cfg(test)]
41pub(super) use controllers::{
42    CONTROLLER_AUTHORITY_OVERLAP_CODE, CONTROLLER_EXTRA_DIFF_CATEGORY,
43    CONTROLLER_MISSING_DIFF_CATEGORY, CONTROLLERS_UNOBSERVED_CODE,
44    EXPECTED_CONTROLLER_MISSING_CODE, EXTRA_CONTROLLER_OBSERVED_CODE,
45};
46use module_hashes::compare_module_hashes;
47#[cfg(test)]
48pub(super) use module_hashes::{
49    INSTALLED_MODULE_HASH_AMBIGUOUS_CODE, INSTALLED_MODULE_HASH_AMBIGUOUS_DIFF_CATEGORY,
50    INSTALLED_MODULE_HASH_DIFF_CATEGORY, INSTALLED_MODULE_HASH_MISMATCH_CODE,
51};
52#[cfg(test)]
53pub(super) use pools::{
54    CANISTER_POOL_ROLE_CONFLICT_CODE, CANISTER_POOL_ROLE_CONFLICT_DIFF_CATEGORY,
55    DUPLICATE_PLANNED_POOL_CODE, DUPLICATE_POOL_CANISTER_OBSERVED_CODE,
56    EXTRA_POOL_CANISTER_OBSERVED_CODE, PLANNED_POOL_CONFLICT_CODE,
57    PLANNED_POOL_CONFLICT_DIFF_CATEGORY, PLANNED_POOL_DUPLICATE_DIFF_CATEGORY,
58    PLANNED_POOL_ID_CONFLICT_CODE, PLANNED_POOL_ID_CONFLICT_DIFF_CATEGORY,
59    POOL_CANISTER_DIFF_CATEGORY, POOL_CANISTER_DUPLICATE_DIFF_CATEGORY,
60    POOL_CANISTER_ID_CONFLICT_CODE, POOL_CANISTER_ID_CONFLICT_DIFF_CATEGORY,
61    POOL_CANISTER_ID_DIFF_CATEGORY, POOL_CANISTER_ID_MISMATCH_CODE, POOL_CANISTER_MISSING_CODE,
62    POOL_CONTROL_CLASS_DIFF_CATEGORY, POOL_EXTRA_DIFF_CATEGORY, UNSAFE_POOL_CONTROL_CLASS_CODE,
63};
64use pools::{compare_observed_canister_pool_role_conflicts, compare_pools};
65pub use receipt_resume::compare_plan_inventory_and_receipt;
66#[cfg(test)]
67pub(super) use receipt_resume::{
68    DUPLICATE_RECEIPT_PHASE_CODE, DUPLICATE_RECEIPT_ROLE_PHASE_CODE,
69    RECEIPT_EXECUTION_STATUS_MISMATCH_CODE, RECEIPT_PHASE_CONFLICT_CODE,
70    RECEIPT_PLAN_MISMATCH_CODE, RECEIPT_POSTCONDITION_UNVERIFIED_CODE,
71    RECEIPT_ROLE_PHASE_CONFLICT_CODE,
72};
73#[cfg(test)]
74pub(super) use root_subnet::ROOT_AUTH_CLOUD_ENGINE_SUBNET_CODE;
75pub(super) use root_subnet::apply_root_auth_signer_subnet_check;
76#[cfg(test)]
77pub(super) use root_subnet::{
78    RootSubnetEvidence, RootSubnetEvidenceSource, apply_root_auth_signer_subnet_check_with_source,
79};
80pub use safety::safety_report_from_diff;
81pub(in crate::deployment_truth::report) use safety::{resume_safety_reasons, safety_status};
82use verifier_readiness::compare_verifier_readiness;
83#[cfg(test)]
84pub(super) use verifier_readiness::{
85    DUPLICATE_PLANNED_VERIFIER_ROLE_EPOCH_CODE, DUPLICATE_VERIFIER_ROLE_EPOCH_OBSERVED_CODE,
86    PLANNED_VERIFIER_ROLE_EPOCH_CONFLICT_CODE, PLANNED_VERIFIER_ROLE_EPOCH_CONFLICT_DIFF_CATEGORY,
87    PLANNED_VERIFIER_ROLE_EPOCH_DUPLICATE_DIFF_CATEGORY, VERIFIER_NOT_OBSERVED_LABEL,
88    VERIFIER_ROLE_EPOCH_CONFLICT_CODE, VERIFIER_ROLE_EPOCH_CONFLICT_DIFF_CATEGORY,
89    VERIFIER_ROLE_EPOCH_DIFF_CATEGORY, VERIFIER_ROLE_EPOCH_DUPLICATE_DIFF_CATEGORY,
90    VERIFIER_ROLE_EPOCH_STALE_CODE, VERIFIER_ROLE_EPOCH_UNOBSERVED_CODE,
91};
92
93pub(in crate::deployment_truth) const DEPLOYMENT_MANIFEST_MISMATCH_CODE: &str =
94    "deployment_manifest_mismatch";
95pub(in crate::deployment_truth) const OBSERVATION_GAP_CODE: &str = "observation_gap";
96pub(in crate::deployment_truth) const UNVERIFIED_DEPLOYMENT_ROOT_CODE: &str =
97    "unverified_deployment_root";
98pub(in crate::deployment_truth) const PLAN_ASSUMPTION_CODE: &str = "plan_assumption";
99pub(in crate::deployment_truth) const IDENTITY_UNOBSERVED_CODE: &str = "identity_unobserved";
100pub(in crate::deployment_truth) const NETWORK_MISMATCH_CODE: &str = "network_mismatch";
101pub(in crate::deployment_truth) const ROOT_TRUST_ANCHOR_MISMATCH_CODE: &str =
102    "root_trust_anchor_mismatch";
103pub(in crate::deployment_truth) const DEPLOYMENT_MANIFEST_UNOBSERVED_CODE: &str =
104    "deployment_manifest_unobserved";
105
106///
107/// DuplicateEvidenceGroup
108///
109struct DuplicateEvidenceGroup {
110    subject: String,
111    count: usize,
112    evidence_label: String,
113    is_conflict: bool,
114}
115
116///
117/// LocalDeploymentCheckRequest
118///
119#[derive(Clone, Debug, Eq, PartialEq)]
120pub struct LocalDeploymentCheckRequest {
121    pub deployment_name: String,
122    pub network: String,
123    pub workspace_root: std::path::PathBuf,
124    pub icp_root: std::path::PathBuf,
125    pub config_path: Option<std::path::PathBuf>,
126    pub observed_at: String,
127    pub runtime_variant: String,
128    pub build_profile: String,
129}
130
131/// Build local plan and inventory, then return the passive safety check bundle.
132pub fn check_local_deployment(
133    request: &LocalDeploymentCheckRequest,
134) -> Result<DeploymentCheckV1, DeploymentTruthError> {
135    let plan = build_local_deployment_plan(&LocalDeploymentPlanRequest {
136        deployment_name: request.deployment_name.clone(),
137        network: request.network.clone(),
138        workspace_root: request.workspace_root.clone(),
139        icp_root: request.icp_root.clone(),
140        config_path: request.config_path.clone(),
141        runtime_variant: request.runtime_variant.clone(),
142        build_profile: request.build_profile.clone(),
143    });
144    let inventory = collect_local_deployment_inventory(&LocalInventoryRequest {
145        deployment_name: request.deployment_name.clone(),
146        network: request.network.clone(),
147        workspace_root: request.workspace_root.clone(),
148        icp_root: request.icp_root.clone(),
149        config_path: request.config_path.clone(),
150        observed_at: request.observed_at.clone(),
151    })?;
152    let mut diff = compare_plan_to_inventory(&plan, &inventory);
153    apply_root_auth_signer_subnet_check(&mut diff, &inventory, &request.network, &request.icp_root);
154    let report = safety_report_from_diff(
155        format!(
156            "local:{}:{}:report",
157            request.network, request.deployment_name
158        ),
159        Some(format!(
160            "local:{}:{}:diff",
161            request.network, request.deployment_name
162        )),
163        &diff,
164    );
165
166    Ok(DeploymentCheckV1 {
167        schema_version: DEPLOYMENT_TRUTH_SCHEMA_VERSION,
168        check_id: format!(
169            "local:{}:{}:check",
170            request.network, request.deployment_name
171        ),
172        plan,
173        inventory,
174        diff,
175        report,
176    })
177}
178
179fn refresh_resume_safety(diff: &mut DeploymentDiffV1) {
180    diff.resume_safety.status = safety_status(&diff.hard_failures, &diff.warnings);
181    diff.resume_safety.reasons = resume_safety_reasons(&diff.hard_failures, &diff.warnings);
182}
183
184/// Compare intended deployment state with observed inventory into a machine diff.
185#[must_use]
186pub fn compare_plan_to_inventory(
187    plan: &DeploymentPlanV1,
188    inventory: &DeploymentInventoryV1,
189) -> DeploymentDiffV1 {
190    let mut artifact_diff = Vec::new();
191    let mut controller_diff = Vec::new();
192    let mut pool_diff = Vec::new();
193    let mut embedded_config_diff = Vec::new();
194    let mut module_hash_diff = Vec::new();
195    let mut verifier_readiness_diff = Vec::new();
196    let mut hard_failures = Vec::new();
197    let mut warnings = Vec::new();
198
199    compare_identity(plan, inventory, &mut hard_failures);
200    compare_authority_profile(plan, &mut controller_diff, &mut hard_failures);
201    compare_artifacts(
202        plan,
203        inventory,
204        &mut artifact_diff,
205        &mut hard_failures,
206        &mut warnings,
207    );
208    compare_observed_canister_id_conflicts(
209        inventory,
210        &mut controller_diff,
211        &mut hard_failures,
212        &mut warnings,
213    );
214    compare_observed_canister_pool_role_conflicts(inventory, &mut pool_diff, &mut hard_failures);
215    compare_canisters(
216        plan,
217        inventory,
218        &mut controller_diff,
219        &mut hard_failures,
220        &mut warnings,
221    );
222    compare_pools(
223        plan,
224        inventory,
225        &mut pool_diff,
226        &mut hard_failures,
227        &mut warnings,
228    );
229    compare_module_hashes(
230        plan,
231        inventory,
232        &mut module_hash_diff,
233        &mut hard_failures,
234        &mut warnings,
235    );
236    compare_raw_config(
237        plan,
238        inventory,
239        &mut embedded_config_diff,
240        &mut hard_failures,
241    );
242    compare_embedded_config(
243        plan,
244        inventory,
245        &mut embedded_config_diff,
246        &mut hard_failures,
247        &mut warnings,
248    );
249    compare_verifier_readiness(
250        plan,
251        inventory,
252        &mut verifier_readiness_diff,
253        &mut hard_failures,
254        &mut warnings,
255    );
256    record_plan_assumptions(plan, &mut hard_failures, &mut warnings);
257    for gap in &inventory.unresolved_observations {
258        warnings.push(SafetyFindingV1 {
259            code: OBSERVATION_GAP_CODE.to_string(),
260            message: gap.description.clone(),
261            severity: SafetySeverityV1::Warning,
262            subject: Some(gap.key.clone()),
263        });
264    }
265
266    let status = safety_status(&hard_failures, &warnings);
267    DeploymentDiffV1 {
268        schema_version: DEPLOYMENT_TRUTH_SCHEMA_VERSION,
269        plan_identity: plan.deployment_identity.clone(),
270        observed_identity: inventory.observed_identity.clone(),
271        artifact_diff,
272        controller_diff,
273        pool_diff,
274        embedded_config_diff,
275        module_hash_diff,
276        verifier_readiness_diff,
277        resume_safety: ResumeSafetyV1 {
278            status,
279            reasons: resume_safety_reasons(&hard_failures, &warnings),
280        },
281        hard_failures,
282        warnings,
283        resumable_phases: Vec::new(),
284    }
285}
286
287fn record_plan_assumptions(
288    plan: &DeploymentPlanV1,
289    hard_failures: &mut Vec<SafetyFindingV1>,
290    warnings: &mut Vec<SafetyFindingV1>,
291) {
292    for assumption in &plan.unresolved_assumptions {
293        if assumption.key == "local_state.unverified_root_canister_id" {
294            hard_failures.push(SafetyFindingV1 {
295                code: UNVERIFIED_DEPLOYMENT_ROOT_CODE.to_string(),
296                message: assumption.description.clone(),
297                severity: SafetySeverityV1::HardFailure,
298                subject: Some(assumption.key.clone()),
299            });
300        } else {
301            warnings.push(SafetyFindingV1 {
302                code: PLAN_ASSUMPTION_CODE.to_string(),
303                message: assumption.description.clone(),
304                severity: SafetySeverityV1::Warning,
305                subject: Some(assumption.key.clone()),
306            });
307        }
308    }
309}
310
311fn compare_identity(
312    plan: &DeploymentPlanV1,
313    inventory: &DeploymentInventoryV1,
314    hard_failures: &mut Vec<SafetyFindingV1>,
315) {
316    let Some(observed) = &inventory.observed_identity else {
317        hard_failures.push(finding(
318            IDENTITY_UNOBSERVED_CODE,
319            "deployment identity was not observed",
320            SafetySeverityV1::HardFailure,
321            None,
322        ));
323        return;
324    };
325
326    if observed.network != plan.deployment_identity.network {
327        hard_failures.push(finding(
328            NETWORK_MISMATCH_CODE,
329            format!(
330                "plan network {} differs from observed network {}",
331                plan.deployment_identity.network, observed.network
332            ),
333            SafetySeverityV1::HardFailure,
334            Some("deployment_identity.network".to_string()),
335        ));
336    }
337    if let (Some(expected), Some(actual)) = (
338        plan.deployment_identity.root_principal.as_ref(),
339        observed.root_principal.as_ref(),
340    ) && expected != actual
341    {
342        hard_failures.push(finding(
343            ROOT_TRUST_ANCHOR_MISMATCH_CODE,
344            format!("plan root {expected} differs from observed root {actual}"),
345            SafetySeverityV1::HardFailure,
346            Some("deployment_identity.root_principal".to_string()),
347        ));
348    }
349    match (
350        plan.deployment_identity.deployment_manifest_digest.as_ref(),
351        observed.deployment_manifest_digest.as_ref(),
352    ) {
353        (Some(expected), Some(actual)) if expected != actual => {
354            hard_failures.push(finding(
355                DEPLOYMENT_MANIFEST_MISMATCH_CODE,
356                "deployment manifest digest differs from the observed local config",
357                SafetySeverityV1::HardFailure,
358                Some("deployment_identity.deployment_manifest_digest".to_string()),
359            ));
360        }
361        (Some(_), None) => {
362            hard_failures.push(finding(
363                DEPLOYMENT_MANIFEST_UNOBSERVED_CODE,
364                "deployment manifest digest was not observed",
365                SafetySeverityV1::HardFailure,
366                Some("deployment_identity.deployment_manifest_digest".to_string()),
367            ));
368        }
369        _ => {}
370    }
371}
372
373fn finding(
374    code: impl Into<String>,
375    message: impl Into<String>,
376    severity: SafetySeverityV1,
377    subject: Option<String>,
378) -> SafetyFindingV1 {
379    SafetyFindingV1 {
380        code: code.into(),
381        message: message.into(),
382        severity,
383        subject,
384    }
385}
386
387fn diff_item(
388    category: impl Into<String>,
389    subject: impl Into<String>,
390    expected: Option<String>,
391    observed: Option<String>,
392    severity: SafetySeverityV1,
393) -> DiffItemV1 {
394    DiffItemV1 {
395        category: category.into(),
396        subject: subject.into(),
397        expected,
398        observed,
399        severity,
400    }
401}
402
403fn duplicate_evidence_groups<T>(
404    items: &[T],
405    subject: impl Fn(&T) -> String,
406    evidence: impl Fn(&T) -> String,
407    evidence_separator: &str,
408) -> Vec<DuplicateEvidenceGroup> {
409    let mut groups = Vec::new();
410    for (subject, entries) in group_by_subject(items, |item| Some(subject(item))) {
411        if entries.len() <= 1 {
412            continue;
413        }
414        let evidence_values = entries
415            .iter()
416            .map(|entry| evidence(entry))
417            .collect::<BTreeSet<_>>();
418        groups.push(DuplicateEvidenceGroup {
419            subject,
420            count: entries.len(),
421            evidence_label: evidence_values
422                .iter()
423                .cloned()
424                .collect::<Vec<_>>()
425                .join(evidence_separator),
426            is_conflict: evidence_values.len() > 1,
427        });
428    }
429    groups
430}
431
432fn conflicting_assignment_groups<T>(
433    items: &[T],
434    subject: impl Fn(&T) -> Option<String>,
435    value: impl Fn(&T) -> String,
436    value_separator: &str,
437) -> Vec<DuplicateEvidenceGroup> {
438    let mut groups = Vec::new();
439    for (subject, entries) in group_by_subject(items, subject) {
440        if entries.len() <= 1 {
441            continue;
442        }
443        let values = entries
444            .iter()
445            .map(|entry| value(entry))
446            .collect::<BTreeSet<_>>();
447        if values.len() <= 1 {
448            continue;
449        }
450        groups.push(DuplicateEvidenceGroup {
451            subject,
452            count: entries.len(),
453            evidence_label: values
454                .iter()
455                .cloned()
456                .collect::<Vec<_>>()
457                .join(value_separator),
458            is_conflict: true,
459        });
460    }
461    groups
462}
463
464fn group_by_subject<T>(
465    items: &[T],
466    subject: impl Fn(&T) -> Option<String>,
467) -> BTreeMap<String, Vec<&T>> {
468    let mut by_subject = BTreeMap::<String, Vec<&T>>::new();
469    for item in items {
470        if let Some(subject) = subject(item) {
471            by_subject.entry(subject).or_default().push(item);
472        }
473    }
474    by_subject
475}