1use super::*;
2use std::collections::BTreeMap;
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 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_artifacts(
83 plan,
84 inventory,
85 &mut artifact_diff,
86 &mut hard_failures,
87 &mut warnings,
88 );
89 compare_canisters(
90 plan,
91 inventory,
92 &mut controller_diff,
93 &mut hard_failures,
94 &mut warnings,
95 );
96 compare_embedded_config(
97 plan,
98 inventory,
99 &mut embedded_config_diff,
100 &mut hard_failures,
101 &mut warnings,
102 );
103 compare_verifier_readiness(plan, inventory, &mut verifier_readiness_diff, &mut warnings);
104 for gap in &inventory.unresolved_observations {
105 warnings.push(SafetyFindingV1 {
106 code: "observation_gap".to_string(),
107 message: gap.description.clone(),
108 severity: SafetySeverityV1::Warning,
109 subject: Some(gap.key.clone()),
110 });
111 }
112
113 let status = safety_status(&hard_failures, &warnings);
114 DeploymentDiffV1 {
115 schema_version: DEPLOYMENT_TRUTH_SCHEMA_VERSION,
116 plan_identity: plan.deployment_identity.clone(),
117 observed_identity: inventory.observed_identity.clone(),
118 artifact_diff,
119 controller_diff,
120 pool_diff,
121 embedded_config_diff,
122 module_hash_diff,
123 verifier_readiness_diff,
124 resume_safety: ResumeSafetyV1 {
125 status,
126 reasons: resume_safety_reasons(&hard_failures, &warnings),
127 },
128 hard_failures,
129 warnings,
130 resumable_phases: Vec::new(),
131 }
132}
133
134#[must_use]
136pub fn safety_report_from_diff(
137 report_id: impl Into<String>,
138 diff_id: Option<String>,
139 diff: &DeploymentDiffV1,
140) -> SafetyReportV1 {
141 let status = safety_status(&diff.hard_failures, &diff.warnings);
142 SafetyReportV1 {
143 schema_version: DEPLOYMENT_TRUTH_SCHEMA_VERSION,
144 report_id: report_id.into(),
145 diff_id,
146 status,
147 summary: safety_summary(status, diff.hard_failures.len(), diff.warnings.len()),
148 hard_failures: diff.hard_failures.clone(),
149 warnings: diff.warnings.clone(),
150 next_actions: safety_next_actions(status),
151 }
152}
153
154fn compare_identity(
155 plan: &DeploymentPlanV1,
156 inventory: &DeploymentInventoryV1,
157 hard_failures: &mut Vec<SafetyFindingV1>,
158) {
159 let Some(observed) = &inventory.observed_identity else {
160 hard_failures.push(finding(
161 "identity_unobserved",
162 "deployment identity was not observed",
163 SafetySeverityV1::HardFailure,
164 None,
165 ));
166 return;
167 };
168
169 if observed.network != plan.deployment_identity.network {
170 hard_failures.push(finding(
171 "network_mismatch",
172 format!(
173 "plan network {} differs from observed network {}",
174 plan.deployment_identity.network, observed.network
175 ),
176 SafetySeverityV1::HardFailure,
177 Some("deployment_identity.network".to_string()),
178 ));
179 }
180 if let (Some(expected), Some(actual)) = (
181 plan.deployment_identity.root_principal.as_ref(),
182 observed.root_principal.as_ref(),
183 ) && expected != actual
184 {
185 hard_failures.push(finding(
186 "root_trust_anchor_mismatch",
187 format!("plan root {expected} differs from observed root {actual}"),
188 SafetySeverityV1::HardFailure,
189 Some("deployment_identity.root_principal".to_string()),
190 ));
191 }
192 match (
193 plan.deployment_identity.deployment_manifest_digest.as_ref(),
194 observed.deployment_manifest_digest.as_ref(),
195 ) {
196 (Some(expected), Some(actual)) if expected != actual => {
197 hard_failures.push(finding(
198 "deployment_manifest_mismatch",
199 "deployment manifest digest differs from the observed local config",
200 SafetySeverityV1::HardFailure,
201 Some("deployment_identity.deployment_manifest_digest".to_string()),
202 ));
203 }
204 (Some(_), None) => {
205 hard_failures.push(finding(
206 "deployment_manifest_unobserved",
207 "deployment manifest digest was not observed",
208 SafetySeverityV1::HardFailure,
209 Some("deployment_identity.deployment_manifest_digest".to_string()),
210 ));
211 }
212 _ => {}
213 }
214}
215
216fn compare_artifacts(
217 plan: &DeploymentPlanV1,
218 inventory: &DeploymentInventoryV1,
219 artifact_diff: &mut Vec<DiffItemV1>,
220 hard_failures: &mut Vec<SafetyFindingV1>,
221 warnings: &mut Vec<SafetyFindingV1>,
222) {
223 let observed_by_role = inventory
224 .observed_artifacts
225 .iter()
226 .map(|artifact| (artifact.role.as_str(), artifact))
227 .collect::<BTreeMap<_, _>>();
228
229 for expected in &plan.role_artifacts {
230 let Some(observed) = observed_by_role.get(expected.role.as_str()) else {
231 artifact_diff.push(diff_item(
232 "artifact",
233 &expected.role,
234 expected.wasm_gz_path.clone(),
235 None,
236 SafetySeverityV1::HardFailure,
237 ));
238 hard_failures.push(finding(
239 "artifact_missing",
240 format!("missing observed artifact for role {}", expected.role),
241 SafetySeverityV1::HardFailure,
242 Some(expected.role.clone()),
243 ));
244 continue;
245 };
246
247 compare_artifact_file_sha256(expected, observed, artifact_diff, hard_failures);
248
249 match (
250 expected.wasm_gz_sha256.as_ref(),
251 observed.payload_sha256.as_ref(),
252 ) {
253 (Some(want), Some(got)) if want != got => {
254 artifact_diff.push(diff_item(
255 "artifact_sha256",
256 &expected.role,
257 Some(want.clone()),
258 Some(got.clone()),
259 SafetySeverityV1::HardFailure,
260 ));
261 hard_failures.push(finding(
262 "artifact_digest_mismatch",
263 format!("artifact digest mismatch for role {}", expected.role),
264 SafetySeverityV1::HardFailure,
265 Some(expected.role.clone()),
266 ));
267 }
268 (Some(want), None) => warnings.push(finding(
269 "artifact_digest_unobserved",
270 format!(
271 "expected artifact digest {want} for role {} was not observed",
272 expected.role
273 ),
274 SafetySeverityV1::Warning,
275 Some(expected.role.clone()),
276 )),
277 _ => {}
278 }
279 }
280}
281
282fn compare_artifact_file_sha256(
283 expected: &RoleArtifactV1,
284 observed: &ObservedArtifactV1,
285 artifact_diff: &mut Vec<DiffItemV1>,
286 hard_failures: &mut Vec<SafetyFindingV1>,
287) {
288 match (
289 expected.observed_wasm_gz_file_sha256.as_ref(),
290 observed.file_sha256.as_ref(),
291 ) {
292 (Some(want), Some(got)) if want != got => {
293 artifact_diff.push(diff_item(
294 "artifact_file_sha256",
295 &expected.role,
296 Some(want.clone()),
297 Some(got.clone()),
298 SafetySeverityV1::HardFailure,
299 ));
300 hard_failures.push(finding(
301 "artifact_file_digest_mismatch",
302 format!(
303 "observed artifact file digest changed during deployment truth check for role {}",
304 expected.role
305 ),
306 SafetySeverityV1::HardFailure,
307 Some(expected.role.clone()),
308 ));
309 }
310 (_, Some(got)) => {
311 artifact_diff.push(diff_item(
312 "artifact_file_sha256",
313 &expected.role,
314 expected.observed_wasm_gz_file_sha256.clone(),
315 Some(got.clone()),
316 SafetySeverityV1::Info,
317 ));
318 }
319 _ => {}
320 }
321}
322
323fn compare_canisters(
324 plan: &DeploymentPlanV1,
325 inventory: &DeploymentInventoryV1,
326 controller_diff: &mut Vec<DiffItemV1>,
327 hard_failures: &mut Vec<SafetyFindingV1>,
328 warnings: &mut Vec<SafetyFindingV1>,
329) {
330 for expected in &plan.expected_canisters {
331 let observed = expected.canister_id.as_ref().map_or_else(
332 || {
333 inventory
334 .observed_canisters
335 .iter()
336 .find(|canister| canister.role.as_deref() == Some(expected.role.as_str()))
337 },
338 |id| {
339 inventory
340 .observed_canisters
341 .iter()
342 .find(|canister| &canister.canister_id == id)
343 },
344 );
345 let Some(observed) = observed else {
346 let severity = if expected.canister_id.is_some() {
347 SafetySeverityV1::HardFailure
348 } else {
349 SafetySeverityV1::Warning
350 };
351 controller_diff.push(diff_item(
352 "canister",
353 &expected.role,
354 expected.canister_id.clone(),
355 None,
356 severity,
357 ));
358 let finding = finding(
359 if expected.canister_id.is_some() {
360 "canister_missing"
361 } else {
362 "canister_unobserved"
363 },
364 format!("missing observed canister for role {}", expected.role),
365 severity,
366 Some(expected.role.clone()),
367 );
368 if expected.canister_id.is_some() {
369 hard_failures.push(finding);
370 } else {
371 warnings.push(finding);
372 }
373 continue;
374 };
375 if matches!(
376 observed.control_class,
377 CanisterControlClassV1::UnknownUnsafe | CanisterControlClassV1::UserControlled
378 ) && expected.control_class == CanisterControlClassV1::DeploymentControlled
379 {
380 controller_diff.push(diff_item(
381 "control_class",
382 &expected.role,
383 Some("DeploymentControlled".to_string()),
384 Some(format!("{:?}", observed.control_class)),
385 SafetySeverityV1::HardFailure,
386 ));
387 hard_failures.push(finding(
388 "unsafe_control_class",
389 format!("role {} has unsafe observed control class", expected.role),
390 SafetySeverityV1::HardFailure,
391 Some(expected.role.clone()),
392 ));
393 }
394 }
395}
396
397fn compare_embedded_config(
398 plan: &DeploymentPlanV1,
399 inventory: &DeploymentInventoryV1,
400 embedded_config_diff: &mut Vec<DiffItemV1>,
401 hard_failures: &mut Vec<SafetyFindingV1>,
402 warnings: &mut Vec<SafetyFindingV1>,
403) {
404 let Some(expected) = &plan.deployment_identity.canonical_runtime_config_digest else {
405 return;
406 };
407 match &inventory.local_config.canonical_embedded_config_sha256 {
408 Some(observed) if observed != expected => {
409 embedded_config_diff.push(diff_item(
410 "canonical_config",
411 "deployment",
412 Some(expected.clone()),
413 Some(observed.clone()),
414 SafetySeverityV1::HardFailure,
415 ));
416 hard_failures.push(finding(
417 "canonical_config_mismatch",
418 "canonical runtime config digest differs from the plan",
419 SafetySeverityV1::HardFailure,
420 Some("local_config".to_string()),
421 ));
422 }
423 None => warnings.push(finding(
424 "canonical_config_unobserved",
425 "canonical runtime config digest was not observed",
426 SafetySeverityV1::Warning,
427 Some("local_config".to_string()),
428 )),
429 _ => {}
430 }
431}
432
433fn compare_verifier_readiness(
434 plan: &DeploymentPlanV1,
435 inventory: &DeploymentInventoryV1,
436 verifier_readiness_diff: &mut Vec<DiffItemV1>,
437 warnings: &mut Vec<SafetyFindingV1>,
438) {
439 if !plan.expected_verifier_readiness.required {
440 return;
441 }
442 if inventory.observed_verifier_readiness.status == ObservationStatusV1::NotObserved {
443 verifier_readiness_diff.push(diff_item(
444 "verifier_readiness",
445 "deployment",
446 Some("required".to_string()),
447 Some("not_observed".to_string()),
448 SafetySeverityV1::Warning,
449 ));
450 warnings.push(finding(
451 "verifier_readiness_unobserved",
452 "verifier readiness was required but not observed",
453 SafetySeverityV1::Warning,
454 Some("verifier_readiness".to_string()),
455 ));
456 }
457}
458
459fn finding(
460 code: impl Into<String>,
461 message: impl Into<String>,
462 severity: SafetySeverityV1,
463 subject: Option<String>,
464) -> SafetyFindingV1 {
465 SafetyFindingV1 {
466 code: code.into(),
467 message: message.into(),
468 severity,
469 subject,
470 }
471}
472
473fn diff_item(
474 category: impl Into<String>,
475 subject: impl Into<String>,
476 expected: Option<String>,
477 observed: Option<String>,
478 severity: SafetySeverityV1,
479) -> DiffItemV1 {
480 DiffItemV1 {
481 category: category.into(),
482 subject: subject.into(),
483 expected,
484 observed,
485 severity,
486 }
487}
488
489const fn safety_status(
490 hard_failures: &[SafetyFindingV1],
491 warnings: &[SafetyFindingV1],
492) -> SafetyStatusV1 {
493 if !hard_failures.is_empty() {
494 SafetyStatusV1::Blocked
495 } else if !warnings.is_empty() {
496 SafetyStatusV1::Warning
497 } else {
498 SafetyStatusV1::Safe
499 }
500}
501
502fn resume_safety_reasons(
503 hard_failures: &[SafetyFindingV1],
504 warnings: &[SafetyFindingV1],
505) -> Vec<String> {
506 if !hard_failures.is_empty() {
507 return hard_failures
508 .iter()
509 .map(|finding| finding.message.clone())
510 .collect();
511 }
512 if !warnings.is_empty() {
513 return warnings
514 .iter()
515 .map(|finding| finding.message.clone())
516 .collect();
517 }
518 vec!["no blocking deployment truth differences were found".to_string()]
519}
520
521fn safety_summary(
522 status: SafetyStatusV1,
523 hard_failure_count: usize,
524 warning_count: usize,
525) -> String {
526 match status {
527 SafetyStatusV1::Safe => "deployment inventory matches the checked plan".to_string(),
528 SafetyStatusV1::Warning => {
529 format!("deployment inventory has {warning_count} warning(s)")
530 }
531 SafetyStatusV1::Blocked => {
532 format!(
533 "deployment inventory has {hard_failure_count} blocking issue(s) and {warning_count} warning(s)"
534 )
535 }
536 SafetyStatusV1::NotEvaluated => "deployment safety has not been evaluated".to_string(),
537 }
538}
539
540fn safety_next_actions(status: SafetyStatusV1) -> Vec<String> {
541 match status {
542 SafetyStatusV1::Safe => Vec::new(),
543 SafetyStatusV1::Warning => {
544 vec!["review deployment warnings before continuing".to_string()]
545 }
546 SafetyStatusV1::Blocked => {
547 vec!["resolve blocking deployment truth differences before mutation".to_string()]
548 }
549 SafetyStatusV1::NotEvaluated => vec!["collect deployment inventory".to_string()],
550 }
551}