1use super::*;
2use thiserror::Error as ThisError;
3
4#[derive(Debug, ThisError)]
8pub enum AuthorityEvidenceError {
9 #[error("authority evidence is missing required field: {field}")]
10 MissingRequiredField { field: &'static str },
11
12 #[error(
13 "authority evidence {component} has unsupported schema version: expected {expected}, found {found}"
14 )]
15 SchemaVersionMismatch {
16 component: &'static str,
17 expected: u32,
18 found: u32,
19 },
20
21 #[error(
22 "authority report does not match reconciliation plan: {field} differs (plan={plan_value}, report={report_value})"
23 )]
24 PlanReportMismatch {
25 field: &'static str,
26 plan_value: String,
27 report_value: String,
28 },
29
30 #[error("authority report content does not match reconciliation plan: {field} differs")]
31 PlanReportContentMismatch { field: &'static str },
32
33 #[error("authority dry-run receipt contains attempted controller actions: {count}")]
34 DryRunReceiptAttemptedActions { count: usize },
35
36 #[error("authority dry-run receipt has invalid operation status: {status:?}")]
37 DryRunReceiptStatus { status: DeploymentExecutionStatusV1 },
38
39 #[error("authority dry-run receipt has invalid command result: {result:?}")]
40 DryRunReceiptCommandResult { result: DeploymentCommandResultV1 },
41
42 #[error("authority dry-run receipt is complete but has no finished_at timestamp")]
43 DryRunReceiptMissingFinishedAt,
44
45 #[error(
46 "authority evidence generated_at does not match receipt finished_at (evidence={evidence_value}, receipt={receipt_value})"
47 )]
48 EvidenceGeneratedAtMismatch {
49 evidence_value: String,
50 receipt_value: String,
51 },
52
53 #[error(
54 "authority dry-run receipt has invalid timestamp order: {field} ({left}) is after {other_field} ({right})"
55 )]
56 DryRunReceiptTimestampOrder {
57 field: &'static str,
58 left: String,
59 other_field: &'static str,
60 right: String,
61 },
62
63 #[error(
64 "authority receipt check id does not match report check id (receipt={receipt_value}, report={report_value})"
65 )]
66 CheckIdMismatch {
67 receipt_value: String,
68 report_value: String,
69 },
70
71 #[error(
72 "authority evidence check id does not match nested {component} check id (evidence={evidence_value}, nested={nested_value})"
73 )]
74 EvidenceCheckIdMismatch {
75 component: &'static str,
76 evidence_value: String,
77 nested_value: String,
78 },
79}
80
81pub fn validate_authority_dry_run_evidence(
86 evidence: &AuthorityDryRunEvidenceV1,
87) -> Result<(), AuthorityEvidenceError> {
88 ensure_authority_evidence_schema_versions(evidence)?;
89 ensure_authority_evidence_required_fields(evidence)?;
90 ensure_authority_report_matches_plan(
91 &evidence.reconciliation_plan,
92 &evidence.authority_report,
93 )?;
94 ensure_authority_evidence_provenance(evidence)?;
95 ensure_authority_receipt_is_completed_dry_run(&evidence.authority_receipt)?;
96 ensure_evidence_generated_at_matches_finished_at(
97 &evidence.generated_at,
98 evidence.authority_receipt.finished_at.as_deref(),
99 )?;
100 ensure_authority_receipt_timestamp_order(&evidence.authority_receipt)?;
101 ensure_authority_receipt_matches_evidence(evidence)
102}
103
104pub fn authority_dry_run_evidence_from_check(
111 check: &DeploymentCheckV1,
112 evidence_id: impl Into<String>,
113 report_id: impl Into<String>,
114 receipt_id: impl Into<String>,
115 generated_at: impl Into<String>,
116) -> Result<AuthorityDryRunEvidenceV1, AuthorityEvidenceError> {
117 let generated_at = generated_at.into();
118 let reconciliation = build_authority_reconciliation_plan(check);
119 let report = authority_report_from_plan_with_check_id(
120 report_id,
121 Some(check.check_id.clone()),
122 &reconciliation,
123 );
124 let receipt = authority_dry_run_receipt_from_plan(
125 &reconciliation,
126 &report,
127 Some(check.check_id.clone()),
128 receipt_id,
129 generated_at.clone(),
130 Some(generated_at.clone()),
131 )?;
132 let evidence = AuthorityDryRunEvidenceV1 {
133 schema_version: DEPLOYMENT_TRUTH_SCHEMA_VERSION,
134 evidence_id: evidence_id.into(),
135 check_id: check.check_id.clone(),
136 generated_at,
137 reconciliation_plan: reconciliation,
138 authority_report: report,
139 authority_receipt: receipt,
140 };
141 validate_authority_dry_run_evidence(&evidence)?;
142 Ok(evidence)
143}
144
145pub fn authority_dry_run_evidence_from_check_with_local_ids(
148 check: &DeploymentCheckV1,
149 generated_at: impl Into<String>,
150) -> Result<AuthorityDryRunEvidenceV1, AuthorityEvidenceError> {
151 authority_dry_run_evidence_from_check(
152 check,
153 local_authority_artifact_id(check, "authority-evidence"),
154 local_authority_artifact_id(check, "authority-report"),
155 local_authority_artifact_id(check, "authority-dry-run-receipt"),
156 generated_at,
157 )
158}
159
160pub fn authority_dry_run_receipt_from_check(
166 check: &DeploymentCheckV1,
167 report_id: impl Into<String>,
168 receipt_id: impl Into<String>,
169 started_at: impl Into<String>,
170 finished_at: Option<String>,
171) -> Result<AuthorityReceiptV1, AuthorityEvidenceError> {
172 let reconciliation = build_authority_reconciliation_plan(check);
173 let report = authority_report_from_plan_with_check_id(
174 report_id,
175 Some(check.check_id.clone()),
176 &reconciliation,
177 );
178 authority_dry_run_receipt_from_plan(
179 &reconciliation,
180 &report,
181 Some(check.check_id.clone()),
182 receipt_id,
183 started_at,
184 finished_at,
185 )
186}
187
188pub fn authority_dry_run_receipt_from_check_with_local_id(
191 check: &DeploymentCheckV1,
192 generated_at: impl Into<String>,
193) -> Result<AuthorityReceiptV1, AuthorityEvidenceError> {
194 let generated_at = generated_at.into();
195 authority_dry_run_receipt_from_check(
196 check,
197 local_authority_artifact_id(check, "authority-report"),
198 local_authority_artifact_id(check, "authority-dry-run-receipt"),
199 generated_at.clone(),
200 Some(generated_at),
201 )
202}
203
204fn ensure_authority_evidence_schema_versions(
205 evidence: &AuthorityDryRunEvidenceV1,
206) -> Result<(), AuthorityEvidenceError> {
207 ensure_authority_schema_version("evidence", evidence.schema_version)?;
208 ensure_authority_schema_version("plan", evidence.reconciliation_plan.schema_version)?;
209 ensure_authority_schema_version("report", evidence.authority_report.schema_version)?;
210 ensure_authority_schema_version("receipt", evidence.authority_receipt.schema_version)
211}
212
213fn ensure_authority_evidence_required_fields(
214 evidence: &AuthorityDryRunEvidenceV1,
215) -> Result<(), AuthorityEvidenceError> {
216 ensure_required_authority_field("evidence.evidence_id", &evidence.evidence_id)?;
217 ensure_required_authority_field("evidence.check_id", &evidence.check_id)?;
218 ensure_required_authority_field("evidence.generated_at", &evidence.generated_at)?;
219 ensure_required_authority_field("plan.plan_id", &evidence.reconciliation_plan.plan_id)?;
220 ensure_required_authority_field(
221 "plan.inventory_id",
222 &evidence.reconciliation_plan.inventory_id,
223 )?;
224 ensure_required_authority_field("report.report_id", &evidence.authority_report.report_id)?;
225 ensure_required_authority_field(
226 "receipt.operation_id",
227 &evidence.authority_receipt.operation_id,
228 )?;
229 ensure_required_authority_field("receipt.started_at", &evidence.authority_receipt.started_at)?;
230 ensure_required_optional_authority_field(
231 "report.check_id",
232 evidence.authority_report.check_id.as_deref(),
233 )?;
234 ensure_required_optional_authority_field(
235 "receipt.check_id",
236 evidence.authority_receipt.check_id.as_deref(),
237 )
238}
239
240fn ensure_authority_evidence_provenance(
241 evidence: &AuthorityDryRunEvidenceV1,
242) -> Result<(), AuthorityEvidenceError> {
243 ensure_evidence_check_id_matches(
244 &evidence.check_id,
245 "report",
246 evidence.authority_report.check_id.as_deref(),
247 )?;
248 ensure_evidence_check_id_matches(
249 &evidence.check_id,
250 "receipt",
251 evidence.authority_receipt.check_id.as_deref(),
252 )?;
253 ensure_matching_authority_evidence_field(
254 "receipt.reconciliation_plan_id",
255 &evidence.reconciliation_plan.plan_id,
256 &evidence.authority_receipt.reconciliation_plan_id,
257 )?;
258 ensure_matching_authority_evidence_field(
259 "receipt.authority_report_id",
260 &evidence.authority_report.report_id,
261 &evidence.authority_receipt.authority_report_id,
262 )?;
263 ensure_matching_authority_evidence_field(
264 "receipt.inventory_id",
265 &evidence.reconciliation_plan.inventory_id,
266 &evidence.authority_receipt.inventory_id,
267 )?;
268 ensure_matching_authority_evidence_field(
269 "receipt.authority_profile_hash",
270 &optional_authority_value(evidence.reconciliation_plan.authority_profile_hash.as_ref()),
271 &optional_authority_value(evidence.authority_receipt.authority_profile_hash.as_ref()),
272 )
273}
274
275fn ensure_authority_receipt_is_completed_dry_run(
276 receipt: &AuthorityReceiptV1,
277) -> Result<(), AuthorityEvidenceError> {
278 if !receipt.attempted_actions.is_empty() {
279 return Err(AuthorityEvidenceError::DryRunReceiptAttemptedActions {
280 count: receipt.attempted_actions.len(),
281 });
282 }
283 if receipt.operation_status != DeploymentExecutionStatusV1::Complete {
284 return Err(AuthorityEvidenceError::DryRunReceiptStatus {
285 status: receipt.operation_status,
286 });
287 }
288 if receipt.command_result != DeploymentCommandResultV1::Succeeded {
289 return Err(AuthorityEvidenceError::DryRunReceiptCommandResult {
290 result: receipt.command_result.clone(),
291 });
292 }
293 Ok(())
294}
295
296fn ensure_authority_receipt_matches_evidence(
297 evidence: &AuthorityDryRunEvidenceV1,
298) -> Result<(), AuthorityEvidenceError> {
299 let expected_observations = evidence
300 .reconciliation_plan
301 .canister_actions
302 .iter()
303 .map(authority_controller_observation_from_action)
304 .collect::<Vec<_>>();
305 ensure_matching_authority_evidence_content(
306 "receipt.verified_controller_observations",
307 &expected_observations,
308 &evidence.authority_receipt.verified_controller_observations,
309 )?;
310 ensure_matching_authority_evidence_content(
311 "receipt.hard_failures",
312 &evidence.authority_report.hard_failures,
313 &evidence.authority_receipt.hard_failures,
314 )?;
315 ensure_matching_authority_evidence_content(
316 "receipt.unresolved_observation_gaps",
317 &evidence.authority_report.observation_gaps,
318 &evidence.authority_receipt.unresolved_observation_gaps,
319 )?;
320 ensure_matching_authority_evidence_content(
321 "receipt.unresolved_external_actions",
322 &evidence.authority_report.external_actions_required,
323 &evidence.authority_receipt.unresolved_external_actions,
324 )
325}
326
327const fn ensure_authority_schema_version(
328 component: &'static str,
329 found: u32,
330) -> Result<(), AuthorityEvidenceError> {
331 if found == DEPLOYMENT_TRUTH_SCHEMA_VERSION {
332 return Ok(());
333 }
334
335 Err(AuthorityEvidenceError::SchemaVersionMismatch {
336 component,
337 expected: DEPLOYMENT_TRUTH_SCHEMA_VERSION,
338 found,
339 })
340}
341
342fn ensure_required_authority_field(
343 field: &'static str,
344 value: &str,
345) -> Result<(), AuthorityEvidenceError> {
346 if !value.trim().is_empty() {
347 return Ok(());
348 }
349
350 Err(AuthorityEvidenceError::MissingRequiredField { field })
351}
352
353fn ensure_required_optional_authority_field(
354 field: &'static str,
355 value: Option<&str>,
356) -> Result<(), AuthorityEvidenceError> {
357 let Some(value) = value else {
358 return Err(AuthorityEvidenceError::MissingRequiredField { field });
359 };
360 ensure_required_authority_field(field, value)
361}
362
363fn ensure_evidence_generated_at_matches_finished_at(
364 evidence_generated_at: &str,
365 receipt_finished_at: Option<&str>,
366) -> Result<(), AuthorityEvidenceError> {
367 let Some(receipt_finished_at) = receipt_finished_at else {
368 return Err(AuthorityEvidenceError::DryRunReceiptMissingFinishedAt);
369 };
370 ensure_required_authority_field("receipt.finished_at", receipt_finished_at)?;
371 if evidence_generated_at == receipt_finished_at {
372 return Ok(());
373 }
374
375 Err(AuthorityEvidenceError::EvidenceGeneratedAtMismatch {
376 evidence_value: evidence_generated_at.to_string(),
377 receipt_value: receipt_finished_at.to_string(),
378 })
379}
380
381fn ensure_authority_receipt_timestamp_order(
382 receipt: &AuthorityReceiptV1,
383) -> Result<(), AuthorityEvidenceError> {
384 let Some(finished_at) = receipt.finished_at.as_deref() else {
385 return Err(AuthorityEvidenceError::DryRunReceiptMissingFinishedAt);
386 };
387 ensure_timestamp_order(
388 "receipt.started_at",
389 &receipt.started_at,
390 "receipt.finished_at",
391 finished_at,
392 )
393}
394
395fn ensure_timestamp_order(
396 field: &'static str,
397 left: &str,
398 other_field: &'static str,
399 right: &str,
400) -> Result<(), AuthorityEvidenceError> {
401 if left <= right {
402 return Ok(());
403 }
404
405 Err(AuthorityEvidenceError::DryRunReceiptTimestampOrder {
406 field,
407 left: left.to_string(),
408 other_field,
409 right: right.to_string(),
410 })
411}
412
413pub fn authority_dry_run_receipt_from_plan(
420 plan: &AuthorityReconciliationPlanV1,
421 report: &AuthorityReportV1,
422 check_id: Option<String>,
423 operation_id: impl Into<String>,
424 started_at: impl Into<String>,
425 finished_at: Option<String>,
426) -> Result<AuthorityReceiptV1, AuthorityEvidenceError> {
427 let operation_id = operation_id.into();
428 let started_at = started_at.into();
429 ensure_authority_receipt_source_inputs(
430 plan,
431 report,
432 &operation_id,
433 &started_at,
434 finished_at.as_deref(),
435 )?;
436 ensure_authority_report_matches_plan(plan, report)?;
437 if let (Some(receipt_check_id), Some(report_check_id)) = (&check_id, &report.check_id)
438 && receipt_check_id != report_check_id
439 {
440 return Err(AuthorityEvidenceError::CheckIdMismatch {
441 receipt_value: receipt_check_id.clone(),
442 report_value: report_check_id.clone(),
443 });
444 }
445 let receipt_check_id = check_id.or_else(|| report.check_id.clone());
446
447 Ok(AuthorityReceiptV1 {
448 schema_version: DEPLOYMENT_TRUTH_SCHEMA_VERSION,
449 operation_id,
450 check_id: receipt_check_id,
451 reconciliation_plan_id: plan.plan_id.clone(),
452 authority_report_id: report.report_id.clone(),
453 inventory_id: plan.inventory_id.clone(),
454 authority_profile_hash: plan.authority_profile_hash.clone(),
455 operation_status: DeploymentExecutionStatusV1::Complete,
456 started_at,
457 finished_at,
458 attempted_actions: Vec::new(),
459 verified_controller_observations: plan
460 .canister_actions
461 .iter()
462 .map(authority_controller_observation_from_action)
463 .collect(),
464 hard_failures: report.hard_failures.clone(),
465 unresolved_observation_gaps: report.observation_gaps.clone(),
466 unresolved_external_actions: report.external_actions_required.clone(),
467 command_result: DeploymentCommandResultV1::Succeeded,
468 })
469}
470
471fn ensure_authority_receipt_source_inputs(
472 plan: &AuthorityReconciliationPlanV1,
473 report: &AuthorityReportV1,
474 operation_id: &str,
475 started_at: &str,
476 finished_at: Option<&str>,
477) -> Result<(), AuthorityEvidenceError> {
478 ensure_authority_schema_version("plan", plan.schema_version)?;
479 ensure_authority_schema_version("report", report.schema_version)?;
480 ensure_required_authority_field("plan.plan_id", &plan.plan_id)?;
481 ensure_required_authority_field("plan.inventory_id", &plan.inventory_id)?;
482 ensure_required_authority_field("report.report_id", &report.report_id)?;
483 ensure_required_optional_authority_field("report.check_id", report.check_id.as_deref())?;
484 ensure_required_authority_field("receipt.operation_id", operation_id)?;
485 ensure_required_authority_field("receipt.started_at", started_at)?;
486 ensure_required_optional_authority_field("receipt.finished_at", finished_at)?;
487 let Some(finished_at) = finished_at else {
488 return Err(AuthorityEvidenceError::MissingRequiredField {
489 field: "receipt.finished_at",
490 });
491 };
492 ensure_timestamp_order(
493 "receipt.started_at",
494 started_at,
495 "receipt.finished_at",
496 finished_at,
497 )
498}
499
500fn ensure_authority_report_matches_plan(
501 plan: &AuthorityReconciliationPlanV1,
502 report: &AuthorityReportV1,
503) -> Result<(), AuthorityEvidenceError> {
504 ensure_matching_authority_evidence_field(
505 "reconciliation_plan_id",
506 &plan.plan_id,
507 &report.reconciliation_plan_id,
508 )?;
509 ensure_matching_authority_evidence_field(
510 "inventory_id",
511 &plan.inventory_id,
512 &report.inventory_id,
513 )?;
514 ensure_matching_authority_evidence_field(
515 "authority_profile_hash",
516 &optional_authority_value(plan.authority_profile_hash.as_ref()),
517 &optional_authority_value(report.authority_profile_hash.as_ref()),
518 )?;
519 ensure_matching_authority_evidence_content(
520 "automatic_actions",
521 &plan.automatic_actions,
522 &report.automatic_actions,
523 )?;
524 ensure_matching_authority_evidence_content(
525 "hard_failures",
526 &plan.hard_failures,
527 &report.hard_failures,
528 )?;
529 ensure_matching_authority_evidence_content(
530 "external_actions_required",
531 &plan.external_actions_required,
532 &report.external_actions_required,
533 )?;
534 ensure_authority_report_is_derived_from_plan(plan, report)
535}
536
537fn ensure_authority_report_is_derived_from_plan(
538 plan: &AuthorityReconciliationPlanV1,
539 report: &AuthorityReportV1,
540) -> Result<(), AuthorityEvidenceError> {
541 let expected_report = authority_report_from_plan_with_check_id(
542 report.report_id.clone(),
543 report.check_id.clone(),
544 plan,
545 );
546 ensure_matching_authority_evidence_content(
547 "report.status",
548 &expected_report.status,
549 &report.status,
550 )?;
551 ensure_matching_authority_evidence_content(
552 "report.summary",
553 &expected_report.summary,
554 &report.summary,
555 )?;
556 ensure_matching_authority_evidence_content(
557 "report.counts",
558 &expected_report.counts,
559 &report.counts,
560 )?;
561 ensure_matching_authority_evidence_content(
562 "report.apply_readiness",
563 &expected_report.apply_readiness,
564 &report.apply_readiness,
565 )?;
566 ensure_matching_authority_evidence_content(
567 "report.action_counts",
568 &expected_report.action_counts,
569 &report.action_counts,
570 )?;
571 ensure_matching_authority_evidence_content(
572 "report.control_class_counts",
573 &expected_report.control_class_counts,
574 &report.control_class_counts,
575 )?;
576 ensure_matching_authority_evidence_content(
577 "report.observation_gaps",
578 &expected_report.observation_gaps,
579 &report.observation_gaps,
580 )?;
581 ensure_matching_authority_evidence_content(
582 "report.next_actions",
583 &expected_report.next_actions,
584 &report.next_actions,
585 )
586}
587
588fn ensure_matching_authority_evidence_field(
589 field: &'static str,
590 plan_value: &str,
591 report_value: &str,
592) -> Result<(), AuthorityEvidenceError> {
593 if plan_value == report_value {
594 return Ok(());
595 }
596
597 Err(AuthorityEvidenceError::PlanReportMismatch {
598 field,
599 plan_value: plan_value.to_string(),
600 report_value: report_value.to_string(),
601 })
602}
603
604fn ensure_evidence_check_id_matches(
605 evidence_check_id: &str,
606 component: &'static str,
607 nested_check_id: Option<&str>,
608) -> Result<(), AuthorityEvidenceError> {
609 let Some(nested_check_id) = nested_check_id else {
610 return Ok(());
611 };
612 if evidence_check_id == nested_check_id {
613 return Ok(());
614 }
615
616 Err(AuthorityEvidenceError::EvidenceCheckIdMismatch {
617 component,
618 evidence_value: evidence_check_id.to_string(),
619 nested_value: nested_check_id.to_string(),
620 })
621}
622
623fn optional_authority_value(value: Option<&String>) -> String {
624 value.map_or_else(|| "<none>".to_string(), ToString::to_string)
625}
626
627fn ensure_matching_authority_evidence_content<T: Eq>(
628 field: &'static str,
629 plan_value: &T,
630 report_value: &T,
631) -> Result<(), AuthorityEvidenceError> {
632 if plan_value == report_value {
633 return Ok(());
634 }
635
636 Err(AuthorityEvidenceError::PlanReportContentMismatch { field })
637}
638
639#[must_use]
643pub fn artifact_gate_phase_receipt(
644 check: &DeploymentCheckV1,
645 started_at: impl Into<String>,
646 finished_at: Option<String>,
647) -> PhaseReceiptV1 {
648 let missing = check
649 .report
650 .hard_failures
651 .iter()
652 .filter(|finding| finding.code == "artifact_missing")
653 .collect::<Vec<_>>();
654 let mut evidence = check
655 .inventory
656 .observed_artifacts
657 .iter()
658 .filter_map(|artifact| {
659 artifact
660 .file_sha256
661 .as_ref()
662 .map(|hash| format!("artifact:{}:sha256:{hash}", artifact.role))
663 })
664 .collect::<Vec<_>>();
665 evidence.extend(
666 missing
667 .iter()
668 .filter_map(|finding| finding.subject.as_ref())
669 .map(|role| format!("artifact:{role}:missing")),
670 );
671 let status = if missing.is_empty() {
672 ObservationStatusV1::Observed
673 } else {
674 ObservationStatusV1::Missing
675 };
676
677 phase_receipt(
678 "materialize_artifacts",
679 started_at,
680 finished_at,
681 "verify configured role artifacts are materialized",
682 status,
683 evidence,
684 )
685}
686
687#[must_use]
694pub fn artifact_gate_role_phase_receipts(check: &DeploymentCheckV1) -> Vec<RolePhaseReceiptV1> {
695 check
696 .plan
697 .role_artifacts
698 .iter()
699 .map(|planned| {
700 let observed = check
701 .inventory
702 .observed_artifacts
703 .iter()
704 .find(|artifact| artifact.role == planned.role);
705 let failures = check
706 .report
707 .hard_failures
708 .iter()
709 .filter(|finding| finding.subject.as_deref() == Some(planned.role.as_str()))
710 .filter(|finding| finding.code.starts_with("artifact_"))
711 .collect::<Vec<_>>();
712 let error = if failures.is_empty() {
713 None
714 } else {
715 Some(
716 failures
717 .iter()
718 .map(|finding| format!("{}: {}", finding.code, finding.message))
719 .collect::<Vec<_>>()
720 .join("; "),
721 )
722 };
723 let artifact_digest = observed
724 .and_then(|artifact| artifact.file_sha256.clone())
725 .or_else(|| observed.and_then(|artifact| artifact.payload_sha256.clone()))
726 .or_else(|| planned.observed_wasm_gz_file_sha256.clone())
727 .or_else(|| planned.wasm_gz_sha256.clone());
728 let result = if !failures.is_empty() {
729 RolePhaseResultV1::Failed
730 } else if observed
731 .and_then(|artifact| artifact.file_sha256.as_ref())
732 .is_some()
733 {
734 RolePhaseResultV1::VerifiedAlreadyApplied
735 } else {
736 RolePhaseResultV1::NotAttempted
737 };
738
739 RolePhaseReceiptV1 {
740 role: planned.role.clone(),
741 phase: "materialize_artifacts".to_string(),
742 result,
743 previous_module_hash: None,
744 target_module_hash: planned.installed_module_hash.clone(),
745 observed_module_hash_after: None,
746 artifact_digest,
747 canonical_embedded_config_sha256: planned.canonical_embedded_config_sha256.clone(),
748 error,
749 }
750 })
751 .collect()
752}
753
754#[must_use]
756pub fn phase_receipt(
757 phase: impl Into<String>,
758 started_at: impl Into<String>,
759 finished_at: Option<String>,
760 attempted_action: impl Into<String>,
761 status: ObservationStatusV1,
762 evidence: Vec<String>,
763) -> PhaseReceiptV1 {
764 PhaseReceiptV1 {
765 phase: phase.into(),
766 started_at: started_at.into(),
767 finished_at,
768 attempted_action: attempted_action.into(),
769 verified_postcondition: VerifiedPostconditionV1 { status, evidence },
770 }
771}
772
773#[must_use]
775pub fn deployment_receipt_from_check(
776 check: &DeploymentCheckV1,
777 operation_id: impl Into<String>,
778 started_at: impl Into<String>,
779 finished_at: Option<String>,
780 phase_receipts: Vec<PhaseReceiptV1>,
781 role_phase_receipts: Vec<RolePhaseReceiptV1>,
782 command_result: DeploymentCommandResultV1,
783) -> DeploymentReceiptV1 {
784 let operation_status = operation_status_for_command_result(&command_result);
785 deployment_receipt_from_check_with_status(
786 check,
787 operation_id,
788 operation_status,
789 started_at,
790 finished_at,
791 phase_receipts,
792 role_phase_receipts,
793 command_result,
794 )
795}
796
797#[must_use]
800#[allow(clippy::too_many_arguments)]
801pub fn deployment_receipt_from_check_with_status(
802 check: &DeploymentCheckV1,
803 operation_id: impl Into<String>,
804 operation_status: DeploymentExecutionStatusV1,
805 started_at: impl Into<String>,
806 finished_at: Option<String>,
807 phase_receipts: Vec<PhaseReceiptV1>,
808 role_phase_receipts: Vec<RolePhaseReceiptV1>,
809 command_result: DeploymentCommandResultV1,
810) -> DeploymentReceiptV1 {
811 DeploymentReceiptV1 {
812 schema_version: DEPLOYMENT_TRUTH_SCHEMA_VERSION,
813 operation_id: operation_id.into(),
814 plan_id: check.plan.plan_id.clone(),
815 execution_context: None,
816 operation_status,
817 started_at: started_at.into(),
818 finished_at,
819 operator_principal: None,
820 root_principal: check
821 .inventory
822 .observed_identity
823 .as_ref()
824 .and_then(|identity| identity.root_principal.clone())
825 .or_else(|| check.plan.deployment_identity.root_principal.clone()),
826 previous_observed_deployment_epoch: None,
827 phase_receipts,
828 role_phase_receipts,
829 final_inventory_id: Some(check.inventory.inventory_id.clone()),
830 command_result,
831 }
832}
833
834fn authority_controller_observation_from_action(
835 action: &CanisterAuthorityActionV1,
836) -> AuthorityControllerObservationV1 {
837 AuthorityControllerObservationV1 {
838 subject: authority_action_subject(action),
839 canister_id: action.canister_id.clone(),
840 role: action.role.clone(),
841 state: action.state,
842 action: action.action,
843 observed_controllers: action.observed_controllers.clone(),
844 desired_controllers: action.desired_controllers.clone(),
845 controller_delta: action.controller_delta.clone(),
846 }
847}
848
849fn authority_action_subject(action: &CanisterAuthorityActionV1) -> String {
850 action
851 .canister_id
852 .clone()
853 .or_else(|| action.role.as_ref().map(|role| format!("role:{role}")))
854 .unwrap_or_else(|| "unknown".to_string())
855}
856
857const fn operation_status_for_command_result(
858 result: &DeploymentCommandResultV1,
859) -> DeploymentExecutionStatusV1 {
860 match result {
861 DeploymentCommandResultV1::NotFinished => DeploymentExecutionStatusV1::InProgress,
862 DeploymentCommandResultV1::Succeeded => DeploymentExecutionStatusV1::Complete,
863 DeploymentCommandResultV1::Failed { .. } => {
864 DeploymentExecutionStatusV1::FailedAfterMutation
865 }
866 }
867}