Skip to main content

lenso_service/production_delivery/
deployment.rs

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}