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