Skip to main content

canic_host/deployment_truth/
report.rs

1use super::*;
2use std::collections::BTreeMap;
3
4///
5/// LocalDeploymentCheckRequest
6///
7#[derive(Clone, Debug, Eq, PartialEq)]
8pub struct LocalDeploymentCheckRequest {
9    pub deployment_name: String,
10    pub network: String,
11    pub workspace_root: std::path::PathBuf,
12    pub icp_root: std::path::PathBuf,
13    pub observed_at: String,
14    pub runtime_variant: String,
15    pub build_profile: String,
16}
17
18/// Build local plan and inventory, then return the passive safety check bundle.
19pub fn check_local_deployment(
20    request: &LocalDeploymentCheckRequest,
21) -> Result<DeploymentCheckV1, DeploymentTruthError> {
22    let plan = build_local_deployment_plan(&LocalDeploymentPlanRequest {
23        deployment_name: request.deployment_name.clone(),
24        network: request.network.clone(),
25        workspace_root: request.workspace_root.clone(),
26        icp_root: request.icp_root.clone(),
27        runtime_variant: request.runtime_variant.clone(),
28        build_profile: request.build_profile.clone(),
29    });
30    let inventory = collect_local_deployment_inventory(&LocalInventoryRequest {
31        deployment_name: request.deployment_name.clone(),
32        network: request.network.clone(),
33        workspace_root: request.workspace_root.clone(),
34        icp_root: request.icp_root.clone(),
35        observed_at: request.observed_at.clone(),
36    })?;
37    let diff = compare_plan_to_inventory(&plan, &inventory);
38    let report = safety_report_from_diff(
39        format!(
40            "local:{}:{}:report",
41            request.network, request.deployment_name
42        ),
43        Some(format!(
44            "local:{}:{}:diff",
45            request.network, request.deployment_name
46        )),
47        &diff,
48    );
49
50    Ok(DeploymentCheckV1 {
51        schema_version: DEPLOYMENT_TRUTH_SCHEMA_VERSION,
52        check_id: format!(
53            "local:{}:{}:check",
54            request.network, request.deployment_name
55        ),
56        plan,
57        inventory,
58        diff,
59        report,
60    })
61}
62
63/// Compare intended deployment state with observed inventory into a machine diff.
64#[must_use]
65pub fn compare_plan_to_inventory(
66    plan: &DeploymentPlanV1,
67    inventory: &DeploymentInventoryV1,
68) -> DeploymentDiffV1 {
69    let mut artifact_diff = Vec::new();
70    let mut controller_diff = Vec::new();
71    let pool_diff = Vec::new();
72    let mut embedded_config_diff = Vec::new();
73    let module_hash_diff = Vec::new();
74    let mut verifier_readiness_diff = Vec::new();
75    let mut hard_failures = Vec::new();
76    let mut warnings = Vec::new();
77
78    compare_identity(plan, inventory, &mut hard_failures);
79    compare_artifacts(
80        plan,
81        inventory,
82        &mut artifact_diff,
83        &mut hard_failures,
84        &mut warnings,
85    );
86    compare_canisters(plan, inventory, &mut controller_diff, &mut hard_failures);
87    compare_embedded_config(
88        plan,
89        inventory,
90        &mut embedded_config_diff,
91        &mut hard_failures,
92        &mut warnings,
93    );
94    compare_verifier_readiness(plan, inventory, &mut verifier_readiness_diff, &mut warnings);
95    for gap in &inventory.unresolved_observations {
96        warnings.push(SafetyFindingV1 {
97            code: "observation_gap".to_string(),
98            message: gap.description.clone(),
99            severity: SafetySeverityV1::Warning,
100            subject: Some(gap.key.clone()),
101        });
102    }
103
104    let status = safety_status(&hard_failures, &warnings);
105    DeploymentDiffV1 {
106        schema_version: DEPLOYMENT_TRUTH_SCHEMA_VERSION,
107        plan_identity: plan.deployment_identity.clone(),
108        observed_identity: inventory.observed_identity.clone(),
109        artifact_diff,
110        controller_diff,
111        pool_diff,
112        embedded_config_diff,
113        module_hash_diff,
114        verifier_readiness_diff,
115        resume_safety: ResumeSafetyV1 {
116            status,
117            reasons: resume_safety_reasons(&hard_failures, &warnings),
118        },
119        hard_failures,
120        warnings,
121        resumable_phases: Vec::new(),
122    }
123}
124
125/// Render an operator-facing safety report from a machine deployment diff.
126#[must_use]
127pub fn safety_report_from_diff(
128    report_id: impl Into<String>,
129    diff_id: Option<String>,
130    diff: &DeploymentDiffV1,
131) -> SafetyReportV1 {
132    let status = safety_status(&diff.hard_failures, &diff.warnings);
133    SafetyReportV1 {
134        schema_version: DEPLOYMENT_TRUTH_SCHEMA_VERSION,
135        report_id: report_id.into(),
136        diff_id,
137        status,
138        summary: safety_summary(status, diff.hard_failures.len(), diff.warnings.len()),
139        hard_failures: diff.hard_failures.clone(),
140        warnings: diff.warnings.clone(),
141        next_actions: safety_next_actions(status),
142    }
143}
144
145fn compare_identity(
146    plan: &DeploymentPlanV1,
147    inventory: &DeploymentInventoryV1,
148    hard_failures: &mut Vec<SafetyFindingV1>,
149) {
150    let Some(observed) = &inventory.observed_identity else {
151        hard_failures.push(finding(
152            "identity_unobserved",
153            "deployment identity was not observed",
154            SafetySeverityV1::HardFailure,
155            None,
156        ));
157        return;
158    };
159
160    if observed.network != plan.deployment_identity.network {
161        hard_failures.push(finding(
162            "network_mismatch",
163            format!(
164                "plan network {} differs from observed network {}",
165                plan.deployment_identity.network, observed.network
166            ),
167            SafetySeverityV1::HardFailure,
168            Some("deployment_identity.network".to_string()),
169        ));
170    }
171    if let (Some(expected), Some(actual)) = (
172        plan.deployment_identity.root_principal.as_ref(),
173        observed.root_principal.as_ref(),
174    ) && expected != actual
175    {
176        hard_failures.push(finding(
177            "root_trust_anchor_mismatch",
178            format!("plan root {expected} differs from observed root {actual}"),
179            SafetySeverityV1::HardFailure,
180            Some("deployment_identity.root_principal".to_string()),
181        ));
182    }
183}
184
185fn compare_artifacts(
186    plan: &DeploymentPlanV1,
187    inventory: &DeploymentInventoryV1,
188    artifact_diff: &mut Vec<DiffItemV1>,
189    hard_failures: &mut Vec<SafetyFindingV1>,
190    warnings: &mut Vec<SafetyFindingV1>,
191) {
192    let observed_by_role = inventory
193        .observed_artifacts
194        .iter()
195        .map(|artifact| (artifact.role.as_str(), artifact))
196        .collect::<BTreeMap<_, _>>();
197
198    for expected in &plan.role_artifacts {
199        let Some(observed) = observed_by_role.get(expected.role.as_str()) else {
200            artifact_diff.push(diff_item(
201                "artifact",
202                &expected.role,
203                expected.wasm_gz_path.clone(),
204                None,
205                SafetySeverityV1::HardFailure,
206            ));
207            hard_failures.push(finding(
208                "artifact_missing",
209                format!("missing observed artifact for role {}", expected.role),
210                SafetySeverityV1::HardFailure,
211                Some(expected.role.clone()),
212            ));
213            continue;
214        };
215
216        if let Some(file_sha256) = &observed.file_sha256 {
217            artifact_diff.push(diff_item(
218                "artifact_file_sha256",
219                &expected.role,
220                None,
221                Some(file_sha256.clone()),
222                SafetySeverityV1::Info,
223            ));
224        }
225
226        match (
227            expected.wasm_gz_sha256.as_ref(),
228            observed.payload_sha256.as_ref(),
229        ) {
230            (Some(want), Some(got)) if want != got => {
231                artifact_diff.push(diff_item(
232                    "artifact_sha256",
233                    &expected.role,
234                    Some(want.clone()),
235                    Some(got.clone()),
236                    SafetySeverityV1::HardFailure,
237                ));
238                hard_failures.push(finding(
239                    "artifact_digest_mismatch",
240                    format!("artifact digest mismatch for role {}", expected.role),
241                    SafetySeverityV1::HardFailure,
242                    Some(expected.role.clone()),
243                ));
244            }
245            (Some(want), None) => warnings.push(finding(
246                "artifact_digest_unobserved",
247                format!(
248                    "expected artifact digest {want} for role {} was not observed",
249                    expected.role
250                ),
251                SafetySeverityV1::Warning,
252                Some(expected.role.clone()),
253            )),
254            _ => {}
255        }
256    }
257}
258
259fn compare_canisters(
260    plan: &DeploymentPlanV1,
261    inventory: &DeploymentInventoryV1,
262    controller_diff: &mut Vec<DiffItemV1>,
263    hard_failures: &mut Vec<SafetyFindingV1>,
264) {
265    for expected in &plan.expected_canisters {
266        let observed = expected.canister_id.as_ref().map_or_else(
267            || {
268                inventory
269                    .observed_canisters
270                    .iter()
271                    .find(|canister| canister.role.as_deref() == Some(expected.role.as_str()))
272            },
273            |id| {
274                inventory
275                    .observed_canisters
276                    .iter()
277                    .find(|canister| &canister.canister_id == id)
278            },
279        );
280        let Some(observed) = observed else {
281            controller_diff.push(diff_item(
282                "canister",
283                &expected.role,
284                expected.canister_id.clone(),
285                None,
286                SafetySeverityV1::HardFailure,
287            ));
288            hard_failures.push(finding(
289                "canister_missing",
290                format!("missing observed canister for role {}", expected.role),
291                SafetySeverityV1::HardFailure,
292                Some(expected.role.clone()),
293            ));
294            continue;
295        };
296        if matches!(
297            observed.control_class,
298            CanisterControlClassV1::UnknownUnsafe | CanisterControlClassV1::UserControlled
299        ) && expected.control_class == CanisterControlClassV1::DeploymentControlled
300        {
301            controller_diff.push(diff_item(
302                "control_class",
303                &expected.role,
304                Some("DeploymentControlled".to_string()),
305                Some(format!("{:?}", observed.control_class)),
306                SafetySeverityV1::HardFailure,
307            ));
308            hard_failures.push(finding(
309                "unsafe_control_class",
310                format!("role {} has unsafe observed control class", expected.role),
311                SafetySeverityV1::HardFailure,
312                Some(expected.role.clone()),
313            ));
314        }
315    }
316}
317
318fn compare_embedded_config(
319    plan: &DeploymentPlanV1,
320    inventory: &DeploymentInventoryV1,
321    embedded_config_diff: &mut Vec<DiffItemV1>,
322    hard_failures: &mut Vec<SafetyFindingV1>,
323    warnings: &mut Vec<SafetyFindingV1>,
324) {
325    let Some(expected) = &plan.deployment_identity.canonical_runtime_config_digest else {
326        return;
327    };
328    match &inventory.local_config.canonical_embedded_config_sha256 {
329        Some(observed) if observed != expected => {
330            embedded_config_diff.push(diff_item(
331                "canonical_config",
332                "deployment",
333                Some(expected.clone()),
334                Some(observed.clone()),
335                SafetySeverityV1::HardFailure,
336            ));
337            hard_failures.push(finding(
338                "canonical_config_mismatch",
339                "canonical runtime config digest differs from the plan",
340                SafetySeverityV1::HardFailure,
341                Some("local_config".to_string()),
342            ));
343        }
344        None => warnings.push(finding(
345            "canonical_config_unobserved",
346            "canonical runtime config digest was not observed",
347            SafetySeverityV1::Warning,
348            Some("local_config".to_string()),
349        )),
350        _ => {}
351    }
352}
353
354fn compare_verifier_readiness(
355    plan: &DeploymentPlanV1,
356    inventory: &DeploymentInventoryV1,
357    verifier_readiness_diff: &mut Vec<DiffItemV1>,
358    warnings: &mut Vec<SafetyFindingV1>,
359) {
360    if !plan.expected_verifier_readiness.required {
361        return;
362    }
363    if inventory.observed_verifier_readiness.status == ObservationStatusV1::NotObserved {
364        verifier_readiness_diff.push(diff_item(
365            "verifier_readiness",
366            "deployment",
367            Some("required".to_string()),
368            Some("not_observed".to_string()),
369            SafetySeverityV1::Warning,
370        ));
371        warnings.push(finding(
372            "verifier_readiness_unobserved",
373            "verifier readiness was required but not observed",
374            SafetySeverityV1::Warning,
375            Some("verifier_readiness".to_string()),
376        ));
377    }
378}
379
380fn finding(
381    code: impl Into<String>,
382    message: impl Into<String>,
383    severity: SafetySeverityV1,
384    subject: Option<String>,
385) -> SafetyFindingV1 {
386    SafetyFindingV1 {
387        code: code.into(),
388        message: message.into(),
389        severity,
390        subject,
391    }
392}
393
394fn diff_item(
395    category: impl Into<String>,
396    subject: impl Into<String>,
397    expected: Option<String>,
398    observed: Option<String>,
399    severity: SafetySeverityV1,
400) -> DiffItemV1 {
401    DiffItemV1 {
402        category: category.into(),
403        subject: subject.into(),
404        expected,
405        observed,
406        severity,
407    }
408}
409
410const fn safety_status(
411    hard_failures: &[SafetyFindingV1],
412    warnings: &[SafetyFindingV1],
413) -> SafetyStatusV1 {
414    if !hard_failures.is_empty() {
415        SafetyStatusV1::Blocked
416    } else if !warnings.is_empty() {
417        SafetyStatusV1::Warning
418    } else {
419        SafetyStatusV1::Safe
420    }
421}
422
423fn resume_safety_reasons(
424    hard_failures: &[SafetyFindingV1],
425    warnings: &[SafetyFindingV1],
426) -> Vec<String> {
427    if !hard_failures.is_empty() {
428        return hard_failures
429            .iter()
430            .map(|finding| finding.message.clone())
431            .collect();
432    }
433    if !warnings.is_empty() {
434        return warnings
435            .iter()
436            .map(|finding| finding.message.clone())
437            .collect();
438    }
439    vec!["no blocking deployment truth differences were found".to_string()]
440}
441
442fn safety_summary(
443    status: SafetyStatusV1,
444    hard_failure_count: usize,
445    warning_count: usize,
446) -> String {
447    match status {
448        SafetyStatusV1::Safe => "deployment inventory matches the checked plan".to_string(),
449        SafetyStatusV1::Warning => {
450            format!("deployment inventory has {warning_count} warning(s)")
451        }
452        SafetyStatusV1::Blocked => {
453            format!(
454                "deployment inventory has {hard_failure_count} blocking issue(s) and {warning_count} warning(s)"
455            )
456        }
457        SafetyStatusV1::NotEvaluated => "deployment safety has not been evaluated".to_string(),
458    }
459}
460
461fn safety_next_actions(status: SafetyStatusV1) -> Vec<String> {
462    match status {
463        SafetyStatusV1::Safe => Vec::new(),
464        SafetyStatusV1::Warning => {
465            vec!["review deployment warnings before continuing".to_string()]
466        }
467        SafetyStatusV1::Blocked => {
468            vec!["resolve blocking deployment truth differences before mutation".to_string()]
469        }
470        SafetyStatusV1::NotEvaluated => vec!["collect deployment inventory".to_string()],
471    }
472}