1use super::*;
2use std::collections::{BTreeMap, BTreeSet};
3
4mod artifacts;
5mod canisters;
6mod config_digests;
7mod controllers;
8mod module_hashes;
9mod pools;
10mod receipt_resume;
11mod root_subnet;
12mod safety;
13mod verifier_readiness;
14
15use artifacts::compare_artifacts;
16#[cfg(test)]
17pub(super) use artifacts::{
18 ARTIFACT_DUPLICATE_DIFF_CATEGORY, ARTIFACT_FILE_DIGEST_MISMATCH_CODE,
19 ARTIFACT_FILE_SHA256_DIFF_CATEGORY, ARTIFACT_ROLE_CONFLICT_DIFF_CATEGORY,
20 DUPLICATE_ARTIFACT_OBSERVED_CODE, DUPLICATE_PLANNED_ARTIFACT_ROLE_CODE,
21 PLANNED_ARTIFACT_DUPLICATE_DIFF_CATEGORY, PLANNED_ARTIFACT_ROLE_CONFLICT_DIFF_CATEGORY,
22};
23pub(in crate::deployment_truth) use artifacts::{
24 ARTIFACT_MISSING_CODE, is_artifact_role_failure_code,
25};
26pub(super) use artifacts::{ARTIFACT_ROLE_CONFLICT_CODE, PLANNED_ARTIFACT_ROLE_CONFLICT_CODE};
27#[cfg(test)]
28pub(super) use canisters::{
29 CANISTER_DUPLICATE_DIFF_CATEGORY, CANISTER_EXTRA_DIFF_CATEGORY,
30 CANISTER_ID_ROLE_CONFLICT_DIFF_CATEGORY, CANISTER_ROLE_AMBIGUOUS_CODE,
31 CANISTER_ROLE_AMBIGUOUS_DIFF_CATEGORY, CANISTER_ROLE_MISMATCH_CODE, CANISTER_UNOBSERVED_CODE,
32 DUPLICATE_CANISTER_OBSERVED_CODE, DUPLICATE_PLANNED_CANISTER_ROLE_CODE,
33 EXTRA_CANISTER_OBSERVED_CODE, PLANNED_CANISTER_DUPLICATE_DIFF_CATEGORY,
34 PLANNED_CANISTER_ID_CONFLICT_DIFF_CATEGORY, PLANNED_CANISTER_ROLE_CONFLICT_DIFF_CATEGORY,
35 ROLE_MISMATCH_DIFF_CATEGORY, SUBNET_REGISTRY_ROLE_MISSING_CODE, UNSAFE_CONTROL_CLASS_CODE,
36};
37pub(super) use canisters::{
38 CANISTER_ID_ROLE_CONFLICT_CODE, PLANNED_CANISTER_ID_CONFLICT_CODE,
39 PLANNED_CANISTER_ROLE_CONFLICT_CODE,
40};
41use canisters::{compare_canisters, compare_observed_canister_id_conflicts};
42#[cfg(test)]
43pub(super) use config_digests::{RAW_CONFIG_DIGEST_MISMATCH_CODE, RAW_CONFIG_SHA256_DIFF_CATEGORY};
44use config_digests::{compare_embedded_config, compare_raw_config};
45use controllers::compare_authority_profile;
46#[cfg(test)]
47pub(super) use controllers::{
48 CONTROLLER_AUTHORITY_OVERLAP_CODE, CONTROLLER_EXTRA_DIFF_CATEGORY,
49 CONTROLLER_MISSING_DIFF_CATEGORY, CONTROLLERS_UNOBSERVED_CODE,
50 EXPECTED_CONTROLLER_MISSING_CODE, EXTRA_CONTROLLER_OBSERVED_CODE,
51};
52use module_hashes::compare_module_hashes;
53#[cfg(test)]
54pub(super) use module_hashes::{
55 INSTALLED_MODULE_HASH_AMBIGUOUS_CODE, INSTALLED_MODULE_HASH_AMBIGUOUS_DIFF_CATEGORY,
56 INSTALLED_MODULE_HASH_DIFF_CATEGORY, INSTALLED_MODULE_HASH_MISMATCH_CODE,
57};
58pub(super) use pools::{
59 CANISTER_POOL_ROLE_CONFLICT_CODE, PLANNED_POOL_CONFLICT_CODE, PLANNED_POOL_ID_CONFLICT_CODE,
60 POOL_CANISTER_ID_CONFLICT_CODE,
61};
62#[cfg(test)]
63pub(super) use pools::{
64 CANISTER_POOL_ROLE_CONFLICT_DIFF_CATEGORY, DUPLICATE_PLANNED_POOL_CODE,
65 DUPLICATE_POOL_CANISTER_OBSERVED_CODE, EXTRA_POOL_CANISTER_OBSERVED_CODE,
66 PLANNED_POOL_CONFLICT_DIFF_CATEGORY, PLANNED_POOL_DUPLICATE_DIFF_CATEGORY,
67 PLANNED_POOL_ID_CONFLICT_DIFF_CATEGORY, POOL_CANISTER_DIFF_CATEGORY,
68 POOL_CANISTER_DUPLICATE_DIFF_CATEGORY, POOL_CANISTER_ID_CONFLICT_DIFF_CATEGORY,
69 POOL_CANISTER_ID_DIFF_CATEGORY, POOL_CANISTER_ID_MISMATCH_CODE, POOL_CANISTER_MISSING_CODE,
70 POOL_CONTROL_CLASS_DIFF_CATEGORY, POOL_EXTRA_DIFF_CATEGORY, UNSAFE_POOL_CONTROL_CLASS_CODE,
71};
72use pools::{compare_observed_canister_pool_role_conflicts, compare_pools};
73pub use receipt_resume::compare_plan_inventory_and_receipt;
74#[cfg(test)]
75pub(super) use receipt_resume::{
76 DUPLICATE_RECEIPT_PHASE_CODE, DUPLICATE_RECEIPT_ROLE_PHASE_CODE,
77 RECEIPT_EXECUTION_STATUS_MISMATCH_CODE, RECEIPT_PLAN_MISMATCH_CODE,
78 RECEIPT_POSTCONDITION_UNVERIFIED_CODE,
79};
80pub(super) use receipt_resume::{RECEIPT_PHASE_CONFLICT_CODE, RECEIPT_ROLE_PHASE_CONFLICT_CODE};
81#[cfg(test)]
82pub(super) use root_subnet::ROOT_AUTH_CLOUD_ENGINE_SUBNET_CODE;
83pub(super) use root_subnet::apply_root_auth_signer_subnet_check;
84#[cfg(test)]
85pub(super) use root_subnet::{
86 RootSubnetEvidence, RootSubnetEvidenceSource, apply_root_auth_signer_subnet_check_with_source,
87};
88pub use safety::safety_report_from_diff;
89pub(in crate::deployment_truth::report) use safety::{resume_safety_reasons, safety_status};
90use verifier_readiness::compare_verifier_readiness;
91#[cfg(test)]
92pub(super) use verifier_readiness::{
93 DUPLICATE_PLANNED_VERIFIER_ROLE_EPOCH_CODE, DUPLICATE_VERIFIER_ROLE_EPOCH_OBSERVED_CODE,
94 PLANNED_VERIFIER_ROLE_EPOCH_CONFLICT_DIFF_CATEGORY,
95 PLANNED_VERIFIER_ROLE_EPOCH_DUPLICATE_DIFF_CATEGORY, VERIFIER_NOT_OBSERVED_LABEL,
96 VERIFIER_ROLE_EPOCH_CONFLICT_DIFF_CATEGORY, VERIFIER_ROLE_EPOCH_DIFF_CATEGORY,
97 VERIFIER_ROLE_EPOCH_DUPLICATE_DIFF_CATEGORY, VERIFIER_ROLE_EPOCH_STALE_CODE,
98 VERIFIER_ROLE_EPOCH_UNOBSERVED_CODE,
99};
100pub(super) use verifier_readiness::{
101 PLANNED_VERIFIER_ROLE_EPOCH_CONFLICT_CODE, VERIFIER_ROLE_EPOCH_CONFLICT_CODE,
102};
103
104pub(in crate::deployment_truth) const DEPLOYMENT_MANIFEST_MISMATCH_CODE: &str =
105 "deployment_manifest_mismatch";
106pub(in crate::deployment_truth) const OBSERVATION_GAP_CODE: &str = "observation_gap";
107pub(in crate::deployment_truth) const PLAN_ASSUMPTION_CODE: &str = "plan_assumption";
108pub(in crate::deployment_truth) const IDENTITY_UNOBSERVED_CODE: &str = "identity_unobserved";
109pub(in crate::deployment_truth) const ENVIRONMENT_MISMATCH_CODE: &str = "environment_mismatch";
110pub(in crate::deployment_truth) const FLEET_IDENTITY_MISMATCH_CODE: &str =
111 "fleet_identity_mismatch";
112pub(in crate::deployment_truth) const ROOT_TRUST_ANCHOR_MISMATCH_CODE: &str =
113 "root_trust_anchor_mismatch";
114pub(in crate::deployment_truth) const DEPLOYMENT_MANIFEST_UNOBSERVED_CODE: &str =
115 "deployment_manifest_unobserved";
116
117#[must_use]
118pub fn is_evidence_conflict_finding_code(code: &str) -> bool {
119 matches!(
120 code,
121 PLANNED_ARTIFACT_ROLE_CONFLICT_CODE
122 | ARTIFACT_ROLE_CONFLICT_CODE
123 | CANISTER_ID_ROLE_CONFLICT_CODE
124 | PLANNED_CANISTER_ROLE_CONFLICT_CODE
125 | PLANNED_CANISTER_ID_CONFLICT_CODE
126 | CANISTER_POOL_ROLE_CONFLICT_CODE
127 | PLANNED_POOL_CONFLICT_CODE
128 | PLANNED_POOL_ID_CONFLICT_CODE
129 | POOL_CANISTER_ID_CONFLICT_CODE
130 | RECEIPT_PHASE_CONFLICT_CODE
131 | RECEIPT_ROLE_PHASE_CONFLICT_CODE
132 | PLANNED_VERIFIER_ROLE_EPOCH_CONFLICT_CODE
133 | VERIFIER_ROLE_EPOCH_CONFLICT_CODE
134 )
135}
136
137struct DuplicateEvidenceGroup {
141 subject: String,
142 count: usize,
143 evidence_label: String,
144 is_conflict: bool,
145}
146
147#[derive(Clone, Debug, Eq, PartialEq)]
151pub struct LocalDeploymentCheckRequest {
152 pub fleet_name: String,
153 pub app: String,
154 pub environment: String,
155 pub artifact_environment: String,
156 pub workspace_root: std::path::PathBuf,
157 pub icp_root: std::path::PathBuf,
158 pub config_path: Option<std::path::PathBuf>,
159 pub observed_at: String,
160 pub runtime_variant: String,
161 pub build_profile: String,
162}
163
164pub fn check_local_deployment(
166 request: &LocalDeploymentCheckRequest,
167) -> Result<DeploymentCheckV1, DeploymentTruthError> {
168 let plan = build_local_deployment_plan(&LocalDeploymentPlanRequest {
169 fleet_name: request.fleet_name.clone(),
170 app: request.app.clone(),
171 environment: request.environment.clone(),
172 artifact_environment: request.artifact_environment.clone(),
173 workspace_root: request.workspace_root.clone(),
174 icp_root: request.icp_root.clone(),
175 config_path: request.config_path.clone(),
176 runtime_variant: request.runtime_variant.clone(),
177 build_profile: request.build_profile.clone(),
178 });
179 let inventory = collect_local_deployment_inventory(&LocalInventoryRequest {
180 fleet_name: request.fleet_name.clone(),
181 environment: request.environment.clone(),
182 artifact_environment: request.artifact_environment.clone(),
183 workspace_root: request.workspace_root.clone(),
184 icp_root: request.icp_root.clone(),
185 config_path: request.config_path.clone(),
186 observed_at: request.observed_at.clone(),
187 })?;
188 let mut diff = compare_plan_to_inventory(&plan, &inventory);
189 apply_root_auth_signer_subnet_check(
190 &mut diff,
191 &inventory,
192 &request.environment,
193 &request.icp_root,
194 );
195 let report = safety_report_from_diff(
196 format!(
197 "local:{}:{}:report",
198 request.environment, request.fleet_name
199 ),
200 Some(format!(
201 "local:{}:{}:diff",
202 request.environment, request.fleet_name
203 )),
204 &diff,
205 );
206
207 Ok(DeploymentCheckV1 {
208 schema_version: DEPLOYMENT_TRUTH_SCHEMA_VERSION,
209 check_id: format!("local:{}:{}:check", request.environment, request.fleet_name),
210 plan,
211 inventory,
212 diff,
213 report,
214 })
215}
216
217fn refresh_resume_safety(diff: &mut DeploymentDiffV1) {
218 diff.resume_safety.status = safety_status(&diff.hard_failures, &diff.warnings);
219 diff.resume_safety.reasons = resume_safety_reasons(&diff.hard_failures, &diff.warnings);
220}
221
222#[must_use]
224pub fn compare_plan_to_inventory(
225 plan: &DeploymentPlanV1,
226 inventory: &DeploymentInventoryV1,
227) -> DeploymentDiffV1 {
228 let mut artifact_diff = Vec::new();
229 let mut controller_diff = Vec::new();
230 let mut pool_diff = Vec::new();
231 let mut embedded_config_diff = Vec::new();
232 let mut module_hash_diff = Vec::new();
233 let mut verifier_readiness_diff = Vec::new();
234 let mut hard_failures = Vec::new();
235 let mut warnings = Vec::new();
236
237 compare_identity(plan, inventory, &mut hard_failures);
238 compare_authority_profile(plan, &mut controller_diff, &mut hard_failures);
239 compare_artifacts(
240 plan,
241 inventory,
242 &mut artifact_diff,
243 &mut hard_failures,
244 &mut warnings,
245 );
246 compare_observed_canister_id_conflicts(
247 inventory,
248 &mut controller_diff,
249 &mut hard_failures,
250 &mut warnings,
251 );
252 compare_observed_canister_pool_role_conflicts(inventory, &mut pool_diff, &mut hard_failures);
253 compare_canisters(
254 plan,
255 inventory,
256 &mut controller_diff,
257 &mut hard_failures,
258 &mut warnings,
259 );
260 compare_pools(
261 plan,
262 inventory,
263 &mut pool_diff,
264 &mut hard_failures,
265 &mut warnings,
266 );
267 compare_module_hashes(
268 plan,
269 inventory,
270 &mut module_hash_diff,
271 &mut hard_failures,
272 &mut warnings,
273 );
274 compare_raw_config(
275 plan,
276 inventory,
277 &mut embedded_config_diff,
278 &mut hard_failures,
279 );
280 compare_embedded_config(
281 plan,
282 inventory,
283 &mut embedded_config_diff,
284 &mut hard_failures,
285 &mut warnings,
286 );
287 compare_verifier_readiness(
288 plan,
289 inventory,
290 &mut verifier_readiness_diff,
291 &mut hard_failures,
292 &mut warnings,
293 );
294 record_plan_assumptions(plan, &mut warnings);
295 for gap in &inventory.unresolved_observations {
296 warnings.push(SafetyFindingV1 {
297 code: OBSERVATION_GAP_CODE.to_string(),
298 message: gap.description.clone(),
299 severity: SafetySeverityV1::Warning,
300 subject: Some(gap.key.clone()),
301 });
302 }
303
304 let status = safety_status(&hard_failures, &warnings);
305 DeploymentDiffV1 {
306 schema_version: DEPLOYMENT_TRUTH_SCHEMA_VERSION,
307 plan_identity: plan.deployment_identity.clone(),
308 observed_identity: inventory.observed_identity.clone(),
309 artifact_diff,
310 controller_diff,
311 pool_diff,
312 embedded_config_diff,
313 module_hash_diff,
314 verifier_readiness_diff,
315 resume_safety: ResumeSafetyV1 {
316 status,
317 reasons: resume_safety_reasons(&hard_failures, &warnings),
318 },
319 hard_failures,
320 warnings,
321 resumable_phases: Vec::new(),
322 }
323}
324
325fn record_plan_assumptions(plan: &DeploymentPlanV1, warnings: &mut Vec<SafetyFindingV1>) {
326 for assumption in &plan.unresolved_assumptions {
327 warnings.push(SafetyFindingV1 {
328 code: PLAN_ASSUMPTION_CODE.to_string(),
329 message: assumption.description.clone(),
330 severity: SafetySeverityV1::Warning,
331 subject: Some(assumption.key.clone()),
332 });
333 }
334}
335
336fn compare_identity(
337 plan: &DeploymentPlanV1,
338 inventory: &DeploymentInventoryV1,
339 hard_failures: &mut Vec<SafetyFindingV1>,
340) {
341 let Some(observed) = &inventory.observed_identity else {
342 hard_failures.push(finding(
343 IDENTITY_UNOBSERVED_CODE,
344 "deployment identity was not observed",
345 SafetySeverityV1::HardFailure,
346 None,
347 ));
348 return;
349 };
350
351 if observed.environment != plan.deployment_identity.environment {
352 hard_failures.push(finding(
353 ENVIRONMENT_MISMATCH_CODE,
354 format!(
355 "plan environment {} differs from observed environment {}",
356 plan.deployment_identity.environment, observed.environment
357 ),
358 SafetySeverityV1::HardFailure,
359 Some("deployment_identity.environment".to_string()),
360 ));
361 }
362 for (field, expected, actual) in [
363 (
364 "fleet_name",
365 Some(plan.deployment_identity.fleet_name.clone()),
366 Some(observed.fleet_name.clone()),
367 ),
368 (
369 "app",
370 Some(plan.deployment_identity.app.clone()),
371 Some(observed.app.clone()),
372 ),
373 (
374 "canonical_network_id",
375 plan.deployment_identity
376 .canonical_network_id
377 .map(|value| value.to_string()),
378 observed.canonical_network_id.map(|value| value.to_string()),
379 ),
380 (
381 "fleet_id",
382 plan.deployment_identity
383 .fleet_id
384 .map(|value| value.to_string()),
385 observed.fleet_id.map(|value| value.to_string()),
386 ),
387 ] {
388 match (expected, actual) {
389 (Some(expected), Some(actual)) if expected != actual => {
390 hard_failures.push(finding(
391 FLEET_IDENTITY_MISMATCH_CODE,
392 format!("plan {field} {expected} differs from observed {field} {actual}"),
393 SafetySeverityV1::HardFailure,
394 Some(format!("deployment_identity.{field}")),
395 ));
396 }
397 (Some(expected), None) => {
398 hard_failures.push(finding(
399 FLEET_IDENTITY_MISMATCH_CODE,
400 format!("plan {field} {expected} was not observed"),
401 SafetySeverityV1::HardFailure,
402 Some(format!("deployment_identity.{field}")),
403 ));
404 }
405 _ => {}
406 }
407 }
408 if let (Some(expected), Some(actual)) = (
409 plan.deployment_identity.root_principal.as_ref(),
410 observed.root_principal.as_ref(),
411 ) && expected != actual
412 {
413 hard_failures.push(finding(
414 ROOT_TRUST_ANCHOR_MISMATCH_CODE,
415 format!("plan root {expected} differs from observed root {actual}"),
416 SafetySeverityV1::HardFailure,
417 Some("deployment_identity.root_principal".to_string()),
418 ));
419 }
420 match (
421 plan.deployment_identity.deployment_manifest_digest.as_ref(),
422 observed.deployment_manifest_digest.as_ref(),
423 ) {
424 (Some(expected), Some(actual)) if expected != actual => {
425 hard_failures.push(finding(
426 DEPLOYMENT_MANIFEST_MISMATCH_CODE,
427 "deployment manifest digest differs from the observed local config",
428 SafetySeverityV1::HardFailure,
429 Some("deployment_identity.deployment_manifest_digest".to_string()),
430 ));
431 }
432 (Some(_), None) => {
433 hard_failures.push(finding(
434 DEPLOYMENT_MANIFEST_UNOBSERVED_CODE,
435 "deployment manifest digest was not observed",
436 SafetySeverityV1::HardFailure,
437 Some("deployment_identity.deployment_manifest_digest".to_string()),
438 ));
439 }
440 _ => {}
441 }
442}
443
444fn finding(
445 code: impl Into<String>,
446 message: impl Into<String>,
447 severity: SafetySeverityV1,
448 subject: Option<String>,
449) -> SafetyFindingV1 {
450 SafetyFindingV1 {
451 code: code.into(),
452 message: message.into(),
453 severity,
454 subject,
455 }
456}
457
458fn diff_item(
459 category: impl Into<String>,
460 subject: impl Into<String>,
461 expected: Option<String>,
462 observed: Option<String>,
463 severity: SafetySeverityV1,
464) -> DiffItemV1 {
465 DiffItemV1 {
466 category: category.into(),
467 subject: subject.into(),
468 expected,
469 observed,
470 severity,
471 }
472}
473
474fn duplicate_evidence_groups<T>(
475 items: &[T],
476 subject: impl Fn(&T) -> String,
477 evidence: impl Fn(&T) -> String,
478 evidence_separator: &str,
479) -> Vec<DuplicateEvidenceGroup> {
480 let mut groups = Vec::new();
481 for (subject, entries) in group_by_subject(items, |item| Some(subject(item))) {
482 if entries.len() <= 1 {
483 continue;
484 }
485 let evidence_values = entries
486 .iter()
487 .map(|entry| evidence(entry))
488 .collect::<BTreeSet<_>>();
489 groups.push(DuplicateEvidenceGroup {
490 subject,
491 count: entries.len(),
492 evidence_label: evidence_values
493 .iter()
494 .cloned()
495 .collect::<Vec<_>>()
496 .join(evidence_separator),
497 is_conflict: evidence_values.len() > 1,
498 });
499 }
500 groups
501}
502
503fn duplicate_evidence_groups_by<T, K>(
504 items: &[T],
505 subject: impl Fn(&T) -> String,
506 evidence_key: impl Fn(&T) -> K,
507 evidence_label: impl Fn(&T) -> String,
508 evidence_separator: &str,
509) -> Vec<DuplicateEvidenceGroup>
510where
511 K: Ord,
512{
513 let mut groups = Vec::new();
514 for (subject, entries) in group_by_subject(items, |item| Some(subject(item))) {
515 if entries.len() <= 1 {
516 continue;
517 }
518 let evidence_values = entries
519 .iter()
520 .map(|entry| (evidence_key(entry), evidence_label(entry)))
521 .collect::<BTreeMap<_, _>>();
522 groups.push(DuplicateEvidenceGroup {
523 subject,
524 count: entries.len(),
525 evidence_label: evidence_values
526 .values()
527 .cloned()
528 .collect::<Vec<_>>()
529 .join(evidence_separator),
530 is_conflict: evidence_values.len() > 1,
531 });
532 }
533 groups
534}
535
536fn conflicting_assignment_groups<T>(
537 items: &[T],
538 subject: impl Fn(&T) -> Option<String>,
539 value: impl Fn(&T) -> String,
540 value_separator: &str,
541) -> Vec<DuplicateEvidenceGroup> {
542 let mut groups = Vec::new();
543 for (subject, entries) in group_by_subject(items, subject) {
544 if entries.len() <= 1 {
545 continue;
546 }
547 let values = entries
548 .iter()
549 .map(|entry| value(entry))
550 .collect::<BTreeSet<_>>();
551 if values.len() <= 1 {
552 continue;
553 }
554 groups.push(DuplicateEvidenceGroup {
555 subject,
556 count: entries.len(),
557 evidence_label: values
558 .iter()
559 .cloned()
560 .collect::<Vec<_>>()
561 .join(value_separator),
562 is_conflict: true,
563 });
564 }
565 groups
566}
567
568fn group_by_subject<T>(
569 items: &[T],
570 subject: impl Fn(&T) -> Option<String>,
571) -> BTreeMap<String, Vec<&T>> {
572 let mut by_subject = BTreeMap::<String, Vec<&T>>::new();
573 for item in items {
574 if let Some(subject) = subject(item) {
575 by_subject.entry(subject).or_default().push(item);
576 }
577 }
578 by_subject
579}
580
581#[cfg(test)]
582mod classification_tests {
583 use super::*;
584
585 #[test]
586 fn finding_classification_uses_exact_owner_codes() {
587 assert!(is_evidence_conflict_finding_code(
588 ARTIFACT_ROLE_CONFLICT_CODE
589 ));
590 assert!(is_evidence_conflict_finding_code(
591 RECEIPT_PHASE_CONFLICT_CODE
592 ));
593 assert!(!is_evidence_conflict_finding_code("artifact_conflict"));
594 assert!(!is_evidence_conflict_finding_code("conflict"));
595
596 assert!(is_artifact_role_failure_code(ARTIFACT_MISSING_CODE));
597 assert!(!is_artifact_role_failure_code(
598 PLANNED_ARTIFACT_ROLE_CONFLICT_CODE
599 ));
600 }
601}