Skip to content

Commit

Merge the runner sandbox: g1t-runner sandbox up makes a person's own computer a self-hosted runner that nothing a job runs can get out of. Linux jobs run in a Linux VM of the runner's own (a WSL 2 distribution with no host mounts, interop or Docker socket on Windows; vfkit on macOS; Sysbox, or QEMU and KVM, on Linux), each in a fresh container under Sysbox inside it; on Windows 11 Pro and up, Windows jobs each run in a Windows Sandbox discarded after the job. Runners report their isolation, and the Runners page shows it.

syntaqxcommitted Parents09824ca63a7cf3Browse files
29 files+3592−8413 files not counted0/29 viewed
+131−46
11 # Releases the self-hosted runner, g1t-runner (crates/runner): builds it for
2−# Linux, macOS and Windows on x64 and arm64, signs the release, publishes it
3−# to g1t.sh/downloads/runner/ (the g1t-downloads R2 bucket), and pushes its
4−# container image. Runners already out there update themselves to it.
2+# Linux, macOS and Windows on x64 and arm64, pushes its container image,
3+# builds the images `g1t-runner sandbox` runs on people's own computers,
4+# signs the release (binaries and sandbox images in one manifest), and
5+# publishes it to g1t.sh/downloads/runner/ (the g1t-downloads R2 bucket).
6+# Runners already out there update themselves to it.
57 #
68 # A release is a tag `runner-v<version>`, where the version is the one in
79 # crates/runner/Cargo.toml; or run it by hand. scripts/runner-release.mjs
8−# does the work, and its header says how to make the release key the first
9−# time; CONTRIBUTING.md ("Deploying") says how a release is made.
10+# and scripts/sandbox-image.mjs do the work, and the first's header says how
11+# to make the release key the first time; CONTRIBUTING.md ("Deploying")
12+# says how a release is made.
13+#
14+# The image jobs run on g1t's own machines (g1t-4core), whose jobs have a
15+# Docker Engine of their own (crates/runner/src/docker/engine.rs). arm64
16+# images need QEMU's emulators registered with the kernel; each image job
17+# tries that, and when the machine does not allow it, builds amd64 alone
18+# and says so in a warning rather than failing the release.
1019 name: Runner release
1120
1221 on:
2736
2837 jobs:
2938 binaries:
30− name: Build, sign and publish
39+ name: Build
3140 runs-on: ubuntu-latest
32− # Production's secrets, without a deployment: a release is not a deploy of
33− # g1t.sh, and would otherwise show on its card as one in progress.
34− environment:
35− name: production
36− deployment: false
3741 timeout-minutes: 60
3842 steps:
3943 - uses: actions/checkout@v5
6468 G1T_RUNNER_RELEASE_KEY: ${{ vars.RUNNER_RELEASE_PUBLIC_KEY }}
6569 RUNNER_AGENT_IMAGE: ${{ vars.RUNNER_AGENT_IMAGE }}
6670 run: node scripts/runner-release.mjs build --targets linux-x64,linux-arm64,windows-x64
67− - name: Sign
68− env:
69− RUNNER_RELEASE_KEY: ${{ secrets.RUNNER_RELEASE_KEY }}
70− G1T_RUNNER_RELEASE_KEY: ${{ vars.RUNNER_RELEASE_PUBLIC_KEY }}
71− run: |
72− node scripts/runner-release.mjs sign
73− node scripts/runner-release.mjs verify
74− # npm's downloads, kept between runs: an install reads them first
75− # and asks the registry only for what changed, so a dropped
76− # connection to it rarely stops the job.
77− - name: Cache npm's downloads
78− uses: actions/cache@v4
79− with:
80− path: ~/.npm
81− key: npm-${{ runner.os }}-${{ hashFiles('package-lock.json') }}
82− restore-keys: npm-${{ runner.os }}-
83− - name: Install Wrangler
84− run: npm ci --prefer-offline --workspaces=false --no-audit --no-fund
85− - name: Publish to g1t.sh/downloads/runner
86− env:
87− CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }}
88− run: node scripts/runner-release.mjs publish
8971 - uses: actions/upload-artifact@v4
9072 with:
91− name: linux-binaries
92− path: |
93− target/runner-release/*/g1t-runner-linux-x64
94− target/runner-release/*/g1t-runner-linux-arm64
73+ name: binaries
74+ path: target/runner-release/
9575
9676 image:
9777 name: Container image
9878 needs: binaries
99− # Builds for arm64 as well as amd64, which needs QEMU's emulators
100− # registered on the machine: a self-hosted runner's, for now.
101− runs-on: [self-hosted, docker]
79+ runs-on: g1t-4core
10280 # Production's secrets, without a deployment: a release is not a deploy of
10381 # g1t.sh, and would otherwise show on its card as one in progress.
10482 environment:
10583 name: production
10684 deployment: false
107− timeout-minutes: 30
85+ timeout-minutes: 45
10886 # Its token pushes the image to g1t's registry.
10987 permissions:
11088 contents: read
11189 packages: write
90+ outputs:
91+ # The pushed image by digest, which the sandbox's manifest names.
92+ runner_image: ${{ steps.push.outputs.runner_image }}
11293 steps:
11394 - uses: actions/checkout@v5
11495 - uses: actions/download-artifact@v4
11596 with:
116− name: linux-binaries
97+ name: binaries
11798 path: release
118− - name: Build and push for amd64 and arm64
99+ - name: QEMU for arm64, if this machine allows it
100+ id: qemu
101+ run: |
102+ if docker run --privileged --rm tonistiigi/binfmt --install arm64 >/dev/null 2>&1 \
103+ && [ "$(docker run --rm --platform linux/arm64 debian:bookworm-slim uname -m 2>/dev/null)" = aarch64 ]; then
104+ echo "arches=amd64 arm64" >> "$GITHUB_OUTPUT"
105+ else
106+ echo "::warning::This machine cannot run arm64 programs (QEMU's emulators could not be registered), so the runner's image is built for amd64 alone this time."
107+ echo "arches=amd64" >> "$GITHUB_OUTPUT"
108+ fi
109+ - name: Build and push
110+ id: push
119111 # To g1t's own registry, where flagon-io keeps it public.
120112 env:
121113 IMAGE: g1t.sh/flagon-io/g1t-runner
114+ ARCHES: ${{ steps.qemu.outputs.arches }}
122115 run: |
123116 version="$(sed -n 's/^version = "\(.*\)"/\1/p' crates/runner/Cargo.toml | head -1)"
124117 echo "${{ secrets.G1T_TOKEN }}" | docker login g1t.sh -u g1t --password-stdin
125− for arch in amd64 arm64; do
118+ tags=()
119+ for arch in $ARCHES; do
126120 mkdir -p "context-$arch"
127121 cp deploy/runner/Dockerfile "context-$arch/"
128122 name="g1t-runner-linux-$([ "$arch" = amd64 ] && echo x64 || echo arm64)"
129123 cp release/*/"$name" "context-$arch/g1t-runner"
130− docker buildx build --platform "linux/$arch" -t "$IMAGE:$version-$arch" --push "context-$arch"
124+ docker buildx build --platform "linux/$arch" -t "$IMAGE:$version-$arch" --load "context-$arch"
125+ docker push "$IMAGE:$version-$arch"
126+ tags+=("$IMAGE:$version-$arch")
131127 done
132− docker buildx imagetools create -t "$IMAGE:$version" -t "$IMAGE:latest" "$IMAGE:$version-amd64" "$IMAGE:$version-arm64"
128+ docker buildx imagetools create -t "$IMAGE:$version" -t "$IMAGE:latest" "${tags[@]}"
129+ digest="$(docker buildx imagetools inspect "$IMAGE:$version" --format '{{json .Manifest}}' | node -e 'let s="";process.stdin.on("data",d=>s+=d).on("end",()=>console.log(JSON.parse(s).digest))')"
130+ echo "runner_image=$IMAGE@$digest" >> "$GITHUB_OUTPUT"
131+
132+ sandbox:
133+ name: Sandbox images
134+ needs: [binaries, image]
135+ runs-on: g1t-4core
136+ timeout-minutes: 60
137+ steps:
138+ - uses: actions/checkout@v5
139+ - uses: actions/download-artifact@v4
140+ with:
141+ name: binaries
142+ path: target/runner-release
143+ - name: QEMU for arm64, if this machine allows it
144+ id: qemu
145+ run: |
146+ if docker run --privileged --rm tonistiigi/binfmt --install arm64 >/dev/null 2>&1 \
147+ && [ "$(docker run --rm --platform linux/arm64 debian:bookworm-slim uname -m 2>/dev/null)" = aarch64 ]; then
148+ echo "arches=x64 arm64" >> "$GITHUB_OUTPUT"
149+ else
150+ echo "::warning::This machine cannot run arm64 programs, so the sandbox's images are built for x64 alone this time: an arm64 Mac or Linux machine keeps the sandbox image it has."
151+ echo "arches=x64" >> "$GITHUB_OUTPUT"
152+ fi
153+ - name: Build the WSL root filesystem and the VM's disk
154+ env:
155+ ARCHES: ${{ steps.qemu.outputs.arches }}
156+ run: |
157+ dir="$(echo target/runner-release/*/)"
158+ for arch in $ARCHES; do
159+ node scripts/sandbox-image.mjs build --arch "$arch" --runner "${dir}g1t-runner-linux-$arch" --out "$dir"
160+ done
161+ - name: The manifest's sandbox section
162+ env:
163+ RUNNER_SANDBOX_IMAGE: ${{ needs.image.outputs.runner_image }}
164+ run: node scripts/sandbox-image.mjs section --dir "$(echo target/runner-release/*/)"
165+ - uses: actions/upload-artifact@v4
166+ with:
167+ name: sandbox
168+ path: |
169+ target/runner-release/*/g1t-sandbox-*
170+ target/runner-release/*/sandbox.json
171+
172+ publish:
173+ name: Sign and publish
174+ needs: [binaries, image, sandbox]
175+ # The binaries go out even when an image job failed: the manifest then
176+ # names no sandbox images, and computers keep the sandbox they have.
177+ if: ${{ always() && needs.binaries.result == 'success' }}
178+ runs-on: ubuntu-latest
179+ environment:
180+ name: production
181+ deployment: false
182+ timeout-minutes: 30
183+ steps:
184+ - uses: actions/checkout@v5
185+ - uses: actions/download-artifact@v4
186+ with:
187+ name: binaries
188+ path: target/runner-release
189+ - uses: actions/download-artifact@v4
190+ if: ${{ needs.sandbox.result == 'success' }}
191+ with:
192+ name: sandbox
193+ path: target/runner-release
194+ - name: Sign
195+ env:
196+ RUNNER_RELEASE_KEY: ${{ secrets.RUNNER_RELEASE_KEY }}
197+ G1T_RUNNER_RELEASE_KEY: ${{ vars.RUNNER_RELEASE_PUBLIC_KEY }}
198+ RUNNER_AGENT_IMAGE: ${{ vars.RUNNER_AGENT_IMAGE }}
199+ run: |
200+ node scripts/runner-release.mjs manifest
201+ node scripts/runner-release.mjs sign
202+ node scripts/runner-release.mjs verify
203+ # npm's downloads, kept between runs: an install reads them first
204+ # and asks the registry only for what changed, so a dropped
205+ # connection to it rarely stops the job.
206+ - name: Cache npm's downloads
207+ uses: actions/cache@v4
208+ with:
209+ path: ~/.npm
210+ key: npm-${{ runner.os }}-${{ hashFiles('package-lock.json') }}
211+ restore-keys: npm-${{ runner.os }}-
212+ - name: Install Wrangler
213+ run: npm ci --prefer-offline --workspaces=false --no-audit --no-fund
214+ - name: Publish to g1t.sh/downloads/runner
215+ env:
216+ CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }}
217+ run: node scripts/runner-release.mjs publish
+1−1
16701670 Op::SetActionsVariable => "Add or change a variable's row, as for secrets.",
16711671 Op::DeleteActionsVariable => "Remove a variable: one row by `id`, or every row of the key.",
16721672 Op::ListRunners => {
1673− "A workspace's self-hosted runners, or a repository's: its own and the workspace's that its runner group lets it use. Each has its `labels` (always `self-hosted`, its OS and its architecture), `status` (`online`, `busy` or `offline`), the `work` it is doing, its `version` and when it was last seen. A workspace's are seen by its owners; a repository's need the Admin role on it."
1673+ "A workspace's self-hosted runners, or a repository's: its own and the workspace's that its runner group lets it use. Each has its `labels` (always `self-hosted`, its OS and its architecture), `status` (`online`, `busy` or `offline`), the `work` it is doing, its `version`, when it was last seen, and its `isolation`: how it keeps work from its machine (`nested-container`, `vm`, `container` or `host`; null for a runner too old to say). A workspace's are seen by its owners; a repository's need the Admin role on it."
16741674 }
16751675 Op::ListRunnerGroups => {
16761676 "A workspace's runner groups: which of its repositories may use the runners in each. The default group (every repository) is where runners go when no group is named. Owners only."
+8−4
34853485 },
34863486 "last_seen_at": "2026-10-06T14:05:40.512Z",
34873487 "created_at": "2026-10-02T09:30:00.000Z",
3488− "created_by": null
3488+ "created_by": null,
3489+ "isolation": "nested-container"
34893490 },
34903491 {
34913492 "id": "rnr_01kq2m8v4c7f0h3k6n9q2t5w9a",
35063507 "work": null,
35073508 "last_seen_at": "2026-10-06T14:05:40.512Z",
35083509 "created_at": "2026-10-02T09:30:00.000Z",
3509− "created_by": null
3510+ "created_by": null,
3511+ "isolation": "host"
35103512 }
35113513 ],
35123514 "notes": "A repository's list holds its own runners (`repo` set) and the workspace's that its runner group lets it use. A runner is `offline` when it has not polled for 90 seconds."
35433545 },
35443546 "last_seen_at": "2026-10-06T14:05:40.512Z",
35453547 "created_at": "2026-10-02T09:30:00.000Z",
3546− "created_by": null
3548+ "created_by": null,
3549+ "isolation": "nested-container"
35473550 },
35483551 {
35493552 "id": "rnr_01kq2m8v4c7f0h3k6n9q2t5w9a",
35643567 "work": null,
35653568 "last_seen_at": "2026-10-06T14:05:40.512Z",
35663569 "created_at": "2026-10-02T09:30:00.000Z",
3567− "created_by": null
3570+ "created_by": null,
3571+ "isolation": "host"
35683572 }
35693573 ]
35703574 },
+8−3
307307
308308 ## What's not there yet
309309
310−- **The sandbox on Windows and macOS.** Until it is released, the runner
311− there runs jobs only if you choose Docker or this computer itself; see
312− [how jobs run](#how-jobs-run).
310+- **The sandbox, from the app, on Windows and macOS.** The sandbox itself
311+ is built: `g1t-runner sandbox up` makes a computer a self-hosted runner
312+ that nothing a job runs can get out of, with Linux jobs in a Linux VM of
313+ the runner's own and, on Windows 11 Pro and up, Windows jobs each in a
314+ Windows Sandbox ([the sandbox](/guides/self-hosted-runners/#sandbox-on-your-computer)).
315+ The app does not start it for you yet, so until it does, its runner on
316+ Windows and macOS runs jobs only if you choose Docker or this computer
317+ itself; see [how jobs run](#how-jobs-run).
313318 - **Agents on your desktop.** The runner takes workflow jobs, and agent
314319 work only if the workspace [sends it to its runners](/guides/self-hosted-runners/#agents-on-your-runners).
315320 - **Opening the conversation from a notification.** Clicking one brings
+202−21
1717 - **Each job in a fresh sandbox.** Every job gets a container of its own,
1818 made for it and removed after it, inside the runner's own container.
1919 See [isolation](#isolation).
20−- **Any OS.** Linux, macOS and Windows, on x64 and arm64. On macOS and
21− Windows, sandboxed jobs are [not built yet](#windows-and-macos).
20+- **Any OS.** Linux, macOS and Windows, on x64 and arm64. On your own
21+ computer, `g1t-runner sandbox up` runs Linux jobs in a Linux VM of the
22+ runner's own and Windows jobs each in a Windows Sandbox: see
23+ [sandbox on your computer](#sandbox-on-your-computer).
2224
2325 | Where | Page | Who manages it |
2426 | --- | --- | --- |
3840 it run Docker without `--privileged` and without the machine's Docker
3941 socket.
4042
41−1. Install [Sysbox](https://github.com/nestybox/sysbox) on the machine,
42− following its install guide. It needs Linux; Docker Desktop on macOS
43− and Windows cannot use it.
43+1. Install [Sysbox](https://github.com/nestybox/sysbox) 0.7 or later on
44+ the machine, following its install guide. Earlier versions cannot run
45+ the runner's own Docker Engine, whose `runc` checks `/proc` in a way
46+ they do not allow. Sysbox needs Linux; on a Mac or Windows computer,
47+ use [the sandbox](#sandbox-on-your-computer) instead.
4448 2. Make a registration token: open **Runners** and click **New runner**.
4549 3. Run the runner:
4650
9296 ## Isolation
9397
9498 How well a runner keeps work from its machine. A runner tells g1t its
95−level when it registers.
99+level when it registers and on every poll, and the **Runners** page shows
100+it on each runner: *Sandboxed*, *VM per job*, *Shared Docker* or
101+*Unsandboxed*.
96102
97103 | Isolation | How you get it | What a job can reach |
98104 | --- | --- | --- |
99−| `nested-container` | The runner's image under Sysbox, as above, or [the desktop app](/guides/desktop/#how-jobs-run)'s Sandboxed choice. | Its own container, and its own Docker Engine if it uses Docker. Not the machine, and not other jobs. |
105+| `nested-container` | The runner's image under Sysbox, as above; on your own computer, `g1t-runner sandbox up`, which runs the same inside a Linux VM of the runner's own; or [the desktop app](/guides/desktop/#how-jobs-run)'s Sandboxed choice. | Its own container, and its own Docker Engine if it uses Docker. Not the machine, and not other jobs. |
106+| `vm` | `g1t-runner sandbox up` on Windows 11 Pro, Enterprise or Education, or `register --native` there: jobs native to Windows, each in a [Windows Sandbox](#windows-jobs-in-windows-sandbox). | Its own virtual machine, made for the job and discarded after, with a read-only copy of the harness and no other folder of the machine's. |
100107 | `container` | `g1t-runner` installed on the machine with Docker, the desktop app's In Docker choice, or the image given the machine's Docker socket with the override. | Its own container. The runner itself drives the machine's Docker, which can do anything on the machine, so a job that escapes its container, or anything that takes over the runner, controls the machine. |
101108 | `host` | `--no-docker`, or the desktop app's Directly on this computer choice. Deprecated. | Everything the runner's user can: the machine, its files, and what earlier jobs left. |
102109
158165 (--no-docker), unsandboxed, so it does not take a public repository's
159166 work.* The job fails with that message. Update the runner.
160167
161−Move a Linux runner to [the container](#run-it-as-a-container). A macOS
162−or Windows runner stays in host mode until sandboxes for them are built.
168+Move a Linux runner to [the container](#run-it-as-a-container), and a
169+Windows or Mac computer to [the sandbox](#sandbox-on-your-computer). A
170+runner registers as running jobs native to Windows or macOS (the
171+`windows` or `macos` label) only when it sandboxes them (`--native`), or
172+when you chose host mode with `--no-docker`.
173+
174+## Sandbox on your computer
175+
176+`g1t-runner sandbox` sets up self-hosted runners on your own desktop or
177+laptop that nothing a job runs can get out of: not to your files, your
178+other programs, your processes or your Docker. It gives the computer up
179+to two capabilities, each a runner of its own on the **Runners** page:
180+
181+| Capability | Runner | Labels | Isolation |
182+| --- | --- | --- | --- |
183+| Linux jobs | `<name>-linux`, inside a Linux VM of the runner's own | `self-hosted`, `linux`, and the architecture | `nested-container` |
184+| Jobs native to the computer's OS | `<name>-windows`, on the computer, starting a Windows Sandbox per job | `self-hosted`, `windows`, and the architecture | `vm` |
185+
186+1. Make a registration token: open **Runners** and click **New runner**.
187+2. Download `g1t-runner` for your computer, as in [add a runner](#add-a-runner),
188+ then run:
189+
190+ ```sh
191+ g1t-runner sandbox up --url https://g1t.sh --token g1trt_…
192+ ```
163193
164−### Windows and macOS
194+The first `up` fetches the sandbox's image for this release, checks its
195+signature and SHA-256, makes the VM, and registers both runners with the
196+token; it takes a few minutes. After that, `up` starts what is stopped
197+and needs no token.
165198
166−Sandboxed jobs on Windows and macOS are not built yet; jobs native to
167−those systems run in host mode. What is planned:
199+| Command | What it does |
200+| --- | --- |
201+| `g1t-runner sandbox up` | Makes the sandbox if there is none, and starts both runners. Waits until the Linux runner is asking for work. |
202+| `g1t-runner sandbox down` | Stops both now. A running job is cut short, and its run says the runner was stopped. `--drain` waits for it to finish instead. `--delete` also removes the VM and everything in it; the runners stay listed on g1t until you remove them there. |
203+| `g1t-runner sandbox status` | Says how each capability is, and what to do about one that is not available. |
204+| `g1t-runner sandbox update` | Drains the Linux runner (the job it is running finishes), swaps in the newest release's image, and starts it again. The registration is kept, so it is the same runner on g1t. |
168205
169−| | How each job will run |
206+| Option (`up`) | |
170207 | --- | --- |
171−| Windows | A Windows container with Hyper-V isolation: a small virtual machine with its own kernel, made from a signed image for the job and destroyed after it. |
172−| macOS | An ephemeral macOS virtual machine, using Apple's Virtualization framework, cloned from a signed image for the job and deleted after it. Apple's licence allows two macOS virtual machines per Mac at once, so a Mac will run at most two jobs at a time. |
208+| `--url`, `--token` | Where to register and the registration token, for the first `up`. |
209+| `--name` | The runners' name before `-linux` and `-windows`. The computer's name if left out. |
210+| `--labels`, `--group` | As for [register](#register-options), for both runners. |
211+| `--cpus` | The Linux VM's runner's CPUs, and so its jobs'. Half the computer's, at most 8, if left out. |
212+| `--memory` | Its memory, such as `8g`. A quarter of the computer's, between 2 and 8 GB and never more than half, if left out. |
213+| `--disk` | The VM's disk on macOS and Linux, such as `64g` (64 GB if left out). On Windows the disk is not limited: WSL can only resize it with all of WSL shut down. |
214+| `--no-native`, `--native` | Leave out the native capability, or bring it back. Kept until said otherwise. |
215+| `--state-dir` | Where the sandbox keeps its settings and images. `~/.g1t-runner/sandbox` if left out. Every command takes it. |
216+| `--json` | Print the status as JSON when done (see below). Every command takes it. |
217+
218+### How each computer runs it
219+
220+| Computer | Linux jobs | Native jobs |
221+| --- | --- | --- |
222+| Windows 11 Pro, Enterprise, Education | A WSL 2 distribution of the runner's own, `g1t-sandbox` | Windows Sandbox, one per job |
223+| Windows 11 Home | A WSL 2 distribution of the runner's own, `g1t-sandbox` | Not available: Home has no Windows Sandbox |
224+| macOS 13 or later | A VM under Apple's Virtualization framework, run by [vfkit](https://github.com/crc-org/vfkit) (`brew install vfkit`) | Not released: sandboxed macOS jobs are designed, not built |
225+| Linux with Sysbox 0.7+ | No VM: the runner's image under Sysbox, as [above](#run-it-as-a-container) | None (Linux jobs are native) |
226+| Linux without Sysbox | A VM under QEMU with KVM (`/dev/kvm`, and `qemu-system-x86_64` or `qemu-system-aarch64`) | None |
227+
228+Inside the VM (and on Linux with Sysbox) it is the runner's image under
229+Sysbox, exactly as in [run it as a container](#run-it-as-a-container):
230+each job is a fresh container on the runner's own Docker Engine, inside the
231+runner's Sysbox container, inside the VM. Nothing listens on your
232+computer; the VM only connects out.
173233
174−On both, the only part on the machine itself will be a small launcher that
175−makes and destroys those sandboxes from signed images; the runner and the
176−job run inside them.
234+**What the VM gets of your computer:** no folders and no drives, no way
235+to start its programs, no Docker socket, no clipboard, no GPU, display or
236+sound, and no network port in. On Windows, the distribution's
237+`/etc/wsl.conf` turns off WSL's drive mounts, its interop with Windows
238+programs and Windows' `PATH`, and when it starts the sandbox also unmounts
239+what WSL puts in every distribution anyway: `/mnt/wsl` (shared by all
240+distributions, where Docker Desktop keeps sockets to its engine on
241+Windows), WSLg's display and sound sockets, and Windows' GPU driver
242+folders. It checks that nothing of the computer's is left, and refuses to
243+run jobs if anything is. Containers in the VM, every job among them, may
244+reach the internet but not your computer: a firewall rule in the VM drops
245+their traffic to the computer's address, but for DNS.
177246
178−On a Mac or Windows machine with Docker Desktop, a runner installed there
179−without `--no-docker` already runs jobs in Linux containers, with
180−`container` isolation.
247+**Resources:** the runner's container has `--cpus` and `--memory`, and
248+each job has its own limits inside that ([what a job's container gets](#what-a-jobs-container-gets)).
249+On Windows these are the sandbox's share of WSL's own VM, which
250+`%USERPROFILE%\.wslconfig` sizes for all of WSL.
181251
252+**Images and updates:** the sandbox's images are published with each
253+runner release, at `g1t.sh/downloads/runner/<version>/`, and named with
254+their SHA-256 in the release's manifest, which is signed with g1t's
255+release key like the runner itself (see [updates](#updates)). The runner's
256+image inside is named by digest in the same manifest. `update` checks the
257+signature and every SHA-256 before using anything, and waits for the
258+running job before it swaps the image. The runner's registration lives
259+apart from the image (on its own disk on macOS and Linux; on Windows the
260+launcher carries it across), so an update does not register again.
261+
262+### Windows jobs in Windows Sandbox
263+
264+With `vm` isolation, the runner on your computer takes jobs that ask for
265+`runs-on: [self-hosted, windows]` and runs each in a Windows Sandbox of
266+its own: a Hyper-V virtual machine with its own kernel, started fresh from
267+your Windows and discarded, disk and all, when the job ends.
268+
269+- Its only folder from your computer is read-only and holds a copy of the
270+ runner (the job's harness) and the job's script. Nothing it writes
271+ outlives it.
272+- No GPU, clipboard, printers, microphone or camera; networking on, so the
273+ job can reach g1t and the internet.
274+- A firewall rule in it blocks the address your computer is reached by.
275+ The job runs as an administrator in its sandbox and could remove it, so
276+ it slows a job down rather than stopping it; the wall is the VM.
277+- One job at a time, as every runner. Agent work runs on Linux runners.
278+- It needs Windows 11 Pro, Enterprise or Education, version 24H2 or later
279+ (for the `wsb` command), with **Windows Sandbox** turned on under
280+ Settings, System, Optional features, More Windows features. `status`
281+ says which of these is missing.
282+
283+On a runner that is not under `sandbox up`, `g1t-runner register --native`
284+does the same.
285+
286+### What it cannot stop
287+
288+- **WSL 2 distributions share one VM.** On Windows, every WSL 2
289+ distribution, your own among them, runs in one virtual machine with one
290+ Linux kernel, and root in one can reach the others' disks. That is why
291+ nothing a job runs is root in the sandbox's distribution: the runner is
292+ a Sysbox container whose root is an unprivileged user there, and each job
293+ a container inside it. A job would have to escape its container and the
294+ runner's, and then reach root in the distribution, before it could touch
295+ your other distributions, from which Windows is reachable.
296+- **Mirrored networking.** With `networkingMode=mirrored` in
297+ `.wslconfig`, Linux reaches Windows' own `localhost`, so the sandbox
298+ will not start until it is removed.
299+- **The computer's network.** Jobs reach the internet, and so the other
300+ machines on your network that the internet could. Only your computer's
301+ own address is blocked.
302+- **The VM's own root** could reach your computer's services on its
303+ address. Jobs are two containers away from it.
304+- **A Windows job's sandbox** is as strong as Windows Sandbox: a Hyper-V
305+ VM, with one read-only folder of your computer's.
306+- **macOS and Linux VMs** (vfkit and QEMU) are built to the same design,
307+ with no shared folders and nothing forwarded in, but have not yet been
308+ run on a Mac or a Linux machine without Sysbox. Windows is where the
309+ sandbox has been checked end to end.
310+
311+### The status as JSON
312+
313+`g1t-runner sandbox status --json` prints one JSON object, which [the
314+desktop app](/guides/desktop/) shows. It exits 0 whenever it could read
315+the status, a sandbox in error included; `up`, `down` and `update` print
316+the same object with `--json` when they are done, and exit 1 with
317+`error` set when they fail.
318+
319+```json
320+{
321+ "schema": 1,
322+ "state": "running",
323+ "backend": "wsl2",
324+ "runner_version": "0.3.0",
325+ "launcher_version": "0.3.0",
326+ "image_version": "0.3.0",
327+ "isolation": "nested-container",
328+ "job": { "kind": "workflow", "id": "job_…", "name": "build", "repo": "acme/web" },
329+ "error": null,
330+ "resources": { "cpus": 4, "memory_mib": 8192, "disk_gib": 64 },
331+ "capabilities": [
332+ { "id": "linux", "available": true, "state": "running", "isolation": "nested-container",
333+ "runner_version": "0.3.0", "job": null, "cause": null, "fix": null },
334+ { "id": "windows", "available": false, "state": "absent", "isolation": "vm",
335+ "runner_version": null, "job": null,
336+ "cause": "Windows Sandbox is not turned on, or this edition of Windows has none (Home does not).",
337+ "fix": "On Windows 11 Pro, Enterprise or Education, turn on Windows Sandbox under Settings, System, Optional features, More Windows features, then restart." }
338+ ],
339+ "state_dir": "C:\\Users\\you\\.g1t-runner\\sandbox"
340+}
341+```
342+
343+| Field | |
344+| --- | --- |
345+| `state` | The Linux VM's: `absent` (never made, or deleted), `stopped`, `starting`, `running`, `updating` or `error`. |
346+| `backend` | What holds it: `wsl2`, `vfkit`, `qemu` or `sysbox`. |
347+| `runner_version`, `image_version`, `launcher_version` | The runner inside the VM, the VM's image, and this `g1t-runner`. |
348+| `job` | What the Linux runner is running now, or null. |
349+| `error` | A sentence for a person when something is wrong, or null. |
350+| `capabilities` | Each kind of job the computer can run sandboxed: `linux`, and `windows` or `macos`. `available` says whether it can be had here; when not, `cause` says why and `fix` what to do. Each has its own `state`, `job` and `runner_version`. |
351+
352+Fields are only added; `schema` changes if one changes meaning.
353+
182354 ## The Runners page
183355
184356 A workspace's **Runners** page shows g1t cloud and your own runners side
279451 | `--group` | A workspace runner's [group](#groups). The default group, or the one the token was made for, if left out. |
280452 | `--ephemeral` | Take one job, then remove itself. For [autoscaling](#autoscaling). |
281453 | `--no-docker` | Deprecated. Run jobs directly on the machine instead of in containers ([host mode](#host-mode-is-deprecated)). |
454+| `--native` | Run jobs native to this OS, each in a throwaway VM of its own: [Windows Sandbox](#windows-jobs-in-windows-sandbox) on Windows. Registers with the OS's label and `vm` isolation, and refuses if the sandbox is not there. |
282455 | `--image` | The image workflow jobs run in when they name no `container:`. `node:24-bookworm` if left out. |
283456 | `--agent-image` | The image [agent work](#agents-on-your-runners) runs in. |
284457 | `--memory` | Each job container's memory, such as `8g`. Three quarters of the runner's memory if left out. |
543716 download matches the release's SHA-256. `g1t-runner update` does it now;
544717 `--no-auto-update` turns it off. Releases are at
545718 `https://g1t.sh/downloads/runner/<version>/`, with a `SHA256SUMS` file.
719+The runner inside [the sandbox](#sandbox-on-your-computer) does not update
720+itself: `g1t-runner sandbox update` swaps the whole sandbox's image, which
721+carries it, once the running job is done.
546722
547723 ## Remove a runner
548724
578754 | *the runner's own Docker Engine could not start* | The container is not running under Sysbox. Add `--runtime=sysbox-runc`, and check that Sysbox is installed (`docker info` lists `sysbox-runc` under runtimes). |
579755 | The job says *Waiting for a sandboxed runner: this repository is public …* | Every online runner with the job's labels is in host mode, which never runs a public repository's jobs. Run a runner's image under Sysbox, or register one without `--no-docker`. |
580756 | *… is public, and this self-hosted runner runs jobs directly on its machine* | An older runner in host mode refused a public repository's job. Update it, so g1t gives the job to a sandboxed runner instead, and run the runner's image under Sysbox. |
757+| `sandbox up` says *WSL is not installed* | Open a terminal as administrator, run `wsl --install --no-distribution`, restart, and run `sandbox up` again. That is the one step that needs an administrator. |
758+| `sandbox up` says *WSL is set to mirrored networking* | Remove `networkingMode=mirrored` from `%USERPROFILE%\.wslconfig`, run `wsl --shutdown`, and try again. |
759+| `sandbox status` says *this sandbox can reach the computer* | Something of your computer's is mounted in the sandbox's VM, or an engine's socket is there; it lists what. Run `g1t-runner sandbox down --delete` and `up` again, which makes it from the image afresh. |
760+| `sandbox status` shows the Linux runner in `error` with *The runner stopped …* | The runner inside could not register or start; the message is its last line. Check the token was still valid (an hour), and that the computer reaches `https://api.g1t.sh`. |
761+| The `windows` capability is unavailable | `status` says why: Windows Sandbox is off, this is Windows 11 Home, or Windows is older than 24H2. |
581762 | A step's `sudo` fails | Job containers have `no-new-privileges`. Steps run as the image's user, root in most CI images, so drop the `sudo`. |
582763 | *could not run docker* | Install Docker and start it, and give the runner's user access to it. |
583764 | Agent work fails at once | Give the runner `--agent-image`. |
+0−0

Binary or large file; its contents are not shown.

+44−2
22 import { useEffect, useState } from "react";
33 import { Form, Link } from "react-router";
44
5−import { DESKTOP_LABEL, type RegistrationToken, RUNNER_DOWNLOADS, RUNNER_FILES, RUNNER_IMAGE, type Runner, type RunnerGroup } from "@g1t/contracts";
5+import { DESKTOP_LABEL, type RegistrationToken, RUNNER_DOWNLOADS, RUNNER_FILES, RUNNER_IMAGE, type Runner, type RunnerGroup, type RunnerIsolation } from "@g1t/contracts";
66
77 import { agentRunHref, workflowRunHref } from "../lib/runners";
88 import type { RunnersAction, RunnersData } from "../lib/runners.server";
99 import { CopyLine, EmptyState, ErrorText, Field, Input, SubmitButton, TimeAgo } from "./ui";
10−import { Badge } from "./ui/badge";
10+import { Badge, type BadgeTone } from "./ui/badge";
1111 import { Button } from "./ui/button";
1212 import { Card } from "./ui/card";
1313 import { Checkbox } from "./ui/checkbox";
1515 import { SelectField } from "./ui/select";
1616 import { Switch } from "./ui/switch";
1717 import { Tabs, TabsList, TabsTrigger } from "./ui/tabs";
18+import { Tooltip, TooltipContent, TooltipTrigger } from "./ui/tooltip";
1819
1920 /** Online and waiting for work reads as Idle; online and working, Busy. */
2021 export const STATUS: Record<Runner["status"], { dot: string; text: string; label: string }> = {
3132
3233 export const OS_NAMES: Record<Runner["os"], string> = { linux: "Linux", macos: "macOS", windows: "Windows" };
3334
35+/** How a runner keeps work from its machine, as a badge and what it means. */
36+export const ISOLATION: Record<RunnerIsolation, { label: string; tone: BadgeTone; about: string }> = {
37+ "nested-container": {
38+ label: "Sandboxed",
39+ tone: "success",
40+ about: "Each job runs in a fresh container inside the runner's own sandboxed container. Neither reaches the machine.",
41+ },
42+ vm: {
43+ label: "VM per job",
44+ tone: "success",
45+ about: "Each job runs in a throwaway virtual machine of its own, such as Windows Sandbox, with no folder of the machine's.",
46+ },
47+ container: {
48+ label: "Shared Docker",
49+ tone: "warn",
50+ about: "Each job runs in a container, but the runner shares the machine's Docker Engine, so anything that takes it over controls the machine.",
51+ },
52+ host: {
53+ label: "Unsandboxed",
54+ tone: "danger",
55+ about: "Jobs run directly on the machine (host mode). It is not given public repositories' work.",
56+ },
57+};
58+
59+function IsolationBadge({ isolation }: { isolation: Runner["isolation"] }) {
60+ if (!isolation) return null;
61+ const level = ISOLATION[isolation];
62+ if (!level) return null;
63+ return (
64+ <Tooltip>
65+ <TooltipTrigger asChild>
66+ <Badge size="md" tone={level.tone} tabIndex={0}>
67+ {level.label}
68+ </Badge>
69+ </TooltipTrigger>
70+ <TooltipContent className="max-w-72">{level.about}</TooltipContent>
71+ </Tooltip>
72+ );
73+}
74+
3475 function Section({ icon, title, about, children }: { icon: React.ReactNode; title: string; about: React.ReactNode; children: React.ReactNode }) {
3576 return (
3677 <section className="grid gap-x-10 gap-y-4 border-t border-line pt-8 first:border-t-0 first:pt-0 lg:grid-cols-[16rem_1fr]">
59100 {/* A desktop app's runner runs while g1t is open there: offline is g1t closed. */}
60101 {runner.status === "offline" && runner.labels.includes(DESKTOP_LABEL) ? `g1t is closed on ${runner.name}` : status.label}
61102 </span>
103+ <IsolationBadge isolation={runner.isolation} />
62104 {runner.ephemeral && <Badge size="md">ephemeral</Badge>}
63105 {runner.group && <span className="text-xs text-faint">in {runner.group}</span>}
64106 </div>
+6−1
107107 pub last_seen_at: Option<String>,
108108 pub created_at: String,
109109 pub created_by: Option<String>,
110+ /// How it keeps work from its machine, as it last said
111+ /// ([`ISOLATIONS`]); null for a runner from before runners said.
112+ #[serde(default)]
113+ pub isolation: Option<String>,
110114 }
111115
112116 /// Which of a workspace's repositories may use its runners.
428432 pub replace: bool,
429433 /// How the runner keeps work from its machine: `nested-container` (a
430434 /// container per job, inside the runner's own sandboxed container),
435+ /// `vm` (a throwaway VM per job, native to the machine's OS),
431436 /// `container` (a container per job, on a Docker Engine it shares with
432437 /// its machine) or `host` (directly on the machine). A `host` runner is
433438 /// given no public repository's work ([`isolation_allows`]); older
437442 }
438443
439444 /// The isolation levels a runner may say it has (`RegisterArgs::isolation`).
440−pub const ISOLATIONS: &[&str] = &["nested-container", "container", "host"];
445+pub const ISOLATIONS: &[&str] = &["nested-container", "vm", "container", "host"];
441446
442447 #[derive(Clone, Debug, Serialize, Deserialize)]
443448 pub struct Registered {
+5−0
6363 /// The image a job's own engine runs: `docker:28-dind` if left out.
6464 #[serde(default)]
6565 pub job_docker_image: Option<String>,
66+ /// Runs jobs native to this OS, each in a throwaway VM of its own
67+ /// (`--native`, native.rs): Windows Sandbox on Windows.
68+ #[serde(default)]
69+ pub native: bool,
6670 }
6771
6872 fn yes() -> bool {
173177 pids_limit: None,
174178 job_docker: true,
175179 job_docker_image: None,
180+ native: false,
176181 };
177182 save(&dir, &config).unwrap();
178183 let back = load(&dir).unwrap();
+97−6
6666 pub id: String,
6767 pub kind: String,
6868 pub name: String,
69+ pub repo: String,
6970 child: Child,
7071 container: Option<String>,
7172 /// The job's network and work volume, removed after.
7475 /// The job's own Docker Engine, when it may have one.
7576 engine: Option<JobEngine>,
7677 folder: Option<PathBuf>,
78+ /// The job's own VM, for a job native to this OS (native.rs); `child`
79+ /// is then `wsb exec`, whose output carries the job's exit code.
80+ native: Option<WsbJob>,
81+ /// How it ended, once read: a native job's code is read once from
82+ /// `wsb exec`'s output.
83+ exit: Option<i32>,
7784 /// Past its time limit and a grace for g1t to say so: stopped here.
7885 deadline: Instant,
7986 }
8087
88+use super::native::{self, WsbJob};
89+
8190 #[cfg(unix)]
8291 use super::engine::JobEngine;
8392
112121
113122 /// Its exit code once it has ended.
114123 pub fn ended(&mut self) -> Option<i32> {
115− match self.child.try_wait() {
124+ if self.exit.is_some() {
125+ return self.exit;
126+ }
127+ self.exit = match self.child.try_wait() {
128+ Ok(Some(_)) if self.native.is_some() => {
129+ use std::io::Read;
130+ let mut text = String::new();
131+ if let Some(out) = self.child.stdout.as_mut() {
132+ let _ = out.read_to_string(&mut text);
133+ }
134+ Some(native::parse_exit_code(&text).unwrap_or(1))
135+ }
116136 Ok(Some(status)) => Some(status.code().unwrap_or(1)),
117137 Ok(None) => None,
118138 Err(_) => Some(1),
119− }
139+ };
140+ self.exit
120141 }
121142
122143 /// Stops it: the container, then the process and everything it started.
128149 if let Some(engine) = &self.engine {
129150 quietly(docker().args(["kill", &engine.container]));
130151 }
152+ if let Some(job) = &self.native {
153+ job.stop();
154+ }
131155 let pid = self.child.id().to_string();
132156 if cfg!(windows) {
133157 quietly(Command::new("taskkill").args(["/T", "/F", "/PID", &pid]));
157181 if let Some(folder) = &self.folder {
158182 let _ = std::fs::remove_dir_all(folder);
159183 }
184+ if let Some(job) = self.native.take() {
185+ job.clean();
186+ }
160187 }
161188 }
162189
202229 /// the runner tells g1t how the process ended.
203230 pub fn start(config: &Config, isolation: Isolation, folder: &Path, work: &Assignment) -> Result<Running> {
204231 let env = harness_env(config, work)?;
205− if isolation == Isolation::Host {
206− refuse_public_on_host(work)?;
207− return directly(config, work, env);
232+ match isolation {
233+ Isolation::Host => {
234+ refuse_public_on_host(work)?;
235+ directly(config, work, env)
236+ }
237+ Isolation::Vm => in_native_vm(config, folder, work, env),
238+ _ => in_docker(config, isolation, folder, work, env),
208239 }
209− in_docker(config, isolation, folder, work, env)
210240 }
211241
242+/// A memory size as `--memory` takes it (`8g`, `4096m`), in MiB.
243+pub fn mib(text: &str) -> Option<u64> {
244+ let text = text.trim().to_ascii_lowercase();
245+ let (number, unit) = text.split_at(text.find(|c: char| !c.is_ascii_digit()).unwrap_or(text.len()));
246+ let number: u64 = number.parse().ok()?;
247+ match unit.trim_end_matches('b').trim_end_matches('i') {
248+ "" | "m" => Some(number),
249+ "g" => Some(number * 1024),
250+ "k" => Some(number / 1024),
251+ _ => None,
252+ }
253+}
254+
255+/// A job native to this OS, in a throwaway VM of its own (native.rs):
256+/// Windows Sandbox on Windows. Agent work needs g1t's Linux sandbox
257+/// layout and runs on a Linux runner.
258+fn in_native_vm(config: &Config, folder: &Path, work: &Assignment, env: BTreeMap<String, String>) -> Result<Running> {
259+ if work.kind != "workflow" {
260+ bail!("agent work runs on Linux runners: this one runs jobs native to {}", std::env::consts::OS);
261+ }
262+ if !cfg!(windows) {
263+ bail!("this runner has no sandbox for jobs native to {}", std::env::consts::OS);
264+ }
265+ let memory = config.memory.as_deref().and_then(mib).unwrap_or(4096);
266+ let drop = folder.join("native").join(container_name(&work.id));
267+ let job = WsbJob::start(drop, &env, memory)?;
268+ let child = match job.exec().spawn() {
269+ Ok(child) => child,
270+ Err(error) => {
271+ job.clean();
272+ return Err(error).context("could not run wsb exec");
273+ }
274+ };
275+ log(&format!(
276+ "Running {} {} in a Windows Sandbox of its own ({memory} MiB): no folder of this machine's but its read-only harness, discarded after",
277+ work.kind, work.name
278+ ));
279+ Ok(Running {
280+ id: work.id.clone(),
281+ repo: work.repo.clone(),
282+ kind: work.kind.clone(),
283+ name: work.name.clone(),
284+ child,
285+ container: None,
286+ network: None,
287+ volume: None,
288+ engine: None,
289+ folder: None,
290+ native: Some(job),
291+ exit: None,
292+ deadline: deadline(work),
293+ })
294+}
295+
212296 /// Host mode does not take a public repository's work: anyone can propose
213297 /// code to one, and here it would run on the machine itself.
214298 fn refuse_public_on_host(work: &Assignment) -> Result<()> {
375459 };
376460 Ok(Running {
377461 id: work.id.clone(),
462+ repo: work.repo.clone(),
378463 kind: work.kind.clone(),
379464 name: work.name.clone(),
380465 child,
383468 volume,
384469 engine,
385470 folder: None,
471+ native: None,
472+ exit: None,
386473 deadline: deadline(work),
387474 })
388475 }
439526 let child = command.spawn().context("could not start the job's process")?;
440527 Ok(Running {
441528 id: work.id.clone(),
529+ repo: work.repo.clone(),
442530 kind: work.kind.clone(),
443531 name: work.name.clone(),
444532 child,
447535 volume: None,
448536 engine: None,
449537 folder,
538+ native: None,
539+ exit: None,
450540 deadline: deadline(work),
451541 })
452542 }
512602 pids_limit: None,
513603 job_docker: true,
514604 job_docker_image: None,
605+ native: false,
515606 }
516607 }
517608
+936−0
1+//! Inside the sandbox's VM: `g1t-runner sandbox guest …`, which the
2+//! launcher on the computer runs (through `wsl.exe --exec` on Windows, as
3+//! the VM's systemd service on macOS and Linux).
4+//!
5+//! `guest run` is the VM's supervisor. It
6+//!
7+//! 1. on WSL, takes away what WSL puts in every distribution that reaches
8+//! Windows: `/mnt/wsl` (shared by every distribution, where Docker
9+//! Desktop keeps sockets to Windows' Docker), WSLg's display and sound
10+//! sockets, Windows' GPU driver folders, and `/dev/dxg`; and checks
11+//! `/etc/wsl.conf` is the image's, which turns off drives and Windows
12+//! programs ([`WSL_CONF`]);
13+//! 2. refuses to go on if anything of the computer's is still mounted, or
14+//! a container engine's socket other than its own is reachable
15+//! ([`problems`]);
16+//! 3. starts Sysbox and a Docker Engine of the VM's own, and adds rules
17+//! that keep every container off the computer's own address (the VM's
18+//! gateway) but for DNS ([`firewall_rules`]);
19+//! 4. runs the runner's image under `--runtime=sysbox-runc` with the
20+//! registration on `/var/lib/g1t-sandbox/state`, and keeps saying how
21+//! it is in `/var/lib/g1t-sandbox/status.json` (and on the console);
22+//! 5. on `guest stop` (or SIGTERM), asks the runner to drain or stop,
23+//! waits for it, and stops everything.
24+//!
25+//! Root in this VM is not a wall on WSL: every WSL distribution, the
26+//! person's own among them, runs in one VM, and root in any of them can
27+//! reach the others' disks. That is why the runner runs under Sysbox here
28+//! and its jobs in containers inside it: a job is two walls from this
29+//! VM's root, and three from the computer.
30+
31+use std::net::Ipv4Addr;
32+use std::path::Path;
33+
34+use anyhow::{Context, Result, bail};
35+use serde::{Deserialize, Serialize};
36+use serde_json::{Value, json};
37+
38+use crate::selfhosted::RunnerStatus;
39+
40+pub const ROOT: &str = "/var/lib/g1t-sandbox";
41+/// The runner's folder (`/data` in its container): its registration, and
42+/// the status file it keeps.
43+pub const STATE_DIR: &str = "/var/lib/g1t-sandbox/state";
44+pub const SETTINGS: &str = "/var/lib/g1t-sandbox/settings.json";
45+pub const REGISTER_ENV: &str = "/var/lib/g1t-sandbox/register.env";
46+pub const STATUS: &str = "/var/lib/g1t-sandbox/status.json";
47+/// `drain` or `now`: asks the supervisor to stop.
48+pub const STOP_FILE: &str = "/var/lib/g1t-sandbox/stop";
49+/// A runner image the launcher put here for the supervisor to load
50+/// (`sandbox up --runner-image-file`, for a build that is not released).
51+pub const IMAGE_TAR: &str = "/var/lib/g1t-sandbox/runner-image.tar";
52+pub const RUN_DIR: &str = "/run/g1t-sandbox";
53+/// The VM's Docker Engine: not `/var/run/docker.sock`.
54+pub const DOCKER_SOCKET: &str = "/run/g1t-sandbox/docker.sock";
55+pub const PID_FILE: &str = "/run/g1t-sandbox/supervisor.pid";
56+pub const RUNNER_CONTAINER: &str = "g1t-runner";
57+/// How a status line starts on a VM's console.
58+pub const STATUS_PREFIX: &str = "G1T-SANDBOX-STATUS ";
59+
60+/// The image's `/etc/wsl.conf`.
61+pub const WSL_CONF: &str = include_str!("../../../../../deploy/sandbox/wsl.conf");
62+
63+/// What WSL mounts into every distribution that reaches Windows or other
64+/// distributions, taken away at start.
65+pub const WSL_CHANNELS: &[&str] = &["/mnt/wsl", "/mnt/wslg", "/tmp/.X11-unix", "/usr/lib/wsl/drivers", "/usr/lib/wsl/lib"];
66+
67+/// What the launcher tells the guest (`guest configure`, or the seed disk).
68+#[derive(Clone, Debug, Default, Serialize, Deserialize, PartialEq)]
69+pub struct Settings {
70+ /// The site to register with, and a registration token: only until the
71+ /// runner has registered.
72+ #[serde(default)]
73+ pub url: Option<String>,
74+ #[serde(default)]
75+ pub token: Option<String>,
76+ #[serde(default)]
77+ pub name: Option<String>,
78+ #[serde(default)]
79+ pub labels: Vec<String>,
80+ #[serde(default)]
81+ pub group: Option<String>,
82+ /// The runner's container's share of the VM, and so its jobs'.
83+ #[serde(default)]
84+ pub cpus: u32,
85+ #[serde(default)]
86+ pub memory_mib: u64,
87+ /// The runner's image, by digest from the signed release.
88+ #[serde(default)]
89+ pub runner_image: String,
90+ #[serde(default)]
91+ pub image_version: String,
92+ /// For a VM, how to stop when it is shut down: `drain` or `now`.
93+ #[serde(default)]
94+ pub stop: Option<String>,
95+}
96+
97+/// What `guest status` says, and the launcher reports.
98+#[derive(Clone, Debug, Default, Serialize, Deserialize, PartialEq)]
99+pub struct GuestStatus {
100+ /// `starting`, `running`, `stopping`, `stopped` or `error`.
101+ pub state: String,
102+ #[serde(default)]
103+ pub runner: Option<RunnerStatus>,
104+ #[serde(default)]
105+ pub error: Option<String>,
106+ #[serde(default)]
107+ pub image_version: Option<String>,
108+ #[serde(default)]
109+ pub updated_at: String,
110+}
111+
112+/// A mount that is the computer's, or WSL's way to it, from
113+/// `/proc/self/mountinfo`.
114+pub fn host_mounts(mountinfo: &str) -> Vec<String> {
115+ let mut found = Vec::new();
116+ for line in mountinfo.lines() {
117+ let fields: Vec<&str> = line.split_whitespace().collect();
118+ if fields.len() < 5 {
119+ continue;
120+ }
121+ let point = fields[4];
122+ let fstype = line.split(" - ").nth(1).and_then(|rest| rest.split_whitespace().next()).unwrap_or("");
123+ let shared_fs = matches!(fstype, "9p" | "drvfs" | "virtiofs" | "vboxsf" | "prl_fs" | "vmhgfs" | "fuse.vmhgfs-fuse" | "smb3" | "cifs");
124+ let channel = WSL_CHANNELS.iter().any(|c| point == *c || point.starts_with(&format!("{c}/")));
125+ if shared_fs || channel || point.starts_with("/mnt/") {
126+ found.push(format!("{point} ({fstype})"));
127+ }
128+ }
129+ found
130+}
131+
132+/// What keeps the VM from being a sandbox: mounts of the computer's, an
133+/// engine socket that is not this VM's own, WSL's interop with Windows.
134+pub fn problems(mountinfo: &str, sockets: &[String], interop: bool, wsl_conf: Option<&str>) -> Vec<String> {
135+ let mut problems: Vec<String> = host_mounts(mountinfo).into_iter().map(|m| format!("{m} is mounted from the computer, or is WSL's way to it")).collect();
136+ for socket in sockets {
137+ problems.push(format!("{socket} is a container engine's socket that is not this sandbox's own"));
138+ }
139+ if interop {
140+ problems.push("WSL interop is on: Windows programs can be started from inside the sandbox".to_owned());
141+ }
142+ if let Some(conf) = wsl_conf
143+ && conf.trim() != WSL_CONF.trim()
144+ {
145+ problems.push("/etc/wsl.conf is not the sandbox image's, which turns off Windows drives and programs".to_owned());
146+ }
147+ problems
148+}
149+
150+/// The VM's default gateway, which is the computer, from `/proc/net/route`
151+/// (addresses in little-endian hex).
152+pub fn gateway(route: &str) -> Option<Ipv4Addr> {
153+ route.lines().skip(1).find_map(|line| {
154+ let fields: Vec<&str> = line.split_whitespace().collect();
155+ if fields.len() > 2 && fields[1] == "00000000" {
156+ u32::from_str_radix(fields[2], 16).ok().map(|n| Ipv4Addr::from(n.swap_bytes())).filter(|a| !a.is_unspecified())
157+ } else {
158+ None
159+ }
160+ })
161+}
162+
163+/// The sandbox's `/etc/resolv.conf`: the resolvers WSL offered, else the
164+/// gateway, which answers DNS on WSL's NAT.
165+pub fn resolv_conf(offered: &str, gateway: Option<Ipv4Addr>) -> String {
166+ let mut servers = nameservers(offered);
167+ if servers.is_empty() {
168+ servers.extend(gateway);
169+ }
170+ servers.iter().map(|s| format!("nameserver {s}\n")).collect()
171+}
172+
173+pub fn nameservers(resolv: &str) -> Vec<Ipv4Addr> {
174+ resolv
175+ .lines()
176+ .filter_map(|l| l.trim().strip_prefix("nameserver"))
177+ .filter_map(|a| a.trim().parse().ok())
178+ .collect()
179+}
180+
181+/// `iptables` arguments, in order, for the `DOCKER-USER` chain, which
182+/// every container's traffic passes: DNS to the VM's resolvers, and
183+/// nothing else to the computer's address (the gateway), to a resolver
184+/// the computer answers on, or to link-local addresses. The internet
185+/// stays reachable, as a job needs.
186+pub fn firewall_rules(gateway: Option<Ipv4Addr>, resolvers: &[Ipv4Addr]) -> Vec<Vec<String>> {
187+ let s = |items: &[&str]| items.iter().map(|i| i.to_string()).collect::<Vec<String>>();
188+ let mut rules = Vec::new();
189+ let mut walled: Vec<Ipv4Addr> = gateway.into_iter().collect();
190+ for resolver in resolvers {
191+ if resolver.is_loopback() {
192+ continue;
193+ }
194+ let address = resolver.to_string();
195+ for proto in ["udp", "tcp"] {
196+ rules.push(s(&["-d", &address, "-p", proto, "--dport", "53", "-j", "ACCEPT"]));
197+ }
198+ // A resolver that is the computer, or WSL's DNS tunnel, takes DNS
199+ // and nothing else.
200+ if (resolver.is_private() || resolver.is_link_local()) && !walled.contains(resolver) {
201+ walled.push(*resolver);
202+ }
203+ }
204+ for address in walled {
205+ rules.push(s(&["-d", &address.to_string(), "-j", "DROP"]));
206+ }
207+ rules.push(s(&["-d", "169.254.0.0/16", "-j", "DROP"]));
208+ rules
209+}
210+
211+/// The VM's Docker Engine: Sysbox as a runtime, its own socket, address
212+/// ranges away from Docker's defaults (the runner's own engine, inside,
213+/// uses those).
214+pub fn daemon_json() -> Value {
215+ json!({
216+ "hosts": [format!("unix://{DOCKER_SOCKET}")],
217+ "pidfile": format!("{RUN_DIR}/dockerd.pid"),
218+ "runtimes": { "sysbox-runc": { "path": "/usr/local/bin/sysbox-runc" } },
219+ "bip": "172.29.254.1/24",
220+ "default-address-pools": [{ "base": "172.28.0.0/16", "size": 24 }],
221+ "log-driver": "json-file",
222+ "log-opts": { "max-size": "20m", "max-file": "2" },
223+ })
224+}
225+
226+/// The registration's variables, for `register-and-run` in the runner's
227+/// image: a file, so the token is on no command line.
228+pub fn register_env(settings: &Settings) -> Result<String> {
229+ let (Some(url), Some(token)) = (&settings.url, &settings.token) else { bail!("the sandbox's runner is not registered, and no --url and --token were given") };
230+ let mut lines = vec![format!("G1T_RUNNER_URL={url}"), format!("G1T_RUNNER_TOKEN={token}")];
231+ if let Some(name) = &settings.name {
232+ lines.push(format!("G1T_RUNNER_NAME={name}"));
233+ }
234+ if !settings.labels.is_empty() {
235+ lines.push(format!("G1T_RUNNER_LABELS={}", settings.labels.join(",")));
236+ }
237+ if let Some(group) = &settings.group {
238+ lines.push(format!("G1T_RUNNER_GROUP={group}"));
239+ }
240+ if lines.iter().any(|l| l.contains('\n') || l.contains('\r')) {
241+ bail!("a registration setting has a line break in it");
242+ }
243+ Ok(lines.join("\n") + "\n")
244+}
245+
246+/// `docker run` for the runner in the VM.
247+pub fn runner_run_args(settings: &Settings, register: bool) -> Vec<String> {
248+ sysbox_run_args(settings, RUNNER_CONTAINER, STATE_DIR, register.then_some(REGISTER_ENV))
249+}
250+
251+/// `docker run` for the runner: its image under Sysbox, `data` (a folder
252+/// or a volume) as `/data`, no socket, no host network, its share of the
253+/// machine, and no self-update (the launcher updates the whole sandbox).
254+pub fn sysbox_run_args(settings: &Settings, name: &str, data: &str, env_file: Option<&str>) -> Vec<String> {
255+ let mut args: Vec<String> = ["run", "-d", "--name", name, "--runtime=sysbox-runc", "--restart", "on-failure:5"]
256+ .iter()
257+ .map(|s| s.to_string())
258+ .collect();
259+ if settings.cpus > 0 {
260+ args.extend(["--cpus".into(), settings.cpus.to_string()]);
261+ }
262+ if settings.memory_mib > 0 {
263+ let memory = format!("{}m", settings.memory_mib);
264+ args.extend(["--memory".into(), memory.clone(), "--memory-swap".into(), memory]);
265+ }
266+ args.extend(["-v".into(), format!("{data}:/data")]);
267+ args.extend(["-e".into(), "G1T_RUNNER_NO_AUTO_UPDATE=1".into()]);
268+ if let Some(file) = env_file {
269+ args.extend(["--env-file".into(), file.into()]);
270+ }
271+ args.extend(["--label".into(), "sh.g1t.sandbox=runner".into()]);
272+ args.push(settings.runner_image.clone());
273+ args
274+}
275+
276+/// Reads a seed disk: JSON, then zeros.
277+pub fn read_seed(bytes: &[u8]) -> Result<Settings> {
278+ let end = bytes.iter().position(|b| *b == 0).unwrap_or(bytes.len());
279+ serde_json::from_slice(&bytes[..end]).context("the seed disk does not read")
280+}
281+
282+/// A seed disk's bytes: the settings' JSON, padded to `size`.
283+pub fn write_seed(settings: &Settings, size: usize) -> Result<Vec<u8>> {
284+ let mut bytes = serde_json::to_vec(settings)?;
285+ if bytes.len() >= size {
286+ bail!("the sandbox's settings do not fit its seed disk");
287+ }
288+ bytes.resize(size, 0);
289+ Ok(bytes)
290+}
291+
292+/// The last status line on a VM's console.
293+pub fn status_from_console(log: &str) -> Option<GuestStatus> {
294+ log.lines().rev().find_map(|line| line.split_once(STATUS_PREFIX).and_then(|(_, json)| serde_json::from_str(json.trim()).ok()))
295+}
296+
297+fn read_json<T: for<'a> Deserialize<'a>>(path: &str) -> Option<T> {
298+ std::fs::read(path).ok().and_then(|bytes| serde_json::from_slice(&bytes).ok())
299+}
300+
301+fn write_private(path: &str, bytes: &[u8]) -> Result<()> {
302+ let temp = format!("{path}.new");
303+ std::fs::write(&temp, bytes).with_context(|| format!("could not write {temp}"))?;
304+ #[cfg(unix)]
305+ {
306+ use std::os::unix::fs::PermissionsExt;
307+ std::fs::set_permissions(&temp, std::fs::Permissions::from_mode(0o600))?;
308+ }
309+ std::fs::rename(&temp, path).with_context(|| format!("could not write {path}"))?;
310+ Ok(())
311+}
312+
313+fn registered() -> bool {
314+ Path::new(STATE_DIR).join("config.json").exists()
315+}
316+
317+/// `g1t-runner sandbox guest <command>`.
318+pub fn main(args: &[String]) -> Result<()> {
319+ let command = args.first().map(String::as_str).unwrap_or("");
320+ let value = |name: &str| args.windows(2).find(|w| w[0] == name).map(|w| w[1].clone());
321+ match command {
322+ "configure" => configure(&read_stdin()?),
323+ "status" => {
324+ println!("{}", serde_json::to_string(&current_status())?);
325+ Ok(())
326+ }
327+ "export-state" => {
328+ use std::io::Write;
329+ let bytes = std::fs::read(Path::new(STATE_DIR).join("config.json")).context("the sandbox's runner is not registered")?;
330+ std::io::stdout().write_all(&bytes)?;
331+ Ok(())
332+ }
333+ "import-state" => {
334+ std::fs::create_dir_all(STATE_DIR)?;
335+ let bytes = read_stdin()?;
336+ serde_json::from_slice::<Value>(&bytes).context("that is not a runner's configuration")?;
337+ write_private(&format!("{STATE_DIR}/config.json"), &bytes)
338+ }
339+ "load-image" => {
340+ std::fs::create_dir_all(ROOT)?;
341+ let bytes = read_stdin()?;
342+ write_private(IMAGE_TAR, &bytes)
343+ }
344+ "check" => {
345+ let found = check();
346+ if found.is_empty() {
347+ println!("Nothing of the computer's reaches this sandbox.");
348+ Ok(())
349+ } else {
350+ bail!("this sandbox can reach the computer:\n - {}", found.join("\n - "))
351+ }
352+ }
353+ "stop" => stop(if args.iter().any(|a| a == "--now") { "now" } else { "drain" }, value("--wait").and_then(|w| w.parse().ok())),
354+ "run" => run(value("--seed"), value("--state-disk"), value("--console")),
355+ _ => bail!("guest takes configure, run, status, stop, check, export-state, import-state or load-image"),
356+ }
357+}
358+
359+fn read_stdin() -> Result<Vec<u8>> {
360+ use std::io::Read;
361+ let mut bytes = Vec::new();
362+ std::io::stdin().read_to_end(&mut bytes)?;
363+ // Windows' tools may put a byte-order mark before JSON.
364+ if bytes.starts_with(&[0xEF, 0xBB, 0xBF]) {
365+ bytes.drain(..3);
366+ }
367+ Ok(bytes)
368+}
369+
370+/// Keeps what the launcher sent, over what was there: a token only until
371+/// the runner has registered.
372+fn configure(bytes: &[u8]) -> Result<()> {
373+ let given: Settings = serde_json::from_slice(bytes).context("the sandbox's settings do not read")?;
374+ std::fs::create_dir_all(STATE_DIR)?;
375+ let mut settings: Settings = read_json(SETTINGS).unwrap_or_default();
376+ if given.url.is_some() {
377+ settings.url = given.url;
378+ }
379+ if given.token.is_some() && !registered() {
380+ settings.token = given.token;
381+ }
382+ if given.name.is_some() {
383+ settings.name = given.name;
384+ }
385+ if !given.labels.is_empty() {
386+ settings.labels = given.labels;
387+ }
388+ if given.group.is_some() {
389+ settings.group = given.group;
390+ }
391+ if given.cpus > 0 {
392+ settings.cpus = given.cpus;
393+ }
394+ if given.memory_mib > 0 {
395+ settings.memory_mib = given.memory_mib;
396+ }
397+ if !given.runner_image.is_empty() {
398+ settings.runner_image = given.runner_image;
399+ }
400+ if !given.image_version.is_empty() {
401+ settings.image_version = given.image_version;
402+ }
403+ write_private(SETTINGS, &serde_json::to_vec_pretty(&settings)?)
404+}
405+
406+/// The supervisor's status, or `stopped` when it is not running.
407+fn current_status() -> GuestStatus {
408+ let status: Option<GuestStatus> = read_json(STATUS);
409+ let alive = supervisor_alive();
410+ match status {
411+ Some(status) if alive || status.state == "error" || status.state == "stopped" => status,
412+ Some(status) => GuestStatus { state: "stopped".into(), ..status },
413+ None => GuestStatus { state: "stopped".into(), ..GuestStatus::default() },
414+ }
415+}
416+
417+fn supervisor_alive() -> bool {
418+ std::fs::read_to_string(PID_FILE)
419+ .ok()
420+ .and_then(|p| p.trim().parse::<u32>().ok())
421+ .is_some_and(|pid| Path::new(&format!("/proc/{pid}")).exists())
422+}
423+
424+/// What reaches the computer from here, now.
425+pub fn check() -> Vec<String> {
426+ let mountinfo = std::fs::read_to_string("/proc/self/mountinfo").unwrap_or_default();
427+ let mut sockets = Vec::new();
428+ // `/var/run` is `/run`.
429+ for dir in ["/mnt", "/run", "/tmp"] {
430+ find_sockets(Path::new(dir), 3, &mut sockets);
431+ }
432+ let sockets: Vec<String> = sockets.into_iter().filter(|s| !own_socket(s)).collect();
433+ let on_wsl = on_wsl();
434+ // With interop off, WSL starts no interop server for the distribution's
435+ // processes, and `WSL_INTEROP` names none.
436+ let interop = on_wsl && std::env::var_os("WSL_INTEROP").is_some();
437+ let conf = on_wsl.then(|| std::fs::read_to_string("/etc/wsl.conf").unwrap_or_default());
438+ problems(&mountinfo, &sockets, interop, conf.as_deref())
439+}
440+
441+/// The sandbox's own engine's sockets, and the containerd and Sysbox it
442+/// runs.
443+pub fn own_socket(path: &str) -> bool {
444+ [RUN_DIR, "/run/docker/", "/run/containerd/", "/run/sysbox/"].iter().any(|own| path.starts_with(own))
445+}
446+
447+#[cfg_attr(not(unix), allow(clippy::only_used_in_recursion))]
448+fn find_sockets(dir: &Path, depth: u32, found: &mut Vec<String>) {
449+ let Ok(entries) = std::fs::read_dir(dir) else { return };
450+ for entry in entries.flatten() {
451+ let Ok(kind) = entry.file_type() else { continue };
452+ let path = entry.path();
453+ #[cfg(unix)]
454+ {
455+ use std::os::unix::fs::FileTypeExt;
456+ if kind.is_socket() {
457+ let name = path.file_name().and_then(|n| n.to_str()).unwrap_or("");
458+ if ["docker.sock", "containerd.sock", "podman.sock", "docker.proxy.sock", "backend.sock", "filesystem.sock"].contains(&name) {
459+ found.push(path.display().to_string());
460+ }
461+ continue;
462+ }
463+ }
464+ if kind.is_dir() && depth > 0 {
465+ find_sockets(&path, depth - 1, found);
466+ }
467+ }
468+}
469+
470+fn on_wsl() -> bool {
471+ std::env::var_os("WSL_DISTRO_NAME").is_some() || Path::new("/proc/sys/fs/binfmt_misc/WSLInterop").exists() || Path::new("/run/WSL").exists()
472+}
473+
474+/// Asks the supervisor to stop (`drain`: once the runner's job is done),
475+/// and waits up to `wait` seconds for it, or forever.
476+fn stop(mode: &str, wait: Option<u64>) -> Result<()> {
477+ if !supervisor_alive() {
478+ return Ok(());
479+ }
480+ std::fs::write(STOP_FILE, mode)?;
481+ let until = wait.map(|w| std::time::Instant::now() + std::time::Duration::from_secs(w));
482+ while supervisor_alive() {
483+ if until.is_some_and(|u| std::time::Instant::now() > u) {
484+ bail!("the sandbox is still stopping");
485+ }
486+ std::thread::sleep(std::time::Duration::from_millis(500));
487+ }
488+ Ok(())
489+}
490+
491+#[cfg(not(target_os = "linux"))]
492+fn run(_seed: Option<String>, _state_disk: Option<String>, _console: Option<String>) -> Result<()> {
493+ bail!("the sandbox's supervisor runs inside its Linux VM")
494+}
495+
496+#[cfg(target_os = "linux")]
497+fn run(seed: Option<String>, state_disk: Option<String>, console: Option<String>) -> Result<()> {
498+ supervisor::run(seed, state_disk, console)
499+}
500+
501+#[cfg(target_os = "linux")]
502+mod supervisor {
503+ use std::path::Path;
504+ use std::process::{Child, Command, Stdio};
505+ use std::sync::atomic::Ordering;
506+ use std::time::{Duration, Instant};
507+
508+ use anyhow::{Context, Result, bail};
509+
510+ use super::*;
511+ use crate::selfhosted::{STOP, g1t_now};
512+
513+ extern "C" fn on_term(_: libc::c_int) {
514+ STOP.store(true, Ordering::SeqCst);
515+ }
516+
517+ fn now() -> String {
518+ g1t_now(std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).map(|d| d.as_millis() as u64).unwrap_or(0))
519+ }
520+
521+ /// A clean environment: WSL's session points `XDG_RUNTIME_DIR` and
522+ /// the display variables into /mnt/wslg, which is gone.
523+ fn clean(command: &mut Command) -> &mut Command {
524+ command.env_clear().env("PATH", "/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin").env("HOME", "/root")
525+ }
526+
527+ fn docker() -> Command {
528+ let mut command = Command::new("docker");
529+ clean(&mut command).env("DOCKER_HOST", format!("unix://{DOCKER_SOCKET}"));
530+ command
531+ }
532+
533+ fn ok(command: &mut Command) -> bool {
534+ command.stdout(Stdio::null()).stderr(Stdio::null()).status().is_ok_and(|s| s.success())
535+ }
536+
537+ struct Say {
538+ console: Option<String>,
539+ version: Option<String>,
540+ }
541+
542+ impl Say {
543+ fn status(&self, state: &str, runner: Option<RunnerStatus>, error: Option<String>) {
544+ let status = GuestStatus { state: state.into(), runner, error, image_version: self.version.clone(), updated_at: now() };
545+ let Ok(bytes) = serde_json::to_vec(&status) else { return };
546+ let _ = write_private(STATUS, &bytes);
547+ if let Some(console) = &self.console {
548+ let _ = std::fs::OpenOptions::new()
549+ .append(true)
550+ .open(console)
551+ .and_then(|mut c| std::io::Write::write_all(&mut c, format!("{STATUS_PREFIX}{}\n", String::from_utf8_lossy(&bytes)).as_bytes()));
552+ }
553+ }
554+ }
555+
556+ fn daemon(program: &str, args: &[&str], log: &str) -> Result<Child> {
557+ let out = std::fs::File::create(format!("{RUN_DIR}/{log}"))?;
558+ let mut command = Command::new(program);
559+ clean(&mut command)
560+ .args(args)
561+ .stdout(out.try_clone()?)
562+ .stderr(out)
563+ .stdin(Stdio::null())
564+ .spawn()
565+ .with_context(|| format!("could not start {program}"))
566+ }
567+
568+ fn wait_for(what: &str, limit: Duration, mut ready: impl FnMut() -> bool) -> Result<()> {
569+ let until = Instant::now() + limit;
570+ while !ready() {
571+ if Instant::now() > until {
572+ bail!("{what} did not start");
573+ }
574+ std::thread::sleep(Duration::from_millis(250));
575+ }
576+ Ok(())
577+ }
578+
579+ /// WSL's ways to Windows and to other distributions, taken away.
580+ fn wsl_prepare() -> Result<()> {
581+ // The resolvers WSL offers (its DNS tunnel, or the gateway), in a
582+ // file of the sandbox's own: WSL's is in /mnt/wsl, unmounted next.
583+ let offered = ["/mnt/wsl/resolv.conf", "/etc/resolv.conf"].iter().find_map(|p| std::fs::read_to_string(p).ok()).unwrap_or_default();
584+ let route = std::fs::read_to_string("/proc/net/route").unwrap_or_default();
585+ let _ = std::fs::remove_file("/etc/resolv.conf");
586+ std::fs::write("/etc/resolv.conf", resolv_conf(&offered, gateway(&route)))?;
587+ for point in WSL_CHANNELS {
588+ let _ = Command::new("umount").args(["-R", "-l", point]).stdout(Stdio::null()).stderr(Stdio::null()).status();
589+ }
590+ // The GPU's paravirtual device: a way to Windows' driver.
591+ let _ = std::fs::remove_file("/dev/dxg");
592+ Ok(())
593+ }
594+
595+ fn mount_state_disk(device: &str) -> Result<()> {
596+ std::fs::create_dir_all(STATE_DIR)?;
597+ let blank = !Command::new("blkid").arg(device).output().is_ok_and(|o| o.status.success() && !o.stdout.is_empty());
598+ if blank && !ok(Command::new("mkfs.ext4").args(["-q", "-L", "g1t-state", device])) {
599+ bail!("could not format the state disk {device}");
600+ }
601+ if !ok(Command::new("mount").args([device, STATE_DIR])) {
602+ bail!("could not mount the state disk {device}");
603+ }
604+ Ok(())
605+ }
606+
607+ fn read_seed_device(device: &str) -> Result<Settings> {
608+ // The launcher may have rewritten it since boot: read past the cache.
609+ let _ = Command::new("blockdev").args(["--flushbufs", device]).status();
610+ let bytes = std::fs::read(device).with_context(|| format!("could not read the seed disk {device}"))?;
611+ read_seed(&bytes)
612+ }
613+
614+ fn firewall() -> Result<()> {
615+ let route = std::fs::read_to_string("/proc/net/route").unwrap_or_default();
616+ let resolv = std::fs::read_to_string("/etc/resolv.conf").unwrap_or_default();
617+ let rules = firewall_rules(gateway(&route), &nameservers(&resolv));
618+ let _ = Command::new("iptables").args(["-N", "DOCKER-USER"]).stderr(Stdio::null()).status();
619+ // Only these rules, however many times the sandbox has started.
620+ let _ = Command::new("iptables").args(["-F", "DOCKER-USER"]).status();
621+ // Inserted last first, so they end up in order at the chain's top.
622+ for (at, rule) in rules.iter().enumerate() {
623+ let position = (at + 1).to_string();
624+ let mut args = vec!["-I", "DOCKER-USER", position.as_str()];
625+ args.extend(rule.iter().map(String::as_str));
626+ if !ok(Command::new("iptables").args(&args)) {
627+ bail!("could not add the rule that keeps containers off the computer: iptables {}", args.join(" "));
628+ }
629+ }
630+ Ok(())
631+ }
632+
633+ fn container_state() -> Option<(bool, i32)> {
634+ let out = docker().args(["inspect", "-f", "{{.State.Running}} {{.State.ExitCode}}", RUNNER_CONTAINER]).output().ok()?;
635+ if !out.status.success() {
636+ return None;
637+ }
638+ let text = String::from_utf8_lossy(&out.stdout);
639+ let mut parts = text.split_whitespace();
640+ Some((parts.next() == Some("true"), parts.next().and_then(|c| c.parse().ok()).unwrap_or(0)))
641+ }
642+
643+ /// The runner's last words: its last line of output.
644+ fn logs_tail() -> String {
645+ docker()
646+ .args(["logs", "--tail", "1", RUNNER_CONTAINER])
647+ .output()
648+ .map(|o| format!("{}{}", String::from_utf8_lossy(&o.stdout), String::from_utf8_lossy(&o.stderr)).trim().to_owned())
649+ .unwrap_or_default()
650+ .trim_start_matches("g1t-runner: ")
651+ .to_owned()
652+ }
653+
654+ fn start_runner(settings: &Settings) -> Result<()> {
655+ if let Some((true, _)) = container_state() {
656+ return Ok(());
657+ }
658+ let _ = docker().args(["rm", "-f", RUNNER_CONTAINER]).stdout(Stdio::null()).stderr(Stdio::null()).status();
659+ // What an earlier runner said is not what this one says.
660+ let _ = std::fs::remove_file(format!("{STATE_DIR}/status.json"));
661+ let register = !registered();
662+ if register {
663+ write_private(REGISTER_ENV, register_env(settings)?.as_bytes())?;
664+ }
665+ let out = docker().args(runner_run_args(settings, register)).output().context("could not run docker")?;
666+ if !out.status.success() {
667+ bail!("could not start the runner: {}", String::from_utf8_lossy(&out.stderr).trim());
668+ }
669+ Ok(())
670+ }
671+
672+ pub fn run(seed: Option<String>, state_disk: Option<String>, console: Option<String>) -> Result<()> {
673+ unsafe {
674+ libc::signal(libc::SIGTERM, on_term as *const () as libc::sighandler_t);
675+ libc::signal(libc::SIGINT, on_term as *const () as libc::sighandler_t);
676+ }
677+ std::fs::create_dir_all(RUN_DIR)?;
678+ std::fs::create_dir_all(ROOT)?;
679+ std::fs::write(PID_FILE, std::process::id().to_string())?;
680+ let _ = std::fs::remove_file(STOP_FILE);
681+ let version = std::fs::read_to_string("/etc/g1t-sandbox-version").ok().map(|v| v.trim().to_owned());
682+ let say = Say { console, version };
683+ say.status("starting", None, None);
684+ let outcome = supervise(&say, seed, state_disk);
685+ if let Err(error) = &outcome {
686+ say.status("error", None, Some(format!("{error:#}")));
687+ }
688+ let _ = std::fs::remove_file(PID_FILE);
689+ outcome
690+ }
691+
692+ fn supervise(say: &Say, seed: Option<String>, state_disk: Option<String>) -> Result<()> {
693+ if on_wsl() {
694+ wsl_prepare()?;
695+ }
696+ if let Some(device) = &state_disk {
697+ // A VM's system disk was grown to the size asked for: its file
698+ // system grows to fill it.
699+ let _ = Command::new("resize2fs").arg("/dev/vda").stdout(Stdio::null()).stderr(Stdio::null()).status();
700+ mount_state_disk(device)?;
701+ }
702+ std::fs::create_dir_all(STATE_DIR)?;
703+ if let Some(device) = &seed {
704+ let settings = read_seed_device(device)?;
705+ configure(&serde_json::to_vec(&settings)?)?;
706+ }
707+ let found = check();
708+ if !found.is_empty() {
709+ bail!("this sandbox can reach the computer, so it will not run jobs:\n - {}", found.join("\n - "));
710+ }
711+ let settings: Settings = read_json(SETTINGS).context("the sandbox has no settings: run `g1t-runner sandbox up` on the computer")?;
712+ if settings.runner_image.is_empty() {
713+ bail!("the sandbox's settings name no runner image");
714+ }
715+
716+ let mut daemons: Vec<Child> = Vec::new();
717+ let _ = std::fs::remove_file("/run/sysbox/sysmgr.sock");
718+ daemons.push(daemon("sysbox-mgr", &[], "sysbox-mgr.log")?);
719+ wait_for("Sysbox's manager", Duration::from_secs(30), || Path::new("/run/sysbox/sysmgr.sock").exists())?;
720+ daemons.push(daemon("sysbox-fs", &[], "sysbox-fs.log")?);
721+ wait_for("Sysbox's file system", Duration::from_secs(30), || Path::new("/run/sysbox/sysfs.sock").exists())?;
722+ std::fs::write("/etc/docker/daemon.json", serde_json::to_vec_pretty(&daemon_json())?)?;
723+ daemons.push(daemon("dockerd", &[], "dockerd.log")?);
724+ let outcome = (|| -> Result<()> {
725+ wait_for("The sandbox's Docker Engine", Duration::from_secs(60), || ok(docker().args(["version"])))?;
726+ firewall()?;
727+ if Path::new(IMAGE_TAR).exists() {
728+ let loaded = docker().args(["load", "-i", IMAGE_TAR]).stdout(Stdio::null()).status();
729+ let _ = std::fs::remove_file(IMAGE_TAR);
730+ if !loaded.is_ok_and(|s| s.success()) {
731+ bail!("could not load the runner image the launcher gave");
732+ }
733+ }
734+ if !ok(docker().args(["image", "inspect", &settings.runner_image])) && !ok(docker().args(["pull", "-q", &settings.runner_image])) {
735+ bail!("could not fetch the runner's image {}", settings.runner_image);
736+ }
737+ start_runner(&settings)?;
738+ watch(say)
739+ })();
740+ // Everything stops with the supervisor; the runner first, so it can
741+ // say how its job ended.
742+ let _ = docker().args(["stop", "-t", "60", RUNNER_CONTAINER]).stdout(Stdio::null()).stderr(Stdio::null()).status();
743+ for child in daemons.iter_mut().rev() {
744+ unsafe {
745+ libc::kill(child.id() as libc::pid_t, libc::SIGTERM);
746+ }
747+ let until = Instant::now() + Duration::from_secs(20);
748+ while matches!(child.try_wait(), Ok(None)) && Instant::now() < until {
749+ std::thread::sleep(Duration::from_millis(200));
750+ }
751+ let _ = child.kill();
752+ let _ = child.wait();
753+ }
754+ if outcome.is_ok() {
755+ say.status("stopped", None, None);
756+ }
757+ outcome
758+ }
759+
760+ /// Says how the runner is until asked to stop, then stops it.
761+ fn watch(say: &Say) -> Result<()> {
762+ let data = Path::new(STATE_DIR);
763+ let mut asked: Option<String> = None;
764+ let mut tried_again = Instant::now();
765+ loop {
766+ if asked.is_none() {
767+ let mode = std::fs::read_to_string(STOP_FILE).ok().map(|m| m.trim().to_owned());
768+ asked = mode.or_else(|| STOP.load(Ordering::SeqCst).then(|| read_json::<Settings>(SETTINGS).and_then(|s| s.stop).unwrap_or_else(|| "now".into())));
769+ if let Some(mode) = &asked {
770+ // The runner reads these in its folder (selfhosted/mod.rs).
771+ std::fs::write(data.join(if mode == "drain" { "drain" } else { "stop" }), "")?;
772+ }
773+ }
774+ if registered() && Path::new(REGISTER_ENV).exists() {
775+ // Registered: the token is no longer kept.
776+ let _ = std::fs::remove_file(REGISTER_ENV);
777+ if let Some(mut settings) = read_json::<Settings>(SETTINGS) {
778+ settings.token = None;
779+ let _ = write_private(SETTINGS, &serde_json::to_vec_pretty(&settings)?);
780+ }
781+ }
782+ let runner: Option<RunnerStatus> = read_json(&format!("{STATE_DIR}/status.json"));
783+ match container_state() {
784+ Some((true, _)) => {
785+ let state = if asked.is_some() { "stopping" } else { "running" };
786+ say.status(state, runner, None);
787+ }
788+ Some((false, code)) if asked.is_some() || code == 0 => {
789+ let _ = std::fs::remove_file(data.join("stop"));
790+ let _ = std::fs::remove_file(data.join("drain"));
791+ return Ok(());
792+ }
793+ Some((false, code)) => {
794+ say.status("error", runner, Some(format!("The runner stopped ({code}): {}", logs_tail())));
795+ if tried_again.elapsed() > Duration::from_secs(30) {
796+ tried_again = Instant::now();
797+ let _ = docker().args(["start", RUNNER_CONTAINER]).stdout(Stdio::null()).stderr(Stdio::null()).status();
798+ }
799+ }
800+ None => bail!("the runner's container is gone"),
801+ }
802+ std::thread::sleep(Duration::from_secs(2));
803+ }
804+ }
805+}
806+
807+#[cfg(test)]
808+mod tests {
809+ use super::*;
810+
811+ const WSL_MOUNTINFO: &str = "\
812+353 361 0:29 / /usr/lib/modules/6.6.87.2-microsoft-standard-WSL2 rw,nosuid,nodev,noatime - overlay none rw,lowerdir=/modules
813+354 361 0:32 / /mnt/wsl rw,relatime shared:1 - tmpfs none rw
814+355 354 0:61 /guest-services /mnt/wsl/docker-desktop/shared-sockets/guest-services rw,nosuid,nodev shared:18 - tmpfs none rw,mode=755
815+358 361 0:34 / /usr/lib/wsl/drivers ro,nosuid,nodev,noatime - 9p drivers ro,aname=drivers;fmask=222;dmask=222
816+361 343 8:96 / / rw,relatime - ext4 /dev/sdg rw,discard,errors=remount-ro,data=ordered
817+362 361 0:87 / /mnt/wslg rw,relatime shared:32 - tmpfs none rw
818+389 361 0:104 / /usr/lib/wsl/lib rw,nosuid,nodev,noatime - overlay none rw,lowerdir=/gpu_lib_packaged:/gpu_lib_inbox
819+393 361 0:108 / /proc rw,nosuid,nodev,noexec,noatime shared:46 - proc proc rw
820+411 361 0:87 /.X11-unix /tmp/.X11-unix ro,relatime shared:32 - tmpfs none rw
821+";
822+
823+ const CLEAN: &str = "\
824+361 343 8:96 / / rw,relatime - ext4 /dev/sdg rw,discard,errors=remount-ro,data=ordered
825+393 361 0:108 / /proc rw,nosuid,nodev,noexec,noatime shared:46 - proc proc rw
826+395 361 0:110 / /run rw,nosuid,nodev shared:48 - tmpfs none rw,mode=755
827+";
828+
829+ #[test]
830+ fn what_wsl_mounts_is_found_and_a_cleaned_distribution_is_clean() {
831+ let found = host_mounts(WSL_MOUNTINFO);
832+ for point in ["/mnt/wsl ", "/mnt/wsl/docker-desktop", "/usr/lib/wsl/drivers", "/mnt/wslg", "/usr/lib/wsl/lib", "/tmp/.X11-unix"] {
833+ assert!(found.iter().any(|f| f.starts_with(point)), "{point} in {found:?}");
834+ }
835+ assert!(!found.iter().any(|f| f.starts_with("/usr/lib/modules")));
836+ assert!(host_mounts(CLEAN).is_empty());
837+ let drive = "500 361 0:90 / /mnt/c rw,noatime - 9p C:\\ rw,aname=drvfs;path=C:\\";
838+ assert_eq!(host_mounts(drive), vec!["/mnt/c (9p)".to_owned()]);
839+ assert_eq!(host_mounts("501 361 0:91 / /work rw - virtiofs share rw").len(), 1);
840+ }
841+
842+ #[test]
843+ fn the_sandbox_refuses_what_reaches_the_computer() {
844+ assert!(problems(CLEAN, &[], false, Some(WSL_CONF)).is_empty());
845+ assert!(problems(CLEAN, &[], false, None).is_empty());
846+ let all = problems(WSL_MOUNTINFO, &["/mnt/wsl/docker-desktop/shared-sockets/host-services/docker.proxy.sock".into()], true, Some("[automount]\nenabled=true\n"));
847+ assert!(all.iter().any(|p| p.contains("docker.proxy.sock")));
848+ assert!(all.iter().any(|p| p.contains("interop is on")));
849+ assert!(all.iter().any(|p| p.contains("/etc/wsl.conf")));
850+ }
851+
852+ #[test]
853+ fn the_image_turns_off_every_way_to_windows() {
854+ let conf = WSL_CONF.replace(' ', "");
855+ for setting in ["[automount]\nenabled=false", "mountFsTab=false", "[interop]\nenabled=false", "appendWindowsPath=false", "default=root"] {
856+ assert!(conf.contains(setting), "{setting}");
857+ }
858+ }
859+
860+ #[test]
861+ fn the_gateway_and_resolvers_read() {
862+ let route = "Iface\tDestination\tGateway \tFlags\tRefCnt\tUse\tMetric\tMask\t\tMTU\tWindow\tIRTT\n\
863+eth0\t00000000\t01E01EAC\t0003\t0\t0\t0\t00000000\t0\t0\t0\n\
864+eth0\t00E01EAC\t00000000\t0001\t0\t0\t0\t00F0FFFF\t0\t0\t0\n";
865+ assert_eq!(gateway(route), Some(Ipv4Addr::new(172, 30, 224, 1)));
866+ assert_eq!(gateway("Iface\tDestination\n"), None);
867+ assert_eq!(nameservers("# x\nnameserver 10.255.255.254\nsearch lan\nnameserver ::1\n"), vec![Ipv4Addr::new(10, 255, 255, 254)]);
868+ assert_eq!(resolv_conf("nameserver 10.255.255.254\n", None), "nameserver 10.255.255.254\n");
869+ assert_eq!(resolv_conf("", Some(Ipv4Addr::new(172, 30, 224, 1))), "nameserver 172.30.224.1\n");
870+ }
871+
872+ #[test]
873+ fn containers_get_dns_and_nothing_else_of_the_computer() {
874+ let gw = Ipv4Addr::new(172, 30, 224, 1);
875+ let rules = firewall_rules(Some(gw), &[Ipv4Addr::new(10, 255, 255, 254), Ipv4Addr::new(1, 1, 1, 1), Ipv4Addr::LOCALHOST]);
876+ let text: Vec<String> = rules.iter().map(|r| r.join(" ")).collect();
877+ assert_eq!(text[0], "-d 10.255.255.254 -p udp --dport 53 -j ACCEPT");
878+ assert!(text.contains(&"-d 1.1.1.1 -p tcp --dport 53 -j ACCEPT".to_owned()));
879+ let first_drop = text.iter().position(|r| r.ends_with("DROP")).unwrap();
880+ assert!(text[..first_drop].iter().all(|r| r.ends_with("ACCEPT")), "accepts come first: {text:?}");
881+ assert!(text.contains(&"-d 172.30.224.1 -j DROP".to_owned()));
882+ assert!(text.contains(&"-d 10.255.255.254 -j DROP".to_owned()));
883+ assert!(!text.contains(&"-d 1.1.1.1 -j DROP".to_owned()), "a public resolver is the internet");
884+ assert!(!text.iter().any(|r| r.contains("127.0.0.1")));
885+ assert_eq!(text.last().unwrap(), "-d 169.254.0.0/16 -j DROP");
886+ }
887+
888+ #[test]
889+ fn the_runner_runs_under_sysbox_with_no_socket_and_no_self_update() {
890+ let settings = Settings { cpus: 4, memory_mib: 8192, runner_image: "g1t.sh/flagon-io/g1t-runner@sha256:ab".into(), ..Settings::default() };
891+ let args = runner_run_args(&settings, false).join(" ");
892+ assert!(args.starts_with("run -d --name g1t-runner --runtime=sysbox-runc"));
893+ assert!(args.contains("--cpus 4 --memory 8192m --memory-swap 8192m"));
894+ assert!(args.contains("-v /var/lib/g1t-sandbox/state:/data"));
895+ assert!(args.contains("G1T_RUNNER_NO_AUTO_UPDATE=1"));
896+ assert!(!args.contains("docker.sock") && !args.contains("--privileged") && !args.contains("--network host"));
897+ assert!(!args.contains("--env-file"));
898+ assert!(args.ends_with("g1t.sh/flagon-io/g1t-runner@sha256:ab"));
899+ assert!(runner_run_args(&settings, true).join(" ").contains("--env-file /var/lib/g1t-sandbox/register.env"));
900+ }
901+
902+ #[test]
903+ fn registration_goes_by_file_and_refuses_line_breaks() {
904+ let mut settings = Settings {
905+ url: Some("https://g1t.sh".into()),
906+ token: Some("g1trt_x".into()),
907+ name: Some("box-linux".into()),
908+ labels: vec!["gpu".into()],
909+ ..Settings::default()
910+ };
911+ let env = register_env(&settings).unwrap();
912+ assert_eq!(env, "G1T_RUNNER_URL=https://g1t.sh\nG1T_RUNNER_TOKEN=g1trt_x\nG1T_RUNNER_NAME=box-linux\nG1T_RUNNER_LABELS=gpu\n");
913+ settings.name = Some("a\nG1T_RUNNER_URL=https://evil".into());
914+ assert!(register_env(&settings).is_err());
915+ assert!(register_env(&Settings::default()).is_err());
916+ }
917+
918+ #[test]
919+ fn a_seed_disk_and_a_console_line_round_trip() {
920+ let settings = Settings { runner_image: "img".into(), cpus: 2, stop: Some("drain".into()), ..Settings::default() };
921+ let bytes = write_seed(&settings, 4096).unwrap();
922+ assert_eq!(bytes.len(), 4096);
923+ assert_eq!(read_seed(&bytes).unwrap(), settings);
924+ assert!(write_seed(&settings, 8).is_err());
925+ let log = format!("[ 1.0] booting\n{STATUS_PREFIX}{{\"state\":\"starting\"}}\nnoise\n{STATUS_PREFIX}{{\"state\":\"running\",\"updated_at\":\"t\"}}\n");
926+ assert_eq!(status_from_console(&log).unwrap().state, "running");
927+ assert!(status_from_console("nothing").is_none());
928+ }
929+
930+ #[test]
931+ fn the_vm_engine_has_sysbox_and_its_own_socket() {
932+ let config = daemon_json();
933+ assert_eq!(config["hosts"][0], "unix:///run/g1t-sandbox/docker.sock");
934+ assert_eq!(config["runtimes"]["sysbox-runc"]["path"], "/usr/local/bin/sysbox-runc");
935+ }
936+}
+195−0
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+
15+use std::collections::BTreeMap;
16+use std::io::{Read, Write};
17+use std::path::{Path, PathBuf};
18+
19+use anyhow::{Context, Result, anyhow, bail};
20+use sha2::{Digest, Sha256};
21+
22+use crate::selfhosted::update::{self, SandboxFile};
23+
24+/// The images a sandbox needs, on disk and checked.
25+#[derive(Clone, Debug, Default)]
26+pub 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.
35+pub 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.
40+pub 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.
56+pub 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`.
64+pub 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.
82+fn 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.
91+fn 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.
118+pub 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`.
141+pub 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)]
151+mod 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+}
+992−0
Loading the diff…
+223−0
Loading the diff…
+454−0
Loading the diff…
+290−0
Loading the diff…
Loading the diff…
Loading the diff…
Loading the diff…
Loading the diff…
Loading the diff…
Loading the diff…
Loading the diff…
Loading the diff…
Loading the diff…
Loading the diff…
Loading the diff…
Loading the diff…
Loading the diff…