Skip to main content

inferlab_runtime/server/
launch.rs

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    // Declared pass-through values flow from the launching machine's
67    // environment — here the invoking process — into the docker client,
68    // which forwards each name-referenced variable into the container. On a
69    // local launch the invoking environment is also composed into the
70    // recorded env map (the standing unredacted-records posture); the
71    // reference channel is what keeps the value out of the plan where no
72    // ambient composition exists ([[RFC-0003:C-RUNTIME-WORKFLOWS]]).
73    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        // The client may already have asked the daemon to create the
88        // container, which the group kill cannot reach. The group was
89        // stopped above, so this final removal races nothing; an
90        // unconfirmed one means the workload may still be running, which
91        // cleanup must never call verified ([[RFC-0003:C-RUNTIME-WORKFLOWS]]).
92        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
237/// A one-line human summary of a structured removal outcome for the launch
238/// failure message; the structured evidence itself rides
239/// [`LaunchFailure::container_removal`].
240pub(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    // Order matters: stop the remote launcher first, so a docker client
436    // that had not yet created the container cannot create it after an
437    // early rm reported it absent, then do the final container-removal
438    // confirmation against a quiescent group — the same order the local
439    // path uses ([[RFC-0003:C-RUNTIME-WORKFLOWS]]).
440    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    // Declared pass-through values flow from the launching machine's
524    // environment: the remote login shell expands the reference before
525    // `env -i` strips it, so the value reaches the docker client — which
526    // forwards each name-referenced variable into the container — while
527    // the script text carries only the reference
528    // ([[RFC-0003:C-RUNTIME-WORKFLOWS]]). Names are load-validated bare
529    // identifiers, safe to splice unquoted.
530    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}