1use std::collections::{BTreeMap, BTreeSet};
2
3use schemars::JsonSchema;
4use serde::{Deserialize, Serialize};
5use utoipa::ToSchema;
6
7use crate::extraction_input_digest;
8
9use super::{
10 ConfigContractDefinition, ConfigRevision, DeliveryEffects, DeliveryIssue, DeliveryIssueCode,
11 ReleaseWorkloadRole, SecretProvider, ServiceRelease, config_revision_matches_contract, issue,
12 service_release_integrity_is_valid,
13};
14
15pub const DEPLOYMENT_PLAN_PROTOCOL: &str = "lenso.deployment-plan.v1";
16pub const DEPLOYMENT_RECEIPT_PROTOCOL: &str = "lenso.deployment-receipt.v1";
17pub const DEPLOYMENT_OBSERVATION_PROTOCOL: &str = "lenso.deployment-observation.v1";
18
19#[derive(
20 Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize, JsonSchema, ToSchema,
21)]
22#[serde(rename_all = "snake_case")]
23pub enum DeploymentAdapterKind {
24 Local,
25 ExternallyManaged,
26 Kubernetes,
27}
28
29#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema, ToSchema)]
30#[serde(rename_all = "camelCase")]
31pub struct DeploymentWorkloadSettings {
32 pub workload_id: String,
33 pub replicas: u32,
34 #[serde(default, skip_serializing_if = "Option::is_none")]
35 pub port: Option<u16>,
36 #[serde(default)]
37 pub command: Vec<String>,
38 #[serde(default, skip_serializing_if = "Option::is_none")]
39 pub health_path: Option<String>,
40 #[serde(default, skip_serializing_if = "Option::is_none")]
41 pub disruption_min_available: Option<u32>,
42}
43
44#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema, ToSchema)]
45#[serde(rename_all = "camelCase")]
46pub struct DeploymentEnvironmentBinding {
47 pub environment: String,
48 pub expected_environment_revision: u64,
49 pub config_revision_id: String,
50 #[serde(default)]
51 pub secret_reference_ids: Vec<String>,
52 #[serde(default)]
53 pub endpoints: BTreeMap<String, String>,
54 #[serde(default)]
55 pub placement: BTreeMap<String, String>,
56 pub workloads: Vec<DeploymentWorkloadSettings>,
57 #[serde(default)]
58 pub adapter_inputs: BTreeMap<String, String>,
59 pub gateway_plan_digest: String,
60 #[serde(default)]
61 pub policy_evidence_references: Vec<String>,
62}
63
64#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema, ToSchema)]
65#[serde(rename_all = "camelCase")]
66pub struct DeploymentWorkloadPlan {
67 pub workload_id: String,
68 pub role: ReleaseWorkloadRole,
69 pub artifact_reference: String,
70 pub artifact_digest: String,
71 pub media_type: String,
72 pub settings: DeploymentWorkloadSettings,
73}
74
75#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema, ToSchema)]
76#[serde(rename_all = "camelCase")]
77pub struct DeploymentPlan {
78 pub protocol: String,
79 pub plan_id: String,
80 pub plan_digest: String,
81 pub adapter: DeploymentAdapterKind,
82 pub environment: String,
83 pub expected_environment_revision: u64,
84 pub release_id: String,
85 pub release_digest: String,
86 pub service_id: String,
87 pub config_revision_id: String,
88 pub secret_reference_ids: Vec<String>,
89 pub endpoints: BTreeMap<String, String>,
90 pub placement: BTreeMap<String, String>,
91 pub workloads: Vec<DeploymentWorkloadPlan>,
92 pub adapter_inputs: BTreeMap<String, String>,
93 pub gateway_plan_digest: String,
94 pub policy_evidence_references: Vec<String>,
95 pub rollback_capable: bool,
96 pub next_actions: Vec<String>,
97 pub effects: DeliveryEffects,
98}
99
100#[derive(Serialize)]
101#[serde(rename_all = "camelCase")]
102struct DeploymentPlanDigestInput<'a> {
103 protocol: &'a str,
104 adapter: DeploymentAdapterKind,
105 environment: &'a str,
106 expected_environment_revision: u64,
107 release_id: &'a str,
108 release_digest: &'a str,
109 service_id: &'a str,
110 config_revision_id: &'a str,
111 secret_reference_ids: &'a [String],
112 endpoints: &'a BTreeMap<String, String>,
113 placement: &'a BTreeMap<String, String>,
114 workloads: &'a [DeploymentWorkloadPlan],
115 adapter_inputs: &'a BTreeMap<String, String>,
116 gateway_plan_digest: &'a str,
117 policy_evidence_references: &'a [String],
118 rollback_capable: bool,
119 next_actions: &'a [String],
120 effects: &'a DeliveryEffects,
121}
122
123#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema, ToSchema)]
124#[serde(rename_all = "camelCase")]
125pub struct DeploymentReceipt {
126 pub protocol: String,
127 pub receipt_id: String,
128 pub plan_id: String,
129 pub adapter: DeploymentAdapterKind,
130 pub environment: String,
131 pub environment_revision_before: u64,
132 pub environment_revision_after: u64,
133 pub release_id: String,
134 pub release_digest: String,
135 pub config_revision_id: String,
136 pub workload_digests: BTreeMap<String, String>,
137 pub gateway_plan_digest: String,
138 pub effects: DeliveryEffects,
139}
140
141#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema, ToSchema)]
142#[serde(rename_all = "camelCase")]
143pub struct DeploymentObservation {
144 pub protocol: String,
145 pub observation_id: String,
146 pub plan_id: String,
147 pub receipt_id: String,
148 pub source_observation_id: String,
149 pub environment: String,
150 pub desired_release_id: String,
151 pub observed_release_id: String,
152 pub observed_release_digest: String,
153 pub desired_workload_digests: BTreeMap<String, String>,
154 pub observed_workload_digests: BTreeMap<String, String>,
155 pub config_revision_id: String,
156 pub drifted: bool,
157 pub fresh: bool,
158 pub next_actions: Vec<String>,
159}
160
161#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema, ToSchema)]
162#[serde(rename_all = "camelCase")]
163pub struct DeploymentState {
164 pub environment: String,
165 pub environment_revision: u64,
166 #[serde(default, skip_serializing_if = "Option::is_none")]
167 pub active_release_id: Option<String>,
168 #[serde(default, skip_serializing_if = "Option::is_none")]
169 pub active_config_revision_id: Option<String>,
170 #[serde(default)]
171 pub history: Vec<DeploymentReceipt>,
172}
173
174impl DeploymentState {
175 #[must_use]
176 pub fn new(environment: impl Into<String>, environment_revision: u64) -> Self {
177 Self {
178 environment: environment.into(),
179 environment_revision,
180 active_release_id: None,
181 active_config_revision_id: None,
182 history: Vec::new(),
183 }
184 }
185}
186
187#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema, ToSchema)]
188#[serde(rename_all = "camelCase")]
189pub struct DeploymentApplyRejection {
190 pub issues: Vec<DeliveryIssue>,
191 pub effects: DeliveryEffects,
192}
193
194pub fn plan_deployment(
195 release: &ServiceRelease,
196 config_contract: &ConfigContractDefinition,
197 config: &ConfigRevision,
198 secret_provider: &dyn SecretProvider,
199 binding: &DeploymentEnvironmentBinding,
200 adapter: DeploymentAdapterKind,
201) -> Result<DeploymentPlan, Vec<DeliveryIssue>> {
202 let mut issues = Vec::new();
203 if !service_release_integrity_is_valid(release)
204 || !config_revision_matches_contract(config, config_contract, secret_provider)
205 || config.service_id != release.service_id
206 || config_contract.reference != release.config_contract.reference
207 || config_contract.digest != release.config_contract.digest
208 || binding.config_revision_id != config.revision_id
209 || binding.environment.trim().is_empty()
210 {
211 issues.push(issue(
212 DeliveryIssueCode::DeploymentInputInvalid,
213 "Deployment planning requires one integrity-valid Service Release, matching Config Revision, and environment binding.",
214 "Bind the exact release and Config Revision without modifying either artifact.",
215 "Correct the binding and plan the Deployment again.",
216 ));
217 }
218 if !deployment_public_inputs_are_valid(
219 adapter,
220 &binding.endpoints,
221 &binding.placement,
222 &binding.adapter_inputs,
223 ) {
224 issues.push(issue(
225 DeliveryIssueCode::PlaintextSecretDetected,
226 "Deployment bindings contain an unclassified or credential-shaped public value.",
227 "Use only public HTTP endpoints, placement labels, and the adapter's typed public identifiers; pass Secret References separately.",
228 "Remove free-form values and plan the Deployment again.",
229 ));
230 }
231 let configured_secret_ids = config
232 .secret_references
233 .iter()
234 .map(|reference| reference.reference_id.as_str())
235 .collect::<BTreeSet<_>>();
236 let bound_secret_ids = binding
237 .secret_reference_ids
238 .iter()
239 .map(String::as_str)
240 .collect::<BTreeSet<_>>();
241 if configured_secret_ids != bound_secret_ids {
242 issues.push(issue(
243 DeliveryIssueCode::SecretReferenceUnresolved,
244 "Deployment Secret References do not match the validated Config Revision.",
245 "Bind the exact opaque Secret Reference identifiers without reading their values.",
246 "Correct the environment binding and plan again.",
247 ));
248 }
249 let settings = binding
250 .workloads
251 .iter()
252 .map(|workload| (workload.workload_id.as_str(), workload))
253 .collect::<BTreeMap<_, _>>();
254 let release_ids = release
255 .workloads
256 .iter()
257 .map(|workload| workload.workload_id.as_str())
258 .collect::<BTreeSet<_>>();
259 if settings.keys().copied().collect::<BTreeSet<_>>() != release_ids {
260 issues.push(issue(
261 DeliveryIssueCode::DeploymentInputInvalid,
262 "Deployment Workload settings must cover every and only release Workload.",
263 "Declare role-specific settings for the exact multi-Workload Service Release.",
264 "Correct the Workload settings and plan again.",
265 ));
266 }
267 if !issues.is_empty() {
268 return Err(issues);
269 }
270 let mut workloads = release
271 .workloads
272 .iter()
273 .map(|workload| DeploymentWorkloadPlan {
274 workload_id: workload.workload_id.clone(),
275 role: workload.role,
276 artifact_reference: workload.artifact_reference.clone(),
277 artifact_digest: workload.artifact_digest.clone(),
278 media_type: workload.media_type.clone(),
279 settings: (*settings[workload.workload_id.as_str()]).clone(),
280 })
281 .collect::<Vec<_>>();
282 workloads.sort_by(|left, right| left.workload_id.cmp(&right.workload_id));
283 let mut secret_reference_ids = binding.secret_reference_ids.clone();
284 secret_reference_ids.sort();
285 let mut policy_evidence_references = binding.policy_evidence_references.clone();
286 policy_evidence_references.sort();
287 policy_evidence_references.dedup();
288 let rollback_capable = release.rollback.automatic_allowed;
289 let next_actions =
290 vec!["Review adapter diff and apply against the expected environment revision.".to_owned()];
291 let effects = DeliveryEffects::default();
292 let plan_digest = digest_json(&DeploymentPlanDigestInput {
293 protocol: DEPLOYMENT_PLAN_PROTOCOL,
294 adapter,
295 environment: &binding.environment,
296 expected_environment_revision: binding.expected_environment_revision,
297 release_id: &release.release_id,
298 release_digest: &release.release_digest,
299 service_id: &release.service_id,
300 config_revision_id: &config.revision_id,
301 secret_reference_ids: &secret_reference_ids,
302 endpoints: &binding.endpoints,
303 placement: &binding.placement,
304 workloads: &workloads,
305 adapter_inputs: &binding.adapter_inputs,
306 gateway_plan_digest: &binding.gateway_plan_digest,
307 policy_evidence_references: &policy_evidence_references,
308 rollback_capable,
309 next_actions: &next_actions,
310 effects: &effects,
311 });
312 Ok(DeploymentPlan {
313 protocol: DEPLOYMENT_PLAN_PROTOCOL.to_owned(),
314 plan_id: format!("deployment-plan:{plan_digest}"),
315 plan_digest,
316 adapter,
317 environment: binding.environment.clone(),
318 expected_environment_revision: binding.expected_environment_revision,
319 release_id: release.release_id.clone(),
320 release_digest: release.release_digest.clone(),
321 service_id: release.service_id.clone(),
322 config_revision_id: config.revision_id.clone(),
323 secret_reference_ids,
324 endpoints: binding.endpoints.clone(),
325 placement: binding.placement.clone(),
326 workloads,
327 adapter_inputs: binding.adapter_inputs.clone(),
328 gateway_plan_digest: binding.gateway_plan_digest.clone(),
329 policy_evidence_references,
330 rollback_capable,
331 next_actions,
332 effects,
333 })
334}
335
336pub fn apply_deployment(
337 state: &mut DeploymentState,
338 plan: &DeploymentPlan,
339) -> Result<DeploymentReceipt, DeploymentApplyRejection> {
340 if !deployment_plan_integrity_is_valid(plan) {
341 return Err(DeploymentApplyRejection {
342 issues: vec![issue(
343 DeliveryIssueCode::StaleInput,
344 "Deployment inputs changed after the plan was generated.",
345 "Generate a new plan from current adapter and environment observations.",
346 "Refresh environment state and plan the Deployment again.",
347 )],
348 effects: DeliveryEffects::default(),
349 });
350 }
351 if let Some(existing) = state
352 .history
353 .iter()
354 .find(|receipt| receipt.plan_id == plan.plan_id)
355 {
356 return deployment_receipt_integrity_is_valid(existing, plan)
357 .then(|| existing.clone())
358 .ok_or_else(|| DeploymentApplyRejection {
359 issues: vec![issue(
360 DeliveryIssueCode::StaleInput,
361 "The completed Deployment receipt no longer matches the exact plan.",
362 "Preserve the immutable plan and append-only receipt together.",
363 "Restore the original receipt or create a new Deployment plan.",
364 )],
365 effects: DeliveryEffects::default(),
366 });
367 }
368 if state.environment != plan.environment
369 || state.environment_revision != plan.expected_environment_revision
370 {
371 return Err(DeploymentApplyRejection {
372 issues: vec![issue(
373 DeliveryIssueCode::StaleInput,
374 "Deployment inputs changed after the plan was generated.",
375 "Generate a new plan from current adapter and environment observations.",
376 "Refresh environment state and plan the Deployment again.",
377 )],
378 effects: DeliveryEffects::default(),
379 });
380 }
381 let revision_before = state.environment_revision;
382 state.environment_revision += 1;
383 state.active_release_id = Some(plan.release_id.clone());
384 state.active_config_revision_id = Some(plan.config_revision_id.clone());
385 let workload_digests = plan
386 .workloads
387 .iter()
388 .map(|workload| {
389 (
390 workload.workload_id.clone(),
391 workload.artifact_digest.clone(),
392 )
393 })
394 .collect::<BTreeMap<_, _>>();
395 let effects = DeliveryEffects {
396 mutates_environment: true,
397 mutates_deployment: true,
398 appends_ledger: true,
399 ..DeliveryEffects::default()
400 };
401 let receipt_digest = digest_json(&(
402 DEPLOYMENT_RECEIPT_PROTOCOL,
403 plan.plan_id.as_str(),
404 plan.adapter,
405 plan.environment.as_str(),
406 revision_before,
407 state.environment_revision,
408 plan.release_id.as_str(),
409 plan.release_digest.as_str(),
410 plan.config_revision_id.as_str(),
411 &workload_digests,
412 plan.gateway_plan_digest.as_str(),
413 &effects,
414 ));
415 let receipt_id = format!("deployment-receipt:{receipt_digest}");
416 let receipt = DeploymentReceipt {
417 protocol: DEPLOYMENT_RECEIPT_PROTOCOL.to_owned(),
418 receipt_id,
419 plan_id: plan.plan_id.clone(),
420 adapter: plan.adapter,
421 environment: plan.environment.clone(),
422 environment_revision_before: revision_before,
423 environment_revision_after: state.environment_revision,
424 release_id: plan.release_id.clone(),
425 release_digest: plan.release_digest.clone(),
426 config_revision_id: plan.config_revision_id.clone(),
427 workload_digests,
428 gateway_plan_digest: plan.gateway_plan_digest.clone(),
429 effects,
430 };
431 state.history.push(receipt.clone());
432 Ok(receipt)
433}
434
435#[must_use]
436pub fn observe_deployment(
437 plan: &DeploymentPlan,
438 receipt: &DeploymentReceipt,
439 fresh: bool,
440) -> DeploymentObservation {
441 observe_deployment_adapter(
442 plan,
443 &receipt.receipt_id,
444 &receipt.receipt_id,
445 &receipt.release_id,
446 &receipt.release_digest,
447 &receipt.workload_digests,
448 &receipt.config_revision_id,
449 fresh,
450 )
451}
452
453#[must_use]
454pub fn observe_deployment_adapter(
455 plan: &DeploymentPlan,
456 receipt_id: &str,
457 source_observation_id: &str,
458 observed_release_id: &str,
459 observed_release_digest: &str,
460 observed_workload_digests: &BTreeMap<String, String>,
461 observed_config_revision_id: &str,
462 fresh: bool,
463) -> DeploymentObservation {
464 let desired_workload_digests = plan
465 .workloads
466 .iter()
467 .map(|workload| {
468 (
469 workload.workload_id.clone(),
470 workload.artifact_digest.clone(),
471 )
472 })
473 .collect::<BTreeMap<_, _>>();
474 let drifted = observed_release_id != plan.release_id
475 || observed_release_digest != plan.release_digest
476 || observed_config_revision_id != plan.config_revision_id
477 || observed_workload_digests != &desired_workload_digests;
478 let next_actions = if drifted || !fresh {
479 vec![
480 "Refresh adapter observations and reconcile the Deployment before Promotion."
481 .to_owned(),
482 ]
483 } else {
484 vec!["Use this fresh observation as Deployment evidence.".to_owned()]
485 };
486 let observation_id = format!(
487 "deployment-observation:{}",
488 digest_json(&(
489 DEPLOYMENT_OBSERVATION_PROTOCOL,
490 plan.plan_id.as_str(),
491 receipt_id,
492 source_observation_id,
493 plan.environment.as_str(),
494 plan.release_id.as_str(),
495 observed_release_id,
496 observed_release_digest,
497 &desired_workload_digests,
498 observed_workload_digests,
499 observed_config_revision_id,
500 drifted,
501 fresh,
502 &next_actions,
503 ))
504 );
505 DeploymentObservation {
506 protocol: DEPLOYMENT_OBSERVATION_PROTOCOL.to_owned(),
507 observation_id,
508 plan_id: plan.plan_id.clone(),
509 receipt_id: receipt_id.to_owned(),
510 source_observation_id: source_observation_id.to_owned(),
511 environment: plan.environment.clone(),
512 desired_release_id: plan.release_id.clone(),
513 observed_release_id: observed_release_id.to_owned(),
514 observed_release_digest: observed_release_digest.to_owned(),
515 desired_workload_digests,
516 observed_workload_digests: observed_workload_digests.clone(),
517 config_revision_id: observed_config_revision_id.to_owned(),
518 drifted,
519 fresh,
520 next_actions,
521 }
522}
523
524#[must_use]
525pub fn deployment_plan_integrity_is_valid(plan: &DeploymentPlan) -> bool {
526 plan.protocol == DEPLOYMENT_PLAN_PROTOCOL
527 && plan.plan_id == format!("deployment-plan:{}", plan.plan_digest)
528 && deployment_public_inputs_are_valid(
529 plan.adapter,
530 &plan.endpoints,
531 &plan.placement,
532 &plan.adapter_inputs,
533 )
534 && digest_json(&DeploymentPlanDigestInput {
535 protocol: &plan.protocol,
536 adapter: plan.adapter,
537 environment: &plan.environment,
538 expected_environment_revision: plan.expected_environment_revision,
539 release_id: &plan.release_id,
540 release_digest: &plan.release_digest,
541 service_id: &plan.service_id,
542 config_revision_id: &plan.config_revision_id,
543 secret_reference_ids: &plan.secret_reference_ids,
544 endpoints: &plan.endpoints,
545 placement: &plan.placement,
546 workloads: &plan.workloads,
547 adapter_inputs: &plan.adapter_inputs,
548 gateway_plan_digest: &plan.gateway_plan_digest,
549 policy_evidence_references: &plan.policy_evidence_references,
550 rollback_capable: plan.rollback_capable,
551 next_actions: &plan.next_actions,
552 effects: &plan.effects,
553 }) == plan.plan_digest
554}
555
556fn deployment_public_inputs_are_valid(
557 adapter: DeploymentAdapterKind,
558 endpoints: &BTreeMap<String, String>,
559 placement: &BTreeMap<String, String>,
560 adapter_inputs: &BTreeMap<String, String>,
561) -> bool {
562 endpoints.iter().all(|(key, value)| {
563 public_field_name_is_safe(key)
564 && value.len() <= 2_048
565 && (value.starts_with("http://") || value.starts_with("https://"))
566 && !value.contains(['@', '?', '#', '\\'])
567 && !value.chars().any(char::is_whitespace)
568 }) && placement.iter().all(|(key, value)| {
569 public_field_name_is_safe(key)
570 && !key.is_empty()
571 && key.len() <= 253
572 && !value.is_empty()
573 && value.len() <= 63
574 && key
575 .chars()
576 .all(|character| character.is_ascii_alphanumeric() || ".-_/".contains(character))
577 && value
578 .chars()
579 .all(|character| character.is_ascii_alphanumeric() || ".-_".contains(character))
580 }) && match adapter {
581 DeploymentAdapterKind::Kubernetes => {
582 adapter_inputs
583 .iter()
584 .all(|(key, value)| match key.as_str() {
585 "resourceName" => kubernetes_resource_name_is_safe(value),
586 "rollbackReleaseId" => immutable_release_id_is_safe(value),
587 _ => false,
588 })
589 }
590 DeploymentAdapterKind::Local | DeploymentAdapterKind::ExternallyManaged => {
591 adapter_inputs.is_empty()
592 }
593 }
594}
595
596fn public_field_name_is_safe(key: &str) -> bool {
597 let normalized = key.to_ascii_lowercase();
598 ![
599 "secret",
600 "password",
601 "credential",
602 "privatekey",
603 "signingkey",
604 "accesstoken",
605 "token",
606 ]
607 .iter()
608 .any(|forbidden| normalized.contains(forbidden))
609}
610
611fn kubernetes_resource_name_is_safe(value: &str) -> bool {
612 !value.is_empty()
613 && value.len() <= 253
614 && value.chars().all(|character| {
615 character.is_ascii_lowercase()
616 || character.is_ascii_digit()
617 || character == '-'
618 || character == '.'
619 })
620 && value
621 .chars()
622 .next()
623 .is_some_and(|character| character.is_ascii_lowercase() || character.is_ascii_digit())
624 && value
625 .chars()
626 .last()
627 .is_some_and(|character| character.is_ascii_lowercase() || character.is_ascii_digit())
628}
629
630fn immutable_release_id_is_safe(value: &str) -> bool {
631 value
632 .strip_prefix("service-release:sha256:")
633 .is_some_and(|digest| {
634 digest.len() == 64
635 && digest
636 .bytes()
637 .all(|byte| byte.is_ascii_digit() || (b'a'..=b'f').contains(&byte))
638 })
639}
640
641#[must_use]
642pub fn deployment_receipt_integrity_is_valid(
643 receipt: &DeploymentReceipt,
644 plan: &DeploymentPlan,
645) -> bool {
646 let expected_workloads = plan
647 .workloads
648 .iter()
649 .map(|workload| {
650 (
651 workload.workload_id.clone(),
652 workload.artifact_digest.clone(),
653 )
654 })
655 .collect::<BTreeMap<_, _>>();
656 let expected_id = format!(
657 "deployment-receipt:{}",
658 digest_json(&(
659 receipt.protocol.as_str(),
660 receipt.plan_id.as_str(),
661 receipt.adapter,
662 receipt.environment.as_str(),
663 receipt.environment_revision_before,
664 receipt.environment_revision_after,
665 receipt.release_id.as_str(),
666 receipt.release_digest.as_str(),
667 receipt.config_revision_id.as_str(),
668 &receipt.workload_digests,
669 receipt.gateway_plan_digest.as_str(),
670 &receipt.effects,
671 ))
672 );
673 deployment_plan_integrity_is_valid(plan)
674 && receipt.protocol == DEPLOYMENT_RECEIPT_PROTOCOL
675 && receipt.receipt_id == expected_id
676 && receipt.plan_id == plan.plan_id
677 && receipt.adapter == plan.adapter
678 && receipt.environment == plan.environment
679 && receipt.environment_revision_before == plan.expected_environment_revision
680 && receipt.environment_revision_after == receipt.environment_revision_before + 1
681 && receipt.release_id == plan.release_id
682 && receipt.release_digest == plan.release_digest
683 && receipt.config_revision_id == plan.config_revision_id
684 && receipt.workload_digests == expected_workloads
685 && receipt.gateway_plan_digest == plan.gateway_plan_digest
686 && receipt.effects
687 == DeliveryEffects {
688 mutates_environment: true,
689 mutates_deployment: true,
690 appends_ledger: true,
691 ..DeliveryEffects::default()
692 }
693}
694
695#[must_use]
696pub fn deployment_observation_integrity_is_valid(
697 observation: &DeploymentObservation,
698 plan: &DeploymentPlan,
699 receipt: &DeploymentReceipt,
700) -> bool {
701 let expected = observe_deployment_adapter(
702 plan,
703 &receipt.receipt_id,
704 &observation.source_observation_id,
705 &receipt.release_id,
706 &receipt.release_digest,
707 &receipt.workload_digests,
708 &receipt.config_revision_id,
709 observation.fresh,
710 );
711 deployment_receipt_integrity_is_valid(receipt, plan)
712 && deployment_observation_content_integrity_is_valid(observation)
713 && !observation.source_observation_id.trim().is_empty()
714 && observation == &expected
715}
716
717#[must_use]
718pub fn deployment_observation_content_integrity_is_valid(
719 observation: &DeploymentObservation,
720) -> bool {
721 observation.protocol == DEPLOYMENT_OBSERVATION_PROTOCOL
722 && observation.observation_id
723 == format!(
724 "deployment-observation:{}",
725 digest_json(&(
726 observation.protocol.as_str(),
727 observation.plan_id.as_str(),
728 observation.receipt_id.as_str(),
729 observation.source_observation_id.as_str(),
730 observation.environment.as_str(),
731 observation.desired_release_id.as_str(),
732 observation.observed_release_id.as_str(),
733 observation.observed_release_digest.as_str(),
734 &observation.desired_workload_digests,
735 &observation.observed_workload_digests,
736 observation.config_revision_id.as_str(),
737 observation.drifted,
738 observation.fresh,
739 observation.next_actions.as_slice(),
740 ))
741 )
742}
743
744fn digest_json(value: &impl Serialize) -> String {
745 extraction_input_digest(serde_json::to_vec(value).expect("Deployment values must serialize"))
746}