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;
16use canisters::{compare_canisters, compare_observed_canister_id_conflicts};
17use config_digests::{compare_embedded_config, compare_raw_config};
18use controllers::compare_authority_profile;
19use module_hashes::compare_module_hashes;
20use pools::{compare_observed_canister_pool_role_conflicts, compare_pools};
21pub use receipt_resume::compare_plan_inventory_and_receipt;
22#[cfg(test)]
23pub(super) use root_subnet::ROOT_AUTH_CLOUD_ENGINE_SUBNET_CODE;
24pub(super) use root_subnet::apply_root_auth_signer_subnet_check;
25#[cfg(test)]
26pub(super) use root_subnet::{
27    RootSubnetEvidence, RootSubnetEvidenceSource, apply_root_auth_signer_subnet_check_with_source,
28};
29pub use safety::safety_report_from_diff;
30pub(in crate::deployment_truth::report) use safety::{resume_safety_reasons, safety_status};
31use verifier_readiness::compare_verifier_readiness;
32
33///
34/// DuplicateEvidenceGroup
35///
36struct DuplicateEvidenceGroup {
37    subject: String,
38    count: usize,
39    evidence_label: String,
40    is_conflict: bool,
41}
42
43///
44/// LocalDeploymentCheckRequest
45///
46#[derive(Clone, Debug, Eq, PartialEq)]
47pub struct LocalDeploymentCheckRequest {
48    pub deployment_name: String,
49    pub network: String,
50    pub workspace_root: std::path::PathBuf,
51    pub icp_root: std::path::PathBuf,
52    pub config_path: Option<std::path::PathBuf>,
53    pub observed_at: String,
54    pub runtime_variant: String,
55    pub build_profile: String,
56}
57
58/// Build local plan and inventory, then return the passive safety check bundle.
59pub fn check_local_deployment(
60    request: &LocalDeploymentCheckRequest,
61) -> Result<DeploymentCheckV1, DeploymentTruthError> {
62    let plan = build_local_deployment_plan(&LocalDeploymentPlanRequest {
63        deployment_name: request.deployment_name.clone(),
64        network: request.network.clone(),
65        workspace_root: request.workspace_root.clone(),
66        icp_root: request.icp_root.clone(),
67        config_path: request.config_path.clone(),
68        runtime_variant: request.runtime_variant.clone(),
69        build_profile: request.build_profile.clone(),
70    });
71    let inventory = collect_local_deployment_inventory(&LocalInventoryRequest {
72        deployment_name: request.deployment_name.clone(),
73        network: request.network.clone(),
74        workspace_root: request.workspace_root.clone(),
75        icp_root: request.icp_root.clone(),
76        config_path: request.config_path.clone(),
77        observed_at: request.observed_at.clone(),
78    })?;
79    let mut diff = compare_plan_to_inventory(&plan, &inventory);
80    apply_root_auth_signer_subnet_check(&mut diff, &inventory, &request.network, &request.icp_root);
81    let report = safety_report_from_diff(
82        format!(
83            "local:{}:{}:report",
84            request.network, request.deployment_name
85        ),
86        Some(format!(
87            "local:{}:{}:diff",
88            request.network, request.deployment_name
89        )),
90        &diff,
91    );
92
93    Ok(DeploymentCheckV1 {
94        schema_version: DEPLOYMENT_TRUTH_SCHEMA_VERSION,
95        check_id: format!(
96            "local:{}:{}:check",
97            request.network, request.deployment_name
98        ),
99        plan,
100        inventory,
101        diff,
102        report,
103    })
104}
105
106fn refresh_resume_safety(diff: &mut DeploymentDiffV1) {
107    diff.resume_safety.status = safety_status(&diff.hard_failures, &diff.warnings);
108    diff.resume_safety.reasons = resume_safety_reasons(&diff.hard_failures, &diff.warnings);
109}
110
111/// Compare intended deployment state with observed inventory into a machine diff.
112#[must_use]
113pub fn compare_plan_to_inventory(
114    plan: &DeploymentPlanV1,
115    inventory: &DeploymentInventoryV1,
116) -> DeploymentDiffV1 {
117    let mut artifact_diff = Vec::new();
118    let mut controller_diff = Vec::new();
119    let mut pool_diff = Vec::new();
120    let mut embedded_config_diff = Vec::new();
121    let mut module_hash_diff = Vec::new();
122    let mut verifier_readiness_diff = Vec::new();
123    let mut hard_failures = Vec::new();
124    let mut warnings = Vec::new();
125
126    compare_identity(plan, inventory, &mut hard_failures);
127    compare_authority_profile(plan, &mut controller_diff, &mut hard_failures);
128    compare_artifacts(
129        plan,
130        inventory,
131        &mut artifact_diff,
132        &mut hard_failures,
133        &mut warnings,
134    );
135    compare_observed_canister_id_conflicts(
136        inventory,
137        &mut controller_diff,
138        &mut hard_failures,
139        &mut warnings,
140    );
141    compare_observed_canister_pool_role_conflicts(inventory, &mut pool_diff, &mut hard_failures);
142    compare_canisters(
143        plan,
144        inventory,
145        &mut controller_diff,
146        &mut hard_failures,
147        &mut warnings,
148    );
149    compare_pools(
150        plan,
151        inventory,
152        &mut pool_diff,
153        &mut hard_failures,
154        &mut warnings,
155    );
156    compare_module_hashes(
157        plan,
158        inventory,
159        &mut module_hash_diff,
160        &mut hard_failures,
161        &mut warnings,
162    );
163    compare_raw_config(
164        plan,
165        inventory,
166        &mut embedded_config_diff,
167        &mut hard_failures,
168    );
169    compare_embedded_config(
170        plan,
171        inventory,
172        &mut embedded_config_diff,
173        &mut hard_failures,
174        &mut warnings,
175    );
176    compare_verifier_readiness(
177        plan,
178        inventory,
179        &mut verifier_readiness_diff,
180        &mut hard_failures,
181        &mut warnings,
182    );
183    record_plan_assumptions(plan, &mut hard_failures, &mut warnings);
184    for gap in &inventory.unresolved_observations {
185        warnings.push(SafetyFindingV1 {
186            code: "observation_gap".to_string(),
187            message: gap.description.clone(),
188            severity: SafetySeverityV1::Warning,
189            subject: Some(gap.key.clone()),
190        });
191    }
192
193    let status = safety_status(&hard_failures, &warnings);
194    DeploymentDiffV1 {
195        schema_version: DEPLOYMENT_TRUTH_SCHEMA_VERSION,
196        plan_identity: plan.deployment_identity.clone(),
197        observed_identity: inventory.observed_identity.clone(),
198        artifact_diff,
199        controller_diff,
200        pool_diff,
201        embedded_config_diff,
202        module_hash_diff,
203        verifier_readiness_diff,
204        resume_safety: ResumeSafetyV1 {
205            status,
206            reasons: resume_safety_reasons(&hard_failures, &warnings),
207        },
208        hard_failures,
209        warnings,
210        resumable_phases: Vec::new(),
211    }
212}
213
214fn record_plan_assumptions(
215    plan: &DeploymentPlanV1,
216    hard_failures: &mut Vec<SafetyFindingV1>,
217    warnings: &mut Vec<SafetyFindingV1>,
218) {
219    for assumption in &plan.unresolved_assumptions {
220        if assumption.key == "local_state.unverified_root_canister_id" {
221            hard_failures.push(SafetyFindingV1 {
222                code: "unverified_deployment_root".to_string(),
223                message: assumption.description.clone(),
224                severity: SafetySeverityV1::HardFailure,
225                subject: Some(assumption.key.clone()),
226            });
227        } else {
228            warnings.push(SafetyFindingV1 {
229                code: "plan_assumption".to_string(),
230                message: assumption.description.clone(),
231                severity: SafetySeverityV1::Warning,
232                subject: Some(assumption.key.clone()),
233            });
234        }
235    }
236}
237
238fn compare_identity(
239    plan: &DeploymentPlanV1,
240    inventory: &DeploymentInventoryV1,
241    hard_failures: &mut Vec<SafetyFindingV1>,
242) {
243    let Some(observed) = &inventory.observed_identity else {
244        hard_failures.push(finding(
245            "identity_unobserved",
246            "deployment identity was not observed",
247            SafetySeverityV1::HardFailure,
248            None,
249        ));
250        return;
251    };
252
253    if observed.network != plan.deployment_identity.network {
254        hard_failures.push(finding(
255            "network_mismatch",
256            format!(
257                "plan network {} differs from observed network {}",
258                plan.deployment_identity.network, observed.network
259            ),
260            SafetySeverityV1::HardFailure,
261            Some("deployment_identity.network".to_string()),
262        ));
263    }
264    if let (Some(expected), Some(actual)) = (
265        plan.deployment_identity.root_principal.as_ref(),
266        observed.root_principal.as_ref(),
267    ) && expected != actual
268    {
269        hard_failures.push(finding(
270            "root_trust_anchor_mismatch",
271            format!("plan root {expected} differs from observed root {actual}"),
272            SafetySeverityV1::HardFailure,
273            Some("deployment_identity.root_principal".to_string()),
274        ));
275    }
276    match (
277        plan.deployment_identity.deployment_manifest_digest.as_ref(),
278        observed.deployment_manifest_digest.as_ref(),
279    ) {
280        (Some(expected), Some(actual)) if expected != actual => {
281            hard_failures.push(finding(
282                "deployment_manifest_mismatch",
283                "deployment manifest digest differs from the observed local config",
284                SafetySeverityV1::HardFailure,
285                Some("deployment_identity.deployment_manifest_digest".to_string()),
286            ));
287        }
288        (Some(_), None) => {
289            hard_failures.push(finding(
290                "deployment_manifest_unobserved",
291                "deployment manifest digest was not observed",
292                SafetySeverityV1::HardFailure,
293                Some("deployment_identity.deployment_manifest_digest".to_string()),
294            ));
295        }
296        _ => {}
297    }
298}
299
300fn finding(
301    code: impl Into<String>,
302    message: impl Into<String>,
303    severity: SafetySeverityV1,
304    subject: Option<String>,
305) -> SafetyFindingV1 {
306    SafetyFindingV1 {
307        code: code.into(),
308        message: message.into(),
309        severity,
310        subject,
311    }
312}
313
314fn diff_item(
315    category: impl Into<String>,
316    subject: impl Into<String>,
317    expected: Option<String>,
318    observed: Option<String>,
319    severity: SafetySeverityV1,
320) -> DiffItemV1 {
321    DiffItemV1 {
322        category: category.into(),
323        subject: subject.into(),
324        expected,
325        observed,
326        severity,
327    }
328}
329
330fn duplicate_evidence_groups<T>(
331    items: &[T],
332    subject: impl Fn(&T) -> String,
333    evidence: impl Fn(&T) -> String,
334    evidence_separator: &str,
335) -> Vec<DuplicateEvidenceGroup> {
336    let mut groups = Vec::new();
337    for (subject, entries) in group_by_subject(items, |item| Some(subject(item))) {
338        if entries.len() <= 1 {
339            continue;
340        }
341        let evidence_values = entries
342            .iter()
343            .map(|entry| evidence(entry))
344            .collect::<BTreeSet<_>>();
345        groups.push(DuplicateEvidenceGroup {
346            subject,
347            count: entries.len(),
348            evidence_label: evidence_values
349                .iter()
350                .cloned()
351                .collect::<Vec<_>>()
352                .join(evidence_separator),
353            is_conflict: evidence_values.len() > 1,
354        });
355    }
356    groups
357}
358
359fn conflicting_assignment_groups<T>(
360    items: &[T],
361    subject: impl Fn(&T) -> Option<String>,
362    value: impl Fn(&T) -> String,
363    value_separator: &str,
364) -> Vec<DuplicateEvidenceGroup> {
365    let mut groups = Vec::new();
366    for (subject, entries) in group_by_subject(items, subject) {
367        if entries.len() <= 1 {
368            continue;
369        }
370        let values = entries
371            .iter()
372            .map(|entry| value(entry))
373            .collect::<BTreeSet<_>>();
374        if values.len() <= 1 {
375            continue;
376        }
377        groups.push(DuplicateEvidenceGroup {
378            subject,
379            count: entries.len(),
380            evidence_label: values
381                .iter()
382                .cloned()
383                .collect::<Vec<_>>()
384                .join(value_separator),
385            is_conflict: true,
386        });
387    }
388    groups
389}
390
391fn group_by_subject<T>(
392    items: &[T],
393    subject: impl Fn(&T) -> Option<String>,
394) -> BTreeMap<String, Vec<&T>> {
395    let mut by_subject = BTreeMap::<String, Vec<&T>>::new();
396    for item in items {
397        if let Some(subject) = subject(item) {
398            by_subject.entry(subject).or_default().push(item);
399        }
400    }
401    by_subject
402}