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.
29 files+3592−8413 files not counted0/29 viewed
| 1 | 1 | # 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. | |
| 5 | 7 | # | |
| 6 | 8 | # A release is a tag `runner-v<version>`, where the version is the one in | |
| 7 | 9 | # 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. | |
| 10 | 19 | name: Runner release | |
| 11 | 20 | ||
| 12 | 21 | on: | |
| ⋯ | |||
| 27 | 36 | ||
| 28 | 37 | jobs: | |
| 29 | 38 | binaries: | |
| 30 | − | name: Build, sign and publish | |
| 39 | + | name: Build | |
| 31 | 40 | 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 | |
| 37 | 41 | timeout-minutes: 60 | |
| 38 | 42 | steps: | |
| 39 | 43 | - uses: actions/checkout@v5 | |
| ⋯ | |||
| 64 | 68 | G1T_RUNNER_RELEASE_KEY: ${{ vars.RUNNER_RELEASE_PUBLIC_KEY }} | |
| 65 | 69 | RUNNER_AGENT_IMAGE: ${{ vars.RUNNER_AGENT_IMAGE }} | |
| 66 | 70 | 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 | |
| 89 | 71 | - uses: actions/upload-artifact@v4 | |
| 90 | 72 | 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/ | |
| 95 | 75 | ||
| 96 | 76 | image: | |
| 97 | 77 | name: Container image | |
| 98 | 78 | 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 | |
| 102 | 80 | # Production's secrets, without a deployment: a release is not a deploy of | |
| 103 | 81 | # g1t.sh, and would otherwise show on its card as one in progress. | |
| 104 | 82 | environment: | |
| 105 | 83 | name: production | |
| 106 | 84 | deployment: false | |
| 107 | − | timeout-minutes: 30 | |
| 85 | + | timeout-minutes: 45 | |
| 108 | 86 | # Its token pushes the image to g1t's registry. | |
| 109 | 87 | permissions: | |
| 110 | 88 | contents: read | |
| 111 | 89 | packages: write | |
| 90 | + | outputs: | |
| 91 | + | # The pushed image by digest, which the sandbox's manifest names. | |
| 92 | + | runner_image: ${{ steps.push.outputs.runner_image }} | |
| 112 | 93 | steps: | |
| 113 | 94 | - uses: actions/checkout@v5 | |
| 114 | 95 | - uses: actions/download-artifact@v4 | |
| 115 | 96 | with: | |
| 116 | − | name: linux-binaries | |
| 97 | + | name: binaries | |
| 117 | 98 | 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 | |
| 119 | 111 | # To g1t's own registry, where flagon-io keeps it public. | |
| 120 | 112 | env: | |
| 121 | 113 | IMAGE: g1t.sh/flagon-io/g1t-runner | |
| 114 | + | ARCHES: ${{ steps.qemu.outputs.arches }} | |
| 122 | 115 | run: | | |
| 123 | 116 | version="$(sed -n 's/^version = "\(.*\)"/\1/p' crates/runner/Cargo.toml | head -1)" | |
| 124 | 117 | 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 | |
| 126 | 120 | mkdir -p "context-$arch" | |
| 127 | 121 | cp deploy/runner/Dockerfile "context-$arch/" | |
| 128 | 122 | name="g1t-runner-linux-$([ "$arch" = amd64 ] && echo x64 || echo arm64)" | |
| 129 | 123 | 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") | |
| 131 | 127 | 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 | |
| 1670 | 1670 | Op::SetActionsVariable => "Add or change a variable's row, as for secrets.", | |
| 1671 | 1671 | Op::DeleteActionsVariable => "Remove a variable: one row by `id`, or every row of the key.", | |
| 1672 | 1672 | 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." | |
| 1674 | 1674 | } | |
| 1675 | 1675 | Op::ListRunnerGroups => { | |
| 1676 | 1676 | "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." |
| 3485 | 3485 | }, | |
| 3486 | 3486 | "last_seen_at": "2026-10-06T14:05:40.512Z", | |
| 3487 | 3487 | "created_at": "2026-10-02T09:30:00.000Z", | |
| 3488 | − | "created_by": null | |
| 3488 | + | "created_by": null, | |
| 3489 | + | "isolation": "nested-container" | |
| 3489 | 3490 | }, | |
| 3490 | 3491 | { | |
| 3491 | 3492 | "id": "rnr_01kq2m8v4c7f0h3k6n9q2t5w9a", | |
| ⋯ | |||
| 3506 | 3507 | "work": null, | |
| 3507 | 3508 | "last_seen_at": "2026-10-06T14:05:40.512Z", | |
| 3508 | 3509 | "created_at": "2026-10-02T09:30:00.000Z", | |
| 3509 | − | "created_by": null | |
| 3510 | + | "created_by": null, | |
| 3511 | + | "isolation": "host" | |
| 3510 | 3512 | } | |
| 3511 | 3513 | ], | |
| 3512 | 3514 | "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." | |
| ⋯ | |||
| 3543 | 3545 | }, | |
| 3544 | 3546 | "last_seen_at": "2026-10-06T14:05:40.512Z", | |
| 3545 | 3547 | "created_at": "2026-10-02T09:30:00.000Z", | |
| 3546 | − | "created_by": null | |
| 3548 | + | "created_by": null, | |
| 3549 | + | "isolation": "nested-container" | |
| 3547 | 3550 | }, | |
| 3548 | 3551 | { | |
| 3549 | 3552 | "id": "rnr_01kq2m8v4c7f0h3k6n9q2t5w9a", | |
| ⋯ | |||
| 3564 | 3567 | "work": null, | |
| 3565 | 3568 | "last_seen_at": "2026-10-06T14:05:40.512Z", | |
| 3566 | 3569 | "created_at": "2026-10-02T09:30:00.000Z", | |
| 3567 | − | "created_by": null | |
| 3570 | + | "created_by": null, | |
| 3571 | + | "isolation": "host" | |
| 3568 | 3572 | } | |
| 3569 | 3573 | ] | |
| 3570 | 3574 | }, | |
| 307 | 307 | ||
| 308 | 308 | ## What's not there yet | |
| 309 | 309 | ||
| 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). | |
| 313 | 318 | - **Agents on your desktop.** The runner takes workflow jobs, and agent | |
| 314 | 319 | work only if the workspace [sends it to its runners](/guides/self-hosted-runners/#agents-on-your-runners). | |
| 315 | 320 | - **Opening the conversation from a notification.** Clicking one brings |
| 17 | 17 | - **Each job in a fresh sandbox.** Every job gets a container of its own, | |
| 18 | 18 | made for it and removed after it, inside the runner's own container. | |
| 19 | 19 | 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). | |
| 22 | 24 | ||
| 23 | 25 | | Where | Page | Who manages it | | |
| 24 | 26 | | --- | --- | --- | | |
| ⋯ | |||
| 38 | 40 | it run Docker without `--privileged` and without the machine's Docker | |
| 39 | 41 | socket. | |
| 40 | 42 | ||
| 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. | |
| 44 | 48 | 2. Make a registration token: open **Runners** and click **New runner**. | |
| 45 | 49 | 3. Run the runner: | |
| 46 | 50 | ||
| ⋯ | |||
| 92 | 96 | ## Isolation | |
| 93 | 97 | ||
| 94 | 98 | 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*. | |
| 96 | 102 | ||
| 97 | 103 | | Isolation | How you get it | What a job can reach | | |
| 98 | 104 | | --- | --- | --- | | |
| 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. | | |
| 100 | 107 | | `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. | | |
| 101 | 108 | | `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. | | |
| 102 | 109 | ||
| ⋯ | |||
| 158 | 165 | (--no-docker), unsandboxed, so it does not take a public repository's | |
| 159 | 166 | work.* The job fails with that message. Update the runner. | |
| 160 | 167 | ||
| 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 | + | ``` | |
| 163 | 193 | ||
| 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. | |
| 165 | 198 | ||
| 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. | | |
| 168 | 205 | ||
| 169 | − | | | How each job will run | | |
| 206 | + | | Option (`up`) | | | |
| 170 | 207 | | --- | --- | | |
| 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. | |
| 173 | 233 | ||
| 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. | |
| 177 | 246 | ||
| 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. | |
| 181 | 251 | ||
| 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 | + | ||
| 182 | 354 | ## The Runners page | |
| 183 | 355 | ||
| 184 | 356 | A workspace's **Runners** page shows g1t cloud and your own runners side | |
| ⋯ | |||
| 279 | 451 | | `--group` | A workspace runner's [group](#groups). The default group, or the one the token was made for, if left out. | | |
| 280 | 452 | | `--ephemeral` | Take one job, then remove itself. For [autoscaling](#autoscaling). | | |
| 281 | 453 | | `--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. | | |
| 282 | 455 | | `--image` | The image workflow jobs run in when they name no `container:`. `node:24-bookworm` if left out. | | |
| 283 | 456 | | `--agent-image` | The image [agent work](#agents-on-your-runners) runs in. | | |
| 284 | 457 | | `--memory` | Each job container's memory, such as `8g`. Three quarters of the runner's memory if left out. | | |
| ⋯ | |||
| 543 | 716 | download matches the release's SHA-256. `g1t-runner update` does it now; | |
| 544 | 717 | `--no-auto-update` turns it off. Releases are at | |
| 545 | 718 | `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. | |
| 546 | 722 | ||
| 547 | 723 | ## Remove a runner | |
| 548 | 724 | ||
| ⋯ | |||
| 578 | 754 | | *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). | | |
| 579 | 755 | | 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`. | | |
| 580 | 756 | | *… 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. | | |
| 581 | 762 | | 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`. | | |
| 582 | 763 | | *could not run docker* | Install Docker and start it, and give the runner's user access to it. | | |
| 583 | 764 | | Agent work fails at once | Give the runner `--agent-image`. | | |
Binary or large file; its contents are not shown.
| 2 | 2 | import { useEffect, useState } from "react"; | |
| 3 | 3 | import { Form, Link } from "react-router"; | |
| 4 | 4 | ||
| 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"; | |
| 6 | 6 | ||
| 7 | 7 | import { agentRunHref, workflowRunHref } from "../lib/runners"; | |
| 8 | 8 | import type { RunnersAction, RunnersData } from "../lib/runners.server"; | |
| 9 | 9 | import { CopyLine, EmptyState, ErrorText, Field, Input, SubmitButton, TimeAgo } from "./ui"; | |
| 10 | − | import { Badge } from "./ui/badge"; | |
| 10 | + | import { Badge, type BadgeTone } from "./ui/badge"; | |
| 11 | 11 | import { Button } from "./ui/button"; | |
| 12 | 12 | import { Card } from "./ui/card"; | |
| 13 | 13 | import { Checkbox } from "./ui/checkbox"; | |
| ⋯ | |||
| 15 | 15 | import { SelectField } from "./ui/select"; | |
| 16 | 16 | import { Switch } from "./ui/switch"; | |
| 17 | 17 | import { Tabs, TabsList, TabsTrigger } from "./ui/tabs"; | |
| 18 | + | import { Tooltip, TooltipContent, TooltipTrigger } from "./ui/tooltip"; | |
| 18 | 19 | ||
| 19 | 20 | /** Online and waiting for work reads as Idle; online and working, Busy. */ | |
| 20 | 21 | export const STATUS: Record<Runner["status"], { dot: string; text: string; label: string }> = { | |
| ⋯ | |||
| 31 | 32 | ||
| 32 | 33 | export const OS_NAMES: Record<Runner["os"], string> = { linux: "Linux", macos: "macOS", windows: "Windows" }; | |
| 33 | 34 | ||
| 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 | + | ||
| 34 | 75 | function Section({ icon, title, about, children }: { icon: React.ReactNode; title: string; about: React.ReactNode; children: React.ReactNode }) { | |
| 35 | 76 | return ( | |
| 36 | 77 | <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]"> | |
| ⋯ | |||
| 59 | 100 | {/* A desktop app's runner runs while g1t is open there: offline is g1t closed. */} | |
| 60 | 101 | {runner.status === "offline" && runner.labels.includes(DESKTOP_LABEL) ? `g1t is closed on ${runner.name}` : status.label} | |
| 61 | 102 | </span> | |
| 103 | + | <IsolationBadge isolation={runner.isolation} /> | |
| 62 | 104 | {runner.ephemeral && <Badge size="md">ephemeral</Badge>} | |
| 63 | 105 | {runner.group && <span className="text-xs text-faint">in {runner.group}</span>} | |
| 64 | 106 | </div> | |
| 107 | 107 | pub last_seen_at: Option<String>, | |
| 108 | 108 | pub created_at: String, | |
| 109 | 109 | 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>, | |
| 110 | 114 | } | |
| 111 | 115 | ||
| 112 | 116 | /// Which of a workspace's repositories may use its runners. | |
| ⋯ | |||
| 428 | 432 | pub replace: bool, | |
| 429 | 433 | /// How the runner keeps work from its machine: `nested-container` (a | |
| 430 | 434 | /// container per job, inside the runner's own sandboxed container), | |
| 435 | + | /// `vm` (a throwaway VM per job, native to the machine's OS), | |
| 431 | 436 | /// `container` (a container per job, on a Docker Engine it shares with | |
| 432 | 437 | /// its machine) or `host` (directly on the machine). A `host` runner is | |
| 433 | 438 | /// given no public repository's work ([`isolation_allows`]); older | |
| ⋯ | |||
| 437 | 442 | } | |
| 438 | 443 | ||
| 439 | 444 | /// 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"]; | |
| 441 | 446 | ||
| 442 | 447 | #[derive(Clone, Debug, Serialize, Deserialize)] | |
| 443 | 448 | pub struct Registered { | |
| 63 | 63 | /// The image a job's own engine runs: `docker:28-dind` if left out. | |
| 64 | 64 | #[serde(default)] | |
| 65 | 65 | 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, | |
| 66 | 70 | } | |
| 67 | 71 | ||
| 68 | 72 | fn yes() -> bool { | |
| ⋯ | |||
| 173 | 177 | pids_limit: None, | |
| 174 | 178 | job_docker: true, | |
| 175 | 179 | job_docker_image: None, | |
| 180 | + | native: false, | |
| 176 | 181 | }; | |
| 177 | 182 | save(&dir, &config).unwrap(); | |
| 178 | 183 | let back = load(&dir).unwrap(); | |
| 66 | 66 | pub id: String, | |
| 67 | 67 | pub kind: String, | |
| 68 | 68 | pub name: String, | |
| 69 | + | pub repo: String, | |
| 69 | 70 | child: Child, | |
| 70 | 71 | container: Option<String>, | |
| 71 | 72 | /// The job's network and work volume, removed after. | |
| ⋯ | |||
| 74 | 75 | /// The job's own Docker Engine, when it may have one. | |
| 75 | 76 | engine: Option<JobEngine>, | |
| 76 | 77 | 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>, | |
| 77 | 84 | /// Past its time limit and a grace for g1t to say so: stopped here. | |
| 78 | 85 | deadline: Instant, | |
| 79 | 86 | } | |
| 80 | 87 | ||
| 88 | + | use super::native::{self, WsbJob}; | |
| 89 | + | ||
| 81 | 90 | #[cfg(unix)] | |
| 82 | 91 | use super::engine::JobEngine; | |
| 83 | 92 | ||
| ⋯ | |||
| 112 | 121 | ||
| 113 | 122 | /// Its exit code once it has ended. | |
| 114 | 123 | 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 | + | } | |
| 116 | 136 | Ok(Some(status)) => Some(status.code().unwrap_or(1)), | |
| 117 | 137 | Ok(None) => None, | |
| 118 | 138 | Err(_) => Some(1), | |
| 119 | − | } | |
| 139 | + | }; | |
| 140 | + | self.exit | |
| 120 | 141 | } | |
| 121 | 142 | ||
| 122 | 143 | /// Stops it: the container, then the process and everything it started. | |
| ⋯ | |||
| 128 | 149 | if let Some(engine) = &self.engine { | |
| 129 | 150 | quietly(docker().args(["kill", &engine.container])); | |
| 130 | 151 | } | |
| 152 | + | if let Some(job) = &self.native { | |
| 153 | + | job.stop(); | |
| 154 | + | } | |
| 131 | 155 | let pid = self.child.id().to_string(); | |
| 132 | 156 | if cfg!(windows) { | |
| 133 | 157 | quietly(Command::new("taskkill").args(["/T", "/F", "/PID", &pid])); | |
| ⋯ | |||
| 157 | 181 | if let Some(folder) = &self.folder { | |
| 158 | 182 | let _ = std::fs::remove_dir_all(folder); | |
| 159 | 183 | } | |
| 184 | + | if let Some(job) = self.native.take() { | |
| 185 | + | job.clean(); | |
| 186 | + | } | |
| 160 | 187 | } | |
| 161 | 188 | } | |
| 162 | 189 | ||
| ⋯ | |||
| 202 | 229 | /// the runner tells g1t how the process ended. | |
| 203 | 230 | pub fn start(config: &Config, isolation: Isolation, folder: &Path, work: &Assignment) -> Result<Running> { | |
| 204 | 231 | 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), | |
| 208 | 239 | } | |
| 209 | − | in_docker(config, isolation, folder, work, env) | |
| 210 | 240 | } | |
| 211 | 241 | ||
| 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 | + | ||
| 212 | 296 | /// Host mode does not take a public repository's work: anyone can propose | |
| 213 | 297 | /// code to one, and here it would run on the machine itself. | |
| 214 | 298 | fn refuse_public_on_host(work: &Assignment) -> Result<()> { | |
| ⋯ | |||
| 375 | 459 | }; | |
| 376 | 460 | Ok(Running { | |
| 377 | 461 | id: work.id.clone(), | |
| 462 | + | repo: work.repo.clone(), | |
| 378 | 463 | kind: work.kind.clone(), | |
| 379 | 464 | name: work.name.clone(), | |
| 380 | 465 | child, | |
| ⋯ | |||
| 383 | 468 | volume, | |
| 384 | 469 | engine, | |
| 385 | 470 | folder: None, | |
| 471 | + | native: None, | |
| 472 | + | exit: None, | |
| 386 | 473 | deadline: deadline(work), | |
| 387 | 474 | }) | |
| 388 | 475 | } | |
| ⋯ | |||
| 439 | 526 | let child = command.spawn().context("could not start the job's process")?; | |
| 440 | 527 | Ok(Running { | |
| 441 | 528 | id: work.id.clone(), | |
| 529 | + | repo: work.repo.clone(), | |
| 442 | 530 | kind: work.kind.clone(), | |
| 443 | 531 | name: work.name.clone(), | |
| 444 | 532 | child, | |
| ⋯ | |||
| 447 | 535 | volume: None, | |
| 448 | 536 | engine: None, | |
| 449 | 537 | folder, | |
| 538 | + | native: None, | |
| 539 | + | exit: None, | |
| 450 | 540 | deadline: deadline(work), | |
| 451 | 541 | }) | |
| 452 | 542 | } | |
| ⋯ | |||
| 512 | 602 | pids_limit: None, | |
| 513 | 603 | job_docker: true, | |
| 514 | 604 | job_docker_image: None, | |
| 605 | + | native: false, | |
| 515 | 606 | } | |
| 516 | 607 | } | |
| 517 | 608 | ||
| 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(¤t_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 | + | } |
| 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 | + | } |
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…
Loading the diff…
Loading the diff…
Loading the diff…