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 observed_at: String,
14 pub runtime_variant: String,
15 pub build_profile: String,
16}
17
18pub fn check_local_deployment(
20 request: &LocalDeploymentCheckRequest,
21) -> Result<DeploymentCheckV1, DeploymentTruthError> {
22 let plan = build_local_deployment_plan(&LocalDeploymentPlanRequest {
23 deployment_name: request.deployment_name.clone(),
24 network: request.network.clone(),
25 workspace_root: request.workspace_root.clone(),
26 icp_root: request.icp_root.clone(),
27 runtime_variant: request.runtime_variant.clone(),
28 build_profile: request.build_profile.clone(),
29 });
30 let inventory = collect_local_deployment_inventory(&LocalInventoryRequest {
31 deployment_name: request.deployment_name.clone(),
32 network: request.network.clone(),
33 workspace_root: request.workspace_root.clone(),
34 icp_root: request.icp_root.clone(),
35 observed_at: request.observed_at.clone(),
36 })?;
37 let diff = compare_plan_to_inventory(&plan, &inventory);
38 let report = safety_report_from_diff(
39 format!(
40 "local:{}:{}:report",
41 request.network, request.deployment_name
42 ),
43 Some(format!(
44 "local:{}:{}:diff",
45 request.network, request.deployment_name
46 )),
47 &diff,
48 );
49
50 Ok(DeploymentCheckV1 {
51 schema_version: DEPLOYMENT_TRUTH_SCHEMA_VERSION,
52 check_id: format!(
53 "local:{}:{}:check",
54 request.network, request.deployment_name
55 ),
56 plan,
57 inventory,
58 diff,
59 report,
60 })
61}
62
63#[must_use]
65pub fn compare_plan_to_inventory(
66 plan: &DeploymentPlanV1,
67 inventory: &DeploymentInventoryV1,
68) -> DeploymentDiffV1 {
69 let mut artifact_diff = Vec::new();
70 let mut controller_diff = Vec::new();
71 let pool_diff = Vec::new();
72 let mut embedded_config_diff = Vec::new();
73 let module_hash_diff = Vec::new();
74 let mut verifier_readiness_diff = Vec::new();
75 let mut hard_failures = Vec::new();
76 let mut warnings = Vec::new();
77
78 compare_identity(plan, inventory, &mut hard_failures);
79 compare_artifacts(
80 plan,
81 inventory,
82 &mut artifact_diff,
83 &mut hard_failures,
84 &mut warnings,
85 );
86 compare_canisters(plan, inventory, &mut controller_diff, &mut hard_failures);
87 compare_embedded_config(
88 plan,
89 inventory,
90 &mut embedded_config_diff,
91 &mut hard_failures,
92 &mut warnings,
93 );
94 compare_verifier_readiness(plan, inventory, &mut verifier_readiness_diff, &mut warnings);
95 for gap in &inventory.unresolved_observations {
96 warnings.push(SafetyFindingV1 {
97 code: "observation_gap".to_string(),
98 message: gap.description.clone(),
99 severity: SafetySeverityV1::Warning,
100 subject: Some(gap.key.clone()),
101 });
102 }
103
104 let status = safety_status(&hard_failures, &warnings);
105 DeploymentDiffV1 {
106 schema_version: DEPLOYMENT_TRUTH_SCHEMA_VERSION,
107 plan_identity: plan.deployment_identity.clone(),
108 observed_identity: inventory.observed_identity.clone(),
109 artifact_diff,
110 controller_diff,
111 pool_diff,
112 embedded_config_diff,
113 module_hash_diff,
114 verifier_readiness_diff,
115 resume_safety: ResumeSafetyV1 {
116 status,
117 reasons: resume_safety_reasons(&hard_failures, &warnings),
118 },
119 hard_failures,
120 warnings,
121 resumable_phases: Vec::new(),
122 }
123}
124
125#[must_use]
127pub fn safety_report_from_diff(
128 report_id: impl Into<String>,
129 diff_id: Option<String>,
130 diff: &DeploymentDiffV1,
131) -> SafetyReportV1 {
132 let status = safety_status(&diff.hard_failures, &diff.warnings);
133 SafetyReportV1 {
134 schema_version: DEPLOYMENT_TRUTH_SCHEMA_VERSION,
135 report_id: report_id.into(),
136 diff_id,
137 status,
138 summary: safety_summary(status, diff.hard_failures.len(), diff.warnings.len()),
139 hard_failures: diff.hard_failures.clone(),
140 warnings: diff.warnings.clone(),
141 next_actions: safety_next_actions(status),
142 }
143}
144
145fn compare_identity(
146 plan: &DeploymentPlanV1,
147 inventory: &DeploymentInventoryV1,
148 hard_failures: &mut Vec<SafetyFindingV1>,
149) {
150 let Some(observed) = &inventory.observed_identity else {
151 hard_failures.push(finding(
152 "identity_unobserved",
153 "deployment identity was not observed",
154 SafetySeverityV1::HardFailure,
155 None,
156 ));
157 return;
158 };
159
160 if observed.network != plan.deployment_identity.network {
161 hard_failures.push(finding(
162 "network_mismatch",
163 format!(
164 "plan network {} differs from observed network {}",
165 plan.deployment_identity.network, observed.network
166 ),
167 SafetySeverityV1::HardFailure,
168 Some("deployment_identity.network".to_string()),
169 ));
170 }
171 if let (Some(expected), Some(actual)) = (
172 plan.deployment_identity.root_principal.as_ref(),
173 observed.root_principal.as_ref(),
174 ) && expected != actual
175 {
176 hard_failures.push(finding(
177 "root_trust_anchor_mismatch",
178 format!("plan root {expected} differs from observed root {actual}"),
179 SafetySeverityV1::HardFailure,
180 Some("deployment_identity.root_principal".to_string()),
181 ));
182 }
183}
184
185fn compare_artifacts(
186 plan: &DeploymentPlanV1,
187 inventory: &DeploymentInventoryV1,
188 artifact_diff: &mut Vec<DiffItemV1>,
189 hard_failures: &mut Vec<SafetyFindingV1>,
190 warnings: &mut Vec<SafetyFindingV1>,
191) {
192 let observed_by_role = inventory
193 .observed_artifacts
194 .iter()
195 .map(|artifact| (artifact.role.as_str(), artifact))
196 .collect::<BTreeMap<_, _>>();
197
198 for expected in &plan.role_artifacts {
199 let Some(observed) = observed_by_role.get(expected.role.as_str()) else {
200 artifact_diff.push(diff_item(
201 "artifact",
202 &expected.role,
203 expected.wasm_gz_path.clone(),
204 None,
205 SafetySeverityV1::HardFailure,
206 ));
207 hard_failures.push(finding(
208 "artifact_missing",
209 format!("missing observed artifact for role {}", expected.role),
210 SafetySeverityV1::HardFailure,
211 Some(expected.role.clone()),
212 ));
213 continue;
214 };
215
216 if let Some(file_sha256) = &observed.file_sha256 {
217 artifact_diff.push(diff_item(
218 "artifact_file_sha256",
219 &expected.role,
220 None,
221 Some(file_sha256.clone()),
222 SafetySeverityV1::Info,
223 ));
224 }
225
226 match (
227 expected.wasm_gz_sha256.as_ref(),
228 observed.payload_sha256.as_ref(),
229 ) {
230 (Some(want), Some(got)) if want != got => {
231 artifact_diff.push(diff_item(
232 "artifact_sha256",
233 &expected.role,
234 Some(want.clone()),
235 Some(got.clone()),
236 SafetySeverityV1::HardFailure,
237 ));
238 hard_failures.push(finding(
239 "artifact_digest_mismatch",
240 format!("artifact digest mismatch for role {}", expected.role),
241 SafetySeverityV1::HardFailure,
242 Some(expected.role.clone()),
243 ));
244 }
245 (Some(want), None) => warnings.push(finding(
246 "artifact_digest_unobserved",
247 format!(
248 "expected artifact digest {want} for role {} was not observed",
249 expected.role
250 ),
251 SafetySeverityV1::Warning,
252 Some(expected.role.clone()),
253 )),
254 _ => {}
255 }
256 }
257}
258
259fn compare_canisters(
260 plan: &DeploymentPlanV1,
261 inventory: &DeploymentInventoryV1,
262 controller_diff: &mut Vec<DiffItemV1>,
263 hard_failures: &mut Vec<SafetyFindingV1>,
264) {
265 for expected in &plan.expected_canisters {
266 let observed = expected.canister_id.as_ref().map_or_else(
267 || {
268 inventory
269 .observed_canisters
270 .iter()
271 .find(|canister| canister.role.as_deref() == Some(expected.role.as_str()))
272 },
273 |id| {
274 inventory
275 .observed_canisters
276 .iter()
277 .find(|canister| &canister.canister_id == id)
278 },
279 );
280 let Some(observed) = observed else {
281 controller_diff.push(diff_item(
282 "canister",
283 &expected.role,
284 expected.canister_id.clone(),
285 None,
286 SafetySeverityV1::HardFailure,
287 ));
288 hard_failures.push(finding(
289 "canister_missing",
290 format!("missing observed canister for role {}", expected.role),
291 SafetySeverityV1::HardFailure,
292 Some(expected.role.clone()),
293 ));
294 continue;
295 };
296 if matches!(
297 observed.control_class,
298 CanisterControlClassV1::UnknownUnsafe | CanisterControlClassV1::UserControlled
299 ) && expected.control_class == CanisterControlClassV1::DeploymentControlled
300 {
301 controller_diff.push(diff_item(
302 "control_class",
303 &expected.role,
304 Some("DeploymentControlled".to_string()),
305 Some(format!("{:?}", observed.control_class)),
306 SafetySeverityV1::HardFailure,
307 ));
308 hard_failures.push(finding(
309 "unsafe_control_class",
310 format!("role {} has unsafe observed control class", expected.role),
311 SafetySeverityV1::HardFailure,
312 Some(expected.role.clone()),
313 ));
314 }
315 }
316}
317
318fn compare_embedded_config(
319 plan: &DeploymentPlanV1,
320 inventory: &DeploymentInventoryV1,
321 embedded_config_diff: &mut Vec<DiffItemV1>,
322 hard_failures: &mut Vec<SafetyFindingV1>,
323 warnings: &mut Vec<SafetyFindingV1>,
324) {
325 let Some(expected) = &plan.deployment_identity.canonical_runtime_config_digest else {
326 return;
327 };
328 match &inventory.local_config.canonical_embedded_config_sha256 {
329 Some(observed) if observed != expected => {
330 embedded_config_diff.push(diff_item(
331 "canonical_config",
332 "deployment",
333 Some(expected.clone()),
334 Some(observed.clone()),
335 SafetySeverityV1::HardFailure,
336 ));
337 hard_failures.push(finding(
338 "canonical_config_mismatch",
339 "canonical runtime config digest differs from the plan",
340 SafetySeverityV1::HardFailure,
341 Some("local_config".to_string()),
342 ));
343 }
344 None => warnings.push(finding(
345 "canonical_config_unobserved",
346 "canonical runtime config digest was not observed",
347 SafetySeverityV1::Warning,
348 Some("local_config".to_string()),
349 )),
350 _ => {}
351 }
352}
353
354fn compare_verifier_readiness(
355 plan: &DeploymentPlanV1,
356 inventory: &DeploymentInventoryV1,
357 verifier_readiness_diff: &mut Vec<DiffItemV1>,
358 warnings: &mut Vec<SafetyFindingV1>,
359) {
360 if !plan.expected_verifier_readiness.required {
361 return;
362 }
363 if inventory.observed_verifier_readiness.status == ObservationStatusV1::NotObserved {
364 verifier_readiness_diff.push(diff_item(
365 "verifier_readiness",
366 "deployment",
367 Some("required".to_string()),
368 Some("not_observed".to_string()),
369 SafetySeverityV1::Warning,
370 ));
371 warnings.push(finding(
372 "verifier_readiness_unobserved",
373 "verifier readiness was required but not observed",
374 SafetySeverityV1::Warning,
375 Some("verifier_readiness".to_string()),
376 ));
377 }
378}
379
380fn finding(
381 code: impl Into<String>,
382 message: impl Into<String>,
383 severity: SafetySeverityV1,
384 subject: Option<String>,
385) -> SafetyFindingV1 {
386 SafetyFindingV1 {
387 code: code.into(),
388 message: message.into(),
389 severity,
390 subject,
391 }
392}
393
394fn diff_item(
395 category: impl Into<String>,
396 subject: impl Into<String>,
397 expected: Option<String>,
398 observed: Option<String>,
399 severity: SafetySeverityV1,
400) -> DiffItemV1 {
401 DiffItemV1 {
402 category: category.into(),
403 subject: subject.into(),
404 expected,
405 observed,
406 severity,
407 }
408}
409
410const fn safety_status(
411 hard_failures: &[SafetyFindingV1],
412 warnings: &[SafetyFindingV1],
413) -> SafetyStatusV1 {
414 if !hard_failures.is_empty() {
415 SafetyStatusV1::Blocked
416 } else if !warnings.is_empty() {
417 SafetyStatusV1::Warning
418 } else {
419 SafetyStatusV1::Safe
420 }
421}
422
423fn resume_safety_reasons(
424 hard_failures: &[SafetyFindingV1],
425 warnings: &[SafetyFindingV1],
426) -> Vec<String> {
427 if !hard_failures.is_empty() {
428 return hard_failures
429 .iter()
430 .map(|finding| finding.message.clone())
431 .collect();
432 }
433 if !warnings.is_empty() {
434 return warnings
435 .iter()
436 .map(|finding| finding.message.clone())
437 .collect();
438 }
439 vec!["no blocking deployment truth differences were found".to_string()]
440}
441
442fn safety_summary(
443 status: SafetyStatusV1,
444 hard_failure_count: usize,
445 warning_count: usize,
446) -> String {
447 match status {
448 SafetyStatusV1::Safe => "deployment inventory matches the checked plan".to_string(),
449 SafetyStatusV1::Warning => {
450 format!("deployment inventory has {warning_count} warning(s)")
451 }
452 SafetyStatusV1::Blocked => {
453 format!(
454 "deployment inventory has {hard_failure_count} blocking issue(s) and {warning_count} warning(s)"
455 )
456 }
457 SafetyStatusV1::NotEvaluated => "deployment safety has not been evaluated".to_string(),
458 }
459}
460
461fn safety_next_actions(status: SafetyStatusV1) -> Vec<String> {
462 match status {
463 SafetyStatusV1::Safe => Vec::new(),
464 SafetyStatusV1::Warning => {
465 vec!["review deployment warnings before continuing".to_string()]
466 }
467 SafetyStatusV1::Blocked => {
468 vec!["resolve blocking deployment truth differences before mutation".to_string()]
469 }
470 SafetyStatusV1::NotEvaluated => vec!["collect deployment inventory".to_string()],
471 }
472}