Skip to main content

runner_manager_agent/lifecycle/
windows_hyperv.rs

1//! Hyper-V-isolated Windows container execution.
2//!
3//! Docker is used as the Windows container control plane, but no Docker socket,
4//! host directory, device, or credential is exposed to the container.  The
5//! verified runner package is copied into a fresh writable layer and the JIT
6//! value crosses the boundary once over the attached container stdin.
7
8use 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
48// This script is immutable Docker container metadata. It creates a nested Job
49// Object inside the Windows container, installs and queries the active-process
50// limit, and only then acknowledges the host. Runner.Listener is created
51// suspended, assigned to the job, checked for membership, and resumed. Neither
52// breakaway flag is set, so ordinary descendants inherit the nested job.
53const 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/// Host-only preflight detail. It is a closed vocabulary and never carries
254/// provider output, so CLI/TUI surfaces cannot accidentally expose it.
255#[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        // Runner.Listener inherits the attached output handles. Drain them so
473        // a verbose action cannot fill the pipe and stall the process tree.
474        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        // Exercise the exact Hyper-V container, bootstrap, and copied runner
645        // before GitHub issues a JIT registration. An OS/architecture match is
646        // insufficient: an image can still lack PowerShell or the libraries
647        // Runner.Listener needs. The fixed marker is non-secret and the same
648        // stopped container is restarted later with the one-time JIT input.
649        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                // ReadToEnd is the payload boundary.
656                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        // The script is constant container metadata. The JIT value is never an
737        // argument, Docker environment setting, label, file, or layer; it is
738        // sent only after the nonce-bound Job Object attestation proves the
739        // active-process limit and kill-on-close flag. Runner.Listener is then
740        // created suspended, assigned, membership-checked, and resumed.
741        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    // Process count is installed inside the container by the pinned bootstrap
1233    // and proved again before JIT. Native client and Server acceptance remains
1234    // a separate preview gate; the static host prerequisites are ready here.
1235    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    // Generic `docker inspect` probes every supported object kind. When a
1258    // container is absent, that fallback reaches the managed-plugin endpoint;
1259    // Windows Docker Engine rejects that endpoint before the provider can
1260    // classify the container as missing. Keep this lookup type-scoped so a
1261    // missing owned container remains an ordinary, recoverable state.
1262    os_args(["container", "inspect", "--format", format, name])
1263}
1264
1265fn container_configuration_inspect_args(name: &str) -> Vec<OsString> {
1266    // Docker serializes absent Windows collections as JSON null. Go template's
1267    // `len` rejects a nil pointer, so normalize only those absent collections
1268    // to zero before the strict ten-field parser validates the result. Values
1269    // whose absence is invalid (including StorageOpt.size) remain unguarded and
1270    // therefore fail closed.
1271    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            // Windows 11 starts at build 22000. The client runtime path is
1339            // Docker Desktop, whose Windows-container support is limited to
1340            // Pro and Enterprise rather than every Desktop-supported edition.
1341            Self::Windows11Client => build >= 22_000,
1342            // Windows containers first shipped in Windows Server 2016.
1343            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}