Skip to main content

lenso_module_management/
workspace.rs

1use crate::{
2    APPLICATION_MODULE_LOCK_PROTOCOL, ApplicationModuleLock, DESIRED_MODULE_COMPOSITION_PROTOCOL,
3    DesiredModuleComposition, MODULE_MANAGEMENT_SNAPSHOT_PROTOCOL,
4    MODULE_PLANNING_CONTEXT_PROTOCOL, ModuleChangePlan, ModuleChangePlanRequest,
5    ModuleChangePlanner, ModuleChangePlannerError, ModuleManagementSnapshot,
6    ModuleManagementSnapshotStatus, ModulePlanningContext, ModuleRootChange,
7    application_module_lock_digest, desired_composition_digest,
8};
9use chrono::{DateTime, Utc};
10use lenso_contracts::digest_json;
11use std::fs;
12use std::path::{Path, PathBuf};
13
14#[derive(Debug, Clone)]
15pub struct WorkspaceModuleManagement {
16    root: PathBuf,
17    desired_path: PathBuf,
18    lock_path: PathBuf,
19    planning_context_path: PathBuf,
20    environment_policy_path: PathBuf,
21}
22
23#[derive(Debug, thiserror::Error)]
24pub enum WorkspaceModuleManagementError {
25    #[error("Module management workspace I/O failed: {0}")]
26    Io(#[from] std::io::Error),
27    #[error("Module management workspace JSON failed: {0}")]
28    Json(#[from] serde_json::Error),
29    #[error("Module planning context is unavailable")]
30    PlanningUnavailable,
31    #[error("Application Module Lock is unavailable")]
32    ApplicationLockUnavailable,
33    #[error("Module management workspace contract is invalid: {0}")]
34    InvalidContract(String),
35    #[error(transparent)]
36    Planning(#[from] ModuleChangePlannerError),
37    #[error(transparent)]
38    ProviderRuntime(#[from] crate::ProviderRuntimePlanError),
39    #[error(transparent)]
40    ServiceInstallation(#[from] crate::ServiceInstallationError),
41}
42
43impl WorkspaceModuleManagement {
44    pub fn new(root: impl Into<PathBuf>) -> Self {
45        Self {
46            root: root.into(),
47            desired_path: PathBuf::from("lenso.modules.json"),
48            lock_path: PathBuf::from("lenso.modules.lock.json"),
49            planning_context_path: PathBuf::from(".lenso/module-planning-context.json"),
50            environment_policy_path: PathBuf::from(".lenso/module-environment-policy.json"),
51        }
52    }
53
54    #[must_use]
55    pub fn with_planning_context_path(mut self, path: impl Into<PathBuf>) -> Self {
56        self.planning_context_path = path.into();
57        self
58    }
59
60    pub fn snapshot(&self) -> Result<ModuleManagementSnapshot, WorkspaceModuleManagementError> {
61        let mut issues = Vec::new();
62        let desired = read_optional::<DesiredModuleComposition>(
63            &self.root.join(&self.desired_path),
64            "desired_composition_invalid",
65            &mut issues,
66        );
67        let application_lock = read_optional::<ApplicationModuleLock>(
68            &self.root.join(&self.lock_path),
69            "application_lock_invalid",
70            &mut issues,
71        );
72        let planning_context = read_optional::<ModulePlanningContext>(
73            &self.root.join(&self.planning_context_path),
74            "planning_context_invalid",
75            &mut issues,
76        );
77        let environment_policy = read_optional::<crate::ModuleEnvironmentPolicy>(
78            &self.root.join(&self.environment_policy_path),
79            "environment_policy_invalid",
80            &mut issues,
81        );
82        if desired
83            .as_ref()
84            .is_some_and(|value| value.protocol != DESIRED_MODULE_COMPOSITION_PROTOCOL)
85        {
86            issues.push("desired_composition_protocol_unsupported".to_owned());
87        }
88        if application_lock
89            .as_ref()
90            .is_some_and(|value| value.protocol != APPLICATION_MODULE_LOCK_PROTOCOL)
91        {
92            issues.push("application_lock_protocol_unsupported".to_owned());
93        }
94        if planning_context
95            .as_ref()
96            .is_some_and(|value| value.protocol != MODULE_PLANNING_CONTEXT_PROTOCOL)
97        {
98            issues.push("planning_context_protocol_unsupported".to_owned());
99        }
100        if environment_policy
101            .as_ref()
102            .is_some_and(|value| value.protocol != crate::MODULE_ENVIRONMENT_POLICY_PROTOCOL)
103        {
104            issues.push("environment_policy_protocol_unsupported".to_owned());
105        }
106        let identities = desired
107            .as_ref()
108            .map(|value| value.application_id.as_str())
109            .into_iter()
110            .chain(
111                application_lock
112                    .as_ref()
113                    .map(|value| value.application_id.as_str()),
114            )
115            .chain(
116                planning_context
117                    .as_ref()
118                    .map(|value| value.application_id.as_str()),
119            )
120            .collect::<std::collections::BTreeSet<_>>();
121        if identities.len() > 1 {
122            issues.push("application_identity_mismatch".to_owned());
123        }
124        issues.sort();
125        issues.dedup();
126        let application_id = identities.first().map(|value| (*value).to_owned());
127        let desired_digest = desired
128            .as_ref()
129            .map(desired_composition_digest)
130            .transpose()?;
131        let application_lock_digest = application_lock
132            .as_ref()
133            .map(application_module_lock_digest)
134            .transpose()?;
135        let planning_context_digest = planning_context.as_ref().map(digest_json).transpose()?;
136        let status = if !issues.is_empty() {
137            ModuleManagementSnapshotStatus::Invalid
138        } else if planning_context.is_none() || environment_policy.is_none() {
139            ModuleManagementSnapshotStatus::Unconfigured
140        } else {
141            ModuleManagementSnapshotStatus::Ready
142        };
143        Ok(ModuleManagementSnapshot {
144            protocol: MODULE_MANAGEMENT_SNAPSHOT_PROTOCOL.to_owned(),
145            status,
146            application_id,
147            desired,
148            desired_digest,
149            application_lock,
150            application_lock_digest,
151            planning_available: planning_context.is_some() && issues.is_empty(),
152            planning_context_digest,
153            execution_available: environment_policy
154                .as_ref()
155                .is_some_and(|policy| policy.mode == crate::EnvironmentManagementMode::Full)
156                && issues.is_empty(),
157            environment_policy,
158            issues,
159        })
160    }
161
162    pub fn preview(
163        &self,
164        change: ModuleRootChange,
165        created_at: DateTime<Utc>,
166    ) -> Result<ModuleChangePlan, WorkspaceModuleManagementError> {
167        let snapshot = self.snapshot()?;
168        if snapshot.status == ModuleManagementSnapshotStatus::Invalid {
169            return Err(WorkspaceModuleManagementError::InvalidContract(
170                snapshot.issues.join(","),
171            ));
172        }
173        let planning_context: ModulePlanningContext = read_required(
174            &self.root.join(&self.planning_context_path),
175        )
176        .map_err(|error| match error {
177            WorkspaceModuleManagementError::Io(ref io)
178                if io.kind() == std::io::ErrorKind::NotFound =>
179            {
180                WorkspaceModuleManagementError::PlanningUnavailable
181            }
182            other => other,
183        })?;
184        if planning_context.protocol != MODULE_PLANNING_CONTEXT_PROTOCOL {
185            return Err(WorkspaceModuleManagementError::InvalidContract(
186                "unsupported planning context protocol".to_owned(),
187            ));
188        }
189        let current_desired = read_optional_strict(&self.root.join(&self.desired_path))?
190            .unwrap_or_else(|| DesiredModuleComposition {
191                protocol: DESIRED_MODULE_COMPOSITION_PROTOCOL.to_owned(),
192                application_id: planning_context.application_id.clone(),
193                revision: 0,
194                selected: Vec::new(),
195                local_overrides: Vec::new(),
196            });
197        let current_lock = read_optional_strict(&self.root.join(&self.lock_path))?;
198        let current_service_installations = crate::WorkspaceServiceInstallationManager::new(
199            &self.root,
200            &planning_context.system_id,
201            &planning_context.environment_id,
202        )
203        .snapshot()
204        .map_err(|error| WorkspaceModuleManagementError::InvalidContract(error.to_string()))?;
205        if current_desired.application_id != planning_context.application_id
206            || current_lock
207                .as_ref()
208                .is_some_and(|lock: &ApplicationModuleLock| {
209                    lock.application_id != planning_context.application_id
210                })
211        {
212            return Err(WorkspaceModuleManagementError::InvalidContract(
213                "application identity differs from planning context".to_owned(),
214            ));
215        }
216        ModuleChangePlanner::new(&self.root)
217            .plan(&ModuleChangePlanRequest {
218                current_desired,
219                current_lock,
220                change,
221                catalog_snapshot_digest: planning_context.catalog_snapshot_digest,
222                trust_policy_digest: planning_context.trust_policy_digest,
223                compatibility_evidence_digest: planning_context.compatibility_evidence_digest,
224                resolver_version: planning_context.resolver_version,
225                environment_id: planning_context.environment_id,
226                expected_target_revision: planning_context.expected_target_revision,
227                candidates: planning_context.candidates,
228                current_service_installations,
229                service_deployments: planning_context.service_deployments,
230                cargo_offline: planning_context.cargo_offline,
231                created_at,
232            })
233            .map_err(Into::into)
234    }
235
236    /// Loads the exact reviewed workspace artifacts and compiles the sole
237    /// Provider transport input. No live Provider endpoint participates in
238    /// Module discovery or selection.
239    pub fn provider_runtime_plan(
240        &self,
241    ) -> Result<crate::ProviderRuntimePlan, WorkspaceModuleManagementError> {
242        let module_lock: ApplicationModuleLock = read_required(&self.root.join(&self.lock_path))
243            .map_err(|error| match error {
244                WorkspaceModuleManagementError::Io(ref io)
245                    if io.kind() == std::io::ErrorKind::NotFound =>
246                {
247                    WorkspaceModuleManagementError::ApplicationLockUnavailable
248                }
249                other => other,
250            })?;
251        let planning_context: ModulePlanningContext = read_required(
252            &self.root.join(&self.planning_context_path),
253        )
254        .map_err(|error| match error {
255            WorkspaceModuleManagementError::Io(ref io)
256                if io.kind() == std::io::ErrorKind::NotFound =>
257            {
258                WorkspaceModuleManagementError::PlanningUnavailable
259            }
260            other => other,
261        })?;
262        let installations = crate::WorkspaceServiceInstallationManager::new(
263            &self.root,
264            &planning_context.system_id,
265            &planning_context.environment_id,
266        )
267        .snapshot()?;
268
269        Ok(crate::compile_provider_runtime_plan(
270            &module_lock,
271            &planning_context,
272            &installations,
273        )?)
274    }
275}
276
277fn read_optional<T: serde::de::DeserializeOwned>(
278    path: &Path,
279    issue: &str,
280    issues: &mut Vec<String>,
281) -> Option<T> {
282    match fs::read(path) {
283        Ok(bytes) => {
284            if let Ok(value) = serde_json::from_slice(&bytes) {
285                Some(value)
286            } else {
287                issues.push(issue.to_owned());
288                None
289            }
290        }
291        Err(error) if error.kind() == std::io::ErrorKind::NotFound => None,
292        Err(_) => {
293            issues.push(format!("{issue}_read_failed"));
294            None
295        }
296    }
297}
298
299fn read_required<T: serde::de::DeserializeOwned>(
300    path: &Path,
301) -> Result<T, WorkspaceModuleManagementError> {
302    Ok(serde_json::from_slice(&fs::read(path)?)?)
303}
304
305fn read_optional_strict<T: serde::de::DeserializeOwned>(
306    path: &Path,
307) -> Result<Option<T>, WorkspaceModuleManagementError> {
308    match fs::read(path) {
309        Ok(bytes) => Ok(Some(serde_json::from_slice(&bytes)?)),
310        Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(None),
311        Err(error) => Err(error.into()),
312    }
313}