Skip to main content

runner_manager_agent/
oci.rs

1//! Rootless Podman-compatible provider for dependency-isolated Linux runners.
2//! The runtime is installed by the operator; this adapter never starts a native
3//! runner for an isolated policy and never grants a host filesystem mount.
4
5use std::collections::BTreeMap;
6use std::io::Write;
7use std::path::{Path, PathBuf};
8use std::process::{Child, Command, Stdio};
9use std::sync::Mutex;
10
11use runner_manager_domain::attempt::{FailureReason, IsolationProviderFailure, RunnerAttempt};
12use runner_manager_domain::execution::{
13    AttemptExecution, Backend, ExecutionPolicy, ImageReference,
14};
15use runner_manager_domain::model::{AttemptId, HostId};
16use runner_manager_domain::policy::ScalePolicy;
17use runner_manager_github::jit::EncodedJitConfig;
18
19use crate::lifecycle::{
20    EnvironmentIdentity, EnvironmentState, ExecutionProvider, NativeProcesses, OneTimeJitHandoff,
21    PreparedEnvironment, ProcessStartFailure, ProviderCapability, ProviderDestroy, ProviderStop,
22    ResolvedEnvironment,
23};
24
25const HOST_LABEL: &str = "io.runner-manager.host";
26const ATTEMPT_LABEL: &str = "io.runner-manager.attempt";
27const GENERATION_LABEL: &str = "io.runner-manager.generation";
28const IMAGE_LABEL: &str = "io.runner-manager.image";
29const BOOTSTRAP: &str = "IFS= read -r jit || exit 43; export ACTIONS_RUNNER_INPUT_JITCONFIG=\"$jit\"; unset jit; cd /runner || exit 44; exec ./bin/Runner.Listener run";
30const OCI_RUNTIME_ENV: &str = "RUNNER_MANAGER_OCI_RUNTIME";
31const BOUNDED_STORAGE_SCHEMA: u64 = 1;
32const BOUNDED_STORAGE_MODE: &str = "exclusive-filesystem-pool";
33const QUOTA_PROBE_MIB: u64 = 1024;
34
35/// The Podman service socket a rootful engine listens on.
36pub const ROOTFUL_SOCKET: &str = "/run/podman/podman.sock";
37
38/// How many host ids each isolated container's user namespace gets with a
39/// rootful engine: enough for any image's accounts, and 32768 containers in
40/// the conventional `containers` subordinate range.
41const AUTO_USERNS_SIZE: u64 = 65536;
42
43/// The operator's isolation settings, in `config/isolation.json`.
44#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
45#[serde(default, deny_unknown_fields)]
46pub struct IsolationSettings {
47    /// Whether an isolated job must have a hard writable-layer quota. Only
48    /// XFS with project quotas, or an attested bounded-storage helper, can
49    /// enforce one; elsewhere `false` lets isolated jobs run without it, so a
50    /// job can fill the engine's storage.
51    pub disk_quota_required: bool,
52}
53
54impl Default for IsolationSettings {
55    fn default() -> Self {
56        Self {
57            disk_quota_required: true,
58        }
59    }
60}
61
62impl IsolationSettings {
63    /// The file in the configuration directory.
64    pub const FILE: &'static str = "isolation.json";
65
66    /// The settings in `config_dir`, or the defaults when there are none.
67    ///
68    /// # Errors
69    ///
70    /// When the file cannot be read or does not parse.
71    pub fn load(config_dir: &Path) -> std::io::Result<Self> {
72        runner_manager_platform::settings_file::load(&config_dir.join(Self::FILE))
73    }
74
75    /// Writes the settings into `config_dir`.
76    ///
77    /// # Errors
78    ///
79    /// When the file cannot be written.
80    pub fn save(&self, config_dir: &Path) -> std::io::Result<()> {
81        runner_manager_platform::settings_file::save(&config_dir.join(Self::FILE), self)
82    }
83}
84
85/// How the provider reaches Podman.
86#[derive(Debug, Clone, PartialEq, Eq)]
87pub enum Engine {
88    /// Podman run by this process's own unprivileged account.
89    Rootless,
90    /// The system's rootful Podman service, reached as a client through its
91    /// socket. A Linux boot service runs this way: its sandbox forbids the
92    /// namespaces rootless Podman creates, and every container instead gets a
93    /// user namespace of its own (`--userns=auto`), so its root is an
94    /// unprivileged host id no other container shares.
95    Rootful(PathBuf),
96}
97
98#[derive(Debug)]
99pub struct OciProcesses {
100    host: HostId,
101    native: NativeProcesses,
102    runtime: String,
103    runtime_configuration_valid: bool,
104    engine: Engine,
105    /// Whether an isolated job must have a hard writable-layer quota.
106    disk_quota_required: bool,
107    /// Where a rootful engine's subordinate id ranges are read from.
108    subordinate_ids: [PathBuf; 2],
109    attached: Mutex<BTreeMap<AttemptId, Child>>,
110}
111
112impl OciProcesses {
113    #[must_use]
114    pub fn new(host: HostId) -> Self {
115        let (runtime, runtime_configuration_valid) = match std::env::var(OCI_RUNTIME_ENV) {
116            Ok(runtime) if operator_runtime_is_trusted(Path::new(&runtime)) => (runtime, true),
117            Ok(_) => ("/runner-manager-invalid-oci-runtime".into(), false),
118            Err(std::env::VarError::NotPresent) => ("podman".into(), true),
119            Err(std::env::VarError::NotUnicode(_)) => {
120                ("/runner-manager-invalid-oci-runtime".into(), false)
121            }
122        };
123        Self {
124            host,
125            native: NativeProcesses::new(),
126            runtime,
127            runtime_configuration_valid,
128            engine: Self::engine_for_this_process(),
129            disk_quota_required: true,
130            subordinate_ids: [PathBuf::from("/etc/subuid"), PathBuf::from("/etc/subgid")],
131            attached: Mutex::new(BTreeMap::new()),
132        }
133    }
134
135    /// The engine this process reaches: the rootful service for `root` on
136    /// Linux, rootless Podman for anyone else.
137    #[must_use]
138    pub fn engine_for_this_process() -> Engine {
139        #[cfg(target_os = "linux")]
140        if runner_manager_platform::host_fitness::is_elevated() {
141            return Engine::Rootful(PathBuf::from(ROOTFUL_SOCKET));
142        }
143        Engine::Rootless
144    }
145
146    /// Applies `settings`.
147    pub fn apply(&mut self, settings: IsolationSettings) {
148        self.disk_quota_required = settings.disk_quota_required;
149    }
150
151    /// Whether this host can run isolated jobs at all, before any image.
152    #[must_use]
153    pub fn host_capability(&self) -> ProviderCapability {
154        self.ready()
155    }
156
157    fn command(&self) -> Command {
158        let mut command = Command::new(&self.runtime);
159        if let Engine::Rootful(socket) = &self.engine {
160            command
161                .arg("--remote")
162                .arg("--url")
163                .arg(format!("unix://{}", socket.display()));
164        }
165        command.stdin(Stdio::null()).stderr(Stdio::null());
166        command
167    }
168
169    fn output(&self, args: &[&str]) -> Result<String, FailureReason> {
170        let output = self
171            .command()
172            .args(args)
173            .output()
174            .map_err(|_| provider_failure())?;
175        if !output.status.success() || output.stdout.len() > 64 * 1024 {
176            return Err(provider_failure());
177        }
178        String::from_utf8(output.stdout).map_err(|_| provider_failure())
179    }
180
181    fn status(&self, args: &[&str]) -> Result<(), FailureReason> {
182        let status = self
183            .command()
184            .args(args)
185            .stdout(Stdio::null())
186            .status()
187            .map_err(|_| provider_failure())?;
188        if status.success() {
189            Ok(())
190        } else {
191            Err(provider_failure())
192        }
193    }
194
195    fn ready(&self) -> ProviderCapability {
196        self.ready_for(QUOTA_PROBE_MIB)
197    }
198
199    fn ready_for(&self, requested_mib: u64) -> ProviderCapability {
200        self.readiness(requested_mib)
201            .err()
202            .unwrap_or(ProviderCapability::Ready)
203    }
204
205    /// Whether a `requested_mib` writable-layer quota is enforced for isolated
206    /// jobs, or why they cannot run.
207    fn readiness(&self, requested_mib: u64) -> Result<bool, ProviderCapability> {
208        if !cfg!(target_os = "linux") {
209            return Err(ProviderCapability::Unsupported);
210        }
211        if !self.runtime_configuration_valid {
212            return Err(ProviderCapability::PermissionDenied);
213        }
214        let info = match self.command().args(["info", "--format=json"]).output() {
215            Ok(output) if output.status.success() && output.stdout.len() <= 64 * 1024 => {
216                match String::from_utf8(output.stdout) {
217                    Ok(info) => info,
218                    Err(_) => return Err(ProviderCapability::Degraded),
219                }
220            }
221            Ok(_) => return Err(ProviderCapability::PermissionDenied),
222            Err(error) if error.kind() == std::io::ErrorKind::NotFound => {
223                return Err(ProviderCapability::NotInstalled);
224            }
225            Err(_) => return Err(ProviderCapability::Degraded),
226        };
227        let Ok(info) = serde_json::from_str::<serde_json::Value>(&info) else {
228            return Err(ProviderCapability::Degraded);
229        };
230        let rootless = info
231            .pointer("/host/security/rootless")
232            .and_then(serde_json::Value::as_bool);
233        let rootful = matches!(self.engine, Engine::Rootful(_));
234        if rootless != Some(!rootful) {
235            return Err(ProviderCapability::PermissionDenied);
236        }
237        if rootful && !rootful_ranges_sufficient(&self.subordinate_ids) {
238            return Err(ProviderCapability::PermissionDenied);
239        }
240        let mappings = ["/host/idMappings/uidmap", "/host/idMappings/gidmap"];
241        if !rootful
242            && mappings.iter().any(|path| {
243                info.pointer(path)
244                    .and_then(serde_json::Value::as_array)
245                    .is_none_or(|ranges| {
246                        !ranges.iter().any(|range| {
247                            range
248                                .get("size")
249                                .and_then(serde_json::Value::as_u64)
250                                .is_some_and(|size| size >= 65536)
251                        })
252                    })
253            })
254        {
255            return Err(ProviderCapability::PermissionDenied);
256        }
257        if info
258            .pointer("/host/cgroupVersion")
259            .and_then(serde_json::Value::as_str)
260            != Some("v2")
261        {
262            return Err(ProviderCapability::Degraded);
263        }
264        let Some(controllers) = info
265            .pointer("/host/cgroupControllers")
266            .and_then(serde_json::Value::as_array)
267        else {
268            return Err(ProviderCapability::Degraded);
269        };
270        if ["cpu", "memory", "pids"].iter().any(|needed| {
271            !controllers
272                .iter()
273                .any(|controller| controller.as_str() == Some(needed))
274        }) {
275            return Err(ProviderCapability::Degraded);
276        }
277        let Some(graph_root) = info
278            .pointer("/store/graphRoot")
279            .and_then(serde_json::Value::as_str)
280        else {
281            return Err(ProviderCapability::Degraded);
282        };
283        let Some(run_root) = info
284            .pointer("/store/runRoot")
285            .and_then(serde_json::Value::as_str)
286        else {
287            return Err(ProviderCapability::Degraded);
288        };
289        if !graph_root.starts_with('/')
290            || !run_root.starts_with('/')
291            || graph_root.starts_with("/mnt/")
292            || run_root.starts_with("/mnt/")
293        {
294            return Err(ProviderCapability::PermissionDenied);
295        }
296        let backing_filesystem = info
297            .pointer("/store/graphStatus/Backing Filesystem")
298            .and_then(serde_json::Value::as_str);
299        let storage_size = format!("size={requested_mib}m");
300        let without_quota = if self.disk_quota_required {
301            Err(ProviderCapability::DiskQuotaUnavailable)
302        } else {
303            Ok(false)
304        };
305        let quota_probe =
306            match self.output(&["--storage-opt", &storage_size, "info", "--format=json"]) {
307                Ok(output) => output,
308                Err(_) => return without_quota,
309            };
310        // Native Podman writable-layer quotas require XFS project quotas. XFS
311        // alone is insufficient: rootless quota setup can still fail with
312        // EPERM, which the command above catches before allocation or JIT.
313        if backing_filesystem == Some("xfs") {
314            return Ok(true);
315        }
316        // Managed WSL can instead use an operator-provisioned runtime helper.
317        // The helper routes every `size=...` create into an exclusive finite
318        // filesystem and attests that contract in the quota-probe response.
319        // Plain Podman on extfs has no attestation and remains fail-closed.
320        let Ok(quota_info) = serde_json::from_str::<serde_json::Value>(&quota_probe) else {
321            return without_quota;
322        };
323        if bounded_storage_attested(&quota_info, graph_root, run_root, requested_mib) {
324            Ok(true)
325        } else {
326            without_quota
327        }
328    }
329
330    fn capability_or(&self, fallback: FailureReason) -> FailureReason {
331        let capability = self.ready();
332        if capability == ProviderCapability::Ready {
333            fallback
334        } else {
335            capability.refusal()
336        }
337    }
338
339    fn image_ready(&self, image: &ImageReference) -> Result<(), FailureReason> {
340        let Some((_, expected)) = image.as_str().rsplit_once("@sha256:") else {
341            return Err(image_failure());
342        };
343        self.status(&["pull", "--quiet", image.as_str()])
344            .map_err(|_| self.capability_or(image_failure()))?;
345        let digests = self
346            .output(&[
347                "image",
348                "inspect",
349                "--format",
350                "{{json .RepoDigests}}",
351                image.as_str(),
352            ])
353            .map_err(|_| self.capability_or(image_failure()))?;
354        let values: Vec<String> =
355            serde_json::from_str(digests.trim()).map_err(|_| image_failure())?;
356        if !values
357            .iter()
358            .any(|value| value.ends_with(&format!("@sha256:{expected}")))
359        {
360            return Err(image_failure());
361        }
362        Ok(())
363    }
364
365    fn isolated_identity(
366        &self,
367        attempt: &RunnerAttempt,
368        id: String,
369    ) -> Result<EnvironmentIdentity, FailureReason> {
370        let AttemptExecution::Isolated {
371            provider_kind: Backend::Oci,
372            resolved_image,
373            generation,
374            ..
375        } = attempt.execution()
376        else {
377            return Err(ownership_failure());
378        };
379        Ok(EnvironmentIdentity::Isolated {
380            host: self.host,
381            attempt: attempt.id,
382            provider_kind: Backend::Oci,
383            environment_id: id,
384            resolved_image: resolved_image.clone(),
385            generation: generation.clone(),
386        })
387    }
388
389    fn environment_id<'a>(&self, attempt: &'a RunnerAttempt) -> Result<&'a str, FailureReason> {
390        let AttemptExecution::Isolated {
391            provider_kind: Backend::Oci,
392            environment_id: Some(id),
393            ..
394        } = attempt.execution()
395        else {
396            return Err(ownership_failure());
397        };
398        Ok(id)
399    }
400
401    fn labels(&self, id: &str) -> Result<BTreeMap<String, String>, FailureReason> {
402        let labels = self.output(&[
403            "container",
404            "inspect",
405            "--format",
406            "{{json .Config.Labels}}",
407            id,
408        ])?;
409        serde_json::from_str(labels.trim()).map_err(|_| ownership_failure())
410    }
411
412    fn exists(&self, id: &str) -> Result<bool, FailureReason> {
413        let status = self
414            .command()
415            .args(["container", "exists", id])
416            .stdout(Stdio::null())
417            .status()
418            .map_err(|_| provider_failure())?;
419        match status.code() {
420            Some(0) => Ok(true),
421            Some(1) => Ok(false),
422            _ => Err(provider_failure()),
423        }
424    }
425
426    fn check_ownership(&self, attempt: &RunnerAttempt) -> Result<bool, FailureReason> {
427        let id = self.environment_id(attempt)?;
428        let labels = self.labels(id)?;
429        let AttemptExecution::Isolated { resolved_image, .. } = attempt.execution() else {
430            return Err(ownership_failure());
431        };
432        Ok(self.matches_attempt_labels(&labels, attempt)
433            && labels
434                .get(IMAGE_LABEL)
435                .is_some_and(|value| value == resolved_image.as_str()))
436    }
437
438    fn matches_attempt_labels(
439        &self,
440        labels: &BTreeMap<String, String>,
441        attempt: &RunnerAttempt,
442    ) -> bool {
443        let AttemptExecution::Isolated { generation, .. } = attempt.execution() else {
444            return false;
445        };
446        labels.get(HOST_LABEL) == Some(&self.host.to_string())
447            && labels.get(ATTEMPT_LABEL) == Some(&attempt.id.to_string())
448            && labels.get(GENERATION_LABEL) == Some(generation)
449    }
450
451    fn state(&self, attempt: &RunnerAttempt) -> Result<EnvironmentState, FailureReason> {
452        if !self.exists(self.environment_id(attempt)?)? {
453            return Ok(EnvironmentState::Missing);
454        }
455        if !self.check_ownership(attempt)? {
456            return Err(ownership_failure());
457        }
458        let status = self.output(&[
459            "container",
460            "inspect",
461            "--format",
462            "{{.State.Status}}",
463            self.environment_id(attempt)?,
464        ])?;
465        match status.trim() {
466            "running" => Ok(EnvironmentState::Running),
467            "created" | "configured" => Ok(EnvironmentState::Starting),
468            "exited" | "stopped" => Ok(EnvironmentState::Exited),
469            _ => Err(provider_failure()),
470        }
471    }
472
473    fn reap_attached(&self, attempt: AttemptId) {
474        let child = self
475            .attached
476            .lock()
477            .unwrap_or_else(std::sync::PoisonError::into_inner)
478            .remove(&attempt);
479        if let Some(mut child) = child {
480            if child.try_wait().ok().flatten().is_none() {
481                let _ = child.kill();
482            }
483            let _ = child.wait();
484        }
485    }
486}
487
488/// Whether `/etc/subuid` and `/etc/subgid` give the `containers` account the
489/// subordinate ids `--userns=auto` hands out: one namespace per concurrent
490/// container, for as many as the runner pool can run.
491fn rootful_ranges_sufficient(files: &[PathBuf]) -> bool {
492    let needed = containers_range_needed();
493    files.iter().all(|file| containers_range(file) >= needed)
494}
495
496/// How many subordinate ids the `containers` account needs for a rootful
497/// engine: one `--userns=auto` namespace for each runner the pool can run.
498#[must_use]
499pub fn containers_range_needed() -> u64 {
500    AUTO_USERNS_SIZE * u64::from(runner_manager_platform::runner_account::POOL_SIZE)
501}
502
503/// How many subordinate ids `file` (`/etc/subuid` or `/etc/subgid`) gives the
504/// `containers` account that `--userns=auto` allocates from; 0 when none.
505#[must_use]
506pub fn containers_range(file: &Path) -> u64 {
507    std::fs::read_to_string(file).map_or(0, |text| {
508        text.lines()
509            .filter_map(|line| line.strip_prefix("containers:"))
510            .filter_map(|range| range.split(':').nth(1)?.trim().parse::<u64>().ok())
511            .sum()
512    })
513}
514
515#[cfg(unix)]
516fn operator_runtime_is_trusted(path: &Path) -> bool {
517    use std::os::unix::fs::{MetadataExt, PermissionsExt};
518
519    if !path.is_absolute() {
520        return false;
521    }
522    let Ok(canonical) = std::fs::canonicalize(path) else {
523        return false;
524    };
525    if canonical == Path::new("/mnt") || canonical.starts_with("/mnt/") {
526        return false;
527    }
528    let Ok(metadata) = canonical.metadata() else {
529        return false;
530    };
531    let mode = metadata.permissions().mode();
532    metadata.is_file() && metadata.uid() == 0 && mode & 0o111 != 0 && mode & 0o022 == 0
533}
534
535#[cfg(not(unix))]
536fn operator_runtime_is_trusted(_path: &Path) -> bool {
537    false
538}
539
540fn bounded_storage_attested(
541    info: &serde_json::Value,
542    graph_root: &str,
543    run_root: &str,
544    requested_mib: u64,
545) -> bool {
546    info.pointer("/host/security/rootless")
547        .and_then(serde_json::Value::as_bool)
548        == Some(true)
549        && info
550            .pointer("/store/graphRoot")
551            .and_then(serde_json::Value::as_str)
552            == Some(graph_root)
553        && info
554            .pointer("/store/runRoot")
555            .and_then(serde_json::Value::as_str)
556            == Some(run_root)
557        && info
558            .pointer("/runnerManagerStorage/schema")
559            .and_then(serde_json::Value::as_u64)
560            == Some(BOUNDED_STORAGE_SCHEMA)
561        && info
562            .pointer("/runnerManagerStorage/mode")
563            .and_then(serde_json::Value::as_str)
564            == Some(BOUNDED_STORAGE_MODE)
565        && info
566            .pointer("/runnerManagerStorage/requestedMiB")
567            .and_then(serde_json::Value::as_u64)
568            == Some(requested_mib)
569        && info
570            .pointer("/runnerManagerStorage/hardCap")
571            .and_then(serde_json::Value::as_bool)
572            == Some(true)
573}
574
575fn provider_failure() -> FailureReason {
576    FailureReason::IsolationProvider(IsolationProviderFailure::RuntimeOperationFailed)
577}
578fn image_failure() -> FailureReason {
579    FailureReason::IsolationProvider(IsolationProviderFailure::ImageUnavailableOrIncompatible)
580}
581fn ownership_failure() -> FailureReason {
582    FailureReason::IsolationProvider(IsolationProviderFailure::OwnershipMismatch)
583}
584
585#[cfg(test)]
586fn retained_after_restart(state: EnvironmentState) -> bool {
587    matches!(state, EnvironmentState::Starting | EnvironmentState::Exited)
588}
589
590impl ExecutionProvider for OciProcesses {
591    fn probe(&self, policy: &ScalePolicy) -> ProviderCapability {
592        if policy.execution_policy().is_native() {
593            return ProviderCapability::Ready;
594        }
595        let ExecutionPolicy::Isolated {
596            backend,
597            image,
598            resources,
599        } = policy.execution_policy()
600        else {
601            return ProviderCapability::Unsupported;
602        };
603        if !matches!(backend, Backend::Auto | Backend::Oci) {
604            return ProviderCapability::Unsupported;
605        }
606        let capability = self.ready_for(u64::from(resources.disk_mib));
607        if capability != ProviderCapability::Ready {
608            return capability;
609        }
610        if self.image_ready(image).is_err() {
611            let capability = self.ready_for(u64::from(resources.disk_mib));
612            if capability == ProviderCapability::Ready {
613                ProviderCapability::ImageUnavailableOrIncompatible
614            } else {
615                capability
616            }
617        } else {
618            ProviderCapability::Ready
619        }
620    }
621
622    fn resolve(&self, policy: &ScalePolicy) -> Result<Option<ResolvedEnvironment>, FailureReason> {
623        let ExecutionPolicy::Isolated {
624            backend,
625            image,
626            resources,
627        } = policy.execution_policy()
628        else {
629            return Ok(None);
630        };
631        if !matches!(backend, Backend::Auto | Backend::Oci) {
632            return Err(ProviderCapability::Unsupported.refusal());
633        }
634        let capability = self.ready_for(u64::from(resources.disk_mib));
635        if capability != ProviderCapability::Ready {
636            return Err(capability.refusal());
637        }
638        self.image_ready(image)?;
639        Ok(Some(ResolvedEnvironment {
640            provider_kind: Backend::Oci,
641            image: image.clone(),
642        }))
643    }
644
645    fn prepare(
646        &self,
647        attempt: &RunnerAttempt,
648        policy: &ScalePolicy,
649    ) -> Result<PreparedEnvironment, FailureReason> {
650        let ExecutionPolicy::Isolated { resources, .. } = policy.execution_policy() else {
651            return ExecutionProvider::prepare(&self.native, attempt, policy);
652        };
653        let AttemptExecution::Isolated {
654            provider_kind: Backend::Oci,
655            resolved_image,
656            generation,
657            ..
658        } = attempt.execution()
659        else {
660            return Err(ownership_failure());
661        };
662        // Managed WSL must keep runner bytes in the distribution's Linux FS.
663        if attempt
664            .runtime_path()
665            .to_str()
666            .is_none_or(|path| !path.starts_with('/') || path.starts_with("/mnt/"))
667        {
668            return Err(FailureReason::IsolationProvider(
669                IsolationProviderFailure::UnsafeRuntimePath,
670            ));
671        }
672        let quota = self
673            .readiness(u64::from(resources.disk_mib))
674            .map_err(ProviderCapability::refusal)?;
675        let name = format!(
676            "rm-{}-{}",
677            attempt.id,
678            &generation[..generation.len().min(8)]
679        );
680        let cpu = format!("{:.3}", f64::from(resources.cpu_millis) / 1000.0);
681        let memory = format!("{}m", resources.memory_mib);
682        let disk = format!("size={}m", resources.disk_mib);
683        let host_label = format!("{HOST_LABEL}={}", self.host);
684        let attempt_label = format!("{ATTEMPT_LABEL}={}", attempt.id);
685        let generation_label = format!("{GENERATION_LABEL}={generation}");
686        let image_label = format!("{IMAGE_LABEL}={}", resolved_image.as_str());
687        let userns = match self.engine {
688            Engine::Rootless => "--userns=keep-id".to_owned(),
689            Engine::Rootful(_) => format!("--userns=auto:size={AUTO_USERNS_SIZE}"),
690        };
691        let mut arguments = vec![
692            "create",
693            "--interactive",
694            "--name",
695            &name,
696            "--pull=never",
697            "--entrypoint=sh",
698            "--log-driver=none",
699            "--image-volume=ignore",
700            "--http-proxy=false",
701            &userns,
702            "--user=0",
703            "--cap-drop=all",
704            "--cap-add=CHOWN",
705            "--cap-add=SETUID",
706            "--cap-add=SETGID",
707            "--cap-add=DAC_OVERRIDE",
708            "--cap-add=FOWNER",
709            "--security-opt=no-new-privileges",
710            "--network=slirp4netns",
711            "--pids-limit=128",
712            "--cpus",
713            &cpu,
714            "--memory",
715            &memory,
716            "--label",
717            &host_label,
718            "--label",
719            &attempt_label,
720            "--label",
721            &generation_label,
722            "--label",
723            &image_label,
724        ];
725        // Only where the storage enforces it: elsewhere `create` would fail,
726        // and running without one was the operator's explicit choice.
727        if quota {
728            arguments.extend(["--storage-opt", disk.as_str()]);
729        }
730        arguments.extend([resolved_image.as_str(), "-c", BOOTSTRAP]);
731        let id = self
732            .output(&arguments)
733            .map_err(|reason| self.capability_or(reason))?
734            .trim()
735            .to_owned();
736        if id.is_empty() || !id.bytes().all(|byte| byte.is_ascii_hexdigit()) {
737            return Err(self.capability_or(provider_failure()));
738        }
739        let identity = self.isolated_identity(attempt, id.clone())?;
740        // The package copy is host-to-guest only; no host path is mounted.
741        let source = format!("{}/.", attempt.runtime_path().display());
742        let destination = format!("{id}:/runner");
743        if self.status(&["cp", &source, &destination]).is_err() {
744            // Roll back before returning without a journalled environment ID.
745            // An unavailable runtime leaves a labelled orphan for enumeration.
746            if self
747                .labels(&id)
748                .is_ok_and(|labels| self.matches_attempt_labels(&labels, attempt))
749            {
750                let _ = self.status(&["rm", "--force", &id]);
751            }
752            return Err(self.capability_or(provider_failure()));
753        }
754        Ok(PreparedEnvironment::isolated(attempt.id, identity))
755    }
756
757    fn start(
758        &self,
759        prepared: PreparedEnvironment,
760        attempt: &RunnerAttempt,
761        handoff: OneTimeJitHandoff<'_>,
762    ) -> Result<EnvironmentIdentity, ProcessStartFailure> {
763        if attempt.execution().is_native() {
764            return ExecutionProvider::start(&self.native, prepared, attempt, handoff);
765        }
766        let Some(identity @ EnvironmentIdentity::Isolated { .. }) = prepared.identity().cloned()
767        else {
768            return Err(ProcessStartFailure::before_spawn(ownership_failure()));
769        };
770        let EnvironmentIdentity::Isolated { environment_id, .. } = &identity else {
771            unreachable!()
772        };
773        if self.environment_id(attempt).ok() != Some(environment_id.as_str())
774            || self.check_ownership(attempt) != Ok(true)
775        {
776            return Err(ProcessStartFailure::before_spawn(ownership_failure()));
777        }
778        let config = handoff.consume();
779        let payload = config.expose();
780        if payload.is_empty()
781            || payload.len() > 64 * 1024
782            || payload
783                .bytes()
784                .any(|byte| byte == b'\n' || byte == b'\r' || byte == 0)
785        {
786            return Err(ProcessStartFailure::before_spawn(
787                FailureReason::IsolationProvider(IsolationProviderFailure::JitHandoffRejected),
788            ));
789        }
790        let mut command = self.command();
791        let mut child = command
792            .args([
793                "start",
794                "--attach",
795                "--interactive",
796                "--sig-proxy=false",
797                environment_id,
798            ])
799            .stdin(Stdio::piped())
800            .stdout(Stdio::null())
801            .stderr(Stdio::null())
802            .spawn()
803            .map_err(|_| ProcessStartFailure::before_spawn(provider_failure()))?;
804        let write = child.stdin.take().is_some_and(|mut stdin| {
805            stdin.write_all(payload.as_bytes()).is_ok() && stdin.write_all(b"\n").is_ok()
806        });
807        if !write {
808            let _ = child.kill();
809            let _ = child.wait();
810            return Err(ProcessStartFailure::before_spawn(provider_failure()));
811        }
812        self.attached
813            .lock()
814            .unwrap_or_else(std::sync::PoisonError::into_inner)
815            .insert(attempt.id, child);
816        Ok(identity)
817    }
818
819    fn inspect(&self, attempt: &RunnerAttempt) -> Result<EnvironmentState, FailureReason> {
820        if attempt.execution().is_native() {
821            return ExecutionProvider::inspect(&self.native, attempt);
822        }
823        self.state(attempt)
824    }
825
826    fn recover(&self, attempt: &RunnerAttempt) -> Result<EnvironmentState, FailureReason> {
827        let AttemptExecution::Isolated {
828            environment_id: None,
829            generation,
830            ..
831        } = attempt.execution()
832        else {
833            return self.inspect(attempt);
834        };
835        let filters = [
836            format!("label={HOST_LABEL}={}", self.host),
837            format!("label={ATTEMPT_LABEL}={}", attempt.id),
838            format!("label={GENERATION_LABEL}={generation}"),
839        ];
840        let ids = self.output(&[
841            "ps",
842            "--all",
843            "--no-trunc",
844            "--filter",
845            &filters[0],
846            "--filter",
847            &filters[1],
848            "--filter",
849            &filters[2],
850            "--format",
851            "{{.ID}}",
852        ])?;
853        if ids.trim().is_empty() {
854            Ok(EnvironmentState::Missing)
855        } else {
856            Ok(EnvironmentState::Starting)
857        }
858    }
859
860    fn stop(&self, attempt: &RunnerAttempt) -> Result<ProviderStop, FailureReason> {
861        if attempt.execution().is_native() {
862            return ExecutionProvider::stop(&self.native, attempt);
863        }
864        if !self.exists(self.environment_id(attempt)?)? {
865            return Ok(ProviderStop::Stopped);
866        }
867        if self.state(attempt)? == EnvironmentState::Exited {
868            return Ok(ProviderStop::Stopped);
869        }
870        if !self.check_ownership(attempt)? {
871            return Err(ownership_failure());
872        }
873        self.status(&["stop", "--time=10", self.environment_id(attempt)?])
874            .map_err(|reason| self.capability_or(reason))?;
875        Ok(ProviderStop::Stopped)
876    }
877
878    fn destroy(&self, attempt: &RunnerAttempt) -> Result<ProviderDestroy, FailureReason> {
879        if attempt.execution().is_native() {
880            return ExecutionProvider::destroy(&self.native, attempt);
881        }
882        if !self.exists(self.environment_id(attempt)?)? {
883            self.reap_attached(attempt.id);
884            return Ok(ProviderDestroy::Destroyed);
885        }
886        if !self.check_ownership(attempt)? {
887            return Err(ownership_failure());
888        }
889        self.status(&["rm", "--force", self.environment_id(attempt)?])
890            .map_err(|reason| self.capability_or(reason))?;
891        self.reap_attached(attempt.id);
892        Ok(ProviderDestroy::Destroyed)
893    }
894
895    fn owns(&self, attempt: &RunnerAttempt) -> Result<bool, FailureReason> {
896        if attempt.execution().is_native() {
897            Ok(true)
898        } else {
899            if !self.exists(self.environment_id(attempt)?)? {
900                return Ok(false);
901            }
902            self.check_ownership(attempt)
903        }
904    }
905
906    fn enumerate_owned(&self, host: HostId) -> Vec<EnvironmentIdentity> {
907        // Recovery discovery must still work when a capability has degraded
908        // since creation. A failed readiness probe is not evidence of absence.
909        if host != self.host {
910            return Vec::new();
911        }
912        let filter = format!("label={HOST_LABEL}={host}");
913        let Ok(ids) = self.output(&[
914            "ps",
915            "--all",
916            "--no-trunc",
917            "--filter",
918            &filter,
919            "--format",
920            "{{.ID}}",
921        ]) else {
922            return Vec::new();
923        };
924        ids.lines()
925            .filter_map(|id| {
926                if id.is_empty() || !id.bytes().all(|byte| byte.is_ascii_hexdigit()) {
927                    return None;
928                }
929                let labels = self.labels(id).ok()?;
930                if labels.get(HOST_LABEL)? != &host.to_string() {
931                    return None;
932                }
933                let attempt =
934                    AttemptId::from_uuid(uuid::Uuid::parse_str(labels.get(ATTEMPT_LABEL)?).ok()?);
935                let generation = labels.get(GENERATION_LABEL)?.clone();
936                let resolved_image = ImageReference::new(labels.get(IMAGE_LABEL)?.clone()).ok()?;
937                Some(EnvironmentIdentity::Isolated {
938                    host,
939                    attempt,
940                    provider_kind: Backend::Oci,
941                    environment_id: id.to_owned(),
942                    resolved_image,
943                    generation,
944                })
945            })
946            .collect()
947    }
948
949    fn spawn(
950        &self,
951        attempt: &RunnerAttempt,
952        config: &EncodedJitConfig,
953    ) -> Result<u32, ProcessStartFailure> {
954        if !attempt.execution().is_native() {
955            return Err(ProcessStartFailure::before_spawn(ownership_failure()));
956        }
957        self.native.spawn(attempt, config)
958    }
959    fn is_alive(&self, attempt: &RunnerAttempt) -> Result<bool, FailureReason> {
960        if !attempt.execution().is_native() {
961            return Err(ownership_failure());
962        }
963        self.native.is_alive(attempt)
964    }
965    fn recovered_pid(&self, attempt: &RunnerAttempt) -> Result<Option<u32>, FailureReason> {
966        if !attempt.execution().is_native() {
967            return Err(ownership_failure());
968        }
969        self.native.recovered_pid(attempt)
970    }
971    fn completed_successfully(&self, attempt: &RunnerAttempt) -> bool {
972        if attempt.execution().is_native() {
973            return self.native.completed_successfully(attempt);
974        }
975        self.attached
976            .lock()
977            .unwrap_or_else(std::sync::PoisonError::into_inner)
978            .get_mut(&attempt.id)
979            .and_then(|child| child.try_wait().ok().flatten())
980            .is_some_and(|status| status.success())
981    }
982    fn record_terminate_intent(&self, attempt: &RunnerAttempt) -> Result<(), FailureReason> {
983        if !attempt.execution().is_native() {
984            return Err(ownership_failure());
985        }
986        self.native.record_terminate_intent(attempt)
987    }
988    fn has_terminate_intent(&self, attempt: &RunnerAttempt) -> bool {
989        attempt.execution().is_native() && self.native.has_terminate_intent(attempt)
990    }
991    fn terminate(&self, attempt: &RunnerAttempt) -> Result<(), FailureReason> {
992        if !attempt.execution().is_native() {
993            return Err(ownership_failure());
994        }
995        self.native.terminate(attempt)
996    }
997}
998
999#[cfg(test)]
1000mod boundary_tests {
1001    use super::*;
1002    use runner_manager_domain::model::Clock;
1003    use runner_manager_testkit::{clock::FakeClock, fixtures};
1004
1005    #[test]
1006    fn restart_recovery_accepts_only_retained_container_states() {
1007        assert!(retained_after_restart(EnvironmentState::Starting));
1008        assert!(retained_after_restart(EnvironmentState::Exited));
1009        assert!(!retained_after_restart(EnvironmentState::Running));
1010        assert!(!retained_after_restart(EnvironmentState::Missing));
1011    }
1012
1013    #[test]
1014    fn isolated_attempt_cannot_use_native_compatibility_methods() {
1015        let root = tempfile::tempdir().unwrap();
1016        let policy = fixtures::policy().build();
1017        let mut attempt = RunnerAttempt::allocate(
1018            AttemptId::new_random(),
1019            policy.id,
1020            root.path(),
1021            FakeClock::default().now(),
1022        );
1023        attempt
1024            .allocate_execution(AttemptExecution::Isolated {
1025                provider_kind: Backend::Oci,
1026                environment_id: None,
1027                resolved_image: ImageReference::new(format!(
1028                    "registry.example/runner@sha256:{}",
1029                    "a".repeat(64)
1030                ))
1031                .unwrap(),
1032                generation: "generation-1".into(),
1033            })
1034            .unwrap();
1035        let provider = OciProcesses::new(HostId::from_u128(1));
1036        let config = EncodedJitConfig::new("test-jit");
1037        let failure = provider.spawn(&attempt, &config).unwrap_err();
1038        assert_eq!(failure.reason, ownership_failure());
1039        assert!(failure.live_pid.is_none());
1040        assert_eq!(provider.is_alive(&attempt), Err(ownership_failure()));
1041        assert_eq!(provider.recovered_pid(&attempt), Err(ownership_failure()));
1042        assert_eq!(
1043            provider.record_terminate_intent(&attempt),
1044            Err(ownership_failure())
1045        );
1046        assert!(!provider.has_terminate_intent(&attempt));
1047        assert_eq!(provider.terminate(&attempt), Err(ownership_failure()));
1048    }
1049}
1050
1051#[cfg(all(test, target_os = "linux"))]
1052mod tests {
1053    use super::*;
1054    use runner_manager_domain::attempt::{AttemptOutcome, AttemptState};
1055    use runner_manager_domain::execution::ResourceLimits;
1056    use runner_manager_domain::model::Clock;
1057    use runner_manager_domain::store::{SqliteStore, Store};
1058    use runner_manager_testkit::{clock::FakeClock, fixtures};
1059    use std::io::Read;
1060    use std::os::unix::fs::PermissionsExt;
1061    use std::path::{Path, PathBuf};
1062    use std::time::{Duration, Instant};
1063
1064    /// Writes an executable script that a test then runs. Through `sh`, so
1065    /// this process never holds the file open for writing: a sibling test
1066    /// that forks meanwhile would carry that descriptor into its child until
1067    /// it execs, and running the script then fails with `ETXTBSY`.
1068    fn write_script(path: &Path, script: &str) {
1069        use std::io::Write as _;
1070        let mut child = std::process::Command::new("/bin/sh")
1071            .args(["-c", "cat > \"$1\" && chmod 700 \"$1\"", "sh"])
1072            .arg(path)
1073            .stdin(Stdio::piped())
1074            .spawn()
1075            .unwrap();
1076        child
1077            .stdin
1078            .take()
1079            .unwrap()
1080            .write_all(script.as_bytes())
1081            .unwrap();
1082        assert!(child.wait().unwrap().success(), "the script was written");
1083    }
1084
1085    fn fixture(info: &str) -> (tempfile::TempDir, OciProcesses) {
1086        fixture_with_quota(info, true)
1087    }
1088
1089    fn fixture_with_quota(info: &str, quota_ok: bool) -> (tempfile::TempDir, OciProcesses) {
1090        let root = tempfile::tempdir().unwrap();
1091        let path = root.path().join("podman");
1092        let quota_gate = if quota_ok {
1093            ""
1094        } else {
1095            "if [ \"$1\" = \"--storage-opt\" ]; then exit 1; fi\n"
1096        };
1097        let script = format!(
1098            "#!/bin/sh\n{quota_gate}printf '%s' '{}'\n",
1099            info.replace('\'', "'\\''")
1100        );
1101        write_script(&path, &script);
1102        let mut provider = OciProcesses::new(HostId::from_u128(1));
1103        provider.runtime = path.to_string_lossy().into_owned();
1104        (root, provider)
1105    }
1106
1107    fn info(rootless: bool, graph_root: &str, subuids: u64) -> String {
1108        serde_json::json!({
1109            "host": {
1110                "security": {"rootless": rootless},
1111                "cgroupVersion": "v2",
1112                "cgroupControllers": ["cpu", "memory", "pids"],
1113                "idMappings": {
1114                    "uidmap": [{"size": 1}, {"size": subuids}],
1115                    "gidmap": [{"size": 1}, {"size": subuids}]
1116                }
1117            },
1118            "store": {
1119                "graphRoot": graph_root,
1120                "runRoot": "/run/user/1000/containers",
1121                "graphStatus": {"Backing Filesystem": "xfs"}
1122            }
1123        })
1124        .to_string()
1125    }
1126
1127    /// A rootful-engine provider over the fixture runtime, with `range` ids
1128    /// in the `containers` subordinate ranges.
1129    fn rootful_fixture(
1130        info: &str,
1131        quota_ok: bool,
1132        range: u64,
1133    ) -> (tempfile::TempDir, OciProcesses) {
1134        let (root, mut provider) = fixture_with_quota(info, quota_ok);
1135        let ranges = root.path().join("subids");
1136        std::fs::write(
1137            &ranges,
1138            format!("ivan:100000:65536\ncontainers:2147483647:{range}\n"),
1139        )
1140        .unwrap();
1141        provider.subordinate_ids = [ranges.clone(), ranges];
1142        provider.engine = Engine::Rootful(PathBuf::from(ROOTFUL_SOCKET));
1143        (root, provider)
1144    }
1145
1146    #[test]
1147    fn a_rootful_engine_needs_rootful_podman_and_room_for_every_runner() {
1148        let rootful_info = info(false, "/var/lib/containers/storage", 1);
1149        let (_root, provider) = rootful_fixture(&rootful_info, true, 2_147_483_648);
1150        assert_eq!(provider.ready(), ProviderCapability::Ready);
1151        assert_eq!(provider.readiness(1024), Ok(true), "xfs enforces the quota");
1152
1153        let (_root, provider) = rootful_fixture(&rootful_info, true, 65_536);
1154        assert_eq!(
1155            provider.ready(),
1156            ProviderCapability::PermissionDenied,
1157            "one namespace's worth of ids cannot hold a namespace per runner"
1158        );
1159
1160        let rootless_info = info(true, "/home/ivan/.local/share/containers", 65536);
1161        let (_root, provider) = rootful_fixture(&rootless_info, true, 2_147_483_648);
1162        assert_eq!(provider.ready(), ProviderCapability::PermissionDenied);
1163    }
1164
1165    #[test]
1166    fn storage_without_a_quota_is_refused_unless_the_operator_accepted_it() {
1167        let ext4 = info(false, "/var/lib/containers/storage", 1).replace("xfs", "extfs");
1168        let (_root, provider) = rootful_fixture(&ext4, false, 2_147_483_648);
1169        assert_eq!(provider.ready(), ProviderCapability::DiskQuotaUnavailable);
1170        let mut provider = provider;
1171        provider.apply(IsolationSettings {
1172            disk_quota_required: false,
1173        });
1174        assert_eq!(provider.readiness(1024), Ok(false));
1175    }
1176
1177    #[test]
1178    fn rootless_probe_refuses_rootful_and_drvfs_storage() {
1179        let (_root, provider) = fixture(&info(false, "/home/ivan/.local/share/containers", 65536));
1180        assert_eq!(provider.ready(), ProviderCapability::PermissionDenied);
1181        let (_root, provider) = fixture(&info(true, "/mnt/c/containers", 65536));
1182        assert_eq!(provider.ready(), ProviderCapability::PermissionDenied);
1183        let (_root, provider) = fixture(&info(true, "/home/ivan/.local/share/containers", 1));
1184        assert_eq!(provider.ready(), ProviderCapability::PermissionDenied);
1185        let (_root, provider) = fixture(&info(true, "/home/ivan/.local/share/containers", 65536));
1186        assert_eq!(provider.ready(), ProviderCapability::Ready);
1187        let no_pids = info(true, "/home/ivan/.local/share/containers", 65536)
1188            .replace("\"pids\"", "\"missing\"");
1189        let (_root, provider) = fixture(&no_pids);
1190        assert_eq!(provider.ready(), ProviderCapability::Degraded);
1191        let extfs = info(true, "/home/ivan/.local/share/containers", 65536).replace(
1192            "\"Backing Filesystem\":\"xfs\"",
1193            "\"Backing Filesystem\":\"extfs\"",
1194        );
1195        let (_root, provider) = fixture(&extfs);
1196        assert_eq!(provider.ready(), ProviderCapability::DiskQuotaUnavailable);
1197        let mut bounded: serde_json::Value = serde_json::from_str(&extfs).unwrap();
1198        bounded["runnerManagerStorage"] = serde_json::json!({
1199            "schema": BOUNDED_STORAGE_SCHEMA,
1200            "mode": BOUNDED_STORAGE_MODE,
1201            "requestedMiB": QUOTA_PROBE_MIB,
1202            "hardCap": true
1203        });
1204        let (_root, provider) = fixture(&bounded.to_string());
1205        assert_eq!(provider.ready(), ProviderCapability::Ready);
1206        assert_eq!(
1207            provider.ready_for(2048),
1208            ProviderCapability::DiskQuotaUnavailable,
1209            "the helper must attest the exact requested size"
1210        );
1211        bounded["runnerManagerStorage"]["hardCap"] = serde_json::json!(false);
1212        let (_root, provider) = fixture(&bounded.to_string());
1213        assert_eq!(provider.ready(), ProviderCapability::DiskQuotaUnavailable);
1214        let (_root, provider) = fixture_with_quota(
1215            &info(true, "/home/ivan/.local/share/containers", 65536),
1216            false,
1217        );
1218        assert_eq!(provider.ready(), ProviderCapability::DiskQuotaUnavailable);
1219    }
1220
1221    #[test]
1222    fn missing_operator_runtime_is_not_treated_as_ready() {
1223        let mut provider = OciProcesses::new(HostId::from_u128(1));
1224        provider.runtime = "/a/path/that/cannot/be/podman".into();
1225        assert_eq!(provider.ready(), ProviderCapability::NotInstalled);
1226    }
1227
1228    #[test]
1229    fn recovery_discovers_owned_container_when_capability_degrades() {
1230        let root = tempfile::tempdir().unwrap();
1231        let path = root.path().join("podman");
1232        let host = HostId::from_u128(1);
1233        let attempt = AttemptId::new_random();
1234        let generation = "generation-1";
1235        let image = format!("registry.example/runner@sha256:{}", "a".repeat(64));
1236        let id = "b".repeat(64);
1237        let degraded_info = info(true, "/home/ivan/.local/share/containers", 65536)
1238            .replace("\"pids\"", "\"missing\"");
1239        let labels = BTreeMap::from([
1240            (HOST_LABEL, host.to_string()),
1241            (ATTEMPT_LABEL, attempt.to_string()),
1242            (GENERATION_LABEL, generation.to_owned()),
1243            (IMAGE_LABEL, image),
1244        ]);
1245        let labels = serde_json::to_string(&labels).unwrap();
1246        let script = format!(
1247            "#!/bin/sh\ncase \"$1\" in\n  info) printf '%s' '{degraded_info}' ;;\n  ps) printf '%s\\n' '{id}' ;;\n  container) printf '%s' '{labels}' ;;\n  *) exit 1 ;;\nesac\n"
1248        );
1249        write_script(&path, &script);
1250        let mut provider = OciProcesses::new(host);
1251        provider.runtime = path.to_string_lossy().into_owned();
1252        assert_eq!(provider.ready(), ProviderCapability::Degraded);
1253        let owned = provider.enumerate_owned(host);
1254        assert_eq!(owned.len(), 1);
1255        assert!(matches!(
1256            &owned[0],
1257            EnvironmentIdentity::Isolated {
1258                host: found_host,
1259                attempt: found_attempt,
1260                environment_id,
1261                generation: found_generation,
1262                ..
1263            } if *found_host == host && *found_attempt == attempt
1264                && environment_id == &id && found_generation == generation
1265        ));
1266    }
1267
1268    #[test]
1269    fn native_start_still_delegates_to_the_existing_process_supervisor() {
1270        let root = tempfile::tempdir().unwrap();
1271        let runtime = root.path().join("runtime");
1272        let bin = runtime.join("bin");
1273        std::fs::create_dir_all(&bin).unwrap();
1274        let listener = bin.join("Runner.Listener");
1275        write_script(&listener, "#!/bin/sh\nsleep 10\n");
1276        let policy = fixtures::policy()
1277            .repository("octo/repo")
1278            .autoscale("home", 1)
1279            .active()
1280            .build();
1281        let attempt = RunnerAttempt::allocate(
1282            AttemptId::new_random(),
1283            policy.id,
1284            &runtime,
1285            FakeClock::default().now(),
1286        );
1287        let provider = OciProcesses::new(HostId::from_u128(1));
1288        let prepared = provider.prepare(&attempt, &policy).unwrap();
1289        let config = EncodedJitConfig::new("native-test-jit");
1290        let identity = provider
1291            .start(prepared, &attempt, OneTimeJitHandoff::new(&config))
1292            .unwrap();
1293        assert!(matches!(identity, EnvironmentIdentity::NativeProcess(_)));
1294        provider.stop(&attempt).unwrap();
1295    }
1296
1297    const LIVE_IMAGE_ENV: &str = "RUNNER_MANAGER_OCI_ACCEPTANCE_IMAGE";
1298    const LIVE_HOST: u128 = 0x00ac_ce7a_ce00_0000_0000_0000_0000_0001;
1299    const LIVE_PACKAGE: &str = "runner-manager-oci-conflict";
1300
1301    fn live_runtime() -> String {
1302        std::env::var(OCI_RUNTIME_ENV).unwrap_or_else(|_| "podman".into())
1303    }
1304
1305    fn podman(args: &[&str]) -> std::process::Output {
1306        Command::new(live_runtime())
1307            .args(args)
1308            .output()
1309            .unwrap_or_else(|error| panic!("podman {args:?} did not execute: {error}"))
1310    }
1311
1312    fn podman_ok(args: &[&str]) -> String {
1313        let output = podman(args);
1314        assert!(
1315            output.status.success(),
1316            "podman {args:?} failed: {}",
1317            String::from_utf8_lossy(&output.stderr)
1318        );
1319        String::from_utf8(output.stdout).expect("podman emitted UTF-8")
1320    }
1321
1322    fn write_listener(runtime: &Path) {
1323        let bin = runtime.join("bin");
1324        std::fs::create_dir_all(&bin).unwrap();
1325        let listener = bin.join("Runner.Listener");
1326        // The real runner consumes the same environment variable. This fixture
1327        // proves the provider's stdin-to-bootstrap handoff, then removes it
1328        // before the long-lived child exists so the sentinel is not retained
1329        // in the process environment or writable layer.
1330        write_script(
1331            &listener,
1332            "#!/bin/sh\n\
1333             test -n \"${ACTIONS_RUNNER_INPUT_JITCONFIG:-}\" || exit 70\n\
1334             unset ACTIONS_RUNNER_INPUT_JITCONFIG\n\
1335             : > /tmp/runner-manager-jit-received\n\
1336             exec sleep 600\n",
1337        );
1338    }
1339
1340    fn build_conflicting_package(runtime: &Path, version: &str) -> PathBuf {
1341        let root = runtime.join(format!("package-{version}"));
1342        let debian = root.join("DEBIAN");
1343        let bin = root.join("usr/local/bin");
1344        std::fs::create_dir_all(&debian).unwrap();
1345        std::fs::create_dir_all(&bin).unwrap();
1346        std::fs::write(
1347            debian.join("control"),
1348            format!(
1349                "Package: {LIVE_PACKAGE}\nVersion: {version}\nArchitecture: all\n\
1350                 Maintainer: Runner Manager acceptance <nobody@example.invalid>\n\
1351                 Description: isolated package database acceptance fixture\n"
1352            ),
1353        )
1354        .unwrap();
1355        let tool = bin.join("runner-manager-conflict");
1356        std::fs::write(&tool, format!("#!/bin/sh\nprintf '%s\\n' '{version}'\n")).unwrap();
1357        std::fs::set_permissions(&tool, std::fs::Permissions::from_mode(0o755)).unwrap();
1358        let package = runtime.join("conflict.deb");
1359        let output = Command::new("dpkg-deb")
1360            .args(["--build", "--root-owner-group"])
1361            .arg(&root)
1362            .arg(&package)
1363            .output()
1364            .expect("dpkg-deb must be installed by the native acceptance job");
1365        assert!(
1366            output.status.success(),
1367            "dpkg-deb failed: {}",
1368            String::from_utf8_lossy(&output.stderr)
1369        );
1370        package
1371    }
1372
1373    fn isolated_policy(image: ImageReference) -> ScalePolicy {
1374        let mut policy = fixtures::policy()
1375            .repository("octo/oci-native-acceptance")
1376            .autoscale("isolated", 3)
1377            .active()
1378            .build();
1379        policy
1380            .set_execution_policy(ExecutionPolicy::Isolated {
1381                backend: Backend::Oci,
1382                image,
1383                resources: ResourceLimits {
1384                    cpu_millis: 1000,
1385                    memory_mib: 512,
1386                    disk_mib: 1024,
1387                },
1388            })
1389            .unwrap();
1390        policy
1391    }
1392
1393    fn prepare_live_attempt(
1394        provider: &OciProcesses,
1395        policy: &ScalePolicy,
1396        image: &ImageReference,
1397        version: Option<&str>,
1398        generation: &str,
1399        production_quota_path: bool,
1400    ) -> (
1401        tempfile::TempDir,
1402        RunnerAttempt,
1403        PreparedEnvironment,
1404        String,
1405    ) {
1406        let runtime = tempfile::tempdir().unwrap();
1407        write_listener(runtime.path());
1408        if let Some(version) = version {
1409            build_conflicting_package(runtime.path(), version);
1410        }
1411        let mut attempt = RunnerAttempt::allocate(
1412            AttemptId::new_random(),
1413            policy.id,
1414            runtime.path(),
1415            FakeClock::default().now(),
1416        );
1417        attempt
1418            .allocate_execution(AttemptExecution::Isolated {
1419                provider_kind: Backend::Oci,
1420                environment_id: None,
1421                resolved_image: image.clone(),
1422                generation: generation.into(),
1423            })
1424            .unwrap();
1425        if production_quota_path {
1426            let prepared = provider.prepare(&attempt, policy).unwrap();
1427            let EnvironmentIdentity::Isolated { environment_id, .. } =
1428                prepared.identity().cloned().expect("isolated identity")
1429            else {
1430                panic!("OCI prepare returned a native identity")
1431            };
1432            return (runtime, attempt, prepared, environment_id);
1433        }
1434        let ExecutionPolicy::Isolated { resources, .. } = policy.execution_policy() else {
1435            panic!("structural acceptance needs an isolated policy")
1436        };
1437        let name = format!("rm-{}-{}", attempt.id, generation);
1438        let cpu = format!("{:.3}", f64::from(resources.cpu_millis) / 1000.0);
1439        let memory = format!("{}m", resources.memory_mib);
1440        let host_label = format!("{HOST_LABEL}={}", provider.host);
1441        let attempt_label = format!("{ATTEMPT_LABEL}={}", attempt.id);
1442        let generation_label = format!("{GENERATION_LABEL}={generation}");
1443        let image_label = format!("{IMAGE_LABEL}={}", image.as_str());
1444        // Rootless Podman cannot initialize XFS project quotas because project
1445        // IDs are not namespaced and an unprivileged user cannot create the
1446        // backing block-device node. The acceptance first proves the production
1447        // provider refuses that missing hard cap. This second, explicitly
1448        // structural fixture omits only --storage-opt so the remaining real
1449        // container boundary can still be measured on the hard-bounded loop FS.
1450        let environment_id = provider
1451            .output(&[
1452                "create",
1453                "--interactive",
1454                "--name",
1455                &name,
1456                "--pull=never",
1457                "--entrypoint=sh",
1458                "--log-driver=none",
1459                "--image-volume=ignore",
1460                "--http-proxy=false",
1461                "--userns=keep-id",
1462                "--user=0",
1463                "--cap-drop=all",
1464                "--cap-add=CHOWN",
1465                "--cap-add=SETUID",
1466                "--cap-add=SETGID",
1467                "--cap-add=DAC_OVERRIDE",
1468                "--cap-add=FOWNER",
1469                "--security-opt=no-new-privileges",
1470                "--network=slirp4netns",
1471                "--pids-limit=128",
1472                "--cpus",
1473                &cpu,
1474                "--memory",
1475                &memory,
1476                "--label",
1477                &host_label,
1478                "--label",
1479                &attempt_label,
1480                "--label",
1481                &generation_label,
1482                "--label",
1483                &image_label,
1484                image.as_str(),
1485                "-c",
1486                BOOTSTRAP,
1487            ])
1488            .unwrap()
1489            .trim()
1490            .to_owned();
1491        assert!(!environment_id.is_empty());
1492        let source = format!("{}/.", attempt.runtime_path().display());
1493        let destination = format!("{environment_id}:/runner");
1494        provider.status(&["cp", &source, &destination]).unwrap();
1495        let identity = provider
1496            .isolated_identity(&attempt, environment_id.clone())
1497            .unwrap();
1498        let prepared = PreparedEnvironment::isolated(attempt.id, identity);
1499        (runtime, attempt, prepared, environment_id)
1500    }
1501
1502    fn start_live_attempt(
1503        provider: &OciProcesses,
1504        attempt: &mut RunnerAttempt,
1505        prepared: PreparedEnvironment,
1506        environment_id: &str,
1507        sentinel: &str,
1508    ) {
1509        attempt
1510            .prepared_environment(environment_id.to_owned())
1511            .unwrap();
1512        let config = EncodedJitConfig::new(sentinel);
1513        provider
1514            .start(prepared, attempt, OneTimeJitHandoff::new(&config))
1515            .unwrap();
1516        let deadline = Instant::now() + Duration::from_secs(20);
1517        loop {
1518            let received = podman(&[
1519                "exec",
1520                environment_id,
1521                "test",
1522                "-f",
1523                "/tmp/runner-manager-jit-received",
1524            ]);
1525            if received.status.success() {
1526                break;
1527            }
1528            assert!(Instant::now() < deadline, "listener did not consume JIT");
1529            std::thread::sleep(Duration::from_millis(100));
1530        }
1531    }
1532
1533    fn inspect_controls(environment_id: &str, image: &ImageReference, sentinel: &str) {
1534        let raw = podman_ok(&["container", "inspect", environment_id]);
1535        assert!(
1536            !raw.contains(sentinel),
1537            "JIT sentinel reached inspect metadata"
1538        );
1539        assert!(!raw.contains("/run/podman/podman.sock"));
1540        assert!(!raw.contains("/var/run/docker.sock"));
1541        let inspect: serde_json::Value = serde_json::from_str(&raw).unwrap();
1542        let item = &inspect[0];
1543        assert_eq!(
1544            item.pointer("/Mounts")
1545                .and_then(|v| v.as_array())
1546                .map(Vec::len),
1547            Some(0)
1548        );
1549        assert!(
1550            item.pointer("/HostConfig/Devices")
1551                .is_none_or(|value| value.is_null() || value.as_array().is_some_and(Vec::is_empty)),
1552            "host devices were attached: {}",
1553            item["HostConfig"]["Devices"]
1554        );
1555        assert!(
1556            item.pointer("/HostConfig/Binds")
1557                .is_none_or(|value| value.is_null() || value.as_array().is_some_and(Vec::is_empty)),
1558            "host bind mounts were attached: {}",
1559            item["HostConfig"]["Binds"]
1560        );
1561        assert_eq!(item["HostConfig"]["PidsLimit"].as_i64(), Some(128));
1562        assert_eq!(
1563            item["HostConfig"]["Memory"].as_u64(),
1564            Some(512 * 1024 * 1024)
1565        );
1566        assert_eq!(
1567            item["Config"]["Labels"][IMAGE_LABEL].as_str(),
1568            Some(image.as_str())
1569        );
1570        let command = item["Config"]["CreateCommand"]
1571            .as_array()
1572            .expect("Podman records create command");
1573        for forbidden in ["--device", "--mount", "--volume", "-v", "--privileged"] {
1574            assert!(
1575                !command.iter().any(|part| part.as_str() == Some(forbidden)),
1576                "forbidden create option {forbidden} was present"
1577            );
1578        }
1579        let socket_check = podman(&[
1580            "exec",
1581            environment_id,
1582            "sh",
1583            "-c",
1584            "test ! -S /run/podman/podman.sock && test ! -S /var/run/docker.sock",
1585        ]);
1586        assert!(
1587            socket_check.status.success(),
1588            "a host runtime socket is visible"
1589        );
1590        let logs = podman(&["logs", environment_id]);
1591        assert!(!String::from_utf8_lossy(&logs.stdout).contains(sentinel));
1592        assert!(!String::from_utf8_lossy(&logs.stderr).contains(sentinel));
1593        let history = podman_ok(&["history", "--no-trunc", image.as_str()]);
1594        assert!(
1595            !history.contains(sentinel),
1596            "JIT sentinel reached image history"
1597        );
1598    }
1599
1600    fn assert_export_excludes(environment_id: &str, sentinel: &str) {
1601        let mut child = Command::new(live_runtime())
1602            .args(["export", environment_id])
1603            .stdout(Stdio::piped())
1604            .stderr(Stdio::piped())
1605            .spawn()
1606            .expect("podman export starts");
1607        let mut bytes = Vec::new();
1608        child
1609            .stdout
1610            .take()
1611            .unwrap()
1612            .read_to_end(&mut bytes)
1613            .unwrap();
1614        let output = child.wait_with_output().unwrap();
1615        assert!(
1616            output.status.success(),
1617            "podman export failed: {}",
1618            String::from_utf8_lossy(&output.stderr)
1619        );
1620        assert!(
1621            !bytes
1622                .windows(sentinel.len())
1623                .any(|window| window == sentinel.as_bytes()),
1624            "JIT sentinel reached the container root filesystem"
1625        );
1626    }
1627
1628    fn wait_for_container_file(environment_id: &str, path: &str, deadline: Instant) -> bool {
1629        loop {
1630            if podman(&["exec", environment_id, "test", "-f", path])
1631                .status
1632                .success()
1633            {
1634                return true;
1635            }
1636            if Instant::now() >= deadline {
1637                return false;
1638            }
1639            std::thread::sleep(Duration::from_millis(250));
1640        }
1641    }
1642
1643    fn assert_live_jit_absent(environment_id: &str, image: &ImageReference, jit: &str) {
1644        let inspect = podman_ok(&["container", "inspect", environment_id]);
1645        assert!(
1646            !inspect.contains(jit),
1647            "JIT reached container inspect metadata"
1648        );
1649
1650        let top = podman(&["top", environment_id, "pid,args"]);
1651        assert!(top.status.success(), "container process listing failed");
1652        assert!(
1653            !top.stdout
1654                .windows(jit.len())
1655                .any(|window| window == jit.as_bytes()),
1656            "JIT reached the container process listing"
1657        );
1658
1659        let logs = podman(&["logs", environment_id]);
1660        assert!(
1661            !logs
1662                .stdout
1663                .windows(jit.len())
1664                .any(|window| window == jit.as_bytes())
1665        );
1666        assert!(
1667            !logs
1668                .stderr
1669                .windows(jit.len())
1670                .any(|window| window == jit.as_bytes())
1671        );
1672        let history = podman_ok(&["history", "--no-trunc", image.as_str()]);
1673        assert!(!history.contains(jit), "JIT reached image history");
1674    }
1675
1676    fn stop_and_destroy(provider: &OciProcesses, attempt: &RunnerAttempt) {
1677        assert_eq!(provider.stop(attempt).unwrap(), ProviderStop::Stopped);
1678        assert_eq!(
1679            provider.destroy(attempt).unwrap(),
1680            ProviderDestroy::Destroyed
1681        );
1682        assert_eq!(
1683            provider.inspect(attempt).unwrap(),
1684            EnvironmentState::Missing
1685        );
1686    }
1687
1688    fn run_live_rootless_acceptance(production_quota_path: bool) {
1689        let image = ImageReference::new(
1690            std::env::var(LIVE_IMAGE_ENV)
1691                .unwrap_or_else(|_| panic!("{LIVE_IMAGE_ENV} must name a pinned image digest")),
1692        )
1693        .unwrap();
1694        let provider = OciProcesses::new(HostId::from_u128(LIVE_HOST));
1695        let policy = isolated_policy(image.clone());
1696
1697        if production_quota_path {
1698            assert_eq!(provider.probe(&policy), ProviderCapability::Ready);
1699            let resolution = provider.resolve(&policy).unwrap().unwrap();
1700            assert_eq!(resolution.provider_kind, Backend::Oci);
1701            assert_eq!(resolution.image, image);
1702        } else {
1703            assert_eq!(
1704                provider.probe(&policy),
1705                ProviderCapability::DiskQuotaUnavailable
1706            );
1707            assert_eq!(
1708                provider.resolve(&policy),
1709                Err(ProviderCapability::DiskQuotaUnavailable.refusal()),
1710                "the production provider must fail before image allocation or JIT"
1711            );
1712            provider.image_ready(&image).unwrap();
1713        }
1714        assert!(
1715            !Command::new("dpkg-query")
1716                .args(["-W", LIVE_PACKAGE])
1717                .status()
1718                .unwrap()
1719                .success(),
1720            "acceptance package unexpectedly exists on the host"
1721        );
1722
1723        let (_runtime_one, mut one, prepared_one, id_one) = prepare_live_attempt(
1724            &provider,
1725            &policy,
1726            &image,
1727            Some("1.0"),
1728            "native-one",
1729            production_quota_path,
1730        );
1731        let sentinel_one = "rm-jit-sentinel-native-one-8eeb09c26b8a";
1732        start_live_attempt(&provider, &mut one, prepared_one, &id_one, sentinel_one);
1733        inspect_controls(&id_one, &image, sentinel_one);
1734        podman_ok(&["exec", &id_one, "dpkg", "-i", "/runner/conflict.deb"]);
1735        assert_eq!(
1736            podman_ok(&[
1737                "exec",
1738                &id_one,
1739                "dpkg-query",
1740                "-W",
1741                "-f=${Version}",
1742                LIVE_PACKAGE,
1743            ])
1744            .trim(),
1745            "1.0"
1746        );
1747        assert_eq!(
1748            podman_ok(&["exec", &id_one, "/usr/local/bin/runner-manager-conflict"]).trim(),
1749            "1.0"
1750        );
1751
1752        let (_runtime_two, mut two, prepared_two, id_two) = prepare_live_attempt(
1753            &provider,
1754            &policy,
1755            &image,
1756            Some("2.0"),
1757            "native-two",
1758            production_quota_path,
1759        );
1760        let sentinel_two = "rm-jit-sentinel-native-two-62b2db40633b";
1761        start_live_attempt(&provider, &mut two, prepared_two, &id_two, sentinel_two);
1762        inspect_controls(&id_two, &image, sentinel_two);
1763        podman_ok(&["exec", &id_two, "dpkg", "-i", "/runner/conflict.deb"]);
1764        assert_eq!(
1765            podman_ok(&[
1766                "exec",
1767                &id_two,
1768                "dpkg-query",
1769                "-W",
1770                "-f=${Version}",
1771                LIVE_PACKAGE,
1772            ])
1773            .trim(),
1774            "2.0"
1775        );
1776        assert_eq!(
1777            podman_ok(&["exec", &id_one, "/usr/local/bin/runner-manager-conflict"]).trim(),
1778            "1.0",
1779            "the second package install mutated its sibling"
1780        );
1781        assert_export_excludes(&id_one, sentinel_one);
1782        assert_export_excludes(&id_two, sentinel_two);
1783
1784        // Prove the loopback store's hard upper bound with real writes. A
1785        // fallocate-only probe may fail because an overlay does not implement
1786        // allocation, which says nothing about the backing filesystem's cap.
1787        // `dd` writes zeroes through fuse-overlayfs until ext4/XFS returns
1788        // ENOSPC; `df` records that the same filesystem reached zero available
1789        // bytes. Remove the partial file before Podman needs to journal the
1790        // process exit or destroy the container.
1791        let oversize_mib = if production_quota_path {
1792            "1100"
1793        } else {
1794            "7168"
1795        };
1796        let exhaustion = podman(&[
1797            "exec",
1798            &id_one,
1799            "sh",
1800            "-c",
1801            "set +e; printf 'RM_FS_BEFORE '; df -B1 --output=size,used,avail / | tail -n 1; dd if=/dev/zero of=/backing-store-cap bs=1M count=\"$1\" conv=fsync status=none; status=$?; printf 'RM_FS_FULL '; df -B1 --output=size,used,avail / | tail -n 1; rm -f /backing-store-cap; sync; exit \"$status\"",
1802            "sh",
1803            oversize_mib,
1804        ]);
1805        assert!(
1806            !exhaustion.status.success(),
1807            "the backing-store hard cap allowed an oversized allocation"
1808        );
1809        let exhaustion_stdout = String::from_utf8(exhaustion.stdout).unwrap();
1810        let exhaustion_stderr = String::from_utf8(exhaustion.stderr).unwrap();
1811        assert!(
1812            exhaustion_stderr.contains("No space left on device"),
1813            "the write failed without ENOSPC: {exhaustion_stderr}"
1814        );
1815        let df_line = |marker: &str| {
1816            exhaustion_stdout
1817                .lines()
1818                .find_map(|line| line.strip_prefix(marker))
1819                .map(|line| {
1820                    line.split_whitespace()
1821                        .map(|value| value.parse().expect("df emitted integer byte counts"))
1822                        .collect::<Vec<u64>>()
1823                })
1824                .unwrap_or_else(|| {
1825                    panic!("the write did not emit {marker:?} df evidence: {exhaustion_stdout}")
1826                })
1827        };
1828        let before = df_line("RM_FS_BEFORE ");
1829        let full = df_line("RM_FS_FULL ");
1830        assert_eq!(
1831            before.len(),
1832            3,
1833            "unexpected initial df evidence: {before:?}"
1834        );
1835        assert_eq!(full.len(), 3, "unexpected full df evidence: {full:?}");
1836        assert!(
1837            full[1] >= before[1] + 512 * 1024 * 1024,
1838            "the ENOSPC probe did not perform substantial writes: before={before:?} full={full:?}"
1839        );
1840        assert!(
1841            full[2] <= 1024 * 1024,
1842            "ENOSPC left more than 1 MiB available: {full:?}"
1843        );
1844        stop_and_destroy(&provider, &one);
1845        stop_and_destroy(&provider, &two);
1846
1847        let (_runtime_fresh, mut fresh, prepared_fresh, id_fresh) = prepare_live_attempt(
1848            &provider,
1849            &policy,
1850            &image,
1851            None,
1852            "native-fresh",
1853            production_quota_path,
1854        );
1855        start_live_attempt(
1856            &provider,
1857            &mut fresh,
1858            prepared_fresh,
1859            &id_fresh,
1860            "rm-jit-sentinel-native-fresh-c1b9f470d09e",
1861        );
1862        assert!(
1863            !podman(&["exec", &id_fresh, "dpkg-query", "-W", LIVE_PACKAGE])
1864                .status
1865                .success(),
1866            "a fresh sibling inherited the earlier package database"
1867        );
1868        assert!(
1869            !podman(&[
1870                "exec",
1871                &id_fresh,
1872                "test",
1873                "-e",
1874                "/usr/local/bin/runner-manager-conflict",
1875            ])
1876            .status
1877            .success(),
1878            "a fresh sibling inherited an earlier writable-layer file"
1879        );
1880        stop_and_destroy(&provider, &fresh);
1881
1882        // Simulate the crash gap after create and before the environment ID is
1883        // journalled. Recovery discovers and quarantines exactly the labelled
1884        // orphan; after the durable identity is restored, normal destroy owns
1885        // and removes it.
1886        let (_runtime_orphan, mut orphan, prepared_orphan, orphan_id) = prepare_live_attempt(
1887            &provider,
1888            &policy,
1889            &image,
1890            None,
1891            "native-orphan",
1892            production_quota_path,
1893        );
1894        assert_eq!(
1895            provider.recover(&orphan).unwrap(),
1896            EnvironmentState::Starting
1897        );
1898        let owned = provider.enumerate_owned(HostId::from_u128(LIVE_HOST));
1899        assert_eq!(owned.len(), 1, "recovery did not isolate one owned orphan");
1900        assert!(matches!(
1901            &owned[0],
1902            EnvironmentIdentity::Isolated { environment_id, .. } if environment_id == &orphan_id
1903        ));
1904        orphan.prepared_environment(orphan_id).unwrap();
1905        assert_eq!(
1906            provider.destroy(&orphan).unwrap(),
1907            ProviderDestroy::Destroyed
1908        );
1909        assert!(
1910            provider
1911                .enumerate_owned(HostId::from_u128(LIVE_HOST))
1912                .is_empty()
1913        );
1914        drop(prepared_orphan);
1915
1916        assert!(
1917            !Command::new("dpkg-query")
1918                .args(["-W", LIVE_PACKAGE])
1919                .status()
1920                .unwrap()
1921                .success(),
1922            "container package installation mutated the host package database"
1923        );
1924    }
1925
1926    const RESTART_FIXTURE_ENV: &str = "RUNNER_MANAGER_OCI_RESTART_FIXTURE";
1927
1928    fn restart_fixture_root() -> PathBuf {
1929        let root = PathBuf::from(std::env::var(RESTART_FIXTURE_ENV).unwrap_or_else(|_| {
1930            panic!("{RESTART_FIXTURE_ENV} must name the durable fixture root")
1931        }));
1932        assert!(root.is_absolute() && !root.starts_with("/mnt"));
1933        root
1934    }
1935
1936    fn restart_attempt(
1937        root: &Path,
1938        policy: &ScalePolicy,
1939        image: &ImageReference,
1940        suffix: &str,
1941        id: u128,
1942        generation: &str,
1943    ) -> RunnerAttempt {
1944        let runtime = root.join("runtimes").join(suffix);
1945        std::fs::create_dir_all(&runtime).unwrap();
1946        write_listener(&runtime);
1947        let mut attempt = RunnerAttempt::allocate(
1948            AttemptId::from_u128(id),
1949            policy.id,
1950            runtime,
1951            FakeClock::default().now(),
1952        );
1953        attempt
1954            .allocate_execution(AttemptExecution::Isolated {
1955                provider_kind: Backend::Oci,
1956                environment_id: None,
1957                resolved_image: image.clone(),
1958                generation: generation.into(),
1959            })
1960            .unwrap();
1961        attempt
1962    }
1963
1964    fn prepare_restart_resource(
1965        provider: &OciProcesses,
1966        policy: &ScalePolicy,
1967        attempt: &mut RunnerAttempt,
1968    ) -> (PreparedEnvironment, String) {
1969        attempt.begin_prepare(FakeClock::default().now()).unwrap();
1970        let prepared = provider.prepare(attempt, policy).unwrap();
1971        let EnvironmentIdentity::Isolated { environment_id, .. } =
1972            prepared.identity().cloned().expect("isolated identity")
1973        else {
1974            panic!("OCI prepare returned a native identity")
1975        };
1976        (prepared, environment_id)
1977    }
1978
1979    #[test]
1980    #[ignore = "seeds real managed-WSL resources for the terminate/restart acceptance"]
1981    fn live_rootless_managed_wsl_restart_seed() {
1982        let root = restart_fixture_root();
1983        std::fs::create_dir_all(&root).unwrap();
1984        let journal = SqliteStore::open(root.join("attempts.sqlite3")).unwrap();
1985        let image = ImageReference::new(
1986            std::env::var(LIVE_IMAGE_ENV)
1987                .unwrap_or_else(|_| panic!("{LIVE_IMAGE_ENV} must name a pinned image digest")),
1988        )
1989        .unwrap();
1990        let provider = OciProcesses::new(HostId::from_u128(LIVE_HOST));
1991        let policy = isolated_policy(image.clone());
1992        let nonce = std::env::var("RUNNER_MANAGER_OCI_RESTART_NONCE").expect(
1993            "RUNNER_MANAGER_OCI_RESTART_NONCE must contain the non-secret acceptance nonce",
1994        );
1995        assert!(nonce.starts_with("rm-reboot-nonce-") && nonce.len() == 48);
1996        assert_eq!(provider.probe(&policy), ProviderCapability::Ready);
1997
1998        let clock = FakeClock::default();
1999        let mut prepared = restart_attempt(
2000            &root,
2001            &policy,
2002            &image,
2003            "prepared",
2004            0xd101,
2005            "restart-prepared",
2006        );
2007        let (_prepared_handle, prepared_id) =
2008            prepare_restart_resource(&provider, &policy, &mut prepared);
2009        prepared.prepared_environment(prepared_id).unwrap();
2010        prepared.mark_prepared(clock.now()).unwrap();
2011        journal.record_attempt(&prepared).unwrap();
2012
2013        let mut running =
2014            restart_attempt(&root, &policy, &image, "running", 0xd102, "restart-running");
2015        let (running_handle, running_id) =
2016            prepare_restart_resource(&provider, &policy, &mut running);
2017        running.prepared_environment(running_id.clone()).unwrap();
2018        running.mark_prepared(clock.now()).unwrap();
2019        running.jit_received(clock.now()).unwrap();
2020        provider
2021            .start(
2022                running_handle,
2023                &running,
2024                OneTimeJitHandoff::new(&EncodedJitConfig::new(&nonce)),
2025            )
2026            .unwrap();
2027        running.started_isolated(clock.now()).unwrap();
2028        assert!(wait_for_container_file(
2029            &running_id,
2030            "/tmp/runner-manager-jit-received",
2031            Instant::now() + Duration::from_secs(60),
2032        ));
2033        journal.record_attempt(&running).unwrap();
2034        inspect_controls(&running_id, &image, &nonce);
2035        assert_export_excludes(&running_id, &nonce);
2036
2037        let mut deferred = restart_attempt(
2038            &root,
2039            &policy,
2040            &image,
2041            "cleanup-deferred",
2042            0xd103,
2043            "restart-deferred",
2044        );
2045        let (_deferred_handle, deferred_id) =
2046            prepare_restart_resource(&provider, &policy, &mut deferred);
2047        deferred.prepared_environment(deferred_id).unwrap();
2048        deferred.mark_prepared(clock.now()).unwrap();
2049        deferred.jit_received(clock.now()).unwrap();
2050        deferred.started_isolated(clock.now()).unwrap();
2051        deferred
2052            .conclude(AttemptOutcome::Orphaned, clock.now())
2053            .unwrap();
2054        deferred.begin_destroy(clock.now()).unwrap();
2055        deferred.defer_cleanup(clock.now()).unwrap();
2056        journal.record_attempt(&deferred).unwrap();
2057
2058        let mut orphaned = restart_attempt(
2059            &root,
2060            &policy,
2061            &image,
2062            "orphaned",
2063            0xd104,
2064            "restart-orphaned",
2065        );
2066        let (_orphaned_handle, orphaned_id) =
2067            prepare_restart_resource(&provider, &policy, &mut orphaned);
2068        orphaned.prepared_environment(orphaned_id).unwrap();
2069        orphaned.mark_prepared(clock.now()).unwrap();
2070        orphaned
2071            .conclude(AttemptOutcome::Orphaned, clock.now())
2072            .unwrap();
2073        journal.record_attempt(&orphaned).unwrap();
2074
2075        // Crash gap: the provider resource exists, but its ID was never put in
2076        // the journal. Recovery may adopt only the resource carrying this exact
2077        // attempt and generation label.
2078        let mut orphan = restart_attempt(
2079            &root,
2080            &policy,
2081            &image,
2082            "crash-gap-orphan",
2083            0xd105,
2084            "restart-orphan",
2085        );
2086        let (_orphan_handle, _orphan_id) =
2087            prepare_restart_resource(&provider, &policy, &mut orphan);
2088        journal.record_attempt(&orphan).unwrap();
2089
2090        let attempts = journal.uncleaned_ephemeral_attempts().unwrap();
2091        assert_eq!(attempts.len(), 5);
2092        assert!(attempts.iter().all(RunnerAttempt::counts_against_capacity));
2093        assert_eq!(
2094            provider.enumerate_owned(HostId::from_u128(LIVE_HOST)).len(),
2095            5
2096        );
2097        assert!(
2098            !std::fs::read(root.join("attempts.sqlite3"))
2099                .unwrap()
2100                .windows(nonce.len())
2101                .any(|window| window == nonce.as_bytes()),
2102            "the non-secret JIT stand-in reached the durable attempt journal"
2103        );
2104        std::fs::write(root.join("registration-count"), "0\n").unwrap();
2105        std::fs::write(
2106            root.join("seed-complete"),
2107            "prepared starting cleanup_deferred orphaned crash_gap\n",
2108        )
2109        .unwrap();
2110        std::fs::write(
2111            root.join("security-scan-complete"),
2112            "inspect logs history export journal: nonce absent; github registrations=0\n",
2113        )
2114        .unwrap();
2115    }
2116
2117    #[test]
2118    #[ignore = "recovers real managed-WSL resources after the distribution restarts"]
2119    fn live_rootless_managed_wsl_restart_recover() {
2120        let root = restart_fixture_root();
2121        assert!(root.join("seed-complete").is_file());
2122        let journal = SqliteStore::open(root.join("attempts.sqlite3")).unwrap();
2123        let provider = OciProcesses::new(HostId::from_u128(LIVE_HOST));
2124        let nonce = std::env::var("RUNNER_MANAGER_OCI_RESTART_NONCE")
2125            .expect("RUNNER_MANAGER_OCI_RESTART_NONCE must survive in the Windows manifest");
2126        let mut attempts = journal.uncleaned_ephemeral_attempts().unwrap();
2127        assert_eq!(attempts.len(), 5, "the durable journal lost an attempt");
2128        assert!(attempts.iter().all(RunnerAttempt::counts_against_capacity));
2129        let image = match attempts[0].execution() {
2130            AttemptExecution::Isolated { resolved_image, .. } => resolved_image.clone(),
2131            _ => unreachable!(),
2132        };
2133        let owned = provider.enumerate_owned(HostId::from_u128(LIVE_HOST));
2134        assert_eq!(owned.len(), 5, "the remounted provider lost a resource");
2135
2136        for identity in &owned {
2137            let EnvironmentIdentity::Isolated { environment_id, .. } = identity else {
2138                unreachable!()
2139            };
2140            let inspect = podman_ok(&["container", "inspect", environment_id]);
2141            assert!(
2142                !inspect.contains(&nonce),
2143                "nonce reached durable inspect metadata"
2144            );
2145            let logs = podman(&["logs", environment_id]);
2146            assert!(
2147                !logs
2148                    .stdout
2149                    .windows(nonce.len())
2150                    .any(|window| window == nonce.as_bytes())
2151            );
2152            assert!(
2153                !logs
2154                    .stderr
2155                    .windows(nonce.len())
2156                    .any(|window| window == nonce.as_bytes())
2157            );
2158            assert_export_excludes(environment_id, &nonce);
2159        }
2160        let history = podman_ok(&["history", "--no-trunc", image.as_str()]);
2161        assert!(
2162            !history.contains(&nonce),
2163            "nonce reached durable image history"
2164        );
2165        assert!(
2166            !std::fs::read(root.join("attempts.sqlite3"))
2167                .unwrap()
2168                .windows(nonce.len())
2169                .any(|window| window == nonce.as_bytes()),
2170            "nonce reached the durable attempt journal"
2171        );
2172
2173        let clock = FakeClock::default();
2174        for attempt in &mut attempts {
2175            if attempt.state() == AttemptState::Preparing {
2176                let AttemptExecution::Isolated {
2177                    resolved_image,
2178                    generation,
2179                    ..
2180                } = attempt.execution()
2181                else {
2182                    unreachable!()
2183                };
2184                let mut wrong_generation = RunnerAttempt::allocate(
2185                    attempt.id,
2186                    attempt.policy_id,
2187                    attempt.runtime_path(),
2188                    clock.now(),
2189                );
2190                wrong_generation
2191                    .allocate_execution(AttemptExecution::Isolated {
2192                        provider_kind: Backend::Oci,
2193                        environment_id: None,
2194                        resolved_image: resolved_image.clone(),
2195                        generation: format!("{generation}-wrong"),
2196                    })
2197                    .unwrap();
2198                assert_eq!(
2199                    provider.recover(&wrong_generation).unwrap(),
2200                    EnvironmentState::Missing,
2201                    "a different generation adopted the crash-gap resource"
2202                );
2203                assert_eq!(
2204                    provider.recover(attempt).unwrap(),
2205                    EnvironmentState::Starting
2206                );
2207                let identity = owned
2208                    .iter()
2209                    .find(|identity| {
2210                        matches!(identity, EnvironmentIdentity::Isolated {
2211                            attempt: found_attempt,
2212                            generation: found_generation,
2213                            ..
2214                        } if found_attempt == &attempt.id && found_generation == generation)
2215                    })
2216                    .cloned()
2217                    .expect("the exact crash-gap identity is enumerable");
2218                let EnvironmentIdentity::Isolated { environment_id, .. } = identity else {
2219                    unreachable!()
2220                };
2221                attempt.prepared_environment(environment_id).unwrap();
2222                attempt.mark_prepared(clock.now()).unwrap();
2223            }
2224
2225            let state_after_restart = provider.inspect(attempt).unwrap();
2226            assert!(
2227                retained_after_restart(state_after_restart),
2228                "the remounted provider did not retain a recoverable resource: {state_after_restart:?}"
2229            );
2230            match attempt.state() {
2231                AttemptState::Prepared => {
2232                    attempt
2233                        .conclude(AttemptOutcome::Orphaned, clock.now())
2234                        .unwrap();
2235                    attempt.begin_destroy(clock.now()).unwrap();
2236                }
2237                AttemptState::Starting => {
2238                    attempt
2239                        .conclude(AttemptOutcome::Orphaned, clock.now())
2240                        .unwrap();
2241                    attempt.begin_destroy(clock.now()).unwrap();
2242                }
2243                AttemptState::Orphaned | AttemptState::CleanupDeferred => {
2244                    attempt.begin_destroy(clock.now()).unwrap()
2245                }
2246                state => panic!("unexpected restart state {state}"),
2247            }
2248            assert_eq!(
2249                provider.destroy(attempt).unwrap(),
2250                ProviderDestroy::Destroyed
2251            );
2252            attempt.clean_isolated(clock.now()).unwrap();
2253            journal.record_attempt(attempt).unwrap();
2254        }
2255
2256        assert!(journal.uncleaned_ephemeral_attempts().unwrap().is_empty());
2257        assert!(
2258            provider
2259                .enumerate_owned(HostId::from_u128(LIVE_HOST))
2260                .is_empty()
2261        );
2262        assert_eq!(
2263            std::fs::read_to_string(root.join("registration-count")).unwrap(),
2264            "0\n",
2265            "restart recovery attempted a second JIT registration"
2266        );
2267        std::fs::write(
2268            root.join("recovery-complete"),
2269            "adopted destroyed capacity=0 registrations=0\n",
2270        )
2271        .unwrap();
2272    }
2273
2274    #[test]
2275    #[ignore = "requires the CI-provisioned native rootless Podman/XFS project-quota fixture"]
2276    fn live_rootless_native_acceptance() {
2277        run_live_rootless_acceptance(false);
2278    }
2279
2280    #[test]
2281    #[ignore = "requires a root-owned bounded-storage helper fixture"]
2282    fn live_rootless_bounded_storage_acceptance() {
2283        run_live_rootless_acceptance(true);
2284    }
2285
2286    #[test]
2287    #[ignore = "requires a queued same-repository Actions job and a real JIT config on stdin"]
2288    fn live_rootless_github_jit_acceptance() {
2289        let image = ImageReference::new(
2290            std::env::var(LIVE_IMAGE_ENV)
2291                .unwrap_or_else(|_| panic!("{LIVE_IMAGE_ENV} must name a pinned image digest")),
2292        )
2293        .unwrap();
2294        let runner_root = PathBuf::from(
2295            std::env::var("RUNNER_MANAGER_OCI_JIT_RUNNER_DIR")
2296                .expect("RUNNER_MANAGER_OCI_JIT_RUNNER_DIR must name the extracted runner package"),
2297        );
2298        assert!(runner_root.is_absolute());
2299        assert!(!runner_root.starts_with("/mnt"));
2300        assert!(runner_root.join("bin/Runner.Listener").is_file());
2301
2302        let mut jit = String::new();
2303        std::io::stdin()
2304            .read_to_string(&mut jit)
2305            .expect("JIT stdin could not be read");
2306        while matches!(jit.as_bytes().last(), Some(b'\n' | b'\r')) {
2307            jit.pop();
2308        }
2309        assert!(!jit.is_empty(), "JIT stdin was empty");
2310        assert!(
2311            jit.len() <= 64 * 1024,
2312            "JIT stdin exceeded the provider limit"
2313        );
2314        assert!(
2315            !jit.bytes()
2316                .any(|byte| byte == b'\n' || byte == b'\r' || byte == 0),
2317            "JIT stdin contained a forbidden delimiter"
2318        );
2319
2320        let provider = OciProcesses::new(HostId::from_u128(LIVE_HOST));
2321        let policy = isolated_policy(image.clone());
2322        assert_eq!(provider.probe(&policy), ProviderCapability::Ready);
2323        let resolution = provider.resolve(&policy).unwrap().unwrap();
2324        assert_eq!(resolution.provider_kind, Backend::Oci);
2325        assert_eq!(resolution.image, image);
2326
2327        let mut attempt = RunnerAttempt::allocate(
2328            AttemptId::new_random(),
2329            policy.id,
2330            &runner_root,
2331            FakeClock::default().now(),
2332        );
2333        attempt
2334            .allocate_execution(AttemptExecution::Isolated {
2335                provider_kind: Backend::Oci,
2336                environment_id: None,
2337                resolved_image: image.clone(),
2338                generation: "github-jit".into(),
2339            })
2340            .unwrap();
2341        let prepared = provider.prepare(&attempt, &policy).unwrap();
2342        let EnvironmentIdentity::Isolated { environment_id, .. } =
2343            prepared.identity().cloned().expect("isolated identity")
2344        else {
2345            panic!("OCI prepare returned a native identity")
2346        };
2347        attempt
2348            .prepared_environment(environment_id.clone())
2349            .unwrap();
2350        let config = EncodedJitConfig::new(std::mem::take(&mut jit));
2351        provider
2352            .start(prepared, &attempt, OneTimeJitHandoff::new(&config))
2353            .unwrap();
2354
2355        let started = wait_for_container_file(
2356            &environment_id,
2357            "/tmp/runner-manager-actions-job-started",
2358            Instant::now() + Duration::from_secs(300),
2359        );
2360        assert!(
2361            started,
2362            "the queued Actions job was not claimed within five minutes"
2363        );
2364        assert_live_jit_absent(&environment_id, &image, config.expose());
2365        assert!(
2366            podman(&[
2367                "exec",
2368                &environment_id,
2369                "touch",
2370                "/tmp/runner-manager-provider-inspected",
2371            ])
2372            .status
2373            .success(),
2374            "could not release the Actions job after provider-side inspection"
2375        );
2376
2377        let completed = wait_for_container_file(
2378            &environment_id,
2379            "/tmp/runner-manager-actions-job-complete",
2380            Instant::now() + Duration::from_secs(600),
2381        );
2382        assert!(
2383            completed,
2384            "the Actions marker step did not complete within ten minutes"
2385        );
2386        let exit_deadline = Instant::now() + Duration::from_secs(120);
2387        while provider.inspect(&attempt).unwrap() != EnvironmentState::Exited {
2388            assert!(
2389                Instant::now() < exit_deadline,
2390                "the one-time runner did not exit after its job"
2391            );
2392            std::thread::sleep(Duration::from_millis(250));
2393        }
2394        assert_export_excludes(&environment_id, config.expose());
2395        assert_eq!(
2396            provider.destroy(&attempt).unwrap(),
2397            ProviderDestroy::Destroyed
2398        );
2399        assert_eq!(
2400            provider.inspect(&attempt).unwrap(),
2401            EnvironmentState::Missing
2402        );
2403        eprintln!("live GitHub JIT job completed through the OCI provider; evidence is redacted");
2404    }
2405
2406    #[test]
2407    #[ignore = "requires a real rootless Podman fixture with unavailable writable-layer quota"]
2408    fn live_rootless_quota_refusal_is_typed_before_jit() {
2409        let provider = OciProcesses::new(HostId::from_u128(1));
2410        assert_eq!(provider.ready(), ProviderCapability::DiskQuotaUnavailable);
2411    }
2412}