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