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}
193
194fn compare_artifacts(
195 plan: &DeploymentPlanV1,
196 inventory: &DeploymentInventoryV1,
197 artifact_diff: &mut Vec<DiffItemV1>,
198 hard_failures: &mut Vec<SafetyFindingV1>,
199 warnings: &mut Vec<SafetyFindingV1>,
200) {
201 let observed_by_role = inventory
202 .observed_artifacts
203 .iter()
204 .map(|artifact| (artifact.role.as_str(), artifact))
205 .collect::<BTreeMap<_, _>>();
206
207 for expected in &plan.role_artifacts {
208 let Some(observed) = observed_by_role.get(expected.role.as_str()) else {
209 artifact_diff.push(diff_item(
210 "artifact",
211 &expected.role,
212 expected.wasm_gz_path.clone(),
213 None,
214 SafetySeverityV1::HardFailure,
215 ));
216 hard_failures.push(finding(
217 "artifact_missing",
218 format!("missing observed artifact for role {}", expected.role),
219 SafetySeverityV1::HardFailure,
220 Some(expected.role.clone()),
221 ));
222 continue;
223 };
224
225 if let Some(file_sha256) = &observed.file_sha256 {
226 artifact_diff.push(diff_item(
227 "artifact_file_sha256",
228 &expected.role,
229 None,
230 Some(file_sha256.clone()),
231 SafetySeverityV1::Info,
232 ));
233 }
234
235 match (
236 expected.wasm_gz_sha256.as_ref(),
237 observed.payload_sha256.as_ref(),
238 ) {
239 (Some(want), Some(got)) if want != got => {
240 artifact_diff.push(diff_item(
241 "artifact_sha256",
242 &expected.role,
243 Some(want.clone()),
244 Some(got.clone()),
245 SafetySeverityV1::HardFailure,
246 ));
247 hard_failures.push(finding(
248 "artifact_digest_mismatch",
249 format!("artifact digest mismatch for role {}", expected.role),
250 SafetySeverityV1::HardFailure,
251 Some(expected.role.clone()),
252 ));
253 }
254 (Some(want), None) => warnings.push(finding(
255 "artifact_digest_unobserved",
256 format!(
257 "expected artifact digest {want} for role {} was not observed",
258 expected.role
259 ),
260 SafetySeverityV1::Warning,
261 Some(expected.role.clone()),
262 )),
263 _ => {}
264 }
265 }
266}
267
268fn compare_canisters(
269 plan: &DeploymentPlanV1,
270 inventory: &DeploymentInventoryV1,
271 controller_diff: &mut Vec<DiffItemV1>,
272 hard_failures: &mut Vec<SafetyFindingV1>,
273 warnings: &mut Vec<SafetyFindingV1>,
274) {
275 for expected in &plan.expected_canisters {
276 let observed = expected.canister_id.as_ref().map_or_else(
277 || {
278 inventory
279 .observed_canisters
280 .iter()
281 .find(|canister| canister.role.as_deref() == Some(expected.role.as_str()))
282 },
283 |id| {
284 inventory
285 .observed_canisters
286 .iter()
287 .find(|canister| &canister.canister_id == id)
288 },
289 );
290 let Some(observed) = observed else {
291 let severity = if expected.canister_id.is_some() {
292 SafetySeverityV1::HardFailure
293 } else {
294 SafetySeverityV1::Warning
295 };
296 controller_diff.push(diff_item(
297 "canister",
298 &expected.role,
299 expected.canister_id.clone(),
300 None,
301 severity,
302 ));
303 let finding = finding(
304 if expected.canister_id.is_some() {
305 "canister_missing"
306 } else {
307 "canister_unobserved"
308 },
309 format!("missing observed canister for role {}", expected.role),
310 severity,
311 Some(expected.role.clone()),
312 );
313 if expected.canister_id.is_some() {
314 hard_failures.push(finding);
315 } else {
316 warnings.push(finding);
317 }
318 continue;
319 };
320 if matches!(
321 observed.control_class,
322 CanisterControlClassV1::UnknownUnsafe | CanisterControlClassV1::UserControlled
323 ) && expected.control_class == CanisterControlClassV1::DeploymentControlled
324 {
325 controller_diff.push(diff_item(
326 "control_class",
327 &expected.role,
328 Some("DeploymentControlled".to_string()),
329 Some(format!("{:?}", observed.control_class)),
330 SafetySeverityV1::HardFailure,
331 ));
332 hard_failures.push(finding(
333 "unsafe_control_class",
334 format!("role {} has unsafe observed control class", expected.role),
335 SafetySeverityV1::HardFailure,
336 Some(expected.role.clone()),
337 ));
338 }
339 }
340}
341
342fn compare_embedded_config(
343 plan: &DeploymentPlanV1,
344 inventory: &DeploymentInventoryV1,
345 embedded_config_diff: &mut Vec<DiffItemV1>,
346 hard_failures: &mut Vec<SafetyFindingV1>,
347 warnings: &mut Vec<SafetyFindingV1>,
348) {
349 let Some(expected) = &plan.deployment_identity.canonical_runtime_config_digest else {
350 return;
351 };
352 match &inventory.local_config.canonical_embedded_config_sha256 {
353 Some(observed) if observed != expected => {
354 embedded_config_diff.push(diff_item(
355 "canonical_config",
356 "deployment",
357 Some(expected.clone()),
358 Some(observed.clone()),
359 SafetySeverityV1::HardFailure,
360 ));
361 hard_failures.push(finding(
362 "canonical_config_mismatch",
363 "canonical runtime config digest differs from the plan",
364 SafetySeverityV1::HardFailure,
365 Some("local_config".to_string()),
366 ));
367 }
368 None => warnings.push(finding(
369 "canonical_config_unobserved",
370 "canonical runtime config digest was not observed",
371 SafetySeverityV1::Warning,
372 Some("local_config".to_string()),
373 )),
374 _ => {}
375 }
376}
377
378fn compare_verifier_readiness(
379 plan: &DeploymentPlanV1,
380 inventory: &DeploymentInventoryV1,
381 verifier_readiness_diff: &mut Vec<DiffItemV1>,
382 warnings: &mut Vec<SafetyFindingV1>,
383) {
384 if !plan.expected_verifier_readiness.required {
385 return;
386 }
387 if inventory.observed_verifier_readiness.status == ObservationStatusV1::NotObserved {
388 verifier_readiness_diff.push(diff_item(
389 "verifier_readiness",
390 "deployment",
391 Some("required".to_string()),
392 Some("not_observed".to_string()),
393 SafetySeverityV1::Warning,
394 ));
395 warnings.push(finding(
396 "verifier_readiness_unobserved",
397 "verifier readiness was required but not observed",
398 SafetySeverityV1::Warning,
399 Some("verifier_readiness".to_string()),
400 ));
401 }
402}
403
404fn finding(
405 code: impl Into<String>,
406 message: impl Into<String>,
407 severity: SafetySeverityV1,
408 subject: Option<String>,
409) -> SafetyFindingV1 {
410 SafetyFindingV1 {
411 code: code.into(),
412 message: message.into(),
413 severity,
414 subject,
415 }
416}
417
418fn diff_item(
419 category: impl Into<String>,
420 subject: impl Into<String>,
421 expected: Option<String>,
422 observed: Option<String>,
423 severity: SafetySeverityV1,
424) -> DiffItemV1 {
425 DiffItemV1 {
426 category: category.into(),
427 subject: subject.into(),
428 expected,
429 observed,
430 severity,
431 }
432}
433
434const fn safety_status(
435 hard_failures: &[SafetyFindingV1],
436 warnings: &[SafetyFindingV1],
437) -> SafetyStatusV1 {
438 if !hard_failures.is_empty() {
439 SafetyStatusV1::Blocked
440 } else if !warnings.is_empty() {
441 SafetyStatusV1::Warning
442 } else {
443 SafetyStatusV1::Safe
444 }
445}
446
447fn resume_safety_reasons(
448 hard_failures: &[SafetyFindingV1],
449 warnings: &[SafetyFindingV1],
450) -> Vec<String> {
451 if !hard_failures.is_empty() {
452 return hard_failures
453 .iter()
454 .map(|finding| finding.message.clone())
455 .collect();
456 }
457 if !warnings.is_empty() {
458 return warnings
459 .iter()
460 .map(|finding| finding.message.clone())
461 .collect();
462 }
463 vec!["no blocking deployment truth differences were found".to_string()]
464}
465
466fn safety_summary(
467 status: SafetyStatusV1,
468 hard_failure_count: usize,
469 warning_count: usize,
470) -> String {
471 match status {
472 SafetyStatusV1::Safe => "deployment inventory matches the checked plan".to_string(),
473 SafetyStatusV1::Warning => {
474 format!("deployment inventory has {warning_count} warning(s)")
475 }
476 SafetyStatusV1::Blocked => {
477 format!(
478 "deployment inventory has {hard_failure_count} blocking issue(s) and {warning_count} warning(s)"
479 )
480 }
481 SafetyStatusV1::NotEvaluated => "deployment safety has not been evaluated".to_string(),
482 }
483}
484
485fn safety_next_actions(status: SafetyStatusV1) -> Vec<String> {
486 match status {
487 SafetyStatusV1::Safe => Vec::new(),
488 SafetyStatusV1::Warning => {
489 vec!["review deployment warnings before continuing".to_string()]
490 }
491 SafetyStatusV1::Blocked => {
492 vec!["resolve blocking deployment truth differences before mutation".to_string()]
493 }
494 SafetyStatusV1::NotEvaluated => vec!["collect deployment inventory".to_string()],
495 }
496}