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([
613 "rm",
614 "--force",
615 "--volumes",
616 expected.name.as_str(),
617 ]));
618 self.note(attempt.id, ProviderDiagnostic::PrepareFailed);
619 return Err(failure(
620 "Windows Hyper-V resource or isolation attestation failed",
621 ));
622 }
623
624 let source = format!(
625 "{}{}.",
626 attempt.runtime_path().display(),
627 std::path::MAIN_SEPARATOR
628 );
629 let destination = format!("{}:C:\\runner", expected.name);
630 let copied = Self::docker(&os_args(["cp", &source, &destination]));
631 if !matches!(copied, Ok(result) if result.success) {
632 let _ = Self::docker(&os_args([
633 "rm",
634 "--force",
635 "--volumes",
636 expected.name.as_str(),
637 ]));
638 self.note(attempt.id, ProviderDiagnostic::PrepareFailed);
639 return Err(failure(
640 "runner package copy into Windows Hyper-V container failed",
641 ));
642 }
643
644 let preflight_ok = match Self::start_attested(&expected.name) {
650 Ok((mut child, mut stdin)) => {
651 let wrote = stdin
652 .write_all(PREFLIGHT_INPUT.as_bytes())
653 .and_then(|()| stdin.flush())
654 .is_ok();
655 drop(stdin);
657 let ok = wrote && wait_until_exit(&mut child, PREFLIGHT_EXIT_TIMEOUT);
658 if !ok {
659 stop_failed_bootstrap(&mut child, &expected.name);
660 }
661 ok
662 }
663 Err(()) => false,
664 };
665 let preflight_record = Self::inspect_record(&expected.name).ok().flatten();
666 if !preflight_ok
667 || !preflight_record.is_some_and(|record| {
668 record.same_owner(&expected)
669 && record.state == "exited"
670 && record.exit_code == Some(0)
671 })
672 {
673 let _ = Self::docker(&os_args([
674 "rm",
675 "--force",
676 "--volumes",
677 expected.name.as_str(),
678 ]));
679 self.note(attempt.id, ProviderDiagnostic::PrepareFailed);
680 return Err(failure(
681 "Windows Hyper-V image bootstrap compatibility check failed",
682 ));
683 }
684
685 let identity = self
686 .identity_for(attempt, expected.name)
687 .ok_or_else(|| failure("Windows Hyper-V identity is invalid"))?;
688 self.limits
689 .lock()
690 .unwrap_or_else(std::sync::PoisonError::into_inner)
691 .insert(attempt.id, *resources);
692 Ok(PreparedEnvironment::isolated(attempt.id, identity))
693 }
694
695 fn start(
696 &self,
697 prepared: PreparedEnvironment,
698 attempt: &RunnerAttempt,
699 handoff: OneTimeJitHandoff<'_>,
700 ) -> Result<EnvironmentIdentity, ProcessStartFailure> {
701 let Some(expected) = self.expected_record(attempt) else {
702 return Err(ProcessStartFailure::before_spawn(failure(
703 "Windows Hyper-V allocation is invalid",
704 )));
705 };
706 if prepared.identity() != self.identity_for(attempt, expected.name.clone()).as_ref() {
707 self.note(attempt.id, ProviderDiagnostic::OwnershipMismatch);
708 return Err(ProcessStartFailure::before_spawn(failure(
709 "Windows Hyper-V prepared identity mismatch",
710 )));
711 }
712 if !self.owns(attempt).unwrap_or(false) {
713 self.note(attempt.id, ProviderDiagnostic::OwnershipMismatch);
714 return Err(ProcessStartFailure::before_spawn(failure(
715 "Windows Hyper-V container ownership mismatch",
716 )));
717 }
718 let Some(resources) = self
719 .limits
720 .lock()
721 .unwrap_or_else(std::sync::PoisonError::into_inner)
722 .get(&attempt.id)
723 .copied()
724 else {
725 self.note(attempt.id, ProviderDiagnostic::StartFailed);
726 return Err(ProcessStartFailure::before_spawn(failure(
727 "Windows Hyper-V resource attestation is unavailable",
728 )));
729 };
730 if !Self::configuration_is_exact(&expected.name, &expected.image, resources) {
731 self.note(attempt.id, ProviderDiagnostic::StartFailed);
732 return Err(ProcessStartFailure::before_spawn(failure(
733 "Windows Hyper-V resource or isolation attestation failed",
734 )));
735 }
736 let (mut child, mut stdin) = Self::start_attested(&expected.name).map_err(|()| {
742 self.note(attempt.id, ProviderDiagnostic::StartFailed);
743 ProcessStartFailure::after_spawn_stopped()
744 })?;
745 let write_result = stdin
746 .write_all(handoff.consume().expose().as_bytes())
747 .and_then(|()| stdin.flush());
748 drop(stdin);
749 if write_result.is_err() {
750 stop_failed_bootstrap(&mut child, &expected.name);
751 self.note(attempt.id, ProviderDiagnostic::StartFailed);
752 return Err(ProcessStartFailure::after_spawn_stopped());
753 }
754 self.children
755 .lock()
756 .unwrap_or_else(std::sync::PoisonError::into_inner)
757 .insert(attempt.id, child);
758 self.identity_for(attempt, expected.name)
759 .ok_or_else(ProcessStartFailure::after_spawn_stopped)
760 }
761
762 fn inspect(&self, attempt: &RunnerAttempt) -> Result<EnvironmentState, FailureReason> {
763 let Some(expected) = self.expected_record(attempt) else {
764 return Err(failure("Windows Hyper-V allocation is invalid"));
765 };
766 let Some(found) = Self::inspect_record(&expected.name)
767 .map_err(|_| failure("Windows Hyper-V container inspection failed"))?
768 else {
769 return Ok(EnvironmentState::Missing);
770 };
771 if !found.same_owner(&expected) {
772 self.note(attempt.id, ProviderDiagnostic::OwnershipMismatch);
773 return Err(failure("Windows Hyper-V container ownership mismatch"));
774 }
775 Ok(match found.state.as_str() {
776 "created" | "restarting" => EnvironmentState::Starting,
777 "running" | "paused" => EnvironmentState::Running,
778 "exited" | "dead" | "removing" => EnvironmentState::Exited,
779 _ => EnvironmentState::Exited,
780 })
781 }
782
783 fn stop(&self, attempt: &RunnerAttempt) -> Result<ProviderStop, FailureReason> {
784 let Some(expected) = self.expected_record(attempt) else {
785 return Err(failure("Windows Hyper-V allocation is invalid"));
786 };
787 if !self.owns(attempt)? {
788 return if Self::inspect_record(&expected.name)
789 .ok()
790 .flatten()
791 .is_none()
792 {
793 Ok(ProviderStop::Stopped)
794 } else {
795 self.note(attempt.id, ProviderDiagnostic::OwnershipMismatch);
796 Err(failure("Windows Hyper-V container ownership mismatch"))
797 };
798 }
799 if let Some(mut child) = self
800 .children
801 .lock()
802 .unwrap_or_else(std::sync::PoisonError::into_inner)
803 .remove(&attempt.id)
804 {
805 let _ = child.kill();
806 let _ = child.wait();
807 }
808 if matches!(Self::inspect_record(&expected.name), Ok(Some(record)) if matches!(record.state.as_str(), "exited" | "dead"))
809 {
810 return Ok(ProviderStop::Stopped);
811 }
812 let stopped = Self::docker(&os_args(["stop", "--time", "10", expected.name.as_str()]))
813 .map_err(|_| failure("Windows Hyper-V container stop failed"))?;
814 Ok(if stopped.success || stopped.is_missing_resource() {
815 ProviderStop::Stopped
816 } else {
817 ProviderStop::StillRunning
818 })
819 }
820
821 fn destroy(&self, attempt: &RunnerAttempt) -> Result<ProviderDestroy, FailureReason> {
822 let Some(expected) = self.expected_record(attempt) else {
823 return Err(failure("Windows Hyper-V allocation is invalid"));
824 };
825 if !self.owns(attempt)? {
826 return if Self::inspect_record(&expected.name)
827 .ok()
828 .flatten()
829 .is_none()
830 {
831 self.limits
832 .lock()
833 .unwrap_or_else(std::sync::PoisonError::into_inner)
834 .remove(&attempt.id);
835 Ok(ProviderDestroy::Destroyed)
836 } else {
837 self.note(attempt.id, ProviderDiagnostic::OwnershipMismatch);
838 Err(failure("Windows Hyper-V container ownership mismatch"))
839 };
840 }
841 let removed = Self::docker(&os_args([
842 "rm",
843 "--force",
844 "--volumes",
845 expected.name.as_str(),
846 ]))
847 .map_err(|_| failure("Windows Hyper-V container destroy failed"))?;
848 if (removed.success || removed.is_missing_resource())
849 && Self::inspect_record(&expected.name)
850 .ok()
851 .flatten()
852 .is_none()
853 {
854 self.limits
855 .lock()
856 .unwrap_or_else(std::sync::PoisonError::into_inner)
857 .remove(&attempt.id);
858 return Ok(ProviderDestroy::Destroyed);
859 }
860 self.note(attempt.id, ProviderDiagnostic::CleanupDeferred);
861 Ok(ProviderDestroy::Deferred(
862 "container runtime retained resource",
863 ))
864 }
865
866 fn recover(&self, attempt: &RunnerAttempt) -> Result<EnvironmentState, FailureReason> {
867 self.inspect(attempt)
868 }
869
870 fn enumerate_owned(&self, host_id: HostId) -> Vec<EnvironmentIdentity> {
871 let filter = format!("label={LABEL_HOST}={host_id}");
872 let Ok(list) = Self::docker(&os_args([
873 "ps",
874 "--all",
875 "--filter",
876 &filter,
877 "--format",
878 "{{.Names}}",
879 ])) else {
880 return Vec::new();
881 };
882 if !list.success {
883 return Vec::new();
884 }
885 list.stdout
886 .lines()
887 .take(1024)
888 .filter_map(|name| Self::inspect_record(name).ok().flatten())
889 .filter(|record| record.host == host_id)
890 .map(|record| EnvironmentIdentity::Isolated {
891 host: record.host,
892 attempt: record.attempt,
893 provider_kind: Backend::WindowsHyperVContainer,
894 environment_id: record.name,
895 resolved_image: record.image,
896 generation: record.generation,
897 })
898 .collect()
899 }
900
901 fn diagnostics(&self, attempt: &RunnerAttempt) -> Vec<ProviderDiagnostic> {
902 self.diagnostics
903 .lock()
904 .ok()
905 .and_then(|all| all.get(&attempt.id).cloned())
906 .unwrap_or_default()
907 }
908
909 fn owns(&self, attempt: &RunnerAttempt) -> Result<bool, FailureReason> {
910 let Some(expected) = self.expected_record(attempt) else {
911 return Ok(false);
912 };
913 Self::inspect_record(&expected.name)
914 .map(|found| found.is_some_and(|record| record.same_owner(&expected)))
915 .map_err(|_| failure("Windows Hyper-V ownership inspection failed"))
916 }
917
918 fn spawn(
919 &self,
920 _attempt: &RunnerAttempt,
921 _config: &runner_manager_github::jit::EncodedJitConfig,
922 ) -> Result<u32, ProcessStartFailure> {
923 Err(ProcessStartFailure::before_spawn(failure(
924 "Windows Hyper-V provider cannot launch native processes",
925 )))
926 }
927
928 fn is_alive(&self, attempt: &RunnerAttempt) -> Result<bool, FailureReason> {
929 self.inspect(attempt).map(|state| {
930 matches!(
931 state,
932 EnvironmentState::Starting | EnvironmentState::Running
933 )
934 })
935 }
936
937 fn recovered_pid(&self, _attempt: &RunnerAttempt) -> Result<Option<u32>, FailureReason> {
938 Ok(None)
939 }
940
941 fn completed_successfully(&self, attempt: &RunnerAttempt) -> bool {
942 let Some(expected) = self.expected_record(attempt) else {
943 return false;
944 };
945 matches!(Self::inspect_record(&expected.name), Ok(Some(record)) if record.same_owner(&expected) && record.state == "exited" && record.exit_code == Some(0))
946 }
947
948 fn record_terminate_intent(&self, attempt: &RunnerAttempt) -> Result<(), FailureReason> {
949 write_durable_file(&Self::intent_path(attempt), b"registration-timeout\n")
950 .map_err(|_| failure("terminate intent could not be journalled"))
951 }
952
953 fn has_terminate_intent(&self, attempt: &RunnerAttempt) -> bool {
954 Self::intent_path(attempt).is_file()
955 }
956
957 fn terminate(&self, attempt: &RunnerAttempt) -> Result<(), FailureReason> {
958 self.stop(attempt).map(|_| ())
959 }
960}
961
962#[derive(Debug, Clone)]
963struct ResourceRecord {
964 name: String,
965 state: String,
966 exit_code: Option<i64>,
967 host: HostId,
968 attempt: AttemptId,
969 generation: String,
970 image: ImageReference,
971}
972
973impl ResourceRecord {
974 fn parse(line: &str) -> Option<Self> {
975 let mut fields = line.trim_start_matches('/').split('|');
976 let name = fields.next()?.to_owned();
977 let state = fields.next()?.to_owned();
978 let exit_code = fields.next()?.parse().ok();
979 let host = HostId::from_uuid(uuid::Uuid::parse_str(fields.next()?).ok()?);
980 let attempt = AttemptId::from_uuid(uuid::Uuid::parse_str(fields.next()?).ok()?);
981 let generation = fields.next()?.to_owned();
982 let image = ImageReference::new(fields.next()?).ok()?;
983 let provider = fields.next()?;
984 (provider == PROVIDER_VALUE && fields.next().is_none()).then_some(Self {
985 name,
986 state,
987 exit_code,
988 host,
989 attempt,
990 generation,
991 image,
992 })
993 }
994
995 fn same_owner(&self, other: &Self) -> bool {
996 self.name == other.name
997 && self.host == other.host
998 && self.attempt == other.attempt
999 && self.generation == other.generation
1000 && self.image == other.image
1001 }
1002}
1003
1004#[derive(Debug, Clone, PartialEq, Eq)]
1005struct ContainerAttestation {
1006 isolation: String,
1007 nano_cpus: u64,
1008 memory_bytes: u64,
1009 storage_size: String,
1010 network_mode: String,
1011 mounts: u32,
1012 privileged: bool,
1013 binds: u32,
1014 devices: u32,
1015 image: ImageReference,
1016}
1017
1018impl ContainerAttestation {
1019 fn expected(image: &ImageReference, resources: ResourceLimits) -> Self {
1020 Self {
1021 isolation: "hyperv".into(),
1022 nano_cpus: u64::from(resources.cpu_millis) * 1_000_000,
1023 memory_bytes: u64::from(resources.memory_mib) * 1024 * 1024,
1024 storage_size: format!("{}m", resources.disk_mib),
1025 network_mode: "nat".into(),
1026 mounts: 0,
1027 privileged: false,
1028 binds: 0,
1029 devices: 0,
1030 image: image.clone(),
1031 }
1032 }
1033
1034 fn parse(line: &str) -> Option<Self> {
1035 let mut fields = line.split('|');
1036 let parsed = Self {
1037 isolation: fields.next()?.to_owned(),
1038 nano_cpus: fields.next()?.parse().ok()?,
1039 memory_bytes: fields.next()?.parse().ok()?,
1040 storage_size: fields.next()?.to_owned(),
1041 network_mode: fields.next()?.to_owned(),
1042 mounts: fields.next()?.parse().ok()?,
1043 privileged: fields.next()?.parse().ok()?,
1044 binds: fields.next()?.parse().ok()?,
1045 devices: fields.next()?.parse().ok()?,
1046 image: ImageReference::new(fields.next()?).ok()?,
1047 };
1048 fields.next().is_none().then_some(parsed)
1049 }
1050}
1051
1052#[derive(Debug, Clone, PartialEq, Eq)]
1053struct JobAttestation {
1054 nonce: String,
1055 active_process_limit: u32,
1056 limit_flags: u32,
1057}
1058
1059impl JobAttestation {
1060 fn parse(line: &str) -> Option<Self> {
1061 let mut fields = line.split('|');
1062 if fields.next()? != BOOTSTRAP_ATTESTATION_PROTOCOL {
1063 return None;
1064 }
1065 let nonce = fields.next()?;
1066 if nonce.len() != 32 || !nonce.bytes().all(|byte| byte.is_ascii_hexdigit()) {
1067 return None;
1068 }
1069 let parsed = Self {
1070 nonce: nonce.to_owned(),
1071 active_process_limit: fields.next()?.parse().ok()?,
1072 limit_flags: fields.next()?.parse().ok()?,
1073 };
1074 fields.next().is_none().then_some(parsed)
1075 }
1076}
1077
1078fn stop_failed_bootstrap(child: &mut Child, name: &str) {
1079 let _ = child.kill();
1080 let _ = WindowsHyperVContainers::docker(&os_args(["stop", "--time", "0", name]));
1081 let _ = child.wait();
1082}
1083
1084fn wait_until_exit(child: &mut Child, timeout: Duration) -> bool {
1085 let deadline = Instant::now() + timeout;
1086 loop {
1087 match child.try_wait() {
1088 Ok(Some(status)) => return status.success(),
1089 Ok(None) if Instant::now() < deadline => {
1090 std::thread::sleep(Duration::from_millis(25));
1091 }
1092 _ => return false,
1093 }
1094 }
1095}
1096
1097#[derive(Debug)]
1098struct CommandResult {
1099 success: bool,
1100 stdout: String,
1101 stderr: String,
1102}
1103
1104impl CommandResult {
1105 fn from_output(output: Output) -> Self {
1106 Self {
1107 success: output.status.success(),
1108 stdout: bounded_text(output.stdout),
1109 stderr: bounded_text(output.stderr),
1110 }
1111 }
1112
1113 fn permission_denied(&self) -> bool {
1114 let text = self.stderr.to_ascii_lowercase();
1115 text.contains("access is denied") || text.contains("permission denied")
1116 }
1117
1118 fn is_missing_resource(&self) -> bool {
1119 let text = self.stderr.to_ascii_lowercase();
1120 text.contains("no such container") || text.contains("no such object")
1121 }
1122
1123 fn failure(&self) -> CommandFailure {
1124 if self.permission_denied() {
1125 CommandFailure::PermissionDenied
1126 } else {
1127 CommandFailure::Failed
1128 }
1129 }
1130}
1131
1132#[derive(Debug, Clone, Copy, PartialEq, Eq)]
1133enum CommandFailure {
1134 NotFound,
1135 PermissionDenied,
1136 Failed,
1137}
1138
1139trait CommandRunner {
1140 fn run(&self, program: &str, args: &[OsString]) -> Result<CommandResult, CommandFailure>;
1141}
1142
1143#[derive(Debug)]
1144struct SystemCommands;
1145
1146impl CommandRunner for SystemCommands {
1147 fn run(&self, program: &str, args: &[OsString]) -> Result<CommandResult, CommandFailure> {
1148 Command::new(program)
1149 .args(args)
1150 .stdin(Stdio::null())
1151 .output()
1152 .map(CommandResult::from_output)
1153 .map_err(|error| {
1154 if error.kind() == std::io::ErrorKind::NotFound {
1155 CommandFailure::NotFound
1156 } else if error.kind() == std::io::ErrorKind::PermissionDenied {
1157 CommandFailure::PermissionDenied
1158 } else {
1159 CommandFailure::Failed
1160 }
1161 })
1162 }
1163}
1164
1165#[cfg(any(not(test), windows))]
1166fn host_state_with(commands: &dyn CommandRunner) -> WindowsHyperVHostState {
1167 if !cfg!(windows) {
1168 return WindowsHyperVHostState::UnsupportedHost;
1169 }
1170 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\"";
1171 let system = match commands.run(
1172 "powershell.exe",
1173 &os_args([
1174 "-NoLogo",
1175 "-NoProfile",
1176 "-NonInteractive",
1177 "-Command",
1178 PROBE,
1179 ]),
1180 ) {
1181 Ok(result) if result.success => result,
1182 Ok(result) if result.permission_denied() => {
1183 return WindowsHyperVHostState::RuntimePermissionDenied;
1184 }
1185 _ => return WindowsHyperVHostState::RuntimeDegraded,
1186 };
1187 let edition = value(&system.stdout, "edition").unwrap_or_default();
1188 let Some(edition_family) = supported_edition(edition) else {
1189 return WindowsHyperVHostState::UnsupportedHost;
1190 };
1191 let Some(build) = value(&system.stdout, "build").and_then(|build| build.parse::<u32>().ok())
1192 else {
1193 return WindowsHyperVHostState::RuntimeDegraded;
1194 };
1195 if !edition_family.supports_build(build) {
1196 return WindowsHyperVHostState::UnsupportedHost;
1197 }
1198 let Some(hyper_v_features) =
1199 value(&system.stdout, "hyperv").and_then(|count| count.parse::<u32>().ok())
1200 else {
1201 return WindowsHyperVHostState::RuntimeDegraded;
1202 };
1203 if hyper_v_features == 0 {
1204 return WindowsHyperVHostState::HyperVUnavailable;
1205 }
1206 let Some(container_features) =
1207 value(&system.stdout, "containers").and_then(|count| count.parse::<u32>().ok())
1208 else {
1209 return WindowsHyperVHostState::RuntimeDegraded;
1210 };
1211 if container_features == 0 {
1212 return WindowsHyperVHostState::ContainersUnavailable;
1213 }
1214 let runtime = match commands.run(
1215 "docker",
1216 &os_args(["version", "--format", "{{.Server.Os}}"]),
1217 ) {
1218 Err(CommandFailure::NotFound) => return WindowsHyperVHostState::RuntimeNotInstalled,
1219 Err(CommandFailure::PermissionDenied) => {
1220 return WindowsHyperVHostState::RuntimePermissionDenied;
1221 }
1222 Err(CommandFailure::Failed) => return WindowsHyperVHostState::RuntimeDegraded,
1223 Ok(result) if result.permission_denied() => {
1224 return WindowsHyperVHostState::RuntimePermissionDenied;
1225 }
1226 Ok(result) if !result.success => return WindowsHyperVHostState::RuntimeDegraded,
1227 Ok(result) => result,
1228 };
1229 if runtime.stdout.trim() != "windows" {
1230 return WindowsHyperVHostState::RuntimeInLinuxMode;
1231 }
1232 WindowsHyperVHostState::Ready
1236}
1237
1238fn inspect_image(image: &ImageReference) -> Result<bool, CommandFailure> {
1239 let result = WindowsHyperVContainers::docker(&os_args([
1240 "image",
1241 "inspect",
1242 "--format",
1243 "{{.Os}}|{{.Architecture}}",
1244 image.as_str(),
1245 ]))?;
1246 if !result.success {
1247 return if result.permission_denied() {
1248 Err(CommandFailure::PermissionDenied)
1249 } else {
1250 Ok(false)
1251 };
1252 }
1253 Ok(compatible_image_metadata(&result.stdout))
1254}
1255
1256fn container_inspect_args(name: &str, format: &str) -> Vec<OsString> {
1257 os_args(["container", "inspect", "--format", format, name])
1263}
1264
1265fn container_configuration_inspect_args(name: &str) -> Vec<OsString> {
1266 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}}";
1272 container_inspect_args(name, FORMAT)
1273}
1274
1275fn compatible_image_metadata(metadata: &str) -> bool {
1276 let mut fields = metadata.trim().split('|');
1277 fields.next() == Some("windows") && fields.next() == Some("amd64") && fields.next().is_none()
1278}
1279
1280fn create_args(record: &ResourceRecord, resources: ResourceLimits) -> Vec<OsString> {
1281 let cpu = format!("{:.3}", f64::from(resources.cpu_millis) / 1000.0);
1282 let memory = format!("{}m", resources.memory_mib);
1283 let disk = format!("{}m", resources.disk_mib);
1284 let host = record.host.to_string();
1285 let attempt = record.attempt.to_string();
1286 os_args([
1287 "create",
1288 "--name",
1289 record.name.as_str(),
1290 "--isolation=hyperv",
1291 "--interactive",
1292 "--network=nat",
1293 "--cpus",
1294 &cpu,
1295 "--memory",
1296 &memory,
1297 "--storage-opt",
1298 &format!("size={disk}"),
1299 "--label",
1300 &format!("{LABEL_HOST}={host}"),
1301 "--label",
1302 &format!("{LABEL_ATTEMPT}={attempt}"),
1303 "--label",
1304 &format!("{LABEL_GENERATION}={}", record.generation),
1305 "--label",
1306 &format!("{LABEL_IMAGE}={}", record.image.as_str()),
1307 "--label",
1308 &format!("{LABEL_PROVIDER}={PROVIDER_VALUE}"),
1309 "--entrypoint",
1310 "powershell.exe",
1311 record.image.as_str(),
1312 "-NoLogo",
1313 "-NoProfile",
1314 "-NonInteractive",
1315 "-Command",
1316 BOOTSTRAP,
1317 ])
1318}
1319
1320fn container_name(host: HostId, attempt: AttemptId, generation: &str) -> String {
1321 let compact = generation
1322 .chars()
1323 .filter(|ch| ch.is_ascii_hexdigit())
1324 .take(12)
1325 .collect::<String>();
1326 format!("runner-manager-{host}-{attempt}-{compact}")
1327}
1328
1329#[derive(Debug, Clone, Copy, PartialEq, Eq)]
1330enum WindowsEditionFamily {
1331 Windows11Client,
1332 WindowsServer,
1333}
1334
1335impl WindowsEditionFamily {
1336 const fn supports_build(self, build: u32) -> bool {
1337 match self {
1338 Self::Windows11Client => build >= 22_000,
1342 Self::WindowsServer => build >= 14_393,
1344 }
1345 }
1346}
1347
1348fn supported_edition(edition: &str) -> Option<WindowsEditionFamily> {
1349 if edition.eq_ignore_ascii_case("Professional") || edition.eq_ignore_ascii_case("Enterprise") {
1350 Some(WindowsEditionFamily::Windows11Client)
1351 } else if edition.eq_ignore_ascii_case("ServerStandard")
1352 || edition.eq_ignore_ascii_case("ServerDatacenter")
1353 {
1354 Some(WindowsEditionFamily::WindowsServer)
1355 } else {
1356 None
1357 }
1358}
1359
1360#[cfg(any(not(test), windows))]
1361fn value<'a>(text: &'a str, key: &str) -> Option<&'a str> {
1362 text.lines()
1363 .find_map(|line| line.trim().strip_prefix(key)?.strip_prefix('='))
1364}
1365
1366fn os_args<const N: usize>(args: [&str; N]) -> Vec<OsString> {
1367 args.into_iter().map(OsString::from).collect()
1368}
1369
1370fn bounded_text(mut bytes: Vec<u8>) -> String {
1371 bytes.truncate(MAX_CAPTURE);
1372 String::from_utf8_lossy(&bytes).into_owned()
1373}
1374
1375fn failure(message: &'static str) -> FailureReason {
1376 FailureReason::Other(message.into())
1377}
1378
1379#[cfg(test)]
1380mod tests {
1381 #[cfg(windows)]
1382 use std::collections::VecDeque;
1383
1384 use super::*;
1385
1386 #[cfg(windows)]
1387 #[derive(Debug)]
1388 struct FakeCommands(Mutex<VecDeque<Result<CommandResult, CommandFailure>>>);
1389
1390 #[cfg(windows)]
1391 impl CommandRunner for FakeCommands {
1392 fn run(&self, _program: &str, _args: &[OsString]) -> Result<CommandResult, CommandFailure> {
1393 self.0
1394 .lock()
1395 .unwrap()
1396 .pop_front()
1397 .expect("scripted command")
1398 }
1399 }
1400
1401 #[cfg(windows)]
1402 fn output(stdout: &str) -> Result<CommandResult, CommandFailure> {
1403 Ok(CommandResult {
1404 success: true,
1405 stdout: stdout.into(),
1406 stderr: String::new(),
1407 })
1408 }
1409
1410 #[cfg(windows)]
1411 fn host_probe(
1412 system: Result<CommandResult, CommandFailure>,
1413 runtime: Option<Result<CommandResult, CommandFailure>>,
1414 ) -> WindowsHyperVHostState {
1415 let mut commands = VecDeque::from([system]);
1416 if let Some(runtime) = runtime {
1417 commands.push_back(runtime);
1418 }
1419 host_state_with(&FakeCommands(Mutex::new(commands)))
1420 }
1421
1422 #[test]
1423 fn editions_are_fail_closed() {
1424 for (supported, family) in [
1425 ("Professional", WindowsEditionFamily::Windows11Client),
1426 ("Enterprise", WindowsEditionFamily::Windows11Client),
1427 ("ServerStandard", WindowsEditionFamily::WindowsServer),
1428 ("ServerDatacenter", WindowsEditionFamily::WindowsServer),
1429 ] {
1430 assert_eq!(supported_edition(supported), Some(family), "{supported}");
1431 }
1432 for refused in [
1433 "",
1434 "Core",
1435 "Home",
1436 "Cloud",
1437 "Education",
1438 "ProfessionalEducation",
1439 "EnterpriseS",
1440 "IoTEnterprise",
1441 "ServerStandardEval",
1442 "mystery",
1443 ] {
1444 assert_eq!(supported_edition(refused), None, "{refused}");
1445 }
1446 assert!(WindowsEditionFamily::Windows11Client.supports_build(22_000));
1447 assert!(!WindowsEditionFamily::Windows11Client.supports_build(19_045));
1448 assert!(WindowsEditionFamily::WindowsServer.supports_build(14_393));
1449 }
1450
1451 #[test]
1452 fn container_inspection_never_falls_through_to_windows_plugin_lookup() {
1453 assert_eq!(
1454 container_inspect_args("runner-manager-owned", "{{.State.Status}}"),
1455 os_args([
1456 "container",
1457 "inspect",
1458 "--format",
1459 "{{.State.Status}}",
1460 "runner-manager-owned",
1461 ])
1462 );
1463 }
1464
1465 #[test]
1466 fn configuration_inspection_normalizes_every_nullable_collection() {
1467 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}}";
1468 assert_eq!(
1469 container_configuration_inspect_args("runner-manager-owned"),
1470 os_args([
1471 "container",
1472 "inspect",
1473 "--format",
1474 expected_format,
1475 "runner-manager-owned",
1476 ])
1477 );
1478 }
1479
1480 #[cfg(windows)]
1481 #[test]
1482 fn host_probe_distinguishes_every_fail_closed_prerequisite() {
1483 assert_eq!(
1484 host_probe(
1485 output("edition=Professional\nbuild=26100\nhyperv=1\ncontainers=1\n"),
1486 Some(output("windows\n")),
1487 ),
1488 WindowsHyperVHostState::Ready
1489 );
1490 assert_eq!(
1491 host_probe(
1492 output("edition=Core\nbuild=26100\nhyperv=1\ncontainers=1\n"),
1493 None,
1494 ),
1495 WindowsHyperVHostState::UnsupportedHost
1496 );
1497 assert_eq!(
1498 host_probe(
1499 output("edition=Professional\nbuild=19045\nhyperv=1\ncontainers=1\n"),
1500 None,
1501 ),
1502 WindowsHyperVHostState::UnsupportedHost
1503 );
1504 assert_eq!(
1505 host_probe(
1506 output("edition=ServerStandard\nbuild=26100\nhyperv=1\ncontainers=1\n"),
1507 Some(output("windows\n")),
1508 ),
1509 WindowsHyperVHostState::Ready
1510 );
1511 assert_eq!(
1512 host_probe(
1513 output("edition=Professional\nbuild=26100\nhyperv=0\ncontainers=1\n"),
1514 None,
1515 ),
1516 WindowsHyperVHostState::HyperVUnavailable
1517 );
1518 assert_eq!(
1519 host_probe(
1520 output("edition=Professional\nbuild=26100\nhyperv=1\ncontainers=0\n"),
1521 None,
1522 ),
1523 WindowsHyperVHostState::ContainersUnavailable
1524 );
1525 assert_eq!(
1526 host_probe(
1527 output("edition=Professional\nbuild=26100\nhyperv=1\ncontainers=1\n"),
1528 Some(Err(CommandFailure::NotFound)),
1529 ),
1530 WindowsHyperVHostState::RuntimeNotInstalled
1531 );
1532 assert_eq!(
1533 host_probe(
1534 output("edition=Professional\nbuild=26100\nhyperv=1\ncontainers=1\n"),
1535 Some(output("linux\n")),
1536 ),
1537 WindowsHyperVHostState::RuntimeInLinuxMode
1538 );
1539 assert_eq!(
1540 host_probe(
1541 output("edition=Professional\nbuild=26100\nhyperv=1\ncontainers=1\n"),
1542 Some(Err(CommandFailure::PermissionDenied)),
1543 ),
1544 WindowsHyperVHostState::RuntimePermissionDenied
1545 );
1546 assert_eq!(
1547 host_probe(
1548 output("edition=Professional\nbuild=26100\nhyperv=1\ncontainers=1\n"),
1549 Some(Err(CommandFailure::Failed)),
1550 ),
1551 WindowsHyperVHostState::RuntimeDegraded
1552 );
1553 for malformed in [
1554 "edition=Professional\nhyperv=1\ncontainers=1\n",
1555 "edition=Professional\nbuild=not-a-build\nhyperv=1\ncontainers=1\n",
1556 "edition=Professional\nbuild=26100\ncontainers=1\n",
1557 "edition=Professional\nbuild=26100\nhyperv=not-a-count\ncontainers=1\n",
1558 "edition=Professional\nbuild=26100\nhyperv=1\n",
1559 "edition=Professional\nbuild=26100\nhyperv=1\ncontainers=not-a-count\n",
1560 ] {
1561 assert_eq!(
1562 host_probe(output(malformed), None),
1563 WindowsHyperVHostState::RuntimeDegraded,
1564 "{malformed:?}"
1565 );
1566 }
1567 }
1568
1569 #[cfg(windows)]
1570 #[test]
1571 fn education_is_refused_before_the_runtime_probe() {
1572 let state = host_probe(
1573 output("edition=Education\nbuild=26100\nhyperv=1\ncontainers=1\n"),
1574 None,
1575 );
1576 assert_eq!(state, WindowsHyperVHostState::UnsupportedHost);
1577 assert_eq!(state.capability(), ProviderCapability::Unsupported);
1578 assert!(
1579 !state.remedy().unwrap().contains("Education"),
1580 "an unsupported edition must not be advertised by its remedy"
1581 );
1582 }
1583
1584 #[test]
1585 fn image_metadata_requires_the_windows_amd64_pair_exactly() {
1586 assert!(compatible_image_metadata("windows|amd64\n"));
1587 for rejected in [
1588 "linux|amd64",
1589 "windows|arm64",
1590 "windows|amd64|extra",
1591 "windows",
1592 "",
1593 ] {
1594 assert!(!compatible_image_metadata(rejected), "{rejected}");
1595 }
1596 }
1597
1598 #[test]
1599 fn resource_record_requires_every_ownership_label() {
1600 let host = HostId::from_u128(1);
1601 let attempt = AttemptId::from_u128(2);
1602 let image = format!("registry.example/runner@sha256:{}", "a".repeat(64));
1603 let line = format!("name|running|0|{host}|{attempt}|generation|{image}|{PROVIDER_VALUE}");
1604 let record = ResourceRecord::parse(&line).expect("complete record");
1605 assert_eq!(record.host, host);
1606 assert_eq!(record.attempt, attempt);
1607 assert!(ResourceRecord::parse(&line.replace(PROVIDER_VALUE, "process")).is_none());
1608 assert!(ResourceRecord::parse("name|running").is_none());
1609 }
1610
1611 #[test]
1612 fn bootstrap_never_embeds_the_jit_value_in_docker_metadata() {
1613 assert!(BOOTSTRAP.contains("[Console]::In.ReadToEnd()"));
1614 assert!(BOOTSTRAP.contains("ACTIONS_RUNNER_INPUT_JITCONFIG"));
1615 assert!(BOOTSTRAP.contains("CREATE_SUSPENDED"));
1616 assert!(BOOTSTRAP.contains("AssignProcessToJobObject"));
1617 assert!(BOOTSTRAP.contains("IsProcessInJob"));
1618 assert!(BOOTSTRAP.contains("JOB_OBJECT_LIMIT_ACTIVE_PROCESS"));
1619 assert!(BOOTSTRAP.contains("JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE"));
1620 assert!(!BOOTSTRAP.contains("JOB_OBJECT_LIMIT_BREAKAWAY_OK"));
1621 assert!(!BOOTSTRAP.contains("JOB_OBJECT_LIMIT_SILENT_BREAKAWAY_OK"));
1622 assert!(BOOTSTRAP.contains(PREFLIGHT_INPUT));
1623 assert!(!BOOTSTRAP.contains("$env:ACTIONS_RUNNER_INPUT_JITCONFIG"));
1624 assert!(!BOOTSTRAP.contains("fixture-jit-value"));
1625 }
1626
1627 #[test]
1628 fn bootstrap_clears_the_one_time_jit_handoff_before_waiting_for_the_runner() {
1629 let run = BOOTSTRAP
1630 .split_once("public int Run")
1631 .expect("guard run method")
1632 .1;
1633 let clear = run
1634 .find("Array.Clear(jit, 0, jit.Length)")
1635 .expect("JIT clear");
1636 let wait = run.find("if (WaitForSingleObject").expect("runner wait");
1637 assert!(
1638 clear < wait,
1639 "the mutable JIT handoff must be cleared before the job wait"
1640 );
1641 assert!(BOOTSTRAP.contains("$jit=$payload.ToCharArray()\n $payload=$null"));
1642 }
1643
1644 #[test]
1645 fn job_attestation_is_typed_nonce_bound_and_exact() {
1646 let nonce = "0123456789abcdef0123456789abcdef";
1647 let line = format!(
1648 "{BOOTSTRAP_ATTESTATION_PROTOCOL}|{nonce}|{WINDOWS_PROCESS_LIMIT}|{JOB_LIMIT_FLAGS}"
1649 );
1650 assert_eq!(
1651 JobAttestation::parse(&line),
1652 Some(JobAttestation {
1653 nonce: nonce.into(),
1654 active_process_limit: WINDOWS_PROCESS_LIMIT,
1655 limit_flags: JOB_LIMIT_FLAGS,
1656 })
1657 );
1658 for rejected in [
1659 line.replace(BOOTSTRAP_ATTESTATION_PROTOCOL, "untyped"),
1660 line.replace(nonce, "short"),
1661 line.replace(&WINDOWS_PROCESS_LIMIT.to_string(), "255"),
1662 format!("{line}|extra"),
1663 ] {
1664 let parsed = JobAttestation::parse(&rejected);
1665 assert!(
1666 parsed
1667 != Some(JobAttestation {
1668 nonce: nonce.into(),
1669 active_process_limit: WINDOWS_PROCESS_LIMIT,
1670 limit_flags: JOB_LIMIT_FLAGS,
1671 }),
1672 "{rejected}"
1673 );
1674 }
1675 }
1676
1677 #[cfg(windows)]
1678 #[test]
1679 fn powershell_bootstrap_installs_queries_and_uses_the_nested_job() {
1680 let nonce = "0123456789abcdef0123456789abcdef";
1681 let compile_temp = tempfile::tempdir().expect("writable Add-Type directory");
1682 let mut child = Command::new("powershell.exe")
1683 .args([
1684 "-NoLogo",
1685 "-NoProfile",
1686 "-NonInteractive",
1687 "-Command",
1688 BOOTSTRAP,
1689 ])
1690 .env("TEMP", compile_temp.path())
1691 .env("TMP", compile_temp.path())
1692 .stdin(Stdio::piped())
1693 .stdout(Stdio::piped())
1694 .stderr(Stdio::piped())
1695 .spawn()
1696 .expect("PowerShell bootstrap starts");
1697 let mut stdin = child.stdin.take().expect("bootstrap stdin");
1698 write!(
1699 stdin,
1700 "{BOOTSTRAP_REQUEST_PROTOCOL}|{nonce}|{WINDOWS_PROCESS_LIMIT}\n{BOOTSTRAP_SELF_TEST_INPUT}"
1701 )
1702 .expect("bootstrap protocol write");
1703 drop(stdin);
1704 let success = wait_until_exit(&mut child, BOOTSTRAP_ATTESTATION_TIMEOUT);
1705 if !success {
1706 let _ = child.kill();
1707 }
1708 let mut stdout = String::new();
1709 child
1710 .stdout
1711 .take()
1712 .expect("bootstrap stdout")
1713 .read_to_string(&mut stdout)
1714 .expect("bootstrap stdout is readable");
1715 let mut stderr = String::new();
1716 child
1717 .stderr
1718 .take()
1719 .expect("bootstrap stderr")
1720 .read_to_string(&mut stderr)
1721 .expect("bootstrap stderr is readable");
1722 assert!(success, "bootstrap failed: {stderr}");
1723 assert_eq!(
1724 stdout.trim(),
1725 format!(
1726 "{BOOTSTRAP_ATTESTATION_PROTOCOL}|{nonce}|{WINDOWS_PROCESS_LIMIT}|{JOB_LIMIT_FLAGS}"
1727 )
1728 );
1729 }
1730
1731 #[test]
1732 fn container_attestation_requires_exact_limits_and_no_host_surface() {
1733 let image =
1734 ImageReference::new(format!("registry.example/runner@sha256:{}", "a".repeat(64)))
1735 .unwrap();
1736 let resources = ResourceLimits {
1737 cpu_millis: 2500,
1738 memory_mib: 3072,
1739 disk_mib: 8192,
1740 };
1741 let line = format!(
1742 "hyperv|2500000000|3221225472|8192m|nat|0|false|0|0|{}",
1743 image.as_str()
1744 );
1745 assert_eq!(
1746 ContainerAttestation::parse(&line),
1747 Some(ContainerAttestation::expected(&image, resources))
1748 );
1749 for rejected in [
1750 line.replacen("hyperv", "process", 1),
1751 line.replacen("2500000000", "2000000000", 1),
1752 line.replacen("3221225472", "2147483648", 1),
1753 line.replacen("8192m", "4096m", 1),
1754 line.replacen("|0|false|0|0|", "|1|false|0|0|", 1),
1755 line.replacen("|0|false|0|0|", "|0|true|0|0|", 1),
1756 line.replacen("|0|false|0|0|", "||false|0|0|", 1),
1757 line.replacen("|0|false|0|0|", "|0|false||0|", 1),
1758 line.replacen("|0|false|0|0|", "|0|false|0||", 1),
1759 format!("{line}|extra"),
1760 ] {
1761 assert_ne!(
1762 ContainerAttestation::parse(&rejected),
1763 Some(ContainerAttestation::expected(&image, resources)),
1764 "{rejected}"
1765 );
1766 }
1767 }
1768
1769 #[test]
1770 fn create_forces_hyper_v_limits_and_has_no_host_escape_surface() {
1771 let record = ResourceRecord {
1772 name: "runner-manager-fixture".into(),
1773 state: String::new(),
1774 exit_code: None,
1775 host: HostId::from_u128(1),
1776 attempt: AttemptId::from_u128(2),
1777 generation: "fixture-generation".into(),
1778 image: ImageReference::new(format!(
1779 "registry.example/runner@sha256:{}",
1780 "a".repeat(64)
1781 ))
1782 .unwrap(),
1783 };
1784 let args = create_args(
1785 &record,
1786 ResourceLimits {
1787 cpu_millis: 2500,
1788 memory_mib: 3072,
1789 disk_mib: 8192,
1790 },
1791 )
1792 .into_iter()
1793 .map(|arg| arg.to_string_lossy().into_owned())
1794 .collect::<Vec<_>>();
1795 assert!(args.iter().any(|arg| arg == "--isolation=hyperv"));
1796 assert!(args.windows(2).any(|pair| pair == ["--cpus", "2.500"]));
1797 assert!(args.windows(2).any(|pair| pair == ["--memory", "3072m"]));
1798 assert!(
1799 args.windows(2)
1800 .any(|pair| pair == ["--storage-opt", "size=8192m"])
1801 );
1802 assert!(!args.iter().any(|arg| arg == "--pids-limit"));
1803 for forbidden in ["--volume", "--mount", "--device", "--privileged"] {
1804 assert!(!args.iter().any(|arg| arg == forbidden), "{args:?}");
1805 }
1806 assert!(!args.iter().any(|arg| arg.contains("docker.sock")));
1807 }
1808}