Skip to main content

lenso_service/production_delivery/
release.rs

1use std::collections::BTreeSet;
2
3use schemars::JsonSchema;
4use serde::{Deserialize, Serialize};
5use serde_json::{Value, json};
6use utoipa::ToSchema;
7
8use crate::extraction_input_digest;
9
10use super::{DeliveryIssue, DeliveryIssueCode, issue, valid_sha256_digest};
11
12pub const SERVICE_RELEASE_PROTOCOL: &str = "lenso.service-release.v1";
13const SERVICE_RELEASE_SCHEMA_ID: &str =
14    "https://contracts.lenso.local/delivery/lenso.service-release.v1.schema.json";
15
16#[derive(
17    Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize, JsonSchema, ToSchema,
18)]
19#[serde(rename_all = "camelCase")]
20pub struct DeliveryEvidenceReference {
21    pub reference: String,
22    pub digest: String,
23}
24
25#[derive(
26    Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize, JsonSchema, ToSchema,
27)]
28#[serde(rename_all = "camelCase")]
29pub struct ReleaseModule {
30    pub module_id: String,
31    pub module_version: String,
32}
33
34#[derive(
35    Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize, JsonSchema, ToSchema,
36)]
37#[serde(rename_all = "snake_case")]
38pub enum ReleaseWorkloadRole {
39    Api,
40    Worker,
41    Migration,
42    Extension,
43}
44
45impl ReleaseWorkloadRole {
46    const fn as_str(self) -> &'static str {
47        match self {
48            Self::Api => "api",
49            Self::Worker => "worker",
50            Self::Migration => "migration",
51            Self::Extension => "extension",
52        }
53    }
54}
55
56#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema, ToSchema)]
57#[serde(rename_all = "camelCase")]
58pub struct ReleaseProvenance {
59    pub reference: String,
60    pub digest: String,
61    pub source: String,
62    pub builder: String,
63    #[serde(default)]
64    pub input_digests: Vec<String>,
65    #[serde(default)]
66    pub subject_digests: Vec<String>,
67}
68
69#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema, ToSchema)]
70#[serde(rename_all = "camelCase")]
71pub struct WorkloadArtifact {
72    pub workload_id: String,
73    pub role: ReleaseWorkloadRole,
74    pub artifact_reference: String,
75    pub artifact_digest: String,
76    pub media_type: String,
77    #[serde(default, skip_serializing_if = "Option::is_none")]
78    pub display_tag: Option<String>,
79    pub sbom: DeliveryEvidenceReference,
80    pub provenance: ReleaseProvenance,
81    pub signature_subject: String,
82}
83
84#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema, ToSchema)]
85#[serde(rename_all = "camelCase")]
86pub struct ReleaseContractVersion {
87    pub contract_id: String,
88    pub version: String,
89    pub kind: String,
90    pub artifact: DeliveryEvidenceReference,
91}
92
93#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema, ToSchema)]
94#[serde(rename_all = "camelCase")]
95pub struct ReleaseMigration {
96    pub migration_id: String,
97    pub phase: String,
98    pub artifact: DeliveryEvidenceReference,
99    pub reversible: bool,
100}
101
102#[derive(
103    Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize, JsonSchema, ToSchema,
104)]
105#[serde(rename_all = "camelCase")]
106pub struct ReleaseRolloutGate {
107    pub gate_id: String,
108    pub evidence_kind: String,
109    pub required: bool,
110}
111
112#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, JsonSchema, ToSchema)]
113#[serde(rename_all = "camelCase")]
114pub struct ReleaseRollbackConstraints {
115    pub previous_release_required: bool,
116    pub automatic_allowed: bool,
117    pub blocked_by_irreversible_migration: bool,
118}
119
120#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, JsonSchema, ToSchema)]
121#[serde(rename_all = "camelCase")]
122pub struct ReleaseRetention {
123    pub evidence_days: u32,
124    pub artifact_days: u32,
125}
126
127#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema, ToSchema)]
128#[serde(rename_all = "camelCase")]
129pub struct ReleaseSignature {
130    pub signer: String,
131    pub subject_digest: String,
132    pub signature: String,
133}
134
135#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
136#[serde(rename_all = "camelCase")]
137pub struct ServiceReleaseInput {
138    pub service_id: String,
139    pub service_version: String,
140    pub modules: Vec<ReleaseModule>,
141    pub workloads: Vec<WorkloadArtifact>,
142    pub contract_versions: Vec<ReleaseContractVersion>,
143    pub config_contract: DeliveryEvidenceReference,
144    pub reliability_contract: DeliveryEvidenceReference,
145    pub migrations: Vec<ReleaseMigration>,
146    pub workflow_compatibility: Vec<DeliveryEvidenceReference>,
147    pub verification_evidence: Vec<DeliveryEvidenceReference>,
148    pub rollout_gates: Vec<ReleaseRolloutGate>,
149    pub rollback: ReleaseRollbackConstraints,
150    pub retention: ReleaseRetention,
151}
152
153#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema, ToSchema)]
154#[serde(rename_all = "camelCase")]
155pub struct ServiceRelease {
156    pub protocol: String,
157    pub release_id: String,
158    pub release_digest: String,
159    pub service_id: String,
160    pub service_version: String,
161    pub modules: Vec<ReleaseModule>,
162    pub workloads: Vec<WorkloadArtifact>,
163    pub contract_versions: Vec<ReleaseContractVersion>,
164    pub config_contract: DeliveryEvidenceReference,
165    pub reliability_contract: DeliveryEvidenceReference,
166    pub migrations: Vec<ReleaseMigration>,
167    pub workflow_compatibility: Vec<DeliveryEvidenceReference>,
168    pub verification_evidence: Vec<DeliveryEvidenceReference>,
169    pub rollout_gates: Vec<ReleaseRolloutGate>,
170    pub rollback: ReleaseRollbackConstraints,
171    pub retention: ReleaseRetention,
172    #[serde(default)]
173    pub signatures: Vec<ReleaseSignature>,
174}
175
176#[derive(Serialize)]
177#[serde(rename_all = "camelCase")]
178struct ServiceReleaseContent<'a> {
179    protocol: &'a str,
180    service_id: &'a str,
181    service_version: &'a str,
182    modules: &'a [ReleaseModule],
183    workloads: &'a [WorkloadArtifact],
184    contract_versions: &'a [ReleaseContractVersion],
185    config_contract: &'a DeliveryEvidenceReference,
186    reliability_contract: &'a DeliveryEvidenceReference,
187    migrations: &'a [ReleaseMigration],
188    workflow_compatibility: &'a [DeliveryEvidenceReference],
189    verification_evidence: &'a [DeliveryEvidenceReference],
190    rollout_gates: &'a [ReleaseRolloutGate],
191    rollback: ReleaseRollbackConstraints,
192    retention: ReleaseRetention,
193}
194
195#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema, ToSchema)]
196#[serde(rename_all = "camelCase")]
197pub struct ServiceReleaseDiffEntry {
198    pub subject: String,
199    #[serde(default, skip_serializing_if = "Option::is_none")]
200    pub before: Option<String>,
201    #[serde(default, skip_serializing_if = "Option::is_none")]
202    pub after: Option<String>,
203}
204
205#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema, ToSchema)]
206#[serde(rename_all = "camelCase")]
207pub struct ServiceReleaseDiff {
208    pub protocol: String,
209    pub from_release_id: String,
210    pub to_release_id: String,
211    pub entries: Vec<ServiceReleaseDiffEntry>,
212}
213
214pub fn assemble_service_release(
215    mut input: ServiceReleaseInput,
216) -> Result<ServiceRelease, Vec<DeliveryIssue>> {
217    normalize_release_input(&mut input);
218    let issues = validate_release_input(&input);
219    if !issues.is_empty() {
220        return Err(issues);
221    }
222
223    let content = ServiceReleaseContent {
224        protocol: SERVICE_RELEASE_PROTOCOL,
225        service_id: &input.service_id,
226        service_version: &input.service_version,
227        modules: &input.modules,
228        workloads: &input.workloads,
229        contract_versions: &input.contract_versions,
230        config_contract: &input.config_contract,
231        reliability_contract: &input.reliability_contract,
232        migrations: &input.migrations,
233        workflow_compatibility: &input.workflow_compatibility,
234        verification_evidence: &input.verification_evidence,
235        rollout_gates: &input.rollout_gates,
236        rollback: input.rollback,
237        retention: input.retention,
238    };
239    let release_digest = extraction_input_digest(
240        serde_json::to_vec(&content).expect("validated Service Release content must serialize"),
241    );
242    Ok(ServiceRelease {
243        protocol: SERVICE_RELEASE_PROTOCOL.to_owned(),
244        release_id: format!("service-release:{release_digest}"),
245        release_digest,
246        service_id: input.service_id,
247        service_version: input.service_version,
248        modules: input.modules,
249        workloads: input.workloads,
250        contract_versions: input.contract_versions,
251        config_contract: input.config_contract,
252        reliability_contract: input.reliability_contract,
253        migrations: input.migrations,
254        workflow_compatibility: input.workflow_compatibility,
255        verification_evidence: input.verification_evidence,
256        rollout_gates: input.rollout_gates,
257        rollback: input.rollback,
258        retention: input.retention,
259        signatures: Vec::new(),
260    })
261}
262
263#[must_use]
264pub fn service_release_integrity_is_valid(release: &ServiceRelease) -> bool {
265    if release.protocol != SERVICE_RELEASE_PROTOCOL
266        || release.release_id != format!("service-release:{}", release.release_digest)
267    {
268        return false;
269    }
270    let content = ServiceReleaseContent {
271        protocol: &release.protocol,
272        service_id: &release.service_id,
273        service_version: &release.service_version,
274        modules: &release.modules,
275        workloads: &release.workloads,
276        contract_versions: &release.contract_versions,
277        config_contract: &release.config_contract,
278        reliability_contract: &release.reliability_contract,
279        migrations: &release.migrations,
280        workflow_compatibility: &release.workflow_compatibility,
281        verification_evidence: &release.verification_evidence,
282        rollout_gates: &release.rollout_gates,
283        rollback: release.rollback,
284        retention: release.retention,
285    };
286    let input = ServiceReleaseInput {
287        service_id: release.service_id.clone(),
288        service_version: release.service_version.clone(),
289        modules: release.modules.clone(),
290        workloads: release.workloads.clone(),
291        contract_versions: release.contract_versions.clone(),
292        config_contract: release.config_contract.clone(),
293        reliability_contract: release.reliability_contract.clone(),
294        migrations: release.migrations.clone(),
295        workflow_compatibility: release.workflow_compatibility.clone(),
296        verification_evidence: release.verification_evidence.clone(),
297        rollout_gates: release.rollout_gates.clone(),
298        rollback: release.rollback,
299        retention: release.retention,
300    };
301    let mut normalized = input.clone();
302    normalize_release_input(&mut normalized);
303    input == normalized
304        && validate_release_input(&input).is_empty()
305        && serde_json::to_vec(&content)
306            .map(extraction_input_digest)
307            .is_ok_and(|digest| digest == release.release_digest)
308}
309
310#[must_use]
311pub fn diff_service_releases(from: &ServiceRelease, to: &ServiceRelease) -> ServiceReleaseDiff {
312    let mut entries = Vec::new();
313    let from_content = release_content_value(from);
314    let to_content = release_content_value(to);
315    for (field, subject) in [
316        ("serviceId", "service.identity"),
317        ("serviceVersion", "service.version"),
318        ("modules", "modules"),
319        ("workloads", "workloads"),
320        ("contractVersions", "contracts"),
321        ("configContract", "config.contract"),
322        ("reliabilityContract", "reliability.contract"),
323        ("migrations", "migrations"),
324        ("workflowCompatibility", "workflow.compatibility"),
325        ("verificationEvidence", "verification.evidence"),
326        ("rolloutGates", "rollout.gates"),
327        ("rollback", "rollback.constraints"),
328        ("retention", "retention"),
329    ] {
330        let before = from_content.get(field);
331        let after = to_content.get(field);
332        if before != after {
333            entries.push(ServiceReleaseDiffEntry {
334                subject: subject.to_owned(),
335                before: before.map(stable_json),
336                after: after.map(stable_json),
337            });
338        }
339    }
340    ServiceReleaseDiff {
341        protocol: "lenso.service-release-diff.v1".to_owned(),
342        from_release_id: from.release_id.clone(),
343        to_release_id: to.release_id.clone(),
344        entries,
345    }
346}
347
348fn release_content_value(release: &ServiceRelease) -> Value {
349    serde_json::to_value(ServiceReleaseContent {
350        protocol: &release.protocol,
351        service_id: &release.service_id,
352        service_version: &release.service_version,
353        modules: &release.modules,
354        workloads: &release.workloads,
355        contract_versions: &release.contract_versions,
356        config_contract: &release.config_contract,
357        reliability_contract: &release.reliability_contract,
358        migrations: &release.migrations,
359        workflow_compatibility: &release.workflow_compatibility,
360        verification_evidence: &release.verification_evidence,
361        rollout_gates: &release.rollout_gates,
362        rollback: release.rollback,
363        retention: release.retention,
364    })
365    .expect("Service Release content must serialize")
366}
367
368fn stable_json(value: &Value) -> String {
369    serde_json::to_string(value).expect("Service Release diff value must serialize")
370}
371
372#[must_use]
373pub fn service_release_schema() -> Value {
374    let mut schema = serde_json::to_value(schemars::schema_for!(ServiceRelease))
375        .expect("Service Release schema must serialize");
376    if let Some(object) = schema.as_object_mut() {
377        object.insert("$id".to_owned(), json!(SERVICE_RELEASE_SCHEMA_ID));
378        object.insert("title".to_owned(), json!("Lenso Service Release v1"));
379    }
380    schema
381}
382
383fn normalize_release_input(input: &mut ServiceReleaseInput) {
384    input.modules.sort();
385    input.workloads.sort_by(|left, right| {
386        (left.role.as_str(), left.workload_id.as_str())
387            .cmp(&(right.role.as_str(), right.workload_id.as_str()))
388    });
389    input.contract_versions.sort_by(|left, right| {
390        (&left.contract_id, &left.version, &left.kind).cmp(&(
391            &right.contract_id,
392            &right.version,
393            &right.kind,
394        ))
395    });
396    input.migrations.sort_by(|left, right| {
397        (&left.migration_id, &left.phase).cmp(&(&right.migration_id, &right.phase))
398    });
399    input.workflow_compatibility.sort();
400    input.verification_evidence.sort();
401    input.rollout_gates.sort();
402    for workload in &mut input.workloads {
403        workload.provenance.input_digests.sort();
404        workload.provenance.subject_digests.sort();
405    }
406}
407
408fn validate_release_input(input: &ServiceReleaseInput) -> Vec<DeliveryIssue> {
409    let mut issues = Vec::new();
410    if input.service_id.trim().is_empty()
411        || input.service_version.trim().is_empty()
412        || input.modules.is_empty()
413        || input.workloads.is_empty()
414        || input.contract_versions.is_empty()
415    {
416        issues.push(issue(
417            DeliveryIssueCode::ReleaseInputInvalid,
418            "A Service Release requires Service identity, version, Modules, Workloads, and Contract Versions.",
419            "Supply the complete environment-independent Service Release inputs.",
420            "Correct the release input and assemble it again.",
421        ));
422    }
423
424    let mut workload_ids = BTreeSet::new();
425    for workload in &input.workloads {
426        if workload.workload_id.trim().is_empty()
427            || workload.artifact_reference.trim().is_empty()
428            || workload.media_type.trim().is_empty()
429            || workload.signature_subject.trim().is_empty()
430        {
431            issues.push(issue(
432                DeliveryIssueCode::ReleaseInputInvalid,
433                "A Workload is missing its identity, media type, or signature subject.",
434                "Declare every Workload artifact completely.",
435                "Correct the Workload declaration and assemble the release again.",
436            ));
437        }
438        if !workload_ids.insert(workload.workload_id.as_str()) {
439            issues.push(issue(
440                DeliveryIssueCode::ReleaseInputInvalid,
441                format!(
442                    "Workload `{}` is declared more than once.",
443                    workload.workload_id
444                ),
445                "Keep exactly one artifact declaration per Workload identity.",
446                "Remove the duplicate Workload and assemble the release again.",
447            ));
448        }
449        if !valid_sha256_digest(&workload.artifact_digest) {
450            issues.push(issue(
451                DeliveryIssueCode::MutableArtifactReference,
452                format!(
453                    "Workload `{}` is not pinned by an immutable sha256 digest.",
454                    workload.workload_id
455                ),
456                "Resolve the artifact through existing build infrastructure and supply its immutable digest.",
457                "Replace the mutable artifact reference and assemble the release again.",
458            ));
459        }
460        if workload.sbom.reference.trim().is_empty() || !valid_sha256_digest(&workload.sbom.digest)
461        {
462            issues.push(issue(
463                DeliveryIssueCode::MissingSbom,
464                format!(
465                    "Workload `{}` has no digest-pinned SBOM.",
466                    workload.workload_id
467                ),
468                "Attach an addressable SBOM produced by the build pipeline.",
469                "Generate and attach the Workload SBOM.",
470            ));
471        }
472        if workload.provenance.reference.trim().is_empty()
473            || workload.provenance.source.trim().is_empty()
474            || workload.provenance.builder.trim().is_empty()
475            || !valid_sha256_digest(&workload.provenance.digest)
476            || workload.provenance.input_digests.is_empty()
477            || workload
478                .provenance
479                .input_digests
480                .iter()
481                .any(|digest| !valid_sha256_digest(digest))
482        {
483            issues.push(issue(
484                DeliveryIssueCode::MissingProvenance,
485                format!(
486                    "Workload `{}` has incomplete provenance.",
487                    workload.workload_id
488                ),
489                "Attach provenance with source, builder, inputs, and subjects.",
490                "Generate and attach complete Workload provenance.",
491            ));
492        }
493    }
494
495    for evidence in input
496        .contract_versions
497        .iter()
498        .map(|contract| &contract.artifact)
499        .chain(std::iter::once(&input.config_contract))
500        .chain(std::iter::once(&input.reliability_contract))
501        .chain(input.migrations.iter().map(|migration| &migration.artifact))
502        .chain(input.workflow_compatibility.iter())
503        .chain(input.verification_evidence.iter())
504    {
505        if evidence.reference.trim().is_empty() || !valid_sha256_digest(&evidence.digest) {
506            issues.push(issue(
507                DeliveryIssueCode::ReleaseInputInvalid,
508                "Release evidence must have a stable reference and sha256 digest.",
509                "Regenerate the versioned evidence and pin its digest.",
510                "Correct the evidence reference and assemble the release again.",
511            ));
512        }
513    }
514    issues
515}