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.

475 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
140function Restart-Pending {{
141 Set-Content -LiteralPath {marker} -Value (Get-CimInstance Win32_OperatingSystem).LastBootUpTime.ToUniversalTime().ToString('o')
142}}
143try {{
144 foreach ($name in @({features})) {{
145 $feature = Get-WindowsOptionalFeature -Online -FeatureName $name
146 if ($feature.State -ne 'Enabled') {{
147 Say "Turning on $name."
148 $result = Enable-WindowsOptionalFeature -Online -FeatureName $name -All -NoRestart -WarningAction SilentlyContinue
149 if ($result.RestartNeeded) {{ $restart = $true }}
150 }}
151 }}
152 {wsl}
153 if ($restart) {{
154 Restart-Pending
155 Say 'Done; Windows needs a restart to finish.'
156 exit {NEEDS_RESTART}
157 }}
158 Say 'Done.'
159 exit 0
160}} catch {{
161 Say ('Failed: ' + $_.Exception.Message)
162 # What was turned on still takes the restart; after it, Set up offers what is left.
163 if ($restart) {{
164 Restart-Pending
165 Say 'Windows needs a restart for what was turned on; Set up does the rest after it.'
166 exit {NEEDS_RESTART}
167 }}
168 exit 1
169}}
170"#,
171 log = quoted(log),
172 marker = quoted(marker),
173 )
174}
175
176/// PowerShell's `-EncodedCommand`: UTF-16LE in base64.
177pub fn encoded(script: &str) -> String {
178 use base64::Engine;
179 let bytes: Vec<u8> = script.encode_utf16().flat_map(u16::to_le_bytes).collect();
180 base64::engine::general_purpose::STANDARD.encode(bytes)
181}
182
183/// How Set up went.
184#[derive(Clone, Debug, PartialEq, Eq)]
185pub enum Outcome {
186 Done,
187 /// Done, and Windows needs a restart to finish.
188 Restart,
189 /// The person said No to Windows' prompt.
190 Declined,
191 Failed(String),
192}
193
194/// What an exit code means.
195pub fn outcome(code: u32) -> Outcome {
196 match code {
197 0 => Outcome::Done,
198 NEEDS_RESTART => Outcome::Restart,
199 CANCELLED => Outcome::Declined,
200 other => Outcome::Failed(format!("Set up stopped (exit code {other}).")),
201 }
202}
203
204/// Runs the script as administrator, asking Windows first (its prompt,
205/// in front of `window`), and waits for it.
206#[cfg(windows)]
207pub fn run_elevated(script: &str, window: isize) -> Outcome {
208 use std::ffi::OsStr;
209 use std::os::windows::ffi::OsStrExt;
210
211 #[repr(C)]
212 struct ShellExecuteInfo {
213 size: u32,
214 mask: u32,
215 window: isize,
216 verb: *const u16,
217 file: *const u16,
218 parameters: *const u16,
219 directory: *const u16,
220 show: i32,
221 instance: isize,
222 id_list: *mut std::ffi::c_void,
223 class: *const u16,
224 class_key: isize,
225 hot_key: u32,
226 icon_or_monitor: isize,
227 process: isize,
228 }
229 #[link(name = "shell32")]
230 unsafe extern "system" {
231 fn ShellExecuteExW(info: *mut ShellExecuteInfo) -> i32;
232 }
233 unsafe extern "system" {
234 fn WaitForSingleObject(handle: isize, millis: u32) -> u32;
235 fn GetExitCodeProcess(handle: isize, code: *mut u32) -> i32;
236 fn CloseHandle(handle: isize) -> i32;
237 fn GetLastError() -> u32;
238 }
239 const SEE_MASK_NOCLOSEPROCESS: u32 = 0x40;
240 const SEE_MASK_NOASYNC: u32 = 0x100;
241 const SW_HIDE: i32 = 0;
242 const INFINITE: u32 = u32::MAX;
243
244 let wide = |text: &str| OsStr::new(text).encode_wide().chain(std::iter::once(0)).collect::<Vec<u16>>();
245 let verb = wide("runas");
246 let file = wide("powershell.exe");
247 let parameters = wide(&format!("-NoProfile -NonInteractive -ExecutionPolicy Bypass -EncodedCommand {}", encoded(script)));
248 let mut info = ShellExecuteInfo {
249 size: std::mem::size_of::<ShellExecuteInfo>() as u32,
250 mask: SEE_MASK_NOCLOSEPROCESS | SEE_MASK_NOASYNC,
251 window,
252 verb: verb.as_ptr(),
253 file: file.as_ptr(),
254 parameters: parameters.as_ptr(),
255 directory: std::ptr::null(),
256 show: SW_HIDE,
257 instance: 0,
258 id_list: std::ptr::null_mut(),
259 class: std::ptr::null(),
260 class_key: 0,
261 hot_key: 0,
262 icon_or_monitor: 0,
263 process: 0,
264 };
265 // SAFETY: `info` and the strings it points to live until the call returns.
266 if unsafe { ShellExecuteExW(&mut info) } == 0 {
267 let error = unsafe { GetLastError() };
268 return if error == CANCELLED { Outcome::Declined } else { Outcome::Failed(format!("Windows couldn't start Set up (error {error}).")) };
269 }
270 if info.process == 0 {
271 return Outcome::Failed("Windows started Set up without a process to wait for.".into());
272 }
273 let mut code = 0u32;
274 // SAFETY: the process handle is ours until CloseHandle.
275 unsafe {
276 WaitForSingleObject(info.process, INFINITE);
277 GetExitCodeProcess(info.process, &mut code);
278 CloseHandle(info.process);
279 }
280 outcome(code)
281}
282
283#[cfg(not(windows))]
284pub fn run_elevated(_script: &str, _window: isize) -> Outcome {
285 Outcome::Failed("Set up is for Windows; the runner's card says what to do here.".into())
286}
287
288/// What Windows' own dialog asks before g1t restarts it.
289pub const RESTART_QUESTION: &str = "Restart Windows now to finish setting up the runner?\n\nSave your work first. g1t opens again after the restart and carries on by itself.";
290
291/// Asks with Windows' own dialog, which no page can answer for the
292/// person, and restarts Windows on Yes: on a thread of its own, as the
293/// dialog waits for them. Restarting is asked this way wherever the button
294/// is, the site's pages included.
295pub fn ask_and_restart(app_exe: std::path::PathBuf) -> Result<(), String> {
296 if !cfg!(windows) {
297 return Err("Only Windows is restarted from g1t.".into());
298 }
299 std::thread::Builder::new()
300 .name("g1t-restart-windows".into())
301 .spawn(move || {
302 if asked_yes(RESTART_QUESTION) {
303 let _ = restart_windows(&app_exe);
304 }
305 })
306 .map(|_| ())
307 .map_err(|e| format!("Windows couldn't be asked: {e}"))
308}
309
310#[cfg(windows)]
311fn asked_yes(question: &str) -> bool {
312 use std::ffi::OsStr;
313 use std::os::windows::ffi::OsStrExt;
314 #[link(name = "user32")]
315 unsafe extern "system" {
316 fn MessageBoxW(window: isize, text: *const u16, caption: *const u16, kind: u32) -> i32;
317 }
318 const MB_YESNO: u32 = 0x4;
319 const MB_ICONQUESTION: u32 = 0x20;
320 const MB_DEFBUTTON2: u32 = 0x100;
321 const MB_SETFOREGROUND: u32 = 0x1_0000;
322 const MB_TOPMOST: u32 = 0x4_0000;
323 const IDYES: i32 = 6;
324 let wide = |text: &str| OsStr::new(text).encode_wide().chain(std::iter::once(0)).collect::<Vec<u16>>();
325 let (text, caption) = (wide(question), wide("g1t"));
326 // SAFETY: both strings live until the call returns. No owner window: this
327 // isn't the window's thread, and the dialog stays on top by itself.
328 unsafe { MessageBoxW(0, text.as_ptr(), caption.as_ptr(), MB_YESNO | MB_ICONQUESTION | MB_DEFBUTTON2 | MB_SETFOREGROUND | MB_TOPMOST) == IDYES }
329}
330
331#[cfg(not(windows))]
332fn asked_yes(_question: &str) -> bool {
333 false
334}
335
336/// Restarts Windows now. g1t opens again after, once (RunOnce), and
337/// carries on setting up.
338fn restart_windows(app_exe: &Path) -> Result<(), String> {
339 open_after_restart(app_exe);
340 let status = super::backend::quiet("shutdown.exe")
341 .args(["/r", "/t", "0", "/c", "g1t is restarting Windows to finish setting up the runner."])
342 .status()
343 .map_err(|e| format!("Windows couldn't be restarted: {e}"))?;
344 if status.success() { Ok(()) } else { Err("Windows didn't restart: restart it from the Start menu.".into()) }
345}
346
347/// g1t opens once when the person next signs in to Windows, after the
348/// restart (`RunOnce`, which Windows clears as it runs it), whether or not
349/// it opens at sign-in anyway: setup carries on from there.
350pub fn open_after_restart(app_exe: &Path) {
351 if cfg!(windows) {
352 let _ = super::backend::quiet("reg")
353 .args(["add", r"HKCU\Software\Microsoft\Windows\CurrentVersion\RunOnce", "/v", "g1t", "/t", "REG_SZ", "/d", &format!("\"{}\"", app_exe.display()), "/f"])
354 .stdout(std::process::Stdio::null())
355 .stderr(std::process::Stdio::null())
356 .status();
357 }
358}
359
360#[cfg(test)]
361mod tests {
362 use super::*;
363
364 fn status(json: &str) -> Status {
365 super::super::launcher::parse(json).unwrap()
366 }
367
368 #[test]
369 fn set_up_does_what_the_kinds_that_are_on_need() {
370 let clean = status(
371 r#"{"schema":1,"state":"error","capabilities":[
372 {"id":"linux","available":false,"state":"error","needs":["vm_platform","wsl"]},
373 {"id":"windows","available":false,"state":"absent","needs":["windows_sandbox"]}]}"#,
374 );
375 let both = plan(&clean, |_| true);
376 assert_eq!(both.features, ["VirtualMachinePlatform", "Containers-DisposableClientVM"]);
377 assert_eq!(both.wsl, Some(Wsl::Install));
378 assert_eq!(both.said(), "turns on Virtual Machine Platform and Windows Sandbox and installs WSL");
379 // Windows jobs stopped by the person: their feature is left alone.
380 let linux = plan(&clean, |kind| kind == "linux");
381 assert_eq!(linux.features, ["VirtualMachinePlatform"]);
382 assert_eq!(linux.said(), "turns on Virtual Machine Platform and installs WSL");
383 let update = status(r#"{"schema":1,"state":"error","capabilities":[{"id":"linux","available":false,"needs":["wsl_update"]}]}"#);
384 assert_eq!(plan(&update, |_| true).said(), "updates WSL");
385 // Home: nothing Set up can do for Windows jobs.
386 let home = status(r#"{"schema":1,"state":"running","capabilities":[{"id":"linux","available":true,"state":"running"},{"id":"windows","available":false,"needs":["windows_edition"]}]}"#);
387 assert!(plan(&home, |_| true).is_empty());
388 assert!(fixable(&["vm_platform".into(), "wsl".into()]));
389 assert!(!fixable(&["virtualization".into(), "vm_platform".into()]));
390 assert!(!fixable(&[]));
391 }
392
393 #[test]
394 fn the_script_is_fixed_and_says_a_restart_by_its_exit_code() {
395 let plan = Plan { features: vec!["VirtualMachinePlatform"], wsl: Some(Wsl::Install) };
396 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"));
397 assert!(script.contains(r"$log = 'C:\Users\o''neil\AppData\Local\sh.g1t\runner\setup.log'"));
398 assert!(script.contains("foreach ($name in @('VirtualMachinePlatform'))"));
399 assert!(script.contains("Install-Wsl '--install', '--no-distribution'"));
400 assert!(script.contains("exit 3010"));
401 assert!(!script.contains("{features}") && !script.contains("{wsl}"));
402 assert_eq!(outcome(0), Outcome::Done);
403 assert_eq!(outcome(3010), Outcome::Restart);
404 assert_eq!(outcome(1223), Outcome::Declined);
405 assert!(matches!(outcome(1), Outcome::Failed(_)));
406 // UTF-16LE in base64, as PowerShell reads it: "a" is 61 00.
407 assert_eq!(encoded("a"), "YQA=");
408 }
409
410 /// Runs the script as administrator, through Windows' prompt, with
411 /// nothing to do: the prompt shows, and its answer comes back.
412 /// `cargo test -- --ignored as_administrator --nocapture`, and say Yes.
413 #[test]
414 #[ignore]
415 fn as_administrator_with_nothing_to_do_it_is_done() {
416 let dir = std::env::temp_dir().join(format!("g1t-setup-admin-{}", std::process::id()));
417 std::fs::create_dir_all(&dir).unwrap();
418 let (log, marker) = (dir.join("setup.log"), dir.join(RESTART_FILE));
419 let outcome = run_elevated(&script(&Plan::default(), &log, &marker), 0);
420 let said = std::fs::read_to_string(&log).unwrap_or_default();
421 println!("{outcome:?}\n{said}");
422 assert_eq!(outcome, Outcome::Done, "{said}");
423 assert_eq!(said.trim(), "g1t setup: Done.");
424 assert!(!marker.exists());
425 let _ = std::fs::remove_dir_all(&dir);
426 }
427
428 /// Runs the script's WSL step as this user with `--version`, which
429 /// changes nothing: wsl.exe started hidden, what it said logged line by
430 /// line, and its exit code read. `cargo test -- --ignored the_wsl_step --nocapture`.
431 #[test]
432 #[ignore]
433 fn the_wsl_step_runs_wsl_and_logs_what_it_said() {
434 let dir = std::env::temp_dir().join(format!("g1t-setup-wsl-{}", std::process::id()));
435 std::fs::create_dir_all(&dir).unwrap();
436 let (log, marker) = (dir.join("setup.log"), dir.join(RESTART_FILE));
437 let script = script(&Plan { features: vec![], wsl: Some(Wsl::Update) }, &log, &marker).replace("Install-Wsl '--update'", "Install-Wsl '--version'");
438 let code = super::super::backend::quiet("powershell.exe")
439 .args(["-NoProfile", "-NonInteractive", "-ExecutionPolicy", "Bypass", "-EncodedCommand", &encoded(&script)])
440 .status()
441 .unwrap()
442 .code()
443 .unwrap();
444 let said = std::fs::read_to_string(&log).unwrap();
445 println!("exit {code}\n{said}");
446 assert_eq!(outcome(code as u32), Outcome::Done, "{said}");
447 assert!(said.contains("g1t setup: wsl: "), "{said}");
448 assert!(said.trim_end().ends_with("g1t setup: Done."), "{said}");
449 let _ = std::fs::remove_dir_all(&dir);
450 }
451
452 /// Runs the script as this user, not as administrator, so it changes
453 /// nothing: it must parse, log, and fail plainly. Windows only:
454 /// `cargo test -- --ignored the_script_runs --nocapture`.
455 #[test]
456 #[ignore]
457 fn the_script_runs_and_without_an_administrator_says_why() {
458 let dir = std::env::temp_dir().join(format!("g1t-setup-{}", std::process::id()));
459 std::fs::create_dir_all(&dir).unwrap();
460 let (log, marker) = (dir.join("setup.log"), dir.join(RESTART_FILE));
461 let plan = Plan { features: vec!["VirtualMachinePlatform"], wsl: Some(Wsl::Update) };
462 let code = super::super::backend::quiet("powershell.exe")
463 .args(["-NoProfile", "-NonInteractive", "-ExecutionPolicy", "Bypass", "-EncodedCommand", &encoded(&script(&plan, &log, &marker))])
464 .status()
465 .unwrap()
466 .code()
467 .unwrap();
468 let said = std::fs::read_to_string(&log).unwrap();
469 println!("exit {code}\n{said}");
470 assert_eq!(outcome(code as u32), Outcome::Failed("Set up stopped (exit code 1).".into()));
471 assert!(said.contains("g1t setup: Failed:"), "{said}");
472 assert!(!marker.exists());
473 let _ = std::fs::remove_dir_all(&dir);
474 }
475}