Skip to content

Commit

Merge branch 'actions-docker'

syntaqxcommitted Parents9d3eea6171afe3Browse files
33 files+3370−1060/33 viewed
+29−9
1616 # with `deployment: false`, so a dry run or a change that deploys nothing
1717 # makes no deployment.
1818 #
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.
2426 name: Deploy
2527
2628 on:
126128 # Runs when nothing before it failed: a migrate job skipped for having
127129 # nothing to apply is not a failure.
128130 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' }}
131134 environment:
132135 name: production
133136 url: https://g1t.sh
185188 restore-keys: |
186189 cargo-target-${{ runner.os }}-${{ matrix.group }}-
187190 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 }}-
188208 - name: Install
189209 run: node scripts/deploy.mjs install --only "${{ matrix.units }}"
190210 - name: Deploy ${{ matrix.units }}
196216 name: edge (${{ matrix.group }})
197217 needs: [plan, migrate, core]
198218 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' }}
200220 environment:
201221 name: production
202222 url: https://g1t.sh
211231 name: front (${{ matrix.group }})
212232 needs: [plan, migrate, core, edge]
213233 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' }}
215235 environment:
216236 name: production
217237 url: https://g1t.sh
+5−3
77 # Weekly, for security updates and new stable toolchains; when the base's
88 # folder changes on main (a change that forgot to rebuild it); and by hand.
99 #
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:
1315 #
1416 # node scripts/deploy.mjs build-base
1517 #
+2−1
7474 image:
7575 name: Container image
7676 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.
7879 runs-on: [self-hosted, docker]
7980 environment: production
8081 timeout-minutes: 30
+33−16
184184
185185 ## Actions and runners
186186
187−### No Docker in g1t's sandboxes
187+### Docker shares the job's network
188188
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.
194193
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.
205222
206223 ### Linux only on g1t's machines
207224
+172−8
4545 | `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. |
4646 | `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. |
4747 | `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. |
4853
4954 The **Actions** page of a workflow says, under *How this runs on g1t*,
5055 anything in it that runs differently.
5560 job with `runs-on: windows-latest` or `macos-latest` fails, and says so.
5661 [Self-hosted runners](/guides/self-hosted-runners/) of any OS run them:
5762 `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).
6266 - **Reusable workflows from other repositories** (`uses: owner/repo/.github/workflows/x.yml@v1`); ones in the same repository work.
6367 - **The toolkit's own cache.** Actions that cache through GitHub's service
6468 themselves, such as `actions/setup-node` with `cache: npm`, run without
7680 ## The runner
7781
7882 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`).
8186 `runner.os` is `Linux`. `ubuntu-latest`, `ubuntu-24.04` and other Linux
8287 labels all run here. A job whose `runs-on` names `self-hosted` waits for one
8388 of your [self-hosted runners](/guides/self-hosted-runners/) instead. Setup actions such as
120125 What builds need is the package registries (npm, PyPI, crates.io, the Go
121126 proxy, RubyGems, Packagist, NuGet, Maven and Gradle, Debian's mirrors),
122127 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
125132 the reason. To reach another host, someone with the Maintain [role](/guides/access-and-roles/) or
126133 higher adds it to the project's
127134 allowed domains under **Settings → Guardrails**; a project whose guardrails
139146 looks like it is mining is stopped. See
140147 [abuse and mining](/guides/guardrails/#abuse-and-mining).
141148
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+
142306 ## The cache
143307
144308 `actions/cache` keeps what a job saves for the repository's later jobs:
+4−2
7272 docker push g1t.sh/${{ github.repository }}:${{ github.sha }}
7373 ```
7474
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
7779 [secrets and variables](/guides/actions/#secrets-and-variables).
7880
7981 ## The 100 MB limit
+13−4
103103 - every package registry above, whatever the project turned on for its
104104 agents, and RubyGems, Packagist, NuGet, Maven Central, Gradle and
105105 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`;
106110 - for deploy builds, Cloudflare's API, which the build uploads its app to.
107111
108112 The repository itself is cloned from g1t, which is always reachable. A
109113 project whose guardrails set **Only allowed hosts** to Open runs its jobs
110114 and builds with an open network too.
111115
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+
112121 ### Workflow-only domains
113122
114123 Some hosts only a workflow should reach: the API a deploy uploads to, a
279288 - **No pool to reach.** No mining pool is on any allowed list, so a
280289 restricted sandbox's miner has nowhere to send its work.
281290 - **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
286295 the sandbox at once.
287296 - **The CPU signature.** Every sandbox samples itself every 30 seconds:
288297 CPU use, file and disk I/O, network bytes, new processes, and whether the
+6−2
139139
140140 - **In Docker** (the default), each job gets a fresh container from its
141141 `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.
143145 - **With `--no-docker`**, each job gets a fresh folder under the work
144146 folder, removed when it ends, and runs with whatever the machine has
145147 installed. A step's default shell is `bash` on Linux and macOS and
146148 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.
148152
149153 A self-hosted job stops at 60 minutes unless its `timeout-minutes` says
150154 more, up to 24 hours (1440). Jobs on g1t's own machines stop at 60 minutes.
+15−5
408408 }
409409 }
410410 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+ );
412416 }
413417 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+ );
415423 }
416424 if spec.contains_key("environment") {
417425 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());
472480 /// `caches`: the step sets a `cache` input.
473481 fn action_note(uses: &str, caches: bool) -> Option<(Severity, String)> {
474482 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).")));
476484 }
477485 let name = uses.split('@').next().unwrap_or(uses).to_ascii_lowercase();
478486 match name.as_str() {
586594 workflow.notes.iter().filter(|n| n.severity == Severity::Unsupported).map(|n| n.message.as_str()).collect();
587595 assert!(unsupported.iter().any(|m| m.contains("`release`")));
588596 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`")));
591601 assert!(unsupported.iter().any(|m| m.contains("pwsh")));
592602 assert!(workflow.notes.iter().any(|n| n.severity == Severity::Info && n.message.contains("actions/cache")));
593603 assert!(workflow.notes.iter().any(|n| n.severity == Severity::Warning && n.message.contains("actions/setup-node")));
+7−4
174174 let deploy = read("deploy.yml");
175175 for stage in ["core", "edge", "front"] {
176176 let job = deploy.jobs.iter().find(|j| j.id == stage).unwrap();
177− let on = |rust: bool| {
177+ let on = |rust: bool, image: bool| {
178178 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 }));
180180 let scope = Scope { contexts: &contexts, status: Status::Success, hash_files: None };
181181 expr::interpolate_value(&job.runs_on, &scope).unwrap()
182182 };
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}");
185187 }
186188 let source = std::fs::read_to_string(workflows_dir().join("deploy.yml")).unwrap();
187189 assert!(source.contains("target/wasm32-unknown-unknown/release"));
188190 assert!(source.contains("!target/**/incremental"));
191+ assert!(source.contains("target/x86_64-unknown-linux-musl/release"));
189192 }
190193
191194 #[test]
+806−0
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", &registry, "--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+}
+83−6
99 //! job's definition, its contexts and its secrets, is fetched with them.
1010
1111 mod blobs;
12+mod containers;
1213 mod files;
1314 mod paths;
1415 mod process;
1516 mod report;
1617 mod uses;
1718
18−use std::collections::BTreeMap;
19+use std::collections::{BTreeMap, BTreeSet};
1920 use std::path::{Path, PathBuf};
2021 use std::process::Command;
2122 use std::time::{Duration, Instant};
5657 pub(crate) enum PostRun {
5758 Node { action_dir: PathBuf, script: String },
5859 CacheSave { key: String, paths: Vec<String> },
60+ /// A Docker action's `post-entrypoint`.
61+ Docker(containers::DockerRun),
5962 }
6063
6164 pub(crate) struct Job {
8184 /// What the last Node process left, for the step that ran it.
8285 pub(crate) last_node_outputs: BTreeMap<String, String>,
8386 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,
84102 }
85103
86104 fn text_map(value: Option<&Value>) -> BTreeMap<String, String> {
105123 self.base_env.get(name).cloned()
106124 }
107125
126+ /// What earlier steps added to `PATH`, newest first.
127+ pub(crate) fn path_prepend_entries(&self) -> &[String] {
128+ &self.path_prepend
129+ }
130+
108131 fn status(&self) -> Status {
109132 if self.failed { Status::Failure } else { Status::Success }
110133 }
114137 let mut contexts = self.contexts.clone();
115138 contexts.insert("env".into(), Value::Object(env.iter().map(|(k, v)| (k.clone(), Value::String(v.clone()))).collect()));
116139 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));
118141 if let Some(inputs) = &frame.inputs {
119142 contexts.insert("inputs".into(), inputs.clone());
120143 }
140163
141164 /// The `env` context for a step: the workflow's, the job's, what earlier
142165 /// 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> {
144167 let mut env = self.added_env.clone();
145168 env.extend(self.workflow_env.clone());
146169 env.extend(self.job_env.clone());
228251 let (program, args, extension): (String, Vec<String>, &str) = match shell {
229252 // A self-hosted Windows runner, as GitHub's: PowerShell.
230253 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"),
231256 None => ("bash".into(), vec!["-e".into(), "{0}".into()], "sh"),
232257 Some("bash") => ("bash".into(), vec!["--noprofile".into(), "--norc".into(), "-eo".into(), "pipefail".into(), "{0}".into()], "sh"),
233258 Some("sh") => ("sh".into(), vec!["-e".into(), "{0}".into()], "sh"),
274299 Some(dir) => self.workspace.join(dir),
275300 None => self.workspace.clone(),
276301 };
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+ };
279315 let mut commands = Commands {
280316 debug: self.debug,
281317 ..Commands::default()
481517 std::fs::create_dir_all(&temp).context("could not make the temporary folder")?;
482518 std::fs::write(temp.join("event.json"), serde_json::to_string_pretty(&spec["event"])?)?;
483519
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");
484525 // This process's environment, less what only it should see.
485526 let mut base_env: BTreeMap<String, String> =
486527 std::env::vars().filter(|(name, _)| !matches!(name.as_str(), "ACTIONS_TOKEN" | "ACTIONS_JOB" | "MODE") && !name.starts_with("G1T_")).collect();
520561 debug,
521562 last_node_outputs: BTreeMap::new(),
522563 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(),
523571 };
524572
525573 // The workflow's env reads github, secrets, inputs and vars; the job's
575623 {
576624 job.log.line(&format!("Matrix: {}", serde_json::to_string(matrix).unwrap_or_default()));
577625 }
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();
578636 job.log.flush();
579637
580638 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) {
582646 job.step(&mut frame, step, index as u32 + 1, true, &defaults);
647+ job.log_docker_notes();
583648 if job.remaining().is_zero() {
584649 job.log.line("##[error]The job ran past its time limit.");
585650 job.failed = true;
604669 let ok = match &post.run {
605670 PostRun::Node { action_dir, script } => job.run_node(action_dir, script, &post.env),
606671 PostRun::CacheSave { key, paths } => job.cache_save(key, paths),
672+ PostRun::Docker(run) => job.run_docker(run).0,
607673 };
608674 job.log.step_state(number, &post.name, "completed", Some(if ok { "success" } else { "failure" }));
609675 if !ok {
611677 }
612678 }
613679
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+
614691 // The job's outputs, read now that every step has run.
615692 let env = job.env_context(&frame);
616693 let contexts = job.contexts_for(&frame, &env);
+7−1
165165 log.line(&shown);
166166 }
167167 }
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(&note);
172+ }
173+ log.tick();
174+ }
169175 Err(mpsc::RecvTimeoutError::Disconnected) => break,
170176 }
171177 if Instant::now() >= deadline {
+114−9
11 //! `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.
45
56 use std::collections::BTreeMap;
67 use std::path::{Path, PathBuf};
1314 use g1t_actions::workflow::yaml_to_json;
1415 use serde_json::{Map, Value, json};
1516
17+use super::containers::{self, DockerRun};
1618 use super::files::StepFiles;
1719 use super::process::{self, Commands, Ended};
1820 use super::{Frame, Job, Post, PostRun};
201203 pub(crate) fn run_node(&mut self, action_dir: &Path, script: &str, env: &BTreeMap<String, String>) -> bool {
202204 let id = format!("node{}", super::rand_id());
203205 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+ };
206222 let mut commands = Commands {
207223 debug: false,
208224 ..Commands::default()
237253 _timeout: Duration,
238254 ) -> (bool, BTreeMap<String, String>) {
239255 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);
243271 }
244272 let (name, git_ref) = uses.split_once('@').unwrap_or((uses, ""));
245273 let lower = name.to_ascii_lowercase();
274+ if lower == "docker/setup-buildx-action" && self.docker_hosted {
275+ return self.setup_buildx(with);
276+ }
246277 match lower.as_str() {
247278 "actions/checkout" => return self.checkout(with),
248279 "actions/upload-artifact" => return self.upload_artifact(with),
396427 return (ok, outputs);
397428 }
398429 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);
401431 }
402432 self.log.line(&format!("##[error]`{uses}` runs with `{using}`, which g1t does not know."));
403433 (false, BTreeMap::new())
404434 }
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+ }
405510 }
+727−0
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+}
+414−0
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, &registry, &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+}
+342−0
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+}
+41−0
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+}
+274−0
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+}
+5−0
4040 mod clone;
4141 mod confidence;
4242 mod deploy;
43+mod docker;
4344 mod guard;
4445 mod harness;
4546 mod learned;
206207 }
207208
208209 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+ }
209214 // A self-hosted runner's commands; the modes below are what it, and
210215 // g1t's sandboxes, run work with.
211216 let args: Vec<String> = std::env::args().skip(1).collect();
+3−0
140140 let mut command = Command::new("docker");
141141 command.args(["run", "--rm", "--name", &name, "--label", &format!("sh.g1t.runner={}", config.runner)]);
142142 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"]);
143146 for name in env.keys() {
144147 command.args(["-e", name]);
145148 }
+75−21
198198
199199 | Image | Built from | Holds | Rebuilt |
200200 | --- | --- | --- | --- |
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`) |
202202 | **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 |
203203
204204 Both are pushed to one repository of Cloudflare's registry,
255255 1. A deploy computes the tag and asks the registry whether it is there
256256 (a `HEAD` of its manifest, with credentials from Wrangler; no Docker).
257257 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.
263267
264268 Then `wrangler deploy` is given the image by reference, from a generated
265269 config (`services/runner/wrangler.deploy.json`, deleted after, ignored by
277281
278282 `.g1t/workflows/runner-base.yml` does the same weekly (and when the
279283 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.
283290
284291 **Sandboxes start from the image.** Cloudflare pulls an image to a
285292 machine the first time a sandbox lands there, and keeps it. A smaller base
298305 their CPU (see the public billing guide). The account's Containers limits
299306 must allow `standard-4`; Wrangler refuses the deploy otherwise.
300307
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+
301344 ## Build speed
302345
303346 Measured on the development machine (Windows, 32 cores, warm Cargo cache),
366409 - **Build groups:** a stage's units are split so each job shares a build:
367410 Rust workers at most four to a job (each a 4-vCPU `g1t-4core` machine), the
368411 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
370414 another off mid-upload; the next stage then does not start.
371415 - **Tests:** there is no CI workflow on g1t yet; `main` is kept passing by
372416 the merge queue's checks. `check` runs the deploy tool's own tests. When a
373417 CI workflow is added, make `plan` wait for it (`workflow_run`, or a job in
374418 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.
377424 - **Caching** (`actions/cache`: up to 2 GB an entry, 10 GB a repository,
378425 kept until unused for 7 days): the worker-build binary, worker-build's
379426 downloaded tools, `~/.cargo/registry/cache`, and the Cargo target's
395442 ### What the sandbox has
396443
397444 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
402451 fetches wasm-bindgen and wasm-opt from GitHub releases and esbuild from
403452 npm. All of those hosts are on the list every workflow job may reach.
404453
418467 ```
419468
420469 `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
423477 jobs with `environment: production` reach them, which are also the only
424478 jobs that can read `CLOUDFLARE_API_TOKEN`. Each change to the list is in
425479 the workspace's audit log as `update_guardrails`.
436490 | Account | Queues: Edit | Attaching each unit's queue consumers on deploy |
437491 | Account | Workers R2 Storage: Read | Wrangler checks `og`'s bucket binding |
438492 | 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. |
440494 | Zone (`g1t.sh`, `g1t.page`) | Workers Routes: Edit | `pages`' zone routes, and custom domains |
441495 | Zone (`g1t.sh`, `g1t.page`) | DNS: Edit | Custom domains (`api`, `mcp`, `og`, `models`, `status`, `sudo`, `docs`, `g1t.sh`, `g1t.page`) keep their DNS records |
442496 | Zone (`g1t.sh`, `g1t.page`) | Zone: Read | Finding the zone a route names |
545599 repository from the git remote on g1t.sh. A report that fails is one line
546600 in the log and never fails the deploy.
547601
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.
550604 The report records it, and production shows the commit that really runs.
551605
552606 ## Rolling back
+101−0
479479 commands declared in `.g1t/checks.yaml`, run in sandboxes on every pull request
480480 and on every combined state in the landing queue.
481481
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+
482583 Agents can also reach integrations directly: an agent definition lists MCP
483584 servers (Sentry, Linear and so on) it may use while working.
484585
+1−1
265265 }
266266 if (!docker) {
267267 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.`,
269269 );
270270 }
271271 const started = Date.now();
+6−6
455455 const data = planJson(stack, decisions, { events: { pending: ["0003_x.sql"] }, repos: { pending: [] } }, HEAD);
456456 assert.deepEqual(data.migrations, [{ unit: "events", database: "g1t-events", pending: ["0003_x.sql"] }]);
457457 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 },
460460 ]);
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 }]);
462462 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 },
465465 ]);
466466 assert.deepEqual(data.stage_order, ["core", "edge", "front"]);
467467 assert.deepEqual(buildGroups([]), []);
469469 const core = stack.units.filter((u) => u.stage === "core");
470470 const jobs = buildGroups(core, ["runner"]);
471471 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));
473473 assert.ok(!jobs.find((j) => j.group === "ts").units.includes("runner"));
474474 });
475475
+1−0
215215 'echo "rust=$(rustc --version | cut -d" " -f2)"',
216216 'echo "git=$(git --version | cut -d" " -f3)"',
217217 'echo "claude_code=$(claude --version | cut -d" " -f1)"',
218+ 'echo "docker=$(dockerd --version | cut -d" " -f3 | tr -d ,)"',
218219 'echo "debian=$(cat /etc/debian_version)"',
219220 ].join("; ");
220221 const found = await exec("docker", ["run", "--rm", "--platform", "linux/amd64", "--entrypoint", "bash", ref, "-c", script]);
+2−1
270270 for (const unit of units.filter((u) => u.kind !== "rust-worker")) {
271271 add(images.includes(unit.id) ? `${unit.id}-image` : unit.kind === "ts-worker" ? "ts" : unit.id, unit.id);
272272 }
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") }));
274275 }
275276
276277 /**
+28−4
33 # g1t-runner-base: everything a g1t sandbox has apart from the g1t runner
44 # itself. Agents, checks, the merge queue, workflow jobs and g1t.page
55 # 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.
88 #
99 # Built and pushed by `node scripts/deploy.mjs build-base` (by hand, or by
1010 # .g1t/workflows/runner-base.yml), which records what it pushed in
1313 # runner builds in seconds. docs/DEPLOYING.md explains the two.
1414 #
1515 # 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.
1818 #
1919 # Build context: this folder (nothing is copied from it).
2020
4141 # BuildKit's cache, not the image (run `sudo apt-get update` before
4242 # installing more). dpkg skips its fsync after every file, which an image
4343 # 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).
4447 RUN --mount=type=cache,target=/var/cache/apt,sharing=locked \
4548 --mount=type=cache,target=/var/lib/apt/lists,sharing=locked \
4649 apt-get update \
4851 git ca-certificates curl build-essential pkg-config libssl-dev \
4952 python3 python3-pip python3-venv ripgrep jq zstd \
5053 sudo unzip zip xz-utils wget file gnupg lsb-release openssh-client \
54+ musl-tools \
5155 && rm -rf /var/log/apt /var/log/dpkg.log /var/cache/debconf/*-old
5256
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+
5377 # Workflows expect GitHub's runner layout under /home/runner, and sudo
5478 # without a password. The agent works in /work.
5579 RUN mkdir /work && chown node:node /work \
+28−2
163163 * What workflow jobs and deploy builds may reach on top of the project's
164164 * allowed domains and registries: where `actions/checkout`, `uses:`
165165 * 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.
168170 */
169171 export const BUILD_HOSTS: readonly string[] = [
170172 // Actions by `uses:`, and releases the setup actions download.
203205 "plugins.gradle.org",
204206 "deb.debian.org",
205207 "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",
206223 ];
207224
208225 /**
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+/**
209235 * A workflow job, for the guardrails' workflow-only domains: its workflow
210236 * file, the environment it names, and whether its run is trusted (not a
211237 * pull request from a fork). Only a trusted run's jobs get them.
+11−1
2727 slotFree,
2828 waitingMessage,
2929 } 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";
3131 import { WAITING, handleMention } from "./mentions.ts";
3232
3333 const repo = { namespace: "acme", name: "web" };
306306 for (const host of ["registry.npmjs.org", "codeload.github.com", "nodejs.org", "crates.io"]) {
307307 assert.ok(BUILD_HOSTS.includes(host), host);
308308 }
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+ }
309313 assert.ok(buildHosts("deploy").includes("api.cloudflare.com"));
310314 assert.ok(!buildHosts("actions").includes("api.cloudflare.com"));
311315 for (const host of buildHosts("deploy")) {
313317 }
314318 });
315319
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+
316326 // ---- Mentions --------------------------------------------------------------------------------
317327
318328 test("a mention whose run waits for a slot says so, and is not a failure", async () => {
+1−0
3232 ABUSE_HOST,
3333 ABUSE_MESSAGE,
3434 SANDBOX_BINDINGS,
35+ dockerFor,
3536 harnessEnv,
3637 jobHosts,
3738 newlyBlocked,
+9−0
103103 type RunGuard,
104104 abuse,
105105 buildGuardFor,
106+ dockerFor,
106107 egress,
107108 egressHosts,
108109 guardFor,
198199 */
199200 ABUSE_WATCH?: string;
200201 /**
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+ /**
201208 * Nightly backups (backup.ts): how many queued backups one sweep starts
202209 * (`0`: none, backups off here), and how many may run at once.
203210 */
16941701 G1T_API: "https://api.g1t.sh",
16951702 ACTIONS_JOB: args.job,
16961703 ACTIONS_TOKEN: args.token,
1704+ // Docker of the job's own, inside its sandbox (crates/runner docker/).
1705+ G1T_DOCKER: dockerFor(this.env.DOCKER),
16971706 },
16981707 });
16991708 } catch (error) {
+5−0
108108 // (crates/runner abuse.rs). "off" turns the CPU watch off; miners
109109 // named in commands are refused either way.
110110 "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",
111116 // Nightly backups (src/backup.ts): each sweep starts this many of the
112117 // backups the repos service queued, with at most BACKUPS_RUNNING at
113118 // once. "0" starts none.