Skip to content

Where people and agents ship software together. The open-source git platform for the whole job: issues, agents, checks and deploys to the edge.

379 linesCodeBlameRaw
1//! Set up: what the sandbox still needs on Windows that Windows can do
2//! itself, done at once behind one administrator prompt, as Docker
3//! Desktop does. The launcher says what is missing, by name, before
4//! anything is downloaded (`needs`, crates/runner/src/selfhosted/needs.rs);
5//! this turns on the Virtual Machine Platform and Windows Sandbox features
6//! and installs or updates WSL, for the kinds of job the person has on.
7//!
8//! The script is the app's own and fixed: nothing in it comes from the
9//! network or from a file another program could change between writing it
10//! and Windows running it as administrator. It goes to PowerShell as
11//! `-EncodedCommand`, and its only values are the app's own paths.
12//!
13//! What it turned on that takes a restart, it says by writing this boot's
14//! time to the launcher's `restart-pending`: the launcher then says
15//! `restart` for the kinds of job that wait on it, until Windows has
16//! restarted. After a restart g1t opens again (RunOnce, if it doesn't open
17//! at sign-in anyway) and starts the runner as it always does.
18//!
19//! Everything here runs on a thread of its own: the prompt waits on the
20//! person, and installing WSL takes minutes.
21
22use std::path::Path;
23
24use super::launcher::Status;
25
26/// The needs Set up meets.
27pub const FIXABLE: [&str; 4] = ["vm_platform", "wsl", "wsl_update", "windows_sandbox"];
28/// The launcher's file that says a restart is still to be done (needs.rs's RESTART_FILE).
29pub const RESTART_FILE: &str = "restart-pending";
30/// What Windows' installer says when it worked and wants a restart.
31const NEEDS_RESTART: u32 = 3010;
32/// What Windows says when the person said No to the prompt (ERROR_CANCELLED).
33const CANCELLED: u32 = 1223;
34
35/// What Set up is to do.
36#[derive(Clone, Debug, Default, PartialEq, Eq)]
37pub struct Plan {
38 /// Windows features to turn on.
39 pub features: Vec<&'static str>,
40 pub wsl: Option<Wsl>,
41}
42
43#[derive(Clone, Copy, Debug, PartialEq, Eq)]
44pub enum Wsl {
45 Install,
46 Update,
47}
48
49impl Plan {
50 pub fn is_empty(&self) -> bool {
51 self.features.is_empty() && self.wsl.is_none()
52 }
53
54 /// What it does, said: `turns on Virtual Machine Platform and installs WSL`.
55 pub fn said(&self) -> String {
56 let mut steps: Vec<String> = Vec::new();
57 let named: Vec<&str> = self
58 .features
59 .iter()
60 .map(|feature| match *feature {
61 "VirtualMachinePlatform" => "Virtual Machine Platform",
62 "Containers-DisposableClientVM" => "Windows Sandbox",
63 other => other,
64 })
65 .collect();
66 if !named.is_empty() {
67 steps.push(format!("turns on {}", named.join(" and ")));
68 }
69 match self.wsl {
70 Some(Wsl::Install) => steps.push("installs WSL".into()),
71 Some(Wsl::Update) => steps.push("updates WSL".into()),
72 None => {}
73 }
74 steps.join(" and ")
75 }
76}
77
78/// Whether a kind of job's needs are all ones Set up meets.
79pub fn fixable(needs: &[String]) -> bool {
80 !needs.is_empty() && needs.iter().all(|need| FIXABLE.contains(&need.as_str()))
81}
82
83/// What Set up does for the kinds of job that are on (`on`) and wait only on it.
84pub fn plan(status: &Status, on: impl Fn(&str) -> bool) -> Plan {
85 plan_of(&needs(status, on))
86}
87
88/// The needs of the kinds of job that are on (`on`).
89pub fn needs(status: &Status, on: impl Fn(&str) -> bool) -> Vec<String> {
90 status.capabilities.iter().filter(|c| on(&c.id) && !c.off()).flat_map(|c| c.needs.iter().cloned()).collect()
91}
92
93/// What Set up does for these needs.
94pub fn plan_of(needs: &[String]) -> Plan {
95 let mut plan = Plan::default();
96 for need in needs {
97 match need.as_str() {
98 "vm_platform" if !plan.features.contains(&"VirtualMachinePlatform") => plan.features.push("VirtualMachinePlatform"),
99 "windows_sandbox" if !plan.features.contains(&"Containers-DisposableClientVM") => plan.features.push("Containers-DisposableClientVM"),
100 "wsl" => plan.wsl = Some(Wsl::Install),
101 "wsl_update" if plan.wsl.is_none() => plan.wsl = Some(Wsl::Update),
102 _ => {}
103 }
104 }
105 plan
106}
107
108/// A path as a PowerShell single-quoted string.
109fn quoted(path: &Path) -> String {
110 format!("'{}'", path.display().to_string().replace('\'', "''"))
111}
112
113/// The script, run as administrator: it logs to `log`, and leaves this
114/// boot's time in `marker` when what it did takes a restart.
115pub fn script(plan: &Plan, log: &Path, marker: &Path) -> String {
116 let features = plan.features.iter().map(|f| format!("'{f}'")).collect::<Vec<_>>().join(", ");
117 let wsl = match plan.wsl {
118 Some(Wsl::Install) => "Install-Wsl '--install', '--no-distribution'",
119 Some(Wsl::Update) => "Install-Wsl '--update'",
120 None => "",
121 };
122 format!(
123 r#"$ErrorActionPreference = 'Stop'
124$log = {log}
125$env:WSL_UTF8 = '1'
126function Say($text) {{ Add-Content -LiteralPath $log -Value ('g1t setup: ' + $text) }}
127function Install-Wsl {{
128 # From GitHub first (--web-download), which needs no Microsoft Store; else from the Store.
129 foreach ($from in @(@('--web-download'), @())) {{
130 $out = Join-Path $env:TEMP 'g1t-setup-wsl.txt'
131 $p = Start-Process -FilePath 'wsl.exe' -ArgumentList ($args + $from) -Wait -PassThru -WindowStyle Hidden -RedirectStandardOutput $out
132 Get-Content -LiteralPath $out -ErrorAction SilentlyContinue | Where-Object {{ $_.Trim() }} | ForEach-Object {{ Say ('wsl: ' + $_.Trim()) }}
133 Remove-Item -LiteralPath $out -ErrorAction SilentlyContinue
134 if ($p.ExitCode -eq 0) {{ return }}
135 Say ('wsl.exe ' + (($args + $from) -join ' ') + ' exited with ' + $p.ExitCode)
136 }}
137 throw 'WSL could not be installed or updated.'
138}}
139$restart = $false
140try {{
141 foreach ($name in @({features})) {{
142 $feature = Get-WindowsOptionalFeature -Online -FeatureName $name
143 if ($feature.State -ne 'Enabled') {{
144 Say "Turning on $name."
145 $result = Enable-WindowsOptionalFeature -Online -FeatureName $name -All -NoRestart -WarningAction SilentlyContinue
146 if ($result.RestartNeeded) {{ $restart = $true }}
147 }}
148 }}
149 {wsl}
150 if ($restart) {{
151 Set-Content -LiteralPath {marker} -Value (Get-CimInstance Win32_OperatingSystem).LastBootUpTime.ToUniversalTime().ToString('o')
152 Say 'Done; Windows needs a restart to finish.'
153 exit {NEEDS_RESTART}
154 }}
155 Say 'Done.'
156 exit 0
157}} catch {{
158 Say ('Failed: ' + $_.Exception.Message)
159 exit 1
160}}
161"#,
162 log = quoted(log),
163 marker = quoted(marker),
164 )
165}
166
167/// PowerShell's `-EncodedCommand`: UTF-16LE in base64.
168pub fn encoded(script: &str) -> String {
169 use base64::Engine;
170 let bytes: Vec<u8> = script.encode_utf16().flat_map(u16::to_le_bytes).collect();
171 base64::engine::general_purpose::STANDARD.encode(bytes)
172}
173
174/// How Set up went.
175#[derive(Clone, Debug, PartialEq, Eq)]
176pub enum Outcome {
177 Done,
178 /// Done, and Windows needs a restart to finish.
179 Restart,
180 /// The person said No to Windows' prompt.
181 Declined,
182 Failed(String),
183}
184
185/// What an exit code means.
186pub fn outcome(code: u32) -> Outcome {
187 match code {
188 0 => Outcome::Done,
189 NEEDS_RESTART => Outcome::Restart,
190 CANCELLED => Outcome::Declined,
191 other => Outcome::Failed(format!("Set up stopped (exit code {other}).")),
192 }
193}
194
195/// Runs the script as administrator, asking Windows first (its prompt,
196/// in front of `window`), and waits for it.
197#[cfg(windows)]
198pub fn run_elevated(script: &str, window: isize) -> Outcome {
199 use std::ffi::OsStr;
200 use std::os::windows::ffi::OsStrExt;
201
202 #[repr(C)]
203 struct ShellExecuteInfo {
204 size: u32,
205 mask: u32,
206 window: isize,
207 verb: *const u16,
208 file: *const u16,
209 parameters: *const u16,
210 directory: *const u16,
211 show: i32,
212 instance: isize,
213 id_list: *mut std::ffi::c_void,
214 class: *const u16,
215 class_key: isize,
216 hot_key: u32,
217 icon_or_monitor: isize,
218 process: isize,
219 }
220 #[link(name = "shell32")]
221 unsafe extern "system" {
222 fn ShellExecuteExW(info: *mut ShellExecuteInfo) -> i32;
223 }
224 unsafe extern "system" {
225 fn WaitForSingleObject(handle: isize, millis: u32) -> u32;
226 fn GetExitCodeProcess(handle: isize, code: *mut u32) -> i32;
227 fn CloseHandle(handle: isize) -> i32;
228 fn GetLastError() -> u32;
229 }
230 const SEE_MASK_NOCLOSEPROCESS: u32 = 0x40;
231 const SEE_MASK_NOASYNC: u32 = 0x100;
232 const SW_HIDE: i32 = 0;
233 const INFINITE: u32 = u32::MAX;
234
235 let wide = |text: &str| OsStr::new(text).encode_wide().chain(std::iter::once(0)).collect::<Vec<u16>>();
236 let verb = wide("runas");
237 let file = wide("powershell.exe");
238 let parameters = wide(&format!("-NoProfile -NonInteractive -ExecutionPolicy Bypass -EncodedCommand {}", encoded(script)));
239 let mut info = ShellExecuteInfo {
240 size: std::mem::size_of::<ShellExecuteInfo>() as u32,
241 mask: SEE_MASK_NOCLOSEPROCESS | SEE_MASK_NOASYNC,
242 window,
243 verb: verb.as_ptr(),
244 file: file.as_ptr(),
245 parameters: parameters.as_ptr(),
246 directory: std::ptr::null(),
247 show: SW_HIDE,
248 instance: 0,
249 id_list: std::ptr::null_mut(),
250 class: std::ptr::null(),
251 class_key: 0,
252 hot_key: 0,
253 icon_or_monitor: 0,
254 process: 0,
255 };
256 // SAFETY: `info` and the strings it points to live until the call returns.
257 if unsafe { ShellExecuteExW(&mut info) } == 0 {
258 let error = unsafe { GetLastError() };
259 return if error == CANCELLED { Outcome::Declined } else { Outcome::Failed(format!("Windows couldn't start Set up (error {error}).")) };
260 }
261 if info.process == 0 {
262 return Outcome::Failed("Windows started Set up without a process to wait for.".into());
263 }
264 let mut code = 0u32;
265 // SAFETY: the process handle is ours until CloseHandle.
266 unsafe {
267 WaitForSingleObject(info.process, INFINITE);
268 GetExitCodeProcess(info.process, &mut code);
269 CloseHandle(info.process);
270 }
271 outcome(code)
272}
273
274#[cfg(not(windows))]
275pub fn run_elevated(_script: &str, _window: isize) -> Outcome {
276 Outcome::Failed("Set up is for Windows; the runner's card says what to do here.".into())
277}
278
279/// Restarts Windows now. g1t opens again after, once (RunOnce), and
280/// carries on setting up.
281pub fn restart_windows(app_exe: &Path) -> Result<(), String> {
282 if !cfg!(windows) {
283 return Err("Only Windows is restarted from g1t.".into());
284 }
285 open_after_restart(app_exe);
286 let status = super::backend::quiet("shutdown.exe")
287 .args(["/r", "/t", "0", "/c", "g1t is restarting Windows to finish setting up the runner."])
288 .status()
289 .map_err(|e| format!("Windows couldn't be restarted: {e}"))?;
290 if status.success() { Ok(()) } else { Err("Windows didn't restart: restart it from the Start menu.".into()) }
291}
292
293/// g1t opens once when the person next signs in to Windows, after the
294/// restart (`RunOnce`, which Windows clears as it runs it), whether or not
295/// it opens at sign-in anyway: setup carries on from there.
296pub fn open_after_restart(app_exe: &Path) {
297 if cfg!(windows) {
298 let _ = super::backend::quiet("reg")
299 .args(["add", r"HKCU\Software\Microsoft\Windows\CurrentVersion\RunOnce", "/v", "g1t", "/t", "REG_SZ", "/d", &format!("\"{}\"", app_exe.display()), "/f"])
300 .stdout(std::process::Stdio::null())
301 .stderr(std::process::Stdio::null())
302 .status();
303 }
304}
305
306#[cfg(test)]
307mod tests {
308 use super::*;
309
310 fn status(json: &str) -> Status {
311 super::super::launcher::parse(json).unwrap()
312 }
313
314 #[test]
315 fn set_up_does_what_the_kinds_that_are_on_need() {
316 let clean = status(
317 r#"{"schema":1,"state":"error","capabilities":[
318 {"id":"linux","available":false,"state":"error","needs":["vm_platform","wsl"]},
319 {"id":"windows","available":false,"state":"absent","needs":["windows_sandbox"]}]}"#,
320 );
321 let both = plan(&clean, |_| true);
322 assert_eq!(both.features, ["VirtualMachinePlatform", "Containers-DisposableClientVM"]);
323 assert_eq!(both.wsl, Some(Wsl::Install));
324 assert_eq!(both.said(), "turns on Virtual Machine Platform and Windows Sandbox and installs WSL");
325 // Windows jobs stopped by the person: their feature is left alone.
326 let linux = plan(&clean, |kind| kind == "linux");
327 assert_eq!(linux.features, ["VirtualMachinePlatform"]);
328 assert_eq!(linux.said(), "turns on Virtual Machine Platform and installs WSL");
329 let update = status(r#"{"schema":1,"state":"error","capabilities":[{"id":"linux","available":false,"needs":["wsl_update"]}]}"#);
330 assert_eq!(plan(&update, |_| true).said(), "updates WSL");
331 // Home: nothing Set up can do for Windows jobs.
332 let home = status(r#"{"schema":1,"state":"running","capabilities":[{"id":"linux","available":true,"state":"running"},{"id":"windows","available":false,"needs":["windows_edition"]}]}"#);
333 assert!(plan(&home, |_| true).is_empty());
334 assert!(fixable(&["vm_platform".into(), "wsl".into()]));
335 assert!(!fixable(&["virtualization".into(), "vm_platform".into()]));
336 assert!(!fixable(&[]));
337 }
338
339 #[test]
340 fn the_script_is_fixed_and_says_a_restart_by_its_exit_code() {
341 let plan = Plan { features: vec!["VirtualMachinePlatform"], wsl: Some(Wsl::Install) };
342 let script = script(&plan, Path::new(r"C:\Users\o'neil\AppData\Local\sh.g1t\runner\setup.log"), Path::new(r"C:\x\restart-pending"));
343 assert!(script.contains(r"$log = 'C:\Users\o''neil\AppData\Local\sh.g1t\runner\setup.log'"));
344 assert!(script.contains("foreach ($name in @('VirtualMachinePlatform'))"));
345 assert!(script.contains("Install-Wsl '--install', '--no-distribution'"));
346 assert!(script.contains("exit 3010"));
347 assert!(!script.contains("{features}") && !script.contains("{wsl}"));
348 assert_eq!(outcome(0), Outcome::Done);
349 assert_eq!(outcome(3010), Outcome::Restart);
350 assert_eq!(outcome(1223), Outcome::Declined);
351 assert!(matches!(outcome(1), Outcome::Failed(_)));
352 // UTF-16LE in base64, as PowerShell reads it: "a" is 61 00.
353 assert_eq!(encoded("a"), "YQA=");
354 }
355
356 /// Runs the script as this user, not as administrator, so it changes
357 /// nothing: it must parse, log, and fail plainly. Windows only:
358 /// `cargo test -- --ignored the_script_runs --nocapture`.
359 #[test]
360 #[ignore]
361 fn the_script_runs_and_without_an_administrator_says_why() {
362 let dir = std::env::temp_dir().join(format!("g1t-setup-{}", std::process::id()));
363 std::fs::create_dir_all(&dir).unwrap();
364 let (log, marker) = (dir.join("setup.log"), dir.join(RESTART_FILE));
365 let plan = Plan { features: vec!["VirtualMachinePlatform"], wsl: Some(Wsl::Update) };
366 let code = super::super::backend::quiet("powershell.exe")
367 .args(["-NoProfile", "-NonInteractive", "-ExecutionPolicy", "Bypass", "-EncodedCommand", &encoded(&script(&plan, &log, &marker))])
368 .status()
369 .unwrap()
370 .code()
371 .unwrap();
372 let said = std::fs::read_to_string(&log).unwrap();
373 println!("exit {code}\n{said}");
374 assert_eq!(outcome(code as u32), Outcome::Failed("Set up stopped (exit code 1).".into()));
375 assert!(said.contains("g1t setup: Failed:"), "{said}");
376 assert!(!marker.exists());
377 let _ = std::fs::remove_dir_all(&dir);
378 }
379}