laminar_db/process_function/remote/python_environment/
mod.rs1use std::path::{Path, PathBuf};
2
3use super::LocalPythonWorkerConfig;
4use crate::error::DbError;
5use crate::process_function::descriptor::{valid_python_handler, MAX_PYTHON_IMPORT_ROOTS};
6use crate::process_function::{ProcessFunctionDescriptor, PythonEnvironmentBinding};
7
8mod file_guards;
9mod tree;
10
11pub(super) use file_guards::FileGuards;
12pub(super) use tree::file_sha256;
13use tree::{canonical_directory, fingerprint_guarded, InventoryBudget};
14
15#[cfg(all(test, target_os = "linux"))]
16pub(crate) fn read_only_fixture_config() -> LocalPythonWorkerConfig {
17 let package = std::path::PathBuf::from(
18 std::env::var_os("LAMINAR_PROCESS_REPLAY_PACKAGE")
19 .expect("set LAMINAR_PROCESS_REPLAY_PACKAGE to the read-only qualification package"),
20 );
21 LocalPythonWorkerConfig {
22 python: package.join("runtime/bin/python3.13"),
23 runtime_root: Some(package.join("runtime")),
24 manifest: package.join("manifest.json"),
25 handler_file: package.join("handler/replay_handler.py"),
26 function: "handle".into(),
27 python_paths: vec![package.join("runtime/lib/python3.13/site-packages")],
28 max_in_flight: 4,
29 timeout: std::time::Duration::from_secs(10),
30 }
31}
32
33pub(super) struct VerifiedEnvironment {
34 pub(super) python: PathBuf,
35 pub(super) runtime_root: Option<PathBuf>,
36 pub(super) import_roots: Vec<PathBuf>,
37 pub(super) _guards: FileGuards,
38}
39
40impl PythonEnvironmentBinding {
41 pub fn capture(
54 runtime_root: &Path,
55 python: &Path,
56 handler: &str,
57 import_roots: &[PathBuf],
58 ) -> Result<Self, DbError> {
59 capture(
60 runtime_root,
61 python,
62 handler,
63 import_roots,
64 &mut FileGuards::default(),
65 )
66 }
67}
68
69fn capture(
70 runtime_root: &Path,
71 python: &Path,
72 handler: &str,
73 import_roots: &[PathBuf],
74 guards: &mut FileGuards,
75) -> Result<PythonEnvironmentBinding, DbError> {
76 if import_roots.is_empty() || import_roots.len() > MAX_PYTHON_IMPORT_ROOTS {
77 return Err(DbError::Config(
78 "Python binding requires 1..=16 import roots".into(),
79 ));
80 }
81 if !valid_python_handler(handler) {
82 return Err(DbError::Config("invalid Python environment handler".into()));
83 }
84 let runtime_root = canonical_directory(runtime_root, guards)?;
85 let python = guards.canonical_file(python)?;
86 let relative = python
87 .strip_prefix(&runtime_root)
88 .map_err(|_| DbError::Config("Python executable is outside the runtime root".into()))?;
89 let executable = relative
90 .to_str()
91 .ok_or_else(|| DbError::Config("Python executable path must be UTF-8".into()))?
92 .replace(std::path::MAIN_SEPARATOR, "/");
93 let mut budget = InventoryBudget::default();
94 let runtime_sha256 = fingerprint_guarded(&runtime_root, &mut budget, guards)?;
95 let mut import_roots_sha256 = Vec::with_capacity(import_roots.len());
96 for root in import_roots {
97 import_roots_sha256.push(fingerprint_guarded(
98 &canonical_directory(root, guards)?,
99 &mut budget,
100 guards,
101 )?);
102 }
103 let binding = PythonEnvironmentBinding {
104 version: 1,
105 executable,
106 handler: handler.to_owned(),
107 runtime_sha256,
108 import_roots_sha256,
109 };
110 binding.validate()?;
111 Ok(binding)
112}
113
114pub(super) fn verify(
115 config: &LocalPythonWorkerConfig,
116 descriptor: &ProcessFunctionDescriptor,
117 handler_directory: &Path,
118 manifest: &Path,
119 module: &str,
120 mut guards: FileGuards,
121) -> Result<VerifiedEnvironment, DbError> {
122 if config.python_paths.len() >= MAX_PYTHON_IMPORT_ROOTS {
123 return Err(DbError::Config(
124 "Python worker supports at most 15 explicit import roots".into(),
125 ));
126 }
127 let mut import_roots = vec![handler_directory.to_path_buf()];
128 for root in &config.python_paths {
129 let root = if descriptor.python_environment.is_some() {
130 canonical_directory(root, &mut guards)?
131 } else {
132 root.canonicalize()
133 .map_err(|error| DbError::Config(format!("resolve Python import root: {error}")))?
134 };
135 import_roots.push(root);
136 }
137 match (&descriptor.python_environment, &config.runtime_root) {
138 (None, None) => Ok(VerifiedEnvironment {
139 python: config.python.clone(),
140 runtime_root: None,
141 import_roots,
142 _guards: guards,
143 }),
144 (Some(expected), Some(root)) => {
145 let runtime_root = canonical_directory(root, &mut guards)?;
146 if manifest.starts_with(&runtime_root)
147 || import_roots.iter().any(|root| manifest.starts_with(root))
148 {
149 return Err(DbError::Config(
150 "bound process manifest must be outside the hashed trees".into(),
151 ));
152 }
153 let actual = capture(
154 &runtime_root,
155 &config.python,
156 &format!("{module}:{}", config.function),
157 &import_roots,
158 &mut guards,
159 )?;
160 if &actual != expected {
161 return Err(DbError::Config(
162 "Python environment differs from its process manifest".into(),
163 ));
164 }
165 let python = runtime_root.join(&actual.executable);
166 Ok(VerifiedEnvironment {
167 python,
168 runtime_root: Some(runtime_root),
169 import_roots,
170 _guards: guards,
171 })
172 }
173 _ => Err(DbError::Config(
174 "Python environment binding and runtime_root must be supplied together".into(),
175 )),
176 }
177}
178
179#[cfg(test)]
180mod tests {
181 use super::*;
182 use std::time::Duration;
183
184 fn deployment(root: &Path) -> (LocalPythonWorkerConfig, ProcessFunctionDescriptor) {
185 let root = root.canonicalize().unwrap();
186 let runtime = root.join("runtime");
187 let handlers = root.join("handlers");
188 let sdk = root.join("sdk");
189 for path in [&runtime, &handlers, &sdk] {
190 std::fs::create_dir(path).unwrap();
191 }
192 let python = runtime.join("python");
193 std::fs::write(&python, b"interpreter").unwrap();
194 std::fs::write(runtime.join("stdlib.py"), b"runtime").unwrap();
195 let handler_file = handlers.join("handler.py");
196 std::fs::write(&handler_file, b"handler").unwrap();
197 std::fs::write(sdk.join("worker.py"), b"sdk").unwrap();
198 let manifest = root.join("manifest.json");
199 std::fs::write(&manifest, []).unwrap();
200 let mut descriptor = ProcessFunctionDescriptor::from_manifest_json(
201 &std::fs::read(
202 PathBuf::from(env!("CARGO_MANIFEST_DIR"))
203 .join("../../examples/process_python/manifest.json"),
204 )
205 .unwrap(),
206 )
207 .unwrap();
208 descriptor.python_environment = Some(
209 PythonEnvironmentBinding::capture(
210 &runtime,
211 &python,
212 "handler:handle",
213 &[handlers, sdk.clone()],
214 )
215 .unwrap(),
216 );
217 (
218 LocalPythonWorkerConfig {
219 python,
220 runtime_root: Some(runtime),
221 manifest,
222 handler_file,
223 function: "handle".into(),
224 python_paths: vec![sdk],
225 max_in_flight: 1,
226 timeout: Duration::from_secs(5),
227 },
228 descriptor,
229 )
230 }
231
232 fn check(
233 config: &LocalPythonWorkerConfig,
234 descriptor: &ProcessFunctionDescriptor,
235 ) -> Result<VerifiedEnvironment, DbError> {
236 verify(
237 config,
238 descriptor,
239 config.handler_file.parent().unwrap(),
240 &config.manifest,
241 "handler",
242 FileGuards::default(),
243 )
244 }
245
246 #[test]
247 fn replay_binding_rejects_writable_deployment() {
248 let root = tempfile::tempdir().unwrap();
249 let (config, _) = deployment(root.path());
250 let result = FileGuards::for_replay()
251 .and_then(|mut guards| guards.canonical_file(&config.python).map(|_| ()));
252 assert!(result.is_err());
253 assert!(result.unwrap_err().to_string().contains("read-only"));
254 }
255
256 #[test]
257 fn binding_rejects_runtime_sdk_addition_and_removal_drift() {
258 let root = tempfile::tempdir().unwrap();
259 let (config, descriptor) = deployment(root.path());
260 check(&config, &descriptor).unwrap();
261 let stdlib = config.runtime_root.as_ref().unwrap().join("stdlib.py");
262 std::fs::write(&stdlib, b"changed").unwrap();
263 assert!(check(&config, &descriptor)
264 .err()
265 .unwrap()
266 .to_string()
267 .contains("environment differs"));
268 std::fs::write(&stdlib, b"runtime").unwrap();
269 let sdk = config.python_paths[0].join("worker.py");
270 std::fs::write(&sdk, b"SDK").unwrap();
271 assert!(check(&config, &descriptor).is_err());
272 std::fs::write(&sdk, b"sdk").unwrap();
273 let extra = config.python_paths[0].join("new.pyc");
274 std::fs::write(&extra, b"new import").unwrap();
275 assert!(check(&config, &descriptor).is_err());
276 std::fs::remove_file(extra).unwrap();
277 check(&config, &descriptor).unwrap();
278 std::fs::remove_file(sdk).unwrap();
279 assert!(check(&config, &descriptor).is_err());
280 }
281
282 #[test]
283 fn binding_is_relocatable_and_requires_both_launch_and_manifest_fields() {
284 let first = tempfile::tempdir().unwrap();
285 let second = tempfile::tempdir().unwrap();
286 let (config, descriptor) = deployment(first.path());
287 let (mut moved, equivalent) = deployment(second.path());
288 assert_eq!(descriptor.python_environment, equivalent.python_environment);
289 check(&moved, &descriptor).unwrap();
290 moved.runtime_root = None;
291 assert!(check(&moved, &descriptor).is_err());
292 let mut unbound = descriptor.clone();
293 unbound.python_environment = None;
294 assert!(check(&config, &unbound).is_err());
295 check(&moved, &unbound).unwrap();
296 let mut changed_entrypoint = config;
297 changed_entrypoint.function = "different_handler".into();
298 assert!(check(&changed_entrypoint, &descriptor).is_err());
299 }
300
301 #[test]
302 fn binding_rejects_uncontained_executable_and_self_referential_manifest() {
303 let root = tempfile::tempdir().unwrap();
304 let (mut config, descriptor) = deployment(root.path());
305 let outside = root.path().join("outside");
306 std::fs::write(&outside, b"interpreter").unwrap();
307 assert!(PythonEnvironmentBinding::capture(
308 config.runtime_root.as_ref().unwrap(),
309 &outside,
310 "handler:handle",
311 &config.python_paths
312 )
313 .unwrap_err()
314 .to_string()
315 .contains("outside"));
316 config.manifest = config.python_paths[0].join("manifest.json");
317 assert!(check(&config, &descriptor)
318 .err()
319 .unwrap()
320 .to_string()
321 .contains("outside the hashed trees"));
322 assert!(PythonEnvironmentBinding::capture(
323 config.runtime_root.as_ref().unwrap(),
324 &config.python,
325 "handler:handle",
326 &vec![config.python_paths[0].clone(); 17]
327 )
328 .is_err());
329 }
330
331 #[cfg(windows)]
332 #[test]
333 fn verified_environment_retains_file_guards_and_failed_checks_release_them() {
334 let root = tempfile::tempdir().unwrap();
335 let (config, descriptor) = deployment(root.path());
336 let files = [
337 config.python.clone(),
338 config.runtime_root.as_ref().unwrap().join("stdlib.py"),
339 config.handler_file.clone(),
340 config.python_paths[0].join("worker.py"),
341 ];
342 let environment = check(&config, &descriptor).unwrap();
343 for file in &files {
344 assert_eq!(
345 std::fs::write(file, b"changed").unwrap_err().raw_os_error(),
346 Some(32)
347 );
348 }
349 drop(environment);
350 std::fs::write(&files[3], b"changed").unwrap();
351 assert!(check(&config, &descriptor).is_err());
352 for file in &files {
353 std::fs::OpenOptions::new().write(true).open(file).unwrap();
354 }
355 }
356}