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