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.

g1t.sh
195 linesCodeBlameRaw

Pick any line to see why it is the way it is: the commit, the pull request and issue it came from, and what the agent was thinking.

Self-hosted runners on a person's own computer are sandboxes that nothing a job runs can get out of. `g1t-runner sandbox up|down|status|update [--json]` gives a Windows, macOS or Linux computer up to two capabilities, each a runner of its own: Linux jobs in a Linux VM the runner owns, where the runner's image runs under Sysbox and each job is a fresh container inside it (nested-container, three walls from the computer); and jobs native to Windows, each in a Windows Sandbox of its own, discarded after, with one read-only folder holding the harness (a new `vm` isolation level, also `g1t-runner register --native`). On Windows the VM is a WSL 2 distribution imported from the release's signed root filesystem: its wsl.conf turns off drives, Windows programs and Windows' PATH, and the supervisor inside unmounts what WSL shares with every distribution anyway (/mnt/wsl, where Docker Desktop keeps its sockets to Windows, WSLg, the GPU driver folders), refuses to run while anything of the computer's is reachable, and drops containers' traffic to the computer's address but for DNS. It needs no administrator once WSL is installed, and works on Windows 11 Home. On macOS the VM runs under vfkit and on Linux without Sysbox under QEMU with KVM, with no shared folders and nothing forwarded in (built and tested as commands, not yet run on those machines); on Linux with Sysbox no VM is needed. `status --json` reports the state, versions, isolation, the current job and a sentence when something is wrong, and each capability's availability with its cause and fix, for the desktop app. `update` drains the runner, swaps in the newest signed image and starts again without registering again. The sandbox's images are built from deploy/sandbox/Dockerfile by scripts/sandbox-image.mjs and listed with their SHA-256 in the release's signed manifest, beside the runner image's digest. The release workflow now builds its images on g1t's own machines (g1t-4core) rather than waiting forever for a self-hosted runner, registering QEMU for arm64 where the machine allows it and building amd64 alone with a warning where it does not, and signs and publishes once the images are built. The Runners page shows each runner's isolation (Sandboxed, VM per job, Shared Docker, Unsandboxed), which the API now returns as `isolation`. The Self-hosted runners guide has a new "Sandbox on your computer" section, with what it cannot stop.1//! The sandbox's images: fetched from the release they belong to, checked
2//! against the signed manifest, and kept by version in the state folder.
3//!
4//! The trust model is the runner's own (update.rs): `latest.json` is only
5//! believed when its Ed25519 signature is g1t's release key's, built into
6//! this program, and a file is only used when its SHA-256 is the one the
7//! signed manifest names. A file already on disk is checked again before
8//! each use. The runner's image inside the sandbox is named by digest in
9//! the same manifest, so the engine that pulls it checks it too.
10//!
11//! A build without the release key (a developer's) cannot check anything,
12//! so it fetches nothing: `--image-file` gives it a local image instead,
13//! and it says loudly that it is unchecked.
14
15use std::collections::BTreeMap;
16use std::io::{Read, Write};
17use std::path::{Path, PathBuf};
18
19use anyhow::{Context, Result, anyhow, bail};
20use sha2::{Digest, Sha256};
21
22use crate::selfhosted::update::{self, SandboxFile};
23
24/// The images a sandbox needs, on disk and checked.
25#[derive(Clone, Debug, Default)]
26pub struct Local {
27 pub version: String,
28 /// By kind (`wsl-x64`, `vm-arm64-disk`…).
29 pub files: BTreeMap<String, PathBuf>,
30 /// The runner's image, by digest.
31 pub runner_image: Option<String>,
32}
33
34/// This machine's architecture, as releases name it.
35pub fn arch() -> &'static str {
36 if std::env::consts::ARCH == "aarch64" { "arm64" } else { "x64" }
37}
38
39/// SHA-256 of a file, read in pieces.
40pub fn sha256_file(path: &Path) -> Result<String> {
41 let mut file = std::fs::File::open(path).with_context(|| format!("could not open {}", path.display()))?;
42 let mut hasher = Sha256::new();
43 let mut buffer = vec![0u8; 1 << 20];
44 loop {
45 let read = file.read(&mut buffer)?;
46 if read == 0 {
47 break;
48 }
49 hasher.update(&buffer[..read]);
50 }
51 Ok(hex::encode(hasher.finalize()))
52}
53
54/// Whether a release's sandbox has every file of `keys`, and the newest
55/// signed release's version.
56pub fn latest(site: &str) -> Result<update::Manifest> {
57 update::latest_signed_at(site)?.ok_or_else(|| {
58 anyhow!("this build of g1t-runner has no release key, so it cannot check the sandbox's images: pass --image-file with an image you built (scripts/sandbox-image.mjs)")
59 })
60}
61
62/// Fetches (or finds) and checks the files of `keys` from the signed
63/// release `manifest`.
64pub fn obtain(site: &str, manifest: &update::Manifest, keys: &[String], images: &Path, progress: &dyn Fn(&str)) -> Result<Local> {
65 let sandbox = manifest.sandbox.as_ref().ok_or_else(|| anyhow!("release {} has no sandbox images", manifest.version))?;
66 let dir = images.join(&manifest.version);
67 std::fs::create_dir_all(&dir).with_context(|| format!("could not make {}", dir.display()))?;
68 let mut files = BTreeMap::new();
69 for key in keys {
70 let file = sandbox.files.get(key).ok_or_else(|| anyhow!("release {} has no sandbox image {key} for this machine", manifest.version))?;
71 let path = dir.join(safe_name(&file.name)?);
72 if !(path.exists() && sha256_file(&path)? == file.sha256) {
73 progress(&format!("Fetching the sandbox's {key} image ({} MB)", file.size.div_ceil(1_000_000)));
74 download_checked(&format!("{}/{}/{}", update::downloads_at(site), manifest.version, file.name), &path, file)?;
75 }
76 files.insert(key.clone(), path);
77 }
78 Ok(Local { version: manifest.version.clone(), files, runner_image: sandbox.runner_image.clone() })
79}
80
81/// A file name from a manifest, which must not name a folder.
82fn safe_name(name: &str) -> Result<&str> {
83 if name.is_empty() || name.contains(['/', '\\']) || name.starts_with('.') {
84 bail!("the release names a sandbox image {name:?}, which is not a file name");
85 }
86 Ok(name)
87}
88
89/// Downloads to `path`, hashing as it goes; the file only takes its name
90/// once its SHA-256 is the manifest's.
91fn download_checked(url: &str, path: &Path, want: &SandboxFile) -> Result<()> {
92 let response = ureq::get(url).timeout(std::time::Duration::from_secs(3600)).call().map_err(|e| anyhow!("could not fetch {url}: {e}"))?;
93 let part = path.with_extension("part");
94 let mut out = std::fs::File::create(&part).with_context(|| format!("could not write {}", part.display()))?;
95 let mut reader = response.into_reader();
96 let mut hasher = Sha256::new();
97 let mut buffer = vec![0u8; 1 << 20];
98 loop {
99 let read = reader.read(&mut buffer).with_context(|| format!("the download of {url} broke off"))?;
100 if read == 0 {
101 break;
102 }
103 hasher.update(&buffer[..read]);
104 out.write_all(&buffer[..read])?;
105 }
106 out.sync_all()?;
107 drop(out);
108 if hex::encode(hasher.finalize()) != want.sha256 {
109 let _ = std::fs::remove_file(&part);
110 bail!("the sandbox image {}'s SHA-256 is not the release's; not using it", want.name);
111 }
112 std::fs::rename(&part, path)?;
113 Ok(())
114}
115
116/// A local image (`--image-file`), unchecked: for a build of the sandbox
117/// that is not released.
118pub fn local(keys: &[String], given: &Path, runner_image: Option<String>) -> Result<Local> {
119 if !given.exists() {
120 bail!("{} does not exist", given.display());
121 }
122 let mut files = BTreeMap::new();
123 if keys.len() == 1 {
124 files.insert(keys[0].clone(), given.to_path_buf());
125 } else {
126 // A folder holding the VM's files, named as a release names them.
127 for key in keys {
128 let suffix = key.rsplit('-').next().unwrap_or(key);
129 let found = std::fs::read_dir(given)?
130 .flatten()
131 .map(|e| e.path())
132 .find(|p| p.file_name().and_then(|n| n.to_str()).is_some_and(|n| n.contains(suffix)))
133 .ok_or_else(|| anyhow!("{} has no {key} file", given.display()))?;
134 files.insert(key.clone(), found);
135 }
136 }
137 Ok(Local { version: "local".into(), files, runner_image })
138}
139
140/// Removes every version but `keep`.
141pub fn prune(images: &Path, keep: &str) {
142 let Ok(entries) = std::fs::read_dir(images) else { return };
143 for entry in entries.flatten() {
144 if entry.file_name().to_string_lossy() != keep {
145 let _ = std::fs::remove_dir_all(entry.path());
146 }
147 }
148}
149
150#[cfg(test)]
151mod tests {
152 use super::*;
153
154 #[test]
155 fn a_files_hash_is_read_in_pieces() {
156 let dir = std::env::temp_dir().join(format!("g1t-sandbox-image-{}", std::process::id()));
157 std::fs::create_dir_all(&dir).unwrap();
158 let path = dir.join("x");
159 std::fs::write(&path, b"abc").unwrap();
160 assert_eq!(sha256_file(&path).unwrap(), "ba7816bf8f01cfea414140de5dae2223b00361a396177a9cb410ff61f20015ad");
161 let _ = std::fs::remove_dir_all(&dir);
162 }
163
164 #[test]
165 fn a_manifest_cannot_name_a_folder() {
166 assert!(safe_name("g1t-sandbox-wsl-x64.tar.gz").is_ok());
167 for bad in ["../x", "a/b", "a\\b", ".hidden", ""] {
168 assert!(safe_name(bad).is_err(), "{bad}");
169 }
170 }
171
172 #[test]
173 fn a_release_without_the_sandbox_is_refused() {
174 let manifest: update::Manifest = serde_json::from_str(r#"{"version":"0.2.0","files":{}}"#).unwrap();
175 let refused = obtain("https://g1t.sh", &manifest, &["wsl-x64".into()], Path::new("."), &|_| {}).unwrap_err();
176 assert!(refused.to_string().contains("no sandbox images"));
177 let manifest: update::Manifest =
178 serde_json::from_str(r#"{"version":"0.3.0","files":{},"sandbox":{"runner_image":"r@sha256:1","files":{"wsl-x64":{"name":"w.tar.gz","sha256":"00"}}}}"#).unwrap();
179 let refused = obtain("https://g1t.sh", &manifest, &["vm-arm64-disk".into()], &std::env::temp_dir(), &|_| {}).unwrap_err();
180 assert!(refused.to_string().contains("no sandbox image vm-arm64-disk"));
181 }
182
183 #[test]
184 fn a_local_vm_image_is_found_by_its_parts() {
185 let dir = std::env::temp_dir().join(format!("g1t-sandbox-local-{}", std::process::id()));
186 std::fs::create_dir_all(&dir).unwrap();
187 for name in ["g1t-sandbox-vm-arm64-kernel", "g1t-sandbox-vm-arm64-initrd", "g1t-sandbox-vm-arm64-disk.raw.gz"] {
188 std::fs::write(dir.join(name), b"x").unwrap();
189 }
190 let keys: Vec<String> = ["kernel", "initrd", "disk"].iter().map(|k| format!("vm-arm64-{k}")).collect();
191 let found = local(&keys, &dir, None).unwrap();
192 assert!(found.files["vm-arm64-disk"].to_string_lossy().ends_with("disk.raw.gz"));
193 let _ = std::fs::remove_dir_all(&dir);
194 }
195}