1use crate::error::{Result, WorkerError};
5use serde::{Deserialize, Serialize};
6use sha2::{Digest, Sha256};
7use std::collections::HashMap;
8use std::path::{Path, PathBuf};
9use std::process::Command;
10
11#[derive(Debug, Clone, Serialize, Deserialize, Default)]
13#[serde(rename_all = "snake_case")]
14pub enum EnvType {
15 #[default]
17 Venv,
18 Conda,
21}
22
23#[derive(Debug, Clone, Serialize, Deserialize, Default)]
25pub struct EnvLockfile {
26 pub packages: HashMap<String, String>,
28 pub requirements_hash: String,
31 pub env_type: EnvType,
33 pub python_version: String,
35}
36
37pub struct EnvManager {
39 base_dir: PathBuf,
40 env_type: EnvType,
41}
42
43impl EnvManager {
44 pub fn new(base_dir: impl Into<PathBuf>, env_type: EnvType) -> Self {
48 let base = base_dir.into();
49 std::fs::create_dir_all(&base).ok();
50 Self {
51 base_dir: base,
52 env_type,
53 }
54 }
55
56 pub fn ensure_env(&self, pipeline_id: &str, requirements: &str) -> Result<PathBuf> {
59 let req_hash = Self::hash_requirements(requirements);
60 let env_dir = self.base_dir.join(format!("env-{pipeline_id}"));
61 let lockfile_path = env_dir.join("lockfile.json");
62
63 if env_dir.exists()
65 && let Ok(lockfile) = self.read_lockfile(&lockfile_path)
66 {
67 if lockfile.requirements_hash == req_hash {
68 tracing::info!("Reusing env for pipeline {pipeline_id} (hash match)");
70 return self.python_path(&env_dir);
71 }
72
73 tracing::info!("Updating env for pipeline {pipeline_id} (requirements changed)");
75 self.incremental_update(&env_dir, requirements, &lockfile)?;
76 self.write_lockfile(&lockfile_path, requirements, &req_hash)?;
77 return self.python_path(&env_dir);
78 }
79
80 tracing::info!("Creating new env for pipeline {pipeline_id}");
82 self.create_env(&env_dir)?;
83 self.install_requirements(&env_dir, requirements)?;
84 self.write_lockfile(&lockfile_path, requirements, &req_hash)?;
85
86 self.python_path(&env_dir)
87 }
88
89 pub fn cleanup(&self, max_age: std::time::Duration) -> usize {
91 let mut removed = 0;
92 if let Ok(entries) = std::fs::read_dir(&self.base_dir) {
93 for entry in entries.flatten() {
94 if let Ok(meta) = entry.metadata()
95 && let Ok(modified) = meta.modified()
96 && modified.elapsed().unwrap_or_default() > max_age
97 {
98 let _ = std::fs::remove_dir_all(entry.path());
99 removed += 1;
100 }
101 }
102 }
103 removed
104 }
105
106 fn link_local_package(env_dir: &Path, package_dir: &str) -> Result<()> {
118 let lib = env_dir.join("lib");
119 let site = std::fs::read_dir(&lib)
120 .map_err(|e| WorkerError::Env(format!("reading {}: {e}", lib.display())))?
121 .filter_map(|e| e.ok())
122 .map(|e| e.path().join("site-packages"))
123 .find(|p| p.is_dir())
124 .ok_or_else(|| {
125 WorkerError::Env(format!(
126 "no site-packages under {} to link the local soma package into",
127 lib.display()
128 ))
129 })?;
130 std::fs::write(site.join("_soma_local.pth"), format!("{package_dir}\n"))
131 .map_err(|e| WorkerError::Env(format!("writing _soma_local.pth: {e}")))?;
132 tracing::info!(path = %package_dir, "worker venv uses the local soma package");
133 Ok(())
134 }
135
136 fn create_env(&self, env_dir: &Path) -> Result<()> {
137 match self.env_type {
138 EnvType::Venv => {
139 let output = Command::new("python3")
140 .args(["-m", "venv", &env_dir.to_string_lossy()])
141 .output()
142 .map_err(|e| WorkerError::Env(format!("Failed to create venv: {e}")))?;
143 if !output.status.success() {
144 return Err(WorkerError::Env(format!(
145 "venv creation failed: {}",
146 String::from_utf8_lossy(&output.stderr)
147 )));
148 }
149 }
150 EnvType::Conda => {
151 let output = Command::new("conda")
152 .args([
153 "create",
154 "-p",
155 &env_dir.to_string_lossy(),
156 "python=3.11",
157 "-y",
158 "-q",
159 ])
160 .output()
161 .map_err(|e| WorkerError::Env(format!("Failed to create conda env: {e}")))?;
162 if !output.status.success() {
163 return Err(WorkerError::Env(format!(
164 "conda create failed: {}",
165 String::from_utf8_lossy(&output.stderr)
166 )));
167 }
168 }
169 }
170 Ok(())
171 }
172
173 fn install_requirements(&self, env_dir: &Path, requirements: &str) -> Result<()> {
174 let pip = self.pip_path(env_dir);
175
176 let req_file = env_dir.join("requirements.txt");
178 std::fs::write(&req_file, requirements)
179 .map_err(|e| WorkerError::Env(format!("Failed to write requirements.txt: {e}")))?;
180
181 let version = env!("CARGO_PKG_VERSION");
210 let local_package = std::env::var("SOMA_LOCAL_PACKAGE").ok().filter(|p| {
211 let ok = std::path::Path::new(p).join("soma").is_dir();
212 if !ok {
213 tracing::warn!(
214 path = %p,
215 "SOMA_LOCAL_PACKAGE does not contain a `soma` directory; \
216 falling back to installing somatize from PyPI"
217 );
218 }
219 ok
220 });
221 let mut bootstrap = if let Some(dir) = &local_package {
222 let out = Command::new(&pip)
223 .args(["install", "-q", "cloudpickle"])
224 .output()
225 .map_err(|e| WorkerError::Env(format!("pip install cloudpickle failed: {e}")))?;
226 if out.status.success() {
227 Self::link_local_package(env_dir, dir)?;
228 }
229 out
230 } else {
231 Command::new(&pip)
232 .args([
233 "install",
234 "-q",
235 "cloudpickle",
236 &format!("somatize=={version}"),
237 ])
238 .output()
239 .map_err(|e| {
240 WorkerError::Env(format!("pip install (bootstrap deps) failed: {e}"))
241 })?
242 };
243 if !bootstrap.status.success() && local_package.is_none() {
244 tracing::warn!(
245 version,
246 "PyPI has no somatize {version}; installing the latest instead. A \
247 filter pickled by this build and unpickled against a different \
248 somatize can fail deep inside its own fit, naming no version. \
249 Set SOMA_LOCAL_PACKAGE to this build's Python package \
250 directory to run the real thing instead",
251 );
252 bootstrap = Command::new(&pip)
253 .args(["install", "-q", "cloudpickle", "somatize"])
254 .output()
255 .map_err(|e| {
256 WorkerError::Env(format!("pip install (bootstrap deps) failed: {e}"))
257 })?;
258 }
259 if !bootstrap.status.success() {
260 return Err(WorkerError::Env(format!(
261 "installing the bootstrap dependencies (cloudpickle, somatize) failed in {}:\n{}",
262 env_dir.display(),
263 String::from_utf8_lossy(&bootstrap.stderr)
264 )));
265 }
266
267 let output = Command::new(&pip)
268 .args(["install", "-r", &req_file.to_string_lossy(), "-q"])
269 .output()
270 .map_err(|e| WorkerError::Env(format!("pip install failed: {e}")))?;
271
272 if !output.status.success() {
273 return Err(WorkerError::Env(format!(
274 "pip install failed:\n{}",
275 String::from_utf8_lossy(&output.stderr)
276 )));
277 }
278
279 Ok(())
280 }
281
282 fn incremental_update(
283 &self,
284 env_dir: &Path,
285 new_requirements: &str,
286 old_lockfile: &EnvLockfile,
287 ) -> Result<()> {
288 let new_packages = Self::parse_requirements(new_requirements);
289 let pip = self.pip_path(env_dir);
290
291 let mut to_install = Vec::new();
293 for (name, version) in &new_packages {
294 match old_lockfile.packages.get(name) {
295 None => {
296 tracing::info!(" + {name}=={version}");
298 to_install.push(format!("{name}=={version}"));
299 }
300 Some(old_ver) if old_ver != version => {
301 tracing::info!(" ↑ {name}: {old_ver} → {version}");
303 to_install.push(format!("{name}=={version}"));
304 }
305 _ => {} }
307 }
308
309 for name in old_lockfile.packages.keys() {
311 if !new_packages.contains_key(name) {
312 tracing::info!(" - {name}");
313 let _ = Command::new(&pip)
314 .args(["uninstall", name, "-y", "-q"])
315 .output();
316 }
317 }
318
319 if !to_install.is_empty() {
321 let output = Command::new(&pip)
322 .args(["install"])
323 .args(&to_install)
324 .arg("-q")
325 .output()
326 .map_err(|e| WorkerError::Env(format!("pip install failed: {e}")))?;
327
328 if !output.status.success() {
329 return Err(WorkerError::Env(format!(
330 "pip install failed:\n{}",
331 String::from_utf8_lossy(&output.stderr)
332 )));
333 }
334 }
335
336 Ok(())
337 }
338
339 fn python_path(&self, env_dir: &Path) -> Result<PathBuf> {
340 let path = env_dir.join("bin").join("python");
341 if path.exists() {
342 Ok(path)
343 } else {
344 Err(WorkerError::Env(format!(
345 "Python not found at {}",
346 path.display()
347 )))
348 }
349 }
350
351 fn pip_path(&self, env_dir: &Path) -> PathBuf {
352 env_dir.join("bin").join("pip")
353 }
354
355 pub fn env_id_for(requirements: &str) -> String {
363 format!("reqs-{}", &Self::hash_requirements(requirements)[..16])
364 }
365
366 fn hash_requirements(requirements: &str) -> String {
367 let mut hasher = Sha256::new();
368 let mut lines: Vec<&str> = requirements
370 .lines()
371 .map(|l| l.trim())
372 .filter(|l| !l.is_empty() && !l.starts_with('#'))
373 .collect();
374 lines.sort();
375 for line in &lines {
376 hasher.update(line.as_bytes());
377 hasher.update(b"\n");
378 }
379 hex::encode(hasher.finalize())
380 }
381
382 fn parse_requirements(requirements: &str) -> HashMap<String, String> {
383 let mut packages = HashMap::new();
384 for line in requirements.lines() {
385 let line = line.trim();
386 if line.is_empty() || line.starts_with('#') {
387 continue;
388 }
389 let (name, version) = if let Some((n, v)) = line.split_once("==") {
391 (n.trim().to_lowercase(), v.trim().to_string())
392 } else if let Some((n, v)) = line.split_once(">=") {
393 (n.trim().to_lowercase(), format!(">={v}"))
394 } else if let Some((n, v)) = line.split_once("<=") {
395 (n.trim().to_lowercase(), format!("<={v}"))
396 } else {
397 (line.to_lowercase(), "latest".to_string())
398 };
399 packages.insert(name, version);
400 }
401 packages
402 }
403
404 fn read_lockfile(&self, path: &Path) -> Result<EnvLockfile> {
405 let content = std::fs::read_to_string(path).map_err(|e| WorkerError::Env(e.to_string()))?;
406 serde_json::from_str(&content).map_err(|e| WorkerError::Encoding(e.to_string()))
407 }
408
409 fn write_lockfile(&self, path: &Path, requirements: &str, hash: &str) -> Result<()> {
410 let lockfile = EnvLockfile {
411 packages: Self::parse_requirements(requirements),
412 requirements_hash: hash.to_string(),
413 env_type: self.env_type.clone(),
414 python_version: "3.11".to_string(),
415 };
416 let json =
417 serde_json::to_string_pretty(&lockfile).map_err(|e| WorkerError::Env(e.to_string()))?;
418 std::fs::write(path, json)
419 .map_err(|e| WorkerError::Env(format!("Failed to write lockfile: {e}")))
420 }
421}
422
423#[cfg(test)]
424mod tests {
425 use super::*;
426
427 #[test]
428 fn hash_requirements_stable() {
429 let r1 = "numpy==1.26\nscikit-learn==1.4\n";
430 let r2 = "scikit-learn==1.4\nnumpy==1.26\n"; assert_eq!(
432 EnvManager::hash_requirements(r1),
433 EnvManager::hash_requirements(r2)
434 );
435 }
436
437 #[test]
443 fn the_env_id_follows_the_requirements_not_the_caller() {
444 let a = EnvManager::env_id_for("numpy==1.26\nscikit-learn==1.4\n");
445 let b = EnvManager::env_id_for("scikit-learn==1.4\n numpy==1.26\n");
446 assert_eq!(a, b, "the same dependency set must reuse one environment");
447
448 let other = EnvManager::env_id_for("numpy==1.26\n");
449 assert_ne!(a, other, "a different dependency set needs its own");
450 }
451
452 #[test]
453 fn hash_requirements_ignores_comments() {
454 let r1 = "numpy==1.26\n# comment\nscikit-learn==1.4\n";
455 let r2 = "numpy==1.26\nscikit-learn==1.4\n";
456 assert_eq!(
457 EnvManager::hash_requirements(r1),
458 EnvManager::hash_requirements(r2)
459 );
460 }
461
462 #[test]
463 fn hash_changes_on_version_change() {
464 let r1 = "numpy==1.26\n";
465 let r2 = "numpy==1.27\n";
466 assert_ne!(
467 EnvManager::hash_requirements(r1),
468 EnvManager::hash_requirements(r2)
469 );
470 }
471
472 #[test]
473 fn parse_requirements_formats() {
474 let pkgs = EnvManager::parse_requirements("numpy==1.26\nsklearn>=1.4\npandas\n");
475 assert_eq!(pkgs["numpy"], "1.26");
476 assert_eq!(pkgs["sklearn"], ">=1.4");
477 assert_eq!(pkgs["pandas"], "latest");
478 }
479}