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