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