1use 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
35pub const ROOTFUL_SOCKET: &str = "/run/podman/podman.sock";
37
38const AUTO_USERNS_SIZE: u64 = 65536;
42
43#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
45#[serde(default, deny_unknown_fields)]
46pub struct IsolationSettings {
47 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 pub const FILE: &'static str = "isolation.json";
65
66 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 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#[derive(Debug, Clone, PartialEq, Eq)]
87pub enum Engine {
88 Rootless,
90 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 disk_quota_required: bool,
107 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 #[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 pub fn apply(&mut self, settings: IsolationSettings) {
148 self.disk_quota_required = settings.disk_quota_required;
149 }
150
151 #[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 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 if backing_filesystem == Some("xfs") {
314 return Ok(true);
315 }
316 let Ok(quota_info) = serde_json::from_str::<serde_json::Value>("a_probe) else {
321 return without_quota;
322 };
323 if bounded_storage_attested("a_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
488fn rootful_ranges_sufficient(files: &[PathBuf]) -> bool {
492 let needed = containers_range_needed();
493 files.iter().all(|file| containers_range(file) >= needed)
494}
495
496#[must_use]
499pub fn containers_range_needed() -> u64 {
500 AUTO_USERNS_SIZE * u64::from(runner_manager_platform::runner_account::POOL_SIZE)
501}
502
503#[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 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 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 let source = format!("{}/.", attempt.runtime_path().display());
742 let destination = format!("{id}:/runner");
743 if self.status(&["cp", &source, &destination]).is_err() {
744 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 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 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 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 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 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 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 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 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}