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