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