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 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}