1use std::collections::{BTreeMap, BTreeSet};
2
3use schemars::JsonSchema;
4use serde::{Deserialize, Serialize};
5use serde_json::Value;
6use utoipa::ToSchema;
7
8use crate::extraction_input_digest;
9
10use super::{DeliveryEffects, DeliveryIssue, DeliveryIssueCode, issue};
11
12pub const CONFIG_CONTRACT_PROTOCOL: &str = "lenso.config-contract.v1";
13pub const CONFIG_REVISION_PROTOCOL: &str = "lenso.config-revision.v1";
14pub const CONFIG_ACTIVATION_PLAN_PROTOCOL: &str = "lenso.config-activation-plan.v1";
15pub const CONFIG_ACTIVATION_RECEIPT_PROTOCOL: &str = "lenso.config-activation-receipt.v1";
16
17#[derive(
18 Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize, JsonSchema, ToSchema,
19)]
20#[serde(rename_all = "snake_case")]
21pub enum ConfigValueType {
22 String,
23 Integer,
24 Number,
25 Boolean,
26 Object,
27 Array,
28}
29
30#[derive(
31 Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize, JsonSchema, ToSchema,
32)]
33#[serde(rename_all = "snake_case")]
34pub enum ConfigFieldSensitivity {
35 Public,
36 Sensitive,
37}
38
39#[derive(
40 Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize, JsonSchema, ToSchema,
41)]
42#[serde(rename_all = "snake_case")]
43pub enum ConfigFieldScope {
44 Service,
45 Workload,
46}
47
48#[derive(
49 Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize, JsonSchema, ToSchema,
50)]
51#[serde(rename_all = "snake_case")]
52pub enum ConfigFieldActivation {
53 Hot,
54 Restart,
55}
56
57#[derive(
58 Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize, JsonSchema, ToSchema,
59)]
60#[serde(rename_all = "camelCase")]
61pub struct ConfigField {
62 pub path: String,
63 pub value_type: ConfigValueType,
64 pub required: bool,
65 pub sensitivity: ConfigFieldSensitivity,
66 pub scope: ConfigFieldScope,
67 pub activation: ConfigFieldActivation,
68 pub mutable: bool,
69}
70
71#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema, ToSchema)]
72#[serde(rename_all = "camelCase")]
73pub struct ConfigContractDefinition {
74 pub protocol: String,
75 pub reference: String,
76 pub digest: String,
77 pub fields: Vec<ConfigField>,
78}
79
80#[derive(
81 Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize, JsonSchema, ToSchema,
82)]
83#[serde(rename_all = "snake_case")]
84pub enum SecretReferenceStatus {
85 Resolved,
86 Unresolved,
87 Expired,
88}
89
90#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema, ToSchema)]
91#[serde(rename_all = "camelCase")]
92pub struct SecretReference {
93 pub reference_id: String,
94 pub provider: String,
95 pub purpose: String,
96 pub scope: String,
97 pub status: SecretReferenceStatus,
98 #[serde(default)]
99 pub metadata: BTreeMap<String, String>,
100}
101
102#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema, ToSchema)]
103#[serde(rename_all = "camelCase")]
104pub struct SecretReferenceObservation {
105 pub status: SecretReferenceStatus,
106 #[serde(default)]
107 pub metadata: BTreeMap<String, String>,
108}
109
110pub trait SecretProvider: std::fmt::Debug + Send + Sync {
112 fn provider_name(&self) -> &str;
113
114 fn observe_reference(&self, reference_id: &str) -> Option<SecretReferenceObservation>;
115}
116
117#[derive(Debug, Clone, Default)]
118pub struct DeterministicSecretProvider {
119 provider_name: String,
120 observations: BTreeMap<String, SecretReferenceObservation>,
121}
122
123impl DeterministicSecretProvider {
124 #[must_use]
125 pub fn new(
126 provider_name: impl Into<String>,
127 observations: impl IntoIterator<Item = (String, SecretReferenceObservation)>,
128 ) -> Self {
129 Self {
130 provider_name: provider_name.into(),
131 observations: observations.into_iter().collect(),
132 }
133 }
134}
135
136impl SecretProvider for DeterministicSecretProvider {
137 fn provider_name(&self) -> &str {
138 &self.provider_name
139 }
140
141 fn observe_reference(&self, reference_id: &str) -> Option<SecretReferenceObservation> {
142 self.observations.get(reference_id).cloned()
143 }
144}
145
146#[must_use]
147pub fn observe_secret_reference(
148 provider: &dyn SecretProvider,
149 reference_id: impl Into<String>,
150 purpose: impl Into<String>,
151 scope: impl Into<String>,
152) -> SecretReference {
153 let reference_id = reference_id.into();
154 let observation = provider
155 .observe_reference(&reference_id)
156 .filter(|observation| secret_metadata_is_safe(&observation.metadata))
157 .unwrap_or(SecretReferenceObservation {
158 status: SecretReferenceStatus::Unresolved,
159 metadata: BTreeMap::new(),
160 });
161 SecretReference {
162 reference_id,
163 provider: provider.provider_name().to_owned(),
164 purpose: purpose.into(),
165 scope: scope.into(),
166 status: observation.status,
167 metadata: observation.metadata,
168 }
169}
170
171#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema, ToSchema)]
172#[serde(rename_all = "camelCase")]
173pub struct ConfigImpact {
174 pub path: String,
175 pub scope: ConfigFieldScope,
176 pub activation: ConfigFieldActivation,
177 pub mutable: bool,
178}
179
180#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema, ToSchema)]
181#[serde(rename_all = "camelCase")]
182pub struct ConfigRevision {
183 pub protocol: String,
184 pub revision_id: String,
185 pub revision_digest: String,
186 pub service_id: String,
187 pub contract_reference: String,
188 pub contract_digest: String,
189 pub values: BTreeMap<String, Value>,
190 pub secret_references: Vec<SecretReference>,
191 pub impacts: Vec<ConfigImpact>,
192}
193
194#[derive(
195 Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize, JsonSchema, ToSchema,
196)]
197#[serde(rename_all = "snake_case")]
198pub enum ConfigOperation {
199 Stage,
200 Activate,
201 Rollback,
202}
203
204#[derive(
205 Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize, JsonSchema, ToSchema,
206)]
207#[serde(rename_all = "snake_case")]
208pub enum ConfigRevisionActivation {
209 Staged,
210 Active,
211 RolledBack,
212}
213
214#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema, ToSchema)]
215#[serde(rename_all = "camelCase")]
216pub struct ConfigActivationPlan {
217 pub protocol: String,
218 pub plan_id: String,
219 pub plan_digest: String,
220 pub environment: String,
221 pub expected_environment_revision: u64,
222 pub operation: ConfigOperation,
223 pub target_revision_id: String,
224 #[serde(default, skip_serializing_if = "Option::is_none")]
225 pub previous_revision_id: Option<String>,
226 pub contract_digest: String,
227 pub effects: DeliveryEffects,
228}
229
230#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema, ToSchema)]
231#[serde(rename_all = "camelCase")]
232pub struct ConfigActivationReceipt {
233 pub protocol: String,
234 pub receipt_id: String,
235 pub plan_id: String,
236 pub environment: String,
237 pub environment_revision_before: u64,
238 pub environment_revision_after: u64,
239 pub target_revision_id: String,
240 #[serde(default, skip_serializing_if = "Option::is_none")]
241 pub previous_revision_id: Option<String>,
242 pub activation: ConfigRevisionActivation,
243 pub effects: DeliveryEffects,
244}
245
246#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema, ToSchema)]
247#[serde(rename_all = "camelCase")]
248pub struct ConfigState {
249 pub environment: String,
250 pub environment_revision: u64,
251 #[serde(default, skip_serializing_if = "Option::is_none")]
252 pub staged_revision_id: Option<String>,
253 #[serde(default, skip_serializing_if = "Option::is_none")]
254 pub active_revision_id: Option<String>,
255 #[serde(default, skip_serializing_if = "Option::is_none")]
256 pub previous_revision_id: Option<String>,
257 #[serde(default)]
258 pub history: Vec<ConfigActivationReceipt>,
259}
260
261impl ConfigState {
262 #[must_use]
263 pub fn new(environment: impl Into<String>, environment_revision: u64) -> Self {
264 Self {
265 environment: environment.into(),
266 environment_revision,
267 staged_revision_id: None,
268 active_revision_id: None,
269 previous_revision_id: None,
270 history: Vec::new(),
271 }
272 }
273}
274
275#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema, ToSchema)]
276#[serde(rename_all = "camelCase")]
277pub struct ConfigApplyRejection {
278 pub issues: Vec<DeliveryIssue>,
279 pub effects: DeliveryEffects,
280}
281
282pub fn build_config_contract(
283 reference: impl Into<String>,
284 mut fields: Vec<ConfigField>,
285) -> Result<ConfigContractDefinition, Vec<DeliveryIssue>> {
286 let reference = reference.into();
287 fields.sort();
288 let mut issues = Vec::new();
289 if reference.trim().is_empty() || fields.is_empty() {
290 issues.push(issue(
291 DeliveryIssueCode::ConfigContractMismatch,
292 "A Config Contract requires a stable reference and at least one field.",
293 "Declare the exact configuration surface consumed by the Service Release.",
294 "Correct the Config Contract and build it again.",
295 ));
296 }
297 let unique_paths = fields
298 .iter()
299 .map(|field| field.path.as_str())
300 .collect::<BTreeSet<_>>();
301 if unique_paths.len() != fields.len() || unique_paths.contains("") {
302 issues.push(issue(
303 DeliveryIssueCode::ConfigContractMismatch,
304 "Config Contract field paths must be non-empty and unique.",
305 "Assign one stable path to every configuration field.",
306 "Correct duplicate or empty paths and build the contract again.",
307 ));
308 }
309 if !issues.is_empty() {
310 return Err(issues);
311 }
312 let digest = digest_json(&(
313 CONFIG_CONTRACT_PROTOCOL,
314 reference.as_str(),
315 fields.as_slice(),
316 ));
317 Ok(ConfigContractDefinition {
318 protocol: CONFIG_CONTRACT_PROTOCOL.to_owned(),
319 reference,
320 digest,
321 fields,
322 })
323}
324
325pub fn build_config_revision(
326 service_id: impl Into<String>,
327 contract: &ConfigContractDefinition,
328 values: BTreeMap<String, Value>,
329 mut secret_references: Vec<SecretReference>,
330 secret_provider: &dyn SecretProvider,
331) -> Result<ConfigRevision, Vec<DeliveryIssue>> {
332 let service_id = service_id.into();
333 let mut issues = Vec::new();
334 if !config_contract_integrity_is_valid(contract) {
335 issues.push(issue(
336 DeliveryIssueCode::ConfigContractMismatch,
337 "The Config Contract content does not match its digest.",
338 "Use the exact Config Contract bound to the Service Release.",
339 "Regenerate the Config Contract and Config Revision.",
340 ));
341 }
342
343 let fields = contract
344 .fields
345 .iter()
346 .map(|field| (field.path.as_str(), field))
347 .collect::<BTreeMap<_, _>>();
348 for (path, value) in &values {
349 let Some(field) = fields.get(path.as_str()) else {
350 issues.push(issue(
351 DeliveryIssueCode::ConfigContractMismatch,
352 format!("Configuration field `{path}` is not declared by the Config Contract."),
353 "Remove unknown values or update the Config Contract before building a revision.",
354 "Correct the values and build the Config Revision again.",
355 ));
356 continue;
357 };
358 if field.sensitivity == ConfigFieldSensitivity::Sensitive {
359 issues.push(issue(
360 DeliveryIssueCode::PlaintextSecretDetected,
361 format!("Sensitive field `{path}` was supplied as configuration data."),
362 "Replace the value with an opaque Secret Reference.",
363 "Remove the plaintext value and bind a Secret Reference.",
364 ));
365 } else if !value_matches(field.value_type, value) {
366 issues.push(issue(
367 DeliveryIssueCode::ConfigContractMismatch,
368 format!("Configuration field `{path}` has the wrong JSON value type."),
369 "Supply a value matching the Config Contract field type.",
370 "Correct the value and build the Config Revision again.",
371 ));
372 }
373 }
374
375 secret_references.sort_by(|left, right| {
376 (&left.purpose, &left.reference_id).cmp(&(&right.purpose, &right.reference_id))
377 });
378 let unique_reference_ids = secret_references
379 .iter()
380 .map(|reference| reference.reference_id.as_str())
381 .collect::<BTreeSet<_>>();
382 let unique_purposes = secret_references
383 .iter()
384 .map(|reference| reference.purpose.as_str())
385 .collect::<BTreeSet<_>>();
386 if unique_reference_ids.len() != secret_references.len()
387 || unique_purposes.len() != secret_references.len()
388 {
389 issues.push(issue(
390 DeliveryIssueCode::ConfigContractMismatch,
391 "Secret References must bind one unique opaque reference to each sensitive field.",
392 "Keep exactly one resolved Secret Reference per declared sensitive purpose.",
393 "Remove duplicate Secret References and build the Config Revision again.",
394 ));
395 }
396 for reference in &secret_references {
397 let observed = secret_provider.observe_reference(&reference.reference_id);
398 if reference.reference_id.trim().is_empty()
399 || reference.provider.trim().is_empty()
400 || reference.purpose.trim().is_empty()
401 || reference.scope.trim().is_empty()
402 || !secret_metadata_is_safe(&reference.metadata)
403 {
404 issues.push(issue(
405 DeliveryIssueCode::PlaintextSecretDetected,
406 "A Secret Reference contains unsafe or value-shaped metadata.",
407 "Keep only opaque provider identity, purpose, scope, and non-sensitive status metadata.",
408 "Remove sensitive metadata and build the Config Revision again.",
409 ));
410 }
411 if reference.provider != secret_provider.provider_name()
412 || observed.as_ref().is_none_or(|observed| {
413 observed.status != reference.status || observed.metadata != reference.metadata
414 })
415 {
416 issues.push(issue(
417 DeliveryIssueCode::SecretReferenceUnresolved,
418 format!(
419 "Secret Reference `{}` is not attested by the selected Secret Provider.",
420 reference.reference_id
421 ),
422 "Observe the opaque reference through the configured provider boundary.",
423 "Refresh the provider observation and build the Config Revision again.",
424 ));
425 }
426 if reference.status != SecretReferenceStatus::Resolved {
427 issues.push(issue(
428 DeliveryIssueCode::SecretReferenceUnresolved,
429 format!(
430 "Secret Reference `{}` is not resolved.",
431 reference.reference_id
432 ),
433 "Resolve the opaque reference through the selected Secret Provider.",
434 "Refresh Secret Reference status before activation.",
435 ));
436 }
437 if !fields
438 .get(reference.purpose.as_str())
439 .is_some_and(|field| field.sensitivity == ConfigFieldSensitivity::Sensitive)
440 {
441 issues.push(issue(
442 DeliveryIssueCode::ConfigContractMismatch,
443 format!(
444 "Secret Reference `{}` does not bind a declared sensitive Config field.",
445 reference.reference_id
446 ),
447 "Bind opaque references only to sensitive fields in the exact Config Contract.",
448 "Correct the Secret Reference purpose and build the Config Revision again.",
449 ));
450 }
451 }
452
453 for field in &contract.fields {
454 if !field.required {
455 continue;
456 }
457 let present = match field.sensitivity {
458 ConfigFieldSensitivity::Public => values.contains_key(&field.path),
459 ConfigFieldSensitivity::Sensitive => secret_references
460 .iter()
461 .any(|reference| reference.purpose == field.path),
462 };
463 if !present {
464 issues.push(issue(
465 if field.sensitivity == ConfigFieldSensitivity::Sensitive {
466 DeliveryIssueCode::SecretReferenceUnresolved
467 } else {
468 DeliveryIssueCode::ConfigContractMismatch
469 },
470 format!("Required configuration field `{}` is missing.", field.path),
471 "Supply the required non-secret value or opaque Secret Reference.",
472 "Complete the Config Revision and validate it again.",
473 ));
474 }
475 }
476 if !issues.is_empty() {
477 return Err(issues);
478 }
479
480 let impacts = contract
481 .fields
482 .iter()
483 .filter(|field| {
484 values.contains_key(&field.path)
485 || secret_references
486 .iter()
487 .any(|reference| reference.purpose == field.path)
488 })
489 .map(|field| ConfigImpact {
490 path: field.path.clone(),
491 scope: field.scope,
492 activation: field.activation,
493 mutable: field.mutable,
494 })
495 .collect::<Vec<_>>();
496 let revision_digest = digest_json(&(
497 CONFIG_REVISION_PROTOCOL,
498 service_id.as_str(),
499 contract.reference.as_str(),
500 contract.digest.as_str(),
501 &values,
502 &secret_references,
503 &impacts,
504 ));
505 Ok(ConfigRevision {
506 protocol: CONFIG_REVISION_PROTOCOL.to_owned(),
507 revision_id: format!("config-revision:{revision_digest}"),
508 revision_digest,
509 service_id,
510 contract_reference: contract.reference.clone(),
511 contract_digest: contract.digest.clone(),
512 values,
513 secret_references,
514 impacts,
515 })
516}
517
518fn secret_metadata_is_safe(metadata: &BTreeMap<String, String>) -> bool {
519 metadata.iter().all(|(key, value)| match key.as_str() {
520 "leaseExpiresAt" | "lastResolvedAt" => timestamp_metadata_is_safe(value),
521 "rotationRevision" | "providerRevision" => {
522 !value.is_empty()
523 && value.len() <= 20
524 && value.bytes().all(|byte| byte.is_ascii_digit())
525 }
526 "rotationStatus" => matches!(
527 value.as_str(),
528 "current" | "due" | "rotating" | "stale" | "revoked"
529 ),
530 _ => false,
531 })
532}
533
534#[must_use]
537pub fn secret_reference_metadata_is_safe(reference: &SecretReference) -> bool {
538 secret_metadata_is_safe(&reference.metadata)
539}
540
541fn timestamp_metadata_is_safe(value: &str) -> bool {
542 value.len() >= 20
543 && value.len() <= 40
544 && value.as_bytes().get(4) == Some(&b'-')
545 && value.as_bytes().get(7) == Some(&b'-')
546 && value.as_bytes().get(10) == Some(&b'T')
547 && (value.ends_with('Z')
548 || value
549 .as_bytes()
550 .iter()
551 .rev()
552 .take(6)
553 .any(|byte| *byte == b'+'))
554 && value.bytes().all(|byte| {
555 byte.is_ascii_digit() || matches!(byte, b'-' | b':' | b'T' | b'.' | b'Z' | b'+')
556 })
557}
558
559pub fn plan_config_activation(
560 state: &ConfigState,
561 contract: &ConfigContractDefinition,
562 revision: &ConfigRevision,
563 secret_provider: &dyn SecretProvider,
564 operation: ConfigOperation,
565) -> Result<ConfigActivationPlan, Vec<DeliveryIssue>> {
566 if state.environment.trim().is_empty()
567 || !config_revision_matches_contract(revision, contract, secret_provider)
568 {
569 return Err(vec![issue(
570 DeliveryIssueCode::ConfigContractMismatch,
571 "Config activation requires an integrity-valid Config Revision and environment identity.",
572 "Regenerate the Config Revision from its exact Config Contract.",
573 "Correct the input and plan activation again.",
574 )]);
575 }
576 if operation == ConfigOperation::Activate
577 && state.staged_revision_id.as_deref() != Some(revision.revision_id.as_str())
578 && state.active_revision_id.as_deref() != Some(revision.revision_id.as_str())
579 {
580 return Err(vec![issue(
581 DeliveryIssueCode::ConfigContractMismatch,
582 "The Config Revision must be staged before activation.",
583 "Stage and validate the exact revision before making it active.",
584 "Plan and apply the stage operation first.",
585 )]);
586 }
587 if operation == ConfigOperation::Rollback
588 && state.previous_revision_id.as_deref() != Some(revision.revision_id.as_str())
589 {
590 return Err(vec![issue(
591 DeliveryIssueCode::ConfigContractMismatch,
592 "Config rollback must target the environment's explicit previous Config Revision.",
593 "Select the exact previous revision recorded by the active environment state.",
594 "Refresh Config state and plan rollback to its previous revision.",
595 )]);
596 }
597 let previous_revision_id = state.active_revision_id.clone();
598 let effects = DeliveryEffects::default();
599 let plan_digest = digest_json(&(
600 CONFIG_ACTIVATION_PLAN_PROTOCOL,
601 state.environment.as_str(),
602 state.environment_revision,
603 operation,
604 revision.revision_id.as_str(),
605 previous_revision_id.as_deref(),
606 revision.contract_digest.as_str(),
607 &effects,
608 ));
609 Ok(ConfigActivationPlan {
610 protocol: CONFIG_ACTIVATION_PLAN_PROTOCOL.to_owned(),
611 plan_id: format!("config-activation-plan:{plan_digest}"),
612 plan_digest,
613 environment: state.environment.clone(),
614 expected_environment_revision: state.environment_revision,
615 operation,
616 target_revision_id: revision.revision_id.clone(),
617 previous_revision_id,
618 contract_digest: revision.contract_digest.clone(),
619 effects,
620 })
621}
622
623pub fn apply_config_activation(
624 state: &mut ConfigState,
625 plan: &ConfigActivationPlan,
626) -> Result<ConfigActivationReceipt, ConfigApplyRejection> {
627 if !config_activation_plan_integrity_is_valid(plan) {
628 return Err(ConfigApplyRejection {
629 issues: vec![issue(
630 DeliveryIssueCode::StaleInput,
631 "Config activation inputs changed after the plan was generated.",
632 "Generate a new plan from the current environment and Config Revision state.",
633 "Refresh state and plan the configuration operation again.",
634 )],
635 effects: DeliveryEffects::default(),
636 });
637 }
638 if let Some(existing) = state
639 .history
640 .iter()
641 .find(|item| item.plan_id == plan.plan_id)
642 {
643 return config_activation_receipt_integrity_is_valid(existing, plan)
644 .then(|| existing.clone())
645 .ok_or_else(|| ConfigApplyRejection {
646 issues: vec![issue(
647 DeliveryIssueCode::StaleInput,
648 "The completed Config activation receipt no longer matches the exact plan.",
649 "Preserve the immutable plan and append-only receipt together.",
650 "Restore the original receipt or create a new Config activation plan.",
651 )],
652 effects: DeliveryEffects::default(),
653 });
654 }
655 if state.environment != plan.environment
656 || state.environment_revision != plan.expected_environment_revision
657 {
658 return Err(ConfigApplyRejection {
659 issues: vec![issue(
660 DeliveryIssueCode::StaleInput,
661 "Config activation inputs changed after the plan was generated.",
662 "Generate a new plan from the current environment and Config Revision state.",
663 "Refresh state and plan the configuration operation again.",
664 )],
665 effects: DeliveryEffects::default(),
666 });
667 }
668
669 let activation = match plan.operation {
670 ConfigOperation::Stage => {
671 state.staged_revision_id = Some(plan.target_revision_id.clone());
672 ConfigRevisionActivation::Staged
673 }
674 ConfigOperation::Activate => {
675 if state.staged_revision_id.as_deref() != Some(plan.target_revision_id.as_str())
676 && state.active_revision_id.as_deref() != Some(plan.target_revision_id.as_str())
677 {
678 return Err(ConfigApplyRejection {
679 issues: vec![issue(
680 DeliveryIssueCode::StaleInput,
681 "The staged Config Revision changed before activation.",
682 "Regenerate the plan from the current staged revision.",
683 "Restage or replan activation.",
684 )],
685 effects: DeliveryEffects::default(),
686 });
687 }
688 state.previous_revision_id = state.active_revision_id.clone();
689 state.active_revision_id = Some(plan.target_revision_id.clone());
690 state.staged_revision_id = None;
691 ConfigRevisionActivation::Active
692 }
693 ConfigOperation::Rollback => {
694 if state.previous_revision_id.as_deref() != Some(plan.target_revision_id.as_str())
695 || state.active_revision_id != plan.previous_revision_id
696 {
697 return Err(ConfigApplyRejection {
698 issues: vec![issue(
699 DeliveryIssueCode::StaleInput,
700 "The previous Config Revision changed before rollback.",
701 "Regenerate rollback from the current active and previous revision pair.",
702 "Refresh state and replan the rollback.",
703 )],
704 effects: DeliveryEffects::default(),
705 });
706 }
707 state.previous_revision_id = state.active_revision_id.clone();
708 state.active_revision_id = Some(plan.target_revision_id.clone());
709 state.staged_revision_id = None;
710 ConfigRevisionActivation::RolledBack
711 }
712 };
713 let revision_before = state.environment_revision;
714 state.environment_revision += 1;
715 let effects = DeliveryEffects {
716 mutates_configuration: true,
717 appends_ledger: true,
718 ..DeliveryEffects::default()
719 };
720 let receipt_id = format!(
721 "config-activation-receipt:{}",
722 digest_json(&(
723 plan.plan_id.as_str(),
724 revision_before,
725 state.environment_revision,
726 activation,
727 ))
728 );
729 let receipt = ConfigActivationReceipt {
730 protocol: CONFIG_ACTIVATION_RECEIPT_PROTOCOL.to_owned(),
731 receipt_id,
732 plan_id: plan.plan_id.clone(),
733 environment: state.environment.clone(),
734 environment_revision_before: revision_before,
735 environment_revision_after: state.environment_revision,
736 target_revision_id: plan.target_revision_id.clone(),
737 previous_revision_id: plan.previous_revision_id.clone(),
738 activation,
739 effects,
740 };
741 state.history.push(receipt.clone());
742 Ok(receipt)
743}
744
745#[must_use]
746pub fn config_contract_integrity_is_valid(contract: &ConfigContractDefinition) -> bool {
747 let mut canonical_fields = contract.fields.clone();
748 canonical_fields.sort();
749 let unique_paths = contract
750 .fields
751 .iter()
752 .map(|field| field.path.as_str())
753 .collect::<BTreeSet<_>>();
754 contract.protocol == CONFIG_CONTRACT_PROTOCOL
755 && !contract.reference.trim().is_empty()
756 && !contract.fields.is_empty()
757 && contract.fields == canonical_fields
758 && unique_paths.len() == contract.fields.len()
759 && !unique_paths.contains("")
760 && digest_json(&(
761 contract.protocol.as_str(),
762 contract.reference.as_str(),
763 contract.fields.as_slice(),
764 )) == contract.digest
765}
766
767#[must_use]
768pub fn config_revision_integrity_is_valid(revision: &ConfigRevision) -> bool {
769 revision.protocol == CONFIG_REVISION_PROTOCOL
770 && revision.revision_id == format!("config-revision:{}", revision.revision_digest)
771 && digest_json(&(
772 revision.protocol.as_str(),
773 revision.service_id.as_str(),
774 revision.contract_reference.as_str(),
775 revision.contract_digest.as_str(),
776 &revision.values,
777 &revision.secret_references,
778 &revision.impacts,
779 )) == revision.revision_digest
780}
781
782#[must_use]
783pub fn config_revision_matches_contract(
784 revision: &ConfigRevision,
785 contract: &ConfigContractDefinition,
786 secret_provider: &dyn SecretProvider,
787) -> bool {
788 if !config_contract_integrity_is_valid(contract)
789 || !config_revision_integrity_is_valid(revision)
790 || revision.contract_reference != contract.reference
791 || revision.contract_digest != contract.digest
792 {
793 return false;
794 }
795 build_config_revision(
796 revision.service_id.clone(),
797 contract,
798 revision.values.clone(),
799 revision.secret_references.clone(),
800 secret_provider,
801 )
802 .is_ok_and(|expected| expected == *revision)
803}
804
805#[must_use]
806pub fn config_activation_plan_integrity_is_valid(plan: &ConfigActivationPlan) -> bool {
807 plan.protocol == CONFIG_ACTIVATION_PLAN_PROTOCOL
808 && plan.plan_id == format!("config-activation-plan:{}", plan.plan_digest)
809 && digest_json(&(
810 plan.protocol.as_str(),
811 plan.environment.as_str(),
812 plan.expected_environment_revision,
813 plan.operation,
814 plan.target_revision_id.as_str(),
815 plan.previous_revision_id.as_deref(),
816 plan.contract_digest.as_str(),
817 &plan.effects,
818 )) == plan.plan_digest
819}
820
821#[must_use]
822pub fn config_activation_receipt_integrity_is_valid(
823 receipt: &ConfigActivationReceipt,
824 plan: &ConfigActivationPlan,
825) -> bool {
826 let activation = match plan.operation {
827 ConfigOperation::Stage => ConfigRevisionActivation::Staged,
828 ConfigOperation::Activate => ConfigRevisionActivation::Active,
829 ConfigOperation::Rollback => ConfigRevisionActivation::RolledBack,
830 };
831 let effects = DeliveryEffects {
832 mutates_configuration: true,
833 appends_ledger: true,
834 ..DeliveryEffects::default()
835 };
836 receipt.protocol == CONFIG_ACTIVATION_RECEIPT_PROTOCOL
837 && config_activation_plan_integrity_is_valid(plan)
838 && receipt.plan_id == plan.plan_id
839 && receipt.environment == plan.environment
840 && receipt.environment_revision_before == plan.expected_environment_revision
841 && receipt.environment_revision_after == receipt.environment_revision_before + 1
842 && receipt.target_revision_id == plan.target_revision_id
843 && receipt.previous_revision_id == plan.previous_revision_id
844 && receipt.activation == activation
845 && receipt.effects == effects
846 && receipt.receipt_id
847 == format!(
848 "config-activation-receipt:{}",
849 digest_json(&(
850 plan.plan_id.as_str(),
851 receipt.environment_revision_before,
852 receipt.environment_revision_after,
853 activation,
854 ))
855 )
856}
857
858fn value_matches(expected: ConfigValueType, value: &Value) -> bool {
859 match expected {
860 ConfigValueType::String => value.is_string(),
861 ConfigValueType::Integer => value.as_i64().is_some() || value.as_u64().is_some(),
862 ConfigValueType::Number => value.is_number(),
863 ConfigValueType::Boolean => value.is_boolean(),
864 ConfigValueType::Object => value.is_object(),
865 ConfigValueType::Array => value.is_array(),
866 }
867}
868
869fn digest_json(value: &impl Serialize) -> String {
870 extraction_input_digest(serde_json::to_vec(value).expect("delivery values must serialize"))
871}