Merge branch 'actions-docker'
33 files+3370−1060/33 viewed
| 16 | 16 | # with `deployment: false`, so a dry run or a change that deploys nothing | |
| 17 | 17 | # makes no deployment. | |
| 18 | 18 | # | |
| 19 | − | # Needs the repository secret CLOUDFLARE_API_TOKEN (a Production row), the | |
| 20 | − | # variable CLOUDFLARE_ACCOUNT_ID, and api.cloudflare.com among the project's | |
| 21 | − | # workflow-only domains for deploy.yml in production (Settings, Guardrails), | |
| 22 | − | # and registry.cloudflare.com there too, to find the runner's image. See | |
| 23 | − | # docs/DEPLOYING.md. | |
| 19 | + | # Needs the repository secret CLOUDFLARE_API_TOKEN (a Production row, with | |
| 20 | + | # Containers write), the variable CLOUDFLARE_ACCOUNT_ID, and | |
| 21 | + | # api.cloudflare.com among the project's workflow-only domains for | |
| 22 | + | # deploy.yml in production (Settings, Guardrails), and | |
| 23 | + | # registry.cloudflare.com there too, to find, pull and push the runner's | |
| 24 | + | # image. A job that must build that image (the `runner-image` group) does | |
| 25 | + | # so with its own Docker Engine, on a larger machine. See docs/DEPLOYING.md. | |
| 24 | 26 | name: Deploy | |
| 25 | 27 | ||
| 26 | 28 | on: | |
| ⋯ | |||
| 126 | 128 | # Runs when nothing before it failed: a migrate job skipped for having | |
| 127 | 129 | # nothing to apply is not a failure. | |
| 128 | 130 | if: ${{ !failure() && !cancelled() && needs.plan.outputs.has_core == 'true' && inputs.dry_run != true }} | |
| 129 | − | # Rust builds get 4 vCPUs; everything else the standard machine. | |
| 130 | − | runs-on: ${{ matrix.rust && 'g1t-4core' || 'ubuntu-latest' }} | |
| 131 | + | # Rust builds and the runner's image get 4 vCPUs; everything else the | |
| 132 | + | # standard machine. | |
| 133 | + | runs-on: ${{ (matrix.rust || matrix.image) && 'g1t-4core' || 'ubuntu-latest' }} | |
| 131 | 134 | environment: | |
| 132 | 135 | name: production | |
| 133 | 136 | url: https://g1t.sh | |
| ⋯ | |||
| 185 | 188 | restore-keys: | | |
| 186 | 189 | cargo-target-${{ runner.os }}-${{ matrix.group }}- | |
| 187 | 190 | cargo-target-${{ runner.os }}- | |
| 191 | + | # The runner's image: its binary, built natively for musl (the base | |
| 192 | + | # has musl-gcc; the target is added here), with its Cargo target kept | |
| 193 | + | # between runs. The image itself is built and pushed with the job's | |
| 194 | + | # own Docker Engine (scripts/deploy/image.mjs). | |
| 195 | + | - name: Rust for the runner | |
| 196 | + | if: ${{ matrix.image }} | |
| 197 | + | run: rustup target add x86_64-unknown-linux-musl | |
| 198 | + | - name: Cache the runner's build | |
| 199 | + | if: ${{ matrix.image }} | |
| 200 | + | uses: actions/cache@v4 | |
| 201 | + | with: | |
| 202 | + | path: | | |
| 203 | + | ~/.cargo/registry/cache | |
| 204 | + | target/x86_64-unknown-linux-musl/release | |
| 205 | + | !target/**/incremental | |
| 206 | + | key: runner-musl-${{ runner.os }}-${{ hashFiles('Cargo.lock', 'services/runner/base.json') }} | |
| 207 | + | restore-keys: runner-musl-${{ runner.os }}- | |
| 188 | 208 | - name: Install | |
| 189 | 209 | run: node scripts/deploy.mjs install --only "${{ matrix.units }}" | |
| 190 | 210 | - name: Deploy ${{ matrix.units }} | |
| ⋯ | |||
| 196 | 216 | name: edge (${{ matrix.group }}) | |
| 197 | 217 | needs: [plan, migrate, core] | |
| 198 | 218 | if: ${{ !failure() && !cancelled() && needs.plan.outputs.has_edge == 'true' && inputs.dry_run != true }} | |
| 199 | − | runs-on: ${{ matrix.rust && 'g1t-4core' || 'ubuntu-latest' }} | |
| 219 | + | runs-on: ${{ (matrix.rust || matrix.image) && 'g1t-4core' || 'ubuntu-latest' }} | |
| 200 | 220 | environment: | |
| 201 | 221 | name: production | |
| 202 | 222 | url: https://g1t.sh | |
| ⋯ | |||
| 211 | 231 | name: front (${{ matrix.group }}) | |
| 212 | 232 | needs: [plan, migrate, core, edge] | |
| 213 | 233 | if: ${{ !failure() && !cancelled() && needs.plan.outputs.has_front == 'true' && inputs.dry_run != true }} | |
| 214 | − | runs-on: ${{ matrix.rust && 'g1t-4core' || 'ubuntu-latest' }} | |
| 234 | + | runs-on: ${{ (matrix.rust || matrix.image) && 'g1t-4core' || 'ubuntu-latest' }} | |
| 215 | 235 | environment: | |
| 216 | 236 | name: production | |
| 217 | 237 | url: https://g1t.sh | |
| 7 | 7 | # Weekly, for security updates and new stable toolchains; when the base's | |
| 8 | 8 | # folder changes on main (a change that forgot to rebuild it); and by hand. | |
| 9 | 9 | # | |
| 10 | − | # It needs Docker, which g1t's own sandboxes do not have, so it runs on a | |
| 11 | − | # self-hosted runner with the `docker` label. Until one is registered, run | |
| 12 | − | # the same thing by hand on a machine with Docker: | |
| 10 | + | # It runs on a self-hosted runner with the `docker` label. g1t's own | |
| 11 | + | # machines have Docker now, but this build's own downloads (Docker's apt | |
| 12 | + | # repository over HTTPS) do not yet trust a guarded job's egress | |
| 13 | + | # certificate, so it stays where the network is open. Until a runner is | |
| 14 | + | # registered, run the same thing by hand on a machine with Docker: | |
| 13 | 15 | # | |
| 14 | 16 | # node scripts/deploy.mjs build-base | |
| 15 | 17 | # |
| 74 | 74 | image: | |
| 75 | 75 | name: Container image | |
| 76 | 76 | needs: binaries | |
| 77 | − | # Needs Docker, which g1t's own sandboxes do not have. | |
| 77 | + | # Builds for arm64 as well as amd64, which needs QEMU's emulators | |
| 78 | + | # registered on the machine: a self-hosted runner's, for now. | |
| 78 | 79 | runs-on: [self-hosted, docker] | |
| 79 | 80 | environment: production | |
| 80 | 81 | timeout-minutes: 30 |
| 184 | 184 | ||
| 185 | 185 | ## Actions and runners | |
| 186 | 186 | ||
| 187 | − | ### No Docker in g1t's sandboxes | |
| 187 | + | ### Docker shares the job's network | |
| 188 | 188 | ||
| 189 | − | On g1t's own machines, a job's `container:` image is not used (its steps | |
| 190 | − | run on g1t's runner image instead), and a step cannot run `docker build`. | |
| 191 | − | Docker container actions (`uses: docker://…`, or an action that runs as a | |
| 192 | − | Docker image) and `services:` containers, such as a database, do not run | |
| 193 | − | on any runner yet, self-hosted ones included. | |
| 189 | + | A job's Docker Engine runs its containers on the job's own network, not on | |
| 190 | + | networks of their own. A service is reached at `localhost` and by its | |
| 191 | + | name, as on GitHub, but two containers cannot listen on the same port, and | |
| 192 | + | `docker network create` gives no separation between containers. | |
| 194 | 193 | ||
| 195 | − | - **Why.** Jobs run in Cloudflare Containers, which offer no supported way | |
| 196 | − | to run Docker or another image builder inside a container. | |
| 197 | − | - **Instead.** Run `container:` jobs and image builds on a | |
| 198 | − | [self-hosted runner](/guides/self-hosted-runners/). A runner in Docker | |
| 199 | − | mode runs each job in its `container:` image. To build images, register a | |
| 200 | − | runner with `--no-docker` on a machine that has Docker, and its steps can | |
| 201 | − | call `docker build` and `docker push`. Self-hosted time costs nothing. For | |
| 202 | − | a database, start it from a `run:` step on a self-hosted runner. | |
| 203 | − | - **Status.** Docker container actions and `services:` are planned. Image | |
| 204 | − | builds on g1t's machines depend on Cloudflare. | |
| 194 | + | - **Why.** Jobs run in Cloudflare Containers, which let a container run | |
| 195 | + | Docker but not route a container network of its own out, or change its | |
| 196 | + | packet filter. Sharing the job's network is also what keeps the job's | |
| 197 | + | guardrails on every container. | |
| 198 | + | - **Instead.** Give containers that would clash different ports. | |
| 199 | + | - **Status.** Not scheduled. | |
| 200 | + | ||
| 201 | + | ### No `type=gha` build cache | |
| 202 | + | ||
| 203 | + | Buildx's GitHub Actions cache backend (`cache-to: type=gha`) is skipped on | |
| 204 | + | g1t, and the build runs without a cache. | |
| 205 | + | ||
| 206 | + | - **Why.** It talks to GitHub's cache service, which g1t's cache does not | |
| 207 | + | speak yet. | |
| 208 | + | - **Instead.** Use a registry cache in g1t's container registry | |
| 209 | + | (`type=registry`), or `type=local` with `actions/cache`. See | |
| 210 | + | [caching image builds](/guides/actions/#caching-image-builds). | |
| 211 | + | - **Status.** Planned. | |
| 212 | + | ||
| 213 | + | ### No multi-platform image builds on g1t's machines | |
| 214 | + | ||
| 215 | + | Building an image for another platform, such as `linux/arm64`, needs QEMU's | |
| 216 | + | emulators, which g1t's machines do not have set up. | |
| 217 | + | ||
| 218 | + | - **Instead.** Build other platforms on a | |
| 219 | + | [self-hosted runner](/guides/self-hosted-runners/) of that architecture, | |
| 220 | + | or one with QEMU set up. | |
| 221 | + | - **Status.** Planned. | |
| 205 | 222 | ||
| 206 | 223 | ### Linux only on g1t's machines | |
| 207 | 224 |
| 45 | 45 | | `environment:` on a job | The job reads each key's row for that environment, as GitHub's environment secrets work, and the run records a [deployment](/guides/deployments-api/#deployments-from-g1t-actions) to it. `url` gives the deployment its address; `deployment: false` reads the environment's values without making one. | | |
| 46 | 46 | | `actions/upload-artifact`, `actions/download-artifact` | Kept with the run for 14 days, passed between its jobs, and downloadable from the run's page. Up to 60 MB each. | | |
| 47 | 47 | | `actions/cache`, `actions/cache/restore`, `actions/cache/save` | Kept per repository, found by `key` or the newest under a `restore-keys` prefix. `path` takes globs and `!` exclusions. Up to 2 GiB each; see [the cache](#the-cache). | | |
| 48 | + | | `docker build`, `push`, `run`, `login`, `compose`, Buildx | The same, with a Docker Engine of the job's own. See [Docker](#docker). | | |
| 49 | + | | `services:` | The same: each service starts before the steps, health checks are waited for, and it is reached at `localhost` on its port and by its name. | | |
| 50 | + | | `container:` | The same: every step runs inside the image. | | |
| 51 | + | | `uses: docker://image`, Docker actions (`runs.using: docker`) | The same: built from the action's Dockerfile or pulled, and run with GitHub's `/github/workspace` layout. | | |
| 52 | + | | `docker/setup-buildx-action`, `docker/build-push-action`, `docker/login-action` | The same. `setup-buildx-action` picks the job's own Engine as the builder. | | |
| 48 | 53 | ||
| 49 | 54 | The **Actions** page of a workflow says, under *How this runs on g1t*, | |
| 50 | 55 | anything in it that runs differently. | |
| ⋯ | |||
| 55 | 60 | job with `runs-on: windows-latest` or `macos-latest` fails, and says so. | |
| 56 | 61 | [Self-hosted runners](/guides/self-hosted-runners/) of any OS run them: | |
| 57 | 62 | `runs-on: [self-hosted, windows]`. | |
| 58 | − | - **Docker** container actions, `services:` containers and `container:` on | |
| 59 | − | g1t's machines. A job's `container:` is ignored there and its steps run on | |
| 60 | − | g1t's image; a [self-hosted runner](/guides/self-hosted-runners/#what-a-job-gets) | |
| 61 | − | that runs jobs in Docker uses it. | |
| 63 | + | - **Docker's `type=gha` build cache.** Buildx skips it on g1t, and the | |
| 64 | + | build runs without a cache. Use a registry cache instead; see | |
| 65 | + | [caching image builds](#caching-image-builds). | |
| 62 | 66 | - **Reusable workflows from other repositories** (`uses: owner/repo/.github/workflows/x.yml@v1`); ones in the same repository work. | |
| 63 | 67 | - **The toolkit's own cache.** Actions that cache through GitHub's service | |
| 64 | 68 | themselves, such as `actions/setup-node` with `cache: npm`, run without | |
| ⋯ | |||
| 76 | 80 | ## The runner | |
| 77 | 81 | ||
| 78 | 82 | Jobs run in a fresh sandbox each: Debian with Node 24, Python 3, Go, Rust, | |
| 79 | − | `build-essential`, `git`, `curl`, `jq` and passwordless `sudo`, in GitHub's | |
| 80 | − | layout (`/home/runner/work`, `RUNNER_TEMP`, `RUNNER_TOOL_CACHE`). | |
| 83 | + | `build-essential`, `git`, `curl`, `jq`, Docker (with Buildx and Compose) | |
| 84 | + | and passwordless `sudo`, in GitHub's layout (`/home/runner/work`, | |
| 85 | + | `RUNNER_TEMP`, `RUNNER_TOOL_CACHE`). | |
| 81 | 86 | `runner.os` is `Linux`. `ubuntu-latest`, `ubuntu-24.04` and other Linux | |
| 82 | 87 | labels all run here. A job whose `runs-on` names `self-hosted` waits for one | |
| 83 | 88 | of your [self-hosted runners](/guides/self-hosted-runners/) instead. Setup actions such as | |
| ⋯ | |||
| 120 | 125 | What builds need is the package registries (npm, PyPI, crates.io, the Go | |
| 121 | 126 | proxy, RubyGems, Packagist, NuGet, Maven and Gradle, Debian's mirrors), | |
| 122 | 127 | GitHub, where `uses:` actions and the setup actions' downloads come from, | |
| 123 | − | and the toolchains' download sites (`nodejs.org`, `go.dev`, | |
| 124 | − | `static.rust-lang.org`). A request anywhere else gets `403` with | |
| 128 | + | the toolchains' download sites (`nodejs.org`, `go.dev`, | |
| 129 | + | `static.rust-lang.org`), and the public container registries (Docker Hub, | |
| 130 | + | GitHub's, Quay, and `mirror.gcr.io`, the mirror of Docker Hub that a job's | |
| 131 | + | Engine asks first). A request anywhere else gets `403` with | |
| 125 | 132 | the reason. To reach another host, someone with the Maintain [role](/guides/access-and-roles/) or | |
| 126 | 133 | higher adds it to the project's | |
| 127 | 134 | allowed domains under **Settings → Guardrails**; a project whose guardrails | |
| ⋯ | |||
| 139 | 146 | looks like it is mining is stopped. See | |
| 140 | 147 | [abuse and mining](/guides/guardrails/#abuse-and-mining). | |
| 141 | 148 | ||
| 149 | + | ## Docker | |
| 150 | + | ||
| 151 | + | Each job on g1t's machines has a Docker Engine of its own, inside the | |
| 152 | + | job's sandbox. Nothing runs until the job uses it: the first `docker` | |
| 153 | + | command, or a job's `services:` or `container:`, starts it, in a second | |
| 154 | + | or two, and the log says so. It ends with the job, with every image, | |
| 155 | + | container and build cache in it. No other job, repository or workspace | |
| 156 | + | ever shares it. | |
| 157 | + | ||
| 158 | + | ```yaml | |
| 159 | + | jobs: | |
| 160 | + | test: | |
| 161 | + | runs-on: ubuntu-latest | |
| 162 | + | services: | |
| 163 | + | postgres: | |
| 164 | + | image: postgres:17 | |
| 165 | + | env: | |
| 166 | + | POSTGRES_PASSWORD: ${{ secrets.DB_PASSWORD }} | |
| 167 | + | ports: ["5432:5432"] | |
| 168 | + | options: >- | |
| 169 | + | --health-cmd pg_isready --health-interval 5s --health-retries 10 | |
| 170 | + | steps: | |
| 171 | + | - uses: actions/checkout@v5 | |
| 172 | + | - run: docker compose up -d --wait | |
| 173 | + | - run: npm test | |
| 174 | + | env: | |
| 175 | + | DATABASE_URL: postgres://postgres:${{ secrets.DB_PASSWORD }}@localhost:5432/postgres | |
| 176 | + | ``` | |
| 177 | + | ||
| 178 | + | ### What works | |
| 179 | + | ||
| 180 | + | | | On g1t's machines | | |
| 181 | + | | --- | --- | | |
| 182 | + | | `docker build`, `buildx build`, `run`, `exec`, `pull`, `push`, `login`, `compose` | Work as they do on GitHub's runners. The Engine, Buildx and Compose are current releases. | | |
| 183 | + | | `services:` | Pulled and started before the first step, with `env`, `ports`, `volumes`, `options` and `credentials`. Services with a health check are waited for; one that turns unhealthy fails the job with its log. Each service's log is printed when the job ends. `job.services.<id>.id`, `.network` and `.ports` are set. | | |
| 184 | + | | `container:` | Every `run` step and JavaScript action runs inside the image, with its `env`, `options`, `volumes` and `credentials`. The workspace, `RUNNER_TEMP` and the tool cache are mounted at the same paths as on g1t's runner. | | |
| 185 | + | | `uses: docker://image` | Pulled and run, with `with.args` and `with.entrypoint`. | | |
| 186 | + | | Docker actions | Built from the action's Dockerfile (or pulled, for `image: docker://…`), and run with its `args`, `env` and `entrypoint`, its inputs as `INPUT_*` variables, and `pre-entrypoint` and `post-entrypoint`. | | |
| 187 | + | | `docker/setup-buildx-action` | Selects the job's own Engine as the builder (BuildKit). Its `name`, `driver`, `platforms` and `nodes` outputs are set. `driver`, `driver-opts` and `buildkitd-*` are not used, and the log says so. | | |
| 188 | + | | `docker/build-push-action` | Works, with `push`, `load`, `tags`, `labels`, `build-args`, `secrets`, `target`, `provenance` and `sbom`. | | |
| 189 | + | | `docker/login-action` | Works, for g1t's registry, Docker Hub, GitHub's registry, Cloudflare's (`registry.cloudflare.com`) and any registry the job can reach. | | |
| 190 | + | ||
| 191 | + | ### Services and the network | |
| 192 | + | ||
| 193 | + | Every container a job starts shares the job's own network, the one its | |
| 194 | + | [guardrails](/guides/guardrails/) apply to. So: | |
| 195 | + | ||
| 196 | + | - **A service is at `localhost`** on its port, from steps and from other | |
| 197 | + | containers. `ports: ["5432:5432"]` and `ports: ["5432"]` both mean | |
| 198 | + | `localhost:5432`. | |
| 199 | + | - **A port mapped to another number** (`ports: ["6543:5432"]`, or | |
| 200 | + | `docker run -p 8080:80`) is forwarded: `localhost:6543` reaches the | |
| 201 | + | service's 5432. `job.services.<id>.ports` says which port to use, and | |
| 202 | + | `docker inspect` and `docker port` report it. | |
| 203 | + | - **A service is also reached by its name**, as it is from a job | |
| 204 | + | container on GitHub: `postgres:5432` works from steps, from the job's | |
| 205 | + | container and from any container started later. So do the names of | |
| 206 | + | containers and Compose services, and their network aliases. | |
| 207 | + | - **Two containers cannot listen on the same port.** A job with a | |
| 208 | + | `redis` service and a Compose file that starts another Redis on 6379 | |
| 209 | + | gets an error from the second; give one of them another port. | |
| 210 | + | ||
| 211 | + | A container that asks for `--network none` gets none, and | |
| 212 | + | `--network container:<name>` shares that container's. | |
| 213 | + | ||
| 214 | + | ### Job containers | |
| 215 | + | ||
| 216 | + | With `container:`, the steps run inside the image as its default user, | |
| 217 | + | usually `root`. A few things differ from GitHub's runner: | |
| 218 | + | ||
| 219 | + | - The workspace is at the same path as on g1t's runner | |
| 220 | + | (`/home/runner/work/…`), not `/__w`. `github.workspace` is correct | |
| 221 | + | either way. | |
| 222 | + | - JavaScript actions run inside the container with g1t's Node 24, which | |
| 223 | + | needs an image with glibc and `libstdc++` (Debian, Ubuntu and most | |
| 224 | + | language images have both). In an image without them, such as Alpine, | |
| 225 | + | they run beside the container, on g1t's runner, with the same files, | |
| 226 | + | and the log says so. | |
| 227 | + | - `actions/checkout`, `actions/cache` and the artifact actions run on | |
| 228 | + | g1t's runner, with the same files. | |
| 229 | + | ||
| 230 | + | ### Building and pushing images | |
| 231 | + | ||
| 232 | + | On g1t's machines, a job is signed in to g1t's container registry from | |
| 233 | + | the start, with its own `G1T_TOKEN`, so it can push to and pull from its | |
| 234 | + | workspace's images without a login step. A run that gets no secrets is | |
| 235 | + | not signed in. See [container registry](/guides/containers/#in-workflows). | |
| 236 | + | ||
| 237 | + | ```yaml | |
| 238 | + | jobs: | |
| 239 | + | image: | |
| 240 | + | runs-on: g1t-4core | |
| 241 | + | steps: | |
| 242 | + | - uses: actions/checkout@v5 | |
| 243 | + | - uses: docker/setup-buildx-action@v3 | |
| 244 | + | - uses: docker/build-push-action@v6 | |
| 245 | + | with: | |
| 246 | + | push: true | |
| 247 | + | tags: g1t.sh/${{ github.repository }}:${{ github.sha }} | |
| 248 | + | cache-from: type=registry,ref=g1t.sh/${{ github.repository }}:buildcache | |
| 249 | + | cache-to: type=registry,ref=g1t.sh/${{ github.repository }}:buildcache,mode=max | |
| 250 | + | ``` | |
| 251 | + | ||
| 252 | + | For other registries, sign in with `docker/login-action` or | |
| 253 | + | `docker login`, as on GitHub. Docker Hub's images are pulled through its | |
| 254 | + | public mirror first, so jobs are rarely held up by Docker Hub's limits on | |
| 255 | + | anonymous pulls. | |
| 256 | + | ||
| 257 | + | #### Caching image builds | |
| 258 | + | ||
| 259 | + | The Engine starts empty in every job, so a build's layers are rebuilt | |
| 260 | + | unless the job brings a cache: | |
| 261 | + | ||
| 262 | + | - **A registry cache** (`cache-to: type=registry,ref=…,mode=max`), in g1t's | |
| 263 | + | registry or any other, is the simplest and is shared by every branch. | |
| 264 | + | - **A local cache** (`cache-to: type=local,dest=/tmp/buildx-cache`) saved | |
| 265 | + | and restored with `actions/cache`, within [the cache's limits](#the-cache). | |
| 266 | + | - **`type=gha`** is not used on g1t yet: Buildx skips it, and the build | |
| 267 | + | runs without a cache. | |
| 268 | + | ||
| 269 | + | ### Limits | |
| 270 | + | ||
| 271 | + | - **Machine.** Containers share the job's machine: its vCPUs, memory and | |
| 272 | + | disk ([machine sizes](#machine-sizes)). Image builds and databases want | |
| 273 | + | `g1t-2core` or `g1t-4core`. `--cpus` and `--memory` limit a container | |
| 274 | + | within that. | |
| 275 | + | - **Disk.** Images take room on the job's disk. On a machine whose disk | |
| 276 | + | cannot hold layered images, the Engine stores plain copies, which take | |
| 277 | + | more room; the log says when it does. | |
| 278 | + | - **Linux, amd64.** Images for other platforms need QEMU's emulators, | |
| 279 | + | which g1t's machines do not have set up; `docker/setup-qemu-action` is | |
| 280 | + | not supported there yet. | |
| 281 | + | - **Privileged containers** (`--privileged`) run, with no more reach than | |
| 282 | + | the job itself has: the job's sandbox is the boundary. | |
| 283 | + | ||
| 284 | + | ### How Docker is kept safe | |
| 285 | + | ||
| 286 | + | - **One Engine per job.** It runs inside the job's own sandbox, a virtual | |
| 287 | + | machine of its own, and is gone with it. No Docker socket of g1t's, or of | |
| 288 | + | any machine, is shared with a job. | |
| 289 | + | - **The job's guardrails hold.** Containers use the job's network, so a | |
| 290 | + | container, a build step or an image pull reaches only what the job may | |
| 291 | + | reach. A host off the list gets `403` with the reason, as any step does. | |
| 292 | + | - **HTTPS keeps working.** In a job whose network is restricted, every | |
| 293 | + | container and build step is given the certificate the job's HTTPS is | |
| 294 | + | checked with, in `/dev/g1t-egress`, and `SSL_CERT_FILE`, | |
| 295 | + | `NODE_EXTRA_CA_CERTS`, `REQUESTS_CA_BUNDLE`, `CURL_CA_BUNDLE`, `PIP_CERT`, | |
| 296 | + | `GIT_SSL_CAINFO` and `CARGO_HTTP_CAINFO` pointing at it, unless the | |
| 297 | + | container sets them itself. None of it is written into an image's layers. | |
| 298 | + | Tools that keep their own list of certificates, such as Java's, need it | |
| 299 | + | added in the build that uses them. | |
| 300 | + | - **Short-lived credentials.** The registry sign-in uses the run's own | |
| 301 | + | token, which ends with the run; `credentials:` for a service or a job | |
| 302 | + | container are used for that pull only. | |
| 303 | + | - **No miners.** A container whose image or command names a miner is not | |
| 304 | + | created, as a step's script is not run. | |
| 305 | + | ||
| 142 | 306 | ## The cache | |
| 143 | 307 | ||
| 144 | 308 | `actions/cache` keeps what a job saves for the repository's later jobs: | |
| 72 | 72 | docker push g1t.sh/${{ github.repository }}:${{ github.sha }} | |
| 73 | 73 | ``` | |
| 74 | 74 | ||
| 75 | − | Runs that get no secrets (a pull request from someone without Write) get an | |
| 76 | − | empty token, and cannot push. See | |
| 75 | + | On g1t's own machines a job is already signed in to g1t.sh with that | |
| 76 | + | token when it starts, so the sign-in step can be left out there; it does | |
| 77 | + | no harm. Runs that get no secrets (a pull request from someone without | |
| 78 | + | Write) get an empty token, are not signed in, and cannot push. See | |
| 77 | 79 | [secrets and variables](/guides/actions/#secrets-and-variables). | |
| 78 | 80 | ||
| 79 | 81 | ## The 100 MB limit |
| 103 | 103 | - every package registry above, whatever the project turned on for its | |
| 104 | 104 | agents, and RubyGems, Packagist, NuGet, Maven Central, Gradle and | |
| 105 | 105 | Debian's mirrors; | |
| 106 | + | - container registries, for a job's own Docker Engine: Docker Hub | |
| 107 | + | (`registry-1.docker.io`, `auth.docker.io` and the CDNs its layers come | |
| 108 | + | from), `mirror.gcr.io`, Quay (`quay.io` and its CDNs), and Docker's | |
| 109 | + | package repository, `download.docker.com`; | |
| 106 | 110 | - for deploy builds, Cloudflare's API, which the build uploads its app to. | |
| 107 | 111 | ||
| 108 | 112 | The repository itself is cloned from g1t, which is always reachable. A | |
| 109 | 113 | project whose guardrails set **Only allowed hosts** to Open runs its jobs | |
| 110 | 114 | and builds with an open network too. | |
| 111 | 115 | ||
| 116 | + | The containers a job starts with [Docker](/guides/actions/#docker), its | |
| 117 | + | services and its build steps share the job's network, so this list is | |
| 118 | + | theirs too: an image from another registry, or a build step that | |
| 119 | + | downloads from another host, needs that host allowed, as a step would. | |
| 120 | + | ||
| 112 | 121 | ### Workflow-only domains | |
| 113 | 122 | ||
| 114 | 123 | Some hosts only a workflow should reach: the API a deploy uploads to, a | |
| ⋯ | |||
| 279 | 288 | - **No pool to reach.** No mining pool is on any allowed list, so a | |
| 280 | 289 | restricted sandbox's miner has nowhere to send its work. | |
| 281 | 290 | - **Miners by name.** A shell command an agent runs, a check, a build | |
| 282 | − | command or a workflow step that names a known miner (`xmrig`, | |
| 283 | − | `cpuminer`, `t-rex` and others), a pool address (`stratum+tcp://`) or a | |
| 284 | − | miner's flags (`--donate-level`, `--algo=rx/0`) is refused, whatever the | |
| 285 | − | project's rules. A running process whose command line names one stops | |
| 291 | + | command, a workflow step or a container a job starts whose image or | |
| 292 | + | command names a known miner (`xmrig`, `cpuminer`, `t-rex` and others), a | |
| 293 | + | pool address (`stratum+tcp://`) or a miner's flags (`--donate-level`, | |
| 294 | + | `--algo=rx/0`) is refused, whatever the project's rules. A running process whose command line names one stops | |
| 286 | 295 | the sandbox at once. | |
| 287 | 296 | - **The CPU signature.** Every sandbox samples itself every 30 seconds: | |
| 288 | 297 | CPU use, file and disk I/O, network bytes, new processes, and whether the | |
| 139 | 139 | ||
| 140 | 140 | - **In Docker** (the default), each job gets a fresh container from its | |
| 141 | 141 | `container:` image or the runner's `--image`, removed when it ends. The | |
| 142 | − | runner needs Docker, and its user needs to be allowed to use it. | |
| 142 | + | runner needs Docker, and its user needs to be allowed to use it. Its | |
| 143 | + | `services:` are not started, since the job's container has no Docker of | |
| 144 | + | its own; the log says so. | |
| 143 | 145 | - **With `--no-docker`**, each job gets a fresh folder under the work | |
| 144 | 146 | folder, removed when it ends, and runs with whatever the machine has | |
| 145 | 147 | installed. A step's default shell is `bash` on Linux and macOS and | |
| 146 | 148 | PowerShell on Windows; `shell: pwsh`, `powershell`, `cmd`, `bash` and | |
| 147 | − | `python` work where installed. | |
| 149 | + | `python` work where installed. On a machine with Docker, the job's | |
| 150 | + | `services:`, `container:`, `docker://` steps and Docker actions use | |
| 151 | + | the machine's Docker, as on GitHub's runners. | |
| 148 | 152 | ||
| 149 | 153 | A self-hosted job stops at 60 minutes unless its `timeout-minutes` says | |
| 150 | 154 | more, up to 24 hours (1440). Jobs on g1t's own machines stop at 60 minutes. |
| 408 | 408 | } | |
| 409 | 409 | } | |
| 410 | 410 | if spec.contains_key("services") { | |
| 411 | − | note(Severity::Unsupported, Some(id), "`services` containers (such as a database) are not started on g1t yet.".to_owned()); | |
| 411 | + | note( | |
| 412 | + | Severity::Info, | |
| 413 | + | Some(id), | |
| 414 | + | "`services`: each service runs in Docker beside the steps and is reached at `localhost:<port>`. On g1t's machines it is the job's own Docker Engine, the service is also reached by its name, and two services cannot listen on the same port.".to_owned(), | |
| 415 | + | ); | |
| 412 | 416 | } | |
| 413 | 417 | if spec.contains_key("container") { | |
| 414 | − | note(Severity::Warning, Some(id), "`container`: steps run on g1t's runner image instead of that container.".to_owned()); | |
| 418 | + | note( | |
| 419 | + | Severity::Info, | |
| 420 | + | Some(id), | |
| 421 | + | "`container`: the steps run inside that image, in Docker (on g1t's machines, the job's own Engine), with the workspace at the same path as on the runner (`/home/runner/work`), not `/__w`.".to_owned(), | |
| 422 | + | ); | |
| 415 | 423 | } | |
| 416 | 424 | if spec.contains_key("environment") { | |
| 417 | 425 | note(Severity::Info, Some(id), "`environment`: the job gets the values its secrets and variables give this environment; protection rules (approvals, wait timers, branch limits) are not enforced on g1t yet. Unless it says `deployment: false`, the run records a deployment to it.".to_owned()); | |
| ⋯ | |||
| 472 | 480 | /// `caches`: the step sets a `cache` input. | |
| 473 | 481 | fn action_note(uses: &str, caches: bool) -> Option<(Severity, String)> { | |
| 474 | 482 | if uses.starts_with("docker://") { | |
| 475 | − | return Some((Severity::Unsupported, format!("`{uses}`: Docker actions do not run on g1t yet."))); | |
| 483 | + | return Some((Severity::Info, format!("`{uses}` runs in Docker (on g1t's machines, the job's own Engine)."))); | |
| 476 | 484 | } | |
| 477 | 485 | let name = uses.split('@').next().unwrap_or(uses).to_ascii_lowercase(); | |
| 478 | 486 | match name.as_str() { | |
| ⋯ | |||
| 586 | 594 | workflow.notes.iter().filter(|n| n.severity == Severity::Unsupported).map(|n| n.message.as_str()).collect(); | |
| 587 | 595 | assert!(unsupported.iter().any(|m| m.contains("`release`"))); | |
| 588 | 596 | assert!(unsupported.iter().any(|m| m.contains("windows-latest"))); | |
| 589 | − | assert!(unsupported.iter().any(|m| m.contains("services"))); | |
| 590 | − | assert!(unsupported.iter().any(|m| m.contains("docker://alpine"))); | |
| 597 | + | assert!(!unsupported.iter().any(|m| m.contains("services"))); | |
| 598 | + | assert!(!unsupported.iter().any(|m| m.contains("docker://alpine"))); | |
| 599 | + | assert!(workflow.notes.iter().any(|n| n.severity == Severity::Info && n.message.contains("own Docker Engine") && n.message.contains("localhost"))); | |
| 600 | + | assert!(workflow.notes.iter().any(|n| n.severity == Severity::Info && n.message.starts_with("`docker://alpine`"))); | |
| 591 | 601 | assert!(unsupported.iter().any(|m| m.contains("pwsh"))); | |
| 592 | 602 | assert!(workflow.notes.iter().any(|n| n.severity == Severity::Info && n.message.contains("actions/cache"))); | |
| 593 | 603 | assert!(workflow.notes.iter().any(|n| n.severity == Severity::Warning && n.message.contains("actions/setup-node"))); | |
| 174 | 174 | let deploy = read("deploy.yml"); | |
| 175 | 175 | for stage in ["core", "edge", "front"] { | |
| 176 | 176 | let job = deploy.jobs.iter().find(|j| j.id == stage).unwrap(); | |
| 177 | − | let on = |rust: bool| { | |
| 177 | + | let on = |rust: bool, image: bool| { | |
| 178 | 178 | let mut contexts = Map::new(); | |
| 179 | − | contexts.insert("matrix".into(), json!({ "group": "g", "units": "u", "rust": rust })); | |
| 179 | + | contexts.insert("matrix".into(), json!({ "group": "g", "units": "u", "rust": rust, "image": image })); | |
| 180 | 180 | let scope = Scope { contexts: &contexts, status: Status::Success, hash_files: None }; | |
| 181 | 181 | expr::interpolate_value(&job.runs_on, &scope).unwrap() | |
| 182 | 182 | }; | |
| 183 | − | assert_eq!(on(true), json!("g1t-4core"), "{stage}"); | |
| 184 | − | assert_eq!(on(false), json!("ubuntu-latest"), "{stage}"); | |
| 183 | + | assert_eq!(on(true, false), json!("g1t-4core"), "{stage}"); | |
| 184 | + | // The runner's image is built with the job's own Docker Engine. | |
| 185 | + | assert_eq!(on(false, true), json!("g1t-4core"), "{stage}"); | |
| 186 | + | assert_eq!(on(false, false), json!("ubuntu-latest"), "{stage}"); | |
| 185 | 187 | } | |
| 186 | 188 | let source = std::fs::read_to_string(workflows_dir().join("deploy.yml")).unwrap(); | |
| 187 | 189 | assert!(source.contains("target/wasm32-unknown-unknown/release")); | |
| 188 | 190 | assert!(source.contains("!target/**/incremental")); | |
| 191 | + | assert!(source.contains("target/x86_64-unknown-linux-musl/release")); | |
| 189 | 192 | } | |
| 190 | 193 | ||
| 191 | 194 | #[test] |
| 1 | + | //! Containers a job asks for, as GitHub's runner starts them, with the | |
| 2 | + | //! `docker` command: its `services:` (a database beside the steps), its | |
| 3 | + | //! `container:` (every step run inside an image), `uses: docker://image` | |
| 4 | + | //! steps and Docker actions (`runs.using: docker`). | |
| 5 | + | //! | |
| 6 | + | //! On g1t's machines the Engine is the job's own (crate::docker). Every | |
| 7 | + | //! container shares the job's network there, so a service is reached at | |
| 8 | + | //! `localhost:<port>` as on GitHub's runner, and by its name as from a job | |
| 9 | + | //! container: the API proxy makes each service's name mean 127.0.0.1. | |
| 10 | + | ||
| 11 | + | use std::collections::{BTreeMap, BTreeSet}; | |
| 12 | + | use std::path::{Path, PathBuf}; | |
| 13 | + | use std::process::Command; | |
| 14 | + | use std::time::{Duration, Instant}; | |
| 15 | + | ||
| 16 | + | use g1t_actions::expr; | |
| 17 | + | use serde_json::{Map, Value, json}; | |
| 18 | + | ||
| 19 | + | use super::files::StepFiles; | |
| 20 | + | use super::process::{self, Commands, Ended}; | |
| 21 | + | use super::Job; | |
| 22 | + | ||
| 23 | + | /// Set in a job a self-hosted runner started inside its `container:` image. | |
| 24 | + | pub(crate) const IN_JOB_CONTAINER: &str = "G1T_JOB_CONTAINER"; | |
| 25 | + | /// Where a job container finds the runner's Node, for JavaScript actions. | |
| 26 | + | pub(crate) const CONTAINER_NODE: &str = "/__e/node24/bin/node"; | |
| 27 | + | /// GitHub's folders inside a Docker action's container. | |
| 28 | + | const GITHUB_WORKSPACE: &str = "/github/workspace"; | |
| 29 | + | const GITHUB_HOME: &str = "/github/home"; | |
| 30 | + | const GITHUB_WORKFLOW: &str = "/github/workflow"; | |
| 31 | + | const GITHUB_FILE_COMMANDS: &str = "/github/file_commands"; | |
| 32 | + | ||
| 33 | + | /// The job's own container, when it has `container:`. | |
| 34 | + | pub(crate) struct JobContainer { | |
| 35 | + | pub(crate) id: String, | |
| 36 | + | /// `bash`, or `sh` in an image without it. | |
| 37 | + | pub(crate) shell: &'static str, | |
| 38 | + | /// Whether the runner's Node runs in it. | |
| 39 | + | pub(crate) node: bool, | |
| 40 | + | /// The image's own `PATH`. | |
| 41 | + | pub(crate) path: String, | |
| 42 | + | } | |
| 43 | + | ||
| 44 | + | /// A `services:` entry or `container:`, read. | |
| 45 | + | #[derive(Debug, Default, PartialEq)] | |
| 46 | + | pub(crate) struct ContainerSpec { | |
| 47 | + | pub(crate) image: String, | |
| 48 | + | pub(crate) env: BTreeMap<String, String>, | |
| 49 | + | pub(crate) ports: Vec<String>, | |
| 50 | + | pub(crate) volumes: Vec<String>, | |
| 51 | + | pub(crate) options: Vec<String>, | |
| 52 | + | pub(crate) credentials: Option<(String, String)>, | |
| 53 | + | pub(crate) command: Vec<String>, | |
| 54 | + | pub(crate) entrypoint: Option<String>, | |
| 55 | + | } | |
| 56 | + | ||
| 57 | + | /// Splits a command line as a shell would: words, with `'…'`, `"…"` and | |
| 58 | + | /// `\` quoting. No variables are expanded. | |
| 59 | + | pub(crate) fn split_words(text: &str) -> Vec<String> { | |
| 60 | + | let mut words = Vec::new(); | |
| 61 | + | let mut word = String::new(); | |
| 62 | + | let mut started = false; | |
| 63 | + | let mut chars = text.chars().peekable(); | |
| 64 | + | while let Some(c) = chars.next() { | |
| 65 | + | match c { | |
| 66 | + | '\'' => { | |
| 67 | + | started = true; | |
| 68 | + | for c in chars.by_ref() { | |
| 69 | + | if c == '\'' { | |
| 70 | + | break; | |
| 71 | + | } | |
| 72 | + | word.push(c); | |
| 73 | + | } | |
| 74 | + | } | |
| 75 | + | '"' => { | |
| 76 | + | started = true; | |
| 77 | + | while let Some(c) = chars.next() { | |
| 78 | + | match c { | |
| 79 | + | '"' => break, | |
| 80 | + | '\\' if matches!(chars.peek(), Some('"' | '\\' | '$' | '`')) => word.push(chars.next().unwrap_or('\\')), | |
| 81 | + | c => word.push(c), | |
| 82 | + | } | |
| 83 | + | } | |
| 84 | + | } | |
| 85 | + | '\\' => { | |
| 86 | + | started = true; | |
| 87 | + | if let Some(next) = chars.next() | |
| 88 | + | && next != '\n' | |
| 89 | + | { | |
| 90 | + | word.push(next); | |
| 91 | + | } | |
| 92 | + | } | |
| 93 | + | c if c.is_whitespace() => { | |
| 94 | + | if started { | |
| 95 | + | words.push(std::mem::take(&mut word)); | |
| 96 | + | started = false; | |
| 97 | + | } | |
| 98 | + | } | |
| 99 | + | c => { | |
| 100 | + | started = true; | |
| 101 | + | word.push(c); | |
| 102 | + | } | |
| 103 | + | } | |
| 104 | + | } | |
| 105 | + | if started { | |
| 106 | + | words.push(word); | |
| 107 | + | } | |
| 108 | + | words | |
| 109 | + | } | |
| 110 | + | ||
| 111 | + | fn texts(value: Option<&Value>) -> Vec<String> { | |
| 112 | + | match value { | |
| 113 | + | Some(Value::Array(items)) => items.iter().map(expr::to_text).filter(|s| !s.is_empty()).collect(), | |
| 114 | + | Some(Value::Null) | None => Vec::new(), | |
| 115 | + | Some(other) => vec![expr::to_text(other)].into_iter().filter(|s| !s.is_empty()).collect(), | |
| 116 | + | } | |
| 117 | + | } | |
| 118 | + | ||
| 119 | + | /// Reads a `services:` entry or `container:`, its expressions already | |
| 120 | + | /// read: a string (the image) or a mapping. | |
| 121 | + | pub(crate) fn container_spec(value: &Value) -> ContainerSpec { | |
| 122 | + | match value { | |
| 123 | + | Value::String(image) => ContainerSpec { image: image.trim().to_owned(), ..ContainerSpec::default() }, | |
| 124 | + | Value::Object(fields) => ContainerSpec { | |
| 125 | + | image: fields.get("image").map(expr::to_text).unwrap_or_default().trim().to_owned(), | |
| 126 | + | env: fields | |
| 127 | + | .get("env") | |
| 128 | + | .and_then(Value::as_object) | |
| 129 | + | .map(|env| env.iter().map(|(k, v)| (k.clone(), expr::to_text(v))).collect()) | |
| 130 | + | .unwrap_or_default(), | |
| 131 | + | ports: texts(fields.get("ports")), | |
| 132 | + | volumes: texts(fields.get("volumes")), | |
| 133 | + | options: fields.get("options").map(|o| split_words(&expr::to_text(o))).unwrap_or_default(), | |
| 134 | + | credentials: fields.get("credentials").and_then(Value::as_object).and_then(|c| { | |
| 135 | + | let username = c.get("username").map(expr::to_text).unwrap_or_default(); | |
| 136 | + | let password = c.get("password").map(expr::to_text).unwrap_or_default(); | |
| 137 | + | (!username.is_empty() || !password.is_empty()).then_some((username, password)) | |
| 138 | + | }), | |
| 139 | + | command: fields.get("command").map(|c| split_words(&expr::to_text(c))).unwrap_or_default(), | |
| 140 | + | entrypoint: fields.get("entrypoint").map(expr::to_text).filter(|e| !e.is_empty()), | |
| 141 | + | }, | |
| 142 | + | _ => ContainerSpec::default(), | |
| 143 | + | } | |
| 144 | + | } | |
| 145 | + | ||
| 146 | + | /// `job.services.<id>.ports`: each container port, and the host port it | |
| 147 | + | /// is reached on. On g1t's machines a port left for Docker to choose is | |
| 148 | + | /// the container's own. | |
| 149 | + | pub(crate) fn port_map(ports: &[String]) -> BTreeMap<String, String> { | |
| 150 | + | let mut map = BTreeMap::new(); | |
| 151 | + | for port in ports { | |
| 152 | + | let without_protocol = port.split('/').next().unwrap_or(port); | |
| 153 | + | let parts: Vec<&str> = without_protocol.split(':').collect(); | |
| 154 | + | let (host, container) = match parts.as_slice() { | |
| 155 | + | [container] => (*container, *container), | |
| 156 | + | [host, container] => (if host.is_empty() { *container } else { *host }, *container), | |
| 157 | + | [_, host, container] => (if host.is_empty() { *container } else { *host }, *container), | |
| 158 | + | _ => continue, | |
| 159 | + | }; | |
| 160 | + | if !container.is_empty() { | |
| 161 | + | map.insert(container.to_owned(), host.to_owned()); | |
| 162 | + | } | |
| 163 | + | } | |
| 164 | + | map | |
| 165 | + | } | |
| 166 | + | ||
| 167 | + | /// The registry an image is pulled from, for signing in with | |
| 168 | + | /// `credentials:`. | |
| 169 | + | pub(crate) fn registry_of(image: &str) -> String { | |
| 170 | + | match image.split_once('/') { | |
| 171 | + | Some((first, _)) if first.contains('.') || first.contains(':') || first == "localhost" => first.to_owned(), | |
| 172 | + | _ => "https://index.docker.io/v1/".to_owned(), | |
| 173 | + | } | |
| 174 | + | } | |
| 175 | + | ||
| 176 | + | /// A name for Docker from any text: lower case, letters, digits, `_`, | |
| 177 | + | /// `.` and `-`. | |
| 178 | + | pub(crate) fn docker_name(text: &str) -> String { | |
| 179 | + | let name: String = text | |
| 180 | + | .to_ascii_lowercase() | |
| 181 | + | .chars() | |
| 182 | + | .map(|c| if c.is_ascii_alphanumeric() || matches!(c, '_' | '.' | '-') { c } else { '_' }) | |
| 183 | + | .collect(); | |
| 184 | + | name.trim_matches(['_', '.', '-']).chars().take(100).collect() | |
| 185 | + | } | |
| 186 | + | ||
| 187 | + | /// The `docker create` arguments of a container. `env` is passed by name | |
| 188 | + | /// (`-e NAME`), its values in the command's environment, so secrets never | |
| 189 | + | /// stand on a command line. `keep_alive`: a job container, which waits | |
| 190 | + | /// while steps run in it. | |
| 191 | + | pub(crate) fn create_args(name: &str, network: Option<&str>, alias: Option<&str>, spec: &ContainerSpec, mounts: &[(String, String)], keep_alive: bool) -> Vec<String> { | |
| 192 | + | let mut args: Vec<String> = vec!["create".into(), "--name".into(), name.into(), "--label".into(), "g1t-job".into()]; | |
| 193 | + | if let Some(network) = network { | |
| 194 | + | args.extend(["--network".into(), network.into()]); | |
| 195 | + | if let Some(alias) = alias { | |
| 196 | + | args.extend(["--network-alias".into(), alias.into()]); | |
| 197 | + | } | |
| 198 | + | } | |
| 199 | + | for port in &spec.ports { | |
| 200 | + | args.extend(["-p".into(), port.clone()]); | |
| 201 | + | } | |
| 202 | + | for volume in &spec.volumes { | |
| 203 | + | args.extend(["-v".into(), volume.clone()]); | |
| 204 | + | } | |
| 205 | + | for (from, to) in mounts { | |
| 206 | + | args.extend(["-v".into(), format!("{from}:{to}")]); | |
| 207 | + | } | |
| 208 | + | for name in spec.env.keys() { | |
| 209 | + | args.extend(["-e".into(), name.clone()]); | |
| 210 | + | } | |
| 211 | + | args.extend(spec.options.iter().cloned()); | |
| 212 | + | if keep_alive { | |
| 213 | + | args.extend(["--entrypoint".into(), "tail".into(), spec.image.clone(), "-f".into(), "/dev/null".into()]); | |
| 214 | + | } else { | |
| 215 | + | if let Some(entrypoint) = &spec.entrypoint { | |
| 216 | + | args.extend(["--entrypoint".into(), entrypoint.clone()]); | |
| 217 | + | } | |
| 218 | + | args.push(spec.image.clone()); | |
| 219 | + | args.extend(spec.command.iter().cloned()); | |
| 220 | + | } | |
| 221 | + | args | |
| 222 | + | } | |
| 223 | + | ||
| 224 | + | /// A Docker action's run, or a `docker://` step's. | |
| 225 | + | #[derive(Clone, Debug)] | |
| 226 | + | pub(crate) struct DockerRun { | |
| 227 | + | pub(crate) image: String, | |
| 228 | + | pub(crate) entrypoint: Option<String>, | |
| 229 | + | pub(crate) args: Vec<String>, | |
| 230 | + | /// The step's variables, `INPUT_*` included. | |
| 231 | + | pub(crate) env: BTreeMap<String, String>, | |
| 232 | + | } | |
| 233 | + | ||
| 234 | + | /// What a command line shows: secrets are passed by name, so nothing | |
| 235 | + | /// needs hiding, but long lists are kept readable. | |
| 236 | + | fn shown(args: &[String]) -> String { | |
| 237 | + | args.iter().map(|a| if a.contains(' ') || a.is_empty() { format!("'{a}'") } else { a.clone() }).collect::<Vec<_>>().join(" ") | |
| 238 | + | } | |
| 239 | + | ||
| 240 | + | impl Job { | |
| 241 | + | /// Runs `docker` with `args`, its output in the log. `env`: the | |
| 242 | + | /// variables it passes to a container by name. | |
| 243 | + | pub(crate) fn docker(&mut self, args: &[String], env: &BTreeMap<String, String>, timeout: Duration) -> bool { | |
| 244 | + | self.log.line(&format!("[command]docker {}", shown(args))); | |
| 245 | + | let mut command = Command::new("docker"); | |
| 246 | + | command.args(args).env_clear().envs(self.docker_cli_env()).envs(env); | |
| 247 | + | let mut commands = Commands::default(); | |
| 248 | + | matches!(process::run(command, timeout.min(self.remaining_time()), &mut self.log, &mut commands), Ok(Ended::Exited(0))) | |
| 249 | + | } | |
| 250 | + | ||
| 251 | + | /// What `docker` prints, or None if it fails. | |
| 252 | + | pub(crate) fn docker_output(&self, args: &[&str]) -> Option<String> { | |
| 253 | + | let output = Command::new("docker").args(args).env_clear().envs(self.docker_cli_env()).output().ok()?; | |
| 254 | + | output.status.success().then(|| String::from_utf8_lossy(&output.stdout).trim().to_owned()) | |
| 255 | + | } | |
| 256 | + | ||
| 257 | + | /// What the `docker` command itself needs of the sandbox's variables: | |
| 258 | + | /// where it is, where its config is, and how to reach the Engine. | |
| 259 | + | fn docker_cli_env(&self) -> BTreeMap<String, String> { | |
| 260 | + | ["PATH", "HOME", "DOCKER_HOST", "DOCKER_CONFIG", "DOCKER_CONTEXT", "DOCKER_CERT_PATH", "DOCKER_TLS_VERIFY"] | |
| 261 | + | .iter() | |
| 262 | + | .filter_map(|name| self.base_env_value(name).map(|value| (name.to_string(), value))) | |
| 263 | + | .collect() | |
| 264 | + | } | |
| 265 | + | ||
| 266 | + | /// Makes sure Docker answers before a job's containers start: the | |
| 267 | + | /// job's own Engine, on g1t's machines. | |
| 268 | + | fn docker_ready(&mut self) -> bool { | |
| 269 | + | if self.docker_hosted { | |
| 270 | + | let started = crate::docker::engine::ensure_started(); | |
| 271 | + | self.log_docker_notes(); | |
| 272 | + | // Why it could not start is among the notes just logged. | |
| 273 | + | return started.is_ok(); | |
| 274 | + | } | |
| 275 | + | if self.docker_output(&["version", "--format", "{{.Server.Version}}"]).is_some() { | |
| 276 | + | return true; | |
| 277 | + | } | |
| 278 | + | self.log.line("##[error]This job needs Docker (`services`, `container` or a Docker action), and Docker does not answer here. On a self-hosted runner, run the runner directly on a machine with Docker (`--no-docker`)."); | |
| 279 | + | false | |
| 280 | + | } | |
| 281 | + | ||
| 282 | + | pub(crate) fn log_docker_notes(&mut self) { | |
| 283 | + | for line in crate::docker::take_notes() { | |
| 284 | + | self.log.line(&line); | |
| 285 | + | } | |
| 286 | + | } | |
| 287 | + | ||
| 288 | + | /// Pulls an image, signed in with `credentials` if it has them. | |
| 289 | + | fn pull(&mut self, image: &str, credentials: Option<&(String, String)>) -> bool { | |
| 290 | + | let Some((username, password)) = credentials else { | |
| 291 | + | return self.docker(&["pull".into(), image.into()], &BTreeMap::new(), Duration::from_secs(1800)); | |
| 292 | + | }; | |
| 293 | + | // A config of its own, so the credentials go no further than this pull. | |
| 294 | + | let config = self.temp.join(format!("docker-config-{:x}", super::rand_id())); | |
| 295 | + | let _ = std::fs::create_dir_all(&config); | |
| 296 | + | let registry = registry_of(image); | |
| 297 | + | let config_text = config.display().to_string(); | |
| 298 | + | self.log.line(&format!("[command]docker --config {config_text} login {registry} --username *** --password-stdin")); | |
| 299 | + | let login = Command::new("docker") | |
| 300 | + | .args(["--config", &config_text, "login", ®istry, "--username", username, "--password-stdin"]) | |
| 301 | + | .env_clear() | |
| 302 | + | .envs(self.docker_cli_env()) | |
| 303 | + | .stdin(std::process::Stdio::piped()) | |
| 304 | + | .stdout(std::process::Stdio::piped()) | |
| 305 | + | .stderr(std::process::Stdio::piped()) | |
| 306 | + | .spawn() | |
| 307 | + | .and_then(|mut child| { | |
| 308 | + | use std::io::Write; | |
| 309 | + | if let Some(mut stdin) = child.stdin.take() { | |
| 310 | + | let _ = stdin.write_all(password.as_bytes()); | |
| 311 | + | } | |
| 312 | + | child.wait_with_output() | |
| 313 | + | }); | |
| 314 | + | let ok = match login { | |
| 315 | + | Ok(output) if output.status.success() => { | |
| 316 | + | self.docker(&["--config".into(), config_text.clone(), "pull".into(), image.into()], &BTreeMap::new(), Duration::from_secs(1800)) | |
| 317 | + | } | |
| 318 | + | Ok(output) => { | |
| 319 | + | self.log.line(&format!("##[error]Could not sign in to {registry}: {}", String::from_utf8_lossy(&output.stderr).trim())); | |
| 320 | + | false | |
| 321 | + | } | |
| 322 | + | Err(error) => { | |
| 323 | + | self.log.line(&format!("##[error]Could not run docker login: {error}")); | |
| 324 | + | false | |
| 325 | + | } | |
| 326 | + | }; | |
| 327 | + | let _ = std::fs::remove_dir_all(&config); | |
| 328 | + | ok | |
| 329 | + | } | |
| 330 | + | ||
| 331 | + | /// Reads a `services:` entry or `container:` with the job's contexts. | |
| 332 | + | fn read_container(&self, value: &Value) -> ContainerSpec { | |
| 333 | + | let frame = super::Frame::default(); | |
| 334 | + | let env = self.env_context(&frame); | |
| 335 | + | let contexts = self.contexts_for(&frame, &env); | |
| 336 | + | let value = self.with_scope(&contexts, |scope| expr::interpolate_value(value, scope)).unwrap_or(Value::Null); | |
| 337 | + | container_spec(&value) | |
| 338 | + | } | |
| 339 | + | ||
| 340 | + | /// Starts the job's services and its container, before its steps: | |
| 341 | + | /// GitHub's "Initialize containers". Returns whether they all started. | |
| 342 | + | pub(crate) fn start_containers(&mut self) -> bool { | |
| 343 | + | let services: Vec<(String, Value)> = self.spec["spec"]["services"].as_object().map(|s| s.iter().map(|(k, v)| (k.clone(), v.clone())).collect()).unwrap_or_default(); | |
| 344 | + | let container = self.spec["spec"].get("container").filter(|c| !c.is_null()).cloned(); | |
| 345 | + | let container = container.map(|c| self.read_container(&c)).filter(|c| !c.image.is_empty()); | |
| 346 | + | let services: Vec<(String, ContainerSpec)> = services.into_iter().map(|(name, value)| (name, self.read_container(&value))).filter(|(_, spec)| !spec.image.is_empty()).collect(); | |
| 347 | + | // A self-hosted runner in Docker mode started this job in its | |
| 348 | + | // `container:` image already (selfhosted/exec.rs). | |
| 349 | + | let container = container.filter(|_| std::env::var_os(IN_JOB_CONTAINER).is_none()); | |
| 350 | + | if services.is_empty() && container.is_none() { | |
| 351 | + | return true; | |
| 352 | + | } | |
| 353 | + | // A self-hosted machine without Docker runs the steps as before, | |
| 354 | + | // on the machine, without the containers. | |
| 355 | + | if !self.docker_hosted && self.docker_output(&["version", "--format", "{{.Server.Version}}"]).is_none() { | |
| 356 | + | self.log.line("##[warning]Docker does not answer on this runner, so the job's `services` and `container` are not started and its steps run on the machine. Run the runner directly on a machine with Docker (`--no-docker`) to start them."); | |
| 357 | + | return true; | |
| 358 | + | } | |
| 359 | + | self.log.line("##[group]Initialize containers"); | |
| 360 | + | let ok = self.docker_ready() && self.start_containers_now(services, container); | |
| 361 | + | self.log.line("##[endgroup]"); | |
| 362 | + | ok | |
| 363 | + | } | |
| 364 | + | ||
| 365 | + | fn start_containers_now(&mut self, services: Vec<(String, ContainerSpec)>, container: Option<ContainerSpec>) -> bool { | |
| 366 | + | let job_id = docker_name(&format!("{}_{:x}", self.spec["name"].as_str().unwrap_or("job"), super::rand_id() & 0xffff_ffff)); | |
| 367 | + | let network = format!("g1t_{job_id}"); | |
| 368 | + | if !self.docker(&["network".into(), "create".into(), "--label".into(), "g1t-job".into(), network.clone()], &BTreeMap::new(), Duration::from_secs(60)) { | |
| 369 | + | self.log.line("##[error]Could not make the job's network."); | |
| 370 | + | return false; | |
| 371 | + | } | |
| 372 | + | self.network = Some(network.clone()); | |
| 373 | + | ||
| 374 | + | let mut services_context = Map::new(); | |
| 375 | + | for (service, spec) in &services { | |
| 376 | + | if !self.pull(&spec.image, spec.credentials.as_ref()) { | |
| 377 | + | self.log.line(&format!("##[error]Could not pull {} for the service `{service}`.", spec.image)); | |
| 378 | + | return false; | |
| 379 | + | } | |
| 380 | + | let name = docker_name(&format!("{service}_{job_id}")); | |
| 381 | + | let args = create_args(&name, Some(&network), Some(service), spec, &[], false); | |
| 382 | + | if !self.docker(&args, &spec.env, Duration::from_secs(300)) { | |
| 383 | + | self.log.line(&format!("##[error]Could not create the service `{service}`.")); | |
| 384 | + | return false; | |
| 385 | + | } | |
| 386 | + | self.services.push((service.clone(), name.clone())); | |
| 387 | + | if !self.docker(&["start".into(), name.clone()], &BTreeMap::new(), Duration::from_secs(300)) { | |
| 388 | + | self.log.line(&format!("##[error]Could not start the service `{service}`.")); | |
| 389 | + | return false; | |
| 390 | + | } | |
| 391 | + | let id = self.docker_output(&["inspect", "--format", "{{.Id}}", &name]).unwrap_or_default(); | |
| 392 | + | let ports: Map<String, Value> = port_map(&spec.ports).into_iter().map(|(k, v)| (k, json!(v))).collect(); | |
| 393 | + | services_context.insert(service.clone(), json!({ "id": id, "network": network, "ports": ports })); | |
| 394 | + | } | |
| 395 | + | ||
| 396 | + | if let Some(spec) = container { | |
| 397 | + | if !self.pull(&spec.image, spec.credentials.as_ref()) { | |
| 398 | + | self.log.line(&format!("##[error]Could not pull {} for the job's container.", spec.image)); | |
| 399 | + | return false; | |
| 400 | + | } | |
| 401 | + | let name = docker_name(&format!("job_{job_id}")); | |
| 402 | + | let mounts = self.container_mounts(); | |
| 403 | + | let mut spec = spec; | |
| 404 | + | spec.env.insert("HOME".into(), GITHUB_HOME.into()); | |
| 405 | + | spec.env.insert("CI".into(), "true".into()); | |
| 406 | + | spec.env.insert("GITHUB_ACTIONS".into(), "true".into()); | |
| 407 | + | let mut args = create_args(&name, Some(&network), None, &spec, &mounts, true); | |
| 408 | + | // Steps start in the workspace. | |
| 409 | + | args.splice(1..1, ["-w".to_owned(), self.workspace.display().to_string()]); | |
| 410 | + | if !self.docker(&args, &spec.env, Duration::from_secs(300)) || !self.docker(&["start".into(), name.clone()], &BTreeMap::new(), Duration::from_secs(300)) { | |
| 411 | + | self.log.line("##[error]Could not start the job's container."); | |
| 412 | + | return false; | |
| 413 | + | } | |
| 414 | + | let id = self.docker_output(&["inspect", "--format", "{{.Id}}", &name]).unwrap_or_default(); | |
| 415 | + | let has_bash = self.docker_output(&["exec", &name, "sh", "-c", "command -v bash"]).is_some_and(|out| !out.is_empty()); | |
| 416 | + | let node = self.docker_output(&["exec", &name, CONTAINER_NODE, "--version"]).is_some(); | |
| 417 | + | let path = self.docker_output(&["exec", &name, "sh", "-c", "printf %s \"$PATH\""]).unwrap_or_else(|| "/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin".into()); | |
| 418 | + | if !node { | |
| 419 | + | self.log.line("##[warning]The runner's Node does not run in this image (it needs glibc and libstdc++), so JavaScript actions run beside the container, on g1t's image, with the same files."); | |
| 420 | + | } | |
| 421 | + | self.job_context.insert("container".into(), json!({ "id": id, "network": network })); | |
| 422 | + | self.container = Some(JobContainer { id: name, shell: if has_bash { "bash" } else { "sh" }, node, path }); | |
| 423 | + | } | |
| 424 | + | ||
| 425 | + | // Services with a health check are waited for. | |
| 426 | + | for (service, name) in self.services.clone() { | |
| 427 | + | if !self.wait_healthy(&service, &name) { | |
| 428 | + | return false; | |
| 429 | + | } | |
| 430 | + | } | |
| 431 | + | if !services_context.is_empty() { | |
| 432 | + | self.job_context.insert("services".into(), Value::Object(services_context)); | |
| 433 | + | } | |
| 434 | + | self.log_docker_notes(); | |
| 435 | + | true | |
| 436 | + | } | |
| 437 | + | ||
| 438 | + | /// GitHub's runner's folders, at the same paths, and Docker's socket. | |
| 439 | + | fn container_mounts(&self) -> Vec<(String, String)> { | |
| 440 | + | let home = super::paths::under_home(super::paths::HOME_RUNNER); | |
| 441 | + | let github_home = self.temp.join("_github_home"); | |
| 442 | + | let _ = std::fs::create_dir_all(&github_home); | |
| 443 | + | let mut mounts: Vec<(String, String)> = ["work", "_temp", "_actions", "_tool"] | |
| 444 | + | .iter() | |
| 445 | + | .map(|dir| { | |
| 446 | + | let path = home.join(dir); | |
| 447 | + | let _ = std::fs::create_dir_all(&path); | |
| 448 | + | (path.display().to_string(), path.display().to_string()) | |
| 449 | + | }) | |
| 450 | + | .collect(); | |
| 451 | + | mounts.push((github_home.display().to_string(), GITHUB_HOME.into())); | |
| 452 | + | if let Some(node) = which_node() { | |
| 453 | + | mounts.push((node.display().to_string(), format!("{CONTAINER_NODE}:ro"))); | |
| 454 | + | } | |
| 455 | + | mounts.push((crate::docker::engine::DOCKER_SOCKET.into(), crate::docker::engine::DOCKER_SOCKET.into())); | |
| 456 | + | mounts | |
| 457 | + | } | |
| 458 | + | ||
| 459 | + | fn wait_healthy(&mut self, service: &str, name: &str) -> bool { | |
| 460 | + | let limit = Duration::from_secs(600).min(self.remaining_time()); | |
| 461 | + | let until = Instant::now() + limit; | |
| 462 | + | let mut wait = Duration::from_secs(1); | |
| 463 | + | loop { | |
| 464 | + | let status = self.docker_output(&["inspect", "--format", "{{if .Config.Healthcheck}}{{print .State.Health.Status}}{{end}}", name]).unwrap_or_default(); | |
| 465 | + | match status.as_str() { | |
| 466 | + | "" => return true, | |
| 467 | + | "healthy" => { | |
| 468 | + | self.log.line(&format!("{service} is healthy.")); | |
| 469 | + | return true; | |
| 470 | + | } | |
| 471 | + | "unhealthy" => { | |
| 472 | + | self.log.line(&format!("##[error]The service `{service}` is unhealthy.")); | |
| 473 | + | self.service_logs(service, name); | |
| 474 | + | return false; | |
| 475 | + | } | |
| 476 | + | _ => {} | |
| 477 | + | } | |
| 478 | + | if Instant::now() >= until { | |
| 479 | + | self.log.line(&format!("##[error]The service `{service}` did not become healthy in {} s.", limit.as_secs())); | |
| 480 | + | self.service_logs(service, name); | |
| 481 | + | return false; | |
| 482 | + | } | |
| 483 | + | self.log.line(&format!("Waiting for {service} to be healthy ({status}).")); | |
| 484 | + | std::thread::sleep(wait); | |
| 485 | + | wait = (wait * 2).min(Duration::from_secs(8)); | |
| 486 | + | } | |
| 487 | + | } | |
| 488 | + | ||
| 489 | + | fn service_logs(&mut self, service: &str, name: &str) { | |
| 490 | + | self.log.line(&format!("##[group]Service container {service}")); | |
| 491 | + | self.docker(&["logs".into(), "--tail".into(), "200".into(), name.into()], &BTreeMap::new(), Duration::from_secs(60)); | |
| 492 | + | self.log.line("##[endgroup]"); | |
| 493 | + | } | |
| 494 | + | ||
| 495 | + | /// Whether the job has containers to stop when it ends. | |
| 496 | + | pub(crate) fn has_containers(&self) -> bool { | |
| 497 | + | self.network.is_some() | |
| 498 | + | } | |
| 499 | + | ||
| 500 | + | /// GitHub's "Stop containers": each service's log, then the job's | |
| 501 | + | /// containers and network removed. | |
| 502 | + | pub(crate) fn stop_containers(&mut self) -> bool { | |
| 503 | + | for (service, name) in self.services.clone() { | |
| 504 | + | self.service_logs(&service, &name); | |
| 505 | + | } | |
| 506 | + | let mut names: Vec<String> = self.services.iter().map(|(_, name)| name.clone()).collect(); | |
| 507 | + | if let Some(container) = &self.container { | |
| 508 | + | names.push(container.id.clone()); | |
| 509 | + | } | |
| 510 | + | if !names.is_empty() { | |
| 511 | + | let mut args = vec!["rm".to_owned(), "--force".to_owned()]; | |
| 512 | + | args.extend(names); | |
| 513 | + | self.docker(&args, &BTreeMap::new(), Duration::from_secs(120)); | |
| 514 | + | } | |
| 515 | + | if let Some(network) = self.network.take() { | |
| 516 | + | self.docker(&["network".into(), "rm".into(), network], &BTreeMap::new(), Duration::from_secs(60)); | |
| 517 | + | } | |
| 518 | + | self.container = None; | |
| 519 | + | true | |
| 520 | + | } | |
| 521 | + | ||
| 522 | + | /// The variables a process inside a container is given: the step's, | |
| 523 | + | /// GitHub's and the job's, but not the sandbox's own (its `PATH`, | |
| 524 | + | /// `HOME` and the like, which mean nothing in another image). | |
| 525 | + | pub(crate) fn container_env(&self, step_env: &BTreeMap<String, String>, full: BTreeMap<String, String>, image_path: &str) -> BTreeMap<String, String> { | |
| 526 | + | let mut out: BTreeMap<String, String> = full | |
| 527 | + | .into_iter() | |
| 528 | + | .filter(|(name, _)| step_env.contains_key(name) || !self.host_env.contains(name) || name.starts_with("GITHUB_") || name.starts_with("RUNNER_")) | |
| 529 | + | .collect(); | |
| 530 | + | if !step_env.contains_key("PATH") { | |
| 531 | + | out.remove("PATH"); | |
| 532 | + | if !self.path_prepend_entries().is_empty() { | |
| 533 | + | out.insert("PATH".into(), format!("{}:{image_path}", self.path_prepend_entries().join(":"))); | |
| 534 | + | } | |
| 535 | + | } | |
| 536 | + | if !step_env.contains_key("HOME") { | |
| 537 | + | out.remove("HOME"); | |
| 538 | + | } | |
| 539 | + | out | |
| 540 | + | } | |
| 541 | + | ||
| 542 | + | /// A step's command, run inside the job's container instead. | |
| 543 | + | pub(crate) fn in_container(&self, program: &str, args: &[String], dir: &Path, env: BTreeMap<String, String>) -> Option<Command> { | |
| 544 | + | let container = self.container.as_ref()?; | |
| 545 | + | let mut command = Command::new("docker"); | |
| 546 | + | command.args(["exec", "-w", &dir.display().to_string()]); | |
| 547 | + | for name in env.keys() { | |
| 548 | + | command.args(["-e", name]); | |
| 549 | + | } | |
| 550 | + | command.arg(&container.id).arg(program).args(args); | |
| 551 | + | command.env_clear().envs(self.docker_cli_env()).envs(env); | |
| 552 | + | Some(command) | |
| 553 | + | } | |
| 554 | + | ||
| 555 | + | /// Runs a container for a Docker action or a `docker://` step, as | |
| 556 | + | /// GitHub's runner does: the workspace at /github/workspace, the step's | |
| 557 | + | /// files at /github/file_commands, the job's network. | |
| 558 | + | pub(crate) fn run_docker(&mut self, run: &DockerRun) -> (bool, BTreeMap<String, String>, BTreeMap<String, String>) { | |
| 559 | + | let fail = (false, BTreeMap::new(), BTreeMap::new()); | |
| 560 | + | if !self.docker_ready() { | |
| 561 | + | return fail; | |
| 562 | + | } | |
| 563 | + | let id = format!("{:x}", super::rand_id()); | |
| 564 | + | let Ok(files) = StepFiles::new(&self.temp, &id) else { return fail }; | |
| 565 | + | let commands_dir = self.temp.join("_runner_file_commands"); | |
| 566 | + | let workflow_dir = self.temp.join("_github_workflow"); | |
| 567 | + | let home_dir = self.temp.join("_github_home"); | |
| 568 | + | for dir in [&workflow_dir, &home_dir] { | |
| 569 | + | let _ = std::fs::create_dir_all(dir); | |
| 570 | + | } | |
| 571 | + | let _ = std::fs::copy(self.temp.join("event.json"), workflow_dir.join("event.json")); | |
| 572 | + | ||
| 573 | + | let full = self.process_env(&run.env, &files); | |
| 574 | + | let mut env = self.container_env(&run.env, full, ""); | |
| 575 | + | env.remove("PATH"); | |
| 576 | + | // Paths as the container sees them. | |
| 577 | + | let moved = |path: &Path| format!("{GITHUB_FILE_COMMANDS}/{}", path.file_name().map(|n| n.to_string_lossy().into_owned()).unwrap_or_default()); | |
| 578 | + | env.insert("GITHUB_OUTPUT".into(), moved(&files.output)); | |
| 579 | + | env.insert("GITHUB_ENV".into(), moved(&files.env)); | |
| 580 | + | env.insert("GITHUB_PATH".into(), moved(&files.path)); | |
| 581 | + | env.insert("GITHUB_STATE".into(), moved(&files.state)); | |
| 582 | + | env.insert("GITHUB_STEP_SUMMARY".into(), moved(&files.summary)); | |
| 583 | + | env.insert("GITHUB_WORKSPACE".into(), GITHUB_WORKSPACE.into()); | |
| 584 | + | env.insert("GITHUB_EVENT_PATH".into(), format!("{GITHUB_WORKFLOW}/event.json")); | |
| 585 | + | env.insert("HOME".into(), GITHUB_HOME.into()); | |
| 586 | + | env.remove("GITHUB_ACTION_PATH"); | |
| 587 | + | ||
| 588 | + | let mut args: Vec<String> = vec!["run".into(), "--rm".into(), "--label".into(), "g1t-job".into(), "--workdir".into(), GITHUB_WORKSPACE.into()]; | |
| 589 | + | if let Some(network) = &self.network { | |
| 590 | + | args.extend(["--network".into(), network.clone()]); | |
| 591 | + | } | |
| 592 | + | for (from, to) in [ | |
| 593 | + | (self.workspace.clone(), GITHUB_WORKSPACE), | |
| 594 | + | (home_dir, GITHUB_HOME), | |
| 595 | + | (workflow_dir, GITHUB_WORKFLOW), | |
| 596 | + | (commands_dir, GITHUB_FILE_COMMANDS), | |
| 597 | + | (PathBuf::from(crate::docker::engine::DOCKER_SOCKET), crate::docker::engine::DOCKER_SOCKET), | |
| 598 | + | ] { | |
| 599 | + | args.extend(["-v".into(), format!("{}:{to}", from.display())]); | |
| 600 | + | } | |
| 601 | + | for name in env.keys() { | |
| 602 | + | args.extend(["-e".into(), name.clone()]); | |
| 603 | + | } | |
| 604 | + | if let Some(entrypoint) = &run.entrypoint { | |
| 605 | + | args.extend(["--entrypoint".into(), entrypoint.clone()]); | |
| 606 | + | } | |
| 607 | + | args.push(run.image.clone()); | |
| 608 | + | args.extend(run.args.iter().cloned()); | |
| 609 | + | ||
| 610 | + | crate::abuse::touch(); | |
| 611 | + | if let Some(miner) = crate::abuse::miner_in(&shown(&args)) { | |
| 612 | + | self.log.line(&format!("##[error]g1t does not run cryptocurrency miners ({miner}). This step was not run.")); | |
| 613 | + | return fail; | |
| 614 | + | } | |
| 615 | + | self.log.line(&format!("[command]docker {}", shown(&args))); | |
| 616 | + | let mut command = Command::new("docker"); | |
| 617 | + | command.args(&args).env_clear().envs(self.docker_cli_env()).envs(&env); | |
| 618 | + | let mut commands = Commands::default(); | |
| 619 | + | let ended = process::run(command, self.remaining_time(), &mut self.log, &mut commands); | |
| 620 | + | self.log_docker_notes(); | |
| 621 | + | let ok = match ended { | |
| 622 | + | Ok(Ended::Exited(0)) => true, | |
| 623 | + | Ok(Ended::Exited(code)) => { | |
| 624 | + | self.log.line(&format!("##[error]Docker run failed with exit code {code}.")); | |
| 625 | + | false | |
| 626 | + | } | |
| 627 | + | Ok(Ended::TimedOut) => { | |
| 628 | + | self.log.line("##[error]The step ran past its time limit and was stopped."); | |
| 629 | + | false | |
| 630 | + | } | |
| 631 | + | Err(error) => { | |
| 632 | + | self.log.line(&format!("##[error]docker could not be started: {error}")); | |
| 633 | + | false | |
| 634 | + | } | |
| 635 | + | }; | |
| 636 | + | let (outputs, state) = self.absorb(&files, &commands); | |
| 637 | + | (ok, outputs, state) | |
| 638 | + | } | |
| 639 | + | ||
| 640 | + | /// Builds a Docker action's image from its Dockerfile, once per job. | |
| 641 | + | pub(crate) fn build_action_image(&mut self, dir: &Path, dockerfile: &str, tag_of: &str) -> Option<String> { | |
| 642 | + | let tag = format!("g1t-action/{}", docker_name(tag_of)); | |
| 643 | + | if self.built_actions.contains(&tag) { | |
| 644 | + | return Some(tag); | |
| 645 | + | } | |
| 646 | + | if !self.docker_ready() { | |
| 647 | + | return None; | |
| 648 | + | } | |
| 649 | + | let file = dir.join(dockerfile); | |
| 650 | + | let args = vec!["build".into(), "-t".into(), tag.clone(), "-f".into(), file.display().to_string(), dir.display().to_string()]; | |
| 651 | + | if !self.docker(&args, &BTreeMap::new(), Duration::from_secs(1800)) { | |
| 652 | + | self.log.line("##[error]The action's image did not build."); | |
| 653 | + | return None; | |
| 654 | + | } | |
| 655 | + | self.built_actions.insert(tag.clone()); | |
| 656 | + | Some(tag) | |
| 657 | + | } | |
| 658 | + | ||
| 659 | + | /// `docker/setup-buildx-action` on g1t's machines: the job's own | |
| 660 | + | /// Engine is the builder (BuildKit, the `docker` driver, with the | |
| 661 | + | /// containerd image store, so cache export and attestations work). | |
| 662 | + | pub(crate) fn setup_buildx(&mut self, with: &BTreeMap<String, String>) -> (bool, BTreeMap<String, String>) { | |
| 663 | + | if !self.docker_ready() { | |
| 664 | + | return (false, BTreeMap::new()); | |
| 665 | + | } | |
| 666 | + | if let Some(driver) = with.get("driver").filter(|d| !d.is_empty() && *d != "docker") { | |
| 667 | + | self.log.line(&format!("`driver: {driver}` is not used on g1t's machines: builds run on this job's own Docker Engine (BuildKit, the `docker` driver), which keeps the sandbox's network and guardrails.")); | |
| 668 | + | } | |
| 669 | + | for input in ["driver-opts", "buildkitd-flags", "buildkitd-config", "buildkitd-config-inline", "endpoint"] { | |
| 670 | + | if with.get(input).is_some_and(|v| !v.trim().is_empty()) { | |
| 671 | + | self.log.line(&format!("`{input}` is not used on g1t's machines.")); | |
| 672 | + | } | |
| 673 | + | } | |
| 674 | + | self.docker(&["buildx".into(), "version".into()], &BTreeMap::new(), Duration::from_secs(60)); | |
| 675 | + | let inspect = self.docker_output(&["buildx", "inspect", "default"]).unwrap_or_default(); | |
| 676 | + | let platforms = inspect | |
| 677 | + | .lines() | |
| 678 | + | .find_map(|line| line.trim().strip_prefix("Platforms:")) | |
| 679 | + | .map(|p| p.split(',').map(|s| s.trim().trim_end_matches('*').to_owned()).filter(|s| !s.is_empty()).collect::<Vec<_>>().join(",")) | |
| 680 | + | .unwrap_or_else(|| "linux/amd64".into()); | |
| 681 | + | self.log.line(&format!("Builder: default (this job's Docker Engine), platforms {platforms}")); | |
| 682 | + | let mut outputs = BTreeMap::new(); | |
| 683 | + | outputs.insert("name".into(), "default".into()); | |
| 684 | + | outputs.insert("driver".into(), "docker".into()); | |
| 685 | + | outputs.insert("platforms".into(), platforms.clone()); | |
| 686 | + | outputs.insert("endpoint".into(), "default".into()); | |
| 687 | + | outputs.insert("status".into(), "running".into()); | |
| 688 | + | outputs.insert("flags".into(), String::new()); | |
| 689 | + | outputs.insert( | |
| 690 | + | "nodes".into(), | |
| 691 | + | json!([{ "name": "default", "endpoint": "default", "status": "running", "platforms": platforms }]).to_string(), | |
| 692 | + | ); | |
| 693 | + | (true, outputs) | |
| 694 | + | } | |
| 695 | + | } | |
| 696 | + | ||
| 697 | + | /// The runner's Node, its real file (not a link), to mount into a job | |
| 698 | + | /// container. | |
| 699 | + | fn which_node() -> Option<PathBuf> { | |
| 700 | + | let path = std::env::var_os("PATH")?; | |
| 701 | + | std::env::split_paths(&path).map(|dir| dir.join("node")).find(|p| p.is_file()).and_then(|p| std::fs::canonicalize(p).ok()) | |
| 702 | + | } | |
| 703 | + | ||
| 704 | + | /// The job context's `container` and `services`, merged with its status. | |
| 705 | + | pub(crate) fn job_context(status: &str, extra: &Map<String, Value>) -> Value { | |
| 706 | + | let mut job = Map::new(); | |
| 707 | + | job.insert("status".into(), json!(status)); | |
| 708 | + | for (key, value) in extra { | |
| 709 | + | job.insert(key.clone(), value.clone()); | |
| 710 | + | } | |
| 711 | + | Value::Object(job) | |
| 712 | + | } | |
| 713 | + | ||
| 714 | + | /// Names a set of tags already built in this job. | |
| 715 | + | pub(crate) type Built = BTreeSet<String>; | |
| 716 | + | ||
| 717 | + | #[cfg(test)] | |
| 718 | + | mod tests { | |
| 719 | + | use super::*; | |
| 720 | + | ||
| 721 | + | #[test] | |
| 722 | + | fn options_split_as_a_shell_would() { | |
| 723 | + | assert_eq!( | |
| 724 | + | split_words(r#"--health-cmd "pg_isready -U postgres" --health-interval 10s --health-retries=5"#), | |
| 725 | + | vec!["--health-cmd", "pg_isready -U postgres", "--health-interval", "10s", "--health-retries=5"] | |
| 726 | + | ); | |
| 727 | + | assert_eq!(split_words("--health-cmd 'redis-cli ping' --tmpfs /var/lib/x"), vec!["--health-cmd", "redis-cli ping", "--tmpfs", "/var/lib/x"]); | |
| 728 | + | assert_eq!(split_words(r#"a\ b "c\"d" ''"#), vec!["a b", "c\"d", ""]); | |
| 729 | + | assert!(split_words(" ").is_empty()); | |
| 730 | + | } | |
| 731 | + | ||
| 732 | + | #[test] | |
| 733 | + | fn services_are_read_from_either_form() { | |
| 734 | + | assert_eq!(container_spec(&json!("redis:7")).image, "redis:7"); | |
| 735 | + | let spec = container_spec(&json!({ | |
| 736 | + | "image": "postgres:17", | |
| 737 | + | "env": { "POSTGRES_PASSWORD": "secret", "POSTGRES_DB": "app" }, | |
| 738 | + | "ports": ["5432:5432", 6543], | |
| 739 | + | "volumes": ["/data:/var/lib/postgresql/data"], | |
| 740 | + | "options": "--health-cmd pg_isready --health-interval 10s", | |
| 741 | + | "credentials": { "username": "me", "password": "token" }, | |
| 742 | + | })); | |
| 743 | + | assert_eq!(spec.image, "postgres:17"); | |
| 744 | + | assert_eq!(spec.env["POSTGRES_DB"], "app"); | |
| 745 | + | assert_eq!(spec.ports, vec!["5432:5432", "6543"]); | |
| 746 | + | assert_eq!(spec.options, vec!["--health-cmd", "pg_isready", "--health-interval", "10s"]); | |
| 747 | + | assert_eq!(spec.credentials, Some(("me".into(), "token".into()))); | |
| 748 | + | // An empty image is a service the job leaves out, as on GitHub. | |
| 749 | + | assert_eq!(container_spec(&json!({ "image": "" })).image, ""); | |
| 750 | + | } | |
| 751 | + | ||
| 752 | + | #[test] | |
| 753 | + | fn ports_map_container_to_host() { | |
| 754 | + | let map = port_map(&["5432:5432".into(), "6543:5432/tcp".into(), "6379".into(), "127.0.0.1:8080:80".into(), ":9000".into()]); | |
| 755 | + | assert_eq!(map["5432"], "6543"); | |
| 756 | + | assert_eq!(map["6379"], "6379"); | |
| 757 | + | assert_eq!(map["80"], "8080"); | |
| 758 | + | assert_eq!(map["9000"], "9000"); | |
| 759 | + | } | |
| 760 | + | ||
| 761 | + | #[test] | |
| 762 | + | fn credentials_sign_in_to_the_images_registry() { | |
| 763 | + | assert_eq!(registry_of("ghcr.io/acme/db:1"), "ghcr.io"); | |
| 764 | + | assert_eq!(registry_of("g1t.sh/acme/web"), "g1t.sh"); | |
| 765 | + | assert_eq!(registry_of("localhost:5000/x"), "localhost:5000"); | |
| 766 | + | assert_eq!(registry_of("acme/private"), "https://index.docker.io/v1/"); | |
| 767 | + | assert_eq!(registry_of("postgres"), "https://index.docker.io/v1/"); | |
| 768 | + | } | |
| 769 | + | ||
| 770 | + | #[test] | |
| 771 | + | fn a_service_is_created_with_its_values_passed_by_name() { | |
| 772 | + | let spec = container_spec(&json!({ | |
| 773 | + | "image": "postgres:17", | |
| 774 | + | "env": { "POSTGRES_PASSWORD": "secret" }, | |
| 775 | + | "ports": ["5432:5432"], | |
| 776 | + | "options": "--health-cmd pg_isready", | |
| 777 | + | })); | |
| 778 | + | let args = create_args("postgres_job", Some("g1t_job"), Some("postgres"), &spec, &[], false); | |
| 779 | + | assert_eq!( | |
| 780 | + | args, | |
| 781 | + | vec![ | |
| 782 | + | "create", "--name", "postgres_job", "--label", "g1t-job", "--network", "g1t_job", "--network-alias", "postgres", "-p", "5432:5432", "-e", | |
| 783 | + | "POSTGRES_PASSWORD", "--health-cmd", "pg_isready", "postgres:17" | |
| 784 | + | ] | |
| 785 | + | ); | |
| 786 | + | assert!(!args.iter().any(|a| a.contains("secret"))); | |
| 787 | + | let job = create_args("job_x", Some("g1t_job"), None, &container_spec(&json!("node:24")), &[("/home/runner/work".into(), "/home/runner/work".into())], true); | |
| 788 | + | assert!(job.ends_with(&["--entrypoint".into(), "tail".into(), "node:24".into(), "-f".into(), "/dev/null".into()])); | |
| 789 | + | assert!(job.contains(&"/home/runner/work:/home/runner/work".to_owned())); | |
| 790 | + | } | |
| 791 | + | ||
| 792 | + | #[test] | |
| 793 | + | fn names_are_docker_names() { | |
| 794 | + | assert_eq!(docker_name("Build & test_1a2b"), "build___test_1a2b"); | |
| 795 | + | assert_eq!(docker_name("docker/login-action@v3"), "docker_login-action_v3"); | |
| 796 | + | } | |
| 797 | + | ||
| 798 | + | #[test] | |
| 799 | + | fn the_job_context_carries_containers() { | |
| 800 | + | let mut extra = Map::new(); | |
| 801 | + | extra.insert("services".into(), json!({ "db": { "ports": { "5432": "5432" } } })); | |
| 802 | + | let job = job_context("success", &extra); | |
| 803 | + | assert_eq!(job["status"], "success"); | |
| 804 | + | assert_eq!(job["services"]["db"]["ports"]["5432"], "5432"); | |
| 805 | + | } | |
| 806 | + | } |
| 9 | 9 | //! job's definition, its contexts and its secrets, is fetched with them. | |
| 10 | 10 | ||
| 11 | 11 | mod blobs; | |
| 12 | + | mod containers; | |
| 12 | 13 | mod files; | |
| 13 | 14 | mod paths; | |
| 14 | 15 | mod process; | |
| 15 | 16 | mod report; | |
| 16 | 17 | mod uses; | |
| 17 | 18 | ||
| 18 | − | use std::collections::BTreeMap; | |
| 19 | + | use std::collections::{BTreeMap, BTreeSet}; | |
| 19 | 20 | use std::path::{Path, PathBuf}; | |
| 20 | 21 | use std::process::Command; | |
| 21 | 22 | use std::time::{Duration, Instant}; | |
| ⋯ | |||
| 56 | 57 | pub(crate) enum PostRun { | |
| 57 | 58 | Node { action_dir: PathBuf, script: String }, | |
| 58 | 59 | CacheSave { key: String, paths: Vec<String> }, | |
| 60 | + | /// A Docker action's `post-entrypoint`. | |
| 61 | + | Docker(containers::DockerRun), | |
| 59 | 62 | } | |
| 60 | 63 | ||
| 61 | 64 | pub(crate) struct Job { | |
| ⋯ | |||
| 81 | 84 | /// What the last Node process left, for the step that ran it. | |
| 82 | 85 | pub(crate) last_node_outputs: BTreeMap<String, String>, | |
| 83 | 86 | pub(crate) last_node_state: BTreeMap<String, String>, | |
| 87 | + | /// The names of the sandbox's own variables, which a container does | |
| 88 | + | /// not get. | |
| 89 | + | host_env: BTreeSet<String>, | |
| 90 | + | /// Whether this job has a Docker Engine of its own (g1t's machines). | |
| 91 | + | pub(crate) docker_hosted: bool, | |
| 92 | + | /// The job's network, once its containers have one. | |
| 93 | + | pub(crate) network: Option<String>, | |
| 94 | + | /// `services:`, by their names, and their containers' names. | |
| 95 | + | pub(crate) services: Vec<(String, String)>, | |
| 96 | + | /// `container:`, once started. | |
| 97 | + | pub(crate) container: Option<containers::JobContainer>, | |
| 98 | + | /// The `job` context's `container` and `services`. | |
| 99 | + | pub(crate) job_context: Map<String, Value>, | |
| 100 | + | /// Docker actions' images built in this job. | |
| 101 | + | pub(crate) built_actions: containers::Built, | |
| 84 | 102 | } | |
| 85 | 103 | ||
| 86 | 104 | fn text_map(value: Option<&Value>) -> BTreeMap<String, String> { | |
| ⋯ | |||
| 105 | 123 | self.base_env.get(name).cloned() | |
| 106 | 124 | } | |
| 107 | 125 | ||
| 126 | + | /// What earlier steps added to `PATH`, newest first. | |
| 127 | + | pub(crate) fn path_prepend_entries(&self) -> &[String] { | |
| 128 | + | &self.path_prepend | |
| 129 | + | } | |
| 130 | + | ||
| 108 | 131 | fn status(&self) -> Status { | |
| 109 | 132 | if self.failed { Status::Failure } else { Status::Success } | |
| 110 | 133 | } | |
| ⋯ | |||
| 114 | 137 | let mut contexts = self.contexts.clone(); | |
| 115 | 138 | contexts.insert("env".into(), Value::Object(env.iter().map(|(k, v)| (k.clone(), Value::String(v.clone()))).collect())); | |
| 116 | 139 | contexts.insert("steps".into(), Value::Object(frame.steps.clone())); | |
| 117 | − | contexts.insert("job".into(), json!({ "status": if self.failed { "failure" } else { "success" } })); | |
| 140 | + | contexts.insert("job".into(), containers::job_context(if self.failed { "failure" } else { "success" }, &self.job_context)); | |
| 118 | 141 | if let Some(inputs) = &frame.inputs { | |
| 119 | 142 | contexts.insert("inputs".into(), inputs.clone()); | |
| 120 | 143 | } | |
| ⋯ | |||
| 140 | 163 | ||
| 141 | 164 | /// The `env` context for a step: the workflow's, the job's, what earlier | |
| 142 | 165 | /// steps wrote to `GITHUB_ENV`, and the frame's. | |
| 143 | − | fn env_context(&self, frame: &Frame) -> BTreeMap<String, String> { | |
| 166 | + | pub(crate) fn env_context(&self, frame: &Frame) -> BTreeMap<String, String> { | |
| 144 | 167 | let mut env = self.added_env.clone(); | |
| 145 | 168 | env.extend(self.workflow_env.clone()); | |
| 146 | 169 | env.extend(self.job_env.clone()); | |
| ⋯ | |||
| 228 | 251 | let (program, args, extension): (String, Vec<String>, &str) = match shell { | |
| 229 | 252 | // A self-hosted Windows runner, as GitHub's: PowerShell. | |
| 230 | 253 | None if cfg!(windows) => (windows_powershell(), powershell_args(), "ps1"), | |
| 254 | + | // A job container without bash, as GitHub's runner does. | |
| 255 | + | None if self.container.as_ref().is_some_and(|c| c.shell == "sh") => ("sh".into(), vec!["-e".into(), "{0}".into()], "sh"), | |
| 231 | 256 | None => ("bash".into(), vec!["-e".into(), "{0}".into()], "sh"), | |
| 232 | 257 | Some("bash") => ("bash".into(), vec!["--noprofile".into(), "--norc".into(), "-eo".into(), "pipefail".into(), "{0}".into()], "sh"), | |
| 233 | 258 | Some("sh") => ("sh".into(), vec!["-e".into(), "{0}".into()], "sh"), | |
| ⋯ | |||
| 274 | 299 | Some(dir) => self.workspace.join(dir), | |
| 275 | 300 | None => self.workspace.clone(), | |
| 276 | 301 | }; | |
| 277 | − | let mut command = Command::new(&program); | |
| 278 | − | command.args(&args).current_dir(&dir).env_clear().envs(self.process_env(env, &files)); | |
| 302 | + | let full = self.process_env(env, &files); | |
| 303 | + | let in_container = self.container.as_ref().map(|c| c.path.clone()).and_then(|image_path| { | |
| 304 | + | let inside = self.container_env(env, full.clone(), &image_path); | |
| 305 | + | self.in_container(&program, &args, &dir, inside) | |
| 306 | + | }); | |
| 307 | + | let command = match in_container { | |
| 308 | + | Some(command) => command, | |
| 309 | + | None => { | |
| 310 | + | let mut command = Command::new(&program); | |
| 311 | + | command.args(&args).current_dir(&dir).env_clear().envs(full); | |
| 312 | + | command | |
| 313 | + | } | |
| 314 | + | }; | |
| 279 | 315 | let mut commands = Commands { | |
| 280 | 316 | debug: self.debug, | |
| 281 | 317 | ..Commands::default() | |
| ⋯ | |||
| 481 | 517 | std::fs::create_dir_all(&temp).context("could not make the temporary folder")?; | |
| 482 | 518 | std::fs::write(temp.join("event.json"), serde_json::to_string_pretty(&spec["event"])?)?; | |
| 483 | 519 | ||
| 520 | + | let host_env: BTreeSet<String> = std::env::vars().map(|(name, _)| name).collect(); | |
| 521 | + | // Docker of the job's own, on g1t's machines (crate::docker). | |
| 522 | + | let docker_hosted = cfg!(target_os = "linux") | |
| 523 | + | && std::env::var("G1T_DOCKER").as_deref() == Ok("on") | |
| 524 | + | && spec["variables"]["RUNNER_ENVIRONMENT"].as_str() != Some("self-hosted"); | |
| 484 | 525 | // This process's environment, less what only it should see. | |
| 485 | 526 | let mut base_env: BTreeMap<String, String> = | |
| 486 | 527 | std::env::vars().filter(|(name, _)| !matches!(name.as_str(), "ACTIONS_TOKEN" | "ACTIONS_JOB" | "MODE") && !name.starts_with("G1T_")).collect(); | |
| ⋯ | |||
| 520 | 561 | debug, | |
| 521 | 562 | last_node_outputs: BTreeMap::new(), | |
| 522 | 563 | last_node_state: BTreeMap::new(), | |
| 564 | + | host_env, | |
| 565 | + | docker_hosted, | |
| 566 | + | network: None, | |
| 567 | + | services: Vec::new(), | |
| 568 | + | container: None, | |
| 569 | + | job_context: Map::new(), | |
| 570 | + | built_actions: containers::Built::new(), | |
| 523 | 571 | }; | |
| 524 | 572 | ||
| 525 | 573 | // The workflow's env reads github, secrets, inputs and vars; the job's | |
| ⋯ | |||
| 575 | 623 | { | |
| 576 | 624 | job.log.line(&format!("Matrix: {}", serde_json::to_string(matrix).unwrap_or_default())); | |
| 577 | 625 | } | |
| 626 | + | if job.docker_hosted { | |
| 627 | + | let registry = job.contexts["github"]["server_url"].as_str().and_then(crate::docker::engine::registry_host); | |
| 628 | + | let token = job.contexts.get("secrets").and_then(|s| s.get("G1T_TOKEN")).map(expr::to_text).filter(|t| !t.is_empty()); | |
| 629 | + | let options = crate::docker::engine::Options { registry: registry.zip(token) }; | |
| 630 | + | if let Err(problem) = crate::docker::engine::enable(options) { | |
| 631 | + | job.log.line(&format!("##[warning]Docker is not available in this job: {problem}")); | |
| 632 | + | job.docker_hosted = false; | |
| 633 | + | } | |
| 634 | + | } | |
| 635 | + | let containers_started = job.start_containers(); | |
| 578 | 636 | job.log.flush(); | |
| 579 | 637 | ||
| 580 | 638 | let mut frame = Frame::default(); | |
| 581 | − | for (index, step) in steps.iter().enumerate() { | |
| 639 | + | if !containers_started { | |
| 640 | + | job.failed = true; | |
| 641 | + | for (index, name) in job.step_names.clone().iter().enumerate() { | |
| 642 | + | job.log.step_state(index as u32 + 1, name, "completed", Some("skipped")); | |
| 643 | + | } | |
| 644 | + | } | |
| 645 | + | for (index, step) in steps.iter().enumerate().filter(|_| containers_started) { | |
| 582 | 646 | job.step(&mut frame, step, index as u32 + 1, true, &defaults); | |
| 647 | + | job.log_docker_notes(); | |
| 583 | 648 | if job.remaining().is_zero() { | |
| 584 | 649 | job.log.line("##[error]The job ran past its time limit."); | |
| 585 | 650 | job.failed = true; | |
| ⋯ | |||
| 604 | 669 | let ok = match &post.run { | |
| 605 | 670 | PostRun::Node { action_dir, script } => job.run_node(action_dir, script, &post.env), | |
| 606 | 671 | PostRun::CacheSave { key, paths } => job.cache_save(key, paths), | |
| 672 | + | PostRun::Docker(run) => job.run_docker(run).0, | |
| 607 | 673 | }; | |
| 608 | 674 | job.log.step_state(number, &post.name, "completed", Some(if ok { "success" } else { "failure" })); | |
| 609 | 675 | if !ok { | |
| ⋯ | |||
| 611 | 677 | } | |
| 612 | 678 | } | |
| 613 | 679 | ||
| 680 | + | // GitHub's "Stop containers": services' logs, and everything removed. | |
| 681 | + | if job.has_containers() { | |
| 682 | + | let number = job.step_names.len() as u32 + 1; | |
| 683 | + | job.step_names.push("Stop containers".into()); | |
| 684 | + | job.report_steps(); | |
| 685 | + | job.log.step(number); | |
| 686 | + | job.log.step_state(number, "Stop containers", "in_progress", None); | |
| 687 | + | job.stop_containers(); | |
| 688 | + | job.log.step_state(number, "Stop containers", "completed", Some("success")); | |
| 689 | + | } | |
| 690 | + | ||
| 614 | 691 | // The job's outputs, read now that every step has run. | |
| 615 | 692 | let env = job.env_context(&frame); | |
| 616 | 693 | let contexts = job.contexts_for(&frame, &env); | |
| 165 | 165 | log.line(&shown); | |
| 166 | 166 | } | |
| 167 | 167 | } | |
| 168 | − | Err(mpsc::RecvTimeoutError::Timeout) => log.tick(), | |
| 168 | + | Err(mpsc::RecvTimeoutError::Timeout) => { | |
| 169 | + | // The job's Docker Engine starting, from its own thread. | |
| 170 | + | for note in crate::docker::take_notes() { | |
| 171 | + | log.line(¬e); | |
| 172 | + | } | |
| 173 | + | log.tick(); | |
| 174 | + | } | |
| 169 | 175 | Err(mpsc::RecvTimeoutError::Disconnected) => break, | |
| 170 | 176 | } | |
| 171 | 177 | if Instant::now() >= deadline { |
| 1 | 1 | //! `uses:` steps: `actions/checkout` done natively against g1t, actions | |
| 2 | − | //! fetched from GitHub and run as they are (JavaScript and composite), and | |
| 3 | − | //! a few of GitHub's own whose services g1t does not have yet. | |
| 2 | + | //! fetched from GitHub and run as they are (JavaScript, composite and | |
| 3 | + | //! Docker), `docker://` images, and a few of GitHub's own whose services | |
| 4 | + | //! g1t does not have yet. | |
| 4 | 5 | ||
| 5 | 6 | use std::collections::BTreeMap; | |
| 6 | 7 | use std::path::{Path, PathBuf}; | |
| ⋯ | |||
| 13 | 14 | use g1t_actions::workflow::yaml_to_json; | |
| 14 | 15 | use serde_json::{Map, Value, json}; | |
| 15 | 16 | ||
| 17 | + | use super::containers::{self, DockerRun}; | |
| 16 | 18 | use super::files::StepFiles; | |
| 17 | 19 | use super::process::{self, Commands, Ended}; | |
| 18 | 20 | use super::{Frame, Job, Post, PostRun}; | |
| ⋯ | |||
| 201 | 203 | pub(crate) fn run_node(&mut self, action_dir: &Path, script: &str, env: &BTreeMap<String, String>) -> bool { | |
| 202 | 204 | let id = format!("node{}", super::rand_id()); | |
| 203 | 205 | let Ok(files) = StepFiles::new(&self.temp, &id) else { return false }; | |
| 204 | − | let mut command = Command::new("node"); | |
| 205 | − | command.arg(action_dir.join(script)).current_dir(&self.workspace).env_clear().envs(self.process_env(env, &files)); | |
| 206 | + | let full = self.process_env(env, &files); | |
| 207 | + | let script_path = action_dir.join(script); | |
| 208 | + | // In a job container whose image runs the runner's Node, the action | |
| 209 | + | // runs there, as on GitHub. | |
| 210 | + | let in_container = self.container.as_ref().filter(|c| c.node).map(|c| c.path.clone()).and_then(|image_path| { | |
| 211 | + | let inside = self.container_env(env, full.clone(), &image_path); | |
| 212 | + | self.in_container(containers::CONTAINER_NODE, &[script_path.display().to_string()], &self.workspace, inside) | |
| 213 | + | }); | |
| 214 | + | let command = match in_container { | |
| 215 | + | Some(command) => command, | |
| 216 | + | None => { | |
| 217 | + | let mut command = Command::new("node"); | |
| 218 | + | command.arg(&script_path).current_dir(&self.workspace).env_clear().envs(full); | |
| 219 | + | command | |
| 220 | + | } | |
| 221 | + | }; | |
| 206 | 222 | let mut commands = Commands { | |
| 207 | 223 | debug: false, | |
| 208 | 224 | ..Commands::default() | |
| ⋯ | |||
| 237 | 253 | _timeout: Duration, | |
| 238 | 254 | ) -> (bool, BTreeMap<String, String>) { | |
| 239 | 255 | let uses = uses.trim(); | |
| 240 | − | if uses.starts_with("docker://") { | |
| 241 | − | self.log.line(&format!("##[error]`{uses}`: Docker actions do not run on g1t yet.")); | |
| 242 | − | return (false, BTreeMap::new()); | |
| 256 | + | if let Some(image) = uses.strip_prefix("docker://") { | |
| 257 | + | // `with.args` and `with.entrypoint` are the container's; every | |
| 258 | + | // input is also an `INPUT_` variable, as on GitHub. | |
| 259 | + | let mut step_env = env.clone(); | |
| 260 | + | for (input, value) in with { | |
| 261 | + | step_env.insert(format!("INPUT_{}", input.replace(' ', "_").to_ascii_uppercase()), value.clone()); | |
| 262 | + | } | |
| 263 | + | let run = DockerRun { | |
| 264 | + | image: image.to_owned(), | |
| 265 | + | entrypoint: with.get("entrypoint").filter(|e| !e.is_empty()).cloned(), | |
| 266 | + | args: with.get("args").map(|a| containers::split_words(a)).unwrap_or_default(), | |
| 267 | + | env: step_env, | |
| 268 | + | }; | |
| 269 | + | let (ok, outputs, _) = self.run_docker(&run); | |
| 270 | + | return (ok, outputs); | |
| 243 | 271 | } | |
| 244 | 272 | let (name, git_ref) = uses.split_once('@').unwrap_or((uses, "")); | |
| 245 | 273 | let lower = name.to_ascii_lowercase(); | |
| 274 | + | if lower == "docker/setup-buildx-action" && self.docker_hosted { | |
| 275 | + | return self.setup_buildx(with); | |
| 276 | + | } | |
| 246 | 277 | match lower.as_str() { | |
| 247 | 278 | "actions/checkout" => return self.checkout(with), | |
| 248 | 279 | "actions/upload-artifact" => return self.upload_artifact(with), | |
| ⋯ | |||
| 396 | 427 | return (ok, outputs); | |
| 397 | 428 | } | |
| 398 | 429 | if using == "docker" { | |
| 399 | − | self.log.line(&format!("##[error]`{uses}` is a Docker action, which does not run on g1t yet.")); | |
| 400 | − | return (false, BTreeMap::new()); | |
| 430 | + | return self.docker_action(uses, &dir, &runs, &inputs, &step_env, frame, title); | |
| 401 | 431 | } | |
| 402 | 432 | self.log.line(&format!("##[error]`{uses}` runs with `{using}`, which g1t does not know.")); | |
| 403 | 433 | (false, BTreeMap::new()) | |
| 404 | 434 | } | |
| 435 | + | ||
| 436 | + | /// A Docker action: its image built from its Dockerfile (or pulled, | |
| 437 | + | /// for `docker://`), then run with its `args`, `entrypoint` and `env`, | |
| 438 | + | /// its inputs as `INPUT_` variables, and `pre-entrypoint` and | |
| 439 | + | /// `post-entrypoint` around it. | |
| 440 | + | #[allow(clippy::too_many_arguments)] | |
| 441 | + | fn docker_action( | |
| 442 | + | &mut self, | |
| 443 | + | uses: &str, | |
| 444 | + | dir: &Path, | |
| 445 | + | runs: &Value, | |
| 446 | + | inputs: &BTreeMap<String, String>, | |
| 447 | + | step_env: &BTreeMap<String, String>, | |
| 448 | + | frame: &Frame, | |
| 449 | + | title: &str, | |
| 450 | + | ) -> (bool, BTreeMap<String, String>) { | |
| 451 | + | let image = runs.get("image").map(expr::to_text).unwrap_or_default(); | |
| 452 | + | let image = if let Some(pulled) = image.strip_prefix("docker://") { | |
| 453 | + | pulled.to_owned() | |
| 454 | + | } else if image.is_empty() { | |
| 455 | + | self.log.line(&format!("##[error]`{uses}` has no `runs.image`.")); | |
| 456 | + | return (false, BTreeMap::new()); | |
| 457 | + | } else { | |
| 458 | + | match self.build_action_image(dir, &image, uses) { | |
| 459 | + | Some(tag) => tag, | |
| 460 | + | None => return (false, BTreeMap::new()), | |
| 461 | + | } | |
| 462 | + | }; | |
| 463 | + | // `args` and `env` read with the action's own inputs. | |
| 464 | + | let mut env = step_env.clone(); | |
| 465 | + | for (input, value) in inputs { | |
| 466 | + | env.insert(format!("INPUT_{}", input.replace(' ', "_").to_ascii_uppercase()), value.clone()); | |
| 467 | + | } | |
| 468 | + | let mut scope_frame = frame.clone(); | |
| 469 | + | scope_frame.inputs = Some(Value::Object(inputs.iter().map(|(k, v)| (k.clone(), json!(v))).collect())); | |
| 470 | + | let contexts = self.contexts_for(&scope_frame, &env); | |
| 471 | + | if let Some(Value::Object(own)) = runs.get("env") { | |
| 472 | + | for (name, value) in own { | |
| 473 | + | let value = self.with_scope(&contexts, |scope| expr::interpolate_value(value, scope)).unwrap_or(Value::Null); | |
| 474 | + | env.insert(name.clone(), expr::to_text(&value)); | |
| 475 | + | } | |
| 476 | + | } | |
| 477 | + | let args: Vec<String> = match runs.get("args") { | |
| 478 | + | Some(Value::Array(items)) => items | |
| 479 | + | .iter() | |
| 480 | + | .map(|item| { | |
| 481 | + | let value = self.with_scope(&contexts, |scope| expr::interpolate_value(item, scope)).unwrap_or(Value::Null); | |
| 482 | + | expr::to_text(&value) | |
| 483 | + | }) | |
| 484 | + | .collect(), | |
| 485 | + | _ => Vec::new(), | |
| 486 | + | }; | |
| 487 | + | let entry = |key: &str| runs.get(key).map(expr::to_text).filter(|e| !e.is_empty()); | |
| 488 | + | if let Some(pre) = entry("pre-entrypoint") { | |
| 489 | + | let run = DockerRun { image: image.clone(), entrypoint: Some(pre), args: Vec::new(), env: env.clone() }; | |
| 490 | + | if !self.run_docker(&run).0 { | |
| 491 | + | return (false, BTreeMap::new()); | |
| 492 | + | } | |
| 493 | + | } | |
| 494 | + | let run = DockerRun { image: image.clone(), entrypoint: entry("entrypoint"), args, env: env.clone() }; | |
| 495 | + | let (ok, outputs, state) = self.run_docker(&run); | |
| 496 | + | if let Some(post) = entry("post-entrypoint") { | |
| 497 | + | let mut post_env = env; | |
| 498 | + | for (name, value) in state { | |
| 499 | + | post_env.insert(format!("STATE_{name}"), value); | |
| 500 | + | } | |
| 501 | + | self.posts.push(Post { | |
| 502 | + | name: format!("Post {title}"), | |
| 503 | + | condition: runs.get("post-if").map(expr::to_text).unwrap_or_else(|| "always()".into()), | |
| 504 | + | env: BTreeMap::new(), | |
| 505 | + | run: PostRun::Docker(DockerRun { image, entrypoint: Some(post), args: Vec::new(), env: post_env }), | |
| 506 | + | }); | |
| 507 | + | } | |
| 508 | + | (ok, outputs) | |
| 509 | + | } | |
| 405 | 510 | } | |
| 1 | + | //! The Engine API as a job sees it: `/var/run/docker.sock` is this proxy, | |
| 2 | + | //! and the job's own Docker Engine is behind it. It passes everything | |
| 3 | + | //! through, and changes the few requests that would not work in a | |
| 4 | + | //! sandbox as they are: | |
| 5 | + | //! | |
| 6 | + | //! - **A container's network.** A sandbox cannot route a container's | |
| 7 | + | //! bridge network out (Cloudflare Containers allow no iptables and no IP | |
| 8 | + | //! forwarding), so containers join the job's own network (`host`), the | |
| 9 | + | //! one its guardrails apply to. The names a container would have had on | |
| 10 | + | //! its network (its name, its aliases, its Compose service) resolve to | |
| 11 | + | //! 127.0.0.1 in later containers and in the job's own steps, and a port | |
| 12 | + | //! published under another number (`-p 8080:80`) is forwarded to it. | |
| 13 | + | //! `docker inspect` reports the ports as published, for tools that look | |
| 14 | + | //! a container's port up. | |
| 15 | + | //! - **Networks joined later** (`docker network connect`): the container | |
| 16 | + | //! already has the job's network, so the request only adds its aliases. | |
| 17 | + | //! - **The classic builder** (`DOCKER_BUILDKIT=0`): its `RUN` steps use the | |
| 18 | + | //! job's network too. BuildKit's are handled where they start (oci.rs). | |
| 19 | + | //! - **Miners**: a container whose image or command names one is refused, | |
| 20 | + | //! as a step's script would be. | |
| 21 | + | ||
| 22 | + | use std::collections::{BTreeMap, BTreeSet}; | |
| 23 | + | use std::io::{self, BufReader, Read, Write}; | |
| 24 | + | use std::sync::mpsc; | |
| 25 | + | use std::sync::{Arc, Mutex}; | |
| 26 | + | ||
| 27 | + | use serde_json::{Map, Value, json}; | |
| 28 | + | ||
| 29 | + | use super::http; | |
| 30 | + | ||
| 31 | + | /// What the proxy remembers across connections. | |
| 32 | + | #[derive(Default)] | |
| 33 | + | pub(crate) struct State { | |
| 34 | + | /// Every name a container has been given, which all now mean 127.0.0.1. | |
| 35 | + | pub(crate) aliases: BTreeSet<String>, | |
| 36 | + | /// Containers moved to the job's network: by id and by name, the ports | |
| 37 | + | /// they publish (`80/tcp` → the host port, as text). | |
| 38 | + | pub(crate) published: BTreeMap<String, BTreeMap<String, String>>, | |
| 39 | + | } | |
| 40 | + | ||
| 41 | + | /// What the proxy asks of the sandbox around it. | |
| 42 | + | pub(crate) trait Host: Send + Sync { | |
| 43 | + | /// Makes names resolve to 127.0.0.1 in the job's own steps. | |
| 44 | + | fn add_hosts(&self, names: &[String]); | |
| 45 | + | /// Forwards a host port to a container's port on the job's network. | |
| 46 | + | fn forward(&self, host_port: u16, container_port: u16); | |
| 47 | + | /// Makes sure the Engine is running; why not, if it cannot be. | |
| 48 | + | fn ensure_engine(&self) -> Result<(), String>; | |
| 49 | + | /// Connects to the Engine itself. | |
| 50 | + | fn connect(&self) -> io::Result<Box<dyn Duplex>>; | |
| 51 | + | } | |
| 52 | + | ||
| 53 | + | /// A connection, readable and writable from two threads. | |
| 54 | + | pub(crate) trait Duplex: Read + Write + Send { | |
| 55 | + | fn try_clone_box(&self) -> io::Result<Box<dyn Duplex>>; | |
| 56 | + | fn shutdown_both(&self); | |
| 57 | + | /// Ends what this side sends, and goes on reading. | |
| 58 | + | fn shutdown_write(&self); | |
| 59 | + | } | |
| 60 | + | ||
| 61 | + | impl Duplex for std::net::TcpStream { | |
| 62 | + | fn try_clone_box(&self) -> io::Result<Box<dyn Duplex>> { | |
| 63 | + | Ok(Box::new(self.try_clone()?)) | |
| 64 | + | } | |
| 65 | + | fn shutdown_both(&self) { | |
| 66 | + | let _ = self.shutdown(std::net::Shutdown::Both); | |
| 67 | + | } | |
| 68 | + | fn shutdown_write(&self) { | |
| 69 | + | let _ = self.shutdown(std::net::Shutdown::Write); | |
| 70 | + | } | |
| 71 | + | } | |
| 72 | + | ||
| 73 | + | #[cfg(unix)] | |
| 74 | + | impl Duplex for std::os::unix::net::UnixStream { | |
| 75 | + | fn try_clone_box(&self) -> io::Result<Box<dyn Duplex>> { | |
| 76 | + | Ok(Box::new(self.try_clone()?)) | |
| 77 | + | } | |
| 78 | + | fn shutdown_both(&self) { | |
| 79 | + | let _ = self.shutdown(std::net::Shutdown::Both); | |
| 80 | + | } | |
| 81 | + | fn shutdown_write(&self) { | |
| 82 | + | let _ = self.shutdown(std::net::Shutdown::Write); | |
| 83 | + | } | |
| 84 | + | } | |
| 85 | + | ||
| 86 | + | /// The Engine's routes start with an optional `/v1.NN`. | |
| 87 | + | fn route(target: &str) -> (&str, &str) { | |
| 88 | + | let (path, query) = target.split_once('?').unwrap_or((target, "")); | |
| 89 | + | let path = match path.strip_prefix("/v") { | |
| 90 | + | Some(rest) if rest.starts_with(|c: char| c.is_ascii_digit()) => rest.find('/').map_or(path, |slash| &rest[slash..]), | |
| 91 | + | _ => path, | |
| 92 | + | }; | |
| 93 | + | (path, query) | |
| 94 | + | } | |
| 95 | + | ||
| 96 | + | fn query_value<'a>(query: &'a str, name: &str) -> Option<&'a str> { | |
| 97 | + | query.split('&').find_map(|pair| pair.split_once('=').filter(|(key, _)| *key == name).map(|(_, value)| value)) | |
| 98 | + | } | |
| 99 | + | ||
| 100 | + | fn decode(text: &str) -> String { | |
| 101 | + | let bytes = text.as_bytes(); | |
| 102 | + | let mut out = Vec::with_capacity(bytes.len()); | |
| 103 | + | let mut i = 0; | |
| 104 | + | while i < bytes.len() { | |
| 105 | + | let hex = |b: u8| (b as char).to_digit(16); | |
| 106 | + | match bytes[i] { | |
| 107 | + | b'%' if i + 2 < bytes.len() && hex(bytes[i + 1]).is_some() && hex(bytes[i + 2]).is_some() => { | |
| 108 | + | out.push((hex(bytes[i + 1]).unwrap_or(0) * 16 + hex(bytes[i + 2]).unwrap_or(0)) as u8); | |
| 109 | + | i += 3; | |
| 110 | + | continue; | |
| 111 | + | } | |
| 112 | + | b'+' => out.push(b' '), | |
| 113 | + | byte => out.push(byte), | |
| 114 | + | } | |
| 115 | + | i += 1; | |
| 116 | + | } | |
| 117 | + | String::from_utf8_lossy(&out).into_owned() | |
| 118 | + | } | |
| 119 | + | ||
| 120 | + | /// What a request is, to the proxy. | |
| 121 | + | #[derive(Debug, PartialEq)] | |
| 122 | + | pub(crate) enum Kind { | |
| 123 | + | CreateContainer { name: Option<String> }, | |
| 124 | + | InspectContainer { id: String }, | |
| 125 | + | ConnectNetwork, | |
| 126 | + | DisconnectNetwork, | |
| 127 | + | ClassicBuild, | |
| 128 | + | Other, | |
| 129 | + | } | |
| 130 | + | ||
| 131 | + | pub(crate) fn classify(method: &str, target: &str) -> Kind { | |
| 132 | + | let (path, query) = route(target); | |
| 133 | + | let parts: Vec<&str> = path.trim_matches('/').split('/').collect(); | |
| 134 | + | match (method, parts.as_slice()) { | |
| 135 | + | ("POST", ["containers", "create"]) => Kind::CreateContainer { name: query_value(query, "name").map(decode).filter(|n| !n.is_empty()) }, | |
| 136 | + | ("GET", ["containers", id, "json"]) => Kind::InspectContainer { id: decode(id) }, | |
| 137 | + | ("POST", ["networks", _, "connect"]) => Kind::ConnectNetwork, | |
| 138 | + | ("POST", ["networks", _, "disconnect"]) => Kind::DisconnectNetwork, | |
| 139 | + | ("POST", ["build"]) => Kind::ClassicBuild, | |
| 140 | + | _ => Kind::Other, | |
| 141 | + | } | |
| 142 | + | } | |
| 143 | + | ||
| 144 | + | /// Whether a network mode is one a sandbox can run as it is. | |
| 145 | + | fn keeps_network(mode: &str) -> bool { | |
| 146 | + | matches!(mode, "host" | "none") || mode.starts_with("container:") | |
| 147 | + | } | |
| 148 | + | ||
| 149 | + | /// Whether a name can stand in `/etc/hosts`. | |
| 150 | + | fn host_name(name: &str) -> bool { | |
| 151 | + | !name.is_empty() | |
| 152 | + | && name.len() <= 253 | |
| 153 | + | && name != "localhost" | |
| 154 | + | && name.chars().all(|c| c.is_ascii_alphanumeric() || matches!(c, '-' | '.' | '_')) | |
| 155 | + | && !name.starts_with(['-', '.']) | |
| 156 | + | } | |
| 157 | + | ||
| 158 | + | /// What changing a container's create request came to. | |
| 159 | + | #[derive(Debug, Default, PartialEq)] | |
| 160 | + | pub(crate) struct Created { | |
| 161 | + | /// The network it asked for, now the job's. | |
| 162 | + | pub(crate) moved_from: Option<String>, | |
| 163 | + | /// Its own names, now meaning 127.0.0.1. | |
| 164 | + | pub(crate) aliases: Vec<String>, | |
| 165 | + | /// `(host port, container port)` pairs to forward. | |
| 166 | + | pub(crate) forwards: Vec<(u16, u16)>, | |
| 167 | + | /// What `docker inspect` should say it publishes. | |
| 168 | + | pub(crate) published: BTreeMap<String, String>, | |
| 169 | + | } | |
| 170 | + | ||
| 171 | + | fn strings(value: Option<&Value>) -> Vec<String> { | |
| 172 | + | value.and_then(Value::as_array).map(|items| items.iter().filter_map(|v| v.as_str().map(str::to_owned)).collect()).unwrap_or_default() | |
| 173 | + | } | |
| 174 | + | ||
| 175 | + | /// Moves a container to the job's network, unless it asked for `host`, | |
| 176 | + | /// `none` or another container's. `known` is every name given so far. | |
| 177 | + | pub(crate) fn rewrite_create(body: &mut Value, name: Option<&str>, known: &BTreeSet<String>) -> Created { | |
| 178 | + | let mut created = Created::default(); | |
| 179 | + | let Some(config) = body.as_object_mut() else { return created }; | |
| 180 | + | let host_config = config.entry("HostConfig").or_insert_with(|| json!({})); | |
| 181 | + | if !host_config.is_object() { | |
| 182 | + | *host_config = json!({}); | |
| 183 | + | } | |
| 184 | + | let mode = host_config.get("NetworkMode").and_then(Value::as_str).unwrap_or_default().to_owned(); | |
| 185 | + | if keeps_network(&mode) { | |
| 186 | + | return created; | |
| 187 | + | } | |
| 188 | + | created.moved_from = Some(if mode.is_empty() || mode == "default" { "bridge".to_owned() } else { mode.clone() }); | |
| 189 | + | ||
| 190 | + | // Its names: its own, its aliases on each network, its links' aliases | |
| 191 | + | // and its Compose service. | |
| 192 | + | let mut aliases: Vec<String> = Vec::new(); | |
| 193 | + | if let Some(name) = name { | |
| 194 | + | aliases.push(name.trim_start_matches('/').to_owned()); | |
| 195 | + | } | |
| 196 | + | if let Some(Value::Object(endpoints)) = config.get("NetworkingConfig").and_then(|n| n.get("EndpointsConfig")) { | |
| 197 | + | for endpoint in endpoints.values() { | |
| 198 | + | aliases.extend(strings(endpoint.get("Aliases"))); | |
| 199 | + | aliases.extend(strings(endpoint.get("DNSNames"))); | |
| 200 | + | } | |
| 201 | + | } | |
| 202 | + | if let Some(service) = config.get("Labels").and_then(|l| l.get("com.docker.compose.service")).and_then(Value::as_str) { | |
| 203 | + | aliases.push(service.to_owned()); | |
| 204 | + | } | |
| 205 | + | let host_config = config.get_mut("HostConfig").and_then(Value::as_object_mut).expect("made above"); | |
| 206 | + | for link in strings(host_config.get("Links")) { | |
| 207 | + | // `name:alias`, or `/name:/container/alias` as the Engine stores them. | |
| 208 | + | if let Some((_, alias)) = link.split_once(':') { | |
| 209 | + | aliases.push(alias.rsplit('/').next().unwrap_or(alias).to_owned()); | |
| 210 | + | } | |
| 211 | + | } | |
| 212 | + | let mut seen = BTreeSet::new(); | |
| 213 | + | aliases.retain(|alias| host_name(alias) && seen.insert(alias.clone())); | |
| 214 | + | created.aliases = aliases.clone(); | |
| 215 | + | ||
| 216 | + | // Ports: published under the same number, they need nothing; under | |
| 217 | + | // another, a forward. A port left to the Engine to choose is the | |
| 218 | + | // container's own. | |
| 219 | + | if let Some(Value::Object(bindings)) = host_config.get("PortBindings") { | |
| 220 | + | for (port, hosts) in bindings { | |
| 221 | + | let (number, protocol) = port.split_once('/').unwrap_or((port, "tcp")); | |
| 222 | + | let Ok(container_port) = number.parse::<u16>() else { continue }; | |
| 223 | + | let host_port = hosts | |
| 224 | + | .as_array() | |
| 225 | + | .and_then(|list| list.iter().find_map(|h| h.get("HostPort").and_then(Value::as_str).filter(|p| !p.is_empty()))) | |
| 226 | + | .and_then(|p| p.parse::<u16>().ok()) | |
| 227 | + | .unwrap_or(container_port); | |
| 228 | + | created.published.insert(format!("{container_port}/{protocol}"), host_port.to_string()); | |
| 229 | + | if host_port != container_port && protocol == "tcp" { | |
| 230 | + | created.forwards.push((host_port, container_port)); | |
| 231 | + | } | |
| 232 | + | } | |
| 233 | + | } | |
| 234 | + | ||
| 235 | + | host_config.insert("NetworkMode".into(), json!("host")); | |
| 236 | + | host_config.remove("Links"); | |
| 237 | + | host_config.insert("PublishAllPorts".into(), json!(false)); | |
| 238 | + | let mut extra = strings(host_config.get("ExtraHosts")); | |
| 239 | + | for alias in known.iter().chain(aliases.iter()) { | |
| 240 | + | let entry = format!("{alias}:127.0.0.1"); | |
| 241 | + | if !extra.iter().any(|e| e.split(':').next() == Some(alias.as_str())) { | |
| 242 | + | extra.push(entry); | |
| 243 | + | } | |
| 244 | + | } | |
| 245 | + | host_config.insert("ExtraHosts".into(), json!(extra)); | |
| 246 | + | config.remove("NetworkingConfig"); | |
| 247 | + | config.remove("MacAddress"); | |
| 248 | + | created | |
| 249 | + | } | |
| 250 | + | ||
| 251 | + | /// What `docker network connect` adds: the container's aliases there. | |
| 252 | + | pub(crate) fn connect_aliases(body: &Value) -> Vec<String> { | |
| 253 | + | let mut aliases = strings(body.get("EndpointConfig").and_then(|e| e.get("Aliases"))); | |
| 254 | + | aliases.extend(strings(body.get("EndpointConfig").and_then(|e| e.get("DNSNames")))); | |
| 255 | + | aliases.retain(|alias| host_name(alias)); | |
| 256 | + | aliases | |
| 257 | + | } | |
| 258 | + | ||
| 259 | + | /// A container's inspection, with the ports it publishes filled in. | |
| 260 | + | pub(crate) fn rewrite_inspect(body: &mut Value, published: &BTreeMap<String, String>) { | |
| 261 | + | let ports: Map<String, Value> = published | |
| 262 | + | .iter() | |
| 263 | + | .map(|(port, host)| (port.clone(), json!([{ "HostIp": "0.0.0.0", "HostPort": host }]))) | |
| 264 | + | .collect(); | |
| 265 | + | if let Some(settings) = body.get_mut("NetworkSettings").and_then(Value::as_object_mut) { | |
| 266 | + | settings.insert("Ports".into(), Value::Object(ports)); | |
| 267 | + | } | |
| 268 | + | } | |
| 269 | + | ||
| 270 | + | /// The classic builder's `RUN` steps on the job's network. | |
| 271 | + | pub(crate) fn rewrite_build(target: &str) -> String { | |
| 272 | + | let (path, query) = target.split_once('?').unwrap_or((target, "")); | |
| 273 | + | let mode = query_value(query, "networkmode").unwrap_or_default(); | |
| 274 | + | if keeps_network(mode) { | |
| 275 | + | return target.to_owned(); | |
| 276 | + | } | |
| 277 | + | let mut pairs: Vec<&str> = query.split('&').filter(|pair| !pair.is_empty() && !pair.starts_with("networkmode=")).collect(); | |
| 278 | + | pairs.push("networkmode=host"); | |
| 279 | + | format!("{path}?{}", pairs.join("&")) | |
| 280 | + | } | |
| 281 | + | ||
| 282 | + | /// What the response thread is to do with the next response. | |
| 283 | + | enum Pending { | |
| 284 | + | /// Pass it through. | |
| 285 | + | Plain { method: String }, | |
| 286 | + | /// A container was created: note its id against its ports. | |
| 287 | + | Created { name: Option<String>, published: BTreeMap<String, String> }, | |
| 288 | + | /// Fill in an inspection's ports. | |
| 289 | + | Inspect { published: BTreeMap<String, String> }, | |
| 290 | + | /// Answer it here: the Engine was never asked. | |
| 291 | + | Answer(Vec<u8>), | |
| 292 | + | /// The connection leaves HTTP after this response. | |
| 293 | + | Upgrade, | |
| 294 | + | } | |
| 295 | + | ||
| 296 | + | /// Serves one connection from a client, until either side closes it. | |
| 297 | + | pub(crate) fn serve(client: Box<dyn Duplex>, host: Arc<dyn Host>, state: Arc<Mutex<State>>) { | |
| 298 | + | if let Err(problem) = host.ensure_engine() { | |
| 299 | + | let mut client = client; | |
| 300 | + | let mut reader = BufReader::new(client.try_clone_box().expect("a clone")); | |
| 301 | + | // Answer whatever was asked, so the CLI says why. | |
| 302 | + | if let Ok(Some(head)) = http::read_head(&mut reader) { | |
| 303 | + | let _ = http::read_body(&mut reader, http::request_body(&head)); | |
| 304 | + | let _ = client.write_all(&http::json_response(503, "Service Unavailable", &json!({ "message": problem }))); | |
| 305 | + | } | |
| 306 | + | client.shutdown_both(); | |
| 307 | + | return; | |
| 308 | + | } | |
| 309 | + | let upstream = match host.connect() { | |
| 310 | + | Ok(upstream) => upstream, | |
| 311 | + | Err(_) => { | |
| 312 | + | client.shutdown_both(); | |
| 313 | + | return; | |
| 314 | + | } | |
| 315 | + | }; | |
| 316 | + | let (Ok(client_writer), Ok(upstream_reader)) = (client.try_clone_box(), upstream.try_clone_box()) else { return }; | |
| 317 | + | let (sender, pending) = mpsc::channel::<Pending>(); | |
| 318 | + | let responses = { | |
| 319 | + | let state = state.clone(); | |
| 320 | + | std::thread::spawn(move || answer(upstream_reader, client_writer, pending, state)) | |
| 321 | + | }; | |
| 322 | + | let _ = forward(client, upstream, sender, &host, &state); | |
| 323 | + | let _ = responses.join(); | |
| 324 | + | } | |
| 325 | + | ||
| 326 | + | /// Requests, client to Engine. | |
| 327 | + | fn forward(client: Box<dyn Duplex>, mut upstream: Box<dyn Duplex>, pending: mpsc::Sender<Pending>, host: &Arc<dyn Host>, state: &Arc<Mutex<State>>) -> io::Result<()> { | |
| 328 | + | let closer = client.try_clone_box()?; | |
| 329 | + | let upstream_closer = upstream.try_clone_box()?; | |
| 330 | + | let mut reader = BufReader::new(client); | |
| 331 | + | let result = (|| -> io::Result<()> { | |
| 332 | + | loop { | |
| 333 | + | let Some(mut head) = http::read_head(&mut reader)? else { return Ok(()) }; | |
| 334 | + | let body = http::request_body(&head); | |
| 335 | + | let method = head.method().to_owned(); | |
| 336 | + | match classify(&method, head.target()) { | |
| 337 | + | Kind::CreateContainer { name } => { | |
| 338 | + | let raw = http::read_body(&mut reader, body)?; | |
| 339 | + | let Ok(mut config) = serde_json::from_slice::<Value>(&raw) else { | |
| 340 | + | upstream.write_all(&http::with_length(head, &raw))?; | |
| 341 | + | let _ = pending.send(Pending::Plain { method }); | |
| 342 | + | continue; | |
| 343 | + | }; | |
| 344 | + | if let Some(miner) = miner_in_config(&config) { | |
| 345 | + | let refusal = json!({ "message": format!("g1t does not run cryptocurrency miners ({miner}). This container was not created.") }); | |
| 346 | + | let _ = pending.send(Pending::Answer(http::json_response(403, "Forbidden", &refusal))); | |
| 347 | + | continue; | |
| 348 | + | } | |
| 349 | + | let known = state.lock().map(|s| s.aliases.clone()).unwrap_or_default(); | |
| 350 | + | let created = rewrite_create(&mut config, name.as_deref(), &known); | |
| 351 | + | if created.moved_from.is_some() { | |
| 352 | + | let fresh: Vec<String> = created.aliases.iter().filter(|a| !known.contains(*a)).cloned().collect(); | |
| 353 | + | if let Ok(mut state) = state.lock() { | |
| 354 | + | state.aliases.extend(created.aliases.iter().cloned()); | |
| 355 | + | // By name now; by id once the Engine says it. | |
| 356 | + | if let Some(name) = &name { | |
| 357 | + | state.published.insert(name.trim_start_matches('/').to_owned(), created.published.clone()); | |
| 358 | + | } | |
| 359 | + | } | |
| 360 | + | if !fresh.is_empty() { | |
| 361 | + | host.add_hosts(&fresh); | |
| 362 | + | } | |
| 363 | + | for (host_port, container_port) in &created.forwards { | |
| 364 | + | host.forward(*host_port, *container_port); | |
| 365 | + | } | |
| 366 | + | } | |
| 367 | + | let text = serde_json::to_vec(&config).unwrap_or(raw); | |
| 368 | + | upstream.write_all(&http::with_length(head, &text))?; | |
| 369 | + | let _ = pending.send(if created.moved_from.is_some() { | |
| 370 | + | Pending::Created { name, published: created.published } | |
| 371 | + | } else { | |
| 372 | + | Pending::Plain { method } | |
| 373 | + | }); | |
| 374 | + | } | |
| 375 | + | Kind::InspectContainer { id } => { | |
| 376 | + | let published = state.lock().ok().and_then(|s| published_for(&s, &id)); | |
| 377 | + | upstream.write_all(&head.to_bytes())?; | |
| 378 | + | http::copy_body(&mut reader, &mut upstream, body)?; | |
| 379 | + | let _ = pending.send(match published { | |
| 380 | + | Some(published) => Pending::Inspect { published }, | |
| 381 | + | None => Pending::Plain { method }, | |
| 382 | + | }); | |
| 383 | + | } | |
| 384 | + | kind @ (Kind::ConnectNetwork | Kind::DisconnectNetwork) => { | |
| 385 | + | let raw = http::read_body(&mut reader, body)?; | |
| 386 | + | let value: Value = serde_json::from_slice(&raw).unwrap_or(Value::Null); | |
| 387 | + | let container = value.get("Container").and_then(Value::as_str).unwrap_or_default().to_owned(); | |
| 388 | + | let moved = state.lock().ok().is_some_and(|s| published_for(&s, &container).is_some()); | |
| 389 | + | if moved { | |
| 390 | + | // The container is on the job's network already. | |
| 391 | + | if kind == Kind::ConnectNetwork { | |
| 392 | + | let aliases = connect_aliases(&value); | |
| 393 | + | let fresh: Vec<String> = match state.lock() { | |
| 394 | + | Ok(mut state) => aliases.into_iter().filter(|a| state.aliases.insert(a.clone())).collect(), | |
| 395 | + | Err(_) => Vec::new(), | |
| 396 | + | }; | |
| 397 | + | if !fresh.is_empty() { | |
| 398 | + | host.add_hosts(&fresh); | |
| 399 | + | } | |
| 400 | + | } | |
| 401 | + | let _ = pending.send(Pending::Answer(http::empty_ok())); | |
| 402 | + | } else { | |
| 403 | + | upstream.write_all(&http::with_length(head, &raw))?; | |
| 404 | + | let _ = pending.send(Pending::Plain { method }); | |
| 405 | + | } | |
| 406 | + | } | |
| 407 | + | Kind::ClassicBuild => { | |
| 408 | + | let target = rewrite_build(head.target()); | |
| 409 | + | head.set_target(&target); | |
| 410 | + | upstream.write_all(&head.to_bytes())?; | |
| 411 | + | http::copy_body(&mut reader, &mut upstream, body)?; | |
| 412 | + | let _ = pending.send(Pending::Plain { method }); | |
| 413 | + | } | |
| 414 | + | Kind::Other => { | |
| 415 | + | let upgrade = head.upgrades(); | |
| 416 | + | upstream.write_all(&head.to_bytes())?; | |
| 417 | + | if upgrade { | |
| 418 | + | let _ = pending.send(Pending::Upgrade); | |
| 419 | + | // From here the two ends speak to each other. | |
| 420 | + | io::copy(&mut reader, &mut upstream)?; | |
| 421 | + | upstream_closer.shutdown_write(); | |
| 422 | + | return Ok(()); | |
| 423 | + | } | |
| 424 | + | http::copy_body(&mut reader, &mut upstream, body)?; | |
| 425 | + | let _ = pending.send(Pending::Plain { method }); | |
| 426 | + | } | |
| 427 | + | } | |
| 428 | + | } | |
| 429 | + | })(); | |
| 430 | + | // The client is done asking; the Engine's answers may still be coming. | |
| 431 | + | drop(pending); | |
| 432 | + | if result.is_err() { | |
| 433 | + | closer.shutdown_both(); | |
| 434 | + | upstream_closer.shutdown_both(); | |
| 435 | + | } | |
| 436 | + | result | |
| 437 | + | } | |
| 438 | + | ||
| 439 | + | /// Responses, Engine to client, in the order they were asked for. | |
| 440 | + | fn answer(upstream: Box<dyn Duplex>, mut client: Box<dyn Duplex>, pending: mpsc::Receiver<Pending>, state: Arc<Mutex<State>>) { | |
| 441 | + | let closer = upstream.try_clone_box().ok(); | |
| 442 | + | let mut reader = BufReader::new(upstream); | |
| 443 | + | let result = (|| -> io::Result<()> { | |
| 444 | + | while let Ok(next) = pending.recv() { | |
| 445 | + | if let Pending::Answer(bytes) = next { | |
| 446 | + | client.write_all(&bytes)?; | |
| 447 | + | client.flush()?; | |
| 448 | + | continue; | |
| 449 | + | } | |
| 450 | + | let Some(head) = http::read_head(&mut reader)? else { return Ok(()) }; | |
| 451 | + | match next { | |
| 452 | + | Pending::Upgrade => { | |
| 453 | + | client.write_all(&head.to_bytes())?; | |
| 454 | + | client.flush()?; | |
| 455 | + | io::copy(&mut reader, &mut client)?; | |
| 456 | + | client.shutdown_write(); | |
| 457 | + | return Err(io::Error::other("upgraded")); | |
| 458 | + | } | |
| 459 | + | Pending::Plain { method } => { | |
| 460 | + | let body = http::response_body(&head, &method); | |
| 461 | + | client.write_all(&head.to_bytes())?; | |
| 462 | + | http::copy_body(&mut reader, &mut client, body)?; | |
| 463 | + | } | |
| 464 | + | Pending::Created { name, published } => { | |
| 465 | + | let body = http::read_body(&mut reader, http::response_body(&head, "POST"))?; | |
| 466 | + | if (200..300).contains(&head.status()) | |
| 467 | + | && let Some(id) = serde_json::from_slice::<Value>(&body).ok().and_then(|v| v.get("Id").and_then(Value::as_str).map(str::to_owned)) | |
| 468 | + | && let Ok(mut state) = state.lock() | |
| 469 | + | { | |
| 470 | + | state.published.insert(id, published.clone()); | |
| 471 | + | if let Some(name) = name { | |
| 472 | + | state.published.insert(name.trim_start_matches('/').to_owned(), published); | |
| 473 | + | } | |
| 474 | + | } | |
| 475 | + | client.write_all(&http::with_length(head, &body))?; | |
| 476 | + | } | |
| 477 | + | Pending::Inspect { published } => { | |
| 478 | + | let body = http::read_body(&mut reader, http::response_body(&head, "GET"))?; | |
| 479 | + | let rewritten = match serde_json::from_slice::<Value>(&body) { | |
| 480 | + | Ok(mut value) if head.status() == 200 => { | |
| 481 | + | rewrite_inspect(&mut value, &published); | |
| 482 | + | serde_json::to_vec(&value).unwrap_or(body) | |
| 483 | + | } | |
| 484 | + | _ => body, | |
| 485 | + | }; | |
| 486 | + | client.write_all(&http::with_length(head, &rewritten))?; | |
| 487 | + | } | |
| 488 | + | Pending::Answer(_) => unreachable!("answered above"), | |
| 489 | + | } | |
| 490 | + | client.flush()?; | |
| 491 | + | } | |
| 492 | + | Ok(()) | |
| 493 | + | })(); | |
| 494 | + | if matches!(&result, Err(error) if error.to_string() == "upgraded") { | |
| 495 | + | // A hijacked connection ends when both ends have said so. | |
| 496 | + | return; | |
| 497 | + | } | |
| 498 | + | client.shutdown_both(); | |
| 499 | + | if let Some(closer) = closer { | |
| 500 | + | closer.shutdown_both(); | |
| 501 | + | } | |
| 502 | + | } | |
| 503 | + | ||
| 504 | + | /// The ports of a container moved to the job's network, by its id, the | |
| 505 | + | /// start of its id, or its name. | |
| 506 | + | fn published_for(state: &State, id: &str) -> Option<BTreeMap<String, String>> { | |
| 507 | + | let id = id.trim_start_matches('/'); | |
| 508 | + | if id.is_empty() { | |
| 509 | + | return None; | |
| 510 | + | } | |
| 511 | + | if let Some(found) = state.published.get(id) { | |
| 512 | + | return Some(found.clone()); | |
| 513 | + | } | |
| 514 | + | if id.len() >= 6 && id.chars().all(|c| c.is_ascii_hexdigit()) { | |
| 515 | + | let mut matches = state.published.iter().filter(|(key, _)| key.len() == 64 && key.starts_with(id)); | |
| 516 | + | if let (Some((_, found)), None) = (matches.next(), matches.next()) { | |
| 517 | + | return Some(found.clone()); | |
| 518 | + | } | |
| 519 | + | } | |
| 520 | + | None | |
| 521 | + | } | |
| 522 | + | ||
| 523 | + | /// The miner a container's image or command names, if any. | |
| 524 | + | fn miner_in_config(config: &Value) -> Option<&'static str> { | |
| 525 | + | let mut words = vec![config.get("Image").and_then(Value::as_str).unwrap_or_default().to_owned()]; | |
| 526 | + | for key in ["Entrypoint", "Cmd"] { | |
| 527 | + | match config.get(key) { | |
| 528 | + | Some(Value::String(text)) => words.push(text.clone()), | |
| 529 | + | other => words.extend(strings(other)), | |
| 530 | + | } | |
| 531 | + | } | |
| 532 | + | crate::abuse::miner_in(&words.join(" ")) | |
| 533 | + | } | |
| 534 | + | ||
| 535 | + | #[cfg(test)] | |
| 536 | + | mod tests { | |
| 537 | + | use super::*; | |
| 538 | + | ||
| 539 | + | #[test] | |
| 540 | + | fn routes_are_read_with_or_without_a_version() { | |
| 541 | + | assert_eq!(classify("POST", "/v1.47/containers/create?name=db"), Kind::CreateContainer { name: Some("db".into()) }); | |
| 542 | + | assert_eq!(classify("POST", "/containers/create"), Kind::CreateContainer { name: None }); | |
| 543 | + | assert_eq!(classify("GET", "/v1.51/containers/abc123/json?size=false"), Kind::InspectContainer { id: "abc123".into() }); | |
| 544 | + | assert_eq!(classify("POST", "/v1.47/networks/app_default/connect"), Kind::ConnectNetwork); | |
| 545 | + | assert_eq!(classify("POST", "/v1.47/build?t=x"), Kind::ClassicBuild); | |
| 546 | + | assert_eq!(classify("GET", "/v1.47/containers/json"), Kind::Other); | |
| 547 | + | assert_eq!(classify("POST", "/v1.47/containers/abc/start"), Kind::Other); | |
| 548 | + | } | |
| 549 | + | ||
| 550 | + | #[test] | |
| 551 | + | fn a_compose_service_moves_to_the_jobs_network_and_keeps_its_names() { | |
| 552 | + | let mut body = json!({ | |
| 553 | + | "Image": "postgres:17", | |
| 554 | + | "Labels": { "com.docker.compose.service": "db", "com.docker.compose.project": "app" }, | |
| 555 | + | "HostConfig": { | |
| 556 | + | "NetworkMode": "app_default", | |
| 557 | + | "PortBindings": { "5432/tcp": [{ "HostIp": "", "HostPort": "15432" }], "8080/tcp": [{ "HostPort": "" }] }, | |
| 558 | + | "Links": ["/cache:/app-db-1/redis"], | |
| 559 | + | "ExtraHosts": ["host.docker.internal:host-gateway"], | |
| 560 | + | }, | |
| 561 | + | "NetworkingConfig": { "EndpointsConfig": { "app_default": { "Aliases": ["db", "app-db-1"], "MacAddress": "x" } } }, | |
| 562 | + | }); | |
| 563 | + | let known: BTreeSet<String> = ["cache".to_owned()].into(); | |
| 564 | + | let created = rewrite_create(&mut body, Some("app-db-1"), &known); | |
| 565 | + | assert_eq!(created.moved_from.as_deref(), Some("app_default")); | |
| 566 | + | assert_eq!(created.aliases, vec!["app-db-1", "db", "redis"]); | |
| 567 | + | assert_eq!(created.forwards, vec![(15432, 5432)]); | |
| 568 | + | assert_eq!(created.published["5432/tcp"], "15432"); | |
| 569 | + | assert_eq!(created.published["8080/tcp"], "8080"); | |
| 570 | + | assert_eq!(body["HostConfig"]["NetworkMode"], "host"); | |
| 571 | + | assert!(body.get("NetworkingConfig").is_none()); | |
| 572 | + | assert!(body["HostConfig"].get("Links").is_none()); | |
| 573 | + | let extra = strings(body["HostConfig"].get("ExtraHosts")); | |
| 574 | + | assert!(extra.contains(&"host.docker.internal:host-gateway".to_owned())); | |
| 575 | + | for name in ["cache", "db", "app-db-1", "redis"] { | |
| 576 | + | assert!(extra.contains(&format!("{name}:127.0.0.1")), "{name} in {extra:?}"); | |
| 577 | + | } | |
| 578 | + | } | |
| 579 | + | ||
| 580 | + | #[test] | |
| 581 | + | fn host_none_and_shared_networks_are_left_alone() { | |
| 582 | + | for mode in ["host", "none", "container:abc"] { | |
| 583 | + | let mut body = json!({ "Image": "alpine", "HostConfig": { "NetworkMode": mode } }); | |
| 584 | + | let before = body.clone(); | |
| 585 | + | assert_eq!(rewrite_create(&mut body, Some("x"), &BTreeSet::new()), Created::default()); | |
| 586 | + | assert_eq!(body, before); | |
| 587 | + | } | |
| 588 | + | // The default network, named or not. | |
| 589 | + | let mut body = json!({ "Image": "alpine" }); | |
| 590 | + | assert_eq!(rewrite_create(&mut body, None, &BTreeSet::new()).moved_from.as_deref(), Some("bridge")); | |
| 591 | + | assert_eq!(body["HostConfig"]["NetworkMode"], "host"); | |
| 592 | + | } | |
| 593 | + | ||
| 594 | + | #[test] | |
| 595 | + | fn names_that_cannot_be_hosts_are_skipped() { | |
| 596 | + | let mut body = json!({ "HostConfig": {}, "NetworkingConfig": { "EndpointsConfig": { "n": { "Aliases": ["ok-name", "bad name", "localhost", ""] } } } }); | |
| 597 | + | assert_eq!(rewrite_create(&mut body, None, &BTreeSet::new()).aliases, vec!["ok-name"]); | |
| 598 | + | } | |
| 599 | + | ||
| 600 | + | #[test] | |
| 601 | + | fn inspections_report_the_ports_published() { | |
| 602 | + | let mut body = json!({ "Id": "abc", "NetworkSettings": { "Ports": {}, "Networks": { "host": {} } } }); | |
| 603 | + | rewrite_inspect(&mut body, &[("5432/tcp".to_owned(), "15432".to_owned())].into()); | |
| 604 | + | assert_eq!(body["NetworkSettings"]["Ports"]["5432/tcp"][0]["HostPort"], "15432"); | |
| 605 | + | } | |
| 606 | + | ||
| 607 | + | #[test] | |
| 608 | + | fn the_classic_builder_runs_on_the_jobs_network() { | |
| 609 | + | assert_eq!(rewrite_build("/v1.47/build?t=app&networkmode=default"), "/v1.47/build?t=app&networkmode=host"); | |
| 610 | + | assert_eq!(rewrite_build("/build"), "/build?networkmode=host"); | |
| 611 | + | assert_eq!(rewrite_build("/build?networkmode=none"), "/build?networkmode=none"); | |
| 612 | + | } | |
| 613 | + | ||
| 614 | + | #[test] | |
| 615 | + | fn ids_are_found_by_their_start_or_a_name() { | |
| 616 | + | let mut state = State::default(); | |
| 617 | + | let id = "a".repeat(64); | |
| 618 | + | state.published.insert(id.clone(), [("80/tcp".to_owned(), "8080".to_owned())].into()); | |
| 619 | + | state.published.insert("web".into(), [("80/tcp".to_owned(), "8080".to_owned())].into()); | |
| 620 | + | assert!(published_for(&state, "aaaaaaaaaaaa").is_some()); | |
| 621 | + | assert!(published_for(&state, "/web").is_some()); | |
| 622 | + | assert!(published_for(&state, "aaa").is_none()); | |
| 623 | + | assert!(published_for(&state, "other").is_none()); | |
| 624 | + | } | |
| 625 | + | ||
| 626 | + | #[test] | |
| 627 | + | fn miners_are_refused_by_image_or_command() { | |
| 628 | + | assert!(miner_in_config(&json!({ "Image": "metal3d/xmrig" })).is_some()); | |
| 629 | + | assert!(miner_in_config(&json!({ "Image": "alpine", "Cmd": ["sh", "-c", "./xmrig -o stratum+tcp://pool"] })).is_some()); | |
| 630 | + | assert!(miner_in_config(&json!({ "Image": "postgres:17", "Cmd": ["postgres"] })).is_none()); | |
| 631 | + | } | |
| 632 | + | ||
| 633 | + | /// A pretend Engine on TCP, and the proxy in front of it, end to end. | |
| 634 | + | #[test] | |
| 635 | + | fn requests_and_responses_pass_through_in_order() { | |
| 636 | + | use std::net::{TcpListener, TcpStream}; | |
| 637 | + | let engine = TcpListener::bind("127.0.0.1:0").unwrap(); | |
| 638 | + | let engine_addr = engine.local_addr().unwrap(); | |
| 639 | + | // The Engine: a create, an inspect, a chunked stream, then a hijack. | |
| 640 | + | let fake = std::thread::spawn(move || { | |
| 641 | + | let (stream, _) = engine.accept().unwrap(); | |
| 642 | + | let mut reader = BufReader::new(stream.try_clone().unwrap()); | |
| 643 | + | let mut writer = stream; | |
| 644 | + | let mut seen = Vec::new(); | |
| 645 | + | while let Some(head) = http::read_head(&mut reader).unwrap() { | |
| 646 | + | let body = http::read_body(&mut reader, http::request_body(&head)).unwrap(); | |
| 647 | + | seen.push((head.start.clone(), String::from_utf8_lossy(&body).into_owned())); | |
| 648 | + | if head.target().contains("/containers/create") { | |
| 649 | + | writer.write_all(&http::json_response(201, "Created", &json!({ "Id": "c".repeat(64), "Warnings": [] }))).unwrap(); | |
| 650 | + | } else if head.target().contains("/json") { | |
| 651 | + | writer | |
| 652 | + | .write_all(b"HTTP/1.1 200 OK\r\nContent-Type: application/json\r\nTransfer-Encoding: chunked\r\n\r\n") | |
| 653 | + | .unwrap(); | |
| 654 | + | let text = json!({ "Id": "c".repeat(64), "NetworkSettings": { "Ports": {} } }).to_string(); | |
| 655 | + | writer.write_all(format!("{:x}\r\n{text}\r\n0\r\n\r\n", text.len()).as_bytes()).unwrap(); | |
| 656 | + | } else if head.target().contains("/logs") { | |
| 657 | + | writer.write_all(b"HTTP/1.1 200 OK\r\nTransfer-Encoding: chunked\r\n\r\n3\r\none\r\n3\r\ntwo\r\n0\r\n\r\n").unwrap(); | |
| 658 | + | } else if head.upgrades() { | |
| 659 | + | writer.write_all(b"HTTP/1.1 101 UPGRADED\r\nConnection: Upgrade\r\nUpgrade: tcp\r\n\r\n").unwrap(); | |
| 660 | + | let mut buffer = [0u8; 5]; | |
| 661 | + | reader.read_exact(&mut buffer).unwrap(); | |
| 662 | + | writer.write_all(&buffer).unwrap(); | |
| 663 | + | break; | |
| 664 | + | } | |
| 665 | + | } | |
| 666 | + | seen | |
| 667 | + | }); | |
| 668 | + | ||
| 669 | + | struct Fake(std::net::SocketAddr, Mutex<Vec<(u16, u16)>>, Mutex<Vec<String>>); | |
| 670 | + | impl Host for Fake { | |
| 671 | + | fn add_hosts(&self, names: &[String]) { | |
| 672 | + | self.2.lock().unwrap().extend(names.iter().cloned()); | |
| 673 | + | } | |
| 674 | + | fn forward(&self, host_port: u16, container_port: u16) { | |
| 675 | + | self.1.lock().unwrap().push((host_port, container_port)); | |
| 676 | + | } | |
| 677 | + | fn ensure_engine(&self) -> Result<(), String> { | |
| 678 | + | Ok(()) | |
| 679 | + | } | |
| 680 | + | fn connect(&self) -> io::Result<Box<dyn Duplex>> { | |
| 681 | + | Ok(Box::new(TcpStream::connect(self.0)?)) | |
| 682 | + | } | |
| 683 | + | } | |
| 684 | + | let host = Arc::new(Fake(engine_addr, Mutex::new(Vec::new()), Mutex::new(Vec::new()))); | |
| 685 | + | let state = Arc::new(Mutex::new(State::default())); | |
| 686 | + | let proxy = TcpListener::bind("127.0.0.1:0").unwrap(); | |
| 687 | + | let proxy_addr = proxy.local_addr().unwrap(); | |
| 688 | + | let (host2, state2) = (host.clone() as Arc<dyn Host>, state.clone()); | |
| 689 | + | std::thread::spawn(move || { | |
| 690 | + | let (stream, _) = proxy.accept().unwrap(); | |
| 691 | + | serve(Box::new(stream), host2, state2); | |
| 692 | + | }); | |
| 693 | + | ||
| 694 | + | let client = TcpStream::connect(proxy_addr).unwrap(); | |
| 695 | + | let mut reader = BufReader::new(client.try_clone().unwrap()); | |
| 696 | + | let mut writer = client; | |
| 697 | + | let create = json!({ "Image": "redis", "HostConfig": { "PortBindings": { "6379/tcp": [{ "HostPort": "16379" }] } }, "NetworkingConfig": { "EndpointsConfig": { "job": { "Aliases": ["redis"] } } } }).to_string(); | |
| 698 | + | // Pipelined: all asked before any answer is read. | |
| 699 | + | writer | |
| 700 | + | .write_all(format!("POST /v1.47/containers/create?name=cache HTTP/1.1\r\nHost: docker\r\nContent-Type: application/json\r\nContent-Length: {}\r\n\r\n{create}", create.len()).as_bytes()) | |
| 701 | + | .unwrap(); | |
| 702 | + | writer.write_all(b"GET /v1.47/containers/cache/json HTTP/1.1\r\nHost: docker\r\n\r\n").unwrap(); | |
| 703 | + | writer.write_all(b"GET /v1.47/containers/cache/logs?follow=1 HTTP/1.1\r\nHost: docker\r\n\r\n").unwrap(); | |
| 704 | + | writer.write_all(b"POST /v1.47/containers/cache/attach?stream=1 HTTP/1.1\r\nHost: docker\r\nConnection: Upgrade\r\nUpgrade: tcp\r\n\r\nhello").unwrap(); | |
| 705 | + | ||
| 706 | + | let created = http::read_head(&mut reader).unwrap().unwrap(); | |
| 707 | + | assert_eq!(created.status(), 201); | |
| 708 | + | let body: Value = serde_json::from_slice(&http::read_body(&mut reader, http::response_body(&created, "POST")).unwrap()).unwrap(); | |
| 709 | + | assert_eq!(body["Id"].as_str().unwrap().len(), 64); | |
| 710 | + | let inspected = http::read_head(&mut reader).unwrap().unwrap(); | |
| 711 | + | let body: Value = serde_json::from_slice(&http::read_body(&mut reader, http::response_body(&inspected, "GET")).unwrap()).unwrap(); | |
| 712 | + | assert_eq!(body["NetworkSettings"]["Ports"]["6379/tcp"][0]["HostPort"], "16379"); | |
| 713 | + | let logs = http::read_head(&mut reader).unwrap().unwrap(); | |
| 714 | + | assert_eq!(http::read_body(&mut reader, http::response_body(&logs, "GET")).unwrap(), b"onetwo"); | |
| 715 | + | let upgraded = http::read_head(&mut reader).unwrap().unwrap(); | |
| 716 | + | assert_eq!(upgraded.status(), 101); | |
| 717 | + | let mut echoed = [0u8; 5]; | |
| 718 | + | reader.read_exact(&mut echoed).unwrap(); | |
| 719 | + | assert_eq!(&echoed, b"hello"); | |
| 720 | + | ||
| 721 | + | let seen = fake.join().unwrap(); | |
| 722 | + | assert!(seen[0].1.contains("\"NetworkMode\":\"host\""), "{}", seen[0].1); | |
| 723 | + | assert!(seen[0].1.contains("redis:127.0.0.1")); | |
| 724 | + | assert_eq!(*host.1.lock().unwrap(), vec![(16379, 6379)]); | |
| 725 | + | assert_eq!(*host.2.lock().unwrap(), vec!["cache".to_owned(), "redis".to_owned()]); | |
| 726 | + | } | |
| 727 | + | } |
| 1 | + | //! The job's own Docker Engine: set up when the job starts (a socket, and | |
| 2 | + | //! nothing running), and started the first time the socket is used, or a | |
| 3 | + | //! job's `services:` or `container:` need it. | |
| 4 | + | //! | |
| 5 | + | //! What Cloudflare Containers allow, and so how it is started: `dockerd` | |
| 6 | + | //! as root (a rootless Engine does not start there), with | |
| 7 | + | //! `--iptables=false --ip6tables=false --ip-forward=false`, since a sandbox | |
| 8 | + | //! may not change its packet filter or forward packets. Containers on a | |
| 9 | + | //! bridge network therefore have no way out, which is why the API proxy | |
| 10 | + | //! puts them on the job's network (api.rs). The Engine's data goes on the | |
| 11 | + | //! sandbox's disk with the overlay filesystem when the disk takes it, and | |
| 12 | + | //! with plain copies (containerd's `native` snapshotter) when it does not. Docker Hub's images come | |
| 13 | + | //! through Google's public mirror of it first, so jobs from many machines | |
| 14 | + | //! sharing addresses do not run into Docker Hub's anonymous limits. | |
| 15 | + | ||
| 16 | + | use std::path::Path; | |
| 17 | + | ||
| 18 | + | use serde_json::{Value, json}; | |
| 19 | + | ||
| 20 | + | /// Everything of the Engine's that is not its data. | |
| 21 | + | pub(crate) const DIR: &str = "/run/g1t-docker"; | |
| 22 | + | /// What the job's steps reach: the API proxy. | |
| 23 | + | pub(crate) const PROXY_SOCKET: &str = "/run/g1t-docker/docker.sock"; | |
| 24 | + | /// The Engine itself. | |
| 25 | + | pub(crate) const ENGINE_SOCKET: &str = "/run/g1t-docker/engine.sock"; | |
| 26 | + | /// Where the job's steps look for Docker. | |
| 27 | + | pub(crate) const DOCKER_SOCKET: &str = "/var/run/docker.sock"; | |
| 28 | + | pub(crate) const MIRROR: &str = "https://mirror.gcr.io"; | |
| 29 | + | ||
| 30 | + | /// What the job tells the Engine when it sets it up. | |
| 31 | + | #[derive(Clone, Default)] | |
| 32 | + | pub(crate) struct Options { | |
| 33 | + | /// g1t's container registry (`g1t.sh`) and the run's token, to be | |
| 34 | + | /// signed in to from the start. None for a run without secrets. | |
| 35 | + | pub(crate) registry: Option<(String, String)>, | |
| 36 | + | } | |
| 37 | + | ||
| 38 | + | /// The Engine's `daemon.json`. `group`: the group whose members may use | |
| 39 | + | /// its socket (the job's user). `copies`: the disk does not take overlays. | |
| 40 | + | pub(crate) fn daemon_config(group: &str, copies: bool) -> Value { | |
| 41 | + | let mut config = json!({ | |
| 42 | + | "hosts": [format!("unix://{ENGINE_SOCKET}")], | |
| 43 | + | "group": group, | |
| 44 | + | "pidfile": format!("{DIR}/dockerd.pid"), | |
| 45 | + | "iptables": false, | |
| 46 | + | "ip6tables": false, | |
| 47 | + | "ip-forward": false, | |
| 48 | + | "registry-mirrors": [MIRROR], | |
| 49 | + | "log-driver": "json-file", | |
| 50 | + | "log-opts": { "max-size": "20m", "max-file": "2" }, | |
| 51 | + | }); | |
| 52 | + | if copies { | |
| 53 | + | // The containerd image store's name for plain copies (vfs). | |
| 54 | + | config["storage-driver"] = json!("native"); | |
| 55 | + | } | |
| 56 | + | config | |
| 57 | + | } | |
| 58 | + | ||
| 59 | + | /// `~/.docker/config.json`, signed in to `registry` with `token` unless it | |
| 60 | + | /// is already signed in there. | |
| 61 | + | pub(crate) fn with_login(mut config: Value, registry: &str, token: &str) -> Option<Value> { | |
| 62 | + | use base64::Engine; | |
| 63 | + | if !config.is_object() { | |
| 64 | + | config = json!({}); | |
| 65 | + | } | |
| 66 | + | let auths = config.as_object_mut()?.entry("auths").or_insert_with(|| json!({})); | |
| 67 | + | let auths = auths.as_object_mut()?; | |
| 68 | + | if auths.contains_key(registry) { | |
| 69 | + | return None; | |
| 70 | + | } | |
| 71 | + | let auth = base64::engine::general_purpose::STANDARD.encode(format!("g1t:{token}")); | |
| 72 | + | auths.insert(registry.to_owned(), json!({ "auth": auth })); | |
| 73 | + | Some(config) | |
| 74 | + | } | |
| 75 | + | ||
| 76 | + | /// The host of a server URL: `https://g1t.sh` is `g1t.sh`. | |
| 77 | + | pub(crate) fn registry_host(server_url: &str) -> Option<String> { | |
| 78 | + | let rest = server_url.split_once("://").map_or(server_url, |(_, rest)| rest); | |
| 79 | + | let host = rest.split('/').next().unwrap_or_default(); | |
| 80 | + | (!host.is_empty()).then(|| host.to_ascii_lowercase()) | |
| 81 | + | } | |
| 82 | + | ||
| 83 | + | /// The last lines of a file, for a message. | |
| 84 | + | fn tail(path: &Path, lines: usize) -> String { | |
| 85 | + | let text = std::fs::read_to_string(path).unwrap_or_default(); | |
| 86 | + | let all: Vec<&str> = text.lines().filter(|l| !l.trim().is_empty()).collect(); | |
| 87 | + | all[all.len().saturating_sub(lines)..].join("\n") | |
| 88 | + | } | |
| 89 | + | ||
| 90 | + | #[cfg(target_os = "linux")] | |
| 91 | + | pub(crate) use linux::{enable, ensure_started}; | |
| 92 | + | ||
| 93 | + | #[cfg(not(target_os = "linux"))] | |
| 94 | + | pub(crate) fn enable(_options: Options) -> Result<(), String> { | |
| 95 | + | Err("Docker runs in jobs on g1t's own Linux machines.".into()) | |
| 96 | + | } | |
| 97 | + | ||
| 98 | + | #[cfg(not(target_os = "linux"))] | |
| 99 | + | pub(crate) fn ensure_started() -> Result<String, String> { | |
| 100 | + | Err("Docker runs in jobs on g1t's own Linux machines.".into()) | |
| 101 | + | } | |
| 102 | + | ||
| 103 | + | #[cfg(target_os = "linux")] | |
| 104 | + | mod linux { | |
| 105 | + | use std::collections::BTreeSet; | |
| 106 | + | use std::io::{self, Write}; | |
| 107 | + | use std::net::{TcpListener, TcpStream}; | |
| 108 | + | use std::os::unix::fs::{MetadataExt, PermissionsExt}; | |
| 109 | + | use std::os::unix::net::{UnixListener, UnixStream}; | |
| 110 | + | use std::path::Path; | |
| 111 | + | use std::process::{Child, Command, Stdio}; | |
| 112 | + | use std::sync::{Arc, Mutex, OnceLock}; | |
| 113 | + | use std::time::{Duration, Instant}; | |
| 114 | + | ||
| 115 | + | use serde_json::Value; | |
| 116 | + | ||
| 117 | + | use super::super::api::{self, Duplex, Host, State}; | |
| 118 | + | use super::{DIR, DOCKER_SOCKET, ENGINE_SOCKET, Options, PROXY_SOCKET, daemon_config, tail, with_login}; | |
| 119 | + | ||
| 120 | + | const LOG: &str = "/run/g1t-docker/dockerd.log"; | |
| 121 | + | /// From moby's `hack/dind`: the sandbox's processes into a group of | |
| 122 | + | /// their own, then every controller enabled for the groups below. | |
| 123 | + | const CGROUP_NESTING: &str = "if [ -f /sys/fs/cgroup/cgroup.controllers ] && [ -z \"$(cat /sys/fs/cgroup/cgroup.subtree_control)\" ]; then \ | |
| 124 | + | mkdir -p /sys/fs/cgroup/init && xargs -rn1 < /sys/fs/cgroup/cgroup.procs > /sys/fs/cgroup/init/cgroup.procs 2>/dev/null; \ | |
| 125 | + | sed -e 's/ / +/g' -e 's/^/+/' < /sys/fs/cgroup/cgroup.controllers > /sys/fs/cgroup/cgroup.subtree_control; fi"; | |
| 126 | + | const CERTS: &str = "/run/g1t-docker/certs"; | |
| 127 | + | const BIN: &str = "/run/g1t-docker/bin"; | |
| 128 | + | ||
| 129 | + | /// The options the job set up with, once set up. | |
| 130 | + | static OPTIONS: OnceLock<Options> = OnceLock::new(); | |
| 131 | + | /// How starting went: the Engine's version, or why not. Held while | |
| 132 | + | /// starting, so everything that needs the Engine waits for it. | |
| 133 | + | static STARTED: Mutex<Option<Result<String, String>>> = Mutex::new(None); | |
| 134 | + | /// Host ports forwarded so far. | |
| 135 | + | static FORWARDED: Mutex<BTreeSet<u16>> = Mutex::new(BTreeSet::new()); | |
| 136 | + | ||
| 137 | + | fn sudo(args: &[&str]) -> bool { | |
| 138 | + | Command::new("sudo").arg("-n").args(args).stdin(Stdio::null()).stdout(Stdio::null()).stderr(Stdio::null()).status().is_ok_and(|s| s.success()) | |
| 139 | + | } | |
| 140 | + | ||
| 141 | + | fn sudo_with_input(args: &[&str], input: &str) -> bool { | |
| 142 | + | let Ok(mut child) = Command::new("sudo").arg("-n").args(args).stdin(Stdio::piped()).stdout(Stdio::null()).stderr(Stdio::null()).spawn() else { | |
| 143 | + | return false; | |
| 144 | + | }; | |
| 145 | + | if let Some(mut stdin) = child.stdin.take() { | |
| 146 | + | let _ = stdin.write_all(input.as_bytes()); | |
| 147 | + | } | |
| 148 | + | child.wait().is_ok_and(|s| s.success()) | |
| 149 | + | } | |
| 150 | + | ||
| 151 | + | fn ids() -> (u32, u32) { | |
| 152 | + | std::fs::metadata("/proc/self").map(|m| (m.uid(), m.gid())).unwrap_or((1000, 1000)) | |
| 153 | + | } | |
| 154 | + | ||
| 155 | + | fn group_name() -> String { | |
| 156 | + | Command::new("id").arg("-gn").output().ok().map(|o| String::from_utf8_lossy(&o.stdout).trim().to_owned()).filter(|g| !g.is_empty()).unwrap_or_else(|| "node".into()) | |
| 157 | + | } | |
| 158 | + | ||
| 159 | + | /// Sets the socket up, with no Engine behind it yet. | |
| 160 | + | pub(crate) fn enable(options: Options) -> Result<(), String> { | |
| 161 | + | if !Path::new("/usr/bin/dockerd").exists() { | |
| 162 | + | return Err("this sandbox's image has no Docker Engine".into()); | |
| 163 | + | } | |
| 164 | + | let (uid, gid) = ids(); | |
| 165 | + | if !sudo(&["install", "-d", "-m", "0755", "-o", &uid.to_string(), "-g", &gid.to_string(), DIR]) { | |
| 166 | + | return Err(format!("could not make {DIR}")); | |
| 167 | + | } | |
| 168 | + | let _ = std::fs::remove_file(PROXY_SOCKET); | |
| 169 | + | let listener = UnixListener::bind(PROXY_SOCKET).map_err(|e| format!("could not listen on {PROXY_SOCKET}: {e}"))?; | |
| 170 | + | // Every process in the sandbox is the job's, root or not. | |
| 171 | + | let _ = std::fs::set_permissions(PROXY_SOCKET, std::fs::Permissions::from_mode(0o666)); | |
| 172 | + | if !sudo(&["ln", "-sfn", PROXY_SOCKET, DOCKER_SOCKET]) { | |
| 173 | + | return Err(format!("could not link {DOCKER_SOCKET}")); | |
| 174 | + | } | |
| 175 | + | let _ = OPTIONS.set(options); | |
| 176 | + | let host: Arc<dyn Host> = Arc::new(Sandbox); | |
| 177 | + | let state = Arc::new(Mutex::new(State::default())); | |
| 178 | + | std::thread::spawn(move || { | |
| 179 | + | for client in listener.incoming().flatten() { | |
| 180 | + | let (host, state) = (host.clone(), state.clone()); | |
| 181 | + | std::thread::spawn(move || api::serve(Box::new(client), host, state)); | |
| 182 | + | } | |
| 183 | + | }); | |
| 184 | + | Ok(()) | |
| 185 | + | } | |
| 186 | + | ||
| 187 | + | /// Starts the Engine, once; its version, or why it could not start. | |
| 188 | + | pub(crate) fn ensure_started() -> Result<String, String> { | |
| 189 | + | let mut started = STARTED.lock().unwrap_or_else(|poisoned| poisoned.into_inner()); | |
| 190 | + | if let Some(result) = &*started { | |
| 191 | + | return result.clone(); | |
| 192 | + | } | |
| 193 | + | let begun = Instant::now(); | |
| 194 | + | let result = start(); | |
| 195 | + | match &result { | |
| 196 | + | Ok(version) => super::super::note(format!( | |
| 197 | + | "Docker: started this job's own Docker Engine {version} in {:.1}s. Its containers run in this job's sandbox, on the job's network and under its guardrails, and end with the job.", | |
| 198 | + | begun.elapsed().as_secs_f64() | |
| 199 | + | )), | |
| 200 | + | Err(problem) => super::super::note(format!("##[error]Docker could not start: {problem}")), | |
| 201 | + | } | |
| 202 | + | *started = Some(result.clone()); | |
| 203 | + | result | |
| 204 | + | } | |
| 205 | + | ||
| 206 | + | /// Whether the overlay filesystem works where the Engine keeps its data. | |
| 207 | + | fn overlay_works() -> bool { | |
| 208 | + | let script = "d=/var/lib/docker/.g1t-probe; rm -rf $d; mkdir -p $d/l $d/u $d/w $d/m && echo x > $d/l/f && mount -t overlay overlay -o lowerdir=$d/l,upperdir=$d/u,workdir=$d/w $d/m && umount $d/m; s=$?; rm -rf $d; exit $s"; | |
| 209 | + | sudo(&["sh", "-c", script]) | |
| 210 | + | } | |
| 211 | + | ||
| 212 | + | fn start() -> Result<String, String> { | |
| 213 | + | // This program, as the runc the Engine finds first (oci.rs). | |
| 214 | + | let exe = std::env::current_exe().map_err(|e| e.to_string())?; | |
| 215 | + | std::fs::create_dir_all(BIN).map_err(|e| format!("could not make {BIN}: {e}"))?; | |
| 216 | + | let shim = Path::new(BIN).join("runc"); | |
| 217 | + | let _ = std::fs::remove_file(&shim); | |
| 218 | + | std::os::unix::fs::symlink(&exe, &shim).map_err(|e| format!("could not link runc: {e}"))?; | |
| 219 | + | ||
| 220 | + | // A guarded job's certificate, for its containers. | |
| 221 | + | let mut ca_dir = None; | |
| 222 | + | if let Ok(ca) = std::env::var("G1T_EGRESS_CA") | |
| 223 | + | && Path::new(&ca).exists() | |
| 224 | + | { | |
| 225 | + | let _ = std::fs::create_dir_all(CERTS); | |
| 226 | + | let copied = std::fs::copy(&ca, Path::new(CERTS).join(super::super::oci::EGRESS)).is_ok() | |
| 227 | + | && std::fs::copy("/etc/ssl/certs/ca-certificates.crt", Path::new(CERTS).join(super::super::oci::BUNDLE)).is_ok(); | |
| 228 | + | if copied { | |
| 229 | + | ca_dir = Some(CERTS.to_owned()); | |
| 230 | + | } | |
| 231 | + | } | |
| 232 | + | // `-p 80:8080` is forwarded by this process, which is not root. | |
| 233 | + | let _ = sudo(&["sysctl", "-q", "-w", "net.ipv4.ip_unprivileged_port_start=0"]); | |
| 234 | + | // Containers' resource limits (`--cpus`, `--memory`) need the | |
| 235 | + | // cgroup controllers handed down, which cgroup v2 allows only from | |
| 236 | + | // a group with no processes of its own: move ours aside first, as | |
| 237 | + | // the Engine's own Docker-in-Docker image does. | |
| 238 | + | let _ = sudo(&["sh", "-c", CGROUP_NESTING]); | |
| 239 | + | ||
| 240 | + | let mut vfs = !overlay_works(); | |
| 241 | + | let group = group_name(); | |
| 242 | + | let mut last = String::new(); | |
| 243 | + | for _ in 0..2 { | |
| 244 | + | let config = daemon_config(&group, vfs); | |
| 245 | + | std::fs::write(format!("{DIR}/daemon.json"), serde_json::to_vec_pretty(&config).unwrap_or_default()) | |
| 246 | + | .map_err(|e| format!("could not write daemon.json: {e}"))?; | |
| 247 | + | let mut child = spawn(ca_dir.as_deref())?; | |
| 248 | + | match wait_ready(&mut child, Duration::from_secs(90)) { | |
| 249 | + | Ok(()) => { | |
| 250 | + | let version = engine_version().unwrap_or_else(|| "?".into()); | |
| 251 | + | sign_in(); | |
| 252 | + | return Ok(if vfs { format!("{version} (plain-copy storage: this disk takes no overlays, so images take more room)") } else { version }); | |
| 253 | + | } | |
| 254 | + | Err(problem) => { | |
| 255 | + | last = problem; | |
| 256 | + | let _ = child.kill(); | |
| 257 | + | let _ = sudo(&["pkill", "-x", "dockerd"]); | |
| 258 | + | let _ = sudo(&["pkill", "-x", "containerd"]); | |
| 259 | + | if vfs { | |
| 260 | + | break; | |
| 261 | + | } | |
| 262 | + | // Overlays that mount but do not work for the Engine. | |
| 263 | + | vfs = true; | |
| 264 | + | } | |
| 265 | + | } | |
| 266 | + | } | |
| 267 | + | Err(last) | |
| 268 | + | } | |
| 269 | + | ||
| 270 | + | fn spawn(ca_dir: Option<&str>) -> Result<Child, String> { | |
| 271 | + | let log = std::fs::File::create(LOG).map_err(|e| format!("could not write {LOG}: {e}"))?; | |
| 272 | + | let err = log.try_clone().map_err(|e| e.to_string())?; | |
| 273 | + | let path = format!("{BIN}:/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin"); | |
| 274 | + | let mut command = Command::new("sudo"); | |
| 275 | + | command.args(["-n", "env", &format!("PATH={path}"), "G1T_REAL_RUNC=/usr/bin/runc"]); | |
| 276 | + | if let Some(dir) = ca_dir { | |
| 277 | + | command.arg(format!("G1T_DOCKER_CA_DIR={dir}")); | |
| 278 | + | } | |
| 279 | + | command.args(["dockerd", "--config-file", &format!("{DIR}/daemon.json")]); | |
| 280 | + | command.stdin(Stdio::null()).stdout(log).stderr(err); | |
| 281 | + | command.spawn().map_err(|e| format!("could not run dockerd: {e}")) | |
| 282 | + | } | |
| 283 | + | ||
| 284 | + | /// A request to the Engine; its status and body. | |
| 285 | + | fn ask(method: &str, path: &str) -> io::Result<(u16, Vec<u8>)> { | |
| 286 | + | let mut stream = UnixStream::connect(ENGINE_SOCKET)?; | |
| 287 | + | stream.set_read_timeout(Some(Duration::from_secs(10)))?; | |
| 288 | + | stream.write_all(format!("{method} {path} HTTP/1.1\r\nHost: docker\r\nConnection: close\r\n\r\n").as_bytes())?; | |
| 289 | + | let mut reader = io::BufReader::new(stream); | |
| 290 | + | let head = super::super::http::read_head(&mut reader)?.ok_or_else(|| io::Error::other("no answer"))?; | |
| 291 | + | let body = super::super::http::read_body(&mut reader, super::super::http::response_body(&head, method))?; | |
| 292 | + | Ok((head.status(), body)) | |
| 293 | + | } | |
| 294 | + | ||
| 295 | + | fn wait_ready(child: &mut Child, limit: Duration) -> Result<(), String> { | |
| 296 | + | let until = Instant::now() + limit; | |
| 297 | + | loop { | |
| 298 | + | if matches!(ask("GET", "/_ping"), Ok((200, _))) { | |
| 299 | + | return Ok(()); | |
| 300 | + | } | |
| 301 | + | if let Ok(Some(status)) = child.try_wait() { | |
| 302 | + | return Err(format!("dockerd stopped ({status}):\n{}", tail(Path::new(LOG), 15))); | |
| 303 | + | } | |
| 304 | + | if Instant::now() >= until { | |
| 305 | + | return Err(format!("dockerd did not answer in {} s:\n{}", limit.as_secs(), tail(Path::new(LOG), 15))); | |
| 306 | + | } | |
| 307 | + | std::thread::sleep(Duration::from_millis(100)); | |
| 308 | + | } | |
| 309 | + | } | |
| 310 | + | ||
| 311 | + | fn engine_version() -> Option<String> { | |
| 312 | + | let (_, body) = ask("GET", "/version").ok()?; | |
| 313 | + | let value: Value = serde_json::from_slice(&body).ok()?; | |
| 314 | + | value.get("Version").and_then(Value::as_str).map(str::to_owned) | |
| 315 | + | } | |
| 316 | + | ||
| 317 | + | /// Signs the job in to g1t's registry with the run's own token. | |
| 318 | + | fn sign_in() { | |
| 319 | + | let Some((registry, token)) = OPTIONS.get().and_then(|o| o.registry.clone()) else { return }; | |
| 320 | + | let home = std::env::var("HOME").unwrap_or_else(|_| "/home/node".into()); | |
| 321 | + | let path = Path::new(&home).join(".docker").join("config.json"); | |
| 322 | + | let current: Value = std::fs::read(&path).ok().and_then(|t| serde_json::from_slice(&t).ok()).unwrap_or(Value::Null); | |
| 323 | + | if let Some(config) = with_login(current, ®istry, &token) { | |
| 324 | + | let _ = std::fs::create_dir_all(path.parent().expect("a folder")); | |
| 325 | + | if std::fs::write(&path, serde_json::to_vec_pretty(&config).unwrap_or_default()).is_ok() { | |
| 326 | + | let _ = std::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o600)); | |
| 327 | + | super::super::note(format!("Docker: signed in to {registry} with this run's token.")); | |
| 328 | + | } | |
| 329 | + | } | |
| 330 | + | } | |
| 331 | + | ||
| 332 | + | /// The sandbox, as the API proxy sees it. | |
| 333 | + | struct Sandbox; | |
| 334 | + | ||
| 335 | + | impl Host for Sandbox { | |
| 336 | + | fn add_hosts(&self, names: &[String]) { | |
| 337 | + | let lines: String = names.iter().map(|name| format!("127.0.0.1\t{name}\n")).collect(); | |
| 338 | + | if !sudo_with_input(&["sh", "-c", "cat >> /etc/hosts"], &lines) { | |
| 339 | + | super::super::note(format!("Docker: could not add {} to /etc/hosts.", names.join(", "))); | |
| 340 | + | } | |
| 341 | + | } | |
| 342 | + | ||
| 343 | + | fn forward(&self, host_port: u16, container_port: u16) { | |
| 344 | + | if !FORWARDED.lock().is_ok_and(|mut set| set.insert(host_port)) { | |
| 345 | + | return; | |
| 346 | + | } | |
| 347 | + | let listener = match TcpListener::bind(("0.0.0.0", host_port)) { | |
| 348 | + | Ok(listener) => listener, | |
| 349 | + | Err(error) => { | |
| 350 | + | super::super::note(format!("##[warning]Docker: port {host_port} could not be published for port {container_port}: {error}")); | |
| 351 | + | return; | |
| 352 | + | } | |
| 353 | + | }; | |
| 354 | + | std::thread::spawn(move || { | |
| 355 | + | for client in listener.incoming().flatten() { | |
| 356 | + | std::thread::spawn(move || { | |
| 357 | + | let Ok(target) = TcpStream::connect(("127.0.0.1", container_port)) else { return }; | |
| 358 | + | pipe(client, target); | |
| 359 | + | }); | |
| 360 | + | } | |
| 361 | + | }); | |
| 362 | + | } | |
| 363 | + | ||
| 364 | + | fn ensure_engine(&self) -> Result<(), String> { | |
| 365 | + | ensure_started().map(|_| ()) | |
| 366 | + | } | |
| 367 | + | ||
| 368 | + | fn connect(&self) -> io::Result<Box<dyn Duplex>> { | |
| 369 | + | Ok(Box::new(UnixStream::connect(ENGINE_SOCKET)?)) | |
| 370 | + | } | |
| 371 | + | } | |
| 372 | + | ||
| 373 | + | /// Copies two connections into each other until both are done. | |
| 374 | + | fn pipe(a: TcpStream, b: TcpStream) { | |
| 375 | + | let (Ok(mut a_read), Ok(mut b_read)) = (a.try_clone(), b.try_clone()) else { return }; | |
| 376 | + | let (mut a_write, mut b_write) = (a, b); | |
| 377 | + | let back = std::thread::spawn(move || { | |
| 378 | + | let _ = io::copy(&mut b_read, &mut a_write); | |
| 379 | + | let _ = a_write.shutdown(std::net::Shutdown::Write); | |
| 380 | + | }); | |
| 381 | + | let _ = io::copy(&mut a_read, &mut b_write); | |
| 382 | + | let _ = b_write.shutdown(std::net::Shutdown::Write); | |
| 383 | + | let _ = back.join(); | |
| 384 | + | } | |
| 385 | + | } | |
| 386 | + | ||
| 387 | + | #[cfg(test)] | |
| 388 | + | mod tests { | |
| 389 | + | use super::*; | |
| 390 | + | ||
| 391 | + | #[test] | |
| 392 | + | fn the_engine_runs_as_a_sandbox_allows() { | |
| 393 | + | let config = daemon_config("node", false); | |
| 394 | + | assert_eq!(config["iptables"], false); | |
| 395 | + | assert_eq!(config["ip6tables"], false); | |
| 396 | + | assert_eq!(config["ip-forward"], false); | |
| 397 | + | assert_eq!(config["group"], "node"); | |
| 398 | + | assert_eq!(config["hosts"][0], "unix:///run/g1t-docker/engine.sock"); | |
| 399 | + | assert_eq!(config["registry-mirrors"][0], MIRROR); | |
| 400 | + | assert!(config.get("storage-driver").is_none()); | |
| 401 | + | assert_eq!(daemon_config("node", true)["storage-driver"], "native"); | |
| 402 | + | } | |
| 403 | + | ||
| 404 | + | #[test] | |
| 405 | + | fn the_run_signs_in_to_g1t_unless_already_signed_in() { | |
| 406 | + | let config = with_login(json!({ "auths": { "ghcr.io": { "auth": "x" } } }), "g1t.sh", "tok").unwrap(); | |
| 407 | + | assert_eq!(config["auths"]["g1t.sh"]["auth"], "ZzF0OnRvaw=="); | |
| 408 | + | assert_eq!(config["auths"]["ghcr.io"]["auth"], "x"); | |
| 409 | + | assert!(with_login(config, "g1t.sh", "other").is_none()); | |
| 410 | + | assert!(with_login(Value::Null, "g1t.sh", "tok").is_some()); | |
| 411 | + | assert_eq!(registry_host("https://g1t.sh").as_deref(), Some("g1t.sh")); | |
| 412 | + | assert_eq!(registry_host("http://localhost:8787/").as_deref(), Some("localhost:8787")); | |
| 413 | + | } | |
| 414 | + | } |
| 1 | + | //! Just enough HTTP/1.1 to stand between the Docker CLI and the Engine: | |
| 2 | + | //! message heads, and bodies framed by `Content-Length`, chunked, or the | |
| 3 | + | //! end of the connection. Bodies pass through as they come (a chunk at a | |
| 4 | + | //! time, so `docker logs -f` and a pull's progress keep streaming), unless | |
| 5 | + | //! the proxy reads one whole to change it. | |
| 6 | + | ||
| 7 | + | use std::io::{self, BufRead, Read, Write}; | |
| 8 | + | ||
| 9 | + | /// The most a message head may be. The Engine's and the CLI's are a few | |
| 10 | + | /// hundred bytes. | |
| 11 | + | const MAX_HEAD: usize = 64 * 1024; | |
| 12 | + | /// The most a body read whole may be: a container's config or inspection | |
| 13 | + | /// is a few kilobytes. | |
| 14 | + | pub(crate) const MAX_BODY: u64 = 16 * 1024 * 1024; | |
| 15 | + | ||
| 16 | + | /// A request's or a response's start line and headers. | |
| 17 | + | #[derive(Clone, Debug, PartialEq)] | |
| 18 | + | pub(crate) struct Head { | |
| 19 | + | pub(crate) start: String, | |
| 20 | + | pub(crate) headers: Vec<(String, String)>, | |
| 21 | + | } | |
| 22 | + | ||
| 23 | + | impl Head { | |
| 24 | + | pub(crate) fn header(&self, name: &str) -> Option<&str> { | |
| 25 | + | self.headers.iter().find(|(key, _)| key.eq_ignore_ascii_case(name)).map(|(_, value)| value.as_str()) | |
| 26 | + | } | |
| 27 | + | ||
| 28 | + | pub(crate) fn remove_header(&mut self, name: &str) { | |
| 29 | + | self.headers.retain(|(key, _)| !key.eq_ignore_ascii_case(name)); | |
| 30 | + | } | |
| 31 | + | ||
| 32 | + | pub(crate) fn set_header(&mut self, name: &str, value: &str) { | |
| 33 | + | self.remove_header(name); | |
| 34 | + | self.headers.push((name.to_owned(), value.to_owned())); | |
| 35 | + | } | |
| 36 | + | ||
| 37 | + | /// A request's method. | |
| 38 | + | pub(crate) fn method(&self) -> &str { | |
| 39 | + | self.start.split(' ').next().unwrap_or_default() | |
| 40 | + | } | |
| 41 | + | ||
| 42 | + | /// A request's target: its path and query. | |
| 43 | + | pub(crate) fn target(&self) -> &str { | |
| 44 | + | self.start.split(' ').nth(1).unwrap_or_default() | |
| 45 | + | } | |
| 46 | + | ||
| 47 | + | pub(crate) fn set_target(&mut self, target: &str) { | |
| 48 | + | let mut parts: Vec<&str> = self.start.splitn(3, ' ').collect(); | |
| 49 | + | if parts.len() == 3 { | |
| 50 | + | parts[1] = target; | |
| 51 | + | self.start = parts.join(" "); | |
| 52 | + | } | |
| 53 | + | } | |
| 54 | + | ||
| 55 | + | /// A response's status code. | |
| 56 | + | pub(crate) fn status(&self) -> u16 { | |
| 57 | + | self.start.split(' ').nth(1).and_then(|code| code.parse().ok()).unwrap_or(0) | |
| 58 | + | } | |
| 59 | + | ||
| 60 | + | /// Whether the request asks to leave HTTP (`docker attach`, `exec`, | |
| 61 | + | /// BuildKit's `/grpc` and `/session`). | |
| 62 | + | pub(crate) fn upgrades(&self) -> bool { | |
| 63 | + | self.header("upgrade").is_some() || self.header("connection").is_some_and(|value| value.to_ascii_lowercase().contains("upgrade")) | |
| 64 | + | } | |
| 65 | + | ||
| 66 | + | pub(crate) fn to_bytes(&self) -> Vec<u8> { | |
| 67 | + | let mut out = String::with_capacity(256); | |
| 68 | + | out.push_str(&self.start); | |
| 69 | + | out.push_str("\r\n"); | |
| 70 | + | for (name, value) in &self.headers { | |
| 71 | + | out.push_str(name); | |
| 72 | + | out.push_str(": "); | |
| 73 | + | out.push_str(value); | |
| 74 | + | out.push_str("\r\n"); | |
| 75 | + | } | |
| 76 | + | out.push_str("\r\n"); | |
| 77 | + | out.into_bytes() | |
| 78 | + | } | |
| 79 | + | } | |
| 80 | + | ||
| 81 | + | /// Reads a message head. `None` when the connection ends before one starts. | |
| 82 | + | pub(crate) fn read_head<R: BufRead>(reader: &mut R) -> io::Result<Option<Head>> { | |
| 83 | + | let mut lines: Vec<String> = Vec::new(); | |
| 84 | + | let mut size = 0; | |
| 85 | + | loop { | |
| 86 | + | let mut line = Vec::new(); | |
| 87 | + | let read = reader.read_until(b'\n', &mut line)?; | |
| 88 | + | if read == 0 { | |
| 89 | + | if lines.is_empty() { | |
| 90 | + | return Ok(None); | |
| 91 | + | } | |
| 92 | + | return Err(io::Error::new(io::ErrorKind::UnexpectedEof, "the connection ended inside a message head")); | |
| 93 | + | } | |
| 94 | + | size += read; | |
| 95 | + | if size > MAX_HEAD { | |
| 96 | + | return Err(io::Error::new(io::ErrorKind::InvalidData, "a message head too large")); | |
| 97 | + | } | |
| 98 | + | let text = String::from_utf8_lossy(&line).trim_end_matches(['\r', '\n']).to_owned(); | |
| 99 | + | if text.is_empty() { | |
| 100 | + | // Blank lines before a request are allowed, and skipped. | |
| 101 | + | if lines.is_empty() { | |
| 102 | + | continue; | |
| 103 | + | } | |
| 104 | + | break; | |
| 105 | + | } | |
| 106 | + | lines.push(text); | |
| 107 | + | } | |
| 108 | + | let start = lines.remove(0); | |
| 109 | + | let headers = lines | |
| 110 | + | .into_iter() | |
| 111 | + | .filter_map(|line| line.split_once(':').map(|(name, value)| (name.trim().to_owned(), value.trim().to_owned()))) | |
| 112 | + | .collect(); | |
| 113 | + | Ok(Some(Head { start, headers })) | |
| 114 | + | } | |
| 115 | + | ||
| 116 | + | /// How a message's body ends. | |
| 117 | + | #[derive(Clone, Copy, Debug, PartialEq)] | |
| 118 | + | pub(crate) enum Body { | |
| 119 | + | None, | |
| 120 | + | Length(u64), | |
| 121 | + | Chunked, | |
| 122 | + | /// Until the connection closes: a response with no length. | |
| 123 | + | UntilClose, | |
| 124 | + | } | |
| 125 | + | ||
| 126 | + | fn chunked(head: &Head) -> bool { | |
| 127 | + | head.header("transfer-encoding").is_some_and(|value| value.to_ascii_lowercase().contains("chunked")) | |
| 128 | + | } | |
| 129 | + | ||
| 130 | + | fn length(head: &Head) -> Option<u64> { | |
| 131 | + | head.header("content-length").and_then(|value| value.trim().parse().ok()) | |
| 132 | + | } | |
| 133 | + | ||
| 134 | + | /// A request's body: chunked, a length, or none. | |
| 135 | + | pub(crate) fn request_body(head: &Head) -> Body { | |
| 136 | + | if chunked(head) { | |
| 137 | + | Body::Chunked | |
| 138 | + | } else { | |
| 139 | + | match length(head) { | |
| 140 | + | Some(0) | None => Body::None, | |
| 141 | + | Some(n) => Body::Length(n), | |
| 142 | + | } | |
| 143 | + | } | |
| 144 | + | } | |
| 145 | + | ||
| 146 | + | /// A response's body, which also depends on what was asked. | |
| 147 | + | pub(crate) fn response_body(head: &Head, method: &str) -> Body { | |
| 148 | + | let status = head.status(); | |
| 149 | + | if method.eq_ignore_ascii_case("HEAD") || (100..200).contains(&status) || status == 204 || status == 304 { | |
| 150 | + | return Body::None; | |
| 151 | + | } | |
| 152 | + | if chunked(head) { | |
| 153 | + | return Body::Chunked; | |
| 154 | + | } | |
| 155 | + | match length(head) { | |
| 156 | + | Some(0) => Body::None, | |
| 157 | + | Some(n) => Body::Length(n), | |
| 158 | + | None => Body::UntilClose, | |
| 159 | + | } | |
| 160 | + | } | |
| 161 | + | ||
| 162 | + | /// Copies a body as it is framed, flushing as each piece arrives. | |
| 163 | + | pub(crate) fn copy_body<R: BufRead, W: Write>(reader: &mut R, writer: &mut W, body: Body) -> io::Result<()> { | |
| 164 | + | match body { | |
| 165 | + | Body::None => Ok(()), | |
| 166 | + | Body::Length(n) => { | |
| 167 | + | let copied = io::copy(&mut reader.take(n), writer)?; | |
| 168 | + | writer.flush()?; | |
| 169 | + | if copied < n { | |
| 170 | + | return Err(io::Error::new(io::ErrorKind::UnexpectedEof, "the body ended early")); | |
| 171 | + | } | |
| 172 | + | Ok(()) | |
| 173 | + | } | |
| 174 | + | Body::UntilClose => { | |
| 175 | + | io::copy(reader, writer)?; | |
| 176 | + | writer.flush() | |
| 177 | + | } | |
| 178 | + | Body::Chunked => loop { | |
| 179 | + | let mut line = Vec::new(); | |
| 180 | + | if reader.read_until(b'\n', &mut line)? == 0 { | |
| 181 | + | return Err(io::Error::new(io::ErrorKind::UnexpectedEof, "a chunked body ended early")); | |
| 182 | + | } | |
| 183 | + | writer.write_all(&line)?; | |
| 184 | + | let size = chunk_size(&line)?; | |
| 185 | + | if size == 0 { | |
| 186 | + | // Trailers, then a blank line. | |
| 187 | + | loop { | |
| 188 | + | let mut trailer = Vec::new(); | |
| 189 | + | if reader.read_until(b'\n', &mut trailer)? == 0 { | |
| 190 | + | break; | |
| 191 | + | } | |
| 192 | + | writer.write_all(&trailer)?; | |
| 193 | + | if trailer == b"\r\n" || trailer == b"\n" { | |
| 194 | + | break; | |
| 195 | + | } | |
| 196 | + | } | |
| 197 | + | writer.flush()?; | |
| 198 | + | return Ok(()); | |
| 199 | + | } | |
| 200 | + | // The chunk and its CRLF. | |
| 201 | + | let copied = io::copy(&mut reader.take(size + 2), writer)?; | |
| 202 | + | writer.flush()?; | |
| 203 | + | if copied < size + 2 { | |
| 204 | + | return Err(io::Error::new(io::ErrorKind::UnexpectedEof, "a chunk ended early")); | |
| 205 | + | } | |
| 206 | + | }, | |
| 207 | + | } | |
| 208 | + | } | |
| 209 | + | ||
| 210 | + | fn chunk_size(line: &[u8]) -> io::Result<u64> { | |
| 211 | + | let text = String::from_utf8_lossy(line); | |
| 212 | + | let hex = text.trim().split(';').next().unwrap_or_default().trim(); | |
| 213 | + | u64::from_str_radix(hex, 16).map_err(|_| io::Error::new(io::ErrorKind::InvalidData, format!("not a chunk size: {hex:?}"))) | |
| 214 | + | } | |
| 215 | + | ||
| 216 | + | /// Reads a whole body, unframed. | |
| 217 | + | pub(crate) fn read_body<R: BufRead>(reader: &mut R, body: Body) -> io::Result<Vec<u8>> { | |
| 218 | + | let mut out = Vec::new(); | |
| 219 | + | match body { | |
| 220 | + | Body::None => {} | |
| 221 | + | Body::Length(n) => { | |
| 222 | + | if n > MAX_BODY { | |
| 223 | + | return Err(io::Error::new(io::ErrorKind::InvalidData, "a body too large to read")); | |
| 224 | + | } | |
| 225 | + | reader.take(n).read_to_end(&mut out)?; | |
| 226 | + | if (out.len() as u64) < n { | |
| 227 | + | return Err(io::Error::new(io::ErrorKind::UnexpectedEof, "the body ended early")); | |
| 228 | + | } | |
| 229 | + | } | |
| 230 | + | Body::UntilClose => { | |
| 231 | + | reader.take(MAX_BODY).read_to_end(&mut out)?; | |
| 232 | + | } | |
| 233 | + | Body::Chunked => loop { | |
| 234 | + | let mut line = Vec::new(); | |
| 235 | + | if reader.read_until(b'\n', &mut line)? == 0 { | |
| 236 | + | return Err(io::Error::new(io::ErrorKind::UnexpectedEof, "a chunked body ended early")); | |
| 237 | + | } | |
| 238 | + | let size = chunk_size(&line)?; | |
| 239 | + | if size == 0 { | |
| 240 | + | loop { | |
| 241 | + | let mut trailer = Vec::new(); | |
| 242 | + | if reader.read_until(b'\n', &mut trailer)? == 0 || trailer == b"\r\n" || trailer == b"\n" { | |
| 243 | + | break; | |
| 244 | + | } | |
| 245 | + | } | |
| 246 | + | break; | |
| 247 | + | } | |
| 248 | + | if out.len() as u64 + size > MAX_BODY { | |
| 249 | + | return Err(io::Error::new(io::ErrorKind::InvalidData, "a body too large to read")); | |
| 250 | + | } | |
| 251 | + | let mut chunk = Vec::new(); | |
| 252 | + | reader.take(size).read_to_end(&mut chunk)?; | |
| 253 | + | out.extend_from_slice(&chunk); | |
| 254 | + | let mut crlf = Vec::new(); | |
| 255 | + | reader.read_until(b'\n', &mut crlf)?; | |
| 256 | + | }, | |
| 257 | + | } | |
| 258 | + | Ok(out) | |
| 259 | + | } | |
| 260 | + | ||
| 261 | + | /// A head with its body framed by length, for a body the proxy rewrote. | |
| 262 | + | pub(crate) fn with_length(mut head: Head, body: &[u8]) -> Vec<u8> { | |
| 263 | + | head.remove_header("transfer-encoding"); | |
| 264 | + | head.set_header("Content-Length", &body.len().to_string()); | |
| 265 | + | let mut out = head.to_bytes(); | |
| 266 | + | out.extend_from_slice(body); | |
| 267 | + | out | |
| 268 | + | } | |
| 269 | + | ||
| 270 | + | /// A whole JSON response, as the Engine words its errors. | |
| 271 | + | pub(crate) fn json_response(status: u16, reason: &str, body: &serde_json::Value) -> Vec<u8> { | |
| 272 | + | let text = body.to_string(); | |
| 273 | + | let head = Head { | |
| 274 | + | start: format!("HTTP/1.1 {status} {reason}"), | |
| 275 | + | headers: vec![("Content-Type".into(), "application/json".into())], | |
| 276 | + | }; | |
| 277 | + | with_length(head, text.as_bytes()) | |
| 278 | + | } | |
| 279 | + | ||
| 280 | + | /// An empty `200 OK`. | |
| 281 | + | pub(crate) fn empty_ok() -> Vec<u8> { | |
| 282 | + | with_length(Head { start: "HTTP/1.1 200 OK".into(), headers: Vec::new() }, b"") | |
| 283 | + | } | |
| 284 | + | ||
| 285 | + | #[cfg(test)] | |
| 286 | + | mod tests { | |
| 287 | + | use super::*; | |
| 288 | + | use std::io::BufReader; | |
| 289 | + | ||
| 290 | + | #[test] | |
| 291 | + | fn heads_are_read_and_written_back() { | |
| 292 | + | let raw = b"POST /v1.47/containers/create?name=db HTTP/1.1\r\nHost: api.moby.localhost\r\nContent-Type: application/json\r\nContent-Length: 2\r\n\r\n{}"; | |
| 293 | + | let mut reader = BufReader::new(&raw[..]); | |
| 294 | + | let head = read_head(&mut reader).unwrap().unwrap(); | |
| 295 | + | assert_eq!(head.method(), "POST"); | |
| 296 | + | assert_eq!(head.target(), "/v1.47/containers/create?name=db"); | |
| 297 | + | assert_eq!(head.header("content-length"), Some("2")); | |
| 298 | + | assert_eq!(request_body(&head), Body::Length(2)); | |
| 299 | + | assert_eq!(read_body(&mut reader, Body::Length(2)).unwrap(), b"{}"); | |
| 300 | + | assert!(read_head(&mut reader).unwrap().is_none()); | |
| 301 | + | let mut again = head.clone(); | |
| 302 | + | again.set_target("/v1.47/containers/create"); | |
| 303 | + | assert!(String::from_utf8(again.to_bytes()).unwrap().starts_with("POST /v1.47/containers/create HTTP/1.1\r\n")); | |
| 304 | + | } | |
| 305 | + | ||
| 306 | + | #[test] | |
| 307 | + | fn chunked_bodies_copy_verbatim_and_read_unframed() { | |
| 308 | + | let raw = b"5\r\nhello\r\n6;ext=1\r\n world\r\n0\r\n\r\nNEXT"; | |
| 309 | + | let mut copied = Vec::new(); | |
| 310 | + | let mut reader = BufReader::new(&raw[..]); | |
| 311 | + | copy_body(&mut reader, &mut copied, Body::Chunked).unwrap(); | |
| 312 | + | assert_eq!(copied, &raw[..raw.len() - 4]); | |
| 313 | + | let mut rest = String::new(); | |
| 314 | + | reader.read_to_string(&mut rest).unwrap(); | |
| 315 | + | assert_eq!(rest, "NEXT"); | |
| 316 | + | let mut reader = BufReader::new(&raw[..]); | |
| 317 | + | assert_eq!(read_body(&mut reader, Body::Chunked).unwrap(), b"hello world"); | |
| 318 | + | } | |
| 319 | + | ||
| 320 | + | #[test] | |
| 321 | + | fn response_bodies_follow_the_request_and_the_status() { | |
| 322 | + | let head = |start: &str, headers: &[(&str, &str)]| Head { | |
| 323 | + | start: start.into(), | |
| 324 | + | headers: headers.iter().map(|(k, v)| (k.to_string(), v.to_string())).collect(), | |
| 325 | + | }; | |
| 326 | + | assert_eq!(response_body(&head("HTTP/1.1 200 OK", &[("Content-Length", "10")]), "HEAD"), Body::None); | |
| 327 | + | assert_eq!(response_body(&head("HTTP/1.1 204 No Content", &[]), "POST"), Body::None); | |
| 328 | + | assert_eq!(response_body(&head("HTTP/1.1 101 UPGRADED", &[]), "POST"), Body::None); | |
| 329 | + | assert_eq!(response_body(&head("HTTP/1.1 200 OK", &[("Transfer-Encoding", "chunked")]), "GET"), Body::Chunked); | |
| 330 | + | assert_eq!(response_body(&head("HTTP/1.1 200 OK", &[]), "GET"), Body::UntilClose); | |
| 331 | + | assert!(head("POST /grpc HTTP/1.1", &[("Connection", "Upgrade"), ("Upgrade", "h2c")]).upgrades()); | |
| 332 | + | } | |
| 333 | + | ||
| 334 | + | #[test] | |
| 335 | + | fn a_rewritten_body_gets_its_length() { | |
| 336 | + | let head = Head { start: "HTTP/1.1 200 OK".into(), headers: vec![("Transfer-Encoding".into(), "chunked".into())] }; | |
| 337 | + | let out = String::from_utf8(with_length(head, b"{\"a\":1}")).unwrap(); | |
| 338 | + | assert!(out.contains("Content-Length: 7\r\n")); | |
| 339 | + | assert!(!out.to_ascii_lowercase().contains("chunked")); | |
| 340 | + | assert!(out.ends_with("\r\n\r\n{\"a\":1}")); | |
| 341 | + | } | |
| 342 | + | } |
| 1 | + | //! Docker in a workflow job on g1t's own machines: a Docker Engine of the | |
| 2 | + | //! job's own, inside its sandbox, started the first time anything asks | |
| 3 | + | //! for it, and gone with the sandbox when the job ends. | |
| 4 | + | //! | |
| 5 | + | //! - `engine` starts it, as root inside the sandbox (as Cloudflare | |
| 6 | + | //! Containers run Docker), with no iptables and no IP forwarding, which | |
| 7 | + | //! a sandbox does not have. | |
| 8 | + | //! - `api` is `/var/run/docker.sock`: the Engine's API, with containers | |
| 9 | + | //! moved to the job's own network, where its guardrails apply. | |
| 10 | + | //! - `oci` is the `runc` the Engine runs containers with: build steps on | |
| 11 | + | //! the job's network, and a guarded job's egress certificate in every | |
| 12 | + | //! container. | |
| 13 | + | //! - `http` is the HTTP/1.1 the proxy reads. | |
| 14 | + | //! | |
| 15 | + | //! Nothing here is shared with another job: each job has its own sandbox, | |
| 16 | + | //! and so its own Engine, images and build cache. | |
| 17 | + | ||
| 18 | + | // Off g1t's Linux machines only the pure parts are used, by tests. | |
| 19 | + | #![cfg_attr(not(target_os = "linux"), allow(dead_code))] | |
| 20 | + | ||
| 21 | + | pub(crate) mod api; | |
| 22 | + | pub(crate) mod engine; | |
| 23 | + | pub(crate) mod http; | |
| 24 | + | pub(crate) mod oci; | |
| 25 | + | ||
| 26 | + | use std::sync::Mutex; | |
| 27 | + | ||
| 28 | + | /// Lines for the job's log, from threads that do not hold it: the Engine | |
| 29 | + | /// starting, a port that could not be forwarded. | |
| 30 | + | static NOTES: Mutex<Vec<String>> = Mutex::new(Vec::new()); | |
| 31 | + | ||
| 32 | + | pub(crate) fn note(line: impl Into<String>) { | |
| 33 | + | if let Ok(mut notes) = NOTES.lock() { | |
| 34 | + | notes.push(line.into()); | |
| 35 | + | } | |
| 36 | + | } | |
| 37 | + | ||
| 38 | + | /// The lines noted since the last call. | |
| 39 | + | pub(crate) fn take_notes() -> Vec<String> { | |
| 40 | + | NOTES.lock().map(|mut notes| std::mem::take(&mut *notes)).unwrap_or_default() | |
| 41 | + | } |
| 1 | + | //! `runc`, as the job's Docker Engine finds it. The Engine is started with | |
| 2 | + | //! a folder of g1t's first on its `PATH`, holding this program under the | |
| 3 | + | //! name `runc`, so every container it and its builder start passes | |
| 4 | + | //! through here on its way to the real runc. Two changes, then the real | |
| 5 | + | //! runc runs with the same arguments: | |
| 6 | + | //! | |
| 7 | + | //! - **BuildKit's `RUN` steps** that would join a bridge network join the | |
| 8 | + | //! job's own network instead (their network namespace and libnetwork's | |
| 9 | + | //! hook are taken out of the container's config), because a sandbox has | |
| 10 | + | //! no route out of a bridge. `RUN --network=none` stays without one. | |
| 11 | + | //! - **In a guarded job**, every container (`docker run`, services, build | |
| 12 | + | //! steps, `docker exec`) gets the certificate the job's HTTPS is | |
| 13 | + | //! re-signed with: the folder of certificates at `/dev/g1t-egress`, and | |
| 14 | + | //! the variables that point common tools at it, unless the container | |
| 15 | + | //! sets them itself. `/dev` is a filesystem of the container's own, so | |
| 16 | + | //! nothing of this is ever written into an image's layers. | |
| 17 | + | //! | |
| 18 | + | //! Anything unexpected leaves the config as it was: this never stops a | |
| 19 | + | //! container from starting. | |
| 20 | + | ||
| 21 | + | use serde_json::{Value, json}; | |
| 22 | + | ||
| 23 | + | /// Where the certificates are seen inside a container. | |
| 24 | + | pub(crate) const CA_MOUNT: &str = "/dev/g1t-egress"; | |
| 25 | + | /// The system's bundle, with the egress certificate added. | |
| 26 | + | pub(crate) const BUNDLE: &str = "ca-certificates.crt"; | |
| 27 | + | /// The egress certificate alone. | |
| 28 | + | pub(crate) const EGRESS: &str = "egress-ca.crt"; | |
| 29 | + | ||
| 30 | + | /// The variables a container is given in a guarded job, as the sandbox's | |
| 31 | + | /// own (services/runner egress.ts `EGRESS_ENV`) point its tools. | |
| 32 | + | pub(crate) fn ca_variables() -> Vec<(&'static str, String)> { | |
| 33 | + | let bundle = format!("{CA_MOUNT}/{BUNDLE}"); | |
| 34 | + | vec![ | |
| 35 | + | ("SSL_CERT_FILE", bundle.clone()), | |
| 36 | + | ("NODE_EXTRA_CA_CERTS", format!("{CA_MOUNT}/{EGRESS}")), | |
| 37 | + | ("REQUESTS_CA_BUNDLE", bundle.clone()), | |
| 38 | + | ("CURL_CA_BUNDLE", bundle.clone()), | |
| 39 | + | ("PIP_CERT", bundle.clone()), | |
| 40 | + | ("GIT_SSL_CAINFO", bundle.clone()), | |
| 41 | + | ("CARGO_HTTP_CAINFO", bundle), | |
| 42 | + | ] | |
| 43 | + | } | |
| 44 | + | ||
| 45 | + | /// What runc was asked to do, from its arguments. | |
| 46 | + | #[derive(Debug, Default, PartialEq)] | |
| 47 | + | pub(crate) struct Call { | |
| 48 | + | pub(crate) command: Option<String>, | |
| 49 | + | pub(crate) bundle: Option<String>, | |
| 50 | + | /// `runc exec --process <file>`. | |
| 51 | + | pub(crate) process: Option<String>, | |
| 52 | + | } | |
| 53 | + | ||
| 54 | + | /// runc's global flags that take a value. | |
| 55 | + | const GLOBAL_VALUES: &[&str] = &["--root", "--log", "--log-format", "--criu", "--rootless"]; | |
| 56 | + | ||
| 57 | + | pub(crate) fn parse(args: &[String]) -> Call { | |
| 58 | + | let mut call = Call::default(); | |
| 59 | + | let mut iter = args.iter().peekable(); | |
| 60 | + | while let Some(arg) = iter.next() { | |
| 61 | + | if call.command.is_none() { | |
| 62 | + | if GLOBAL_VALUES.contains(&arg.as_str()) { | |
| 63 | + | iter.next(); | |
| 64 | + | } else if !arg.starts_with('-') { | |
| 65 | + | call.command = Some(arg.clone()); | |
| 66 | + | } | |
| 67 | + | continue; | |
| 68 | + | } | |
| 69 | + | let (flag, inline) = match arg.split_once('=') { | |
| 70 | + | Some((flag, value)) if flag.starts_with("--") => (flag, Some(value.to_owned())), | |
| 71 | + | _ => (arg.as_str(), None), | |
| 72 | + | }; | |
| 73 | + | let mut value = || inline.clone().or_else(|| iter.next().cloned()); | |
| 74 | + | match flag { | |
| 75 | + | "--bundle" | "-b" => call.bundle = value(), | |
| 76 | + | "--process" | "-p" => call.process = value(), | |
| 77 | + | _ => {} | |
| 78 | + | } | |
| 79 | + | } | |
| 80 | + | call | |
| 81 | + | } | |
| 82 | + | ||
| 83 | + | /// Whether a hook entry is libnetwork's, which joins a container to a | |
| 84 | + | /// network the Engine set up. | |
| 85 | + | fn libnetwork_hook(hook: &Value) -> bool { | |
| 86 | + | hook.get("args") | |
| 87 | + | .and_then(Value::as_array) | |
| 88 | + | .is_some_and(|args| args.iter().any(|a| a.as_str().is_some_and(|a| a.contains("libnetwork-setkey")))) | |
| 89 | + | } | |
| 90 | + | ||
| 91 | + | /// Adds the certificates and their variables to a process's environment. | |
| 92 | + | fn add_variables(process: &mut Value) -> bool { | |
| 93 | + | let Some(env) = process.get_mut("env").and_then(Value::as_array_mut) else { return false }; | |
| 94 | + | let mut changed = false; | |
| 95 | + | for (name, value) in ca_variables() { | |
| 96 | + | let prefix = format!("{name}="); | |
| 97 | + | if !env.iter().any(|e| e.as_str().is_some_and(|e| e.starts_with(&prefix))) { | |
| 98 | + | env.push(json!(format!("{name}={value}"))); | |
| 99 | + | changed = true; | |
| 100 | + | } | |
| 101 | + | } | |
| 102 | + | changed | |
| 103 | + | } | |
| 104 | + | ||
| 105 | + | /// Changes a container's `config.json`. `ca_dir`: the folder of | |
| 106 | + | /// certificates to give it, in a guarded job. Returns whether it changed. | |
| 107 | + | pub(crate) fn patch_config(config: &mut Value, bundle: &str, ca_dir: Option<&str>) -> bool { | |
| 108 | + | let mut changed = false; | |
| 109 | + | // A BuildKit step bound for a bridge network: the job's network instead. | |
| 110 | + | if bundle.contains("/buildkit/") { | |
| 111 | + | let mut bridged = false; | |
| 112 | + | if let Some(hooks) = config.get_mut("hooks").and_then(Value::as_object_mut) { | |
| 113 | + | for list in hooks.values_mut() { | |
| 114 | + | if let Some(entries) = list.as_array_mut() { | |
| 115 | + | let before = entries.len(); | |
| 116 | + | entries.retain(|hook| !libnetwork_hook(hook)); | |
| 117 | + | bridged |= entries.len() != before; | |
| 118 | + | } | |
| 119 | + | } | |
| 120 | + | } | |
| 121 | + | if bridged && let Some(namespaces) = config.pointer_mut("/linux/namespaces").and_then(Value::as_array_mut) { | |
| 122 | + | namespaces.retain(|ns| ns.get("type").and_then(Value::as_str) != Some("network")); | |
| 123 | + | changed = true; | |
| 124 | + | } | |
| 125 | + | } | |
| 126 | + | if let Some(dir) = ca_dir { | |
| 127 | + | if let Some(mounts) = config.get_mut("mounts").and_then(Value::as_array_mut) | |
| 128 | + | && !mounts.iter().any(|m| m.get("destination").and_then(Value::as_str) == Some(CA_MOUNT)) | |
| 129 | + | { | |
| 130 | + | mounts.push(json!({ | |
| 131 | + | "destination": CA_MOUNT, | |
| 132 | + | "type": "bind", | |
| 133 | + | "source": dir, | |
| 134 | + | "options": ["rbind", "ro", "nosuid", "nodev", "noexec"], | |
| 135 | + | })); | |
| 136 | + | changed = true; | |
| 137 | + | } | |
| 138 | + | if let Some(process) = config.get_mut("process") { | |
| 139 | + | changed |= add_variables(process); | |
| 140 | + | } | |
| 141 | + | } | |
| 142 | + | changed | |
| 143 | + | } | |
| 144 | + | ||
| 145 | + | /// Changes the process of a `runc exec` (`docker exec`), which has an | |
| 146 | + | /// environment of its own. | |
| 147 | + | pub(crate) fn patch_process(process: &mut Value, ca_dir: Option<&str>) -> bool { | |
| 148 | + | ca_dir.is_some() && add_variables(process) | |
| 149 | + | } | |
| 150 | + | ||
| 151 | + | /// Whether this program was started as `runc`. | |
| 152 | + | pub(crate) fn invoked_as_runc() -> bool { | |
| 153 | + | std::env::args_os() | |
| 154 | + | .next() | |
| 155 | + | .and_then(|arg| std::path::Path::new(&arg).file_name().map(|name| name == "runc")) | |
| 156 | + | .unwrap_or(false) | |
| 157 | + | } | |
| 158 | + | ||
| 159 | + | /// Rewrites a JSON file in place, if `change` changes it. | |
| 160 | + | #[cfg(unix)] | |
| 161 | + | fn rewrite(path: &std::path::Path, change: impl FnOnce(&mut Value) -> bool) { | |
| 162 | + | let Ok(text) = std::fs::read(path) else { return }; | |
| 163 | + | let Ok(mut value) = serde_json::from_slice::<Value>(&text) else { return }; | |
| 164 | + | if change(&mut value) | |
| 165 | + | && let Ok(out) = serde_json::to_vec(&value) | |
| 166 | + | { | |
| 167 | + | let staged = path.with_extension("g1t"); | |
| 168 | + | if std::fs::write(&staged, out).is_ok() { | |
| 169 | + | let _ = std::fs::rename(&staged, path); | |
| 170 | + | } | |
| 171 | + | } | |
| 172 | + | } | |
| 173 | + | ||
| 174 | + | /// `runc …`: changes the container's config, then becomes the real runc. | |
| 175 | + | #[cfg(unix)] | |
| 176 | + | pub(crate) fn main() -> ! { | |
| 177 | + | use std::os::unix::process::CommandExt; | |
| 178 | + | let args: Vec<String> = std::env::args().skip(1).collect(); | |
| 179 | + | let call = parse(&args); | |
| 180 | + | let ca_dir = std::env::var("G1T_DOCKER_CA_DIR").ok().filter(|dir| std::path::Path::new(dir).is_dir()); | |
| 181 | + | match (call.command.as_deref(), &call.bundle, &call.process) { | |
| 182 | + | (Some("create" | "run"), Some(bundle), _) => { | |
| 183 | + | rewrite(&std::path::Path::new(bundle).join("config.json"), |config| patch_config(config, bundle, ca_dir.as_deref())); | |
| 184 | + | } | |
| 185 | + | (Some("exec"), _, Some(process)) => rewrite(std::path::Path::new(process), |process| patch_process(process, ca_dir.as_deref())), | |
| 186 | + | _ => {} | |
| 187 | + | } | |
| 188 | + | let real = std::env::var("G1T_REAL_RUNC").unwrap_or_else(|_| "/usr/bin/runc".into()); | |
| 189 | + | let error = std::process::Command::new(&real).arg0("runc").args(&args).exec(); | |
| 190 | + | eprintln!("g1t-runner: could not run {real}: {error}"); | |
| 191 | + | std::process::exit(127) | |
| 192 | + | } | |
| 193 | + | ||
| 194 | + | #[cfg(not(unix))] | |
| 195 | + | pub(crate) fn main() -> ! { | |
| 196 | + | eprintln!("g1t-runner: runc runs on Linux only"); | |
| 197 | + | std::process::exit(127) | |
| 198 | + | } | |
| 199 | + | ||
| 200 | + | #[cfg(test)] | |
| 201 | + | mod tests { | |
| 202 | + | use super::*; | |
| 203 | + | ||
| 204 | + | fn args(text: &str) -> Vec<String> { | |
| 205 | + | text.split_whitespace().map(str::to_owned).collect() | |
| 206 | + | } | |
| 207 | + | ||
| 208 | + | #[test] | |
| 209 | + | fn calls_are_read_as_containerd_and_buildkit_make_them() { | |
| 210 | + | let call = parse(&args( | |
| 211 | + | "--root /var/run/docker/runtime-runc/moby --log /x/log.json --log-format json create --bundle /var/run/docker/containerd/daemon/io.containerd.runtime.v2.task/moby/abc --pid-file /x/init.pid abc", | |
| 212 | + | )); | |
| 213 | + | assert_eq!(call.command.as_deref(), Some("create")); | |
| 214 | + | assert_eq!(call.bundle.as_deref(), Some("/var/run/docker/containerd/daemon/io.containerd.runtime.v2.task/moby/abc")); | |
| 215 | + | let call = parse(&args("--log /var/lib/docker/buildkit/executor/runc-log.json --log-format json run --bundle /var/lib/docker/buildkit/executor/x1 --keep x1")); | |
| 216 | + | assert_eq!((call.command.as_deref(), call.bundle.as_deref()), (Some("run"), Some("/var/lib/docker/buildkit/executor/x1"))); | |
| 217 | + | let call = parse(&args("--root /r exec --process /tmp/runc-process1 --detach --pid-file /p abc")); | |
| 218 | + | assert_eq!((call.command.as_deref(), call.process.as_deref()), (Some("exec"), Some("/tmp/runc-process1"))); | |
| 219 | + | assert_eq!(parse(&args("--root=/r start abc")).command.as_deref(), Some("start")); | |
| 220 | + | assert_eq!(parse(&args("create --bundle=/b x")).bundle.as_deref(), Some("/b")); | |
| 221 | + | assert_eq!(parse(&args("--version")), Call::default()); | |
| 222 | + | } | |
| 223 | + | ||
| 224 | + | fn build_step(hooked: bool) -> Value { | |
| 225 | + | let hooks = if hooked { | |
| 226 | + | json!({ "prestart": [{ "path": "/proc/21/exe", "args": ["libnetwork-setkey", "-exec-root=/var/run/docker", "abc", "def"] }] }) | |
| 227 | + | } else { | |
| 228 | + | json!({}) | |
| 229 | + | }; | |
| 230 | + | json!({ | |
| 231 | + | "hostname": "buildkitsandbox", | |
| 232 | + | "process": { "env": ["PATH=/usr/bin", "SSL_CERT_FILE=/mine.pem"] }, | |
| 233 | + | "mounts": [{ "destination": "/proc" }, { "destination": "/dev", "type": "tmpfs" }], | |
| 234 | + | "linux": { "namespaces": [{ "type": "pid" }, { "type": "network" }, { "type": "mount" }] }, | |
| 235 | + | "hooks": hooks, | |
| 236 | + | }) | |
| 237 | + | } | |
| 238 | + | ||
| 239 | + | #[test] | |
| 240 | + | fn build_steps_bound_for_a_bridge_join_the_jobs_network() { | |
| 241 | + | let mut config = build_step(true); | |
| 242 | + | assert!(patch_config(&mut config, "/var/lib/docker/buildkit/executor/x1", None)); | |
| 243 | + | let namespaces: Vec<&str> = config["linux"]["namespaces"].as_array().unwrap().iter().map(|n| n["type"].as_str().unwrap()).collect(); | |
| 244 | + | assert_eq!(namespaces, vec!["pid", "mount"]); | |
| 245 | + | assert!(config["hooks"]["prestart"].as_array().unwrap().is_empty()); | |
| 246 | + | // RUN --network=none: no libnetwork hook, so its own empty network. | |
| 247 | + | let mut config = build_step(false); | |
| 248 | + | assert!(!patch_config(&mut config, "/var/lib/docker/buildkit/executor/x2", None)); | |
| 249 | + | assert_eq!(config["linux"]["namespaces"].as_array().unwrap().len(), 3); | |
| 250 | + | // A container the Engine runs is the API proxy's business, not this. | |
| 251 | + | let mut config = build_step(true); | |
| 252 | + | assert!(!patch_config(&mut config, "/run/containerd/io.containerd.runtime.v2.task/moby/abc", None)); | |
| 253 | + | } | |
| 254 | + | ||
| 255 | + | #[test] | |
| 256 | + | fn guarded_containers_get_the_certificates_without_losing_their_own_settings() { | |
| 257 | + | let mut config = build_step(false); | |
| 258 | + | assert!(patch_config(&mut config, "/run/containerd/io.containerd.runtime.v2.task/moby/abc", Some("/run/g1t-docker/certs"))); | |
| 259 | + | let mounts = config["mounts"].as_array().unwrap(); | |
| 260 | + | let last = mounts.last().unwrap(); | |
| 261 | + | assert_eq!(last["destination"], CA_MOUNT); | |
| 262 | + | assert_eq!(last["source"], "/run/g1t-docker/certs"); | |
| 263 | + | assert!(last["options"].as_array().unwrap().contains(&json!("ro"))); | |
| 264 | + | let env: Vec<&str> = config["process"]["env"].as_array().unwrap().iter().map(|e| e.as_str().unwrap()).collect(); | |
| 265 | + | assert!(env.contains(&"SSL_CERT_FILE=/mine.pem")); | |
| 266 | + | assert!(!env.contains(&"SSL_CERT_FILE=/dev/g1t-egress/ca-certificates.crt")); | |
| 267 | + | assert!(env.contains(&"NODE_EXTRA_CA_CERTS=/dev/g1t-egress/egress-ca.crt")); | |
| 268 | + | // Patching twice changes nothing more. | |
| 269 | + | assert!(!patch_config(&mut config, "/run/containerd/io.containerd.runtime.v2.task/moby/abc", Some("/run/g1t-docker/certs"))); | |
| 270 | + | let mut process = json!({ "env": ["PATH=/bin"] }); | |
| 271 | + | assert!(patch_process(&mut process, Some("/run/g1t-docker/certs"))); | |
| 272 | + | assert!(!patch_process(&mut json!({ "env": [] }), None)); | |
| 273 | + | } | |
| 274 | + | } |
| 40 | 40 | mod clone; | |
| 41 | 41 | mod confidence; | |
| 42 | 42 | mod deploy; | |
| 43 | + | mod docker; | |
| 43 | 44 | mod guard; | |
| 44 | 45 | mod harness; | |
| 45 | 46 | mod learned; | |
| ⋯ | |||
| 206 | 207 | } | |
| 207 | 208 | ||
| 208 | 209 | fn main() { | |
| 210 | + | // The runc the job's Docker Engine starts containers with (docker/oci.rs). | |
| 211 | + | if docker::oci::invoked_as_runc() { | |
| 212 | + | docker::oci::main(); | |
| 213 | + | } | |
| 209 | 214 | // A self-hosted runner's commands; the modes below are what it, and | |
| 210 | 215 | // g1t's sandboxes, run work with. | |
| 211 | 216 | let args: Vec<String> = std::env::args().skip(1).collect(); | |
| 140 | 140 | let mut command = Command::new("docker"); | |
| 141 | 141 | command.args(["run", "--rm", "--name", &name, "--label", &format!("sh.g1t.runner={}", config.runner)]); | |
| 142 | 142 | command.args(["--pull", "missing", "--init"]); | |
| 143 | + | // The job is in its `container:` image already: the harness inside | |
| 144 | + | // does not start it again (actions/containers.rs). | |
| 145 | + | command.args(["-e", "G1T_JOB_CONTAINER=1"]); | |
| 143 | 146 | for name in env.keys() { | |
| 144 | 147 | command.args(["-e", name]); | |
| 145 | 148 | } |
| 198 | 198 | ||
| 199 | 199 | | Image | Built from | Holds | Rebuilt | | |
| 200 | 200 | | --- | --- | --- | --- | | |
| 201 | − | | **Base**, `g1t-runner:base-<date>-<inputs>` | `services/runner/base/Dockerfile` | Debian bookworm, Node 24, Python 3.11, Go (from go.dev), Rust stable for the `node` user with rustfmt, clippy and the `wasm32-unknown-unknown` target, build-essential, git, ripgrep, jq, zstd, sudo, and the pinned Claude Code CLI on top | When its folder changes, weekly, or by hand (`build-base`) | | |
| 201 | + | | **Base**, `g1t-runner:base-<date>-<inputs>` | `services/runner/base/Dockerfile` | Debian bookworm, Node 24, Python 3.11, Docker (Engine, Buildx, Compose, from Docker's apt repository), Go (from go.dev), Rust stable for the `node` user with rustfmt, clippy and the `wasm32-unknown-unknown` target, build-essential, musl-tools, git, ripgrep, jq, zstd, sudo, and the pinned Claude Code CLI on top | When its folder changes, weekly, or by hand (`build-base`) | | |
| 202 | 202 | | **Runner**, `g1t-runner:<content hash>` | `services/runner/Dockerfile`: `FROM` the base, plus one file | The g1t runner, a static binary | When the binary or the base changes | | |
| 203 | 203 | ||
| 204 | 204 | Both are pushed to one repository of Cloudflare's registry, | |
| ⋯ | |||
| 255 | 255 | 1. A deploy computes the tag and asks the registry whether it is there | |
| 256 | 256 | (a `HEAD` of its manifest, with credentials from Wrangler; no Docker). | |
| 257 | 257 | 2. If it is, nothing is built: the deploy uses it. | |
| 258 | − | 3. If not, and Docker is here, it builds the binary and the image (seconds | |
| 259 | − | on a warm machine) and pushes it. | |
| 260 | − | 4. If not, and Docker is not here (a g1t Actions sandbox), the unit fails | |
| 261 | − | saying to run `node scripts/deploy.mjs image` on a machine with Docker; | |
| 262 | − | then re-run the workflow. | |
| 258 | + | 3. If not, it builds the binary and the image (seconds on a warm machine) | |
| 259 | + | and pushes it. In `deploy.yml` that is the `runner-image` job, on | |
| 260 | + | `g1t-4core`, with the job's own Docker Engine (see | |
| 261 | + | [Docker in workflow jobs](#docker-in-workflow-jobs)): it adds the musl | |
| 262 | + | target, builds the binary natively (the base has `musl-gcc`), pulls the | |
| 263 | + | base from Cloudflare's registry, builds, and pushes one layer. | |
| 264 | + | 4. If Docker does not answer (a machine without it, or jobs with Docker | |
| 265 | + | turned off), the unit fails saying to run `node scripts/deploy.mjs | |
| 266 | + | image` on a machine with Docker; then re-run the workflow. | |
| 263 | 267 | ||
| 264 | 268 | Then `wrangler deploy` is given the image by reference, from a generated | |
| 265 | 269 | config (`services/runner/wrangler.deploy.json`, deleted after, ignored by | |
| ⋯ | |||
| 277 | 281 | ||
| 278 | 282 | `.g1t/workflows/runner-base.yml` does the same weekly (and when the | |
| 279 | 283 | base's folder changes on `main`), and opens a pull request with | |
| 280 | − | `base.json`. It needs Docker, so it runs on a self-hosted runner with the | |
| 281 | − | `docker` label (`runs-on: [self-hosted, docker]`). Until one is | |
| 282 | − | registered, its runs wait for one; run `build-base` by hand instead. | |
| 284 | + | `base.json`. It runs on a self-hosted runner with the `docker` label | |
| 285 | + | (`runs-on: [self-hosted, docker]`): g1t's own machines have Docker now, | |
| 286 | + | but the base's build downloads from Docker's apt repository over HTTPS, | |
| 287 | + | which does not trust a guarded job's egress certificate, so it stays on an | |
| 288 | + | open network. Until a runner is registered, its runs wait for one; run | |
| 289 | + | `build-base` by hand instead. | |
| 283 | 290 | ||
| 284 | 291 | **Sandboxes start from the image.** Cloudflare pulls an image to a | |
| 285 | 292 | machine the first time a sandbox lands there, and keeps it. A smaller base | |
| ⋯ | |||
| 298 | 305 | their CPU (see the public billing guide). The account's Containers limits | |
| 299 | 306 | must allow `standard-4`; Wrangler refuses the deploy otherwise. | |
| 300 | 307 | ||
| 308 | + | #### Docker in workflow jobs | |
| 309 | + | ||
| 310 | + | Workflow jobs on g1t's machines have a Docker Engine of their own | |
| 311 | + | (`crates/runner/src/docker/`; the public guide is | |
| 312 | + | `apps/docs/src/content/docs/guides/actions.md`, "Docker"). What Cloudflare | |
| 313 | + | Containers allow decides how it runs (findings in `docs/PLAN.md`, | |
| 314 | + | "Docker in workflow jobs"): | |
| 315 | + | ||
| 316 | + | | Piece | What it does | | |
| 317 | + | | --- | --- | | |
| 318 | + | | `dockerd` | Started as root with `sudo`, only when the job first uses Docker or has `services:` or `container:`. Flags: `--iptables=false --ip6tables=false --ip-forward=false` (Containers allow neither), the containerd image store, Docker Hub through `mirror.gcr.io`. Its config, socket and log are in `/run/g1t-docker` (`dockerd.log` is the place to look); its data in `/var/lib/docker`, on overlays when the disk takes them, plain copies (`native`) when not. | | |
| 319 | + | | `/var/run/docker.sock` | The runner's API proxy (`docker/api.rs`), which starts the Engine on the first connection. Containers that ask for a bridge network get the job's own (`host`), the names they would have had resolve to 127.0.0.1, and ports published under another number are forwarded. | | |
| 320 | + | | `runc` | The Engine finds the runner binary first on its `PATH` as `runc` (`docker/oci.rs`): BuildKit's `RUN` steps join the job's network, and in a guarded job every container gets the egress certificate at `/dev/g1t-egress`. Then the real `/usr/bin/runc` runs. | | |
| 321 | + | | cgroups | Before the Engine starts, the sandbox's processes move to a cgroup of their own and every controller is handed down, as Docker's own Docker-in-Docker image does, so `--cpus` and `--memory` work. | | |
| 322 | + | ||
| 323 | + | - **Turning it off:** set the runner Worker's `DOCKER` var to `off` | |
| 324 | + | (`services/runner/wrangler.jsonc`) and deploy the runner: new jobs get | |
| 325 | + | no Engine (`G1T_DOCKER=off`), and jobs that need one fail saying Docker | |
| 326 | + | does not answer. Anything else is on. | |
| 327 | + | - **Network:** containers share the job's network, so the guardrails, the | |
| 328 | + | workflow-only domains and the egress Worker apply to them unchanged. The | |
| 329 | + | public registries are in `BUILD_HOSTS` (`services/runner/src/egress.ts`). | |
| 330 | + | - **Isolation:** one Engine per job, inside the job's sandbox (its own | |
| 331 | + | VM), gone with it. The job already had root through `sudo`; Docker adds | |
| 332 | + | no reach beyond the sandbox, and no host socket is ever mounted into one. | |
| 333 | + | - **Checked locally** (2026-10-08, Docker Desktop, the base and runner | |
| 334 | + | image built from this tree, a privileged container standing in for a | |
| 335 | + | sandbox, a pretend API): services with health checks, `localhost` and | |
| 336 | + | names, port forwarding, `docker build` with a networked `RUN`, Compose | |
| 337 | + | with a healthy dependency, `docker://` steps, a Dockerfile action, a | |
| 338 | + | `container:` job with a JavaScript action, an Alpine job container, the | |
| 339 | + | egress certificate in `run`, `exec` and build steps and in no layer, | |
| 340 | + | plain-copy storage, and the deploy's own build and push to a registry. | |
| 341 | + | Not yet seen on Cloudflare itself: watch the first runs' logs for the | |
| 342 | + | `Docker: started` line, and `dockerd.log` if it does not come. | |
| 343 | + | ||
| 301 | 344 | ## Build speed | |
| 302 | 345 | ||
| 303 | 346 | Measured on the development machine (Windows, 32 cores, warm Cargo cache), | |
| ⋯ | |||
| 366 | 409 | - **Build groups:** a stage's units are split so each job shares a build: | |
| 367 | 410 | Rust workers at most four to a job (each a 4-vCPU `g1t-4core` machine), the | |
| 368 | 411 | TypeScript Workers together, each site alone, and a unit whose image must | |
| 369 | − | be rebuilt alone. `fail-fast: false`, so one failed job does not cut | |
| 412 | + | be rebuilt alone (`image: true` in the matrix, also on `g1t-4core`, where | |
| 413 | + | it builds and pushes the image with the job's own Docker Engine). `fail-fast: false`, so one failed job does not cut | |
| 370 | 414 | another off mid-upload; the next stage then does not start. | |
| 371 | 415 | - **Tests:** there is no CI workflow on g1t yet; `main` is kept passing by | |
| 372 | 416 | the merge queue's checks. `check` runs the deploy tool's own tests. When a | |
| 373 | 417 | CI workflow is added, make `plan` wait for it (`workflow_run`, or a job in | |
| 374 | 418 | this file). | |
| 375 | − | - **Machines:** Rust jobs run on `g1t-4core` (4 vCPUs, 12 GiB), the | |
| 376 | − | others on the standard machine (`runs-on: ${{ matrix.rust && 'g1t-4core' || 'ubuntu-latest' }}`). | |
| 419 | + | - **Machines:** Rust jobs and the runner's image run on `g1t-4core` (4 vCPUs, | |
| 420 | + | 12 GiB, 20 GB), the others on the standard machine | |
| 421 | + | (`runs-on: ${{ (matrix.rust || matrix.image) && 'g1t-4core' || 'ubuntu-latest' }}`). | |
| 422 | + | The image job needs the room: the base it builds on is about 3.2 GB | |
| 423 | + | unpacked. | |
| 377 | 424 | - **Caching** (`actions/cache`: up to 2 GB an entry, 10 GB a repository, | |
| 378 | 425 | kept until unused for 7 days): the worker-build binary, worker-build's | |
| 379 | 426 | downloaded tools, `~/.cargo/registry/cache`, and the Cargo target's | |
| ⋯ | |||
| 395 | 442 | ### What the sandbox has | |
| 396 | 443 | ||
| 397 | 444 | The base image (`services/runner/base/Dockerfile`) has Node 24, npm, git, | |
| 398 | − | Go, zstd, and Rust stable for the `node` user with rustfmt, clippy and the | |
| 399 | − | `wasm32-unknown-unknown` target, but not worker-build or Docker. The | |
| 400 | − | workflow's `rustup target add wasm32-unknown-unknown` is then a no-op, and | |
| 401 | − | worker-build is restored from the cache, installed on a miss. worker-build | |
| 445 | + | Go, zstd, Docker, musl-tools, and Rust stable for the `node` user with | |
| 446 | + | rustfmt, clippy and the `wasm32-unknown-unknown` target, but not | |
| 447 | + | worker-build. The workflow's `rustup target add wasm32-unknown-unknown` is | |
| 448 | + | then a no-op, and worker-build is restored from the cache, installed on a | |
| 449 | + | miss. The image job adds `x86_64-unknown-linux-musl` (about 30 MB from | |
| 450 | + | `static.rust-lang.org`) and keeps its Cargo target in the cache. worker-build | |
| 402 | 451 | fetches wasm-bindgen and wasm-opt from GitHub releases and esbuild from | |
| 403 | 452 | npm. All of those hosts are on the list every workflow job may reach. | |
| 404 | 453 | ||
| ⋯ | |||
| 418 | 467 | ``` | |
| 419 | 468 | ||
| 420 | 469 | `api.cloudflare.com` is Wrangler's API; `registry.cloudflare.com` is where | |
| 421 | − | the deploy asks whether the runner's image is already built (and where | |
| 422 | − | `runner-base.yml` pushes). Only `deploy.yml`'s and `runner-base.yml`'s | |
| 470 | + | the deploy asks whether the runner's image is already built, and where the | |
| 471 | + | `runner-image` job pulls the base from and pushes the runner's image to | |
| 472 | + | (as `runner-base.yml` pushes the base). The job's Docker Engine shares the | |
| 473 | + | job's network, so these lines are what let it reach the registry. If a pull | |
| 474 | + | is refused with `g1t guardrails: <host> is not on this project's allowed | |
| 475 | + | domains`, the registry sent the layers from another host: add that host on | |
| 476 | + | the same line. Only `deploy.yml`'s and `runner-base.yml`'s | |
| 423 | 477 | jobs with `environment: production` reach them, which are also the only | |
| 424 | 478 | jobs that can read `CLOUDFLARE_API_TOKEN`. Each change to the list is in | |
| 425 | 479 | the workspace's audit log as `update_guardrails`. | |
| ⋯ | |||
| 436 | 490 | | Account | Queues: Edit | Attaching each unit's queue consumers on deploy | | |
| 437 | 491 | | Account | Workers R2 Storage: Read | Wrangler checks `og`'s bucket binding | | |
| 438 | 492 | | Account | Account Settings: Read | Wrangler reads the account | | |
| 439 | − | | Account | Containers: Edit | The runner's deploy updates its applications (the image reference, the three classes), and gets registry credentials to look for its image. `runner-base.yml` pushes images with it. | | |
| 493 | + | | Account | Containers: Edit | The runner's deploy updates its applications (the image reference, the three classes), and gets registry credentials (`wrangler containers registries credentials --push`, one hour) to look for, pull and push its image. `runner-base.yml` pushes images with it. | | |
| 440 | 494 | | Zone (`g1t.sh`, `g1t.page`) | Workers Routes: Edit | `pages`' zone routes, and custom domains | | |
| 441 | 495 | | Zone (`g1t.sh`, `g1t.page`) | DNS: Edit | Custom domains (`api`, `mcp`, `og`, `models`, `status`, `sudo`, `docs`, `g1t.sh`, `g1t.page`) keep their DNS records | | |
| 442 | 496 | | Zone (`g1t.sh`, `g1t.page`) | Zone: Read | Finding the zone a route names | | |
| ⋯ | |||
| 545 | 599 | repository from the git remote on g1t.sh. A report that fails is one line | |
| 546 | 600 | in the log and never fails the deploy. | |
| 547 | 601 | ||
| 548 | − | When CI cannot finish a deploy, for example a runner image that must be | |
| 549 | − | built (hosted runners have no Docker), deploy from a machine with Docker. | |
| 602 | + | When CI cannot finish a deploy, for example a runner image that will not | |
| 603 | + | build there, deploy from a machine with Docker. | |
| 550 | 604 | The report records it, and production shows the commit that really runs. | |
| 551 | 605 | ||
| 552 | 606 | ## Rolling back | |
| 479 | 479 | commands declared in `.g1t/checks.yaml`, run in sandboxes on every pull request | |
| 480 | 480 | and on every combined state in the landing queue. | |
| 481 | 481 | ||
| 482 | + | ### Docker in workflow jobs (built 2026-10-08) | |
| 483 | + | ||
| 484 | + | > **2026-10-08:** "Why didn't we give CI docker then? We need GitHub | |
| 485 | + | > Actions functionality maxxed baby but with all the good good security | |
| 486 | + | > etc." The trigger: `deploy.yml`'s runner-image job needs `docker build` | |
| 487 | + | > and `docker push`, and failed on hosted runners. | |
| 488 | + | ||
| 489 | + | **What Cloudflare Containers allow** (their FAQ, updated 2026-10-05, and | |
| 490 | + | the Docker-in-Docker guide and example it links): | |
| 491 | + | ||
| 492 | + | - Docker runs inside a container: `docker:dind`, with `dockerd` as | |
| 493 | + | **root**. A rootless Engine does not start there. | |
| 494 | + | - **No iptables**: `--iptables=false --ip6tables=false`, or the Engine | |
| 495 | + | fails setting up its rules. Containers with the `durable_object` | |
| 496 | + | scheduling policy (ours: each sandbox is a Durable Object's) cannot turn | |
| 497 | + | IP forwarding on either: `--ip-forward=false`, or the Engine exits. | |
| 498 | + | - So **a bridge network has no way out**: the guide's answer is | |
| 499 | + | `--network=host` for `docker run` and `docker build`, which gives | |
| 500 | + | containers the outer container's network. | |
| 501 | + | - Built images and containers are lost when the sandbox stops. | |
| 502 | + | ||
| 503 | + | **What was found by trying** (Docker Engine 29.8.2 in a privileged | |
| 504 | + | container standing in for a sandbox, with the same flags): | |
| 505 | + | ||
| 506 | + | - `--bridge=none` makes BuildKit's `RUN` steps fail outright ("network | |
| 507 | + | bridge not found"); with the default bridge they run with no route out. | |
| 508 | + | Containers default to the bridge too. The default bridge is created | |
| 509 | + | fine without iptables, as the FAQ's own example relies on. | |
| 510 | + | - The overlay snapshotter does not work on an overlay root filesystem, and | |
| 511 | + | the containerd image store does not fall back by itself: the Engine | |
| 512 | + | starts, then every container fails to mount. Whether a sandbox's disk | |
| 513 | + | takes overlays is not documented, so the runner tries a mount first and | |
| 514 | + | uses `native` (plain copies) when it fails. `vfs` is not a name the | |
| 515 | + | containerd store accepts. | |
| 516 | + | - `--cpus` and `--memory` need cgroup v2 controllers handed down from a | |
| 517 | + | cgroup with no processes, which `docker:dind`'s entrypoint does and a | |
| 518 | + | plain `dockerd` does not. | |
| 519 | + | - A `runc` earlier on the Engine's `PATH` is used both for containers (via | |
| 520 | + | containerd's shim) and by BuildKit's executor, with the bundle's | |
| 521 | + | `config.json` written before it runs. BuildKit's bridged steps carry | |
| 522 | + | libnetwork's `libnetwork-setkey` prestart hook; `RUN --network=none` | |
| 523 | + | does not. | |
| 524 | + | - Buildx skips `type=gha` caches when the job has no GitHub cache service, | |
| 525 | + | and the build succeeds without one. | |
| 526 | + | - Registries' layer hosts: Docker Hub sends layers from | |
| 527 | + | `production.cloudfront.docker.com` (and `production.cloudflare.docker.com`), | |
| 528 | + | Quay from `cdn0N.quay.io`, Microsoft's from regional | |
| 529 | + | `*.data.mcr.microsoft.com`, public ECR from a CloudFront host; | |
| 530 | + | `mirror.gcr.io` serves its own. | |
| 531 | + | ||
| 532 | + | **What was built** (`crates/runner/src/docker`, `actions/containers.rs`): | |
| 533 | + | ||
| 534 | + | - **One Engine per job, started lazily.** The runner listens on | |
| 535 | + | `/var/run/docker.sock` itself and starts `dockerd` (root, the flags | |
| 536 | + | above, containerd image store, `mirror.gcr.io` first for Docker Hub) on | |
| 537 | + | the first connection, or when the job has `services:` or `container:`. | |
| 538 | + | A log line says it started and how long it took. | |
| 539 | + | - **Containers on the job's network.** The socket is an API proxy: a | |
| 540 | + | container that asks for a bridge or user network gets `host`; its names | |
| 541 | + | (container name, aliases, Compose service, links) resolve to 127.0.0.1 | |
| 542 | + | in later containers (`ExtraHosts`) and in the job's steps | |
| 543 | + | (`/etc/hosts`); `-p 8080:80` is forwarded; `docker inspect` reports the | |
| 544 | + | ports as published; `network connect` adds aliases. Sharing the job's | |
| 545 | + | network is also what keeps the guardrails on every container: the | |
| 546 | + | egress Worker sees their traffic as the job's. | |
| 547 | + | - **A `runc` shim.** The runner binary, as `runc`: BuildKit steps bound for | |
| 548 | + | a bridge lose the network namespace and the libnetwork hook (so they use | |
| 549 | + | the job's network); in a guarded job every container (run, exec, build | |
| 550 | + | step) gets the egress certificate at `/dev/g1t-egress` and the | |
| 551 | + | variables that point tools at it. `/dev` is the container's own tmpfs, | |
| 552 | + | so none of it lands in a layer; checked by saving a built image. | |
| 553 | + | - **Job features:** `services:` (pull, credentials, health waits, logs at | |
| 554 | + | the end, `job.services.*`), `container:` (steps and JavaScript actions | |
| 555 | + | through `docker exec`, Node mounted from the runner, Alpine falls back to | |
| 556 | + | running actions beside it), `docker://` steps and Dockerfile actions in | |
| 557 | + | GitHub's `/github/*` layout, `docker/setup-buildx-action` answered | |
| 558 | + | natively (the job's Engine is the builder), sign-in to g1t's registry | |
| 559 | + | with the run's token. | |
| 560 | + | - **Kill switch:** the runner Worker's `DOCKER` var (`off`). | |
| 561 | + | ||
| 562 | + | **Rejected:** rootless Docker or BuildKit (does not start in Containers); | |
| 563 | + | Podman or buildah with `vfs` (no better networking, less compatible, slow); | |
| 564 | + | a standalone `buildkitd --oci-worker-net=host` as the default builder | |
| 565 | + | (images not in the Engine's store, so `FROM` a just-built image and | |
| 566 | + | `docker run` of a build fail without `--load` round trips); a `docker` CLI | |
| 567 | + | wrapper adding `--network=host` (misses Compose, SDKs and testcontainers, | |
| 568 | + | which speak the API). | |
| 569 | + | ||
| 570 | + | **Not yet:** | |
| 571 | + | ||
| 572 | + | - Seen on Cloudflare itself: whether a sandbox's disk takes overlays, | |
| 573 | + | `--privileged`, and the first deploy's pull of the base from | |
| 574 | + | `registry.cloudflare.com` (its layer host may need a workflow-only line). | |
| 575 | + | - `type=gha` build caches backed by g1t's Actions cache. | |
| 576 | + | - Multi-platform builds (QEMU's `binfmt_misc` in a sandbox). | |
| 577 | + | - Docker for agents and checks, not just workflow jobs. | |
| 578 | + | - `runner-base.yml` on g1t's machines: the base's apt step needs | |
| 579 | + | `Acquire::https::CAInfo` pointed at the egress certificate first. | |
| 580 | + | - A deploy that appends the runner binary as a layer through the registry | |
| 581 | + | API, with no Docker and no 3 GB pull. | |
| 582 | + | ||
| 482 | 583 | Agents can also reach integrations directly: an agent definition lists MCP | |
| 483 | 584 | servers (Sentry, Linear and so on) it may use while working. | |
| 484 | 585 |
| 265 | 265 | } | |
| 266 | 266 | if (!docker) { | |
| 267 | 267 | throw new Error( | |
| 268 | − | `its image ${tag} is not in the registry, and Docker is not available here. Build and push it from a machine with Docker (node scripts/deploy.mjs image), then run this again.`, | |
| 268 | + | `its image ${tag} is not in the registry, and Docker does not answer here. Build and push it where Docker runs (a g1t Actions job, or a machine with Docker: node scripts/deploy.mjs image), then run this again.`, | |
| 269 | 269 | ); | |
| 270 | 270 | } | |
| 271 | 271 | const started = Date.now(); |
| 455 | 455 | const data = planJson(stack, decisions, { events: { pending: ["0003_x.sql"] }, repos: { pending: [] } }, HEAD); | |
| 456 | 456 | assert.deepEqual(data.migrations, [{ unit: "events", database: "g1t-events", pending: ["0003_x.sql"] }]); | |
| 457 | 457 | assert.deepEqual(data.stages.core.jobs, [ | |
| 458 | − | { group: "rust", units: "events,repos", rust: true }, | |
| 459 | − | { group: "ts", units: "projects", rust: false }, | |
| 458 | + | { group: "rust", units: "events,repos", rust: true, image: false }, | |
| 459 | + | { group: "ts", units: "projects", rust: false, image: false }, | |
| 460 | 460 | ]); | |
| 461 | − | assert.deepEqual(data.stages.edge.jobs, [{ group: "rust", units: "api", rust: true }]); | |
| 461 | + | assert.deepEqual(data.stages.edge.jobs, [{ group: "rust", units: "api", rust: true, image: false }]); | |
| 462 | 462 | assert.deepEqual(data.stages.front.jobs, [ | |
| 463 | − | { group: "web", units: "web", rust: false }, | |
| 464 | − | { group: "docs", units: "docs", rust: false }, | |
| 463 | + | { group: "web", units: "web", rust: false, image: false }, | |
| 464 | + | { group: "docs", units: "docs", rust: false, image: false }, | |
| 465 | 465 | ]); | |
| 466 | 466 | assert.deepEqual(data.stage_order, ["core", "edge", "front"]); | |
| 467 | 467 | assert.deepEqual(buildGroups([]), []); | |
| ⋯ | |||
| 469 | 469 | const core = stack.units.filter((u) => u.stage === "core"); | |
| 470 | 470 | const jobs = buildGroups(core, ["runner"]); | |
| 471 | 471 | assert.deepEqual(jobs.filter((j) => j.rust).map((j) => j.units.split(",").length), [4, 4, 3]); | |
| 472 | − | assert.ok(jobs.some((j) => j.group === "runner-image" && j.units === "runner")); | |
| 472 | + | assert.ok(jobs.some((j) => j.group === "runner-image" && j.units === "runner" && j.image && !j.rust)); | |
| 473 | 473 | assert.ok(!jobs.find((j) => j.group === "ts").units.includes("runner")); | |
| 474 | 474 | }); | |
| 475 | 475 | ||
| 215 | 215 | 'echo "rust=$(rustc --version | cut -d" " -f2)"', | |
| 216 | 216 | 'echo "git=$(git --version | cut -d" " -f3)"', | |
| 217 | 217 | 'echo "claude_code=$(claude --version | cut -d" " -f1)"', | |
| 218 | + | 'echo "docker=$(dockerd --version | cut -d" " -f3 | tr -d ,)"', | |
| 218 | 219 | 'echo "debian=$(cat /etc/debian_version)"', | |
| 219 | 220 | ].join("; "); | |
| 220 | 221 | const found = await exec("docker", ["run", "--rm", "--platform", "linux/amd64", "--entrypoint", "bash", ref, "-c", script]); |
| 270 | 270 | for (const unit of units.filter((u) => u.kind !== "rust-worker")) { | |
| 271 | 271 | add(images.includes(unit.id) ? `${unit.id}-image` : unit.kind === "ts-worker" ? "ts" : unit.id, unit.id); | |
| 272 | 272 | } | |
| 273 | − | return [...groups].map(([group, ids]) => ({ group, units: ids.join(","), rust: group.startsWith("rust") })); | |
| 273 | + | // `image`: the job builds a Containers image (Docker, on a larger machine). | |
| 274 | + | return [...groups].map(([group, ids]) => ({ group, units: ids.join(","), rust: group.startsWith("rust"), image: group.endsWith("-image") })); | |
| 274 | 275 | } | |
| 275 | 276 | ||
| 276 | 277 | /** |
| 3 | 3 | # g1t-runner-base: everything a g1t sandbox has apart from the g1t runner | |
| 4 | 4 | # itself. Agents, checks, the merge queue, workflow jobs and g1t.page | |
| 5 | 5 | # builds all run in it: git, Node, Python, Go, Rust (with the formatter, | |
| 6 | − | # the linter and the wasm32 target), the usual build tools, and the | |
| 7 | − | # Claude Code CLI. | |
| 6 | + | # the linter, and the wasm32 and musl targets), the usual build tools, | |
| 7 | + | # Docker (Engine, Buildx, Compose), and the Claude Code CLI. | |
| 8 | 8 | # | |
| 9 | 9 | # Built and pushed by `node scripts/deploy.mjs build-base` (by hand, or by | |
| 10 | 10 | # .g1t/workflows/runner-base.yml), which records what it pushed in | |
| ⋯ | |||
| 13 | 13 | # runner builds in seconds. docs/DEPLOYING.md explains the two. | |
| 14 | 14 | # | |
| 15 | 15 | # Layers go from what changes least to what changes most: the system's | |
| 16 | − | # packages, then Go, then Rust, then the Claude Code CLI on top, so a new | |
| 17 | − | # CLI version rebuilds and pushes one layer. | |
| 16 | + | # packages, Docker, then Go, then Rust, then the Claude Code CLI on top, so | |
| 17 | + | # a new CLI version rebuilds and pushes one layer. | |
| 18 | 18 | # | |
| 19 | 19 | # Build context: this folder (nothing is copied from it). | |
| 20 | 20 | ||
| ⋯ | |||
| 41 | 41 | # BuildKit's cache, not the image (run `sudo apt-get update` before | |
| 42 | 42 | # installing more). dpkg skips its fsync after every file, which an image | |
| 43 | 43 | # build has no use for and which made this step several times slower. | |
| 44 | + | # musl-tools is musl-gcc, with which (and `rustup target add | |
| 45 | + | # x86_64-unknown-linux-musl`) the static g1t runner builds in a workflow | |
| 46 | + | # job (scripts/build-runner.mjs). | |
| 44 | 47 | RUN --mount=type=cache,target=/var/cache/apt,sharing=locked \ | |
| 45 | 48 | --mount=type=cache,target=/var/lib/apt/lists,sharing=locked \ | |
| 46 | 49 | apt-get update \ | |
| ⋯ | |||
| 48 | 51 | git ca-certificates curl build-essential pkg-config libssl-dev \ | |
| 49 | 52 | python3 python3-pip python3-venv ripgrep jq zstd \ | |
| 50 | 53 | sudo unzip zip xz-utils wget file gnupg lsb-release openssh-client \ | |
| 54 | + | musl-tools \ | |
| 51 | 55 | && rm -rf /var/log/apt /var/log/dpkg.log /var/cache/debconf/*-old | |
| 52 | 56 | ||
| 57 | + | # Docker, for workflow jobs: the CLI with Buildx and Compose, and the | |
| 58 | + | # Engine (dockerd, containerd, runc), which the runner starts as a job's | |
| 59 | + | # own the first time the job uses it (crates/runner/src/docker). From | |
| 60 | + | # Docker's own repository, its signing key checked against the fingerprint | |
| 61 | + | # Docker publishes. Nothing here runs until a job asks for it. | |
| 62 | + | ARG DOCKER_KEY_FINGERPRINT=9DC858229FC7DD38854AE2D88D81803C0EBFCD88 | |
| 63 | + | RUN --mount=type=cache,target=/var/cache/apt,sharing=locked \ | |
| 64 | + | --mount=type=cache,target=/var/lib/apt/lists,sharing=locked \ | |
| 65 | + | install -m 0755 -d /etc/apt/keyrings \ | |
| 66 | + | && curl -fsSL https://download.docker.com/linux/debian/gpg -o /etc/apt/keyrings/docker.asc \ | |
| 67 | + | && GNUPGHOME="$(mktemp -d)" gpg --show-keys --with-colons /etc/apt/keyrings/docker.asc \ | |
| 68 | + | | grep -q "^fpr:::::::::${DOCKER_KEY_FINGERPRINT}:" \ | |
| 69 | + | && echo "deb [arch=amd64 signed-by=/etc/apt/keyrings/docker.asc] https://download.docker.com/linux/debian bookworm stable" \ | |
| 70 | + | > /etc/apt/sources.list.d/docker.list \ | |
| 71 | + | && apt-get update \ | |
| 72 | + | && apt-get install -y --no-install-recommends -o Dpkg::Options::=--force-unsafe-io \ | |
| 73 | + | docker-ce docker-ce-cli containerd.io docker-buildx-plugin docker-compose-plugin \ | |
| 74 | + | && rm -rf /var/log/apt /var/log/dpkg.log /var/cache/debconf/*-old \ | |
| 75 | + | && docker --version && dockerd --version && docker buildx version && docker compose version | |
| 76 | + | ||
| 53 | 77 | # Workflows expect GitHub's runner layout under /home/runner, and sudo | |
| 54 | 78 | # without a password. The agent works in /work. | |
| 55 | 79 | RUN mkdir /work && chown node:node /work \ | |
| 163 | 163 | * What workflow jobs and deploy builds may reach on top of the project's | |
| 164 | 164 | * allowed domains and registries: where `actions/checkout`, `uses:` | |
| 165 | 165 | * actions and the `setup-*` actions fetch from, the package registries | |
| 166 | − | * builds install from, and (for deploys) Cloudflare's API, which a build | |
| 167 | − | * uploads its app to. No mining pool is on it, and no general host. | |
| 166 | + | * builds install from, the public container registries a job's Docker | |
| 167 | + | * Engine pulls images from (crates/runner docker/), and (for deploys) | |
| 168 | + | * Cloudflare's API, which a build uploads its app to. No mining pool is on | |
| 169 | + | * it, and no general host. | |
| 168 | 170 | */ | |
| 169 | 171 | export const BUILD_HOSTS: readonly string[] = [ | |
| 170 | 172 | // Actions by `uses:`, and releases the setup actions download. | |
| ⋯ | |||
| 203 | 205 | "plugins.gradle.org", | |
| 204 | 206 | "deb.debian.org", | |
| 205 | 207 | "security.debian.org", | |
| 208 | + | // Container images, for a job's own Docker Engine: Docker Hub (and the | |
| 209 | + | // CDN its layers come from), Google's public mirror of it, which the | |
| 210 | + | // Engine asks first, and Quay. GitHub's registry is above. | |
| 211 | + | "registry-1.docker.io", | |
| 212 | + | "auth.docker.io", | |
| 213 | + | "index.docker.io", | |
| 214 | + | "production.cloudflare.docker.com", | |
| 215 | + | "production.cloudfront.docker.com", | |
| 216 | + | "mirror.gcr.io", | |
| 217 | + | "quay.io", | |
| 218 | + | "cdn01.quay.io", | |
| 219 | + | "cdn02.quay.io", | |
| 220 | + | "cdn03.quay.io", | |
| 221 | + | // Docker's own packages (apt), for images that install the CLI. | |
| 222 | + | "download.docker.com", | |
| 206 | 223 | ]; | |
| 207 | 224 | ||
| 208 | 225 | /** | |
| 226 | + | * Whether a workflow job gets a Docker Engine of its own, from the | |
| 227 | + | * Worker's `DOCKER` setting: on unless it says `off`. Its containers share | |
| 228 | + | * the job's network, so the hosts above, and its guardrails, are theirs too. | |
| 229 | + | */ | |
| 230 | + | export function dockerFor(setting: string | undefined): "on" | "off" { | |
| 231 | + | return setting?.trim().toLowerCase() === "off" ? "off" : "on"; | |
| 232 | + | } | |
| 233 | + | ||
| 234 | + | /** | |
| 209 | 235 | * A workflow job, for the guardrails' workflow-only domains: its workflow | |
| 210 | 236 | * file, the environment it names, and whether its run is trusted (not a | |
| 211 | 237 | * pull request from a fork). Only a trusted run's jobs get them. | |
| 27 | 27 | slotFree, | |
| 28 | 28 | waitingMessage, | |
| 29 | 29 | } from "../../../packages/contracts/src/compute.ts"; | |
| 30 | − | import { BUILD_HOSTS, buildHosts, withPlanLimits } from "./egress.ts"; | |
| 30 | + | import { BUILD_HOSTS, buildHosts, dockerFor, withPlanLimits } from "./egress.ts"; | |
| 31 | 31 | import { WAITING, handleMention } from "./mentions.ts"; | |
| 32 | 32 | ||
| 33 | 33 | const repo = { namespace: "acme", name: "web" }; | |
| ⋯ | |||
| 306 | 306 | for (const host of ["registry.npmjs.org", "codeload.github.com", "nodejs.org", "crates.io"]) { | |
| 307 | 307 | assert.ok(BUILD_HOSTS.includes(host), host); | |
| 308 | 308 | } | |
| 309 | + | // A job's Docker Engine pulls from Docker Hub (through its mirror) and Quay. | |
| 310 | + | for (const host of ["registry-1.docker.io", "auth.docker.io", "production.cloudflare.docker.com", "mirror.gcr.io", "ghcr.io", "quay.io"]) { | |
| 311 | + | assert.ok(BUILD_HOSTS.includes(host), host); | |
| 312 | + | } | |
| 309 | 313 | assert.ok(buildHosts("deploy").includes("api.cloudflare.com")); | |
| 310 | 314 | assert.ok(!buildHosts("actions").includes("api.cloudflare.com")); | |
| 311 | 315 | for (const host of buildHosts("deploy")) { | |
| ⋯ | |||
| 313 | 317 | } | |
| 314 | 318 | }); | |
| 315 | 319 | ||
| 320 | + | test("workflow jobs get Docker unless the operator turns it off", () => { | |
| 321 | + | assert.equal(dockerFor(undefined), "on"); | |
| 322 | + | assert.equal(dockerFor("on"), "on"); | |
| 323 | + | assert.equal(dockerFor(" OFF "), "off"); | |
| 324 | + | }); | |
| 325 | + | ||
| 316 | 326 | // ---- Mentions -------------------------------------------------------------------------------- | |
| 317 | 327 | ||
| 318 | 328 | test("a mention whose run waits for a slot says so, and is not a failure", async () => { | |
| 32 | 32 | ABUSE_HOST, | |
| 33 | 33 | ABUSE_MESSAGE, | |
| 34 | 34 | SANDBOX_BINDINGS, | |
| 35 | + | dockerFor, | |
| 35 | 36 | harnessEnv, | |
| 36 | 37 | jobHosts, | |
| 37 | 38 | newlyBlocked, |
| 103 | 103 | type RunGuard, | |
| 104 | 104 | abuse, | |
| 105 | 105 | buildGuardFor, | |
| 106 | + | dockerFor, | |
| 106 | 107 | egress, | |
| 107 | 108 | egressHosts, | |
| 108 | 109 | guardFor, | |
| ⋯ | |||
| 198 | 199 | */ | |
| 199 | 200 | ABUSE_WATCH?: string; | |
| 200 | 201 | /** | |
| 202 | + | * `off` leaves workflow jobs without a Docker Engine of their own | |
| 203 | + | * (crates/runner docker/): a switch for the operator. Anything else | |
| 204 | + | * gives each job one, started the first time it is used. | |
| 205 | + | */ | |
| 206 | + | DOCKER?: string; | |
| 207 | + | /** | |
| 201 | 208 | * Nightly backups (backup.ts): how many queued backups one sweep starts | |
| 202 | 209 | * (`0`: none, backups off here), and how many may run at once. | |
| 203 | 210 | */ | |
| ⋯ | |||
| 1694 | 1701 | G1T_API: "https://api.g1t.sh", | |
| 1695 | 1702 | ACTIONS_JOB: args.job, | |
| 1696 | 1703 | ACTIONS_TOKEN: args.token, | |
| 1704 | + | // Docker of the job's own, inside its sandbox (crates/runner docker/). | |
| 1705 | + | G1T_DOCKER: dockerFor(this.env.DOCKER), | |
| 1697 | 1706 | }, | |
| 1698 | 1707 | }); | |
| 1699 | 1708 | } catch (error) { | |
| 108 | 108 | // (crates/runner abuse.rs). "off" turns the CPU watch off; miners | |
| 109 | 109 | // named in commands are refused either way. | |
| 110 | 110 | "ABUSE_WATCH": "on", | |
| 111 | + | // Each workflow job gets a Docker Engine of its own, inside its | |
| 112 | + | // sandbox, started the first time a step uses Docker or the job has | |
| 113 | + | // `services:` or `container:` (crates/runner docker/). "off" gives | |
| 114 | + | // jobs none. | |
| 115 | + | "DOCKER": "on", | |
| 111 | 116 | // Nightly backups (src/backup.ts): each sweep starts this many of the | |
| 112 | 117 | // backups the repos service queued, with at most BACKUPS_RUNNING at | |
| 113 | 118 | // once. "0" starts none. |