1use chrono::{DateTime, Utc};
2use fs2::FileExt as _;
3use lenso_contracts::{ServiceResponsibilityProfile, digest_json};
4use schemars::JsonSchema;
5use serde::{Deserialize, Serialize};
6use std::collections::{BTreeMap, BTreeSet};
7use std::fs::{self, OpenOptions};
8use std::io::Write as _;
9use std::path::{Path, PathBuf};
10use thiserror::Error;
11
12pub const SERVICE_INSTALLATION_SET_PROTOCOL: &str = "lenso.service-installations.v1";
13pub const SERVICE_INSTALLATION_PLAN_PROTOCOL: &str = "lenso.service-install-plan.v1";
14pub const SERVICE_INSTALLATION_RECEIPT_PROTOCOL: &str = "lenso.service-install-receipt.v1";
15
16#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize, JsonSchema)]
17#[serde(deny_unknown_fields)]
18pub struct ServiceReference {
19 pub system_id: String,
20 pub service_id: String,
21}
22
23#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
24#[serde(rename_all = "snake_case")]
25pub enum ServiceDesiredMode {
26 Active,
27 Inactive,
28}
29
30#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
31#[serde(deny_unknown_fields)]
32pub struct InstalledServiceRelease {
33 pub version: String,
34 pub digest: String,
35 pub immutable_locator: String,
36}
37
38#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
39#[serde(deny_unknown_fields)]
40pub struct InstalledServiceExport {
41 pub export_key: String,
42 pub module_id: String,
43 pub module_version: String,
44 pub module_release_digest: String,
45 pub manifest_digest: String,
46 #[serde(default, skip_serializing_if = "Vec::is_empty")]
47 pub contract_digests: Vec<String>,
48}
49
50#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
51#[serde(rename_all = "snake_case")]
52pub enum ConfigActivationIntent {
53 Prepare,
54 Activate,
55}
56
57#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
58#[serde(deny_unknown_fields)]
59pub struct ServiceConfigBinding {
60 pub owner_id: String,
61 pub config_contract_digest: String,
62 pub config_revision_id: String,
63 pub config_revision_digest: String,
64 pub activation: ConfigActivationIntent,
65 #[serde(default, skip_serializing_if = "Vec::is_empty")]
66 pub secret_references: Vec<String>,
67}
68
69#[derive(
70 Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize, JsonSchema,
71)]
72#[serde(rename_all = "snake_case")]
73pub enum ServiceTransportBinding {
74 ProviderHttpJson,
75 ProviderGrpc,
76 DirectHttp,
77 DirectGrpc,
78 Event,
79 SystemPlane,
80}
81
82#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
83#[serde(deny_unknown_fields)]
84pub struct StaticEndpointDeclaration {
85 pub address: String,
86 pub binding: ServiceTransportBinding,
87 #[serde(default, skip_serializing_if = "Option::is_none")]
88 pub region: Option<String>,
89 #[serde(default, skip_serializing_if = "Option::is_none")]
90 pub failure_domain: Option<String>,
91 #[serde(default)]
92 pub priority: u32,
93 #[serde(default = "default_weight")]
94 pub weight: u32,
95}
96
97const fn default_weight() -> u32 {
98 1
99}
100
101#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
102#[serde(tag = "kind", rename_all = "snake_case")]
103pub enum EndpointResolverSource {
104 Static {
105 endpoints: Vec<StaticEndpointDeclaration>,
106 },
107 LocalProcess {
108 source_id: String,
109 },
110 Adapter {
111 adapter_id: String,
112 #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
113 public_config: BTreeMap<String, String>,
114 #[serde(default, skip_serializing_if = "Vec::is_empty")]
115 secret_references: Vec<String>,
116 },
117}
118
119#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
120#[serde(deny_unknown_fields)]
121pub struct ServiceIdentityPolicy {
122 pub principal: String,
123 pub audience: String,
124 pub trust_profile: String,
125 #[serde(default, skip_serializing_if = "Vec::is_empty")]
126 pub credential_references: Vec<String>,
127}
128
129#[derive(Debug, Clone, PartialEq, Eq, Default, Serialize, Deserialize, JsonSchema)]
130#[serde(deny_unknown_fields)]
131pub struct EndpointSelectionPolicy {
132 #[serde(default, skip_serializing_if = "Vec::is_empty")]
133 pub preferred_regions: Vec<String>,
134 #[serde(default)]
135 pub require_distinct_failure_domains: bool,
136}
137
138#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
139#[serde(deny_unknown_fields)]
140pub struct EndpointCachePolicy {
141 pub maximum_age_seconds: u64,
142 #[serde(default, skip_serializing_if = "Option::is_none")]
143 pub stale_if_source_unavailable_seconds: Option<u64>,
144}
145
146#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
147#[serde(deny_unknown_fields)]
148pub struct EndpointBinding {
149 pub binding_id: String,
150 pub service_ref: ServiceReference,
151 pub resolver_source: EndpointResolverSource,
152 pub allowed_bindings: Vec<ServiceTransportBinding>,
153 pub identity_policy: ServiceIdentityPolicy,
154 #[serde(default)]
155 pub selection_policy: EndpointSelectionPolicy,
156 pub cache_policy: EndpointCachePolicy,
157}
158
159#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
160#[serde(tag = "kind", rename_all = "snake_case")]
161pub enum ServiceLifecycleBinding {
162 External {
163 deployment_reference: String,
164 observation_adapter_id: String,
165 #[serde(default, skip_serializing_if = "Option::is_none")]
166 operation_adapter_id: Option<String>,
167 },
168 LocalSupervisor {
169 supervisor_id: String,
170 workload_artifact_digests: Vec<String>,
171 working_directory: String,
172 readiness_timeout_seconds: u64,
173 shutdown_timeout_seconds: u64,
174 },
175}
176
177#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
178#[serde(deny_unknown_fields)]
179pub struct ServiceInstallation {
180 pub service_ref: ServiceReference,
181 pub profile: ServiceResponsibilityProfile,
182 pub desired_mode: ServiceDesiredMode,
183 pub service_release: InstalledServiceRelease,
184 pub exports: Vec<InstalledServiceExport>,
185 #[serde(default, skip_serializing_if = "Vec::is_empty")]
186 pub config_bindings: Vec<ServiceConfigBinding>,
187 pub endpoint_binding: EndpointBinding,
188 pub lifecycle_binding: ServiceLifecycleBinding,
189}
190
191#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
192#[serde(deny_unknown_fields)]
193pub struct ServiceInstallationSet {
194 pub protocol: String,
195 pub system_id: String,
196 pub environment_id: String,
197 pub revision: u64,
198 #[serde(default, skip_serializing_if = "Option::is_none")]
199 pub previous_state_digest: Option<String>,
200 pub services: Vec<ServiceInstallation>,
201}
202
203impl ServiceInstallationSet {
204 #[must_use]
205 pub fn empty(system_id: impl Into<String>, environment_id: impl Into<String>) -> Self {
206 Self {
207 protocol: SERVICE_INSTALLATION_SET_PROTOCOL.to_owned(),
208 system_id: system_id.into(),
209 environment_id: environment_id.into(),
210 revision: 0,
211 previous_state_digest: None,
212 services: Vec::new(),
213 }
214 }
215}
216
217#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
218#[serde(tag = "kind", rename_all = "snake_case")]
219pub enum ServiceInstallationChange {
220 Install { installation: ServiceInstallation },
221 Uninstall { service_ref: ServiceReference },
222}
223
224#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
225#[serde(rename_all = "snake_case")]
226pub enum ServiceInstallationPlanKind {
227 Install,
228 Update,
229 Reuse,
230 Uninstall,
231}
232
233#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
234#[serde(deny_unknown_fields)]
235pub struct ServiceInstallationPlan {
236 pub protocol: String,
237 pub plan_id: String,
238 pub plan_digest: String,
239 pub system_id: String,
240 pub environment_id: String,
241 pub kind: ServiceInstallationPlanKind,
242 pub change: ServiceInstallationChange,
243 pub expected_revision: u64,
244 pub expected_state_digest: String,
245 pub target_revision: u64,
246 pub target_state_digest: String,
247 pub target: ServiceInstallationSet,
248 pub required_authority: String,
249 pub next_actions: Vec<String>,
250 pub created_at: DateTime<Utc>,
251}
252
253#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
254#[serde(rename_all = "snake_case")]
255pub enum ServiceInstallationOutcome {
256 AppliedNeedsAttention,
257 Reused,
258 Removed,
259}
260
261#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
262#[serde(deny_unknown_fields)]
263pub struct ServiceInstallationReceipt {
264 pub protocol: String,
265 pub receipt_id: String,
266 pub operation_id: String,
267 pub plan_id: String,
268 pub plan_digest: String,
269 pub actor_id: String,
270 pub verified_authorities: Vec<String>,
271 pub system_id: String,
272 pub environment_id: String,
273 pub service_ref: ServiceReference,
274 pub prior_revision: u64,
275 pub target_revision: u64,
276 pub prior_state_digest: String,
277 pub target_state_digest: String,
278 pub outcome: ServiceInstallationOutcome,
279 pub reasons: Vec<String>,
280 pub next_actions: Vec<String>,
281 pub committed_at: DateTime<Utc>,
282}
283
284#[derive(Debug, Error)]
285pub enum ServiceInstallationError {
286 #[error("Service Installation contract is invalid: {0}")]
287 InvalidContract(String),
288 #[error("Service Installation state is stale")]
289 StaleState,
290 #[error("Service Installation operation requires authority `{0}`")]
291 MissingAuthority(String),
292 #[error("Service Installation operation identity is unsafe")]
293 UnsafeOperationIdentity,
294 #[error("Service Installation I/O failed: {0}")]
295 Io(#[from] std::io::Error),
296 #[error("Service Installation JSON failed: {0}")]
297 Json(#[from] serde_json::Error),
298}
299
300#[derive(Debug, Clone)]
301pub struct WorkspaceServiceInstallationManager {
302 root: PathBuf,
303 system_id: String,
304 environment_id: String,
305}
306
307impl WorkspaceServiceInstallationManager {
308 pub fn new(
309 root: impl Into<PathBuf>,
310 system_id: impl Into<String>,
311 environment_id: impl Into<String>,
312 ) -> Self {
313 Self {
314 root: root.into(),
315 system_id: system_id.into(),
316 environment_id: environment_id.into(),
317 }
318 }
319
320 pub fn snapshot(&self) -> Result<ServiceInstallationSet, ServiceInstallationError> {
321 let path = self.state_path()?;
322 match fs::read(path) {
323 Ok(bytes) => {
324 let state: ServiceInstallationSet = serde_json::from_slice(&bytes)?;
325 validate_service_installation_set(&state)?;
326 if state.system_id != self.system_id || state.environment_id != self.environment_id
327 {
328 return invalid("Service Installation Set scope differs from target scope");
329 }
330 Ok(state)
331 }
332 Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(
333 ServiceInstallationSet::empty(&self.system_id, &self.environment_id),
334 ),
335 Err(error) => Err(error.into()),
336 }
337 }
338
339 pub fn preview(
340 &self,
341 change: ServiceInstallationChange,
342 created_at: DateTime<Utc>,
343 ) -> Result<ServiceInstallationPlan, ServiceInstallationError> {
344 let current = self.snapshot()?;
345 plan_service_installation(¤t, change, created_at)
346 }
347
348 pub fn apply(
349 &self,
350 operation_id: &str,
351 plan: &ServiceInstallationPlan,
352 actor_id: &str,
353 authorities: &BTreeSet<String>,
354 now: DateTime<Utc>,
355 ) -> Result<ServiceInstallationReceipt, ServiceInstallationError> {
356 if !safe_id(operation_id) {
357 return Err(ServiceInstallationError::UnsafeOperationIdentity);
358 }
359 if !authorities.contains(&plan.required_authority) {
360 return Err(ServiceInstallationError::MissingAuthority(
361 plan.required_authority.clone(),
362 ));
363 }
364 validate_service_installation_plan(plan)?;
365 let receipt_path = self.receipt_path(operation_id)?;
366 if let Ok(bytes) = fs::read(&receipt_path) {
367 let receipt: ServiceInstallationReceipt = serde_json::from_slice(&bytes)?;
368 if receipt.protocol != SERVICE_INSTALLATION_RECEIPT_PROTOCOL
369 || receipt.operation_id != operation_id
370 || receipt.plan_id != plan.plan_id
371 || receipt.plan_digest != plan.plan_digest
372 || receipt.target_state_digest != plan.target_state_digest
373 {
374 return Err(ServiceInstallationError::StaleState);
375 }
376 return Ok(receipt);
377 }
378
379 let lock_path = self.environment_root()?.join("service-installations.lock");
380 fs::create_dir_all(self.environment_root()?)?;
381 let lock = OpenOptions::new()
382 .create(true)
383 .read(true)
384 .write(true)
385 .open(lock_path)?;
386 lock.lock_exclusive()?;
387 let result = self.apply_locked(operation_id, plan, actor_id, authorities, now);
388 let unlock_result = lock.unlock();
389 result.and_then(|receipt| {
390 unlock_result?;
391 Ok(receipt)
392 })
393 }
394
395 fn apply_locked(
396 &self,
397 operation_id: &str,
398 plan: &ServiceInstallationPlan,
399 actor_id: &str,
400 authorities: &BTreeSet<String>,
401 now: DateTime<Utc>,
402 ) -> Result<ServiceInstallationReceipt, ServiceInstallationError> {
403 let current = self.snapshot()?;
404 let current_digest = service_installation_set_digest(¤t)?;
405 if current.revision == plan.target_revision && current_digest == plan.target_state_digest {
406 let receipt =
407 service_installation_receipt(operation_id, plan, actor_id, authorities, now)?;
408 atomic_write_json(&self.receipt_path(operation_id)?, &receipt)?;
409 return Ok(receipt);
410 }
411 if current.revision != plan.expected_revision
412 || current_digest != plan.expected_state_digest
413 {
414 return Err(ServiceInstallationError::StaleState);
415 }
416 let verified = plan_service_installation(¤t, plan.change.clone(), plan.created_at)?;
417 if &verified != plan {
418 return Err(ServiceInstallationError::StaleState);
419 }
420 if plan.kind != ServiceInstallationPlanKind::Reuse {
421 atomic_write_json(&self.state_path()?, &plan.target)?;
422 }
423 let receipt = service_installation_receipt(operation_id, plan, actor_id, authorities, now)?;
424 atomic_write_json(&self.receipt_path(operation_id)?, &receipt)?;
425 Ok(receipt)
426 }
427
428 fn environment_root(&self) -> Result<PathBuf, ServiceInstallationError> {
429 if !safe_segment(&self.environment_id) {
430 return invalid("environment identity is unsafe");
431 }
432 Ok(self
433 .root
434 .join(".lenso/environments")
435 .join(&self.environment_id))
436 }
437
438 fn state_path(&self) -> Result<PathBuf, ServiceInstallationError> {
439 Ok(self.environment_root()?.join("service-installations.json"))
440 }
441
442 fn receipt_path(&self, operation_id: &str) -> Result<PathBuf, ServiceInstallationError> {
443 if !safe_id(operation_id) {
444 return Err(ServiceInstallationError::UnsafeOperationIdentity);
445 }
446 Ok(self
447 .environment_root()?
448 .join("service-install-receipts")
449 .join(format!("{operation_id}.json")))
450 }
451}
452
453pub fn plan_service_installation(
454 current: &ServiceInstallationSet,
455 change: ServiceInstallationChange,
456 created_at: DateTime<Utc>,
457) -> Result<ServiceInstallationPlan, ServiceInstallationError> {
458 validate_service_installation_set(current)?;
459 let expected_state_digest = service_installation_set_digest(current)?;
460 let mut target = current.clone();
461 let kind = match &change {
462 ServiceInstallationChange::Install { installation } => {
463 validate_service_installation(installation)?;
464 if installation.service_ref.system_id != current.system_id {
465 return invalid("Service Reference system differs from Installation Set");
466 }
467 match target
468 .services
469 .binary_search_by(|candidate| candidate.service_ref.cmp(&installation.service_ref))
470 {
471 Ok(index) if target.services[index] == *installation => {
472 ServiceInstallationPlanKind::Reuse
473 }
474 Ok(index) => {
475 if target.services[index].profile != installation.profile {
476 return invalid(
477 "Service responsibility profile replacement requires a separate plan",
478 );
479 }
480 target.services[index] = installation.clone();
481 ServiceInstallationPlanKind::Update
482 }
483 Err(index) => {
484 target.services.insert(index, installation.clone());
485 ServiceInstallationPlanKind::Install
486 }
487 }
488 }
489 ServiceInstallationChange::Uninstall { service_ref } => {
490 if service_ref.system_id != current.system_id {
491 return invalid("Service Reference system differs from Installation Set");
492 }
493 let index = target
494 .services
495 .binary_search_by(|candidate| candidate.service_ref.cmp(service_ref))
496 .map_err(|_| {
497 ServiceInstallationError::InvalidContract("Service is not installed".to_owned())
498 })?;
499 target.services.remove(index);
500 ServiceInstallationPlanKind::Uninstall
501 }
502 };
503 if kind != ServiceInstallationPlanKind::Reuse {
504 target.previous_state_digest = Some(expected_state_digest.clone());
505 target.revision = current.revision.saturating_add(1);
506 }
507 let target_state_digest = service_installation_set_digest(&target)?;
508 let identity = digest_json(&(
509 current.system_id.as_str(),
510 current.environment_id.as_str(),
511 &change,
512 current.revision,
513 expected_state_digest.as_str(),
514 target_state_digest.as_str(),
515 ))?;
516 let mut plan = ServiceInstallationPlan {
517 protocol: SERVICE_INSTALLATION_PLAN_PROTOCOL.to_owned(),
518 plan_id: format!("service-install-plan:{}", &identity[7..23]),
519 plan_digest: String::new(),
520 system_id: current.system_id.clone(),
521 environment_id: current.environment_id.clone(),
522 kind,
523 change,
524 expected_revision: current.revision,
525 expected_state_digest,
526 target_revision: target.revision,
527 target_state_digest,
528 target,
529 required_authority: "service.manage".to_owned(),
530 next_actions: vec![
531 "review_service_installation_plan".to_owned(),
532 "apply_service_installation_plan".to_owned(),
533 ],
534 created_at,
535 };
536 plan.plan_digest = service_installation_plan_digest(&plan)?;
537 validate_service_installation_plan(&plan)?;
538 Ok(plan)
539}
540
541pub fn service_installation_set_digest(
542 state: &ServiceInstallationSet,
543) -> Result<String, serde_json::Error> {
544 digest_json(state)
545}
546
547pub fn service_installation_plan_digest(
548 plan: &ServiceInstallationPlan,
549) -> Result<String, serde_json::Error> {
550 let mut content = plan.clone();
551 content.plan_digest.clear();
552 digest_json(&content)
553}
554
555pub fn validate_service_installation_plan(
556 plan: &ServiceInstallationPlan,
557) -> Result<(), ServiceInstallationError> {
558 if plan.protocol != SERVICE_INSTALLATION_PLAN_PROTOCOL
559 || service_installation_plan_digest(plan)? != plan.plan_digest
560 || plan.target_state_digest != service_installation_set_digest(&plan.target)?
561 || plan.target.system_id != plan.system_id
562 || plan.target.environment_id != plan.environment_id
563 || plan.required_authority != "service.manage"
564 {
565 return invalid("Service Installation Plan identity or digest is invalid");
566 }
567 validate_service_installation_set(&plan.target)
568}
569
570pub fn validate_service_installation_set(
571 state: &ServiceInstallationSet,
572) -> Result<(), ServiceInstallationError> {
573 if state.protocol != SERVICE_INSTALLATION_SET_PROTOCOL
574 || !safe_identity(&state.system_id)
575 || !safe_segment(&state.environment_id)
576 || state
577 .previous_state_digest
578 .as_deref()
579 .is_some_and(|digest| !valid_digest(digest))
580 {
581 return invalid("Service Installation Set identity, protocol, or digest is invalid");
582 }
583 require_sorted_unique(
584 state.services.iter().map(|service| &service.service_ref),
585 "Service References",
586 )?;
587 for service in &state.services {
588 validate_service_installation(service)?;
589 if service.service_ref.system_id != state.system_id {
590 return invalid("installed Service belongs to another System");
591 }
592 }
593 Ok(())
594}
595
596pub fn validate_service_installation(
597 installation: &ServiceInstallation,
598) -> Result<(), ServiceInstallationError> {
599 if !safe_identity(&installation.service_ref.system_id)
600 || !safe_identity(&installation.service_ref.service_id)
601 || semver::Version::parse(&installation.service_release.version).is_err()
602 || !valid_digest(&installation.service_release.digest)
603 || installation
604 .service_release
605 .immutable_locator
606 .trim()
607 .is_empty()
608 || installation.endpoint_binding.service_ref != installation.service_ref
609 || installation.exports.is_empty()
610 || installation.endpoint_binding.binding_id.trim().is_empty()
611 || installation.endpoint_binding.allowed_bindings.is_empty()
612 || installation
613 .endpoint_binding
614 .cache_policy
615 .maximum_age_seconds
616 == 0
617 || installation
618 .endpoint_binding
619 .identity_policy
620 .principal
621 .trim()
622 .is_empty()
623 || installation
624 .endpoint_binding
625 .identity_policy
626 .audience
627 .trim()
628 .is_empty()
629 || installation
630 .endpoint_binding
631 .identity_policy
632 .trust_profile
633 .trim()
634 .is_empty()
635 {
636 return invalid(
637 "installed Service identity, release, endpoint, or identity policy is invalid",
638 );
639 }
640 require_sorted_unique(
641 installation
642 .exports
643 .iter()
644 .map(|export| export.export_key.as_str()),
645 "Service export keys",
646 )?;
647 require_sorted_unique(
648 installation.endpoint_binding.allowed_bindings.iter(),
649 "allowed endpoint bindings",
650 )?;
651 for export in &installation.exports {
652 if export.export_key.trim().is_empty()
653 || !valid_module_id(&export.module_id)
654 || semver::Version::parse(&export.module_version).is_err()
655 || !valid_digest(&export.module_release_digest)
656 || !valid_digest(&export.manifest_digest)
657 || export
658 .contract_digests
659 .iter()
660 .any(|digest| !valid_digest(digest))
661 {
662 return invalid("installed Service export is invalid");
663 }
664 require_sorted_unique(
665 export.contract_digests.iter(),
666 "Service export contract digests",
667 )?;
668 }
669 for binding in &installation.config_bindings {
670 if binding.owner_id.trim().is_empty()
671 || !valid_digest(&binding.config_contract_digest)
672 || binding.config_revision_id.trim().is_empty()
673 || !valid_digest(&binding.config_revision_digest)
674 {
675 return invalid("Service Config binding is invalid");
676 }
677 require_sorted_unique(binding.secret_references.iter(), "Secret References")?;
678 }
679 match &installation.endpoint_binding.resolver_source {
680 EndpointResolverSource::Static { endpoints } => {
681 if endpoints.is_empty()
682 || endpoints.iter().any(|endpoint| {
683 endpoint.address.trim().is_empty()
684 || endpoint.weight == 0
685 || !installation
686 .endpoint_binding
687 .allowed_bindings
688 .contains(&endpoint.binding)
689 })
690 {
691 return invalid("static Endpoint source is invalid");
692 }
693 }
694 EndpointResolverSource::LocalProcess { source_id } => {
695 if source_id.trim().is_empty() {
696 return invalid("local process Endpoint source is invalid");
697 }
698 }
699 EndpointResolverSource::Adapter {
700 adapter_id,
701 public_config,
702 secret_references,
703 } => {
704 if adapter_id.trim().is_empty()
705 || public_config.iter().any(|(key, value)| {
706 key.trim().is_empty()
707 || value.trim().is_empty()
708 || secret_shaped(key)
709 || secret_shaped(value)
710 })
711 {
712 return invalid("external Endpoint adapter configuration is invalid");
713 }
714 require_sorted_unique(secret_references.iter(), "Endpoint Secret References")?;
715 }
716 }
717 Ok(())
718}
719
720fn service_installation_receipt(
721 operation_id: &str,
722 plan: &ServiceInstallationPlan,
723 actor_id: &str,
724 authorities: &BTreeSet<String>,
725 now: DateTime<Utc>,
726) -> Result<ServiceInstallationReceipt, ServiceInstallationError> {
727 let service_ref = match &plan.change {
728 ServiceInstallationChange::Install { installation } => installation.service_ref.clone(),
729 ServiceInstallationChange::Uninstall { service_ref } => service_ref.clone(),
730 };
731 let (outcome, reasons, next_actions) = match plan.kind {
732 ServiceInstallationPlanKind::Reuse => (
733 ServiceInstallationOutcome::Reused,
734 vec!["desired_service_installation_already_matches".to_owned()],
735 Vec::new(),
736 ),
737 ServiceInstallationPlanKind::Uninstall => (
738 ServiceInstallationOutcome::Removed,
739 vec!["desired_service_installation_removed".to_owned()],
740 vec!["preserve_deployment_data_and_observations".to_owned()],
741 ),
742 ServiceInstallationPlanKind::Install | ServiceInstallationPlanKind::Update => (
743 ServiceInstallationOutcome::AppliedNeedsAttention,
744 vec!["runtime_readiness_requires_fresh_observation".to_owned()],
745 vec!["observe_service_identity_and_readiness".to_owned()],
746 ),
747 };
748 let mut verified_authorities = authorities.iter().cloned().collect::<Vec<_>>();
749 verified_authorities.sort();
750 let receipt_seed = digest_json(&(operation_id, plan.plan_digest.as_str(), actor_id, now))?;
751 Ok(ServiceInstallationReceipt {
752 protocol: SERVICE_INSTALLATION_RECEIPT_PROTOCOL.to_owned(),
753 receipt_id: format!("service-install-receipt:{}", &receipt_seed[7..23]),
754 operation_id: operation_id.to_owned(),
755 plan_id: plan.plan_id.clone(),
756 plan_digest: plan.plan_digest.clone(),
757 actor_id: actor_id.to_owned(),
758 verified_authorities,
759 system_id: plan.system_id.clone(),
760 environment_id: plan.environment_id.clone(),
761 service_ref,
762 prior_revision: plan.expected_revision,
763 target_revision: plan.target_revision,
764 prior_state_digest: plan.expected_state_digest.clone(),
765 target_state_digest: plan.target_state_digest.clone(),
766 outcome,
767 reasons,
768 next_actions,
769 committed_at: now,
770 })
771}
772
773fn atomic_write_json<T: Serialize>(path: &Path, value: &T) -> Result<(), ServiceInstallationError> {
774 let parent = path.parent().ok_or_else(|| {
775 ServiceInstallationError::InvalidContract("state path has no parent".to_owned())
776 })?;
777 fs::create_dir_all(parent)?;
778 let temporary = path.with_extension(format!("json.tmp-{}", std::process::id()));
779 let bytes = serde_json::to_vec_pretty(value)?;
780 let mut file = OpenOptions::new()
781 .create(true)
782 .truncate(true)
783 .write(true)
784 .open(&temporary)?;
785 file.write_all(&bytes)?;
786 file.sync_all()?;
787 fs::rename(&temporary, path)?;
788 OpenOptions::new().read(true).open(parent)?.sync_all()?;
789 Ok(())
790}
791
792fn require_sorted_unique<'a, T: Ord + ?Sized + 'a>(
793 values: impl IntoIterator<Item = &'a T>,
794 field: &str,
795) -> Result<(), ServiceInstallationError> {
796 let values = values.into_iter().collect::<Vec<_>>();
797 if values.windows(2).any(|pair| pair[0] >= pair[1]) {
798 return invalid(format!("{field} must be sorted and unique"));
799 }
800 Ok(())
801}
802
803fn valid_digest(value: &str) -> bool {
804 value.strip_prefix("sha256:").is_some_and(|hex| {
805 hex.len() == 64
806 && hex
807 .bytes()
808 .all(|byte| byte.is_ascii_digit() || (b'a'..=b'f').contains(&byte))
809 })
810}
811
812fn safe_identity(value: &str) -> bool {
813 !value.is_empty()
814 && value.len() <= 255
815 && value.chars().all(|character| {
816 character.is_ascii_alphanumeric() || matches!(character, '-' | '_' | '.' | '/')
817 })
818}
819
820fn safe_segment(value: &str) -> bool {
821 safe_identity(value) && !value.contains('/') && value != "." && value != ".."
822}
823
824fn safe_id(value: &str) -> bool {
825 safe_segment(value) && value.len() <= 180
826}
827
828fn valid_module_id(value: &str) -> bool {
829 value.split_once('/').is_some_and(|(namespace, name)| {
830 !namespace.is_empty()
831 && !name.is_empty()
832 && !name.contains('/')
833 && safe_identity(namespace)
834 && safe_identity(name)
835 })
836}
837
838fn secret_shaped(value: &str) -> bool {
839 let normalized = value.to_ascii_lowercase();
840 ["secret", "password", "credential", "privatekey", "token"]
841 .iter()
842 .any(|needle| normalized.contains(needle))
843}
844
845fn invalid<T>(message: impl Into<String>) -> Result<T, ServiceInstallationError> {
846 Err(ServiceInstallationError::InvalidContract(message.into()))
847}