1use super::*;
2use std::collections::{BTreeMap, BTreeSet};
3
4#[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
19pub 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#[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 mut 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_authority_profile(plan, &mut controller_diff, &mut hard_failures);
83 compare_artifacts(
84 plan,
85 inventory,
86 &mut artifact_diff,
87 &mut hard_failures,
88 &mut warnings,
89 );
90 compare_canisters(
91 plan,
92 inventory,
93 &mut controller_diff,
94 &mut hard_failures,
95 &mut warnings,
96 );
97 compare_module_hashes(
98 plan,
99 inventory,
100 &mut module_hash_diff,
101 &mut hard_failures,
102 &mut warnings,
103 );
104 compare_raw_config(
105 plan,
106 inventory,
107 &mut embedded_config_diff,
108 &mut hard_failures,
109 );
110 compare_embedded_config(
111 plan,
112 inventory,
113 &mut embedded_config_diff,
114 &mut hard_failures,
115 &mut warnings,
116 );
117 compare_verifier_readiness(plan, inventory, &mut verifier_readiness_diff, &mut warnings);
118 for gap in &inventory.unresolved_observations {
119 warnings.push(SafetyFindingV1 {
120 code: "observation_gap".to_string(),
121 message: gap.description.clone(),
122 severity: SafetySeverityV1::Warning,
123 subject: Some(gap.key.clone()),
124 });
125 }
126
127 let status = safety_status(&hard_failures, &warnings);
128 DeploymentDiffV1 {
129 schema_version: DEPLOYMENT_TRUTH_SCHEMA_VERSION,
130 plan_identity: plan.deployment_identity.clone(),
131 observed_identity: inventory.observed_identity.clone(),
132 artifact_diff,
133 controller_diff,
134 pool_diff,
135 embedded_config_diff,
136 module_hash_diff,
137 verifier_readiness_diff,
138 resume_safety: ResumeSafetyV1 {
139 status,
140 reasons: resume_safety_reasons(&hard_failures, &warnings),
141 },
142 hard_failures,
143 warnings,
144 resumable_phases: Vec::new(),
145 }
146}
147
148#[must_use]
150pub fn safety_report_from_diff(
151 report_id: impl Into<String>,
152 diff_id: Option<String>,
153 diff: &DeploymentDiffV1,
154) -> SafetyReportV1 {
155 let status = safety_status(&diff.hard_failures, &diff.warnings);
156 SafetyReportV1 {
157 schema_version: DEPLOYMENT_TRUTH_SCHEMA_VERSION,
158 report_id: report_id.into(),
159 diff_id,
160 status,
161 summary: safety_summary(status, diff.hard_failures.len(), diff.warnings.len()),
162 hard_failures: diff.hard_failures.clone(),
163 warnings: diff.warnings.clone(),
164 next_actions: safety_next_actions(status),
165 }
166}
167
168fn compare_identity(
169 plan: &DeploymentPlanV1,
170 inventory: &DeploymentInventoryV1,
171 hard_failures: &mut Vec<SafetyFindingV1>,
172) {
173 let Some(observed) = &inventory.observed_identity else {
174 hard_failures.push(finding(
175 "identity_unobserved",
176 "deployment identity was not observed",
177 SafetySeverityV1::HardFailure,
178 None,
179 ));
180 return;
181 };
182
183 if observed.network != plan.deployment_identity.network {
184 hard_failures.push(finding(
185 "network_mismatch",
186 format!(
187 "plan network {} differs from observed network {}",
188 plan.deployment_identity.network, observed.network
189 ),
190 SafetySeverityV1::HardFailure,
191 Some("deployment_identity.network".to_string()),
192 ));
193 }
194 if let (Some(expected), Some(actual)) = (
195 plan.deployment_identity.root_principal.as_ref(),
196 observed.root_principal.as_ref(),
197 ) && expected != actual
198 {
199 hard_failures.push(finding(
200 "root_trust_anchor_mismatch",
201 format!("plan root {expected} differs from observed root {actual}"),
202 SafetySeverityV1::HardFailure,
203 Some("deployment_identity.root_principal".to_string()),
204 ));
205 }
206 match (
207 plan.deployment_identity.deployment_manifest_digest.as_ref(),
208 observed.deployment_manifest_digest.as_ref(),
209 ) {
210 (Some(expected), Some(actual)) if expected != actual => {
211 hard_failures.push(finding(
212 "deployment_manifest_mismatch",
213 "deployment manifest digest differs from the observed local config",
214 SafetySeverityV1::HardFailure,
215 Some("deployment_identity.deployment_manifest_digest".to_string()),
216 ));
217 }
218 (Some(_), None) => {
219 hard_failures.push(finding(
220 "deployment_manifest_unobserved",
221 "deployment manifest digest was not observed",
222 SafetySeverityV1::HardFailure,
223 Some("deployment_identity.deployment_manifest_digest".to_string()),
224 ));
225 }
226 _ => {}
227 }
228}
229
230fn compare_authority_profile(
231 plan: &DeploymentPlanV1,
232 controller_diff: &mut Vec<DiffItemV1>,
233 hard_failures: &mut Vec<SafetyFindingV1>,
234) {
235 let mut reported = BTreeSet::new();
236 for controller in &plan.authority_profile.expected_controllers {
237 if !is_staging_or_emergency_controller(plan, controller) {
238 continue;
239 }
240 if !reported.insert(controller.as_str()) {
241 continue;
242 }
243 controller_diff.push(diff_item(
244 "controller_authority_overlap",
245 "authority_profile",
246 Some("expected-only".to_string()),
247 Some(controller.clone()),
248 SafetySeverityV1::HardFailure,
249 ));
250 hard_failures.push(finding(
251 "controller_authority_overlap",
252 format!(
253 "controller {controller} appears in both expected and staging/emergency authority"
254 ),
255 SafetySeverityV1::HardFailure,
256 Some("authority_profile".to_string()),
257 ));
258 }
259}
260
261fn compare_artifacts(
262 plan: &DeploymentPlanV1,
263 inventory: &DeploymentInventoryV1,
264 artifact_diff: &mut Vec<DiffItemV1>,
265 hard_failures: &mut Vec<SafetyFindingV1>,
266 warnings: &mut Vec<SafetyFindingV1>,
267) {
268 let observed_by_role = inventory
269 .observed_artifacts
270 .iter()
271 .map(|artifact| (artifact.role.as_str(), artifact))
272 .collect::<BTreeMap<_, _>>();
273
274 for expected in &plan.role_artifacts {
275 let Some(observed) = observed_by_role.get(expected.role.as_str()) else {
276 artifact_diff.push(diff_item(
277 "artifact",
278 &expected.role,
279 expected.wasm_gz_path.clone(),
280 None,
281 SafetySeverityV1::HardFailure,
282 ));
283 hard_failures.push(finding(
284 "artifact_missing",
285 format!("missing observed artifact for role {}", expected.role),
286 SafetySeverityV1::HardFailure,
287 Some(expected.role.clone()),
288 ));
289 continue;
290 };
291
292 compare_artifact_file_sha256(expected, observed, artifact_diff, hard_failures);
293
294 match (
295 expected.wasm_gz_sha256.as_ref(),
296 observed.payload_sha256.as_ref(),
297 ) {
298 (Some(want), Some(got)) if want != got => {
299 artifact_diff.push(diff_item(
300 "artifact_sha256",
301 &expected.role,
302 Some(want.clone()),
303 Some(got.clone()),
304 SafetySeverityV1::HardFailure,
305 ));
306 hard_failures.push(finding(
307 "artifact_digest_mismatch",
308 format!("artifact digest mismatch for role {}", expected.role),
309 SafetySeverityV1::HardFailure,
310 Some(expected.role.clone()),
311 ));
312 }
313 (Some(want), None) => warnings.push(finding(
314 "artifact_digest_unobserved",
315 format!(
316 "expected artifact digest {want} for role {} was not observed",
317 expected.role
318 ),
319 SafetySeverityV1::Warning,
320 Some(expected.role.clone()),
321 )),
322 _ => {}
323 }
324 }
325}
326
327fn compare_artifact_file_sha256(
328 expected: &RoleArtifactV1,
329 observed: &ObservedArtifactV1,
330 artifact_diff: &mut Vec<DiffItemV1>,
331 hard_failures: &mut Vec<SafetyFindingV1>,
332) {
333 match (
334 expected.observed_wasm_gz_file_sha256.as_ref(),
335 observed.file_sha256.as_ref(),
336 ) {
337 (Some(want), Some(got)) if want != got => {
338 artifact_diff.push(diff_item(
339 "artifact_file_sha256",
340 &expected.role,
341 Some(want.clone()),
342 Some(got.clone()),
343 SafetySeverityV1::HardFailure,
344 ));
345 hard_failures.push(finding(
346 "artifact_file_digest_mismatch",
347 format!(
348 "observed artifact file digest changed during deployment truth check for role {}",
349 expected.role
350 ),
351 SafetySeverityV1::HardFailure,
352 Some(expected.role.clone()),
353 ));
354 }
355 (_, Some(got)) => {
356 artifact_diff.push(diff_item(
357 "artifact_file_sha256",
358 &expected.role,
359 expected.observed_wasm_gz_file_sha256.clone(),
360 Some(got.clone()),
361 SafetySeverityV1::Info,
362 ));
363 }
364 _ => {}
365 }
366}
367
368fn compare_canisters(
369 plan: &DeploymentPlanV1,
370 inventory: &DeploymentInventoryV1,
371 controller_diff: &mut Vec<DiffItemV1>,
372 hard_failures: &mut Vec<SafetyFindingV1>,
373 warnings: &mut Vec<SafetyFindingV1>,
374) {
375 for expected in &plan.expected_canisters {
376 let observed = expected.canister_id.as_ref().map_or_else(
377 || {
378 inventory
379 .observed_canisters
380 .iter()
381 .find(|canister| canister.role.as_deref() == Some(expected.role.as_str()))
382 },
383 |id| {
384 inventory
385 .observed_canisters
386 .iter()
387 .find(|canister| &canister.canister_id == id)
388 },
389 );
390 let Some(observed) = observed else {
391 let severity = if expected.canister_id.is_some() {
392 SafetySeverityV1::HardFailure
393 } else {
394 SafetySeverityV1::Warning
395 };
396 controller_diff.push(diff_item(
397 "canister",
398 &expected.role,
399 expected.canister_id.clone(),
400 None,
401 severity,
402 ));
403 let finding = finding(
404 if expected.canister_id.is_some() {
405 "canister_missing"
406 } else {
407 "canister_unobserved"
408 },
409 format!("missing observed canister for role {}", expected.role),
410 severity,
411 Some(expected.role.clone()),
412 );
413 if expected.canister_id.is_some() {
414 hard_failures.push(finding);
415 } else {
416 warnings.push(finding);
417 }
418 continue;
419 };
420 if matches!(
421 observed.control_class,
422 CanisterControlClassV1::UnknownUnsafe | CanisterControlClassV1::UserControlled
423 ) && expected.control_class == CanisterControlClassV1::DeploymentControlled
424 {
425 controller_diff.push(diff_item(
426 "control_class",
427 &expected.role,
428 Some("DeploymentControlled".to_string()),
429 Some(format!("{:?}", observed.control_class)),
430 SafetySeverityV1::HardFailure,
431 ));
432 hard_failures.push(finding(
433 "unsafe_control_class",
434 format!("role {} has unsafe observed control class", expected.role),
435 SafetySeverityV1::HardFailure,
436 Some(expected.role.clone()),
437 ));
438 }
439 compare_role_controllers(plan, observed, controller_diff, hard_failures, warnings);
440 }
441}
442
443fn compare_role_controllers(
444 plan: &DeploymentPlanV1,
445 observed: &ObservedCanisterV1,
446 controller_diff: &mut Vec<DiffItemV1>,
447 hard_failures: &mut Vec<SafetyFindingV1>,
448 warnings: &mut Vec<SafetyFindingV1>,
449) {
450 let role = observed.role.as_deref().unwrap_or("unknown");
451 for expected in &plan.authority_profile.expected_controllers {
452 if observed
453 .controllers
454 .iter()
455 .any(|controller| controller == expected)
456 {
457 continue;
458 }
459 controller_diff.push(diff_item(
460 "controller_missing",
461 role,
462 Some(expected.clone()),
463 Some(controller_set_label(&observed.controllers)),
464 SafetySeverityV1::HardFailure,
465 ));
466 hard_failures.push(finding(
467 "expected_controller_missing",
468 format!("role {role} is missing expected controller {expected}"),
469 SafetySeverityV1::HardFailure,
470 Some(role.to_string()),
471 ));
472 }
473
474 for observed_controller in &observed.controllers {
475 if is_declared_controller(plan, observed_controller) {
476 continue;
477 }
478 controller_diff.push(diff_item(
479 "controller_extra",
480 role,
481 Some(controller_set_label(
482 &plan.authority_profile.expected_controllers,
483 )),
484 Some(observed_controller.clone()),
485 SafetySeverityV1::Warning,
486 ));
487 warnings.push(finding(
488 "extra_controller_observed",
489 format!("role {role} has controller outside the expected authority profile"),
490 SafetySeverityV1::Warning,
491 Some(role.to_string()),
492 ));
493 }
494}
495
496fn is_declared_controller(plan: &DeploymentPlanV1, controller: &str) -> bool {
497 plan.authority_profile
498 .expected_controllers
499 .iter()
500 .chain(plan.authority_profile.staging_controllers.iter())
501 .chain(plan.authority_profile.emergency_controllers.iter())
502 .any(|expected| expected == controller)
503}
504
505fn is_staging_or_emergency_controller(plan: &DeploymentPlanV1, controller: &str) -> bool {
506 plan.authority_profile
507 .staging_controllers
508 .iter()
509 .chain(plan.authority_profile.emergency_controllers.iter())
510 .any(|declared| declared == controller)
511}
512
513fn controller_set_label(controllers: &[String]) -> String {
514 if controllers.is_empty() {
515 return "<none>".to_string();
516 }
517 controllers.join(",")
518}
519
520fn compare_module_hashes(
521 plan: &DeploymentPlanV1,
522 inventory: &DeploymentInventoryV1,
523 module_hash_diff: &mut Vec<DiffItemV1>,
524 hard_failures: &mut Vec<SafetyFindingV1>,
525 warnings: &mut Vec<SafetyFindingV1>,
526) {
527 let observed_by_role = inventory
528 .observed_canisters
529 .iter()
530 .filter_map(|canister| canister.role.as_deref().map(|role| (role, canister)))
531 .collect::<BTreeMap<_, _>>();
532
533 for artifact in &plan.role_artifacts {
534 let Some(expected) = artifact.installed_module_hash.as_ref() else {
535 continue;
536 };
537 let Some(observed_canister) = observed_by_role.get(artifact.role.as_str()) else {
538 continue;
539 };
540 match observed_canister.module_hash.as_ref() {
541 Some(observed) if observed != expected => {
542 module_hash_diff.push(diff_item(
543 "installed_module_hash",
544 &artifact.role,
545 Some(expected.clone()),
546 Some(observed.clone()),
547 SafetySeverityV1::HardFailure,
548 ));
549 hard_failures.push(finding(
550 "installed_module_hash_mismatch",
551 format!("installed module hash differs for role {}", artifact.role),
552 SafetySeverityV1::HardFailure,
553 Some(artifact.role.clone()),
554 ));
555 }
556 None => warnings.push(finding(
557 "installed_module_hash_unobserved",
558 format!(
559 "installed module hash was not observed for role {}",
560 artifact.role
561 ),
562 SafetySeverityV1::Warning,
563 Some(artifact.role.clone()),
564 )),
565 _ => {}
566 }
567 }
568}
569
570fn compare_raw_config(
571 plan: &DeploymentPlanV1,
572 inventory: &DeploymentInventoryV1,
573 embedded_config_diff: &mut Vec<DiffItemV1>,
574 hard_failures: &mut Vec<SafetyFindingV1>,
575) {
576 let mut expected = plan
577 .role_artifacts
578 .iter()
579 .filter_map(|artifact| artifact.raw_config_sha256.as_ref())
580 .collect::<Vec<_>>();
581 expected.sort_unstable();
582 expected.dedup();
583 let [expected] = expected.as_slice() else {
584 if expected.len() > 1 {
585 hard_failures.push(finding(
586 "raw_config_plan_inconsistent",
587 "planned role artifacts disagree on raw config digest",
588 SafetySeverityV1::HardFailure,
589 Some("role_artifacts.raw_config_sha256".to_string()),
590 ));
591 }
592 return;
593 };
594
595 if let Some(observed) = &inventory.local_config.raw_config_sha256
596 && observed != *expected
597 {
598 embedded_config_diff.push(diff_item(
599 "raw_config_sha256",
600 "deployment",
601 Some((*expected).clone()),
602 Some(observed.clone()),
603 SafetySeverityV1::HardFailure,
604 ));
605 hard_failures.push(finding(
606 "raw_config_digest_mismatch",
607 "raw local config digest changed during deployment truth check",
608 SafetySeverityV1::HardFailure,
609 Some("local_config.raw_sha256".to_string()),
610 ));
611 }
612}
613
614fn compare_embedded_config(
615 plan: &DeploymentPlanV1,
616 inventory: &DeploymentInventoryV1,
617 embedded_config_diff: &mut Vec<DiffItemV1>,
618 hard_failures: &mut Vec<SafetyFindingV1>,
619 warnings: &mut Vec<SafetyFindingV1>,
620) {
621 let Some(expected) = &plan.deployment_identity.canonical_runtime_config_digest else {
622 return;
623 };
624 match &inventory.local_config.canonical_embedded_config_sha256 {
625 Some(observed) if observed != expected => {
626 embedded_config_diff.push(diff_item(
627 "canonical_config",
628 "deployment",
629 Some(expected.clone()),
630 Some(observed.clone()),
631 SafetySeverityV1::HardFailure,
632 ));
633 hard_failures.push(finding(
634 "canonical_config_mismatch",
635 "canonical runtime config digest differs from the plan",
636 SafetySeverityV1::HardFailure,
637 Some("local_config".to_string()),
638 ));
639 }
640 None => warnings.push(finding(
641 "canonical_config_unobserved",
642 "canonical runtime config digest was not observed",
643 SafetySeverityV1::Warning,
644 Some("local_config".to_string()),
645 )),
646 _ => {}
647 }
648}
649
650fn compare_verifier_readiness(
651 plan: &DeploymentPlanV1,
652 inventory: &DeploymentInventoryV1,
653 verifier_readiness_diff: &mut Vec<DiffItemV1>,
654 warnings: &mut Vec<SafetyFindingV1>,
655) {
656 if !plan.expected_verifier_readiness.required {
657 return;
658 }
659 if inventory.observed_verifier_readiness.status == ObservationStatusV1::NotObserved {
660 verifier_readiness_diff.push(diff_item(
661 "verifier_readiness",
662 "deployment",
663 Some("required".to_string()),
664 Some("not_observed".to_string()),
665 SafetySeverityV1::Warning,
666 ));
667 warnings.push(finding(
668 "verifier_readiness_unobserved",
669 "verifier readiness was required but not observed",
670 SafetySeverityV1::Warning,
671 Some("verifier_readiness".to_string()),
672 ));
673 }
674}
675
676fn finding(
677 code: impl Into<String>,
678 message: impl Into<String>,
679 severity: SafetySeverityV1,
680 subject: Option<String>,
681) -> SafetyFindingV1 {
682 SafetyFindingV1 {
683 code: code.into(),
684 message: message.into(),
685 severity,
686 subject,
687 }
688}
689
690fn diff_item(
691 category: impl Into<String>,
692 subject: impl Into<String>,
693 expected: Option<String>,
694 observed: Option<String>,
695 severity: SafetySeverityV1,
696) -> DiffItemV1 {
697 DiffItemV1 {
698 category: category.into(),
699 subject: subject.into(),
700 expected,
701 observed,
702 severity,
703 }
704}
705
706const fn safety_status(
707 hard_failures: &[SafetyFindingV1],
708 warnings: &[SafetyFindingV1],
709) -> SafetyStatusV1 {
710 if !hard_failures.is_empty() {
711 SafetyStatusV1::Blocked
712 } else if !warnings.is_empty() {
713 SafetyStatusV1::Warning
714 } else {
715 SafetyStatusV1::Safe
716 }
717}
718
719fn resume_safety_reasons(
720 hard_failures: &[SafetyFindingV1],
721 warnings: &[SafetyFindingV1],
722) -> Vec<String> {
723 if !hard_failures.is_empty() {
724 return hard_failures
725 .iter()
726 .map(|finding| finding.message.clone())
727 .collect();
728 }
729 if !warnings.is_empty() {
730 return warnings
731 .iter()
732 .map(|finding| finding.message.clone())
733 .collect();
734 }
735 vec!["no blocking deployment truth differences were found".to_string()]
736}
737
738fn safety_summary(
739 status: SafetyStatusV1,
740 hard_failure_count: usize,
741 warning_count: usize,
742) -> String {
743 match status {
744 SafetyStatusV1::Safe => "deployment inventory matches the checked plan".to_string(),
745 SafetyStatusV1::Warning => {
746 format!("deployment inventory has {warning_count} warning(s)")
747 }
748 SafetyStatusV1::Blocked => {
749 format!(
750 "deployment inventory has {hard_failure_count} blocking issue(s) and {warning_count} warning(s)"
751 )
752 }
753 SafetyStatusV1::NotEvaluated => "deployment safety has not been evaluated".to_string(),
754 }
755}
756
757fn safety_next_actions(status: SafetyStatusV1) -> Vec<String> {
758 match status {
759 SafetyStatusV1::Safe => Vec::new(),
760 SafetyStatusV1::Warning => {
761 vec!["review deployment warnings before continuing".to_string()]
762 }
763 SafetyStatusV1::Blocked => {
764 vec!["resolve blocking deployment truth differences before mutation".to_string()]
765 }
766 SafetyStatusV1::NotEvaluated => vec!["collect deployment inventory".to_string()],
767 }
768}