1use std::collections::BTreeMap;
9use std::ffi::{OsStr, OsString};
10use std::io::{Read as _, Write as _};
11use std::process::{Child, ChildStdin, Command, Output, Stdio};
12use std::sync::{Mutex, mpsc};
13use std::time::{Duration, Instant};
14
15use runner_manager_domain::attempt::{FailureReason, RunnerAttempt};
16use runner_manager_domain::execution::{
17 AttemptExecution, Backend, ExecutionPolicy, ImageReference, ResourceLimits,
18};
19use runner_manager_domain::model::{AttemptId, HostId};
20use runner_manager_domain::policy::ScalePolicy;
21
22use super::{
23 EnvironmentIdentity, EnvironmentState, ExecutionProvider, OneTimeJitHandoff,
24 PreparedEnvironment, ProcessStartFailure, ProviderCapability, ProviderDestroy,
25 ProviderDiagnostic, ProviderStop, ResolvedEnvironment, TERMINATE_INTENT_FILE,
26 write_durable_file,
27};
28
29const LABEL_HOST: &str = "runner-manager.host";
30const LABEL_ATTEMPT: &str = "runner-manager.attempt";
31const LABEL_GENERATION: &str = "runner-manager.generation";
32const LABEL_IMAGE: &str = "runner-manager.image";
33const LABEL_PROVIDER: &str = "runner-manager.provider";
34const PROVIDER_VALUE: &str = "windows-hyper-v-container";
35const MAX_CAPTURE: usize = 64 * 1024;
36const PREFLIGHT_INPUT: &str = "runner-manager-preflight-v1";
37#[cfg(all(test, windows))]
38const BOOTSTRAP_SELF_TEST_INPUT: &str = "runner-manager-windows-job-self-test-v1";
39const BOOTSTRAP_REQUEST_PROTOCOL: &str = "runner-manager-windows-job-request-v1";
40const BOOTSTRAP_ATTESTATION_PROTOCOL: &str = "runner-manager-windows-job-ready-v1";
41const WINDOWS_PROCESS_LIMIT: u32 = 256;
42const JOB_OBJECT_LIMIT_ACTIVE_PROCESS: u32 = 0x0000_0008;
43const JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE: u32 = 0x0000_2000;
44const JOB_LIMIT_FLAGS: u32 = JOB_OBJECT_LIMIT_ACTIVE_PROCESS | JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE;
45const BOOTSTRAP_ATTESTATION_TIMEOUT: Duration = Duration::from_secs(60);
46const PREFLIGHT_EXIT_TIMEOUT: Duration = Duration::from_secs(60);
47
48const BOOTSTRAP: &str = r#"
54$ErrorActionPreference='Stop'
55$source=@'
56using System;
57using System.ComponentModel;
58using System.Runtime.InteropServices;
59using System.Text;
60
61public sealed class RunnerManagerJobGuard : IDisposable {
62 const uint JOB_OBJECT_LIMIT_ACTIVE_PROCESS = 0x00000008;
63 const uint JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE = 0x00002000;
64 const uint CREATE_SUSPENDED = 0x00000004;
65 const uint INFINITE = 0xffffffff;
66 const int JobObjectExtendedLimitInformation = 9;
67 IntPtr job;
68
69 [StructLayout(LayoutKind.Sequential)]
70 struct BasicLimitInformation {
71 public long PerProcessUserTimeLimit;
72 public long PerJobUserTimeLimit;
73 public uint LimitFlags;
74 public UIntPtr MinimumWorkingSetSize;
75 public UIntPtr MaximumWorkingSetSize;
76 public uint ActiveProcessLimit;
77 public UIntPtr Affinity;
78 public uint PriorityClass;
79 public uint SchedulingClass;
80 }
81 [StructLayout(LayoutKind.Sequential)]
82 struct IoCounters {
83 public ulong ReadOperationCount;
84 public ulong WriteOperationCount;
85 public ulong OtherOperationCount;
86 public ulong ReadTransferCount;
87 public ulong WriteTransferCount;
88 public ulong OtherTransferCount;
89 }
90 [StructLayout(LayoutKind.Sequential)]
91 struct ExtendedLimitInformation {
92 public BasicLimitInformation BasicLimitInformation;
93 public IoCounters IoInfo;
94 public UIntPtr ProcessMemoryLimit;
95 public UIntPtr JobMemoryLimit;
96 public UIntPtr PeakProcessMemoryUsed;
97 public UIntPtr PeakJobMemoryUsed;
98 }
99 [StructLayout(LayoutKind.Sequential, CharSet=CharSet.Unicode)]
100 struct StartupInfo {
101 public uint cb;
102 public string lpReserved;
103 public string lpDesktop;
104 public string lpTitle;
105 public uint dwX;
106 public uint dwY;
107 public uint dwXSize;
108 public uint dwYSize;
109 public uint dwXCountChars;
110 public uint dwYCountChars;
111 public uint dwFillAttribute;
112 public uint dwFlags;
113 public ushort wShowWindow;
114 public ushort cbReserved2;
115 public IntPtr lpReserved2;
116 public IntPtr hStdInput;
117 public IntPtr hStdOutput;
118 public IntPtr hStdError;
119 }
120 [StructLayout(LayoutKind.Sequential)]
121 struct ProcessInformation {
122 public IntPtr hProcess;
123 public IntPtr hThread;
124 public uint dwProcessId;
125 public uint dwThreadId;
126 }
127
128 [DllImport("kernel32.dll", SetLastError=true, CharSet=CharSet.Unicode)]
129 static extern IntPtr CreateJobObject(IntPtr attributes, string name);
130 [DllImport("kernel32.dll", SetLastError=true)]
131 static extern bool SetInformationJobObject(IntPtr job, int infoClass, IntPtr info, uint length);
132 [DllImport("kernel32.dll", SetLastError=true)]
133 static extern bool QueryInformationJobObject(IntPtr job, int infoClass, IntPtr info, uint length, IntPtr returnedLength);
134 [DllImport("kernel32.dll", SetLastError=true, CharSet=CharSet.Unicode)]
135 static extern bool CreateProcess(string applicationName, StringBuilder commandLine, IntPtr processAttributes, IntPtr threadAttributes, bool inheritHandles, uint creationFlags, IntPtr environment, string currentDirectory, ref StartupInfo startupInfo, out ProcessInformation processInformation);
136 [DllImport("kernel32.dll", SetLastError=true)]
137 static extern bool AssignProcessToJobObject(IntPtr job, IntPtr process);
138 [DllImport("kernel32.dll", SetLastError=true)]
139 static extern bool IsProcessInJob(IntPtr process, IntPtr job, out bool result);
140 [DllImport("kernel32.dll", SetLastError=true)]
141 static extern uint ResumeThread(IntPtr thread);
142 [DllImport("kernel32.dll", SetLastError=true)]
143 static extern uint WaitForSingleObject(IntPtr handle, uint milliseconds);
144 [DllImport("kernel32.dll", SetLastError=true)]
145 static extern bool GetExitCodeProcess(IntPtr process, out uint exitCode);
146 [DllImport("kernel32.dll", SetLastError=true)]
147 static extern bool TerminateProcess(IntPtr process, uint exitCode);
148 [DllImport("kernel32.dll", SetLastError=true)]
149 static extern bool CloseHandle(IntPtr handle);
150
151 static void Check(bool ok) {
152 if (!ok) throw new Win32Exception(Marshal.GetLastWin32Error());
153 }
154
155 public RunnerManagerJobGuard(uint activeProcessLimit) {
156 job = CreateJobObject(IntPtr.Zero, null);
157 if (job == IntPtr.Zero) throw new Win32Exception(Marshal.GetLastWin32Error());
158 ExtendedLimitInformation limits = new ExtendedLimitInformation();
159 limits.BasicLimitInformation.LimitFlags = JOB_OBJECT_LIMIT_ACTIVE_PROCESS | JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE;
160 limits.BasicLimitInformation.ActiveProcessLimit = activeProcessLimit;
161 int size = Marshal.SizeOf(typeof(ExtendedLimitInformation));
162 IntPtr buffer = Marshal.AllocHGlobal(size);
163 try {
164 Marshal.StructureToPtr(limits, buffer, false);
165 Check(SetInformationJobObject(job, JobObjectExtendedLimitInformation, buffer, (uint)size));
166 } finally {
167 Marshal.FreeHGlobal(buffer);
168 }
169 }
170
171 public string Attest() {
172 int size = Marshal.SizeOf(typeof(ExtendedLimitInformation));
173 IntPtr buffer = Marshal.AllocHGlobal(size);
174 try {
175 Check(QueryInformationJobObject(job, JobObjectExtendedLimitInformation, buffer, (uint)size, IntPtr.Zero));
176 ExtendedLimitInformation limits = (ExtendedLimitInformation)Marshal.PtrToStructure(buffer, typeof(ExtendedLimitInformation));
177 uint required = JOB_OBJECT_LIMIT_ACTIVE_PROCESS | JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE;
178 if (limits.BasicLimitInformation.LimitFlags != required) throw new InvalidOperationException("unexpected job limit flags");
179 return limits.BasicLimitInformation.ActiveProcessLimit.ToString() + "|" + limits.BasicLimitInformation.LimitFlags.ToString();
180 } finally {
181 Marshal.FreeHGlobal(buffer);
182 }
183 }
184
185 public int Run(string executable, string arguments, char[] jit) {
186 StartupInfo startup = new StartupInfo();
187 startup.cb = (uint)Marshal.SizeOf(typeof(StartupInfo));
188 ProcessInformation process;
189 StringBuilder command = new StringBuilder("\"" + executable + "\" " + arguments);
190 string priorJit = Environment.GetEnvironmentVariable("ACTIONS_RUNNER_INPUT_JITCONFIG", EnvironmentVariableTarget.Process);
191 string jitValue = jit == null ? null : new string(jit);
192 bool created;
193 try {
194 if (jitValue != null) Environment.SetEnvironmentVariable("ACTIONS_RUNNER_INPUT_JITCONFIG", jitValue, EnvironmentVariableTarget.Process);
195 created = CreateProcess(null, command, IntPtr.Zero, IntPtr.Zero, true, CREATE_SUSPENDED, IntPtr.Zero, null, ref startup, out process);
196 } finally {
197 if (jitValue != null) Environment.SetEnvironmentVariable("ACTIONS_RUNNER_INPUT_JITCONFIG", priorJit, EnvironmentVariableTarget.Process);
198 if (jit != null) Array.Clear(jit, 0, jit.Length);
199 jitValue = null;
200 }
201 Check(created);
202 bool assigned = false;
203 try {
204 Check(AssignProcessToJobObject(job, process.hProcess));
205 bool inJob;
206 Check(IsProcessInJob(process.hProcess, job, out inJob));
207 if (!inJob) throw new InvalidOperationException("runner did not enter process-limit job");
208 assigned = true;
209 if (ResumeThread(process.hThread) == 0xffffffff) throw new Win32Exception(Marshal.GetLastWin32Error());
210 if (WaitForSingleObject(process.hProcess, INFINITE) != 0) throw new Win32Exception(Marshal.GetLastWin32Error());
211 uint exitCode;
212 Check(GetExitCodeProcess(process.hProcess, out exitCode));
213 return unchecked((int)exitCode);
214 } finally {
215 if (!assigned) TerminateProcess(process.hProcess, 73);
216 CloseHandle(process.hThread);
217 CloseHandle(process.hProcess);
218 }
219 }
220
221 public void Dispose() {
222 if (job != IntPtr.Zero) {
223 CloseHandle(job);
224 job = IntPtr.Zero;
225 }
226 }
227}
228'@
229Add-Type -TypeDefinition $source -Language CSharp
230$request=[Console]::In.ReadLine()
231$parts=$request.Split('|')
232if ($parts.Length -ne 3 -or $parts[0] -ne 'runner-manager-windows-job-request-v1' -or $parts[1] -notmatch '^[0-9a-f]{32}$') { exit 70 }
233$limit=0
234if (-not [UInt32]::TryParse($parts[2],[ref]$limit) -or $limit -lt 1 -or $limit -gt 4096) { exit 70 }
235$guard=[RunnerManagerJobGuard]::new($limit)
236try {
237 $proof=$guard.Attest()
238 [Console]::Out.WriteLine('runner-manager-windows-job-ready-v1|'+$parts[1]+'|'+$proof)
239 [Console]::Out.Flush()
240 $payload=[Console]::In.ReadToEnd()
241 if ([String]::IsNullOrWhiteSpace($payload)) { exit 70 }
242 if ($payload -eq 'runner-manager-windows-job-self-test-v1') { exit $guard.Run(($env:WINDIR+'\\System32\\cmd.exe'),'/d /c exit 0',$null) }
243 if ($payload -eq 'runner-manager-preflight-v1') { exit $guard.Run('C:\\runner\\bin\\Runner.Listener.exe','--version',$null) }
244 $jit=$payload.ToCharArray()
245 $payload=$null
246 try { $code=$guard.Run('C:\\runner\\bin\\Runner.Listener.exe','run',$jit) } finally { [Array]::Clear($jit,0,$jit.Length); $jit=$null }
247 exit $code
248} finally {
249 $guard.Dispose()
250}
251"#;
252
253#[derive(Debug, Clone, Copy, PartialEq, Eq)]
256pub enum WindowsHyperVHostState {
257 Ready,
258 UnsupportedHost,
259 HyperVUnavailable,
260 ContainersUnavailable,
261 RuntimeNotInstalled,
262 RuntimePermissionDenied,
263 RuntimeInLinuxMode,
264 RuntimeDegraded,
265}
266
267impl WindowsHyperVHostState {
268 #[must_use]
269 pub const fn capability(self) -> ProviderCapability {
270 match self {
271 Self::Ready => ProviderCapability::Ready,
272 Self::UnsupportedHost | Self::RuntimeInLinuxMode => ProviderCapability::Unsupported,
273 Self::HyperVUnavailable | Self::ContainersUnavailable | Self::RuntimeNotInstalled => {
274 ProviderCapability::NotInstalled
275 }
276 Self::RuntimePermissionDenied => ProviderCapability::PermissionDenied,
277 Self::RuntimeDegraded => ProviderCapability::Degraded,
278 }
279 }
280
281 #[must_use]
282 pub const fn remedy(self) -> Option<&'static str> {
283 match self {
284 Self::Ready => None,
285 Self::UnsupportedHost => Some(
286 "use Windows 11 Pro or Enterprise with Docker Desktop in Windows-container mode, or Windows Server Standard or Datacenter with Moby or Mirantis Container Runtime",
287 ),
288 Self::HyperVUnavailable => Some("enable Hyper-V and restart Windows"),
289 Self::ContainersUnavailable => {
290 Some("enable the Windows Containers feature and restart Windows")
291 }
292 Self::RuntimeNotInstalled => {
293 Some("install a Windows container runtime with Docker-compatible CLI")
294 }
295 Self::RuntimePermissionDenied => {
296 Some("grant the runner-manager service account access to the container runtime")
297 }
298 Self::RuntimeInLinuxMode => Some("switch the container runtime to Windows containers"),
299 Self::RuntimeDegraded => Some("repair or start the Windows container runtime"),
300 }
301 }
302}
303
304#[derive(Debug)]
305pub struct WindowsHyperVContainers {
306 host_id: HostId,
307 children: Mutex<BTreeMap<AttemptId, Child>>,
308 limits: Mutex<BTreeMap<AttemptId, ResourceLimits>>,
309 diagnostics: Mutex<BTreeMap<AttemptId, Vec<ProviderDiagnostic>>>,
310}
311
312impl WindowsHyperVContainers {
313 #[must_use]
314 pub fn new(host_id: HostId) -> Self {
315 Self {
316 host_id,
317 children: Mutex::new(BTreeMap::new()),
318 limits: Mutex::new(BTreeMap::new()),
319 diagnostics: Mutex::new(BTreeMap::new()),
320 }
321 }
322
323 #[must_use]
324 pub fn host_state() -> WindowsHyperVHostState {
325 #[cfg(test)]
326 {
327 WindowsHyperVHostState::UnsupportedHost
328 }
329 #[cfg(not(test))]
330 {
331 host_state_with(&SystemCommands)
332 }
333 }
334
335 fn note(&self, attempt: AttemptId, diagnostic: ProviderDiagnostic) {
336 let mut all = self
337 .diagnostics
338 .lock()
339 .unwrap_or_else(std::sync::PoisonError::into_inner);
340 let entries = all.entry(attempt).or_default();
341 if entries.len() < 32 && !entries.contains(&diagnostic) {
342 entries.push(diagnostic);
343 }
344 }
345
346 fn docker(args: &[OsString]) -> Result<CommandResult, CommandFailure> {
347 SystemCommands.run("docker", args)
348 }
349
350 fn inspect_record(name: &str) -> Result<Option<ResourceRecord>, CommandFailure> {
351 let format = format!(
352 "{{{{.Name}}}}|{{{{.State.Status}}}}|{{{{.State.ExitCode}}}}|{{{{index .Config.Labels \"{LABEL_HOST}\"}}}}|\
353 {{{{index .Config.Labels \"{LABEL_ATTEMPT}\"}}}}|\
354 {{{{index .Config.Labels \"{LABEL_GENERATION}\"}}}}|\
355 {{{{index .Config.Labels \"{LABEL_IMAGE}\"}}}}|\
356 {{{{index .Config.Labels \"{LABEL_PROVIDER}\"}}}}"
357 );
358 let result = Self::docker(&container_inspect_args(name, &format))?;
359 if !result.success {
360 return if result.is_missing_resource() {
361 Ok(None)
362 } else {
363 Err(result.failure())
364 };
365 }
366 Ok(ResourceRecord::parse(result.stdout.trim()))
367 }
368
369 fn inspect_configuration(name: &str) -> Result<ContainerAttestation, CommandFailure> {
370 let result = Self::docker(&container_configuration_inspect_args(name))?;
371 if !result.success {
372 return Err(result.failure());
373 }
374 ContainerAttestation::parse(result.stdout.trim()).ok_or(CommandFailure::Failed)
375 }
376
377 fn configuration_is_exact(
378 name: &str,
379 image: &ImageReference,
380 resources: ResourceLimits,
381 ) -> bool {
382 Self::inspect_configuration(name)
383 .is_ok_and(|found| found == ContainerAttestation::expected(image, resources))
384 }
385
386 fn start_attested(name: &str) -> Result<(Child, ChildStdin), ()> {
387 let mut child = Command::new("docker")
388 .args([
389 OsStr::new("start"),
390 OsStr::new("--attach"),
391 OsStr::new("--interactive"),
392 OsStr::new(name),
393 ])
394 .stdin(Stdio::piped())
395 .stdout(Stdio::piped())
396 .stderr(Stdio::null())
397 .spawn()
398 .map_err(|_| ())?;
399 let Some(mut stdin) = child.stdin.take() else {
400 stop_failed_bootstrap(&mut child, name);
401 return Err(());
402 };
403 let Some(stdout) = child.stdout.take() else {
404 stop_failed_bootstrap(&mut child, name);
405 return Err(());
406 };
407 let nonce = uuid::Uuid::new_v4().simple().to_string();
408 let request = format!("{BOOTSTRAP_REQUEST_PROTOCOL}|{nonce}|{WINDOWS_PROCESS_LIMIT}\n");
409 if stdin
410 .write_all(request.as_bytes())
411 .and_then(|()| stdin.flush())
412 .is_err()
413 {
414 stop_failed_bootstrap(&mut child, name);
415 return Err(());
416 }
417
418 let (sender, receiver) = mpsc::sync_channel(1);
419 std::thread::spawn(move || {
420 let mut stdout = stdout;
421 let mut bytes = Vec::with_capacity(256);
422 let result = loop {
423 if bytes.len() == 1024 {
424 break Err(std::io::Error::new(
425 std::io::ErrorKind::InvalidData,
426 "bootstrap attestation exceeded limit",
427 ));
428 }
429 let mut byte = [0_u8; 1];
430 match stdout.read(&mut byte) {
431 Ok(0) => {
432 break Err(std::io::Error::new(
433 std::io::ErrorKind::UnexpectedEof,
434 "bootstrap closed before attestation",
435 ));
436 }
437 Ok(_) if byte[0] == b'\n' => {
438 break String::from_utf8(bytes).map_err(|_| {
439 std::io::Error::new(
440 std::io::ErrorKind::InvalidData,
441 "bootstrap attestation was not UTF-8",
442 )
443 });
444 }
445 Ok(_) => bytes.push(byte[0]),
446 Err(error) => break Err(error),
447 }
448 };
449 let _ = sender.send((result, stdout));
450 });
451
452 let received = receiver.recv_timeout(BOOTSTRAP_ATTESTATION_TIMEOUT);
453 let Ok((Ok(line), mut stdout)) = received else {
454 stop_failed_bootstrap(&mut child, name);
455 return Err(());
456 };
457 let Some(attestation) = JobAttestation::parse(line.trim_end_matches('\r')) else {
458 stop_failed_bootstrap(&mut child, name);
459 return Err(());
460 };
461 if attestation
462 != (JobAttestation {
463 nonce,
464 active_process_limit: WINDOWS_PROCESS_LIMIT,
465 limit_flags: JOB_LIMIT_FLAGS,
466 })
467 {
468 stop_failed_bootstrap(&mut child, name);
469 return Err(());
470 }
471
472 std::thread::spawn(move || {
475 let _ = std::io::copy(&mut stdout, &mut std::io::sink());
476 });
477 Ok((child, stdin))
478 }
479
480 fn identity_for(
481 &self,
482 attempt: &RunnerAttempt,
483 environment_id: String,
484 ) -> Option<EnvironmentIdentity> {
485 let AttemptExecution::Isolated {
486 provider_kind,
487 resolved_image,
488 generation,
489 ..
490 } = attempt.execution()
491 else {
492 return None;
493 };
494 Some(EnvironmentIdentity::Isolated {
495 host: self.host_id,
496 attempt: attempt.id,
497 provider_kind: *provider_kind,
498 environment_id,
499 resolved_image: resolved_image.clone(),
500 generation: generation.clone(),
501 })
502 }
503
504 fn expected_record(&self, attempt: &RunnerAttempt) -> Option<ResourceRecord> {
505 let AttemptExecution::Isolated {
506 provider_kind: Backend::WindowsHyperVContainer,
507 resolved_image,
508 generation,
509 ..
510 } = attempt.execution()
511 else {
512 return None;
513 };
514 Some(ResourceRecord {
515 name: container_name(self.host_id, attempt.id, generation),
516 state: String::new(),
517 exit_code: None,
518 host: self.host_id,
519 attempt: attempt.id,
520 generation: generation.clone(),
521 image: resolved_image.clone(),
522 })
523 }
524
525 fn intent_path(attempt: &RunnerAttempt) -> std::path::PathBuf {
526 attempt.runtime_path().join(TERMINATE_INTENT_FILE)
527 }
528}
529
530impl ExecutionProvider for WindowsHyperVContainers {
531 fn probe(&self, policy: &ScalePolicy) -> ProviderCapability {
532 let ExecutionPolicy::Isolated { backend, image, .. } = policy.execution_policy() else {
533 return ProviderCapability::Unsupported;
534 };
535 if !matches!(backend, Backend::Auto | Backend::WindowsHyperVContainer) {
536 return ProviderCapability::Unsupported;
537 }
538 let host = Self::host_state().capability();
539 if host != ProviderCapability::Ready {
540 return host;
541 }
542 match inspect_image(image) {
543 Ok(true) => ProviderCapability::Ready,
544 Ok(false) => ProviderCapability::ImageUnavailableOrIncompatible,
545 Err(CommandFailure::PermissionDenied) => ProviderCapability::PermissionDenied,
546 Err(_) => ProviderCapability::ImageUnavailableOrIncompatible,
547 }
548 }
549
550 fn resolve(&self, policy: &ScalePolicy) -> Result<Option<ResolvedEnvironment>, FailureReason> {
551 let ExecutionPolicy::Isolated { backend, image, .. } = policy.execution_policy() else {
552 return Err(failure("Windows Hyper-V provider received a native policy"));
553 };
554 if !matches!(backend, Backend::Auto | Backend::WindowsHyperVContainer) {
555 return Err(failure("Windows Hyper-V provider was not selected"));
556 }
557 if self.probe(policy) != ProviderCapability::Ready {
558 return Err(failure("Windows Hyper-V provider preflight failed"));
559 }
560 Ok(Some(ResolvedEnvironment {
561 provider_kind: Backend::WindowsHyperVContainer,
562 image: image.clone(),
563 }))
564 }
565
566 fn prepare(
567 &self,
568 attempt: &RunnerAttempt,
569 policy: &ScalePolicy,
570 ) -> Result<PreparedEnvironment, FailureReason> {
571 let Some(expected) = self.expected_record(attempt) else {
572 self.note(attempt.id, ProviderDiagnostic::PrepareFailed);
573 return Err(failure("Windows Hyper-V allocation is invalid"));
574 };
575 let ExecutionPolicy::Isolated { resources, .. } = policy.execution_policy() else {
576 return Err(failure("Windows Hyper-V policy is invalid"));
577 };
578
579 let exists = match Self::inspect_record(&expected.name) {
580 Ok(Some(found))
581 if found.same_owner(&expected)
582 && matches!(found.state.as_str(), "created" | "exited") =>
583 {
584 true
585 }
586 Ok(Some(found)) if found.same_owner(&expected) => {
587 self.note(attempt.id, ProviderDiagnostic::PrepareFailed);
588 return Err(failure(
589 "Windows Hyper-V container state is incompatible with preparation",
590 ));
591 }
592 Ok(Some(_)) => {
593 self.note(attempt.id, ProviderDiagnostic::OwnershipMismatch);
594 return Err(failure("Windows Hyper-V container ownership mismatch"));
595 }
596 Ok(None) => false,
597 Err(_) => {
598 self.note(attempt.id, ProviderDiagnostic::PrepareFailed);
599 return Err(failure("Windows Hyper-V container inspection failed"));
600 }
601 };
602
603 if !exists {
604 let create = Self::docker(&create_args(&expected, *resources))
605 .map_err(|_| failure("Windows Hyper-V container creation failed"))?;
606 if !create.success {
607 self.note(attempt.id, ProviderDiagnostic::PrepareFailed);
608 return Err(failure("Windows Hyper-V container creation failed"));
609 }
610 }
611 if !Self::configuration_is_exact(&expected.name, &expected.image, *resources) {
612 let _ = Self::docker(&os_args(["rm", "--force", expected.name.as_str()]));
613 self.note(attempt.id, ProviderDiagnostic::PrepareFailed);
614 return Err(failure(
615 "Windows Hyper-V resource or isolation attestation failed",
616 ));
617 }
618
619 let source = format!(
620 "{}{}.",
621 attempt.runtime_path().display(),
622 std::path::MAIN_SEPARATOR
623 );
624 let destination = format!("{}:C:\\runner", expected.name);
625 let copied = Self::docker(&os_args(["cp", &source, &destination]));
626 if !matches!(copied, Ok(result) if result.success) {
627 let _ = Self::docker(&os_args(["rm", "--force", expected.name.as_str()]));
628 self.note(attempt.id, ProviderDiagnostic::PrepareFailed);
629 return Err(failure(
630 "runner package copy into Windows Hyper-V container failed",
631 ));
632 }
633
634 let preflight_ok = match Self::start_attested(&expected.name) {
640 Ok((mut child, mut stdin)) => {
641 let wrote = stdin
642 .write_all(PREFLIGHT_INPUT.as_bytes())
643 .and_then(|()| stdin.flush())
644 .is_ok();
645 drop(stdin);
647 let ok = wrote && wait_until_exit(&mut child, PREFLIGHT_EXIT_TIMEOUT);
648 if !ok {
649 stop_failed_bootstrap(&mut child, &expected.name);
650 }
651 ok
652 }
653 Err(()) => false,
654 };
655 let preflight_record = Self::inspect_record(&expected.name).ok().flatten();
656 if !preflight_ok
657 || !preflight_record.is_some_and(|record| {
658 record.same_owner(&expected)
659 && record.state == "exited"
660 && record.exit_code == Some(0)
661 })
662 {
663 let _ = Self::docker(&os_args(["rm", "--force", expected.name.as_str()]));
664 self.note(attempt.id, ProviderDiagnostic::PrepareFailed);
665 return Err(failure(
666 "Windows Hyper-V image bootstrap compatibility check failed",
667 ));
668 }
669
670 let identity = self
671 .identity_for(attempt, expected.name)
672 .ok_or_else(|| failure("Windows Hyper-V identity is invalid"))?;
673 self.limits
674 .lock()
675 .unwrap_or_else(std::sync::PoisonError::into_inner)
676 .insert(attempt.id, *resources);
677 Ok(PreparedEnvironment::isolated(attempt.id, identity))
678 }
679
680 fn start(
681 &self,
682 prepared: PreparedEnvironment,
683 attempt: &RunnerAttempt,
684 handoff: OneTimeJitHandoff<'_>,
685 ) -> Result<EnvironmentIdentity, ProcessStartFailure> {
686 let Some(expected) = self.expected_record(attempt) else {
687 return Err(ProcessStartFailure::before_spawn(failure(
688 "Windows Hyper-V allocation is invalid",
689 )));
690 };
691 if prepared.identity() != self.identity_for(attempt, expected.name.clone()).as_ref() {
692 self.note(attempt.id, ProviderDiagnostic::OwnershipMismatch);
693 return Err(ProcessStartFailure::before_spawn(failure(
694 "Windows Hyper-V prepared identity mismatch",
695 )));
696 }
697 if !self.owns(attempt).unwrap_or(false) {
698 self.note(attempt.id, ProviderDiagnostic::OwnershipMismatch);
699 return Err(ProcessStartFailure::before_spawn(failure(
700 "Windows Hyper-V container ownership mismatch",
701 )));
702 }
703 let Some(resources) = self
704 .limits
705 .lock()
706 .unwrap_or_else(std::sync::PoisonError::into_inner)
707 .get(&attempt.id)
708 .copied()
709 else {
710 self.note(attempt.id, ProviderDiagnostic::StartFailed);
711 return Err(ProcessStartFailure::before_spawn(failure(
712 "Windows Hyper-V resource attestation is unavailable",
713 )));
714 };
715 if !Self::configuration_is_exact(&expected.name, &expected.image, resources) {
716 self.note(attempt.id, ProviderDiagnostic::StartFailed);
717 return Err(ProcessStartFailure::before_spawn(failure(
718 "Windows Hyper-V resource or isolation attestation failed",
719 )));
720 }
721 let (mut child, mut stdin) = Self::start_attested(&expected.name).map_err(|()| {
727 self.note(attempt.id, ProviderDiagnostic::StartFailed);
728 ProcessStartFailure::after_spawn_stopped()
729 })?;
730 let write_result = stdin
731 .write_all(handoff.consume().expose().as_bytes())
732 .and_then(|()| stdin.flush());
733 drop(stdin);
734 if write_result.is_err() {
735 stop_failed_bootstrap(&mut child, &expected.name);
736 self.note(attempt.id, ProviderDiagnostic::StartFailed);
737 return Err(ProcessStartFailure::after_spawn_stopped());
738 }
739 self.children
740 .lock()
741 .unwrap_or_else(std::sync::PoisonError::into_inner)
742 .insert(attempt.id, child);
743 self.identity_for(attempt, expected.name)
744 .ok_or_else(ProcessStartFailure::after_spawn_stopped)
745 }
746
747 fn inspect(&self, attempt: &RunnerAttempt) -> Result<EnvironmentState, FailureReason> {
748 let Some(expected) = self.expected_record(attempt) else {
749 return Err(failure("Windows Hyper-V allocation is invalid"));
750 };
751 let Some(found) = Self::inspect_record(&expected.name)
752 .map_err(|_| failure("Windows Hyper-V container inspection failed"))?
753 else {
754 return Ok(EnvironmentState::Missing);
755 };
756 if !found.same_owner(&expected) {
757 self.note(attempt.id, ProviderDiagnostic::OwnershipMismatch);
758 return Err(failure("Windows Hyper-V container ownership mismatch"));
759 }
760 Ok(match found.state.as_str() {
761 "created" | "restarting" => EnvironmentState::Starting,
762 "running" | "paused" => EnvironmentState::Running,
763 "exited" | "dead" | "removing" => EnvironmentState::Exited,
764 _ => EnvironmentState::Exited,
765 })
766 }
767
768 fn stop(&self, attempt: &RunnerAttempt) -> Result<ProviderStop, FailureReason> {
769 let Some(expected) = self.expected_record(attempt) else {
770 return Err(failure("Windows Hyper-V allocation is invalid"));
771 };
772 if !self.owns(attempt)? {
773 return if Self::inspect_record(&expected.name)
774 .ok()
775 .flatten()
776 .is_none()
777 {
778 Ok(ProviderStop::Stopped)
779 } else {
780 self.note(attempt.id, ProviderDiagnostic::OwnershipMismatch);
781 Err(failure("Windows Hyper-V container ownership mismatch"))
782 };
783 }
784 if let Some(mut child) = self
785 .children
786 .lock()
787 .unwrap_or_else(std::sync::PoisonError::into_inner)
788 .remove(&attempt.id)
789 {
790 let _ = child.kill();
791 let _ = child.wait();
792 }
793 if matches!(Self::inspect_record(&expected.name), Ok(Some(record)) if matches!(record.state.as_str(), "exited" | "dead"))
794 {
795 return Ok(ProviderStop::Stopped);
796 }
797 let stopped = Self::docker(&os_args(["stop", "--time", "10", expected.name.as_str()]))
798 .map_err(|_| failure("Windows Hyper-V container stop failed"))?;
799 Ok(if stopped.success || stopped.is_missing_resource() {
800 ProviderStop::Stopped
801 } else {
802 ProviderStop::StillRunning
803 })
804 }
805
806 fn destroy(&self, attempt: &RunnerAttempt) -> Result<ProviderDestroy, FailureReason> {
807 let Some(expected) = self.expected_record(attempt) else {
808 return Err(failure("Windows Hyper-V allocation is invalid"));
809 };
810 if !self.owns(attempt)? {
811 return if Self::inspect_record(&expected.name)
812 .ok()
813 .flatten()
814 .is_none()
815 {
816 self.limits
817 .lock()
818 .unwrap_or_else(std::sync::PoisonError::into_inner)
819 .remove(&attempt.id);
820 Ok(ProviderDestroy::Destroyed)
821 } else {
822 self.note(attempt.id, ProviderDiagnostic::OwnershipMismatch);
823 Err(failure("Windows Hyper-V container ownership mismatch"))
824 };
825 }
826 let removed = Self::docker(&os_args(["rm", "--force", expected.name.as_str()]))
827 .map_err(|_| failure("Windows Hyper-V container destroy failed"))?;
828 if (removed.success || removed.is_missing_resource())
829 && Self::inspect_record(&expected.name)
830 .ok()
831 .flatten()
832 .is_none()
833 {
834 self.limits
835 .lock()
836 .unwrap_or_else(std::sync::PoisonError::into_inner)
837 .remove(&attempt.id);
838 return Ok(ProviderDestroy::Destroyed);
839 }
840 self.note(attempt.id, ProviderDiagnostic::CleanupDeferred);
841 Ok(ProviderDestroy::Deferred(
842 "container runtime retained resource",
843 ))
844 }
845
846 fn recover(&self, attempt: &RunnerAttempt) -> Result<EnvironmentState, FailureReason> {
847 self.inspect(attempt)
848 }
849
850 fn enumerate_owned(&self, host_id: HostId) -> Vec<EnvironmentIdentity> {
851 let filter = format!("label={LABEL_HOST}={host_id}");
852 let Ok(list) = Self::docker(&os_args([
853 "ps",
854 "--all",
855 "--filter",
856 &filter,
857 "--format",
858 "{{.Names}}",
859 ])) else {
860 return Vec::new();
861 };
862 if !list.success {
863 return Vec::new();
864 }
865 list.stdout
866 .lines()
867 .take(1024)
868 .filter_map(|name| Self::inspect_record(name).ok().flatten())
869 .filter(|record| record.host == host_id)
870 .map(|record| EnvironmentIdentity::Isolated {
871 host: record.host,
872 attempt: record.attempt,
873 provider_kind: Backend::WindowsHyperVContainer,
874 environment_id: record.name,
875 resolved_image: record.image,
876 generation: record.generation,
877 })
878 .collect()
879 }
880
881 fn diagnostics(&self, attempt: &RunnerAttempt) -> Vec<ProviderDiagnostic> {
882 self.diagnostics
883 .lock()
884 .ok()
885 .and_then(|all| all.get(&attempt.id).cloned())
886 .unwrap_or_default()
887 }
888
889 fn owns(&self, attempt: &RunnerAttempt) -> Result<bool, FailureReason> {
890 let Some(expected) = self.expected_record(attempt) else {
891 return Ok(false);
892 };
893 Self::inspect_record(&expected.name)
894 .map(|found| found.is_some_and(|record| record.same_owner(&expected)))
895 .map_err(|_| failure("Windows Hyper-V ownership inspection failed"))
896 }
897
898 fn spawn(
899 &self,
900 _attempt: &RunnerAttempt,
901 _config: &runner_manager_github::jit::EncodedJitConfig,
902 ) -> Result<u32, ProcessStartFailure> {
903 Err(ProcessStartFailure::before_spawn(failure(
904 "Windows Hyper-V provider cannot launch native processes",
905 )))
906 }
907
908 fn is_alive(&self, attempt: &RunnerAttempt) -> Result<bool, FailureReason> {
909 self.inspect(attempt).map(|state| {
910 matches!(
911 state,
912 EnvironmentState::Starting | EnvironmentState::Running
913 )
914 })
915 }
916
917 fn recovered_pid(&self, _attempt: &RunnerAttempt) -> Result<Option<u32>, FailureReason> {
918 Ok(None)
919 }
920
921 fn completed_successfully(&self, attempt: &RunnerAttempt) -> bool {
922 let Some(expected) = self.expected_record(attempt) else {
923 return false;
924 };
925 matches!(Self::inspect_record(&expected.name), Ok(Some(record)) if record.same_owner(&expected) && record.state == "exited" && record.exit_code == Some(0))
926 }
927
928 fn record_terminate_intent(&self, attempt: &RunnerAttempt) -> Result<(), FailureReason> {
929 write_durable_file(&Self::intent_path(attempt), b"registration-timeout\n")
930 .map_err(|_| failure("terminate intent could not be journalled"))
931 }
932
933 fn has_terminate_intent(&self, attempt: &RunnerAttempt) -> bool {
934 Self::intent_path(attempt).is_file()
935 }
936
937 fn terminate(&self, attempt: &RunnerAttempt) -> Result<(), FailureReason> {
938 self.stop(attempt).map(|_| ())
939 }
940}
941
942#[derive(Debug, Clone)]
943struct ResourceRecord {
944 name: String,
945 state: String,
946 exit_code: Option<i64>,
947 host: HostId,
948 attempt: AttemptId,
949 generation: String,
950 image: ImageReference,
951}
952
953impl ResourceRecord {
954 fn parse(line: &str) -> Option<Self> {
955 let mut fields = line.trim_start_matches('/').split('|');
956 let name = fields.next()?.to_owned();
957 let state = fields.next()?.to_owned();
958 let exit_code = fields.next()?.parse().ok();
959 let host = HostId::from_uuid(uuid::Uuid::parse_str(fields.next()?).ok()?);
960 let attempt = AttemptId::from_uuid(uuid::Uuid::parse_str(fields.next()?).ok()?);
961 let generation = fields.next()?.to_owned();
962 let image = ImageReference::new(fields.next()?).ok()?;
963 let provider = fields.next()?;
964 (provider == PROVIDER_VALUE && fields.next().is_none()).then_some(Self {
965 name,
966 state,
967 exit_code,
968 host,
969 attempt,
970 generation,
971 image,
972 })
973 }
974
975 fn same_owner(&self, other: &Self) -> bool {
976 self.name == other.name
977 && self.host == other.host
978 && self.attempt == other.attempt
979 && self.generation == other.generation
980 && self.image == other.image
981 }
982}
983
984#[derive(Debug, Clone, PartialEq, Eq)]
985struct ContainerAttestation {
986 isolation: String,
987 nano_cpus: u64,
988 memory_bytes: u64,
989 storage_size: String,
990 network_mode: String,
991 mounts: u32,
992 privileged: bool,
993 binds: u32,
994 devices: u32,
995 image: ImageReference,
996}
997
998impl ContainerAttestation {
999 fn expected(image: &ImageReference, resources: ResourceLimits) -> Self {
1000 Self {
1001 isolation: "hyperv".into(),
1002 nano_cpus: u64::from(resources.cpu_millis) * 1_000_000,
1003 memory_bytes: u64::from(resources.memory_mib) * 1024 * 1024,
1004 storage_size: format!("{}m", resources.disk_mib),
1005 network_mode: "nat".into(),
1006 mounts: 0,
1007 privileged: false,
1008 binds: 0,
1009 devices: 0,
1010 image: image.clone(),
1011 }
1012 }
1013
1014 fn parse(line: &str) -> Option<Self> {
1015 let mut fields = line.split('|');
1016 let parsed = Self {
1017 isolation: fields.next()?.to_owned(),
1018 nano_cpus: fields.next()?.parse().ok()?,
1019 memory_bytes: fields.next()?.parse().ok()?,
1020 storage_size: fields.next()?.to_owned(),
1021 network_mode: fields.next()?.to_owned(),
1022 mounts: fields.next()?.parse().ok()?,
1023 privileged: fields.next()?.parse().ok()?,
1024 binds: fields.next()?.parse().ok()?,
1025 devices: fields.next()?.parse().ok()?,
1026 image: ImageReference::new(fields.next()?).ok()?,
1027 };
1028 fields.next().is_none().then_some(parsed)
1029 }
1030}
1031
1032#[derive(Debug, Clone, PartialEq, Eq)]
1033struct JobAttestation {
1034 nonce: String,
1035 active_process_limit: u32,
1036 limit_flags: u32,
1037}
1038
1039impl JobAttestation {
1040 fn parse(line: &str) -> Option<Self> {
1041 let mut fields = line.split('|');
1042 if fields.next()? != BOOTSTRAP_ATTESTATION_PROTOCOL {
1043 return None;
1044 }
1045 let nonce = fields.next()?;
1046 if nonce.len() != 32 || !nonce.bytes().all(|byte| byte.is_ascii_hexdigit()) {
1047 return None;
1048 }
1049 let parsed = Self {
1050 nonce: nonce.to_owned(),
1051 active_process_limit: fields.next()?.parse().ok()?,
1052 limit_flags: fields.next()?.parse().ok()?,
1053 };
1054 fields.next().is_none().then_some(parsed)
1055 }
1056}
1057
1058fn stop_failed_bootstrap(child: &mut Child, name: &str) {
1059 let _ = child.kill();
1060 let _ = WindowsHyperVContainers::docker(&os_args(["stop", "--time", "0", name]));
1061 let _ = child.wait();
1062}
1063
1064fn wait_until_exit(child: &mut Child, timeout: Duration) -> bool {
1065 let deadline = Instant::now() + timeout;
1066 loop {
1067 match child.try_wait() {
1068 Ok(Some(status)) => return status.success(),
1069 Ok(None) if Instant::now() < deadline => {
1070 std::thread::sleep(Duration::from_millis(25));
1071 }
1072 _ => return false,
1073 }
1074 }
1075}
1076
1077#[derive(Debug)]
1078struct CommandResult {
1079 success: bool,
1080 stdout: String,
1081 stderr: String,
1082}
1083
1084impl CommandResult {
1085 fn from_output(output: Output) -> Self {
1086 Self {
1087 success: output.status.success(),
1088 stdout: bounded_text(output.stdout),
1089 stderr: bounded_text(output.stderr),
1090 }
1091 }
1092
1093 fn permission_denied(&self) -> bool {
1094 let text = self.stderr.to_ascii_lowercase();
1095 text.contains("access is denied") || text.contains("permission denied")
1096 }
1097
1098 fn is_missing_resource(&self) -> bool {
1099 let text = self.stderr.to_ascii_lowercase();
1100 text.contains("no such container") || text.contains("no such object")
1101 }
1102
1103 fn failure(&self) -> CommandFailure {
1104 if self.permission_denied() {
1105 CommandFailure::PermissionDenied
1106 } else {
1107 CommandFailure::Failed
1108 }
1109 }
1110}
1111
1112#[derive(Debug, Clone, Copy, PartialEq, Eq)]
1113enum CommandFailure {
1114 NotFound,
1115 PermissionDenied,
1116 Failed,
1117}
1118
1119trait CommandRunner {
1120 fn run(&self, program: &str, args: &[OsString]) -> Result<CommandResult, CommandFailure>;
1121}
1122
1123#[derive(Debug)]
1124struct SystemCommands;
1125
1126impl CommandRunner for SystemCommands {
1127 fn run(&self, program: &str, args: &[OsString]) -> Result<CommandResult, CommandFailure> {
1128 Command::new(program)
1129 .args(args)
1130 .stdin(Stdio::null())
1131 .output()
1132 .map(CommandResult::from_output)
1133 .map_err(|error| {
1134 if error.kind() == std::io::ErrorKind::NotFound {
1135 CommandFailure::NotFound
1136 } else if error.kind() == std::io::ErrorKind::PermissionDenied {
1137 CommandFailure::PermissionDenied
1138 } else {
1139 CommandFailure::Failed
1140 }
1141 })
1142 }
1143}
1144
1145#[cfg(any(not(test), windows))]
1146fn host_state_with(commands: &dyn CommandRunner) -> WindowsHyperVHostState {
1147 if !cfg!(windows) {
1148 return WindowsHyperVHostState::UnsupportedHost;
1149 }
1150 const PROBE: &str = "$os=Get-ItemProperty 'HKLM:\\SOFTWARE\\Microsoft\\Windows NT\\CurrentVersion' -ErrorAction Stop; $f=Get-CimInstance Win32_OptionalFeature -ErrorAction Stop; $hv=@($f | Where-Object { $_.Name -in @('Microsoft-Hyper-V','Microsoft-Hyper-V-All') -and $_.InstallState -eq 1 }).Count; $ct=@($f | Where-Object { $_.Name -eq 'Containers' -and $_.InstallState -eq 1 }).Count; Write-Output \"edition=$($os.EditionID)\"; Write-Output \"build=$($os.CurrentBuildNumber)\"; Write-Output \"hyperv=$hv\"; Write-Output \"containers=$ct\"";
1151 let system = match commands.run(
1152 "powershell.exe",
1153 &os_args([
1154 "-NoLogo",
1155 "-NoProfile",
1156 "-NonInteractive",
1157 "-Command",
1158 PROBE,
1159 ]),
1160 ) {
1161 Ok(result) if result.success => result,
1162 Ok(result) if result.permission_denied() => {
1163 return WindowsHyperVHostState::RuntimePermissionDenied;
1164 }
1165 _ => return WindowsHyperVHostState::RuntimeDegraded,
1166 };
1167 let edition = value(&system.stdout, "edition").unwrap_or_default();
1168 let Some(edition_family) = supported_edition(edition) else {
1169 return WindowsHyperVHostState::UnsupportedHost;
1170 };
1171 let Some(build) = value(&system.stdout, "build").and_then(|build| build.parse::<u32>().ok())
1172 else {
1173 return WindowsHyperVHostState::RuntimeDegraded;
1174 };
1175 if !edition_family.supports_build(build) {
1176 return WindowsHyperVHostState::UnsupportedHost;
1177 }
1178 let Some(hyper_v_features) =
1179 value(&system.stdout, "hyperv").and_then(|count| count.parse::<u32>().ok())
1180 else {
1181 return WindowsHyperVHostState::RuntimeDegraded;
1182 };
1183 if hyper_v_features == 0 {
1184 return WindowsHyperVHostState::HyperVUnavailable;
1185 }
1186 let Some(container_features) =
1187 value(&system.stdout, "containers").and_then(|count| count.parse::<u32>().ok())
1188 else {
1189 return WindowsHyperVHostState::RuntimeDegraded;
1190 };
1191 if container_features == 0 {
1192 return WindowsHyperVHostState::ContainersUnavailable;
1193 }
1194 let runtime = match commands.run(
1195 "docker",
1196 &os_args(["version", "--format", "{{.Server.Os}}"]),
1197 ) {
1198 Err(CommandFailure::NotFound) => return WindowsHyperVHostState::RuntimeNotInstalled,
1199 Err(CommandFailure::PermissionDenied) => {
1200 return WindowsHyperVHostState::RuntimePermissionDenied;
1201 }
1202 Err(CommandFailure::Failed) => return WindowsHyperVHostState::RuntimeDegraded,
1203 Ok(result) if result.permission_denied() => {
1204 return WindowsHyperVHostState::RuntimePermissionDenied;
1205 }
1206 Ok(result) if !result.success => return WindowsHyperVHostState::RuntimeDegraded,
1207 Ok(result) => result,
1208 };
1209 if runtime.stdout.trim() != "windows" {
1210 return WindowsHyperVHostState::RuntimeInLinuxMode;
1211 }
1212 WindowsHyperVHostState::Ready
1216}
1217
1218fn inspect_image(image: &ImageReference) -> Result<bool, CommandFailure> {
1219 let result = WindowsHyperVContainers::docker(&os_args([
1220 "image",
1221 "inspect",
1222 "--format",
1223 "{{.Os}}|{{.Architecture}}",
1224 image.as_str(),
1225 ]))?;
1226 if !result.success {
1227 return if result.permission_denied() {
1228 Err(CommandFailure::PermissionDenied)
1229 } else {
1230 Ok(false)
1231 };
1232 }
1233 Ok(compatible_image_metadata(&result.stdout))
1234}
1235
1236fn container_inspect_args(name: &str, format: &str) -> Vec<OsString> {
1237 os_args(["container", "inspect", "--format", format, name])
1243}
1244
1245fn container_configuration_inspect_args(name: &str) -> Vec<OsString> {
1246 const FORMAT: &str = "{{.HostConfig.Isolation}}|{{.HostConfig.NanoCpus}}|{{.HostConfig.Memory}}|{{index .HostConfig.StorageOpt \"size\"}}|{{.HostConfig.NetworkMode}}|{{if .Mounts}}{{len .Mounts}}{{else}}0{{end}}|{{.HostConfig.Privileged}}|{{if .HostConfig.Binds}}{{len .HostConfig.Binds}}{{else}}0{{end}}|{{if .HostConfig.Devices}}{{len .HostConfig.Devices}}{{else}}0{{end}}|{{.Config.Image}}";
1252 container_inspect_args(name, FORMAT)
1253}
1254
1255fn compatible_image_metadata(metadata: &str) -> bool {
1256 let mut fields = metadata.trim().split('|');
1257 fields.next() == Some("windows") && fields.next() == Some("amd64") && fields.next().is_none()
1258}
1259
1260fn create_args(record: &ResourceRecord, resources: ResourceLimits) -> Vec<OsString> {
1261 let cpu = format!("{:.3}", f64::from(resources.cpu_millis) / 1000.0);
1262 let memory = format!("{}m", resources.memory_mib);
1263 let disk = format!("{}m", resources.disk_mib);
1264 let host = record.host.to_string();
1265 let attempt = record.attempt.to_string();
1266 os_args([
1267 "create",
1268 "--name",
1269 record.name.as_str(),
1270 "--isolation=hyperv",
1271 "--interactive",
1272 "--network=nat",
1273 "--cpus",
1274 &cpu,
1275 "--memory",
1276 &memory,
1277 "--storage-opt",
1278 &format!("size={disk}"),
1279 "--label",
1280 &format!("{LABEL_HOST}={host}"),
1281 "--label",
1282 &format!("{LABEL_ATTEMPT}={attempt}"),
1283 "--label",
1284 &format!("{LABEL_GENERATION}={}", record.generation),
1285 "--label",
1286 &format!("{LABEL_IMAGE}={}", record.image.as_str()),
1287 "--label",
1288 &format!("{LABEL_PROVIDER}={PROVIDER_VALUE}"),
1289 "--entrypoint",
1290 "powershell.exe",
1291 record.image.as_str(),
1292 "-NoLogo",
1293 "-NoProfile",
1294 "-NonInteractive",
1295 "-Command",
1296 BOOTSTRAP,
1297 ])
1298}
1299
1300fn container_name(host: HostId, attempt: AttemptId, generation: &str) -> String {
1301 let compact = generation
1302 .chars()
1303 .filter(|ch| ch.is_ascii_hexdigit())
1304 .take(12)
1305 .collect::<String>();
1306 format!("runner-manager-{host}-{attempt}-{compact}")
1307}
1308
1309#[derive(Debug, Clone, Copy, PartialEq, Eq)]
1310enum WindowsEditionFamily {
1311 Windows11Client,
1312 WindowsServer,
1313}
1314
1315impl WindowsEditionFamily {
1316 const fn supports_build(self, build: u32) -> bool {
1317 match self {
1318 Self::Windows11Client => build >= 22_000,
1322 Self::WindowsServer => build >= 14_393,
1324 }
1325 }
1326}
1327
1328fn supported_edition(edition: &str) -> Option<WindowsEditionFamily> {
1329 if edition.eq_ignore_ascii_case("Professional") || edition.eq_ignore_ascii_case("Enterprise") {
1330 Some(WindowsEditionFamily::Windows11Client)
1331 } else if edition.eq_ignore_ascii_case("ServerStandard")
1332 || edition.eq_ignore_ascii_case("ServerDatacenter")
1333 {
1334 Some(WindowsEditionFamily::WindowsServer)
1335 } else {
1336 None
1337 }
1338}
1339
1340#[cfg(any(not(test), windows))]
1341fn value<'a>(text: &'a str, key: &str) -> Option<&'a str> {
1342 text.lines()
1343 .find_map(|line| line.trim().strip_prefix(key)?.strip_prefix('='))
1344}
1345
1346fn os_args<const N: usize>(args: [&str; N]) -> Vec<OsString> {
1347 args.into_iter().map(OsString::from).collect()
1348}
1349
1350fn bounded_text(mut bytes: Vec<u8>) -> String {
1351 bytes.truncate(MAX_CAPTURE);
1352 String::from_utf8_lossy(&bytes).into_owned()
1353}
1354
1355fn failure(message: &'static str) -> FailureReason {
1356 FailureReason::Other(message.into())
1357}
1358
1359#[cfg(test)]
1360mod tests {
1361 #[cfg(windows)]
1362 use std::collections::VecDeque;
1363
1364 use super::*;
1365
1366 #[cfg(windows)]
1367 #[derive(Debug)]
1368 struct FakeCommands(Mutex<VecDeque<Result<CommandResult, CommandFailure>>>);
1369
1370 #[cfg(windows)]
1371 impl CommandRunner for FakeCommands {
1372 fn run(&self, _program: &str, _args: &[OsString]) -> Result<CommandResult, CommandFailure> {
1373 self.0
1374 .lock()
1375 .unwrap()
1376 .pop_front()
1377 .expect("scripted command")
1378 }
1379 }
1380
1381 #[cfg(windows)]
1382 fn output(stdout: &str) -> Result<CommandResult, CommandFailure> {
1383 Ok(CommandResult {
1384 success: true,
1385 stdout: stdout.into(),
1386 stderr: String::new(),
1387 })
1388 }
1389
1390 #[cfg(windows)]
1391 fn host_probe(
1392 system: Result<CommandResult, CommandFailure>,
1393 runtime: Option<Result<CommandResult, CommandFailure>>,
1394 ) -> WindowsHyperVHostState {
1395 let mut commands = VecDeque::from([system]);
1396 if let Some(runtime) = runtime {
1397 commands.push_back(runtime);
1398 }
1399 host_state_with(&FakeCommands(Mutex::new(commands)))
1400 }
1401
1402 #[test]
1403 fn editions_are_fail_closed() {
1404 for (supported, family) in [
1405 ("Professional", WindowsEditionFamily::Windows11Client),
1406 ("Enterprise", WindowsEditionFamily::Windows11Client),
1407 ("ServerStandard", WindowsEditionFamily::WindowsServer),
1408 ("ServerDatacenter", WindowsEditionFamily::WindowsServer),
1409 ] {
1410 assert_eq!(supported_edition(supported), Some(family), "{supported}");
1411 }
1412 for refused in [
1413 "",
1414 "Core",
1415 "Home",
1416 "Cloud",
1417 "Education",
1418 "ProfessionalEducation",
1419 "EnterpriseS",
1420 "IoTEnterprise",
1421 "ServerStandardEval",
1422 "mystery",
1423 ] {
1424 assert_eq!(supported_edition(refused), None, "{refused}");
1425 }
1426 assert!(WindowsEditionFamily::Windows11Client.supports_build(22_000));
1427 assert!(!WindowsEditionFamily::Windows11Client.supports_build(19_045));
1428 assert!(WindowsEditionFamily::WindowsServer.supports_build(14_393));
1429 }
1430
1431 #[test]
1432 fn container_inspection_never_falls_through_to_windows_plugin_lookup() {
1433 assert_eq!(
1434 container_inspect_args("runner-manager-owned", "{{.State.Status}}"),
1435 os_args([
1436 "container",
1437 "inspect",
1438 "--format",
1439 "{{.State.Status}}",
1440 "runner-manager-owned",
1441 ])
1442 );
1443 }
1444
1445 #[test]
1446 fn configuration_inspection_normalizes_every_nullable_collection() {
1447 let expected_format = "{{.HostConfig.Isolation}}|{{.HostConfig.NanoCpus}}|{{.HostConfig.Memory}}|{{index .HostConfig.StorageOpt \"size\"}}|{{.HostConfig.NetworkMode}}|{{if .Mounts}}{{len .Mounts}}{{else}}0{{end}}|{{.HostConfig.Privileged}}|{{if .HostConfig.Binds}}{{len .HostConfig.Binds}}{{else}}0{{end}}|{{if .HostConfig.Devices}}{{len .HostConfig.Devices}}{{else}}0{{end}}|{{.Config.Image}}";
1448 assert_eq!(
1449 container_configuration_inspect_args("runner-manager-owned"),
1450 os_args([
1451 "container",
1452 "inspect",
1453 "--format",
1454 expected_format,
1455 "runner-manager-owned",
1456 ])
1457 );
1458 }
1459
1460 #[cfg(windows)]
1461 #[test]
1462 fn host_probe_distinguishes_every_fail_closed_prerequisite() {
1463 assert_eq!(
1464 host_probe(
1465 output("edition=Professional\nbuild=26100\nhyperv=1\ncontainers=1\n"),
1466 Some(output("windows\n")),
1467 ),
1468 WindowsHyperVHostState::Ready
1469 );
1470 assert_eq!(
1471 host_probe(
1472 output("edition=Core\nbuild=26100\nhyperv=1\ncontainers=1\n"),
1473 None,
1474 ),
1475 WindowsHyperVHostState::UnsupportedHost
1476 );
1477 assert_eq!(
1478 host_probe(
1479 output("edition=Professional\nbuild=19045\nhyperv=1\ncontainers=1\n"),
1480 None,
1481 ),
1482 WindowsHyperVHostState::UnsupportedHost
1483 );
1484 assert_eq!(
1485 host_probe(
1486 output("edition=ServerStandard\nbuild=26100\nhyperv=1\ncontainers=1\n"),
1487 Some(output("windows\n")),
1488 ),
1489 WindowsHyperVHostState::Ready
1490 );
1491 assert_eq!(
1492 host_probe(
1493 output("edition=Professional\nbuild=26100\nhyperv=0\ncontainers=1\n"),
1494 None,
1495 ),
1496 WindowsHyperVHostState::HyperVUnavailable
1497 );
1498 assert_eq!(
1499 host_probe(
1500 output("edition=Professional\nbuild=26100\nhyperv=1\ncontainers=0\n"),
1501 None,
1502 ),
1503 WindowsHyperVHostState::ContainersUnavailable
1504 );
1505 assert_eq!(
1506 host_probe(
1507 output("edition=Professional\nbuild=26100\nhyperv=1\ncontainers=1\n"),
1508 Some(Err(CommandFailure::NotFound)),
1509 ),
1510 WindowsHyperVHostState::RuntimeNotInstalled
1511 );
1512 assert_eq!(
1513 host_probe(
1514 output("edition=Professional\nbuild=26100\nhyperv=1\ncontainers=1\n"),
1515 Some(output("linux\n")),
1516 ),
1517 WindowsHyperVHostState::RuntimeInLinuxMode
1518 );
1519 assert_eq!(
1520 host_probe(
1521 output("edition=Professional\nbuild=26100\nhyperv=1\ncontainers=1\n"),
1522 Some(Err(CommandFailure::PermissionDenied)),
1523 ),
1524 WindowsHyperVHostState::RuntimePermissionDenied
1525 );
1526 assert_eq!(
1527 host_probe(
1528 output("edition=Professional\nbuild=26100\nhyperv=1\ncontainers=1\n"),
1529 Some(Err(CommandFailure::Failed)),
1530 ),
1531 WindowsHyperVHostState::RuntimeDegraded
1532 );
1533 for malformed in [
1534 "edition=Professional\nhyperv=1\ncontainers=1\n",
1535 "edition=Professional\nbuild=not-a-build\nhyperv=1\ncontainers=1\n",
1536 "edition=Professional\nbuild=26100\ncontainers=1\n",
1537 "edition=Professional\nbuild=26100\nhyperv=not-a-count\ncontainers=1\n",
1538 "edition=Professional\nbuild=26100\nhyperv=1\n",
1539 "edition=Professional\nbuild=26100\nhyperv=1\ncontainers=not-a-count\n",
1540 ] {
1541 assert_eq!(
1542 host_probe(output(malformed), None),
1543 WindowsHyperVHostState::RuntimeDegraded,
1544 "{malformed:?}"
1545 );
1546 }
1547 }
1548
1549 #[cfg(windows)]
1550 #[test]
1551 fn education_is_refused_before_the_runtime_probe() {
1552 let state = host_probe(
1553 output("edition=Education\nbuild=26100\nhyperv=1\ncontainers=1\n"),
1554 None,
1555 );
1556 assert_eq!(state, WindowsHyperVHostState::UnsupportedHost);
1557 assert_eq!(state.capability(), ProviderCapability::Unsupported);
1558 assert!(
1559 !state.remedy().unwrap().contains("Education"),
1560 "an unsupported edition must not be advertised by its remedy"
1561 );
1562 }
1563
1564 #[test]
1565 fn image_metadata_requires_the_windows_amd64_pair_exactly() {
1566 assert!(compatible_image_metadata("windows|amd64\n"));
1567 for rejected in [
1568 "linux|amd64",
1569 "windows|arm64",
1570 "windows|amd64|extra",
1571 "windows",
1572 "",
1573 ] {
1574 assert!(!compatible_image_metadata(rejected), "{rejected}");
1575 }
1576 }
1577
1578 #[test]
1579 fn resource_record_requires_every_ownership_label() {
1580 let host = HostId::from_u128(1);
1581 let attempt = AttemptId::from_u128(2);
1582 let image = format!("registry.example/runner@sha256:{}", "a".repeat(64));
1583 let line = format!("name|running|0|{host}|{attempt}|generation|{image}|{PROVIDER_VALUE}");
1584 let record = ResourceRecord::parse(&line).expect("complete record");
1585 assert_eq!(record.host, host);
1586 assert_eq!(record.attempt, attempt);
1587 assert!(ResourceRecord::parse(&line.replace(PROVIDER_VALUE, "process")).is_none());
1588 assert!(ResourceRecord::parse("name|running").is_none());
1589 }
1590
1591 #[test]
1592 fn bootstrap_never_embeds_the_jit_value_in_docker_metadata() {
1593 assert!(BOOTSTRAP.contains("[Console]::In.ReadToEnd()"));
1594 assert!(BOOTSTRAP.contains("ACTIONS_RUNNER_INPUT_JITCONFIG"));
1595 assert!(BOOTSTRAP.contains("CREATE_SUSPENDED"));
1596 assert!(BOOTSTRAP.contains("AssignProcessToJobObject"));
1597 assert!(BOOTSTRAP.contains("IsProcessInJob"));
1598 assert!(BOOTSTRAP.contains("JOB_OBJECT_LIMIT_ACTIVE_PROCESS"));
1599 assert!(BOOTSTRAP.contains("JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE"));
1600 assert!(!BOOTSTRAP.contains("JOB_OBJECT_LIMIT_BREAKAWAY_OK"));
1601 assert!(!BOOTSTRAP.contains("JOB_OBJECT_LIMIT_SILENT_BREAKAWAY_OK"));
1602 assert!(BOOTSTRAP.contains(PREFLIGHT_INPUT));
1603 assert!(!BOOTSTRAP.contains("$env:ACTIONS_RUNNER_INPUT_JITCONFIG"));
1604 assert!(!BOOTSTRAP.contains("fixture-jit-value"));
1605 }
1606
1607 #[test]
1608 fn bootstrap_clears_the_one_time_jit_handoff_before_waiting_for_the_runner() {
1609 let run = BOOTSTRAP
1610 .split_once("public int Run")
1611 .expect("guard run method")
1612 .1;
1613 let clear = run
1614 .find("Array.Clear(jit, 0, jit.Length)")
1615 .expect("JIT clear");
1616 let wait = run.find("if (WaitForSingleObject").expect("runner wait");
1617 assert!(
1618 clear < wait,
1619 "the mutable JIT handoff must be cleared before the job wait"
1620 );
1621 assert!(BOOTSTRAP.contains("$jit=$payload.ToCharArray()\n $payload=$null"));
1622 }
1623
1624 #[test]
1625 fn job_attestation_is_typed_nonce_bound_and_exact() {
1626 let nonce = "0123456789abcdef0123456789abcdef";
1627 let line = format!(
1628 "{BOOTSTRAP_ATTESTATION_PROTOCOL}|{nonce}|{WINDOWS_PROCESS_LIMIT}|{JOB_LIMIT_FLAGS}"
1629 );
1630 assert_eq!(
1631 JobAttestation::parse(&line),
1632 Some(JobAttestation {
1633 nonce: nonce.into(),
1634 active_process_limit: WINDOWS_PROCESS_LIMIT,
1635 limit_flags: JOB_LIMIT_FLAGS,
1636 })
1637 );
1638 for rejected in [
1639 line.replace(BOOTSTRAP_ATTESTATION_PROTOCOL, "untyped"),
1640 line.replace(nonce, "short"),
1641 line.replace(&WINDOWS_PROCESS_LIMIT.to_string(), "255"),
1642 format!("{line}|extra"),
1643 ] {
1644 let parsed = JobAttestation::parse(&rejected);
1645 assert!(
1646 parsed
1647 != Some(JobAttestation {
1648 nonce: nonce.into(),
1649 active_process_limit: WINDOWS_PROCESS_LIMIT,
1650 limit_flags: JOB_LIMIT_FLAGS,
1651 }),
1652 "{rejected}"
1653 );
1654 }
1655 }
1656
1657 #[cfg(windows)]
1658 #[test]
1659 fn powershell_bootstrap_installs_queries_and_uses_the_nested_job() {
1660 let nonce = "0123456789abcdef0123456789abcdef";
1661 let compile_temp = tempfile::tempdir().expect("writable Add-Type directory");
1662 let mut child = Command::new("powershell.exe")
1663 .args([
1664 "-NoLogo",
1665 "-NoProfile",
1666 "-NonInteractive",
1667 "-Command",
1668 BOOTSTRAP,
1669 ])
1670 .env("TEMP", compile_temp.path())
1671 .env("TMP", compile_temp.path())
1672 .stdin(Stdio::piped())
1673 .stdout(Stdio::piped())
1674 .stderr(Stdio::piped())
1675 .spawn()
1676 .expect("PowerShell bootstrap starts");
1677 let mut stdin = child.stdin.take().expect("bootstrap stdin");
1678 write!(
1679 stdin,
1680 "{BOOTSTRAP_REQUEST_PROTOCOL}|{nonce}|{WINDOWS_PROCESS_LIMIT}\n{BOOTSTRAP_SELF_TEST_INPUT}"
1681 )
1682 .expect("bootstrap protocol write");
1683 drop(stdin);
1684 let success = wait_until_exit(&mut child, BOOTSTRAP_ATTESTATION_TIMEOUT);
1685 if !success {
1686 let _ = child.kill();
1687 }
1688 let mut stdout = String::new();
1689 child
1690 .stdout
1691 .take()
1692 .expect("bootstrap stdout")
1693 .read_to_string(&mut stdout)
1694 .expect("bootstrap stdout is readable");
1695 let mut stderr = String::new();
1696 child
1697 .stderr
1698 .take()
1699 .expect("bootstrap stderr")
1700 .read_to_string(&mut stderr)
1701 .expect("bootstrap stderr is readable");
1702 assert!(success, "bootstrap failed: {stderr}");
1703 assert_eq!(
1704 stdout.trim(),
1705 format!(
1706 "{BOOTSTRAP_ATTESTATION_PROTOCOL}|{nonce}|{WINDOWS_PROCESS_LIMIT}|{JOB_LIMIT_FLAGS}"
1707 )
1708 );
1709 }
1710
1711 #[test]
1712 fn container_attestation_requires_exact_limits_and_no_host_surface() {
1713 let image =
1714 ImageReference::new(format!("registry.example/runner@sha256:{}", "a".repeat(64)))
1715 .unwrap();
1716 let resources = ResourceLimits {
1717 cpu_millis: 2500,
1718 memory_mib: 3072,
1719 disk_mib: 8192,
1720 };
1721 let line = format!(
1722 "hyperv|2500000000|3221225472|8192m|nat|0|false|0|0|{}",
1723 image.as_str()
1724 );
1725 assert_eq!(
1726 ContainerAttestation::parse(&line),
1727 Some(ContainerAttestation::expected(&image, resources))
1728 );
1729 for rejected in [
1730 line.replacen("hyperv", "process", 1),
1731 line.replacen("2500000000", "2000000000", 1),
1732 line.replacen("3221225472", "2147483648", 1),
1733 line.replacen("8192m", "4096m", 1),
1734 line.replacen("|0|false|0|0|", "|1|false|0|0|", 1),
1735 line.replacen("|0|false|0|0|", "|0|true|0|0|", 1),
1736 line.replacen("|0|false|0|0|", "||false|0|0|", 1),
1737 line.replacen("|0|false|0|0|", "|0|false||0|", 1),
1738 line.replacen("|0|false|0|0|", "|0|false|0||", 1),
1739 format!("{line}|extra"),
1740 ] {
1741 assert_ne!(
1742 ContainerAttestation::parse(&rejected),
1743 Some(ContainerAttestation::expected(&image, resources)),
1744 "{rejected}"
1745 );
1746 }
1747 }
1748
1749 #[test]
1750 fn create_forces_hyper_v_limits_and_has_no_host_escape_surface() {
1751 let record = ResourceRecord {
1752 name: "runner-manager-fixture".into(),
1753 state: String::new(),
1754 exit_code: None,
1755 host: HostId::from_u128(1),
1756 attempt: AttemptId::from_u128(2),
1757 generation: "fixture-generation".into(),
1758 image: ImageReference::new(format!(
1759 "registry.example/runner@sha256:{}",
1760 "a".repeat(64)
1761 ))
1762 .unwrap(),
1763 };
1764 let args = create_args(
1765 &record,
1766 ResourceLimits {
1767 cpu_millis: 2500,
1768 memory_mib: 3072,
1769 disk_mib: 8192,
1770 },
1771 )
1772 .into_iter()
1773 .map(|arg| arg.to_string_lossy().into_owned())
1774 .collect::<Vec<_>>();
1775 assert!(args.iter().any(|arg| arg == "--isolation=hyperv"));
1776 assert!(args.windows(2).any(|pair| pair == ["--cpus", "2.500"]));
1777 assert!(args.windows(2).any(|pair| pair == ["--memory", "3072m"]));
1778 assert!(
1779 args.windows(2)
1780 .any(|pair| pair == ["--storage-opt", "size=8192m"])
1781 );
1782 assert!(!args.iter().any(|arg| arg == "--pids-limit"));
1783 for forbidden in ["--volume", "--mount", "--device", "--privileged"] {
1784 assert!(!args.iter().any(|arg| arg == forbidden), "{args:?}");
1785 }
1786 assert!(!args.iter().any(|arg| arg.contains("docker.sock")));
1787 }
1788}