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}