1use super::*;
2use serde::Serialize;
3use std::collections::{BTreeMap, BTreeSet};
4
5#[derive(Serialize)]
6struct DeploymentComparisonReportDigestInput<'a> {
7 report_id: &'a str,
8 compared_at: &'a str,
9 left: &'a DeploymentComparisonTargetV1,
10 right: &'a DeploymentComparisonTargetV1,
11 status: SafetyStatusV1,
12 identity_diff: &'a [DeploymentComparisonDiffV1],
13 artifact_diff: &'a [DeploymentComparisonDiffV1],
14 module_hash_diff: &'a [DeploymentComparisonDiffV1],
15 embedded_config_diff: &'a [DeploymentComparisonDiffV1],
16 authority_diff: &'a [DeploymentComparisonDiffV1],
17 pool_diff: &'a [DeploymentComparisonDiffV1],
18 verifier_readiness_diff: &'a [DeploymentComparisonDiffV1],
19 external_lifecycle_diff: &'a [DeploymentComparisonDiffV1],
20 hard_failures: &'a [SafetyFindingV1],
21 warnings: &'a [SafetyFindingV1],
22 next_actions: &'a [String],
23}
24
25#[derive(Debug, Eq, thiserror::Error, PartialEq)]
29pub enum DeploymentComparisonReportError {
30 #[error(
31 "deployment comparison report schema version {actual} does not match expected {expected}"
32 )]
33 SchemaVersionMismatch { expected: u32, actual: u32 },
34 #[error("deployment comparison report field `{field}` is required")]
35 MissingRequiredField { field: &'static str },
36 #[error("deployment comparison report field `{field}` digest is stale")]
37 DigestMismatch { field: &'static str },
38 #[error("deployment comparison report status does not match report findings")]
39 StatusMismatch,
40}
41
42#[must_use]
46pub fn deployment_comparison_report_from_checks(
47 report_id: impl Into<String>,
48 compared_at: impl Into<String>,
49 left_label: impl Into<String>,
50 right_label: impl Into<String>,
51 left: &DeploymentCheckV1,
52 right: &DeploymentCheckV1,
53) -> DeploymentComparisonReportV1 {
54 let left_label = left_label.into();
55 let right_label = right_label.into();
56 let mut identity_diff = Vec::new();
57 let mut artifact_diff = Vec::new();
58 let mut module_hash_diff = Vec::new();
59 let mut embedded_config_diff = Vec::new();
60 let mut authority_diff = Vec::new();
61 let mut pool_diff = Vec::new();
62 let mut verifier_readiness_diff = Vec::new();
63 let mut external_lifecycle_diff = Vec::new();
64
65 compare_identity(left, right, &mut identity_diff);
66 compare_artifact_evidence(left, right, &mut artifact_diff);
67 compare_observed_module_hashes(left, right, &mut module_hash_diff);
68 compare_embedded_config_evidence(left, right, &mut embedded_config_diff);
69 compare_authority_evidence(left, right, &mut authority_diff);
70 compare_pool_evidence(left, right, &mut pool_diff);
71 compare_verifier_readiness_evidence(left, right, &mut verifier_readiness_diff);
72 compare_external_lifecycle_evidence(left, right, &mut external_lifecycle_diff);
73
74 let mut hard_failures = Vec::new();
75 let mut warnings = Vec::new();
76 compare_input_check_consistency(&left_label, left, &mut hard_failures);
77 compare_input_check_consistency(&right_label, right, &mut hard_failures);
78 compare_input_check_status(&left_label, &left.report, &mut hard_failures, &mut warnings);
79 compare_input_check_status(
80 &right_label,
81 &right.report,
82 &mut hard_failures,
83 &mut warnings,
84 );
85 let diff_groups = [
86 identity_diff.as_slice(),
87 artifact_diff.as_slice(),
88 module_hash_diff.as_slice(),
89 embedded_config_diff.as_slice(),
90 authority_diff.as_slice(),
91 pool_diff.as_slice(),
92 verifier_readiness_diff.as_slice(),
93 external_lifecycle_diff.as_slice(),
94 ];
95 warnings.extend(comparison_warnings(&diff_groups));
96 let status = comparison_status(&hard_failures, &warnings);
97 let next_actions = comparison_next_actions(status);
98
99 let mut report = DeploymentComparisonReportV1 {
100 schema_version: DEPLOYMENT_TRUTH_SCHEMA_VERSION,
101 report_id: report_id.into(),
102 report_digest: String::new(),
103 compared_at: compared_at.into(),
104 left: comparison_target(left_label, left),
105 right: comparison_target(right_label, right),
106 status,
107 identity_diff,
108 artifact_diff,
109 module_hash_diff,
110 embedded_config_diff,
111 authority_diff,
112 pool_diff,
113 verifier_readiness_diff,
114 external_lifecycle_diff,
115 hard_failures,
116 warnings,
117 next_actions,
118 };
119 report.report_digest = deployment_comparison_report_digest(&report);
120 report
121}
122
123pub fn validate_deployment_comparison_report(
125 report: &DeploymentComparisonReportV1,
126) -> Result<(), DeploymentComparisonReportError> {
127 if report.schema_version != DEPLOYMENT_TRUTH_SCHEMA_VERSION {
128 return Err(DeploymentComparisonReportError::SchemaVersionMismatch {
129 expected: DEPLOYMENT_TRUTH_SCHEMA_VERSION,
130 actual: report.schema_version,
131 });
132 }
133 ensure_comparison_field("report_id", report.report_id.as_str())?;
134 ensure_comparison_field("report_digest", report.report_digest.as_str())?;
135 ensure_comparison_field("compared_at", report.compared_at.as_str())?;
136 validate_comparison_target("left", &report.left)?;
137 validate_comparison_target("right", &report.right)?;
138 if report.status != comparison_status(&report.hard_failures, &report.warnings) {
139 return Err(DeploymentComparisonReportError::StatusMismatch);
140 }
141 if report.report_digest != deployment_comparison_report_digest(report) {
142 return Err(DeploymentComparisonReportError::DigestMismatch {
143 field: "report_digest",
144 });
145 }
146 Ok(())
147}
148
149fn comparison_target(label: String, check: &DeploymentCheckV1) -> DeploymentComparisonTargetV1 {
150 DeploymentComparisonTargetV1 {
151 label,
152 check_id: check.check_id.clone(),
153 check_digest: stable_json_sha256_hex(check),
154 plan_id: check.plan.plan_id.clone(),
155 plan_digest: stable_json_sha256_hex(&check.plan),
156 inventory_id: check.inventory.inventory_id.clone(),
157 inventory_digest: stable_json_sha256_hex(&check.inventory),
158 deployment_identity: check.plan.deployment_identity.clone(),
159 }
160}
161
162fn compare_identity(
163 left: &DeploymentCheckV1,
164 right: &DeploymentCheckV1,
165 diffs: &mut Vec<DeploymentComparisonDiffV1>,
166) {
167 compare_identity_names(left, right, diffs);
168 compare_identity_digests(left, right, diffs);
169 compare_identity_plan_shape(left, right, diffs);
170 compare_identity_trust_domain(left, right, diffs);
171}
172
173fn compare_identity_names(
174 left: &DeploymentCheckV1,
175 right: &DeploymentCheckV1,
176 diffs: &mut Vec<DeploymentComparisonDiffV1>,
177) {
178 compare_value(
179 DeploymentComparisonCategoryV1::Identity,
180 "deployment_name",
181 Some(left.plan.deployment_identity.deployment_name.as_str()),
182 Some(right.plan.deployment_identity.deployment_name.as_str()),
183 "deployment names differ",
184 diffs,
185 );
186 compare_value(
187 DeploymentComparisonCategoryV1::Identity,
188 "network",
189 Some(left.plan.deployment_identity.network.as_str()),
190 Some(right.plan.deployment_identity.network.as_str()),
191 "deployment networks differ",
192 diffs,
193 );
194 compare_optional(
195 DeploymentComparisonCategoryV1::Identity,
196 "root_principal",
197 left.plan.deployment_identity.root_principal.as_deref(),
198 right.plan.deployment_identity.root_principal.as_deref(),
199 "root principals differ",
200 diffs,
201 );
202}
203
204fn compare_identity_digests(
205 left: &DeploymentCheckV1,
206 right: &DeploymentCheckV1,
207 diffs: &mut Vec<DeploymentComparisonDiffV1>,
208) {
209 compare_optional(
210 DeploymentComparisonCategoryV1::Identity,
211 "authority_profile_hash",
212 left.plan
213 .deployment_identity
214 .authority_profile_hash
215 .as_deref(),
216 right
217 .plan
218 .deployment_identity
219 .authority_profile_hash
220 .as_deref(),
221 "authority profile hashes differ",
222 diffs,
223 );
224 compare_optional(
225 DeploymentComparisonCategoryV1::Identity,
226 "artifact_set_digest",
227 left.plan.deployment_identity.artifact_set_digest.as_deref(),
228 right
229 .plan
230 .deployment_identity
231 .artifact_set_digest
232 .as_deref(),
233 "artifact set digests differ",
234 diffs,
235 );
236 compare_optional(
237 DeploymentComparisonCategoryV1::Identity,
238 "role_topology_hash",
239 left.plan.deployment_identity.role_topology_hash.as_deref(),
240 right.plan.deployment_identity.role_topology_hash.as_deref(),
241 "role topology hashes differ",
242 diffs,
243 );
244 compare_optional(
245 DeploymentComparisonCategoryV1::Identity,
246 "pool_identity_set_digest",
247 left.plan
248 .deployment_identity
249 .pool_identity_set_digest
250 .as_deref(),
251 right
252 .plan
253 .deployment_identity
254 .pool_identity_set_digest
255 .as_deref(),
256 "pool identity set digests differ",
257 diffs,
258 );
259 compare_optional(
260 DeploymentComparisonCategoryV1::Identity,
261 "canonical_runtime_config_digest",
262 left.plan
263 .deployment_identity
264 .canonical_runtime_config_digest
265 .as_deref(),
266 right
267 .plan
268 .deployment_identity
269 .canonical_runtime_config_digest
270 .as_deref(),
271 "canonical runtime config digests differ",
272 diffs,
273 );
274 compare_optional(
275 DeploymentComparisonCategoryV1::Identity,
276 "role_embedded_config_set_digest",
277 left.plan
278 .deployment_identity
279 .role_embedded_config_set_digest
280 .as_deref(),
281 right
282 .plan
283 .deployment_identity
284 .role_embedded_config_set_digest
285 .as_deref(),
286 "role embedded config set digests differ",
287 diffs,
288 );
289}
290
291fn compare_identity_plan_shape(
292 left: &DeploymentCheckV1,
293 right: &DeploymentCheckV1,
294 diffs: &mut Vec<DeploymentComparisonDiffV1>,
295) {
296 compare_value(
297 DeploymentComparisonCategoryV1::Identity,
298 "fleet_template",
299 Some(left.plan.fleet_template.as_str()),
300 Some(right.plan.fleet_template.as_str()),
301 "fleet templates differ",
302 diffs,
303 );
304 compare_value(
305 DeploymentComparisonCategoryV1::Identity,
306 "runtime_variant",
307 Some(left.plan.runtime_variant.as_str()),
308 Some(right.plan.runtime_variant.as_str()),
309 "runtime variants differ",
310 diffs,
311 );
312}
313
314fn compare_identity_trust_domain(
315 left: &DeploymentCheckV1,
316 right: &DeploymentCheckV1,
317 diffs: &mut Vec<DeploymentComparisonDiffV1>,
318) {
319 compare_optional(
320 DeploymentComparisonCategoryV1::TrustDomain,
321 "root_trust_anchor",
322 left.plan.trust_domain.root_trust_anchor.as_deref(),
323 right.plan.trust_domain.root_trust_anchor.as_deref(),
324 "root trust anchors differ",
325 diffs,
326 );
327 compare_optional(
328 DeploymentComparisonCategoryV1::TrustDomain,
329 "migration_from",
330 left.plan.trust_domain.migration_from.as_deref(),
331 right.plan.trust_domain.migration_from.as_deref(),
332 "migration sources differ",
333 diffs,
334 );
335}
336
337fn compare_artifact_evidence(
338 left: &DeploymentCheckV1,
339 right: &DeploymentCheckV1,
340 diffs: &mut Vec<DeploymentComparisonDiffV1>,
341) {
342 compare_maps(
343 DeploymentComparisonCategoryV1::Artifact,
344 &role_artifact_fingerprints(&left.plan.role_artifacts),
345 &role_artifact_fingerprints(&right.plan.role_artifacts),
346 "role artifact identity differs",
347 diffs,
348 );
349}
350
351fn compare_observed_module_hashes(
352 left: &DeploymentCheckV1,
353 right: &DeploymentCheckV1,
354 diffs: &mut Vec<DeploymentComparisonDiffV1>,
355) {
356 compare_maps(
357 DeploymentComparisonCategoryV1::ModuleHash,
358 &observed_canister_map(&left.inventory, |canister| {
359 canister
360 .module_hash
361 .clone()
362 .unwrap_or_else(|| "missing".into())
363 }),
364 &observed_canister_map(&right.inventory, |canister| {
365 canister
366 .module_hash
367 .clone()
368 .unwrap_or_else(|| "missing".into())
369 }),
370 "observed module hash differs",
371 diffs,
372 );
373}
374
375fn compare_embedded_config_evidence(
376 left: &DeploymentCheckV1,
377 right: &DeploymentCheckV1,
378 diffs: &mut Vec<DeploymentComparisonDiffV1>,
379) {
380 compare_maps(
381 DeploymentComparisonCategoryV1::EmbeddedConfig,
382 &observed_canister_map(&left.inventory, |canister| {
383 canister
384 .canonical_embedded_config_digest
385 .clone()
386 .unwrap_or_else(|| "missing".into())
387 }),
388 &observed_canister_map(&right.inventory, |canister| {
389 canister
390 .canonical_embedded_config_digest
391 .clone()
392 .unwrap_or_else(|| "missing".into())
393 }),
394 "observed embedded config digest differs",
395 diffs,
396 );
397}
398
399fn compare_authority_evidence(
400 left: &DeploymentCheckV1,
401 right: &DeploymentCheckV1,
402 diffs: &mut Vec<DeploymentComparisonDiffV1>,
403) {
404 compare_maps(
405 DeploymentComparisonCategoryV1::Authority,
406 &observed_canister_map(&left.inventory, canister_authority_fingerprint),
407 &observed_canister_map(&right.inventory, canister_authority_fingerprint),
408 "observed authority evidence differs",
409 diffs,
410 );
411}
412
413fn compare_pool_evidence(
414 left: &DeploymentCheckV1,
415 right: &DeploymentCheckV1,
416 diffs: &mut Vec<DeploymentComparisonDiffV1>,
417) {
418 compare_maps(
419 DeploymentComparisonCategoryV1::Pool,
420 &pool_fingerprints(&left.inventory.observed_pool),
421 &pool_fingerprints(&right.inventory.observed_pool),
422 "observed pool evidence differs",
423 diffs,
424 );
425}
426
427fn compare_verifier_readiness_evidence(
428 left: &DeploymentCheckV1,
429 right: &DeploymentCheckV1,
430 diffs: &mut Vec<DeploymentComparisonDiffV1>,
431) {
432 compare_value(
433 DeploymentComparisonCategoryV1::VerifierReadiness,
434 "verifier_readiness",
435 Some(stable_json_sha256_hex(&left.inventory.observed_verifier_readiness).as_str()),
436 Some(stable_json_sha256_hex(&right.inventory.observed_verifier_readiness).as_str()),
437 "verifier readiness observations differ",
438 diffs,
439 );
440}
441
442fn compare_external_lifecycle_evidence(
443 left: &DeploymentCheckV1,
444 right: &DeploymentCheckV1,
445 diffs: &mut Vec<DeploymentComparisonDiffV1>,
446) {
447 compare_maps(
448 DeploymentComparisonCategoryV1::ExternalLifecycle,
449 &control_class_counts(&left.inventory),
450 &control_class_counts(&right.inventory),
451 "external lifecycle control-class evidence differs",
452 diffs,
453 );
454}
455
456fn role_artifact_fingerprints(artifacts: &[RoleArtifactV1]) -> BTreeMap<String, String> {
457 artifacts
458 .iter()
459 .map(|artifact| {
460 (
461 artifact.role.clone(),
462 stable_json_sha256_hex(&(
463 artifact.source,
464 artifact.wasm_sha256.as_deref(),
465 artifact.wasm_gz_sha256.as_deref(),
466 artifact.installed_module_hash.as_deref(),
467 artifact.candid_sha256.as_deref(),
468 artifact.canonical_embedded_config_sha256.as_deref(),
469 artifact.package_version.as_deref(),
470 )),
471 )
472 })
473 .collect()
474}
475
476fn observed_canister_map(
477 inventory: &DeploymentInventoryV1,
478 value: impl Fn(&ObservedCanisterV1) -> String,
479) -> BTreeMap<String, String> {
480 inventory
481 .observed_canisters
482 .iter()
483 .map(|canister| (canister_subject(canister), value(canister)))
484 .collect()
485}
486
487fn canister_authority_fingerprint(canister: &ObservedCanisterV1) -> String {
488 stable_json_sha256_hex(&(
489 canister.control_class,
490 &canister.controllers,
491 canister.root_trust_anchor.as_deref(),
492 ))
493}
494
495fn pool_fingerprints(pool: &[ObservedPoolCanisterV1]) -> BTreeMap<String, String> {
496 pool.iter()
497 .map(|canister| {
498 (
499 format!("{}:{}", canister.pool, canister.canister_id),
500 stable_json_sha256_hex(&(canister.role.as_deref(), canister.control_class)),
501 )
502 })
503 .collect()
504}
505
506fn control_class_counts(inventory: &DeploymentInventoryV1) -> BTreeMap<String, String> {
507 let mut counts: BTreeMap<String, usize> = BTreeMap::new();
508 for canister in &inventory.observed_canisters {
509 *counts
510 .entry(format!("{:?}", canister.control_class))
511 .or_default() += 1;
512 }
513 counts
514 .into_iter()
515 .map(|(class, count)| (class, count.to_string()))
516 .collect()
517}
518
519fn canister_subject(canister: &ObservedCanisterV1) -> String {
520 canister
521 .role
522 .as_ref()
523 .map_or_else(|| canister.canister_id.clone(), Clone::clone)
524}
525
526fn compare_maps(
527 category: DeploymentComparisonCategoryV1,
528 left: &BTreeMap<String, String>,
529 right: &BTreeMap<String, String>,
530 message: &'static str,
531 diffs: &mut Vec<DeploymentComparisonDiffV1>,
532) {
533 let subjects: BTreeSet<_> = left.keys().chain(right.keys()).cloned().collect();
534 for subject in subjects {
535 compare_optional(
536 category,
537 &subject,
538 left.get(&subject).map(String::as_str),
539 right.get(&subject).map(String::as_str),
540 message,
541 diffs,
542 );
543 }
544}
545
546fn compare_value(
547 category: DeploymentComparisonCategoryV1,
548 subject: &str,
549 left: Option<&str>,
550 right: Option<&str>,
551 message: &'static str,
552 diffs: &mut Vec<DeploymentComparisonDiffV1>,
553) {
554 if left == right {
555 return;
556 }
557 diffs.push(DeploymentComparisonDiffV1 {
558 category,
559 subject: subject.to_string(),
560 left: left.map(str::to_string),
561 right: right.map(str::to_string),
562 severity: SafetySeverityV1::Warning,
563 message: message.to_string(),
564 });
565}
566
567fn compare_optional(
568 category: DeploymentComparisonCategoryV1,
569 subject: &str,
570 left: Option<&str>,
571 right: Option<&str>,
572 message: &'static str,
573 diffs: &mut Vec<DeploymentComparisonDiffV1>,
574) {
575 compare_value(category, subject, left, right, message, diffs);
576}
577
578fn comparison_warnings(diff_groups: &[&[DeploymentComparisonDiffV1]]) -> Vec<SafetyFindingV1> {
579 let diff_count = diff_groups.iter().map(|group| group.len()).sum::<usize>();
580 if diff_count == 0 {
581 return Vec::new();
582 }
583 vec![SafetyFindingV1 {
584 code: "deployment_comparison_drift".to_string(),
585 message: format!("deployment comparison found {diff_count} drift item(s)"),
586 severity: SafetySeverityV1::Warning,
587 subject: None,
588 }]
589}
590
591fn compare_input_check_status(
592 label: &str,
593 report: &SafetyReportV1,
594 hard_failures: &mut Vec<SafetyFindingV1>,
595 warnings: &mut Vec<SafetyFindingV1>,
596) {
597 match report.status {
598 SafetyStatusV1::Safe => {}
599 SafetyStatusV1::Warning => warnings.push(SafetyFindingV1 {
600 code: "deployment_comparison_input_warning".to_string(),
601 message: "input deployment check has warnings; comparison is drift evidence, not whole-deployment safety".to_string(),
602 severity: SafetySeverityV1::Warning,
603 subject: Some(format!("{label}:{}", report.report_id)),
604 }),
605 SafetyStatusV1::Blocked => hard_failures.push(SafetyFindingV1 {
606 code: "deployment_comparison_input_blocked".to_string(),
607 message: "input deployment check is blocked; comparison cannot be used as ready deployment evidence".to_string(),
608 severity: SafetySeverityV1::HardFailure,
609 subject: Some(format!("{label}:{}", report.report_id)),
610 }),
611 SafetyStatusV1::NotEvaluated => hard_failures.push(SafetyFindingV1 {
612 code: "deployment_comparison_input_not_evaluated".to_string(),
613 message: "input deployment check was not evaluated; comparison cannot establish deployment safety".to_string(),
614 severity: SafetySeverityV1::HardFailure,
615 subject: Some(format!("{label}:{}", report.report_id)),
616 }),
617 }
618}
619
620fn compare_input_check_consistency(
621 label: &str,
622 check: &DeploymentCheckV1,
623 hard_failures: &mut Vec<SafetyFindingV1>,
624) {
625 if check.schema_version != DEPLOYMENT_TRUTH_SCHEMA_VERSION {
626 hard_failures.push(SafetyFindingV1 {
627 code: "deployment_comparison_input_schema_mismatch".to_string(),
628 message: "input deployment check schema version is unsupported".to_string(),
629 severity: SafetySeverityV1::HardFailure,
630 subject: Some(format!("{label}:{}", check.check_id)),
631 });
632 return;
633 }
634
635 let expected_diff = compare_plan_to_inventory(&check.plan, &check.inventory);
636 if check.diff != expected_diff {
637 hard_failures.push(SafetyFindingV1 {
638 code: "deployment_comparison_input_diff_stale".to_string(),
639 message: "input deployment check diff does not match its plan and inventory"
640 .to_string(),
641 severity: SafetySeverityV1::HardFailure,
642 subject: Some(format!("{label}:{}", check.check_id)),
643 });
644 return;
645 }
646
647 let expected_report = safety_report_from_diff(
648 &check.report.report_id,
649 check.report.diff_id.clone(),
650 &check.diff,
651 );
652 if check.report != expected_report {
653 hard_failures.push(SafetyFindingV1 {
654 code: "deployment_comparison_input_report_stale".to_string(),
655 message: "input deployment check report does not match its diff".to_string(),
656 severity: SafetySeverityV1::HardFailure,
657 subject: Some(format!("{label}:{}", check.check_id)),
658 });
659 }
660}
661
662const fn comparison_status(
663 hard_failures: &[SafetyFindingV1],
664 warnings: &[SafetyFindingV1],
665) -> SafetyStatusV1 {
666 if !hard_failures.is_empty() {
667 SafetyStatusV1::Blocked
668 } else if !warnings.is_empty() {
669 SafetyStatusV1::Warning
670 } else {
671 SafetyStatusV1::Safe
672 }
673}
674
675fn comparison_next_actions(status: SafetyStatusV1) -> Vec<String> {
676 match status {
677 SafetyStatusV1::Safe => vec!["no cross-deployment drift detected".to_string()],
678 SafetyStatusV1::Warning => {
679 vec!["review comparison drift before promotion, rebuild, or teardown".to_string()]
680 }
681 SafetyStatusV1::Blocked => {
682 vec!["resolve hard comparison failures before using this evidence".to_string()]
683 }
684 SafetyStatusV1::NotEvaluated => vec!["run deployment comparison".to_string()],
685 }
686}
687
688fn deployment_comparison_report_digest(report: &DeploymentComparisonReportV1) -> String {
689 stable_json_sha256_hex(&DeploymentComparisonReportDigestInput {
690 report_id: &report.report_id,
691 compared_at: &report.compared_at,
692 left: &report.left,
693 right: &report.right,
694 status: report.status,
695 identity_diff: &report.identity_diff,
696 artifact_diff: &report.artifact_diff,
697 module_hash_diff: &report.module_hash_diff,
698 embedded_config_diff: &report.embedded_config_diff,
699 authority_diff: &report.authority_diff,
700 pool_diff: &report.pool_diff,
701 verifier_readiness_diff: &report.verifier_readiness_diff,
702 external_lifecycle_diff: &report.external_lifecycle_diff,
703 hard_failures: &report.hard_failures,
704 warnings: &report.warnings,
705 next_actions: &report.next_actions,
706 })
707}
708
709fn validate_comparison_target(
710 prefix: &'static str,
711 target: &DeploymentComparisonTargetV1,
712) -> Result<(), DeploymentComparisonReportError> {
713 ensure_comparison_field(field_name(prefix, "label"), target.label.as_str())?;
714 ensure_comparison_field(field_name(prefix, "check_id"), target.check_id.as_str())?;
715 ensure_comparison_field(
716 field_name(prefix, "check_digest"),
717 target.check_digest.as_str(),
718 )?;
719 ensure_comparison_field(field_name(prefix, "plan_id"), target.plan_id.as_str())?;
720 ensure_comparison_field(
721 field_name(prefix, "plan_digest"),
722 target.plan_digest.as_str(),
723 )?;
724 ensure_comparison_field(
725 field_name(prefix, "inventory_id"),
726 target.inventory_id.as_str(),
727 )?;
728 ensure_comparison_field(
729 field_name(prefix, "inventory_digest"),
730 target.inventory_digest.as_str(),
731 )?;
732 ensure_comparison_field(
733 field_name(prefix, "deployment_name"),
734 target.deployment_identity.deployment_name.as_str(),
735 )?;
736 ensure_comparison_field(
737 field_name(prefix, "network"),
738 target.deployment_identity.network.as_str(),
739 )?;
740 Ok(())
741}
742
743fn field_name(prefix: &'static str, field: &'static str) -> &'static str {
744 match (prefix, field) {
745 ("left", "label") => "left.label",
746 ("left", "check_id") => "left.check_id",
747 ("left", "check_digest") => "left.check_digest",
748 ("left", "plan_id") => "left.plan_id",
749 ("left", "plan_digest") => "left.plan_digest",
750 ("left", "inventory_id") => "left.inventory_id",
751 ("left", "inventory_digest") => "left.inventory_digest",
752 ("left", "deployment_name") => "left.deployment_identity.deployment_name",
753 ("left", "network") => "left.deployment_identity.network",
754 ("right", "label") => "right.label",
755 ("right", "check_id") => "right.check_id",
756 ("right", "check_digest") => "right.check_digest",
757 ("right", "plan_id") => "right.plan_id",
758 ("right", "plan_digest") => "right.plan_digest",
759 ("right", "inventory_id") => "right.inventory_id",
760 ("right", "inventory_digest") => "right.inventory_digest",
761 ("right", "deployment_name") => "right.deployment_identity.deployment_name",
762 ("right", "network") => "right.deployment_identity.network",
763 _ => field,
764 }
765}
766
767fn ensure_comparison_field(
768 field: &'static str,
769 value: &str,
770) -> Result<(), DeploymentComparisonReportError> {
771 if value.trim().is_empty() {
772 return Err(DeploymentComparisonReportError::MissingRequiredField { field });
773 }
774 Ok(())
775}