1use std::{
6 collections::{BTreeMap, BTreeSet, HashMap},
7 fs,
8 path::{Path, PathBuf},
9 sync::{
10 Arc, Mutex, OnceLock, Weak,
11 atomic::{AtomicU64, Ordering},
12 },
13 time::{Duration, SystemTime},
14};
15
16use async_trait::async_trait;
17use serde_json::Value;
18use sha2::{Digest, Sha256};
19use soma_provider_adapters::python::{
20 PythonInterpreter,
21 lifecycle::PythonEnvironmentLifecycle,
22 materializer::{PreparedPythonEnvironment, UvRunner},
23};
24use soma_provider_core::{ProviderCatalog, ProviderOutput};
25
26use super::{FileProviderLoadError, FileProviderSource};
27use crate::{
28 provider_errors::ProviderError,
29 provider_registry::{Provider, ProviderCall},
30};
31
32pub trait PythonProviderEnvironmentPreparer: Send + Sync {
35 fn prepare(&self, provider_path: &Path) -> Result<PythonInterpreter, String>;
37
38 fn validate_candidate(
40 &self,
41 provider_path: &Path,
42 candidate: &PreparedPythonEnvironment,
43 ) -> Result<PythonInterpreter, String>;
44}
45
46impl<R> PythonProviderEnvironmentPreparer for PythonEnvironmentLifecycle<R>
47where
48 R: UvRunner + 'static,
49{
50 fn prepare(&self, provider_path: &Path) -> Result<PythonInterpreter, String> {
51 self.prepare_provider(provider_path)
52 .map(|prepared| PythonInterpreter::prepared(&prepared))
53 .map_err(|error| error.to_string())
54 }
55
56 fn validate_candidate(
57 &self,
58 provider_path: &Path,
59 candidate: &PreparedPythonEnvironment,
60 ) -> Result<PythonInterpreter, String> {
61 self.validate_provider_candidate(provider_path, candidate)
62 .map(|prepared| PythonInterpreter::prepared(&prepared))
63 .map_err(|error| error.to_string())
64 }
65}
66
67pub type PythonProviderEnvironmentSelections = BTreeMap<PathBuf, PreparedPythonEnvironment>;
69
70const MAX_GENERATION_FILES: usize = 4_096;
71const MAX_GENERATION_BYTES: u64 = 64 * 1024 * 1024;
72const STALE_GENERATION_STORE_AGE: Duration = Duration::from_secs(24 * 60 * 60);
73struct GenerationLeaseEntry {
74 id: u64,
75 lease: Weak<PythonGenerationLease>,
76}
77
78static GENERATION_LEASES: OnceLock<Mutex<HashMap<PathBuf, GenerationLeaseEntry>>> = OnceLock::new();
79static GENERATION_STORE_INITIALIZED: OnceLock<()> = OnceLock::new();
80static GENERATION_LEASE_SEQUENCE: AtomicU64 = AtomicU64::new(1);
81
82pub(super) struct ImmutablePythonSource {
83 pub(super) path: PathBuf,
84 pub(super) lease: Arc<PythonGenerationLease>,
85}
86
87pub(super) struct ImmutablePythonGeneration {
88 root: PathBuf,
89 pub(super) digest: String,
90 lease: Arc<PythonGenerationLease>,
91}
92
93impl ImmutablePythonGeneration {
94 pub(super) fn source(
95 &self,
96 provider_root: &Path,
97 path: &Path,
98 ) -> Result<ImmutablePythonSource, FileProviderLoadError> {
99 let relative_source =
100 path.strip_prefix(provider_root)
101 .map_err(|_| FileProviderLoadError {
102 path: path.to_path_buf(),
103 message: "Python provider source is outside its managed root".to_owned(),
104 })?;
105 Ok(ImmutablePythonSource {
106 path: self.root.join(relative_source),
107 lease: self.lease.clone(),
108 })
109 }
110}
111
112pub(super) struct PythonGenerationLease {
113 generation: PathBuf,
114 id: u64,
115}
116
117impl Drop for PythonGenerationLease {
118 fn drop(&mut self) {
119 let cleanup = if let Some(leases) = GENERATION_LEASES.get() {
120 let mut leases = leases
121 .lock()
122 .expect("Python generation lease lock should not be poisoned");
123 let owns_entry = leases
124 .get(&self.generation)
125 .is_some_and(|entry| entry.id == self.id);
126 if !owns_entry {
127 return;
128 }
129 leases.remove(&self.generation);
130 claim_generation_for_cleanup(&self.generation, self.id)
131 } else {
132 claim_generation_for_cleanup(&self.generation, self.id)
133 };
134 let Some(cleanup) = cleanup else {
135 return;
136 };
137 let background_cleanup = cleanup.clone();
138 if let Err(error) = std::thread::Builder::new()
139 .name("soma-python-generation-cleanup".to_owned())
140 .spawn(move || remove_claimed_generation(&background_cleanup))
141 {
142 tracing::warn!(
143 path = %cleanup.display(),
144 %error,
145 "failed to start immutable Python generation cleanup thread"
146 );
147 remove_claimed_generation(&cleanup);
148 }
149 }
150}
151
152fn claim_generation_for_cleanup(generation: &Path, id: u64) -> Option<PathBuf> {
153 let name = generation.file_name()?.to_string_lossy();
154 let cleanup = generation.with_file_name(format!(".{name}.{id}.reclaiming"));
155 match fs::rename(generation, &cleanup) {
156 Ok(()) => Some(cleanup),
157 Err(error) if error.kind() == std::io::ErrorKind::NotFound => None,
158 Err(error) => {
159 tracing::warn!(
160 path = %generation.display(),
161 %error,
162 "failed to atomically claim immutable Python generation for cleanup"
163 );
164 None
165 }
166 }
167}
168
169fn remove_claimed_generation(generation: &Path) {
170 if let Err(error) = fs::remove_dir_all(generation)
171 && error.kind() != std::io::ErrorKind::NotFound
172 {
173 tracing::warn!(
174 path = %generation.display(),
175 %error,
176 "failed to reclaim immutable Python generation"
177 );
178 }
179}
180
181struct SnapshotRetainedProvider {
182 inner: Arc<dyn Provider>,
183 _lease: Arc<PythonGenerationLease>,
184}
185
186pub(super) fn retain_python_snapshot(
187 inner: Arc<dyn Provider>,
188 lease: Arc<PythonGenerationLease>,
189) -> Arc<dyn Provider> {
190 Arc::new(SnapshotRetainedProvider {
191 inner,
192 _lease: lease,
193 })
194}
195
196#[async_trait]
197impl Provider for SnapshotRetainedProvider {
198 fn catalog(&self) -> ProviderCatalog {
199 self.inner.catalog()
200 }
201
202 async fn call(&self, call: ProviderCall) -> Result<ProviderOutput, ProviderError> {
203 self.inner.call(call).await
204 }
205
206 async fn retire(&self) {
207 self.inner.retire().await;
208 }
209
210 async fn suspend(&self) {
211 self.inner.suspend().await;
212 }
213
214 fn runtime_status(&self) -> Option<Value> {
215 self.inner.runtime_status()
216 }
217
218 fn cancel_active(&self) -> bool {
219 self.inner.cancel_active()
220 }
221
222 async fn reset_quarantine(&self) {
223 self.inner.reset_quarantine().await;
224 }
225
226 fn deactivate(&self) {
227 self.inner.deactivate();
228 }
229
230 fn activate(&self) {
231 self.inner.activate();
232 }
233
234 fn acquire_dispatch(&self) -> bool {
235 self.inner.acquire_dispatch()
236 }
237
238 fn release_dispatch(&self) {
239 self.inner.release_dispatch();
240 }
241}
242
243#[cfg(test)]
244pub(super) fn immutable_python_source(
245 provider_root: &Path,
246 path: &Path,
247) -> Result<ImmutablePythonSource, FileProviderLoadError> {
248 immutable_python_generation(provider_root)?.source(provider_root, path)
249}
250
251pub(super) fn immutable_python_generation(
252 provider_root: &Path,
253) -> Result<ImmutablePythonGeneration, FileProviderLoadError> {
254 static STAGING_SEQUENCE: AtomicU64 = AtomicU64::new(1);
255 let mut paths = BTreeSet::new();
256 collect_python_dependency_paths(provider_root, &mut paths)?;
257 validate_generation_limits(provider_root, &paths)?;
258 let digest = python_tree_digest(provider_root, &paths)?;
259 let generation_store =
260 std::env::temp_dir().join(format!("soma-python-generations.{}", std::process::id()));
261 create_private_generation_store(&generation_store)?;
262 GENERATION_STORE_INITIALIZED.get_or_init(|| {
263 if let Ok(entries) = fs::read_dir(&generation_store) {
264 for entry in entries.flatten() {
265 let path = entry.path();
266 let _ = if path.is_dir() {
267 fs::remove_dir_all(path)
268 } else {
269 fs::remove_file(path)
270 };
271 }
272 }
273 });
274 cleanup_stale_generation_stores(&generation_store);
275 let generation = generation_store.join(&digest);
276 if generation.exists() {
277 verify_immutable_generation(&generation, &digest)?;
278 return Ok(ImmutablePythonGeneration {
279 root: generation.join("tree"),
280 digest,
281 lease: generation_lease(generation)?,
282 });
283 }
284 let staging = generation_store.join(format!(
285 ".{digest}.{}.{}.staging",
286 std::process::id(),
287 STAGING_SEQUENCE.fetch_add(1, Ordering::Relaxed)
288 ));
289 let staging_tree = staging.join("tree");
290 let staged = (|| {
291 for source in &paths {
292 let relative = source
293 .strip_prefix(provider_root)
294 .expect("collected dependency must remain under provider root");
295 let destination = staging_tree.join(relative);
296 if let Some(parent) = destination.parent() {
297 fs::create_dir_all(parent).map_err(|error| FileProviderLoadError {
298 path: parent.to_path_buf(),
299 message: format!("failed to create Python generation directory: {error}"),
300 })?;
301 }
302 fs::copy(source, &destination).map_err(|error| FileProviderLoadError {
303 path: destination,
304 message: format!("failed to snapshot Python generation source: {error}"),
305 })?;
306 }
307 verify_immutable_generation(&staging, &digest)?;
308 Ok::<(), FileProviderLoadError>(())
309 })();
310 if let Err(error) = staged {
311 let _ = fs::remove_dir_all(&staging);
312 return Err(error);
313 }
314 match fs::rename(&staging, &generation) {
315 Ok(()) => Ok(ImmutablePythonGeneration {
316 root: generation.join("tree"),
317 digest,
318 lease: generation_lease(generation)?,
319 }),
320 Err(_) if generation.exists() => {
321 let _ = fs::remove_dir_all(&staging);
322 verify_immutable_generation(&generation, &digest)?;
323 Ok(ImmutablePythonGeneration {
324 root: generation.join("tree"),
325 digest,
326 lease: generation_lease(generation)?,
327 })
328 }
329 Err(error) => {
330 let _ = fs::remove_dir_all(&staging);
331 Err(FileProviderLoadError {
332 path: generation,
333 message: format!("failed to publish Python generation snapshot: {error}"),
334 })
335 }
336 }
337}
338
339fn generation_lease(
340 generation: PathBuf,
341) -> Result<Arc<PythonGenerationLease>, FileProviderLoadError> {
342 let mut leases = GENERATION_LEASES
343 .get_or_init(|| Mutex::new(HashMap::new()))
344 .lock()
345 .expect("Python generation lease lock should not be poisoned");
346 if let Some(lease) = leases
347 .get(&generation)
348 .and_then(|entry| Weak::upgrade(&entry.lease))
349 {
350 return Ok(lease);
351 }
352 if !generation.is_dir() {
353 return Err(FileProviderLoadError {
354 path: generation,
355 message: "Python generation snapshot disappeared during acquisition".to_owned(),
356 });
357 }
358 let id = GENERATION_LEASE_SEQUENCE.fetch_add(1, Ordering::Relaxed);
359 let lease = Arc::new(PythonGenerationLease {
360 generation: generation.clone(),
361 id,
362 });
363 leases.insert(
364 generation,
365 GenerationLeaseEntry {
366 id,
367 lease: Arc::downgrade(&lease),
368 },
369 );
370 Ok(lease)
371}
372
373fn cleanup_stale_generation_stores(active_store: &Path) {
374 let Some(parent) = active_store.parent() else {
375 return;
376 };
377 let Ok(entries) = fs::read_dir(parent) else {
378 return;
379 };
380 let now = SystemTime::now();
381 for entry in entries.flatten() {
382 let path = entry.path();
383 if path == active_store
384 || !entry
385 .file_name()
386 .to_string_lossy()
387 .starts_with("soma-python-generations.")
388 {
389 continue;
390 }
391 let stale = entry
392 .metadata()
393 .and_then(|metadata| metadata.modified())
394 .ok()
395 .and_then(|modified| now.duration_since(modified).ok())
396 .is_some_and(|age| age >= STALE_GENERATION_STORE_AGE);
397 if stale && stale_store_process_is_gone(&entry.file_name().to_string_lossy()) {
398 let _ = fs::remove_dir_all(path);
399 }
400 }
401}
402
403#[cfg(target_os = "linux")]
404fn stale_store_process_is_gone(name: &str) -> bool {
405 let Some(pid) = name
406 .strip_prefix("soma-python-generations.")
407 .and_then(|pid| pid.parse::<u32>().ok())
408 else {
409 return false;
410 };
411 !Path::new("/proc").join(pid.to_string()).exists()
412}
413
414#[cfg(not(target_os = "linux"))]
415fn stale_store_process_is_gone(_name: &str) -> bool {
416 false
417}
418
419fn validate_generation_limits(
420 root: &Path,
421 paths: &BTreeSet<PathBuf>,
422) -> Result<(), FileProviderLoadError> {
423 if paths.len() > MAX_GENERATION_FILES {
424 return Err(FileProviderLoadError {
425 path: root.to_path_buf(),
426 message: format!(
427 "Python generation tree exceeds the {MAX_GENERATION_FILES}-file limit"
428 ),
429 });
430 }
431 let mut total = 0_u64;
432 for path in paths {
433 total = total.saturating_add(
434 fs::metadata(path)
435 .map_err(|error| FileProviderLoadError {
436 path: path.clone(),
437 message: format!("failed to inspect Python generation input: {error}"),
438 })?
439 .len(),
440 );
441 if total > MAX_GENERATION_BYTES {
442 return Err(FileProviderLoadError {
443 path: root.to_path_buf(),
444 message: format!(
445 "Python generation tree exceeds the {MAX_GENERATION_BYTES}-byte limit"
446 ),
447 });
448 }
449 }
450 Ok(())
451}
452
453fn secure_generation_store(path: &Path) -> Result<(), FileProviderLoadError> {
454 let metadata = fs::symlink_metadata(path).map_err(|error| FileProviderLoadError {
455 path: path.to_path_buf(),
456 message: format!("failed to inspect Python generation store: {error}"),
457 })?;
458 if metadata.file_type().is_symlink() || !metadata.file_type().is_dir() {
459 return Err(FileProviderLoadError {
460 path: path.to_path_buf(),
461 message: "Python generation store is not a private directory".to_owned(),
462 });
463 }
464 #[cfg(unix)]
465 {
466 use std::os::unix::fs::PermissionsExt;
467 fs::set_permissions(path, fs::Permissions::from_mode(0o700)).map_err(|error| {
468 FileProviderLoadError {
469 path: path.to_path_buf(),
470 message: format!("failed to secure Python generation store: {error}"),
471 }
472 })?;
473 }
474 Ok(())
475}
476
477fn create_private_generation_store(path: &Path) -> Result<(), FileProviderLoadError> {
478 #[cfg(unix)]
479 {
480 use std::os::unix::fs::DirBuilderExt;
481 let mut builder = fs::DirBuilder::new();
482 builder.mode(0o700);
483 match builder.create(path) {
484 Ok(()) => {}
485 Err(error) if error.kind() == std::io::ErrorKind::AlreadyExists => {}
486 Err(error) => {
487 return Err(FileProviderLoadError {
488 path: path.to_path_buf(),
489 message: format!("failed to create Python generation store: {error}"),
490 });
491 }
492 }
493 }
494 #[cfg(not(unix))]
495 fs::create_dir_all(path).map_err(|error| FileProviderLoadError {
496 path: path.to_path_buf(),
497 message: format!("failed to create Python generation store: {error}"),
498 })?;
499 secure_generation_store(path)
500}
501
502fn python_tree_digest(
503 root: &Path,
504 paths: &BTreeSet<PathBuf>,
505) -> Result<String, FileProviderLoadError> {
506 let mut hasher = Sha256::new();
507 for path in paths {
508 let relative = path.strip_prefix(root).map_err(|_| FileProviderLoadError {
509 path: path.clone(),
510 message: "Python dependency is outside its managed root".to_owned(),
511 })?;
512 let bytes = fs::read(path).map_err(|error| FileProviderLoadError {
513 path: path.clone(),
514 message: format!("failed to read Python generation source: {error}"),
515 })?;
516 let label = relative.to_string_lossy();
517 hasher.update(label.len().to_le_bytes());
518 hasher.update(label.as_bytes());
519 hasher.update(bytes.len().to_le_bytes());
520 hasher.update(bytes);
521 }
522 Ok(hasher
523 .finalize()
524 .iter()
525 .map(|byte| format!("{byte:02x}"))
526 .collect())
527}
528
529fn verify_immutable_generation(
530 generation: &Path,
531 expected: &str,
532) -> Result<(), FileProviderLoadError> {
533 let tree = generation.join("tree");
534 let mut paths = BTreeSet::new();
535 collect_python_dependency_paths(&tree, &mut paths)?;
536 validate_generation_limits(&tree, &paths)?;
537 let actual = python_tree_digest(&tree, &paths)?;
538 if actual != expected {
539 return Err(FileProviderLoadError {
540 path: generation.to_path_buf(),
541 message: "Python generation snapshot digest mismatch".to_owned(),
542 });
543 }
544 Ok(())
545}
546
547impl FileProviderSource {
548 pub fn resolve_python_provider_path(
550 &self,
551 provider_path: &Path,
552 ) -> Result<PathBuf, FileProviderLoadError> {
553 let requested = if provider_path.is_absolute() {
554 provider_path.to_path_buf()
555 } else {
556 self.root.join(provider_path)
557 };
558 self.provider_paths()?
559 .into_iter()
560 .find(|path| path == &requested && is_python_provider_source(path))
561 .ok_or_else(|| FileProviderLoadError {
562 path: requested,
563 message: "Python provider path is not a managed provider source".to_owned(),
564 })
565 }
566
567 pub(super) fn validate_python_environment_selections(
568 &self,
569 selections: &PythonProviderEnvironmentSelections,
570 ) -> Result<(), FileProviderLoadError> {
571 if selections.is_empty() {
572 return Ok(());
573 }
574 let managed = self
575 .provider_paths()?
576 .into_iter()
577 .filter(|path| is_python_provider_source(path))
578 .collect::<BTreeSet<_>>();
579 if let Some(path) = selections.keys().find(|path| !managed.contains(*path)) {
580 return Err(FileProviderLoadError {
581 path: path.clone(),
582 message: "Python environment selection is not a managed provider source".to_owned(),
583 });
584 }
585 Ok(())
586 }
587
588 #[cfg(test)]
589 pub(super) fn python_interpreter(
590 &self,
591 path: &Path,
592 ) -> Result<PythonInterpreter, FileProviderLoadError> {
593 self.python_interpreter_with_environments(path, &PythonProviderEnvironmentSelections::new())
594 }
595
596 pub(super) fn python_interpreter_with_environments(
597 &self,
598 path: &Path,
599 selections: &PythonProviderEnvironmentSelections,
600 ) -> Result<PythonInterpreter, FileProviderLoadError> {
601 if !is_python_provider_source(path) {
602 return Ok(PythonInterpreter::Ambient);
603 }
604 if let Some(candidate) = selections.get(path) {
605 let preparer =
606 self.python_environment_preparer
607 .as_ref()
608 .ok_or_else(|| FileProviderLoadError {
609 path: path.to_path_buf(),
610 message: "Python candidate validation requires an environment preparer"
611 .to_owned(),
612 })?;
613 return preparer
614 .validate_candidate(path, candidate)
615 .map_err(|source| FileProviderLoadError {
616 path: path.to_path_buf(),
617 message: format!("failed to validate Python provider candidate: {source}"),
618 });
619 }
620 self.python_environment_preparer.as_ref().map_or(
621 Ok(PythonInterpreter::Ambient),
622 |preparer| {
623 preparer
624 .prepare(path)
625 .map_err(|source| FileProviderLoadError {
626 path: path.to_path_buf(),
627 message: format!("failed to prepare Python provider environment: {source}"),
628 })
629 },
630 )
631 }
632}
633
634pub(super) fn collect_python_dependency_paths(
635 root: &Path,
636 paths: &mut BTreeSet<PathBuf>,
637) -> Result<(), FileProviderLoadError> {
638 if !root.exists() {
639 return Ok(());
640 }
641 collect_python_dependency_paths_inner(root, paths)
642}
643
644fn collect_python_dependency_paths_inner(
645 dir: &Path,
646 paths: &mut BTreeSet<PathBuf>,
647) -> Result<(), FileProviderLoadError> {
648 let entries = fs::read_dir(dir).map_err(|source| FileProviderLoadError {
649 path: dir.to_path_buf(),
650 message: format!("failed to read provider dependency directory: {source}"),
651 })?;
652 for entry in entries {
653 let entry = entry.map_err(|source| FileProviderLoadError {
654 path: dir.to_path_buf(),
655 message: format!("failed to read provider dependency directory entry: {source}"),
656 })?;
657 let path = entry.path();
658 let metadata = fs::symlink_metadata(&path).map_err(|source| FileProviderLoadError {
659 path: path.clone(),
660 message: format!("failed to inspect provider dependency entry: {source}"),
661 })?;
662 if metadata.file_type().is_symlink() {
663 return Err(FileProviderLoadError {
664 path,
665 message: "Python generation inputs must not contain symbolic links".to_owned(),
666 });
667 }
668 if metadata.is_dir() {
669 if should_scan_dependency_dir(&path) {
670 collect_python_dependency_paths_inner(&path, paths)?;
671 }
672 continue;
673 }
674 if metadata.is_file() {
675 paths.insert(path);
676 }
677 }
678 Ok(())
679}
680
681fn should_scan_dependency_dir(path: &Path) -> bool {
682 let Some(name) = path.file_name().and_then(|name| name.to_str()) else {
683 return false;
684 };
685 !matches!(
686 name,
687 "__pycache__"
688 | ".git"
689 | ".mypy_cache"
690 | ".pytest_cache"
691 | ".ruff_cache"
692 | ".venv"
693 | "venv"
694 | "node_modules"
695 | "target"
696 | "dist"
697 | "build"
698 )
699}
700
701pub(super) fn is_python_provider_source(path: &Path) -> bool {
702 path.extension().and_then(|extension| extension.to_str()) == Some("py")
703}
704
705pub(super) fn fingerprint_python_environment(
706 hasher: &mut Sha256,
707 root: &Path,
708 path: &Path,
709 candidate: &PreparedPythonEnvironment,
710) {
711 let label = path
712 .strip_prefix(root)
713 .unwrap_or(path)
714 .display()
715 .to_string();
716 hasher.update(b"python-environment\0");
717 hasher.update(label.as_bytes());
718 hasher.update([0]);
719 for value in [
720 candidate.key.as_str(),
721 candidate.lock_sha256.as_str(),
722 candidate
723 .provider_source_sha256
724 .as_deref()
725 .unwrap_or_default(),
726 candidate.input_plan_key.as_deref().unwrap_or_default(),
727 ] {
728 hasher.update(value.len().to_le_bytes());
729 hasher.update(value.as_bytes());
730 hasher.update([0]);
731 }
732}
733
734pub(super) fn python_worker_generation_digest(
735 source_tree_digest: &str,
736 selections: &PythonProviderEnvironmentSelections,
737) -> String {
738 let mut hasher = Sha256::new();
739 hasher.update(b"python-worker-generation-v1\0");
740 hasher.update(source_tree_digest.as_bytes());
741 for (path, candidate) in selections {
742 let path = path.to_string_lossy();
743 for value in [
744 path.as_ref(),
745 candidate.key.as_str(),
746 candidate.lock_sha256.as_str(),
747 ] {
748 hasher.update(value.len().to_le_bytes());
749 hasher.update(value.as_bytes());
750 }
751 }
752 hasher
753 .finalize()
754 .iter()
755 .map(|byte| format!("{byte:02x}"))
756 .collect()
757}
758
759#[cfg(test)]
760#[path = "filesystem_python_tests.rs"]
761mod tests;