1use super::cleanup::{
2 KILL_POLL_LIMIT, REMOTE_SERVER_CLEANUP_DEADLINE, TERM_POLL_LIMIT, cleanup_error,
3 cleanup_failed_local_launch, completed_cleanup, remove_server_container,
4};
5use super::observation::{remote_group_alive_script, run_cleanup_command};
6use super::{
7 CleanupTrigger, HostProcessHandle, LaunchFailure, ProcessHandle, ProcessLauncher, ProcessSpec,
8 ServerLaunchError, SshProcessHandle, SystemProcessRuntime,
9};
10use crate::operation_bound::{OperationBound, duration_millis};
11use crate::plan::{CommandPlan, LaunchFilePlan, LaunchPlan};
12use crate::shell::{shell_quote, shell_quote_path};
13use crate::ssh::{ssh_argv, ssh_output};
14use sha2::{Digest, Sha256};
15use std::fs;
16use std::fs::File;
17use std::io::{self, Read, Write};
18use std::os::unix::fs::PermissionsExt;
19use std::os::unix::process::CommandExt;
20use std::path::{Path, PathBuf};
21use std::process::{Command, Output, Stdio};
22use std::thread;
23use std::time::Instant;
24
25const HANDLE_MARKER: &str = "INFERLAB_HANDLE\t";
26
27pub(super) fn spawn_local(spec: ProcessSpec<'_>) -> Result<HostProcessHandle, LaunchFailure> {
28 let fail = |message: String| LaunchFailure::before_launch(message);
29 fs::create_dir_all(spec.cache_root).map_err(|source| {
30 LaunchFailure::from_error(ServerLaunchError::FileIo {
31 operation: "create runtime cache root",
32 path: spec.cache_root.to_path_buf(),
33 source,
34 })
35 })?;
36 materialize_local_launch_files(spec.launch_files).map_err(LaunchFailure::from_error)?;
37 let (program, args) = spec
38 .command
39 .argv
40 .split_first()
41 .ok_or_else(|| fail("resolved server command is empty".to_owned()))?;
42 let stdout = File::create(spec.stdout).map_err(|source| {
43 LaunchFailure::from_error(ServerLaunchError::FileIo {
44 operation: "create server stdout log",
45 path: spec.stdout.to_path_buf(),
46 source,
47 })
48 })?;
49 let stderr = File::create(spec.stderr).map_err(|source| {
50 LaunchFailure::from_error(ServerLaunchError::FileIo {
51 operation: "create server stderr log",
52 path: spec.stderr.to_path_buf(),
53 source,
54 })
55 })?;
56 let mut command = Command::new(program);
57 command
58 .args(args)
59 .current_dir(&spec.command.cwd)
60 .env_clear()
61 .envs(&spec.command.env)
62 .stdin(Stdio::null())
63 .stdout(Stdio::from(stdout))
64 .stderr(Stdio::from(stderr))
65 .process_group(0);
66 for name in &spec.command.pass_env {
74 if let Some(value) = std::env::var_os(name) {
75 command.env(name, value);
76 }
77 }
78 let mut child = command.spawn().map_err(|source| {
79 LaunchFailure::from_error(ServerLaunchError::Process {
80 program: program.clone(),
81 source,
82 })
83 })?;
84 HostProcessHandle::new(child.id(), spec.container.map(str::to_owned)).map_err(|error| {
85 let mut cleanup = cleanup_failed_local_launch(&mut child);
86 let error = ServerLaunchError::Preparation { message: error };
87 match spec.container {
93 Some(container) => {
94 let removal = remove_server_container(None, container);
95 if !removal.confirmed {
96 cleanup.verified = false;
97 if cleanup.error.is_none() {
98 cleanup.error = removal.error.clone();
99 }
100 }
101 cleanup.container_removal = Some(removal.clone());
102 LaunchFailure {
103 error,
104 ownership_unknown: !cleanup.verified,
105 container_removal: Some(Box::new(removal)),
106 cleanup: Some(Box::new(cleanup)),
107 cleanup_note: None,
108 }
109 }
110 None => LaunchFailure {
111 error,
112 ownership_unknown: !cleanup.verified,
113 container_removal: None,
114 cleanup: Some(Box::new(cleanup)),
115 cleanup_note: None,
116 },
117 }
118 })
119}
120
121pub(super) fn materialize_local_launch_files(
122 launch_files: &[LaunchFilePlan],
123) -> Result<(), ServerLaunchError> {
124 for launch_file in launch_files {
125 publish_local_launch_file(launch_file)?;
126 }
127 Ok(())
128}
129
130fn publish_local_launch_file(launch_file: &LaunchFilePlan) -> Result<(), ServerLaunchError> {
131 let target = &launch_file.resolved_path;
132 let parent = target
133 .parent()
134 .ok_or_else(|| ServerLaunchError::Preparation {
135 message: format!(
136 "launch file target {} has no parent directory",
137 target.display()
138 ),
139 })?;
140 fs::create_dir_all(parent).map_err(|source| ServerLaunchError::FileIo {
141 operation: "create launch file directory",
142 path: parent.to_path_buf(),
143 source,
144 })?;
145 let mut staged = tempfile::Builder::new()
146 .prefix(".inferlab-launch.")
147 .tempfile_in(parent)
148 .map_err(|source| ServerLaunchError::FileIo {
149 operation: "stage launch file",
150 path: target.to_path_buf(),
151 source,
152 })?;
153 staged
154 .write_all(launch_file.text.as_bytes())
155 .and_then(|()| staged.flush())
156 .map_err(|source| ServerLaunchError::FileIo {
157 operation: "write staged launch file",
158 path: target.to_path_buf(),
159 source,
160 })?;
161 staged
162 .as_file()
163 .set_permissions(fs::Permissions::from_mode(0o444))
164 .and_then(|()| staged.as_file().sync_all())
165 .map_err(|source| ServerLaunchError::FileIo {
166 operation: "finalize staged launch file",
167 path: target.to_path_buf(),
168 source,
169 })?;
170
171 match staged.persist_noclobber(target) {
172 Ok(_) => Ok(()),
173 Err(failure) => {
174 let source = failure.error;
175 if source.kind() == io::ErrorKind::AlreadyExists {
176 verify_existing_launch_file(target, &launch_file.sha256)
177 } else {
178 Err(ServerLaunchError::FileIo {
179 operation: "publish launch file",
180 path: target.to_path_buf(),
181 source,
182 })
183 }
184 }
185 }
186}
187
188fn verify_existing_launch_file(
189 target: &Path,
190 expected_sha256: &str,
191) -> Result<(), ServerLaunchError> {
192 let metadata = fs::symlink_metadata(target).map_err(|source| ServerLaunchError::FileIo {
193 operation: "inspect existing launch file",
194 path: target.to_path_buf(),
195 source,
196 })?;
197 if !metadata.file_type().is_file() {
198 return Err(ServerLaunchError::NotRegularFile {
199 path: target.to_path_buf(),
200 });
201 }
202 let actual_sha256 = file_sha256(target)?;
203 if actual_sha256 != expected_sha256 {
204 return Err(ServerLaunchError::FileDigestMismatch {
205 path: target.to_path_buf(),
206 expected: expected_sha256.to_owned(),
207 actual: actual_sha256,
208 });
209 }
210 Ok(())
211}
212
213fn file_sha256(path: &Path) -> Result<String, ServerLaunchError> {
214 let mut file = File::open(path).map_err(|source| ServerLaunchError::FileIo {
215 operation: "read launch file",
216 path: path.to_path_buf(),
217 source,
218 })?;
219 let mut digest = Sha256::new();
220 let mut buffer = [0_u8; 8192];
221 loop {
222 let read = file
223 .read(&mut buffer)
224 .map_err(|source| ServerLaunchError::FileIo {
225 operation: "read launch file",
226 path: path.to_path_buf(),
227 source,
228 })?;
229 if read == 0 {
230 break;
231 }
232 digest.update(&buffer[..read]);
233 }
234 Ok(format!("{:x}", digest.finalize()))
235}
236
237pub(super) fn spawn_ssh(
241 target: &str,
242 spec: ProcessSpec<'_>,
243) -> Result<SshProcessHandle, LaunchFailure> {
244 let remote_stdout = spec.remote_dir.join("stdout.log");
245 let remote_stderr = spec.remote_dir.join("stderr.log");
246 let remote_handle = spec.remote_dir.join("launch.handle");
247 let command = render_env_command(spec.command).map_err(LaunchFailure::before_launch)?;
248 materialize_ssh_launch_files(target, spec.launch_files).map_err(LaunchFailure::from_error)?;
249 let script = format!(
250 "set -eu; mkdir -p {dir} {cache}; cd {cwd}; nohup setsid {command} >{stdout} 2>{stderr} </dev/null & pid=$!; cleanup_pending=1; cleanup_launch() {{ if [ \"$cleanup_pending\" = 1 ]; then kill -KILL -- -$pid 2>/dev/null || kill -KILL $pid 2>/dev/null || true; fi; }}; trap cleanup_launch EXIT; ticks=$(awk '{{print $22}}' /proc/$pid/stat); printf '%s %s\\n' \"$pid\" \"$ticks\" > {handle}; printf 'INFERLAB_HANDLE\\t%s\\t%s\\n' \"$pid\" \"$ticks\"; cleanup_pending=0; trap - EXIT",
251 dir = shell_quote_path(spec.remote_dir),
252 cache = shell_quote_path(spec.cache_root),
253 cwd = shell_quote_path(&spec.command.cwd),
254 stdout = shell_quote_path(&remote_stdout),
255 stderr = shell_quote_path(&remote_stderr),
256 handle = shell_quote_path(&remote_handle),
257 );
258 let output = ssh_output(target, &script)
259 .map_err(ServerLaunchError::from)
260 .map_err(LaunchFailure::from_error)?;
261 if !output.status.success() {
262 return Err(failed_ssh_handle_delivery(
263 target,
264 &remote_handle,
265 spec.container,
266 ServerLaunchError::Exit {
267 operation: format!("SSH launch on {target:?}"),
268 status: output.status,
269 diagnostics: String::from_utf8_lossy(&output.stderr).trim().to_owned(),
270 },
271 ));
272 }
273 let text = match String::from_utf8(output.stdout) {
274 Ok(text) => text,
275 Err(error) => {
276 return Err(failed_ssh_handle_delivery(
277 target,
278 &remote_handle,
279 spec.container,
280 ServerLaunchError::NonUtf8Identity {
281 target: target.to_owned(),
282 source: error,
283 },
284 ));
285 }
286 };
287 parse_ssh_handle(
288 target,
289 remote_stdout,
290 remote_stderr,
291 &text,
292 spec.container.map(str::to_owned),
293 )
294 .map_err(|error| failed_ssh_handle_delivery(target, &remote_handle, spec.container, error))
295}
296
297fn materialize_ssh_launch_files(
298 target: &str,
299 launch_files: &[LaunchFilePlan],
300) -> Result<(), ServerLaunchError> {
301 for launch_file in launch_files {
302 let script = remote_launch_file_script(launch_file)?;
303 let output = ssh_output_with_input(target, &script, launch_file.text.as_bytes())?;
304 if !output.status.success() {
305 return Err(ServerLaunchError::Exit {
306 operation: format!(
307 "materializing launch file {} over SSH on {target:?}",
308 launch_file.resolved_path.display()
309 ),
310 status: output.status,
311 diagnostics: String::from_utf8_lossy(&output.stderr).trim().to_owned(),
312 });
313 }
314 }
315 Ok(())
316}
317
318pub(super) fn remote_launch_file_script(
319 launch_file: &LaunchFilePlan,
320) -> Result<String, ServerLaunchError> {
321 let target = &launch_file.resolved_path;
322 let parent = target
323 .parent()
324 .ok_or_else(|| ServerLaunchError::Preparation {
325 message: format!(
326 "launch file target {} has no parent directory",
327 target.display()
328 ),
329 })?;
330 Ok(format!(
331 "# INFERLAB_LAUNCH_FILE\nset -eu\numask 077\nparent={parent}\ntarget={target}\ndigest={digest}\nmkdir -p -- \"$parent\"\nstage=$(mktemp \"$parent/.inferlab-launch.XXXXXX\")\ntrap 'rm -f -- \"$stage\"' EXIT\ncat > \"$stage\"\nactual=$(sha256sum -- \"$stage\" | awk '{{print $1}}')\nif [ \"$actual\" != \"$digest\" ]; then printf 'staged launch file digest mismatch for %s: expected %s, found %s\\n' \"$target\" \"$digest\" \"$actual\" >&2; exit 1; fi\nchmod 0444 -- \"$stage\"\nif ln -T -- \"$stage\" \"$target\" 2>/dev/null; then exit 0; fi\nif [ ! -f \"$target\" ] || [ -L \"$target\" ]; then printf 'existing launch file target %s is not a regular file\\n' \"$target\" >&2; exit 1; fi\nactual=$(sha256sum -- \"$target\" | awk '{{print $1}}')\nif [ \"$actual\" != \"$digest\" ]; then printf 'existing launch file %s does not match declared digest %s; found %s\\n' \"$target\" \"$digest\" \"$actual\" >&2; exit 1; fi",
332 parent = shell_quote_path(parent),
333 target = shell_quote_path(target),
334 digest = shell_quote(&launch_file.sha256),
335 ))
336}
337
338fn ssh_output_with_input(
339 target: &str,
340 script: &str,
341 input: &[u8],
342) -> Result<Output, ServerLaunchError> {
343 let argv = ssh_argv(target, script);
344 let mut command = Command::new(&argv[0]);
345 command.args(&argv[1..]);
346 command_output_with_input(command, input).map_err(|source| ServerLaunchError::SshInput {
347 target: target.to_owned(),
348 source,
349 })
350}
351
352pub(super) fn command_output_with_input(mut command: Command, input: &[u8]) -> io::Result<Output> {
353 let mut child = command
354 .stdin(Stdio::piped())
355 .stdout(Stdio::piped())
356 .stderr(Stdio::piped())
357 .spawn()?;
358 let mut stdin = child
359 .stdin
360 .take()
361 .ok_or_else(|| io::Error::other("child stdin was not piped"))?;
362 thread::scope(|scope| {
363 let writer = scope.spawn(move || stdin.write_all(input));
364 let output = child.wait_with_output();
365 let write_result = writer
366 .join()
367 .map_err(|_| io::Error::other("child stdin writer panicked"))?;
368 let output = output?;
369 write_result?;
370 Ok(output)
371 })
372}
373
374fn parse_ssh_handle(
375 target: &str,
376 remote_stdout: PathBuf,
377 remote_stderr: PathBuf,
378 output: &str,
379 container: Option<String>,
380) -> Result<SshProcessHandle, ServerLaunchError> {
381 let result = output
382 .lines()
383 .rev()
384 .find_map(|line| line.strip_prefix(HANDLE_MARKER))
385 .ok_or_else(|| ServerLaunchError::MissingProcessId {
386 target: target.to_owned(),
387 })?
388 .split_once('\t')
389 .ok_or_else(|| ServerLaunchError::MissingStartTime {
390 target: target.to_owned(),
391 })?;
392 let leader_pid =
393 result
394 .0
395 .parse::<u32>()
396 .map_err(|source| ServerLaunchError::InvalidProcessId {
397 target: target.to_owned(),
398 value: result.0.to_owned(),
399 source,
400 })?;
401 let leader_start_time_ticks =
402 result
403 .1
404 .parse::<u64>()
405 .map_err(|source| ServerLaunchError::InvalidStartTime {
406 target: target.to_owned(),
407 value: result.1.to_owned(),
408 source,
409 })?;
410 let handle = SshProcessHandle {
411 target: target.to_owned(),
412 leader_pid,
413 process_group: leader_pid,
414 leader_start_time_ticks,
415 stdout: remote_stdout,
416 stderr: remote_stderr,
417 container,
418 };
419 handle
420 .validate()
421 .map_err(|details| ServerLaunchError::InvalidIdentity {
422 target: target.to_owned(),
423 details,
424 })?;
425 Ok(handle)
426}
427
428fn failed_ssh_handle_delivery(
429 target: &str,
430 remote_handle: &Path,
431 container: Option<&str>,
432 error: ServerLaunchError,
433) -> LaunchFailure {
434 let cleanup_started = Instant::now();
435 let process_cleanup = cleanup_incomplete_ssh_launch(target, remote_handle);
441 let removal = container.map(|container| remove_server_container(Some(target), container));
442 let removal_confirmed = removal.as_ref().is_none_or(|removal| removal.confirmed);
443 let (process_confirmed, cleanup_note) = match &process_cleanup {
444 Ok(()) => (
445 true,
446 format!(
447 "cleaned the remote process using {}",
448 remote_handle.display()
449 ),
450 ),
451 Err(cleanup) => (
452 false,
453 format!(
454 "remote launch cleanup using {} was not verified: {cleanup}",
455 remote_handle.display()
456 ),
457 ),
458 };
459 let verified = removal_confirmed && process_confirmed;
460 let mut cleanup = if process_confirmed {
461 completed_cleanup(CleanupTrigger::StartupRollback, false, false, Vec::new())
462 } else {
463 cleanup_error(
464 CleanupTrigger::StartupRollback,
465 false,
466 Vec::new(),
467 process_cleanup
468 .as_ref()
469 .err()
470 .cloned()
471 .unwrap_or_else(|| "remote launch cleanup was not verified".to_owned()),
472 )
473 };
474 cleanup.elapsed_ms = duration_millis(cleanup_started.elapsed());
475 cleanup.remote_deadline_ms = Some(duration_millis(REMOTE_SERVER_CLEANUP_DEADLINE));
476 cleanup.container_removal = removal.clone();
477 cleanup.verified = verified;
478 if !verified && cleanup.error.is_none() {
479 cleanup.error = removal.as_ref().and_then(|removal| removal.error.clone());
480 }
481 LaunchFailure {
482 error,
483 ownership_unknown: !verified,
484 container_removal: removal.map(Box::new),
485 cleanup: Some(Box::new(cleanup)),
486 cleanup_note: Some(cleanup_note),
487 }
488}
489
490fn cleanup_incomplete_ssh_launch(target: &str, remote_handle: &Path) -> Result<(), String> {
491 let bound = OperationBound::finite(REMOTE_SERVER_CLEANUP_DEADLINE);
492 let alive = remote_group_alive_script("$pid");
493 let script = format!(
494 "set +e; file={file}; if [ ! -r \"$file\" ]; then exit 4; fi; read pid expected < \"$file\" || exit 4; if [ -r /proc/$pid/stat ]; then actual=$(awk '{{print $22}}' /proc/$pid/stat) || exit 4; [ \"$actual\" = \"$expected\" ] || exit 4; elif {alive}; then exit 5; else rm -f \"$file\"; exit 0; fi; if ! {alive}; then rm -f \"$file\"; exit 0; fi; kill -TERM -- -$pid; i=0; while {alive} && [ $i -lt {term_limit} ]; do sleep 0.1; i=$((i+1)); done; if {alive}; then kill -KILL -- -$pid; i=0; while {alive} && [ $i -lt {kill_limit} ]; do sleep 0.1; i=$((i+1)); done; fi; if {alive}; then exit 6; fi; rm -f \"$file\"",
495 file = shell_quote_path(remote_handle),
496 term_limit = TERM_POLL_LIMIT,
497 kill_limit = KILL_POLL_LIMIT,
498 );
499 let output = run_cleanup_command(&ssh_argv(target, &script), &bound, "SSH launch cleanup")
500 .map_err(|error| error.to_string())?;
501 if output.status.success() {
502 Ok(())
503 } else {
504 Err(format!(
505 "SSH cleanup exited with {}: {}",
506 output.status,
507 String::from_utf8_lossy(&output.stderr).trim()
508 ))
509 }
510}
511
512fn render_env_command(command: &CommandPlan) -> Result<String, String> {
513 if command.argv.is_empty() {
514 return Err("resolved server command is empty".to_owned());
515 }
516 let mut parts = vec!["env".to_owned(), "-i".to_owned()];
517 parts.extend(
518 command
519 .env
520 .iter()
521 .map(|(name, value)| shell_quote(&format!("{name}={value}"))),
522 );
523 parts.extend(
531 command
532 .pass_env
533 .iter()
534 .map(|name| format!("{name}=\"${{{name}}}\"")),
535 );
536 parts.extend(command.argv.iter().map(|value| shell_quote(value)));
537 Ok(parts.join(" "))
538}
539
540impl ProcessLauncher for SystemProcessRuntime {
541 fn spawn(&self, spec: ProcessSpec<'_>) -> Result<ProcessHandle, LaunchFailure> {
542 match spec.launch {
543 LaunchPlan::Local => spawn_local(spec).map(ProcessHandle::Local),
544 LaunchPlan::Ssh { target } => spawn_ssh(target, spec).map(ProcessHandle::Ssh),
545 }
546 }
547}