1use crate::{
2 APPLICATION_MODULE_LOCK_PROTOCOL, ApplicationModuleLock, EndpointBinding,
3 EndpointResolverSource, InstalledServiceRelease, ModulePlanningContext,
4 SERVICE_INSTALLATION_SET_PROTOCOL, ServiceDesiredMode, ServiceInstallation,
5 ServiceInstallationSet, ServiceReference, ServiceTransportBinding,
6};
7use lenso_contracts::{ModuleDelivery, ModuleManifest, ServiceResponsibilityProfile, digest_json};
8use schemars::JsonSchema;
9use serde::{Deserialize, Serialize};
10use std::collections::BTreeMap;
11
12pub const PROVIDER_RUNTIME_PLAN_PROTOCOL: &str = "lenso.provider-runtime-plan.v1";
13
14#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
15#[serde(deny_unknown_fields)]
16pub struct ProviderRuntimePlan {
17 pub protocol: String,
18 pub system_id: String,
19 pub application_id: String,
20 pub environment_id: String,
21 pub application_lock_digest: String,
22 pub service_installation_revision: u64,
23 pub providers: Vec<ProviderRuntimeService>,
24}
25
26#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
27#[serde(deny_unknown_fields)]
28pub struct ProviderRuntimeService {
29 pub service_ref: ServiceReference,
30 pub service_release: InstalledServiceRelease,
31 pub endpoint_binding: EndpointBinding,
32 pub modules: Vec<ProviderRuntimeModule>,
33}
34
35#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
36#[serde(deny_unknown_fields)]
37pub struct ProviderRuntimeModule {
38 pub export_key: String,
39 pub module_id: String,
40 pub module_version: String,
41 pub module_release_digest: String,
42 pub manifest_digest: String,
43 pub contract_digests: Vec<String>,
44 pub manifest: ModuleManifest,
45}
46
47#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
48#[serde(rename_all = "snake_case")]
49pub enum ProviderRuntimePlanIssueCode {
50 UnsupportedProtocol,
51 IdentityMismatch,
52 InvalidApplicationLock,
53 ReleaseMissing,
54 ReleaseMismatch,
55 ManifestMismatch,
56 InstallationMissing,
57 InstallationInactive,
58 ProfileMismatch,
59 ServiceReleaseMismatch,
60 ExportMissing,
61 ExportMismatch,
62 ProviderTransportUnavailable,
63 ProviderEndpointUnavailable,
64}
65
66#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
67#[serde(deny_unknown_fields)]
68pub struct ProviderRuntimePlanIssue {
69 pub code: ProviderRuntimePlanIssueCode,
70 pub path: String,
71 pub message: String,
72 pub next_action: String,
73}
74
75#[derive(Debug, thiserror::Error)]
76#[error("Provider runtime plan could not be compiled: {issues:?}")]
77pub struct ProviderRuntimePlanError {
78 pub issues: Vec<ProviderRuntimePlanIssue>,
79}
80
81pub fn compile_provider_runtime_plan(
87 module_lock: &ApplicationModuleLock,
88 planning_context: &ModulePlanningContext,
89 installations: &ServiceInstallationSet,
90) -> Result<ProviderRuntimePlan, ProviderRuntimePlanError> {
91 let mut issues = validate_roots(module_lock, planning_context, installations);
92 let application_lock_digest = match digest_json(module_lock) {
93 Ok(digest) => digest,
94 Err(error) => {
95 issues.push(issue(
96 ProviderRuntimePlanIssueCode::InvalidApplicationLock,
97 "$.application_lock",
98 format!("Application Module Lock cannot be canonicalized: {error}"),
99 "regenerate the Application Module Lock from a reviewed plan",
100 ));
101 String::new()
102 }
103 };
104
105 let candidates = planning_context
106 .candidates
107 .iter()
108 .map(|candidate| (candidate.release_digest.as_str(), candidate))
109 .collect::<BTreeMap<_, _>>();
110 let installation_index = installations
111 .services
112 .iter()
113 .map(|installation| (installation.service_ref.clone(), installation))
114 .collect::<BTreeMap<_, _>>();
115 let mut providers = BTreeMap::<ServiceReference, ProviderRuntimeService>::new();
116
117 for (index, locked) in module_lock.modules.iter().enumerate() {
118 let ModuleDelivery::Service(delivery) = &locked.delivery else {
119 continue;
120 };
121 if delivery.responsibility_profile != ServiceResponsibilityProfile::Provider {
122 continue;
123 }
124 let path = format!("$.application_lock.modules[{index}]");
125 let Some(candidate) = candidates.get(locked.release_digest.as_str()) else {
126 issues.push(issue(
127 ProviderRuntimePlanIssueCode::ReleaseMissing,
128 &path,
129 format!(
130 "locked Provider Service {} has no exact immutable Module Release",
131 locked.module_id
132 ),
133 "restore the exact planning context used to create the Application Module Lock",
134 ));
135 continue;
136 };
137 let release = &candidate.release;
138 if release.module_id != locked.module_id
139 || release.version != locked.version
140 || release.delivery != locked.delivery
141 {
142 issues.push(issue(
143 ProviderRuntimePlanIssueCode::ReleaseMismatch,
144 &path,
145 format!(
146 "locked Provider Service {} does not match its immutable Module Release",
147 locked.module_id
148 ),
149 "re-resolve the Module graph from the verified Catalog Snapshot",
150 ));
151 continue;
152 }
153 if release.manifest_digest != locked.manifest_digest
154 || digest_json(&release.manifest).ok().as_deref() != Some(&locked.manifest_digest)
155 {
156 issues.push(issue(
157 ProviderRuntimePlanIssueCode::ManifestMismatch,
158 &format!("{path}.manifest_digest"),
159 format!(
160 "locked Manifest digest for {} does not match canonical Release bytes",
161 locked.module_id
162 ),
163 "restore the exact verified Module Release and regenerate the lock",
164 ));
165 continue;
166 }
167
168 let service_ref = ServiceReference {
169 system_id: planning_context.system_id.clone(),
170 service_id: delivery.service_id.clone(),
171 };
172 let Some(installation) = installation_index.get(&service_ref) else {
173 issues.push(issue(
174 ProviderRuntimePlanIssueCode::InstallationMissing,
175 &path,
176 format!("Provider Service {} is not installed", delivery.service_id),
177 "apply the exact Service Installation plan before activating this Module",
178 ));
179 continue;
180 };
181 validate_installation(installation, delivery, &path, &mut issues);
182 let Some(export) = installation
183 .exports
184 .iter()
185 .find(|export| export.export_key == delivery.export)
186 else {
187 issues.push(issue(
188 ProviderRuntimePlanIssueCode::ExportMissing,
189 &path,
190 format!(
191 "Provider Service {} does not install export {}",
192 delivery.service_id, delivery.export
193 ),
194 "install the exact Service Release that owns the locked export",
195 ));
196 continue;
197 };
198 if export.module_id != locked.module_id
199 || export.module_version != locked.version
200 || export.module_release_digest != locked.release_digest
201 || export.manifest_digest != locked.manifest_digest
202 || export.contract_digests != delivery.contract_digests
203 {
204 issues.push(issue(
205 ProviderRuntimePlanIssueCode::ExportMismatch,
206 &path,
207 format!(
208 "installed export {} does not match locked Module {}",
209 delivery.export, locked.module_id
210 ),
211 "re-plan the Service Installation and Module graph as one exact target",
212 ));
213 continue;
214 }
215
216 providers
217 .entry(service_ref.clone())
218 .or_insert_with(|| ProviderRuntimeService {
219 service_ref,
220 service_release: installation.service_release.clone(),
221 endpoint_binding: installation.endpoint_binding.clone(),
222 modules: Vec::new(),
223 })
224 .modules
225 .push(ProviderRuntimeModule {
226 export_key: delivery.export.clone(),
227 module_id: locked.module_id.clone(),
228 module_version: locked.version.clone(),
229 module_release_digest: locked.release_digest.clone(),
230 manifest_digest: locked.manifest_digest.clone(),
231 contract_digests: delivery.contract_digests.clone(),
232 manifest: release.manifest.clone(),
233 });
234 }
235
236 if !issues.is_empty() {
237 issues.sort_by(|left, right| {
238 left.path
239 .cmp(&right.path)
240 .then_with(|| format!("{:?}", left.code).cmp(&format!("{:?}", right.code)))
241 });
242 return Err(ProviderRuntimePlanError { issues });
243 }
244
245 let mut providers = providers.into_values().collect::<Vec<_>>();
246 for provider in &mut providers {
247 provider.modules.sort_by(|left, right| {
248 left.module_id
249 .cmp(&right.module_id)
250 .then_with(|| left.export_key.cmp(&right.export_key))
251 });
252 }
253 Ok(ProviderRuntimePlan {
254 protocol: PROVIDER_RUNTIME_PLAN_PROTOCOL.to_owned(),
255 system_id: planning_context.system_id.clone(),
256 application_id: module_lock.application_id.clone(),
257 environment_id: planning_context.environment_id.clone(),
258 application_lock_digest,
259 service_installation_revision: installations.revision,
260 providers,
261 })
262}
263
264fn validate_roots(
265 module_lock: &ApplicationModuleLock,
266 planning_context: &ModulePlanningContext,
267 installations: &ServiceInstallationSet,
268) -> Vec<ProviderRuntimePlanIssue> {
269 let mut issues = Vec::new();
270 if module_lock.protocol != APPLICATION_MODULE_LOCK_PROTOCOL
271 || planning_context.protocol != crate::MODULE_PLANNING_CONTEXT_PROTOCOL
272 || installations.protocol != SERVICE_INSTALLATION_SET_PROTOCOL
273 {
274 issues.push(issue(
275 ProviderRuntimePlanIssueCode::UnsupportedProtocol,
276 "$",
277 "Provider runtime inputs use an unsupported protocol",
278 "regenerate management artifacts with the current Lenso version",
279 ));
280 }
281 if module_lock.application_id != planning_context.application_id
282 || planning_context.system_id != installations.system_id
283 || planning_context.environment_id != installations.environment_id
284 {
285 issues.push(issue(
286 ProviderRuntimePlanIssueCode::IdentityMismatch,
287 "$",
288 "Provider runtime inputs belong to different application, system, or environment identities",
289 "load all runtime inputs from the same reviewed environment target",
290 ));
291 }
292 if let Err(error) = crate::validate_application_module_lock(module_lock) {
293 issues.push(issue(
294 ProviderRuntimePlanIssueCode::InvalidApplicationLock,
295 "$.application_lock",
296 error.to_string(),
297 "regenerate the Application Module Lock from a reviewed plan",
298 ));
299 }
300 issues
301}
302
303fn validate_installation(
304 installation: &ServiceInstallation,
305 delivery: &lenso_contracts::ServiceModuleDelivery,
306 path: &str,
307 issues: &mut Vec<ProviderRuntimePlanIssue>,
308) {
309 if installation.desired_mode != ServiceDesiredMode::Active {
310 issues.push(issue(
311 ProviderRuntimePlanIssueCode::InstallationInactive,
312 path,
313 format!(
314 "Provider Service {} is installed but inactive",
315 delivery.service_id
316 ),
317 "activate the Service Installation before activating its Modules",
318 ));
319 }
320 if installation.profile != ServiceResponsibilityProfile::Provider {
321 issues.push(issue(
322 ProviderRuntimePlanIssueCode::ProfileMismatch,
323 path,
324 format!(
325 "Service {} is not installed with the Provider profile",
326 delivery.service_id
327 ),
328 "replace the Service Installation with the exact Provider profile",
329 ));
330 }
331 if installation.service_release.version != delivery.service_release_version
332 || installation.service_release.digest != delivery.service_release_digest
333 {
334 issues.push(issue(
335 ProviderRuntimePlanIssueCode::ServiceReleaseMismatch,
336 path,
337 format!(
338 "installed Service Release for {} differs from the locked release",
339 delivery.service_id
340 ),
341 "apply the exact co-resolved Service Installation update",
342 ));
343 }
344 let provider_bindings = installation
345 .endpoint_binding
346 .allowed_bindings
347 .iter()
348 .copied()
349 .filter(is_provider_binding)
350 .collect::<Vec<_>>();
351 if provider_bindings.is_empty() {
352 issues.push(issue(
353 ProviderRuntimePlanIssueCode::ProviderTransportUnavailable,
354 path,
355 format!(
356 "Provider Service {} allows no Provider V1 transport",
357 delivery.service_id
358 ),
359 "configure Provider HTTP/JSON or Provider gRPC in the Endpoint Binding",
360 ));
361 } else if let EndpointResolverSource::Static { endpoints } =
362 &installation.endpoint_binding.resolver_source
363 && !endpoints.iter().any(|endpoint| {
364 provider_bindings.contains(&endpoint.binding) && !endpoint.address.trim().is_empty()
365 })
366 {
367 issues.push(issue(
368 ProviderRuntimePlanIssueCode::ProviderEndpointUnavailable,
369 path,
370 format!(
371 "Provider Service {} has no eligible static Provider endpoint",
372 delivery.service_id
373 ),
374 "configure an endpoint matching an allowed Provider V1 transport",
375 ));
376 }
377}
378
379fn is_provider_binding(binding: &ServiceTransportBinding) -> bool {
380 matches!(
381 binding,
382 ServiceTransportBinding::ProviderHttpJson | ServiceTransportBinding::ProviderGrpc
383 )
384}
385
386fn issue(
387 code: ProviderRuntimePlanIssueCode,
388 path: impl Into<String>,
389 message: impl Into<String>,
390 next_action: impl Into<String>,
391) -> ProviderRuntimePlanIssue {
392 ProviderRuntimePlanIssue {
393 code,
394 path: path.into(),
395 message: message.into(),
396 next_action: next_action.into(),
397 }
398}