Skip to main content

lenso_module_management/
provider_runtime.rs

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
81/// Compiles the only runtime input accepted by a Provider transport adapter.
82///
83/// Canonical Manifests come from the exact Module Releases retained in the
84/// planning context. Environment state contributes endpoints and identity
85/// policy only. No live Provider response can add or replace a Module.
86pub 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}