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}
193
194fn compare_artifacts(
195    plan: &DeploymentPlanV1,
196    inventory: &DeploymentInventoryV1,
197    artifact_diff: &mut Vec<DiffItemV1>,
198    hard_failures: &mut Vec<SafetyFindingV1>,
199    warnings: &mut Vec<SafetyFindingV1>,
200) {
201    let observed_by_role = inventory
202        .observed_artifacts
203        .iter()
204        .map(|artifact| (artifact.role.as_str(), artifact))
205        .collect::<BTreeMap<_, _>>();
206
207    for expected in &plan.role_artifacts {
208        let Some(observed) = observed_by_role.get(expected.role.as_str()) else {
209            artifact_diff.push(diff_item(
210                "artifact",
211                &expected.role,
212                expected.wasm_gz_path.clone(),
213                None,
214                SafetySeverityV1::HardFailure,
215            ));
216            hard_failures.push(finding(
217                "artifact_missing",
218                format!("missing observed artifact for role {}", expected.role),
219                SafetySeverityV1::HardFailure,
220                Some(expected.role.clone()),
221            ));
222            continue;
223        };
224
225        if let Some(file_sha256) = &observed.file_sha256 {
226            artifact_diff.push(diff_item(
227                "artifact_file_sha256",
228                &expected.role,
229                None,
230                Some(file_sha256.clone()),
231                SafetySeverityV1::Info,
232            ));
233        }
234
235        match (
236            expected.wasm_gz_sha256.as_ref(),
237            observed.payload_sha256.as_ref(),
238        ) {
239            (Some(want), Some(got)) if want != got => {
240                artifact_diff.push(diff_item(
241                    "artifact_sha256",
242                    &expected.role,
243                    Some(want.clone()),
244                    Some(got.clone()),
245                    SafetySeverityV1::HardFailure,
246                ));
247                hard_failures.push(finding(
248                    "artifact_digest_mismatch",
249                    format!("artifact digest mismatch for role {}", expected.role),
250                    SafetySeverityV1::HardFailure,
251                    Some(expected.role.clone()),
252                ));
253            }
254            (Some(want), None) => warnings.push(finding(
255                "artifact_digest_unobserved",
256                format!(
257                    "expected artifact digest {want} for role {} was not observed",
258                    expected.role
259                ),
260                SafetySeverityV1::Warning,
261                Some(expected.role.clone()),
262            )),
263            _ => {}
264        }
265    }
266}
267
268fn compare_canisters(
269    plan: &DeploymentPlanV1,
270    inventory: &DeploymentInventoryV1,
271    controller_diff: &mut Vec<DiffItemV1>,
272    hard_failures: &mut Vec<SafetyFindingV1>,
273    warnings: &mut Vec<SafetyFindingV1>,
274) {
275    for expected in &plan.expected_canisters {
276        let observed = expected.canister_id.as_ref().map_or_else(
277            || {
278                inventory
279                    .observed_canisters
280                    .iter()
281                    .find(|canister| canister.role.as_deref() == Some(expected.role.as_str()))
282            },
283            |id| {
284                inventory
285                    .observed_canisters
286                    .iter()
287                    .find(|canister| &canister.canister_id == id)
288            },
289        );
290        let Some(observed) = observed else {
291            let severity = if expected.canister_id.is_some() {
292                SafetySeverityV1::HardFailure
293            } else {
294                SafetySeverityV1::Warning
295            };
296            controller_diff.push(diff_item(
297                "canister",
298                &expected.role,
299                expected.canister_id.clone(),
300                None,
301                severity,
302            ));
303            let finding = finding(
304                if expected.canister_id.is_some() {
305                    "canister_missing"
306                } else {
307                    "canister_unobserved"
308                },
309                format!("missing observed canister for role {}", expected.role),
310                severity,
311                Some(expected.role.clone()),
312            );
313            if expected.canister_id.is_some() {
314                hard_failures.push(finding);
315            } else {
316                warnings.push(finding);
317            }
318            continue;
319        };
320        if matches!(
321            observed.control_class,
322            CanisterControlClassV1::UnknownUnsafe | CanisterControlClassV1::UserControlled
323        ) && expected.control_class == CanisterControlClassV1::DeploymentControlled
324        {
325            controller_diff.push(diff_item(
326                "control_class",
327                &expected.role,
328                Some("DeploymentControlled".to_string()),
329                Some(format!("{:?}", observed.control_class)),
330                SafetySeverityV1::HardFailure,
331            ));
332            hard_failures.push(finding(
333                "unsafe_control_class",
334                format!("role {} has unsafe observed control class", expected.role),
335                SafetySeverityV1::HardFailure,
336                Some(expected.role.clone()),
337            ));
338        }
339    }
340}
341
342fn compare_embedded_config(
343    plan: &DeploymentPlanV1,
344    inventory: &DeploymentInventoryV1,
345    embedded_config_diff: &mut Vec<DiffItemV1>,
346    hard_failures: &mut Vec<SafetyFindingV1>,
347    warnings: &mut Vec<SafetyFindingV1>,
348) {
349    let Some(expected) = &plan.deployment_identity.canonical_runtime_config_digest else {
350        return;
351    };
352    match &inventory.local_config.canonical_embedded_config_sha256 {
353        Some(observed) if observed != expected => {
354            embedded_config_diff.push(diff_item(
355                "canonical_config",
356                "deployment",
357                Some(expected.clone()),
358                Some(observed.clone()),
359                SafetySeverityV1::HardFailure,
360            ));
361            hard_failures.push(finding(
362                "canonical_config_mismatch",
363                "canonical runtime config digest differs from the plan",
364                SafetySeverityV1::HardFailure,
365                Some("local_config".to_string()),
366            ));
367        }
368        None => warnings.push(finding(
369            "canonical_config_unobserved",
370            "canonical runtime config digest was not observed",
371            SafetySeverityV1::Warning,
372            Some("local_config".to_string()),
373        )),
374        _ => {}
375    }
376}
377
378fn compare_verifier_readiness(
379    plan: &DeploymentPlanV1,
380    inventory: &DeploymentInventoryV1,
381    verifier_readiness_diff: &mut Vec<DiffItemV1>,
382    warnings: &mut Vec<SafetyFindingV1>,
383) {
384    if !plan.expected_verifier_readiness.required {
385        return;
386    }
387    if inventory.observed_verifier_readiness.status == ObservationStatusV1::NotObserved {
388        verifier_readiness_diff.push(diff_item(
389            "verifier_readiness",
390            "deployment",
391            Some("required".to_string()),
392            Some("not_observed".to_string()),
393            SafetySeverityV1::Warning,
394        ));
395        warnings.push(finding(
396            "verifier_readiness_unobserved",
397            "verifier readiness was required but not observed",
398            SafetySeverityV1::Warning,
399            Some("verifier_readiness".to_string()),
400        ));
401    }
402}
403
404fn finding(
405    code: impl Into<String>,
406    message: impl Into<String>,
407    severity: SafetySeverityV1,
408    subject: Option<String>,
409) -> SafetyFindingV1 {
410    SafetyFindingV1 {
411        code: code.into(),
412        message: message.into(),
413        severity,
414        subject,
415    }
416}
417
418fn diff_item(
419    category: impl Into<String>,
420    subject: impl Into<String>,
421    expected: Option<String>,
422    observed: Option<String>,
423    severity: SafetySeverityV1,
424) -> DiffItemV1 {
425    DiffItemV1 {
426        category: category.into(),
427        subject: subject.into(),
428        expected,
429        observed,
430        severity,
431    }
432}
433
434const fn safety_status(
435    hard_failures: &[SafetyFindingV1],
436    warnings: &[SafetyFindingV1],
437) -> SafetyStatusV1 {
438    if !hard_failures.is_empty() {
439        SafetyStatusV1::Blocked
440    } else if !warnings.is_empty() {
441        SafetyStatusV1::Warning
442    } else {
443        SafetyStatusV1::Safe
444    }
445}
446
447fn resume_safety_reasons(
448    hard_failures: &[SafetyFindingV1],
449    warnings: &[SafetyFindingV1],
450) -> Vec<String> {
451    if !hard_failures.is_empty() {
452        return hard_failures
453            .iter()
454            .map(|finding| finding.message.clone())
455            .collect();
456    }
457    if !warnings.is_empty() {
458        return warnings
459            .iter()
460            .map(|finding| finding.message.clone())
461            .collect();
462    }
463    vec!["no blocking deployment truth differences were found".to_string()]
464}
465
466fn safety_summary(
467    status: SafetyStatusV1,
468    hard_failure_count: usize,
469    warning_count: usize,
470) -> String {
471    match status {
472        SafetyStatusV1::Safe => "deployment inventory matches the checked plan".to_string(),
473        SafetyStatusV1::Warning => {
474            format!("deployment inventory has {warning_count} warning(s)")
475        }
476        SafetyStatusV1::Blocked => {
477            format!(
478                "deployment inventory has {hard_failure_count} blocking issue(s) and {warning_count} warning(s)"
479            )
480        }
481        SafetyStatusV1::NotEvaluated => "deployment safety has not been evaluated".to_string(),
482    }
483}
484
485fn safety_next_actions(status: SafetyStatusV1) -> Vec<String> {
486    match status {
487        SafetyStatusV1::Safe => Vec::new(),
488        SafetyStatusV1::Warning => {
489            vec!["review deployment warnings before continuing".to_string()]
490        }
491        SafetyStatusV1::Blocked => {
492            vec!["resolve blocking deployment truth differences before mutation".to_string()]
493        }
494        SafetyStatusV1::NotEvaluated => vec!["collect deployment inventory".to_string()],
495    }
496}