Merge branch 'main' into actions-toolkit-oidc-artifacts
# Conflicts: # apps/docs/src/content/docs/guides/actions.md
| 16 | 16 | # with `deployment: false`, so a dry run or a change that deploys nothing | |
| 17 | 17 | # makes no deployment. | |
| 18 | 18 | # | |
| 19 | − | # Needs the repository secret CLOUDFLARE_API_TOKEN (a Production row), the | |
| 20 | − | # variable CLOUDFLARE_ACCOUNT_ID, and api.cloudflare.com among the project's | |
| 21 | − | # workflow-only domains for deploy.yml in production (Settings, Guardrails), | |
| 22 | − | # and registry.cloudflare.com there too, to find the runner's image. See | |
| 23 | − | # docs/DEPLOYING.md. | |
| 19 | + | # Needs the repository secret CLOUDFLARE_API_TOKEN (a Production row, with | |
| 20 | + | # Containers write), the variable CLOUDFLARE_ACCOUNT_ID, and | |
| 21 | + | # api.cloudflare.com among the project's workflow-only domains for | |
| 22 | + | # deploy.yml in production (Settings, Guardrails), and | |
| 23 | + | # registry.cloudflare.com there too, to find, pull and push the runner's | |
| 24 | + | # image. A job that must build that image (the `runner-image` group) does | |
| 25 | + | # so with its own Docker Engine, on a larger machine. See docs/DEPLOYING.md. | |
| 24 | 26 | name: Deploy | |
| 25 | 27 | ||
| 26 | 28 | on: | |
| 126 | 128 | # Runs when nothing before it failed: a migrate job skipped for having | |
| 127 | 129 | # nothing to apply is not a failure. | |
| 128 | 130 | if: ${{ !failure() && !cancelled() && needs.plan.outputs.has_core == 'true' && inputs.dry_run != true }} | |
| 129 | − | # Rust builds get 4 vCPUs; everything else the standard machine. | |
| 130 | − | runs-on: ${{ matrix.rust && 'g1t-4core' || 'ubuntu-latest' }} | |
| 131 | + | # Rust builds and the runner's image get 4 vCPUs; everything else the | |
| 132 | + | # standard machine. | |
| 133 | + | runs-on: ${{ (matrix.rust || matrix.image) && 'g1t-4core' || 'ubuntu-latest' }} | |
| 131 | 134 | environment: | |
| 132 | 135 | name: production | |
| 133 | 136 | url: https://g1t.sh | |
| 185 | 188 | restore-keys: | | |
| 186 | 189 | cargo-target-${{ runner.os }}-${{ matrix.group }}- | |
| 187 | 190 | cargo-target-${{ runner.os }}- | |
| 191 | + | # The runner's image: its binary, built natively for musl (the base | |
| 192 | + | # has musl-gcc; the target is added here), with its Cargo target kept | |
| 193 | + | # between runs. The image itself is built and pushed with the job's | |
| 194 | + | # own Docker Engine (scripts/deploy/image.mjs). | |
| 195 | + | - name: Rust for the runner | |
| 196 | + | if: ${{ matrix.image }} | |
| 197 | + | run: rustup target add x86_64-unknown-linux-musl | |
| 198 | + | - name: Cache the runner's build | |
| 199 | + | if: ${{ matrix.image }} | |
| 200 | + | uses: actions/cache@v4 | |
| 201 | + | with: | |
| 202 | + | path: | | |
| 203 | + | ~/.cargo/registry/cache | |
| 204 | + | target/x86_64-unknown-linux-musl/release | |
| 205 | + | !target/**/incremental | |
| 206 | + | key: runner-musl-${{ runner.os }}-${{ hashFiles('Cargo.lock', 'services/runner/base.json') }} | |
| 207 | + | restore-keys: runner-musl-${{ runner.os }}- | |
| 188 | 208 | - name: Install | |
| 189 | 209 | run: node scripts/deploy.mjs install --only "${{ matrix.units }}" | |
| 190 | 210 | - name: Deploy ${{ matrix.units }} | |
| 196 | 216 | name: edge (${{ matrix.group }}) | |
| 197 | 217 | needs: [plan, migrate, core] | |
| 198 | 218 | if: ${{ !failure() && !cancelled() && needs.plan.outputs.has_edge == 'true' && inputs.dry_run != true }} | |
| 199 | − | runs-on: ${{ matrix.rust && 'g1t-4core' || 'ubuntu-latest' }} | |
| 219 | + | runs-on: ${{ (matrix.rust || matrix.image) && 'g1t-4core' || 'ubuntu-latest' }} | |
| 200 | 220 | environment: | |
| 201 | 221 | name: production | |
| 202 | 222 | url: https://g1t.sh | |
| 211 | 231 | name: front (${{ matrix.group }}) | |
| 212 | 232 | needs: [plan, migrate, core, edge] | |
| 213 | 233 | if: ${{ !failure() && !cancelled() && needs.plan.outputs.has_front == 'true' && inputs.dry_run != true }} | |
| 214 | − | runs-on: ${{ matrix.rust && 'g1t-4core' || 'ubuntu-latest' }} | |
| 234 | + | runs-on: ${{ (matrix.rust || matrix.image) && 'g1t-4core' || 'ubuntu-latest' }} | |
| 215 | 235 | environment: | |
| 216 | 236 | name: production | |
| 217 | 237 | url: https://g1t.sh |
| 7 | 7 | # Weekly, for security updates and new stable toolchains; when the base's | |
| 8 | 8 | # folder changes on main (a change that forgot to rebuild it); and by hand. | |
| 9 | 9 | # | |
| 10 | − | # It needs Docker, which g1t's own sandboxes do not have, so it runs on a | |
| 11 | − | # self-hosted runner with the `docker` label. Until one is registered, run | |
| 12 | − | # the same thing by hand on a machine with Docker: | |
| 10 | + | # It runs on a self-hosted runner with the `docker` label. g1t's own | |
| 11 | + | # machines have Docker now, but this build's own downloads (Docker's apt | |
| 12 | + | # repository over HTTPS) do not yet trust a guarded job's egress | |
| 13 | + | # certificate, so it stays where the network is open. Until a runner is | |
| 14 | + | # registered, run the same thing by hand on a machine with Docker: | |
| 13 | 15 | # | |
| 14 | 16 | # node scripts/deploy.mjs build-base | |
| 15 | 17 | # |
| 74 | 74 | image: | |
| 75 | 75 | name: Container image | |
| 76 | 76 | needs: binaries | |
| 77 | − | # Needs Docker, which g1t's own sandboxes do not have. | |
| 77 | + | # Builds for arm64 as well as amd64, which needs QEMU's emulators | |
| 78 | + | # registered on the machine: a self-hosted runner's, for now. | |
| 78 | 79 | runs-on: [self-hosted, docker] | |
| 79 | 80 | environment: production | |
| 80 | 81 | timeout-minutes: 30 |
| 184 | 184 | ||
| 185 | 185 | ## Actions and runners | |
| 186 | 186 | ||
| 187 | − | ### No Docker in g1t's sandboxes | |
| 187 | + | ### Docker shares the job's network | |
| 188 | 188 | ||
| 189 | − | On g1t's own machines, a job's `container:` image is not used (its steps | |
| 190 | − | run on g1t's runner image instead), and a step cannot run `docker build`. | |
| 191 | − | Docker container actions (`uses: docker://…`, or an action that runs as a | |
| 192 | − | Docker image) and `services:` containers, such as a database, do not run | |
| 193 | − | on any runner yet, self-hosted ones included. | |
| 189 | + | A job's Docker Engine runs its containers on the job's own network, not on | |
| 190 | + | networks of their own. A service is reached at `localhost` and by its | |
| 191 | + | name, as on GitHub, but two containers cannot listen on the same port, and | |
| 192 | + | `docker network create` gives no separation between containers. | |
| 194 | 193 | ||
| 195 | − | - **Why.** Jobs run in Cloudflare Containers, which offer no supported way | |
| 196 | − | to run Docker or another image builder inside a container. | |
| 197 | − | - **Instead.** Run `container:` jobs and image builds on a | |
| 198 | − | [self-hosted runner](/guides/self-hosted-runners/). A runner in Docker | |
| 199 | − | mode runs each job in its `container:` image. To build images, register a | |
| 200 | − | runner with `--no-docker` on a machine that has Docker, and its steps can | |
| 201 | − | call `docker build` and `docker push`. Self-hosted time costs nothing. For | |
| 202 | − | a database, start it from a `run:` step on a self-hosted runner. | |
| 203 | − | - **Status.** Docker container actions and `services:` are planned. Image | |
| 204 | − | builds on g1t's machines depend on Cloudflare. | |
| 194 | + | - **Why.** Jobs run in Cloudflare Containers, which let a container run | |
| 195 | + | Docker but not route a container network of its own out, or change its | |
| 196 | + | packet filter. Sharing the job's network is also what keeps the job's | |
| 197 | + | guardrails on every container. | |
| 198 | + | - **Instead.** Give containers that would clash different ports. | |
| 199 | + | - **Status.** Not scheduled. | |
| 200 | + | ||
| 201 | + | ### No `type=gha` build cache | |
| 202 | + | ||
| 203 | + | Buildx's GitHub Actions cache backend (`cache-to: type=gha`) is skipped on | |
| 204 | + | g1t, and the build runs without a cache. | |
| 205 | + | ||
| 206 | + | - **Why.** It talks to GitHub's cache service, which g1t's cache does not | |
| 207 | + | speak yet. | |
| 208 | + | - **Instead.** Use a registry cache in g1t's container registry | |
| 209 | + | (`type=registry`), or `type=local` with `actions/cache`. See | |
| 210 | + | [caching image builds](/guides/actions/#caching-image-builds). | |
| 211 | + | - **Status.** Planned. | |
| 212 | + | ||
| 213 | + | ### No multi-platform image builds on g1t's machines | |
| 214 | + | ||
| 215 | + | Building an image for another platform, such as `linux/arm64`, needs QEMU's | |
| 216 | + | emulators, which g1t's machines do not have set up. | |
| 217 | + | ||
| 218 | + | - **Instead.** Build other platforms on a | |
| 219 | + | [self-hosted runner](/guides/self-hosted-runners/) of that architecture, | |
| 220 | + | or one with QEMU set up. | |
| 221 | + | - **Status.** Planned. | |
| 205 | 222 | ||
| 206 | 223 | ### Linux only on g1t's machines | |
| 207 | 224 |
| 47 | 47 | | `actions/cache`, `actions/cache/restore`, `actions/cache/save` | Kept per repository, found by `key` or the newest under a `restore-keys` prefix. `path` takes globs and `!` exclusions. Up to 2 GiB each; see [the cache](#the-cache). | | |
| 48 | 48 | | Actions that cache through the toolkit, such as `actions/setup-node` with `cache: npm` or `Swatinem/rust-cache` | The same: they save to and restore from the repository's cache. See [actions built on the toolkit](#actions-built-on-the-toolkit). | | |
| 49 | 49 | | `permissions: id-token: write` | The job can ask for an OIDC token, and trade it for a cloud provider's credentials. See [OIDC tokens](#oidc-tokens). | | |
| 50 | + | | `docker build`, `push`, `run`, `login`, `compose`, Buildx | The same, with a Docker Engine of the job's own. See [Docker](#docker). | | |
| 51 | + | | `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. | | |
| 52 | + | | `container:` | The same: every step runs inside the image. | | |
| 53 | + | | `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. | | |
| 54 | + | | `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. | | |
| 50 | 55 | ||
| 51 | 56 | The **Actions** page of a workflow says, under *How this runs on g1t*, | |
| 52 | 57 | anything in it that runs differently. | |
| 57 | 62 | job with `runs-on: windows-latest` or `macos-latest` fails, and says so. | |
| 58 | 63 | [Self-hosted runners](/guides/self-hosted-runners/) of any OS run them: | |
| 59 | 64 | `runs-on: [self-hosted, windows]`. | |
| 60 | − | - **Docker** container actions, `services:` containers and `container:` on | |
| 61 | − | g1t's machines. A job's `container:` is ignored there and its steps run on | |
| 62 | − | g1t's image; a [self-hosted runner](/guides/self-hosted-runners/#what-a-job-gets) | |
| 63 | − | that runs jobs in Docker uses it. | |
| 65 | + | - **Docker's `type=gha` build cache.** Buildx skips it on g1t, and the | |
| 66 | + | build runs without a cache. Use a registry cache instead; see | |
| 67 | + | [caching image builds](#caching-image-builds). | |
| 64 | 68 | - **Reusable workflows from other repositories** (`uses: owner/repo/.github/workflows/x.yml@v1`); ones in the same repository work. | |
| 65 | 69 | - **Actions that upload artifacts with the toolkit's artifact library | |
| 66 | 70 | themselves.** The library refuses to run against any server but | |
| 80 | 84 | ## The runner | |
| 81 | 85 | ||
| 82 | 86 | Jobs run in a fresh sandbox each: Debian with Node 24, Python 3, Go, Rust, | |
| 83 | − | `build-essential`, `git`, `curl`, `jq` and passwordless `sudo`, in GitHub's | |
| 84 | − | layout (`/home/runner/work`, `RUNNER_TEMP`, `RUNNER_TOOL_CACHE`). | |
| 87 | + | `build-essential`, `git`, `curl`, `jq`, Docker (with Buildx and Compose) | |
| 88 | + | and passwordless `sudo`, in GitHub's layout (`/home/runner/work`, | |
| 89 | + | `RUNNER_TEMP`, `RUNNER_TOOL_CACHE`). | |
| 85 | 90 | `runner.os` is `Linux`. `ubuntu-latest`, `ubuntu-24.04` and other Linux | |
| 86 | 91 | labels all run here. A job whose `runs-on` names `self-hosted` waits for one | |
| 87 | 92 | of your [self-hosted runners](/guides/self-hosted-runners/) instead. Setup actions such as | |
| 124 | 129 | What builds need is the package registries (npm, PyPI, crates.io, the Go | |
| 125 | 130 | proxy, RubyGems, Packagist, NuGet, Maven and Gradle, Debian's mirrors), | |
| 126 | 131 | GitHub, where `uses:` actions and the setup actions' downloads come from, | |
| 127 | − | and the toolchains' download sites (`nodejs.org`, `go.dev`, | |
| 128 | − | `static.rust-lang.org`). A request anywhere else gets `403` with | |
| 132 | + | the toolchains' download sites (`nodejs.org`, `go.dev`, | |
| 133 | + | `static.rust-lang.org`), and the public container registries (Docker Hub, | |
| 134 | + | GitHub's, Quay, and `mirror.gcr.io`, the mirror of Docker Hub that a job's | |
| 135 | + | Engine asks first). A request anywhere else gets `403` with | |
| 129 | 136 | the reason. To reach another host, someone with the Maintain [role](/guides/access-and-roles/) or | |
| 130 | 137 | higher adds it to the project's | |
| 131 | 138 | allowed domains under **Settings → Guardrails**; a project whose guardrails | |
| 143 | 150 | looks like it is mining is stopped. See | |
| 144 | 151 | [abuse and mining](/guides/guardrails/#abuse-and-mining). | |
| 145 | 152 | ||
| 153 | + | ## Docker | |
| 154 | + | ||
| 155 | + | Each job on g1t's machines has a Docker Engine of its own, inside the | |
| 156 | + | job's sandbox. Nothing runs until the job uses it: the first `docker` | |
| 157 | + | command, or a job's `services:` or `container:`, starts it, in a second | |
| 158 | + | or two, and the log says so. It ends with the job, with every image, | |
| 159 | + | container and build cache in it. No other job, repository or workspace | |
| 160 | + | ever shares it. | |
| 161 | + | ||
| 162 | + | ```yaml | |
| 163 | + | jobs: | |
| 164 | + | test: | |
| 165 | + | runs-on: ubuntu-latest | |
| 166 | + | services: | |
| 167 | + | postgres: | |
| 168 | + | image: postgres:17 | |
| 169 | + | env: | |
| 170 | + | POSTGRES_PASSWORD: ${{ secrets.DB_PASSWORD }} | |
| 171 | + | ports: ["5432:5432"] | |
| 172 | + | options: >- | |
| 173 | + | --health-cmd pg_isready --health-interval 5s --health-retries 10 | |
| 174 | + | steps: | |
| 175 | + | - uses: actions/checkout@v5 | |
| 176 | + | - run: docker compose up -d --wait | |
| 177 | + | - run: npm test | |
| 178 | + | env: | |
| 179 | + | DATABASE_URL: postgres://postgres:${{ secrets.DB_PASSWORD }}@localhost:5432/postgres | |
| 180 | + | ``` | |
| 181 | + | ||
| 182 | + | ### What works | |
| 183 | + | ||
| 184 | + | | | On g1t's machines | | |
| 185 | + | | --- | --- | | |
| 186 | + | | `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. | | |
| 187 | + | | `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. | | |
| 188 | + | | `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. | | |
| 189 | + | | `uses: docker://image` | Pulled and run, with `with.args` and `with.entrypoint`. | | |
| 190 | + | | 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`. | | |
| 191 | + | | `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. | | |
| 192 | + | | `docker/build-push-action` | Works, with `push`, `load`, `tags`, `labels`, `build-args`, `secrets`, `target`, `provenance` and `sbom`. | | |
| 193 | + | | `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. | | |
| 194 | + | ||
| 195 | + | ### Services and the network | |
| 196 | + | ||
| 197 | + | Every container a job starts shares the job's own network, the one its | |
| 198 | + | [guardrails](/guides/guardrails/) apply to. So: | |
| 199 | + | ||
| 200 | + | - **A service is at `localhost`** on its port, from steps and from other | |
| 201 | + | containers. `ports: ["5432:5432"]` and `ports: ["5432"]` both mean | |
| 202 | + | `localhost:5432`. | |
| 203 | + | - **A port mapped to another number** (`ports: ["6543:5432"]`, or | |
| 204 | + | `docker run -p 8080:80`) is forwarded: `localhost:6543` reaches the | |
| 205 | + | service's 5432. `job.services.<id>.ports` says which port to use, and | |
| 206 | + | `docker inspect` and `docker port` report it. | |
| 207 | + | - **A service is also reached by its name**, as it is from a job | |
| 208 | + | container on GitHub: `postgres:5432` works from steps, from the job's | |
| 209 | + | container and from any container started later. So do the names of | |
| 210 | + | containers and Compose services, and their network aliases. | |
| 211 | + | - **Two containers cannot listen on the same port.** A job with a | |
| 212 | + | `redis` service and a Compose file that starts another Redis on 6379 | |
| 213 | + | gets an error from the second; give one of them another port. | |
| 214 | + | ||
| 215 | + | A container that asks for `--network none` gets none, and | |
| 216 | + | `--network container:<name>` shares that container's. | |
| 217 | + | ||
| 218 | + | ### Job containers | |
| 219 | + | ||
| 220 | + | With `container:`, the steps run inside the image as its default user, | |
| 221 | + | usually `root`. A few things differ from GitHub's runner: | |
| 222 | + | ||
| 223 | + | - The workspace is at the same path as on g1t's runner | |
| 224 | + | (`/home/runner/work/…`), not `/__w`. `github.workspace` is correct | |
| 225 | + | either way. | |
| 226 | + | - JavaScript actions run inside the container with g1t's Node 24, which | |
| 227 | + | needs an image with glibc and `libstdc++` (Debian, Ubuntu and most | |
| 228 | + | language images have both). In an image without them, such as Alpine, | |
| 229 | + | they run beside the container, on g1t's runner, with the same files, | |
| 230 | + | and the log says so. | |
| 231 | + | - `actions/checkout`, `actions/cache` and the artifact actions run on | |
| 232 | + | g1t's runner, with the same files. | |
| 233 | + | ||
| 234 | + | ### Building and pushing images | |
| 235 | + | ||
| 236 | + | On g1t's machines, a job is signed in to g1t's container registry from | |
| 237 | + | the start, with its own `G1T_TOKEN`, so it can push to and pull from its | |
| 238 | + | workspace's images without a login step. A run that gets no secrets is | |
| 239 | + | not signed in. See [container registry](/guides/containers/#in-workflows). | |
| 240 | + | ||
| 241 | + | ```yaml | |
| 242 | + | jobs: | |
| 243 | + | image: | |
| 244 | + | runs-on: g1t-4core | |
| 245 | + | steps: | |
| 246 | + | - uses: actions/checkout@v5 | |
| 247 | + | - uses: docker/setup-buildx-action@v3 | |
| 248 | + | - uses: docker/build-push-action@v6 | |
| 249 | + | with: | |
| 250 | + | push: true | |
| 251 | + | tags: g1t.sh/${{ github.repository }}:${{ github.sha }} | |
| 252 | + | cache-from: type=registry,ref=g1t.sh/${{ github.repository }}:buildcache | |
| 253 | + | cache-to: type=registry,ref=g1t.sh/${{ github.repository }}:buildcache,mode=max | |
| 254 | + | ``` | |
| 255 | + | ||
| 256 | + | For other registries, sign in with `docker/login-action` or | |
| 257 | + | `docker login`, as on GitHub. Docker Hub's images are pulled through its | |
| 258 | + | public mirror first, so jobs are rarely held up by Docker Hub's limits on | |
| 259 | + | anonymous pulls. | |
| 260 | + | ||
| 261 | + | #### Caching image builds | |
| 262 | + | ||
| 263 | + | The Engine starts empty in every job, so a build's layers are rebuilt | |
| 264 | + | unless the job brings a cache: | |
| 265 | + | ||
| 266 | + | - **A registry cache** (`cache-to: type=registry,ref=…,mode=max`), in g1t's | |
| 267 | + | registry or any other, is the simplest and is shared by every branch. | |
| 268 | + | - **A local cache** (`cache-to: type=local,dest=/tmp/buildx-cache`) saved | |
| 269 | + | and restored with `actions/cache`, within [the cache's limits](#the-cache). | |
| 270 | + | - **`type=gha`** is not used on g1t yet: Buildx skips it, and the build | |
| 271 | + | runs without a cache. | |
| 272 | + | ||
| 273 | + | ### Limits | |
| 274 | + | ||
| 275 | + | - **Machine.** Containers share the job's machine: its vCPUs, memory and | |
| 276 | + | disk ([machine sizes](#machine-sizes)). Image builds and databases want | |
| 277 | + | `g1t-2core` or `g1t-4core`. `--cpus` and `--memory` limit a container | |
| 278 | + | within that. | |
| 279 | + | - **Disk.** Images take room on the job's disk. On a machine whose disk | |
| 280 | + | cannot hold layered images, the Engine stores plain copies, which take | |
| 281 | + | more room; the log says when it does. | |
| 282 | + | - **Linux, amd64.** Images for other platforms need QEMU's emulators, | |
| 283 | + | which g1t's machines do not have set up; `docker/setup-qemu-action` is | |
| 284 | + | not supported there yet. | |
| 285 | + | - **Privileged containers** (`--privileged`) run, with no more reach than | |
| 286 | + | the job itself has: the job's sandbox is the boundary. | |
| 287 | + | ||
| 288 | + | ### How Docker is kept safe | |
| 289 | + | ||
| 290 | + | - **One Engine per job.** It runs inside the job's own sandbox, a virtual | |
| 291 | + | machine of its own, and is gone with it. No Docker socket of g1t's, or of | |
| 292 | + | any machine, is shared with a job. | |
| 293 | + | - **The job's guardrails hold.** Containers use the job's network, so a | |
| 294 | + | container, a build step or an image pull reaches only what the job may | |
| 295 | + | reach. A host off the list gets `403` with the reason, as any step does. | |
| 296 | + | - **HTTPS keeps working.** In a job whose network is restricted, every | |
| 297 | + | container and build step is given the certificate the job's HTTPS is | |
| 298 | + | checked with, in `/dev/g1t-egress`, and `SSL_CERT_FILE`, | |
| 299 | + | `NODE_EXTRA_CA_CERTS`, `REQUESTS_CA_BUNDLE`, `CURL_CA_BUNDLE`, `PIP_CERT`, | |
| 300 | + | `GIT_SSL_CAINFO` and `CARGO_HTTP_CAINFO` pointing at it, unless the | |
| 301 | + | container sets them itself. None of it is written into an image's layers. | |
| 302 | + | Tools that keep their own list of certificates, such as Java's, need it | |
| 303 | + | added in the build that uses them. | |
| 304 | + | - **Short-lived credentials.** The registry sign-in uses the run's own | |
| 305 | + | token, which ends with the run; `credentials:` for a service or a job | |
| 306 | + | container are used for that pull only. | |
| 307 | + | - **No miners.** A container whose image or command names a miner is not | |
| 308 | + | created, as a step's script is not run. | |
| 309 | + | ||
| 146 | 310 | ## The cache | |
| 147 | 311 | ||
| 148 | 312 | `actions/cache` keeps what a job saves for the repository's later jobs: |
| 253 | 253 | your key. | |
| 254 | 254 | ||
| 255 | 255 | `GET /openai/v1/models` lists what the workspace can use: its own | |
| 256 | − | providers' models first, then g1t's, cheapest Claude first. Each has | |
| 256 | + | providers' models first, then g1t's, starting with the Claude g1t suggests | |
| 257 | + | starting with (Claude Haiku 5.5 today). g1t adds models as providers | |
| 258 | + | release them, once their prices are confirmed, so the list and the tables | |
| 259 | + | below grow over time; a model a provider stops offering is listed until it | |
| 260 | + | is retired. Each has | |
| 257 | 261 | `billed_to` (`workspace` or `g1t`), `connection` (your provider's name) and, | |
| 258 | 262 | on g1t's models, `pricing` in dollars per million tokens: | |
| 259 | 263 |
| 72 | 72 | docker push g1t.sh/${{ github.repository }}:${{ github.sha }} | |
| 73 | 73 | ``` | |
| 74 | 74 | ||
| 75 | − | Runs that get no secrets (a pull request from someone without Write) get an | |
| 76 | − | empty token, and cannot push. See | |
| 75 | + | On g1t's own machines a job is already signed in to g1t.sh with that | |
| 76 | + | token when it starts, so the sign-in step can be left out there; it does | |
| 77 | + | no harm. Runs that get no secrets (a pull request from someone without | |
| 78 | + | Write) get an empty token, are not signed in, and cannot push. See | |
| 77 | 79 | [secrets and variables](/guides/actions/#secrets-and-variables). | |
| 78 | 80 | ||
| 79 | 81 | ## The 100 MB limit |
| 103 | 103 | - every package registry above, whatever the project turned on for its | |
| 104 | 104 | agents, and RubyGems, Packagist, NuGet, Maven Central, Gradle and | |
| 105 | 105 | Debian's mirrors; | |
| 106 | + | - container registries, for a job's own Docker Engine: Docker Hub | |
| 107 | + | (`registry-1.docker.io`, `auth.docker.io` and the CDNs its layers come | |
| 108 | + | from), `mirror.gcr.io`, Quay (`quay.io` and its CDNs), and Docker's | |
| 109 | + | package repository, `download.docker.com`; | |
| 106 | 110 | - for deploy builds, Cloudflare's API, which the build uploads its app to. | |
| 107 | 111 | ||
| 108 | 112 | The repository itself is cloned from g1t, which is always reachable. A | |
| 109 | 113 | project whose guardrails set **Only allowed hosts** to Open runs its jobs | |
| 110 | 114 | and builds with an open network too. | |
| 111 | 115 | ||
| 116 | + | The containers a job starts with [Docker](/guides/actions/#docker), its | |
| 117 | + | services and its build steps share the job's network, so this list is | |
| 118 | + | theirs too: an image from another registry, or a build step that | |
| 119 | + | downloads from another host, needs that host allowed, as a step would. | |
| 120 | + | ||
| 112 | 121 | ### Workflow-only domains | |
| 113 | 122 | ||
| 114 | 123 | Some hosts only a workflow should reach: the API a deploy uploads to, a | |
| 279 | 288 | - **No pool to reach.** No mining pool is on any allowed list, so a | |
| 280 | 289 | restricted sandbox's miner has nowhere to send its work. | |
| 281 | 290 | - **Miners by name.** A shell command an agent runs, a check, a build | |
| 282 | − | command or a workflow step that names a known miner (`xmrig`, | |
| 283 | − | `cpuminer`, `t-rex` and others), a pool address (`stratum+tcp://`) or a | |
| 284 | − | miner's flags (`--donate-level`, `--algo=rx/0`) is refused, whatever the | |
| 285 | − | project's rules. A running process whose command line names one stops | |
| 291 | + | command, a workflow step or a container a job starts whose image or | |
| 292 | + | command names a known miner (`xmrig`, `cpuminer`, `t-rex` and others), a | |
| 293 | + | pool address (`stratum+tcp://`) or a miner's flags (`--donate-level`, | |
| 294 | + | `--algo=rx/0`) is refused, whatever the project's rules. A running process whose command line names one stops | |
| 286 | 295 | the sandbox at once. | |
| 287 | 296 | - **The CPU signature.** Every sandbox samples itself every 30 seconds: | |
| 288 | 297 | CPU use, file and disk I/O, network bytes, new processes, and whether the |
| 47 | 47 | of the same kind, Auto moves that work down a tier there; when a model | |
| 48 | 48 | keeps failing, up. | |
| 49 | 49 | ||
| 50 | + | The models behind the tiers are today's. g1t keeps up with new models | |
| 51 | + | as providers release them: it checks for new ones every day, and when g1t | |
| 52 | + | moves a tier to a new model, your runs use it within a minute, with | |
| 53 | + | nothing for you to change. Nobody picks a model; Auto keeps choosing by the | |
| 54 | + | work. A model a provider retires is never used again: the next model for | |
| 55 | + | that tier runs instead, and the run says so. | |
| 56 | + | ||
| 50 | 57 | Every run says which model it used and why, in one line on its run and in | |
| 51 | 58 | its pull request's session, such as *Used a fast model (Claude Haiku 5.5): | |
| 52 | 59 | small change, 3 files and 80 lines.* The full rules are in |
| 139 | 139 | ||
| 140 | 140 | - **In Docker** (the default), each job gets a fresh container from its | |
| 141 | 141 | `container:` image or the runner's `--image`, removed when it ends. The | |
| 142 | − | runner needs Docker, and its user needs to be allowed to use it. | |
| 142 | + | runner needs Docker, and its user needs to be allowed to use it. Its | |
| 143 | + | `services:` are not started, since the job's container has no Docker of | |
| 144 | + | its own; the log says so. | |
| 143 | 145 | - **With `--no-docker`**, each job gets a fresh folder under the work | |
| 144 | 146 | folder, removed when it ends, and runs with whatever the machine has | |
| 145 | 147 | installed. A step's default shell is `bash` on Linux and macOS and | |
| 146 | 148 | PowerShell on Windows; `shell: pwsh`, `powershell`, `cmd`, `bash` and | |
| 147 | − | `python` work where installed. | |
| 149 | + | `python` work where installed. On a machine with Docker, the job's | |
| 150 | + | `services:`, `container:`, `docker://` steps and Docker actions use | |
| 151 | + | the machine's Docker, as on GitHub's runners. | |
| 148 | 152 | ||
| 149 | 153 | A self-hosted job stops at 60 minutes unless its `timeout-minutes` says | |
| 150 | 154 | more, up to 24 hours (1440). Jobs on g1t's own machines stop at 60 minutes. |
| 452 | 452 | | Standard | Claude Sonnet 5.5 | Most changes and reviews | | |
| 453 | 453 | | Most capable | Claude Opus 5.5 | Hard work, and work that failed on the standard model | | |
| 454 | 454 | ||
| 455 | + | The models are today's: when g1t moves a tier to a newer model, runs use | |
| 456 | + | it within a minute. A model its provider retires is never used; the next | |
| 457 | + | model for the tier runs, and the run's line says so. | |
| 458 | + | ||
| 455 | 459 | The job starts on its tier: | |
| 456 | 460 | ||
| 457 | 461 | | Work | Starts on | |
| 102 | 102 | to Stripe's hosted invoice page. An invoice also goes out on its own as | |
| 103 | 103 | each month closes: one Stripe invoice, a line per workspace for what it | |
| 104 | 104 | owes, net 30, emailed by Stripe. | |
| 105 | + | - **Agents & models** (`/agents`, under Platform): billing's model | |
| 106 | + | catalogue, every model g1t can use (`admin_models`). **Check for new | |
| 107 | + | models** lists each provider's models now, through the model proxy's | |
| 108 | + | `Discovery` entrypoint (the `MODELS` binding), as the daily check does; | |
| 109 | + | the result says what each provider listed, what is new or gone, or why a | |
| 110 | + | provider could not be listed. **Defaults**: the model behind each of | |
| 111 | + | Auto's tiers, the harness's background model and the AI Gateway's first | |
| 112 | + | Claude (an available, priced Claude each, shown with what a typical run | |
| 113 | + | costs on it), and each kind of job's starting tier and effort. A change | |
| 114 | + | needs a reason and shows a review first, the current and new value side | |
| 115 | + | by side with what a typical run would cost on each, before **Save** | |
| 116 | + | (`admin_set_model_default`); runs pick it up within a minute. A default | |
| 117 | + | that has fallen back (its model retired or no longer listed) says so. | |
| 118 | + | **New models**: each model a check found, with its prices filled in | |
| 119 | + | where known; confirm its name, tier and prices per million tokens (and | |
| 120 | + | long-prompt prices) and **Approve**, or **Retire** it | |
| 121 | + | (`admin_decide_model`). **Catalogue**: every other model with its status, | |
| 122 | + | context, prices, typical run and when its provider last listed it, each | |
| 123 | + | with **Retire** or **Restore**. **Checks**: the latest checks of each | |
| 124 | + | provider. Every change names the staff member and why in the audit log | |
| 125 | + | (account `models`). See docs/BILLING_OPERATIONS.md, "The model | |
| 126 | + | catalogue". | |
| 105 | 127 | - **Stripe**: whether billing's key is in test or live mode (or off), the | |
| 106 | 128 | webhook Stripe calls (URL, endpoint id, events, who registered it and | |
| 107 | 129 | when), and the events Stripe sent lately with what billing did with | |
| 193 | 215 | ||
| 194 | 216 | ```sh | |
| 195 | 217 | npm run typecheck -w @g1t/sudo | |
| 196 | − | npm test -w @g1t/sudo # JWT verification, forms, money, the workspace join, paging, nav, charts, signals | |
| 218 | + | npm test -w @g1t/sudo # JWT verification, forms, money, the workspace join, paging, nav, charts, signals, models | |
| 197 | 219 | npm run build -w @g1t/sudo | |
| 198 | 220 | ``` | |
| 199 | 221 |
| 67 | 67 | */ | |
| 68 | 68 | export function accountPath(account: string | null | undefined): string | null { | |
| 69 | 69 | const id = (account ?? "").trim().toLowerCase(); | |
| 70 | + | // The model catalogue's changes (billing's catalogue.rs). | |
| 71 | + | if (id === "models") return "/agents"; | |
| 70 | 72 | const status = /^(incident|maintenance):([a-z0-9-]{1,64})$/.exec(id); | |
| 71 | 73 | if (status) return status[1] === "incident" ? `/incidents/${status[2]}` : `/incidents/maintenance/${status[2]}`; | |
| 72 | 74 | if (ENTERPRISE.test(id)) return `/enterprises/${encodeURIComponent(id)}`; | |
| 77 | 79 | /** What to call an account: a workspace by its slug, an enterprise by its name when known. */ | |
| 78 | 80 | export function accountName(account: string, names: Map<string, string> = new Map()): string { | |
| 79 | 81 | if (names.has(account)) return names.get(account) as string; | |
| 82 | + | if (account === "models") return "Agents & models"; | |
| 80 | 83 | if (account.startsWith("incident:")) return "Incident"; | |
| 81 | 84 | if (account.startsWith("maintenance:")) return "Maintenance"; | |
| 82 | 85 | if (account.startsWith("ws_")) return account.slice(3); | |
| 133 | 136 | maintenance_cancelled: "Maintenance cancelled", | |
| 134 | 137 | credit_revoked: "Credit revoked", | |
| 135 | 138 | credit_expired: "Credit expired", | |
| 139 | + | // The model catalogue (Agents & models). | |
| 140 | + | models_discovered: "Models found or gone", | |
| 141 | + | model_approved: "Model approved", | |
| 142 | + | model_retired: "Model retired", | |
| 143 | + | model_restored: "Model restored", | |
| 144 | + | model_default: "Model default changed", | |
| 136 | 145 | }; | |
| 137 | 146 | ||
| 138 | 147 | /** |
| 1 | + | import assert from "node:assert/strict"; | |
| 2 | + | import { test } from "node:test"; | |
| 3 | + | ||
| 4 | + | import type { CatalogueModel } from "@g1t/contracts"; | |
| 5 | + | ||
| 6 | + | import { choicesFor, describeDefault, impact, parseApproval, parseDefault, parsePerMillion, perMillion, tokens } from "./models.ts"; | |
| 7 | + | ||
| 8 | + | function model(id: string, name: string, typical: number, extra: Partial<CatalogueModel> = {}): CatalogueModel { | |
| 9 | + | return { | |
| 10 | + | model: id, | |
| 11 | + | name, | |
| 12 | + | provider: "anthropic", | |
| 13 | + | kind: "chat", | |
| 14 | + | inputMicros: 1_000_000, | |
| 15 | + | outputMicros: 5_000_000, | |
| 16 | + | cacheReadMicros: 100_000, | |
| 17 | + | cacheWriteMicros: 1_250_000, | |
| 18 | + | aliases: [], | |
| 19 | + | family: "haiku", | |
| 20 | + | tierHint: "small", | |
| 21 | + | contextWindow: 0, | |
| 22 | + | maxOutput: 0, | |
| 23 | + | capabilities: [], | |
| 24 | + | dimensions: 0, | |
| 25 | + | status: "available", | |
| 26 | + | priced: true, | |
| 27 | + | source: "staff", | |
| 28 | + | firstSeenAt: null, | |
| 29 | + | lastSeenAt: null, | |
| 30 | + | missingSince: null, | |
| 31 | + | approvedBy: null, | |
| 32 | + | approvedAt: null, | |
| 33 | + | note: "", | |
| 34 | + | typicalRunMicros: typical, | |
| 35 | + | ...extra, | |
| 36 | + | }; | |
| 37 | + | } | |
| 38 | + | ||
| 39 | + | const catalogue = [ | |
| 40 | + | model("claude-haiku-5-5", "Claude Haiku 5.5", 76_000), | |
| 41 | + | model("claude-sonnet-5-5", "Claude Sonnet 5.5", 1_340_000, { tierHint: "large" }), | |
| 42 | + | model("claude-haiku-6", "Claude Haiku 6", 0, { status: "new", priced: false }), | |
| 43 | + | model("@cf/openai/gpt-oss-120b", "gpt-oss-120b", 30_000, { provider: "workers-ai" }), | |
| 44 | + | model("claude-haiku-4-5", "Claude Haiku 4.5", 600_000, { status: "retired" }), | |
| 45 | + | ]; | |
| 46 | + | ||
| 47 | + | function form(fields: Record<string, string>): FormData { | |
| 48 | + | const data = new FormData(); | |
| 49 | + | for (const [name, value] of Object.entries(fields)) data.set(name, value); | |
| 50 | + | return data; | |
| 51 | + | } | |
| 52 | + | ||
| 53 | + | test("prices per million are typed in dollars to a millionth", () => { | |
| 54 | + | assert.equal(parsePerMillion("0.125"), 125_000); | |
| 55 | + | assert.equal(parsePerMillion("$2"), 2_000_000); | |
| 56 | + | assert.equal(parsePerMillion("12.50"), 12_500_000); | |
| 57 | + | assert.equal(parsePerMillion("0.000001"), 1); | |
| 58 | + | assert.equal(parsePerMillion(""), 0); | |
| 59 | + | assert.equal(parsePerMillion("-1"), null); | |
| 60 | + | assert.equal(parsePerMillion("0.0000001"), null); | |
| 61 | + | assert.equal(parsePerMillion("abc"), null); | |
| 62 | + | assert.equal(perMillion(125_000), "0.125"); | |
| 63 | + | assert.equal(perMillion(100_000), "0.10"); | |
| 64 | + | assert.equal(perMillion(2_000_000), "2"); | |
| 65 | + | }); | |
| 66 | + | ||
| 67 | + | test("approving a model takes its name, tier, prices and why", () => { | |
| 68 | + | const approved = parseApproval( | |
| 69 | + | form({ name: "Claude Haiku 6", tier_hint: "small", input: "0.08", output: "0.40", cache_read: "0.008", cache_write: "0.10", reason: "Anthropic's price page" }), | |
| 70 | + | "chat", | |
| 71 | + | ); | |
| 72 | + | assert.ok(approved.ok); | |
| 73 | + | assert.equal(approved.value.prices.inputMicros, 80_000); | |
| 74 | + | assert.equal(approved.value.prices.outputMicros, 400_000); | |
| 75 | + | // No hour-long price: what a five-minute write costs. | |
| 76 | + | assert.equal(approved.value.prices.cacheWrite1hMicros, 100_000); | |
| 77 | + | assert.equal(approved.value.prices.threshold, 0); | |
| 78 | + | assert.equal(parseApproval(form({ name: "X", input: "1", output: "", reason: "r" }), "chat").ok, false); | |
| 79 | + | assert.equal(parseApproval(form({ name: "X", input: "0.012", reason: "r" }), "embeddings").ok, true); | |
| 80 | + | assert.deepEqual(parseApproval(form({ name: "X", input: "1", output: "5", reason: "" }), "chat"), { ok: false, error: "Say why, for whoever looks next." }); | |
| 81 | + | assert.equal(parseApproval(form({ name: "X", input: "1", output: "5", over_input: "2", reason: "r" }), "chat").ok, false); | |
| 82 | + | const long = parseApproval(form({ name: "X", input: "1", output: "5", threshold: "200,000", over_input: "2", over_output: "10", reason: "r" }), "chat"); | |
| 83 | + | assert.ok(long.ok); | |
| 84 | + | assert.equal(long.value.prices.threshold, 200_000); | |
| 85 | + | assert.equal(parseApproval(form({ name: "X", tier_hint: "huge", input: "1", output: "5", reason: "r" }), "chat").ok, false); | |
| 86 | + | assert.equal(parseApproval(form({ name: "", input: "1", output: "5", reason: "r" }), "chat").ok, false); | |
| 87 | + | }); | |
| 88 | + | ||
| 89 | + | test("a model purpose takes only an available, priced Claude", () => { | |
| 90 | + | const choices = choicesFor(catalogue); | |
| 91 | + | assert.deepEqual(choices.map((m) => m.model), ["claude-haiku-5-5", "claude-sonnet-5-5"]); | |
| 92 | + | const set = parseDefault(form({ purpose: "tier_small", model: "claude-sonnet-5-5", reason: "testing" }), choices); | |
| 93 | + | assert.deepEqual(set, { ok: true, value: { purpose: "tier_small", model: "claude-sonnet-5-5", tier: null, effort: null, reason: "testing" } }); | |
| 94 | + | assert.equal(parseDefault(form({ purpose: "tier_small", model: "claude-haiku-6", reason: "x" }), choices).ok, false); | |
| 95 | + | assert.equal(parseDefault(form({ purpose: "background", model: "@cf/openai/gpt-oss-120b", reason: "x" }), choices).ok, false); | |
| 96 | + | assert.equal(parseDefault(form({ purpose: "tier_small", model: "claude-haiku-5-5", reason: "" }), choices).ok, false); | |
| 97 | + | assert.equal(parseDefault(form({ purpose: "nonsense", model: "claude-haiku-5-5", reason: "x" }), choices).ok, false); | |
| 98 | + | }); | |
| 99 | + | ||
| 100 | + | test("a job takes a tier and an effort, or the harness's own", () => { | |
| 101 | + | const choices = choicesFor(catalogue); | |
| 102 | + | assert.deepEqual(parseDefault(form({ purpose: "job_plan", tier: "small", effort: "xhigh", reason: "x" }), choices), { | |
| 103 | + | ok: true, | |
| 104 | + | value: { purpose: "job_plan", model: null, tier: "small", effort: "xhigh", reason: "x" }, | |
| 105 | + | }); | |
| 106 | + | const own = parseDefault(form({ purpose: "job_update", tier: "small", effort: "", reason: "x" }), choices); | |
| 107 | + | assert.ok(own.ok); | |
| 108 | + | assert.equal(own.value.effort, null); | |
| 109 | + | // Only a review is sized by the change. | |
| 110 | + | assert.equal(parseDefault(form({ purpose: "job_review", tier: "change", reason: "x" }), choices).ok, true); | |
| 111 | + | assert.equal(parseDefault(form({ purpose: "job_plan", tier: "change", reason: "x" }), choices).ok, false); | |
| 112 | + | assert.equal(parseDefault(form({ purpose: "job_plan", tier: "small", effort: "huge", reason: "x" }), choices).ok, false); | |
| 113 | + | }); | |
| 114 | + | ||
| 115 | + | test("a change shows what a typical run would cost before it is saved", () => { | |
| 116 | + | const before = { purpose: "tier_small", chosen: "claude-haiku-5-5", model: catalogue[0], capabilities: [], note: null }; | |
| 117 | + | assert.equal( | |
| 118 | + | impact(before, catalogue[1], catalogue), | |
| 119 | + | "A typical run: about $1.34 on Claude Sonnet 5.5, 17.6 times $0.076 on Claude Haiku 5.5.", | |
| 120 | + | ); | |
| 121 | + | assert.equal(impact({ ...before, model: catalogue[1] }, catalogue[0], catalogue), "A typical run: about $0.076 on Claude Haiku 5.5, 94% less than $1.34 on Claude Sonnet 5.5."); | |
| 122 | + | assert.equal(impact(before, catalogue[0], catalogue), "No change: Claude Haiku 5.5 already runs it, at about $0.076 a typical run."); | |
| 123 | + | assert.equal(impact(undefined, catalogue[1], catalogue), "A typical run would cost about $1.34 on Claude Sonnet 5.5."); | |
| 124 | + | assert.equal(impact(before, undefined, catalogue), null); | |
| 125 | + | }); | |
| 126 | + | ||
| 127 | + | test("defaults and sizes read plainly", () => { | |
| 128 | + | assert.equal(describeDefault({ model: "claude-haiku-5-5", tier: null, effort: null }, catalogue), "Claude Haiku 5.5"); | |
| 129 | + | assert.equal(describeDefault({ model: null, tier: "small", effort: "high" }, catalogue), "Fast, high effort"); | |
| 130 | + | assert.equal(describeDefault({ model: null, tier: "change", effort: null }, catalogue), "By the change's size, the harness's own effort"); | |
| 131 | + | assert.equal(tokens(1_000_000), "1M"); | |
| 132 | + | assert.equal(tokens(200_000), "200k"); | |
| 133 | + | assert.equal(tokens(0), "—"); | |
| 134 | + | }); |
| 1 | + | /** | |
| 2 | + | * Agents & models, as the page reads its forms and states its figures: | |
| 3 | + | * prices per million tokens, each purpose's default, and what a change | |
| 4 | + | * does to the cost of a typical run. Billing checks every value again. No | |
| 5 | + | * Workers imports, so it can be tested under Node. | |
| 6 | + | */ | |
| 7 | + | import type { CatalogueModel, ModelDefault, ModelPrices, ResolvedModel } from "@g1t/contracts"; | |
| 8 | + | ||
| 9 | + | import type { Parsed } from "./forms.ts"; | |
| 10 | + | ||
| 11 | + | /** The longest reason billing keeps. */ | |
| 12 | + | export const MAX_REASON = 500; | |
| 13 | + | ||
| 14 | + | /** A model purpose: what it is called and what it is for. */ | |
| 15 | + | export type PurposeInfo = { purpose: string; label: string; about: string }; | |
| 16 | + | ||
| 17 | + | /** The model purposes, in the order the page shows them. */ | |
| 18 | + | export const MODEL_PURPOSES: PurposeInfo[] = [ | |
| 19 | + | { purpose: "tier_small", label: "Fast", about: "Auto's fast tier: catching up, answers, plans, small reviews." }, | |
| 20 | + | { purpose: "tier_large", label: "Standard", about: "Auto's standard tier: making and revising changes, most reviews." }, | |
| 21 | + | { purpose: "tier_frontier", label: "Most capable", about: "Auto's top tier: large reviews, architecture, work that failed twice." }, | |
| 22 | + | { purpose: "background", label: "Background", about: "The harness's own small tasks in every run, such as titles and summaries." }, | |
| 23 | + | { | |
| 24 | + | purpose: "gateway_first", | |
| 25 | + | label: "AI Gateway's first Claude", | |
| 26 | + | about: "Listed first by GET /openai/v1/models, the one people start with.", | |
| 27 | + | }, | |
| 28 | + | ]; | |
| 29 | + | ||
| 30 | + | /** Each kind of agent job, as the page names it. */ | |
| 31 | + | export const JOBS: { kind: string; label: string }[] = [ | |
| 32 | + | { kind: "implement", label: "Making a change" }, | |
| 33 | + | { kind: "revise", label: "Revising a change" }, | |
| 34 | + | { kind: "answer", label: "Answering a question" }, | |
| 35 | + | { kind: "review", label: "Reviewing" }, | |
| 36 | + | { kind: "update", label: "Catching up" }, | |
| 37 | + | { kind: "plan", label: "Planning" }, | |
| 38 | + | ]; | |
| 39 | + | ||
| 40 | + | export const TIER_LABELS: Record<string, string> = { | |
| 41 | + | small: "Fast", | |
| 42 | + | large: "Standard", | |
| 43 | + | frontier: "Most capable", | |
| 44 | + | change: "By the change's size", | |
| 45 | + | }; | |
| 46 | + | ||
| 47 | + | export const EFFORTS = ["low", "medium", "high", "xhigh", "max"] as const; | |
| 48 | + | ||
| 49 | + | /** What a purpose is called anywhere on the page or in a notice. */ | |
| 50 | + | export function purposeLabel(purpose: string): string { | |
| 51 | + | const model = MODEL_PURPOSES.find((p) => p.purpose === purpose); | |
| 52 | + | if (model) return model.label; | |
| 53 | + | const job = JOBS.find((j) => `job_${j.kind}` === purpose); | |
| 54 | + | return job ? job.label : purpose; | |
| 55 | + | } | |
| 56 | + | ||
| 57 | + | /** The catalogue models a purpose may be set to: available, priced Claude chat models. */ | |
| 58 | + | export function choicesFor(catalogue: CatalogueModel[]): CatalogueModel[] { | |
| 59 | + | return catalogue.filter((m) => m.status === "available" && m.priced && m.provider === "anthropic" && (m.kind ?? "chat") === "chat"); | |
| 60 | + | } | |
| 61 | + | ||
| 62 | + | /** A price per million tokens, in dollars, to show or to fill a field: `0.125`, `2`, `12.50`. */ | |
| 63 | + | export function perMillion(micros: number | null | undefined): string { | |
| 64 | + | if (micros == null) return ""; | |
| 65 | + | const text = (Math.max(0, micros) / 1_000_000).toFixed(6).replace(/0+$/, "").replace(/\.$/, ""); | |
| 66 | + | const [whole, cents] = text.split("."); | |
| 67 | + | return cents && cents.length === 1 ? `${whole}.${cents}0` : text; | |
| 68 | + | } | |
| 69 | + | ||
| 70 | + | /** | |
| 71 | + | * Micros from a price per million tokens as typed: `0.125`, `$2`, `12.50`. | |
| 72 | + | * Up to six decimal places (a millionth of a dollar per million tokens); | |
| 73 | + | * an empty field is 0. | |
| 74 | + | */ | |
| 75 | + | export function parsePerMillion(input: string): number | null { | |
| 76 | + | const text = input.trim().replace(/^\$/, "").replace(/,/g, ""); | |
| 77 | + | if (text === "") return 0; | |
| 78 | + | const match = /^(\d{1,4})(?:\.(\d{1,6}))?$/.exec(text); | |
| 79 | + | if (!match) return null; | |
| 80 | + | return Number(match[1]) * 1_000_000 + Number((match[2] ?? "").padEnd(6, "0")); | |
| 81 | + | } | |
| 82 | + | ||
| 83 | + | /** The price fields of the approval form, by the name each is posted as. */ | |
| 84 | + | export const PRICE_FIELDS: { name: string; key: keyof ModelPrices; label: string }[] = [ | |
| 85 | + | { name: "input", key: "inputMicros", label: "Input" }, | |
| 86 | + | { name: "output", key: "outputMicros", label: "Output" }, | |
| 87 | + | { name: "cache_read", key: "cacheReadMicros", label: "Cache read" }, | |
| 88 | + | { name: "cache_write", key: "cacheWriteMicros", label: "Cache write, 5 min" }, | |
| 89 | + | { name: "cache_write_1h", key: "cacheWrite1hMicros", label: "Cache write, 1 h" }, | |
| 90 | + | ]; | |
| 91 | + | ||
| 92 | + | export const OVER_FIELDS: { name: string; key: keyof ModelPrices; label: string }[] = [ | |
| 93 | + | { name: "over_input", key: "overInputMicros", label: "Input" }, | |
| 94 | + | { name: "over_output", key: "overOutputMicros", label: "Output" }, | |
| 95 | + | { name: "over_cache_read", key: "overCacheReadMicros", label: "Cache read" }, | |
| 96 | + | { name: "over_cache_write", key: "overCacheWriteMicros", label: "Cache write, 5 min" }, | |
| 97 | + | { name: "over_cache_write_1h", key: "overCacheWrite1hMicros", label: "Cache write, 1 h" }, | |
| 98 | + | ]; | |
| 99 | + | ||
| 100 | + | /** A model's prices as the catalogue has them, for the approval form. */ | |
| 101 | + | export function pricesOf(model: CatalogueModel): ModelPrices { | |
| 102 | + | return { | |
| 103 | + | inputMicros: model.inputMicros, | |
| 104 | + | outputMicros: model.outputMicros, | |
| 105 | + | cacheReadMicros: model.cacheReadMicros, | |
| 106 | + | cacheWriteMicros: model.cacheWriteMicros, | |
| 107 | + | cacheWrite1hMicros: model.cacheWrite1hMicros ?? 0, | |
| 108 | + | threshold: model.threshold ?? 0, | |
| 109 | + | overInputMicros: model.overInputMicros ?? 0, | |
| 110 | + | overOutputMicros: model.overOutputMicros ?? 0, | |
| 111 | + | overCacheReadMicros: model.overCacheReadMicros ?? 0, | |
| 112 | + | overCacheWriteMicros: model.overCacheWriteMicros ?? 0, | |
| 113 | + | overCacheWrite1hMicros: model.overCacheWrite1hMicros ?? 0, | |
| 114 | + | }; | |
| 115 | + | } | |
| 116 | + | ||
| 117 | + | /** A reason as the forms take it: required, kept short. */ | |
| 118 | + | export function parseReason(raw: string): Parsed<string> { | |
| 119 | + | const reason = raw.trim(); | |
| 120 | + | if (!reason) return { ok: false, error: "Say why, for whoever looks next." }; | |
| 121 | + | if ([...reason].length > MAX_REASON) return { ok: false, error: `Keep the reason to ${MAX_REASON} characters.` }; | |
| 122 | + | return { ok: true, value: reason }; | |
| 123 | + | } | |
| 124 | + | ||
| 125 | + | export type Approval = { name: string; tierHint: string; prices: ModelPrices; reason: string }; | |
| 126 | + | ||
| 127 | + | /** | |
| 128 | + | * The approval form: the name people see, the tier it suits, its prices | |
| 129 | + | * per million tokens (and above a long-prompt threshold, if it has one), | |
| 130 | + | * and why. Input is required; a chat model needs an output price too. | |
| 131 | + | */ | |
| 132 | + | export function parseApproval(form: FormData, kind: string): Parsed<Approval> { | |
| 133 | + | const get = (name: string) => { | |
| 134 | + | const value = form.get(name); | |
| 135 | + | return typeof value === "string" ? value : ""; | |
| 136 | + | }; | |
| 137 | + | const name = get("name").trim(); | |
| 138 | + | if (!name) return { ok: false, error: "Give the name people see, such as Claude Haiku 6." }; | |
| 139 | + | if (name.length > 120) return { ok: false, error: "Keep the name to 120 characters." }; | |
| 140 | + | const tierHint = get("tier_hint").trim(); | |
| 141 | + | if (tierHint && !["small", "large", "frontier"].includes(tierHint)) return { ok: false, error: "Choose a tier it suits, or none." }; | |
| 142 | + | const prices = {} as ModelPrices; | |
| 143 | + | for (const field of [...PRICE_FIELDS, ...OVER_FIELDS]) { | |
| 144 | + | const micros = parsePerMillion(get(field.name)); | |
| 145 | + | if (micros == null) return { ok: false, error: `${field.label}: a price in dollars per million tokens, such as 0.125.` }; | |
| 146 | + | prices[field.key] = micros; | |
| 147 | + | } | |
| 148 | + | const threshold = get("threshold").trim().replace(/,/g, ""); | |
| 149 | + | if (threshold && !/^\d{1,9}$/.test(threshold)) return { ok: false, error: "The long-prompt threshold is a number of tokens, such as 200000." }; | |
| 150 | + | prices.threshold = threshold ? Number(threshold) : 0; | |
| 151 | + | if (prices.inputMicros === 0) return { ok: false, error: "Give the input price per million tokens." }; | |
| 152 | + | if (kind === "chat" && prices.outputMicros === 0) return { ok: false, error: "Give the output price per million tokens." }; | |
| 153 | + | // An hour-long write with no price of its own costs what a five-minute one does. | |
| 154 | + | if (prices.cacheWrite1hMicros === 0) prices.cacheWrite1hMicros = prices.cacheWriteMicros; | |
| 155 | + | const over = prices.overInputMicros + prices.overOutputMicros + prices.overCacheReadMicros + prices.overCacheWriteMicros; | |
| 156 | + | if (prices.threshold === 0 && over > 0) return { ok: false, error: "Long-prompt prices need the prompt length they start above." }; | |
| 157 | + | if (prices.threshold > 0 && prices.overInputMicros === 0) return { ok: false, error: "With a long-prompt threshold, give the prices above it." }; | |
| 158 | + | if (prices.threshold > 0 && prices.overCacheWrite1hMicros === 0) prices.overCacheWrite1hMicros = prices.overCacheWriteMicros; | |
| 159 | + | const reason = parseReason(get("reason")); | |
| 160 | + | if (!reason.ok) return reason; | |
| 161 | + | return { ok: true, value: { name, tierHint, prices, reason: reason.value } }; | |
| 162 | + | } | |
| 163 | + | ||
| 164 | + | /** A default as the forms post it: a model, or a job's tier and effort. */ | |
| 165 | + | export type DefaultChange = { purpose: string; model: string | null; tier: string | null; effort: string | null; reason: string }; | |
| 166 | + | ||
| 167 | + | /** | |
| 168 | + | * One default's form: a model purpose takes one of `choices`; a job takes | |
| 169 | + | * a tier (`change` only for reviews) and an effort, or none for the | |
| 170 | + | * harness's own. | |
| 171 | + | */ | |
| 172 | + | export function parseDefault(form: FormData, choices: CatalogueModel[]): Parsed<DefaultChange> { | |
| 173 | + | const get = (name: string) => { | |
| 174 | + | const value = form.get(name); | |
| 175 | + | return typeof value === "string" ? value.trim() : ""; | |
| 176 | + | }; | |
| 177 | + | const purpose = get("purpose"); | |
| 178 | + | const reason = parseReason(get("reason")); | |
| 179 | + | if (MODEL_PURPOSES.some((p) => p.purpose === purpose)) { | |
| 180 | + | const model = get("model"); | |
| 181 | + | if (!choices.some((m) => m.model === model)) return { ok: false, error: "Choose an available Claude model." }; | |
| 182 | + | if (!reason.ok) return reason; | |
| 183 | + | return { ok: true, value: { purpose, model, tier: null, effort: null, reason: reason.value } }; | |
| 184 | + | } | |
| 185 | + | const job = JOBS.find((j) => `job_${j.kind}` === purpose); | |
| 186 | + | if (!job) return { ok: false, error: "That is not something a default is chosen for." }; | |
| 187 | + | const tier = get("tier"); | |
| 188 | + | const tiers = job.kind === "review" ? ["small", "large", "frontier", "change"] : ["small", "large", "frontier"]; | |
| 189 | + | if (!tiers.includes(tier)) return { ok: false, error: `Choose ${job.kind === "review" ? "a tier, or by the change's size" : "a tier"}.` }; | |
| 190 | + | const effort = get("effort"); | |
| 191 | + | if (effort && !(EFFORTS as readonly string[]).includes(effort)) return { ok: false, error: "Effort is low, medium, high, xhigh or max, or the harness's own." }; | |
| 192 | + | if (!reason.ok) return reason; | |
| 193 | + | return { ok: true, value: { purpose, model: null, tier, effort: effort || null, reason: reason.value } }; | |
| 194 | + | } | |
| 195 | + | ||
| 196 | + | /** How a default reads: a model's name, or `Fast, high effort`. */ | |
| 197 | + | export function describeDefault(value: { model: string | null; tier: string | null; effort: string | null }, catalogue: CatalogueModel[]): string { | |
| 198 | + | if (value.model) return catalogue.find((m) => m.model === value.model)?.name ?? value.model; | |
| 199 | + | const tier = TIER_LABELS[value.tier ?? ""] ?? value.tier ?? "none"; | |
| 200 | + | return value.effort ? `${tier}, ${value.effort} effort` : `${tier}, the harness's own effort`; | |
| 201 | + | } | |
| 202 | + | ||
| 203 | + | /** What a change to a default does to a typical run's cost, in a sentence; null for a job. */ | |
| 204 | + | export function impact(before: ResolvedModel | undefined, after: CatalogueModel | undefined, catalogue: CatalogueModel[]): string | null { | |
| 205 | + | if (!after) return null; | |
| 206 | + | const was = before?.model ? catalogue.find((m) => m.model === before.model!.model) : undefined; | |
| 207 | + | const now = after.typicalRunMicros; | |
| 208 | + | const money = (micros: number) => (micros < 1_000_000 ? `$${(micros / 1_000_000).toFixed(3)}` : `$${(micros / 1_000_000).toFixed(2)}`); | |
| 209 | + | if (!was || was.typicalRunMicros <= 0) return `A typical run would cost about ${money(now)} on ${after.name}.`; | |
| 210 | + | if (was.model === after.model) return `No change: ${after.name} already runs it, at about ${money(now)} a typical run.`; | |
| 211 | + | const ratio = now / was.typicalRunMicros; | |
| 212 | + | const change = Math.round((ratio - 1) * 100); | |
| 213 | + | const direction = | |
| 214 | + | ratio >= 2 | |
| 215 | + | ? `${Number(ratio.toFixed(1))} times` | |
| 216 | + | : change === 0 | |
| 217 | + | ? "the same as" | |
| 218 | + | : change > 0 | |
| 219 | + | ? `${change}% more than` | |
| 220 | + | : `${-change}% less than`; | |
| 221 | + | return `A typical run: about ${money(now)} on ${after.name}, ${direction} ${money(was.typicalRunMicros)} on ${was.name}.`; | |
| 222 | + | } | |
| 223 | + | ||
| 224 | + | /** The current default for each purpose, by purpose. */ | |
| 225 | + | export function defaultsByPurpose(defaults: ModelDefault[]): Map<string, ModelDefault> { | |
| 226 | + | return new Map(defaults.map((d) => [d.purpose, d])); | |
| 227 | + | } | |
| 228 | + | ||
| 229 | + | /** A context window or output limit: `1M`, `200k`, or a dash when not known. */ | |
| 230 | + | export function tokens(n: number): string { | |
| 231 | + | if (!n) return "—"; | |
| 232 | + | if (n >= 1_000_000) return `${Number((n / 1_000_000).toFixed(1))}M`; | |
| 233 | + | if (n >= 1_000) return `${Math.round(n / 1_000)}k`; | |
| 234 | + | return String(n); | |
| 235 | + | } |
| 1 | 1 | import { env } from "cloudflare:workers"; | |
| 2 | 2 | ||
| 3 | 3 | import { | |
| 4 | + | type ModelDiscoveryApi, | |
| 4 | 5 | type StatusAdminApi, | |
| 5 | 6 | accountsAdminClient, | |
| 6 | 7 | billingAdminClient, | |
| 30 | 31 | /** The status page's incidents (apps/status's `StatusAdmin` entrypoint). */ | |
| 31 | 32 | export const statusAdmin = env.STATUS as unknown as StatusAdminApi; | |
| 32 | 33 | ||
| 34 | + | /** "Check for new models": the model proxy's `Discovery` entrypoint (services/models). */ | |
| 35 | + | export const modelDiscovery = env.MODELS as unknown as ModelDiscoveryApi; | |
| 36 | + | ||
| 33 | 37 | /** STAFF_EMAILS, for suggesting staff in the incident roles. */ | |
| 34 | 38 | export const staffEmails = (): string => env.STAFF_EMAILS ?? ""; |
| 19 | 19 | route("velocity", "routes/velocity.tsx"), | |
| 20 | 20 | route("costs", "routes/costs.tsx"), | |
| 21 | 21 | route("costs/bill", "routes/costs-bill.tsx"), | |
| 22 | + | route("agents", "routes/agents.tsx"), | |
| 22 | 23 | route("abuse", "routes/abuse.tsx"), | |
| 23 | 24 | route("invoices", "routes/invoices.tsx"), | |
| 24 | 25 | route("credits", "routes/credits.tsx"), |
| 1 | + | import { Check, RefreshCw } from "lucide-react"; | |
| 2 | + | import { data, redirect } from "react-router"; | |
| 3 | + | ||
| 4 | + | import type { AdminModels, CatalogueModel, DiscoveryResult, ModelStatus } from "@g1t/contracts"; | |
| 5 | + | ||
| 6 | + | import type { Route } from "./+types/agents"; | |
| 7 | + | import { Badge, Button, ButtonLink, EmptyState, Field, Input, Notice, PageHeader, Section, Select, When } from "~/components/ui"; | |
| 8 | + | import { text } from "~/lib/forms"; | |
| 9 | + | import { | |
| 10 | + | EFFORTS, | |
| 11 | + | JOBS, | |
| 12 | + | MAX_REASON, | |
| 13 | + | MODEL_PURPOSES, | |
| 14 | + | OVER_FIELDS, | |
| 15 | + | PRICE_FIELDS, | |
| 16 | + | TIER_LABELS, | |
| 17 | + | type DefaultChange, | |
| 18 | + | choicesFor, | |
| 19 | + | defaultsByPurpose, | |
| 20 | + | describeDefault, | |
| 21 | + | impact, | |
| 22 | + | parseApproval, | |
| 23 | + | parseDefault, | |
| 24 | + | parseReason, | |
| 25 | + | perMillion, | |
| 26 | + | pricesOf, | |
| 27 | + | purposeLabel, | |
| 28 | + | tokens, | |
| 29 | + | } from "~/lib/models"; | |
| 30 | + | import { usd } from "~/lib/money"; | |
| 31 | + | import { admin, modelDiscovery } from "~/lib/services.server"; | |
| 32 | + | import { settle } from "~/lib/settle"; | |
| 33 | + | import { requireStaff } from "~/lib/staff"; | |
| 34 | + | ||
| 35 | + | export const meta: Route.MetaFunction = () => [{ title: "Agents & models · sudo" }, { name: "robots", content: "noindex, nofollow" }]; | |
| 36 | + | ||
| 37 | + | export async function loader({ request, context }: Route.LoaderArgs) { | |
| 38 | + | requireStaff(context); | |
| 39 | + | const url = new URL(request.url); | |
| 40 | + | const models = await settle(admin.models()); | |
| 41 | + | const done = url.searchParams.get("done"); | |
| 42 | + | const subject = url.searchParams.get("subject") ?? ""; | |
| 43 | + | const message: Record<string, string> = { | |
| 44 | + | approved: `${subject} is available now: defaults can use it, and the AI Gateway offers it.`, | |
| 45 | + | retired: `${subject} is retired. Any default that chose it uses the next suitable model until you choose another.`, | |
| 46 | + | restored: `${subject} is back where it stood.`, | |
| 47 | + | default: `The default for ${subject} is saved. Runs pick it up within a minute.`, | |
| 48 | + | }; | |
| 49 | + | return { | |
| 50 | + | models: models.ok ? models.value : null, | |
| 51 | + | error: models.ok ? null : models.error, | |
| 52 | + | done: done && message[done] ? message[done] : null, | |
| 53 | + | }; | |
| 54 | + | } | |
| 55 | + | ||
| 56 | + | type Review = { change: DefaultChange; before: string; after: string; impact: string | null }; | |
| 57 | + | ||
| 58 | + | type ActionData = | |
| 59 | + | | { kind: "error"; error: string; target: string; values?: Record<string, string> } | |
| 60 | + | | { kind: "review"; review: Review } | |
| 61 | + | | { kind: "checked"; results: DiscoveryResult[] | null; error: string | null }; | |
| 62 | + | ||
| 63 | + | const back = (done: string, subject: string, anchor: string) => | |
| 64 | + | redirect(`/agents?done=${done}&subject=${encodeURIComponent(subject)}#${anchor}`); | |
| 65 | + | ||
| 66 | + | /** | |
| 67 | + | * Checking for new models, approving, retiring and restoring them, and | |
| 68 | + | * changing a default. Billing checks each again and records it in the | |
| 69 | + | * audit log with the staff member and why; a default shows what it does | |
| 70 | + | * to a typical run's cost before it is saved. | |
| 71 | + | */ | |
| 72 | + | export async function action({ request, context }: Route.ActionArgs) { | |
| 73 | + | const staff = requireStaff(context); | |
| 74 | + | const form = await request.formData(); | |
| 75 | + | const failed = (error: string, target: string, values?: Record<string, string>) => | |
| 76 | + | data<ActionData>({ kind: "error", error, target, values }, { status: 422 }); | |
| 77 | + | const intent = text(form, "intent"); | |
| 78 | + | ||
| 79 | + | if (intent === "check") { | |
| 80 | + | const results = await settle(modelDiscovery.check(staff.email)); | |
| 81 | + | return { kind: "checked", results: results.ok ? results.value : null, error: results.ok ? null : results.error } satisfies ActionData; | |
| 82 | + | } | |
| 83 | + | ||
| 84 | + | const catalogue = await admin.models().then((m) => m.catalogue); | |
| 85 | + | if (intent === "approve" || intent === "retire" || intent === "restore") { | |
| 86 | + | const id = text(form, "model"); | |
| 87 | + | const model = catalogue.find((m) => m.model === id); | |
| 88 | + | if (!model) return failed(`${id} is not in the catalogue.`, `model-${id}`); | |
| 89 | + | if (intent === "approve") { | |
| 90 | + | const approval = parseApproval(form, model.kind ?? "chat"); | |
| 91 | + | if (!approval.ok) return failed(approval.error, `model-${id}`, Object.fromEntries([...form.entries()].map(([k, v]) => [k, String(v)]))); | |
| 92 | + | const { name, tierHint, prices, reason } = approval.value; | |
| 93 | + | const result = await admin.decideModel(id, "approve", { name, tierHint: tierHint || "", prices }, reason, staff.email); | |
| 94 | + | if (!result.ok) return failed(result.error.message, `model-${id}`); | |
| 95 | + | throw back("approved", result.value.name, "catalogue"); | |
| 96 | + | } | |
| 97 | + | const reason = parseReason(text(form, "reason")); | |
| 98 | + | if (!reason.ok) return failed(reason.error, `model-${id}`); | |
| 99 | + | const result = await admin.decideModel(id, intent, {}, reason.value, staff.email); | |
| 100 | + | if (!result.ok) return failed(result.error.message, `model-${id}`); | |
| 101 | + | throw back(intent === "retire" ? "retired" : "restored", result.value.name, "catalogue"); | |
| 102 | + | } | |
| 103 | + | ||
| 104 | + | if (intent === "default") { | |
| 105 | + | const parsed = parseDefault(form, choicesFor(catalogue)); | |
| 106 | + | const purpose = text(form, "purpose"); | |
| 107 | + | if (!parsed.ok) return failed(parsed.error, `default-${purpose}`, { reason: text(form, "reason") }); | |
| 108 | + | const change = parsed.value; | |
| 109 | + | if (text(form, "confirm") !== "yes") { | |
| 110 | + | const models = await admin.models(); | |
| 111 | + | const current = defaultsByPurpose(models.defaults).get(change.purpose); | |
| 112 | + | const resolved = models.resolved.models.find((m) => m.purpose === change.purpose); | |
| 113 | + | return { | |
| 114 | + | kind: "review", | |
| 115 | + | review: { | |
| 116 | + | change, | |
| 117 | + | before: current ? describeDefault(current, catalogue) : "nothing", | |
| 118 | + | after: describeDefault(change, catalogue), | |
| 119 | + | impact: change.model ? impact(resolved, catalogue.find((m) => m.model === change.model), catalogue) : null, | |
| 120 | + | }, | |
| 121 | + | } satisfies ActionData; | |
| 122 | + | } | |
| 123 | + | const result = await admin.setModelDefault(change.purpose, change, change.reason, staff.email); | |
| 124 | + | if (!result.ok) return failed(result.error.message, `default-${change.purpose}`); | |
| 125 | + | throw back("default", purposeLabel(change.purpose), "defaults"); | |
| 126 | + | } | |
| 127 | + | return data<ActionData>({ kind: "error", error: "Unknown action.", target: "" }, { status: 400 }); | |
| 128 | + | } | |
| 129 | + | ||
| 130 | + | const STATUS: Record<ModelStatus, { label: string; tone: "mint" | "lavender" | "warn" | "danger" }> = { | |
| 131 | + | available: { label: "Available", tone: "mint" }, | |
| 132 | + | new: { label: "New", tone: "lavender" }, | |
| 133 | + | deprecated: { label: "Deprecated", tone: "warn" }, | |
| 134 | + | retired: { label: "Retired", tone: "danger" }, | |
| 135 | + | }; | |
| 136 | + | ||
| 137 | + | const PROVIDER: Record<string, string> = { anthropic: "Anthropic", "workers-ai": "Workers AI" }; | |
| 138 | + | ||
| 139 | + | /** A typical run's cost, with the cents a fast model's run is measured in. */ | |
| 140 | + | function runCost(micros: number): string { | |
| 141 | + | return micros > 0 ? usd(micros) : "—"; | |
| 142 | + | } | |
| 143 | + | ||
| 144 | + | export default function Agents({ loaderData, actionData }: Route.ComponentProps) { | |
| 145 | + | const { models, error, done } = loaderData; | |
| 146 | + | const result = actionData as ActionData | undefined; | |
| 147 | + | const failure = result?.kind === "error" ? result : null; | |
| 148 | + | return ( | |
| 149 | + | <main id="top" className="mx-auto max-w-6xl scroll-mt-20 px-4 py-8 sm:py-10"> | |
| 150 | + | <PageHeader | |
| 151 | + | title="Agents & models" | |
| 152 | + | description="Every model g1t can use, found by listing each provider daily; new ones wait here for their prices to be confirmed. Staff choose which model each tier and job uses. Customers never pick a model: Auto routes their work with these." | |
| 153 | + | actions={ | |
| 154 | + | <form method="post" action="/agents#top"> | |
| 155 | + | <input type="hidden" name="intent" value="check" /> | |
| 156 | + | <Button type="submit" variant="lavender"> | |
| 157 | + | <RefreshCw size={14} /> | |
| 158 | + | Check for new models | |
| 159 | + | </Button> | |
| 160 | + | </form> | |
| 161 | + | } | |
| 162 | + | /> | |
| 163 | + | <div className="mt-6 space-y-3"> | |
| 164 | + | {done && <Notice tone="ok">{done}</Notice>} | |
| 165 | + | {error && <Notice tone="error">Billing did not answer: {error}</Notice>} | |
| 166 | + | {failure && !failure.target && <Notice tone="error">{failure.error}</Notice>} | |
| 167 | + | {result?.kind === "checked" && <Checked results={result.results} error={result.error} />} | |
| 168 | + | </div> | |
| 169 | + | {result?.kind === "review" && <ReviewPanel review={result.review} />} | |
| 170 | + | {models && <Page models={models} failure={failure} />} | |
| 171 | + | </main> | |
| 172 | + | ); | |
| 173 | + | } | |
| 174 | + | ||
| 175 | + | function Checked({ results, error }: { results: DiscoveryResult[] | null; error: string | null }) { | |
| 176 | + | if (!results) return <Notice tone="error">The model proxy did not answer: {error}</Notice>; | |
| 177 | + | return ( | |
| 178 | + | <Notice tone={results.some((r) => r.error) ? "warn" : "ok"}> | |
| 179 | + | <ul className="space-y-1"> | |
| 180 | + | {results.map((r) => ( | |
| 181 | + | <li key={r.provider}> | |
| 182 | + | <span className="font-medium text-fg">{PROVIDER[r.provider] ?? r.provider}</span>:{" "} | |
| 183 | + | {r.error | |
| 184 | + | ? `could not be listed (${r.error}). Nothing changed.` | |
| 185 | + | : `${r.listed} listed; ${r.added.length ? `new: ${r.added.join(", ")}` : "nothing new"}${r.deprecated.length ? `; no longer listed: ${r.deprecated.join(", ")}` : ""}${r.restored.length ? `; listed again: ${r.restored.join(", ")}` : ""}.`} | |
| 186 | + | </li> | |
| 187 | + | ))} | |
| 188 | + | </ul> | |
| 189 | + | </Notice> | |
| 190 | + | ); | |
| 191 | + | } | |
| 192 | + | ||
| 193 | + | function ReviewPanel({ review }: { review: Review }) { | |
| 194 | + | const { change } = review; | |
| 195 | + | return ( | |
| 196 | + | <Section id="review" className="mt-6 border-accent/40" title={`Change ${purposeLabel(change.purpose)}?`} description="Nothing is saved until you confirm."> | |
| 197 | + | <dl className="grid gap-x-6 gap-y-2 text-sm sm:grid-cols-[max-content_minmax(0,1fr)]"> | |
| 198 | + | <dt className="text-muted">Now</dt> | |
| 199 | + | <dd>{review.before}</dd> | |
| 200 | + | <dt className="text-muted">After</dt> | |
| 201 | + | <dd className="font-medium">{review.after}</dd> | |
| 202 | + | {review.impact && ( | |
| 203 | + | <> | |
| 204 | + | <dt className="text-muted">Cost</dt> | |
| 205 | + | <dd>{review.impact}</dd> | |
| 206 | + | </> | |
| 207 | + | )} | |
| 208 | + | <dt className="text-muted">Why</dt> | |
| 209 | + | <dd className="text-fg-soft">{change.reason}</dd> | |
| 210 | + | </dl> | |
| 211 | + | <form method="post" action="/agents#defaults" className="mt-4 flex flex-wrap gap-2"> | |
| 212 | + | <input type="hidden" name="intent" value="default" /> | |
| 213 | + | <input type="hidden" name="confirm" value="yes" /> | |
| 214 | + | <input type="hidden" name="purpose" value={change.purpose} /> | |
| 215 | + | {change.model && <input type="hidden" name="model" value={change.model} />} | |
| 216 | + | {change.tier && <input type="hidden" name="tier" value={change.tier} />} | |
| 217 | + | <input type="hidden" name="effort" value={change.effort ?? ""} /> | |
| 218 | + | <input type="hidden" name="reason" value={change.reason} /> | |
| 219 | + | <Button type="submit" variant="lavender"> | |
| 220 | + | <Check size={14} /> | |
| 221 | + | Save | |
| 222 | + | </Button> | |
| 223 | + | <ButtonLink to="/agents#defaults" variant="quiet"> | |
| 224 | + | Cancel | |
| 225 | + | </ButtonLink> | |
| 226 | + | </form> | |
| 227 | + | </Section> | |
| 228 | + | ); | |
| 229 | + | } | |
| 230 | + | ||
| 231 | + | type Failure = Extract<ActionData, { kind: "error" }> | null; | |
| 232 | + | ||
| 233 | + | function Page({ models, failure }: { models: AdminModels; failure: Failure }) { | |
| 234 | + | const waiting = models.catalogue.filter((m) => m.status === "new"); | |
| 235 | + | const t = models.typical; | |
| 236 | + | return ( | |
| 237 | + | <> | |
| 238 | + | <Defaults models={models} failure={failure} /> | |
| 239 | + | <Section | |
| 240 | + | id="waiting" | |
| 241 | + | className="mt-6" | |
| 242 | + | title="New models" | |
| 243 | + | description="Found by a check and not used for anything yet. Confirm the name and prices (from the provider's price page) to make one available; until then nothing routes to it, offers it or charges for it." | |
| 244 | + | > | |
| 245 | + | {waiting.length === 0 ? ( | |
| 246 | + | <EmptyState title="Nothing waiting">A model a provider starts listing appears here after the next check.</EmptyState> | |
| 247 | + | ) : ( | |
| 248 | + | <ul className="space-y-4"> | |
| 249 | + | {waiting.map((model) => ( | |
| 250 | + | <Waiting key={model.model} model={model} failure={failure?.target === `model-${model.model}` ? failure : null} /> | |
| 251 | + | ))} | |
| 252 | + | </ul> | |
| 253 | + | )} | |
| 254 | + | </Section> | |
| 255 | + | <Section | |
| 256 | + | id="catalogue" | |
| 257 | + | className="mt-6" | |
| 258 | + | title="Catalogue" | |
| 259 | + | description={`Prices per million tokens. A typical run is ${t.requests} requests of ${t.input.toLocaleString("en-US")} input, ${t.output.toLocaleString("en-US")} output, ${t.cacheRead.toLocaleString("en-US")} cache-read and ${t.cacheWrite.toLocaleString("en-US")} cache-write tokens: an estimate for comparing models, never a charge.`} | |
| 260 | + | > | |
| 261 | + | <Catalogue catalogue={models.catalogue} failure={failure} /> | |
| 262 | + | </Section> | |
| 263 | + | <Section id="checks" className="mt-6" title="Checks" description="Each provider is listed daily at 05:29 UTC, and whenever someone checks from here. Listing models is free; nothing calls a model."> | |
| 264 | + | {models.checks.length === 0 ? ( | |
| 265 | + | <EmptyState title="No checks yet">Check for new models above, or wait for the daily check.</EmptyState> | |
| 266 | + | ) : ( | |
| 267 | + | <ul className="divide-y divide-line text-sm"> | |
| 268 | + | {models.checks.map((check) => ( | |
| 269 | + | <li key={check.id} className="flex flex-col gap-1 py-2.5 first:pt-0 last:pb-0 sm:flex-row sm:items-baseline sm:gap-4"> | |
| 270 | + | <span className="w-28 shrink-0 font-medium">{PROVIDER[check.provider] ?? check.provider}</span> | |
| 271 | + | <span className="w-44 shrink-0 text-muted"> | |
| 272 | + | <When at={check.checkedAt} time /> | |
| 273 | + | </span> | |
| 274 | + | <span className="min-w-0 flex-1 text-fg-soft"> | |
| 275 | + | {check.error ? ( | |
| 276 | + | <span className="text-danger">Failed: {check.error}</span> | |
| 277 | + | ) : ( | |
| 278 | + | <> | |
| 279 | + | {check.listed} listed | |
| 280 | + | {check.added.length > 0 && <>; new: {check.added.join(", ")}</>} | |
| 281 | + | {check.deprecated.length > 0 && <>; gone: {check.deprecated.join(", ")}</>} | |
| 282 | + | </> | |
| 283 | + | )} | |
| 284 | + | </span> | |
| 285 | + | <span className="text-xs text-faint">{check.by === "schedule" ? "Daily check" : check.by}</span> | |
| 286 | + | </li> | |
| 287 | + | ))} | |
| 288 | + | </ul> | |
| 289 | + | )} | |
| 290 | + | </Section> | |
| 291 | + | </> | |
| 292 | + | ); | |
| 293 | + | } | |
| 294 | + | ||
| 295 | + | function Defaults({ models, failure }: { models: AdminModels; failure: Failure }) { | |
| 296 | + | const choices = choicesFor(models.catalogue); | |
| 297 | + | const current = defaultsByPurpose(models.defaults); | |
| 298 | + | return ( | |
| 299 | + | <Section | |
| 300 | + | id="defaults" | |
| 301 | + | className="mt-6" | |
| 302 | + | title="Defaults" | |
| 303 | + | description="What Auto runs each tier on, the harness's background model, the AI Gateway's first Claude, and where each kind of job starts and how hard it thinks. Runs read these within a minute; a model that is retired or no longer listed is never used, and the next suitable one runs instead." | |
| 304 | + | > | |
| 305 | + | <ul className="divide-y divide-line"> | |
| 306 | + | {MODEL_PURPOSES.map((info) => { | |
| 307 | + | const row = current.get(info.purpose); | |
| 308 | + | const resolved = models.resolved.models.find((m) => m.purpose === info.purpose); | |
| 309 | + | const running = resolved?.model ? models.catalogue.find((m) => m.model === resolved.model!.model) : undefined; | |
| 310 | + | const error = failure?.target === `default-${info.purpose}` ? failure : null; | |
| 311 | + | return ( | |
| 312 | + | <li key={info.purpose} id={`default-${info.purpose}`} className="scroll-mt-20 py-4 first:pt-0 last:pb-0"> | |
| 313 | + | <div className="flex flex-wrap items-baseline justify-between gap-x-4 gap-y-1"> | |
| 314 | + | <div> | |
| 315 | + | <h3 className="text-sm font-semibold">{info.label}</h3> | |
| 316 | + | <p className="text-xs text-muted">{info.about}</p> | |
| 317 | + | </div> | |
| 318 | + | <div className="text-right text-sm"> | |
| 319 | + | <span className="font-medium">{running?.name ?? resolved?.chosen ?? "Not set"}</span> | |
| 320 | + | {running && running.typicalRunMicros > 0 && <span className="ml-2 text-xs text-muted">{runCost(running.typicalRunMicros)} a typical run</span>} | |
| 321 | + | </div> | |
| 322 | + | </div> | |
| 323 | + | {resolved?.note && ( | |
| 324 | + | <div className="mt-2"> | |
| 325 | + | <Notice tone="warn">{resolved.note}</Notice> | |
| 326 | + | </div> | |
| 327 | + | )} | |
| 328 | + | {row && ( | |
| 329 | + | <p className="mt-1 text-xs text-faint"> | |
| 330 | + | Set by {row.updatedBy} <When at={row.updatedAt} /> | |
| 331 | + | {row.reason ? `: ${row.reason}` : ""} | |
| 332 | + | </p> | |
| 333 | + | )} | |
| 334 | + | <form method="post" action={`/agents#review`} className="mt-3 grid gap-2 sm:grid-cols-[minmax(0,14rem)_minmax(0,1fr)_auto] sm:items-end"> | |
| 335 | + | <input type="hidden" name="intent" value="default" /> | |
| 336 | + | <input type="hidden" name="purpose" value={info.purpose} /> | |
| 337 | + | <Select name="model" defaultValue={row?.model ?? ""} aria-label={`Model for ${info.label}`}> | |
| 338 | + | {choices.map((m) => ( | |
| 339 | + | <option key={m.model} value={m.model}> | |
| 340 | + | {m.name} · {runCost(m.typicalRunMicros)} | |
| 341 | + | </option> | |
| 342 | + | ))} | |
| 343 | + | </Select> | |
| 344 | + | <Input name="reason" required maxLength={MAX_REASON} placeholder="Why" aria-label={`Why change ${info.label}`} defaultValue={error?.values?.reason} /> | |
| 345 | + | <Button type="submit" variant="quiet"> | |
| 346 | + | Review | |
| 347 | + | </Button> | |
| 348 | + | </form> | |
| 349 | + | {error && ( | |
| 350 | + | <div className="mt-2"> | |
| 351 | + | <Notice tone="error">{error.error}</Notice> | |
| 352 | + | </div> | |
| 353 | + | )} | |
| 354 | + | </li> | |
| 355 | + | ); | |
| 356 | + | })} | |
| 357 | + | </ul> | |
| 358 | + | <h3 className="mt-6 border-t border-line pt-4 text-sm font-semibold">Jobs</h3> | |
| 359 | + | <p className="text-xs text-muted"> | |
| 360 | + | Where each kind of job starts. Failures, labels and the repository's own history still move it up or down. Effort applies on models that take | |
| 361 | + | it; on one that does not, the harness's own. | |
| 362 | + | </p> | |
| 363 | + | <ul className="mt-3 divide-y divide-line"> | |
| 364 | + | {JOBS.map((job) => { | |
| 365 | + | const purpose = `job_${job.kind}`; | |
| 366 | + | const row = current.get(purpose); | |
| 367 | + | const error = failure?.target === `default-${purpose}` ? failure : null; | |
| 368 | + | const tiers = job.kind === "review" ? ["small", "large", "frontier", "change"] : ["small", "large", "frontier"]; | |
| 369 | + | return ( | |
| 370 | + | <li key={job.kind} id={`default-${purpose}`} className="scroll-mt-20 py-3 first:pt-0 last:pb-0"> | |
| 371 | + | <form method="post" action="/agents#review" className="grid gap-2 sm:grid-cols-[10rem_minmax(0,11rem)_minmax(0,9rem)_minmax(0,1fr)_auto] sm:items-center"> | |
| 372 | + | <input type="hidden" name="intent" value="default" /> | |
| 373 | + | <input type="hidden" name="purpose" value={purpose} /> | |
| 374 | + | <div> | |
| 375 | + | <span className="text-sm font-medium">{job.label}</span> | |
| 376 | + | {row && ( | |
| 377 | + | <span className="block text-xs text-faint"> | |
| 378 | + | {row.updatedBy} <When at={row.updatedAt} /> | |
| 379 | + | </span> | |
| 380 | + | )} | |
| 381 | + | </div> | |
| 382 | + | <Select name="tier" defaultValue={row?.tier ?? "large"} aria-label={`Starting tier for ${job.label}`}> | |
| 383 | + | {tiers.map((tier) => ( | |
| 384 | + | <option key={tier} value={tier}> | |
| 385 | + | {TIER_LABELS[tier]} | |
| 386 | + | </option> | |
| 387 | + | ))} | |
| 388 | + | </Select> | |
| 389 | + | <Select name="effort" defaultValue={row?.effort ?? ""} aria-label={`Effort for ${job.label}`}> | |
| 390 | + | <option value="">Harness's own</option> | |
| 391 | + | {EFFORTS.map((effort) => ( | |
| 392 | + | <option key={effort} value={effort}> | |
| 393 | + | {effort} effort | |
| 394 | + | </option> | |
| 395 | + | ))} | |
| 396 | + | </Select> | |
| 397 | + | <Input name="reason" required maxLength={MAX_REASON} placeholder="Why" aria-label={`Why change ${job.label}`} /> | |
| 398 | + | <Button type="submit" variant="quiet"> | |
| 399 | + | Review | |
| 400 | + | </Button> | |
| 401 | + | </form> | |
| 402 | + | {error && ( | |
| 403 | + | <div className="mt-2"> | |
| 404 | + | <Notice tone="error">{error.error}</Notice> | |
| 405 | + | </div> | |
| 406 | + | )} | |
| 407 | + | </li> | |
| 408 | + | ); | |
| 409 | + | })} | |
| 410 | + | </ul> | |
| 411 | + | </Section> | |
| 412 | + | ); | |
| 413 | + | } | |
| 414 | + | ||
| 415 | + | function Waiting({ model, failure }: { model: CatalogueModel; failure: Failure }) { | |
| 416 | + | const prices = pricesOf(model); | |
| 417 | + | const value = (name: string, fallback: string) => failure?.values?.[name] ?? fallback; | |
| 418 | + | return ( | |
| 419 | + | <li id={`model-${model.model}`} className="scroll-mt-20 rounded-lg border border-line bg-bg p-4 sm:p-5"> | |
| 420 | + | <div className="flex flex-wrap items-center gap-2"> | |
| 421 | + | <span className="font-medium">{model.name}</span> | |
| 422 | + | <code className="text-xs text-muted">{model.model}</code> | |
| 423 | + | <Badge>{PROVIDER[model.provider] ?? model.provider}</Badge> | |
| 424 | + | {!model.priced && <Badge tone="warn">No price known</Badge>} | |
| 425 | + | </div> | |
| 426 | + | <p className="mt-1 text-xs text-muted"> | |
| 427 | + | Found <When at={model.firstSeenAt} /> · {model.kind === "embeddings" ? "Embeddings" : "Chat"} · context {tokens(model.contextWindow)} | |
| 428 | + | {model.capabilities.length > 0 && <> · {model.capabilities.join(", ")}</>} | |
| 429 | + | {model.priced && " · prices filled from the provider; check them"} | |
| 430 | + | </p> | |
| 431 | + | <form method="post" action={`/agents#model-${model.model}`} className="mt-4 space-y-3"> | |
| 432 | + | <input type="hidden" name="intent" value="approve" /> | |
| 433 | + | <input type="hidden" name="model" value={model.model} /> | |
| 434 | + | <div className="grid gap-3 sm:grid-cols-[minmax(0,1fr)_12rem]"> | |
| 435 | + | <Field label="Name people see"> | |
| 436 | + | <Input name="name" required maxLength={120} defaultValue={value("name", model.name)} /> | |
| 437 | + | </Field> | |
| 438 | + | <Field label="Suits the tier"> | |
| 439 | + | <Select name="tier_hint" defaultValue={value("tier_hint", model.tierHint)}> | |
| 440 | + | <option value="">None</option> | |
| 441 | + | <option value="small">Fast</option> | |
| 442 | + | <option value="large">Standard</option> | |
| 443 | + | <option value="frontier">Most capable</option> | |
| 444 | + | </Select> | |
| 445 | + | </Field> | |
| 446 | + | </div> | |
| 447 | + | <fieldset> | |
| 448 | + | <legend className="mb-1.5 text-sm font-medium text-muted">Dollars per million tokens</legend> | |
| 449 | + | <div className="grid grid-cols-2 gap-3 sm:grid-cols-5"> | |
| 450 | + | {PRICE_FIELDS.map((field) => ( | |
| 451 | + | <Field key={field.name} label={field.label}> | |
| 452 | + | <Input name={field.name} inputMode="decimal" className="font-mono" defaultValue={value(field.name, model.priced ? perMillion(prices[field.key]) : "")} /> | |
| 453 | + | </Field> | |
| 454 | + | ))} | |
| 455 | + | </div> | |
| 456 | + | </fieldset> | |
| 457 | + | <details className="rounded-md border border-line px-3 py-2" open={prices.threshold > 0}> | |
| 458 | + | <summary className="cursor-pointer text-sm text-muted">Priced by prompt length</summary> | |
| 459 | + | <div className="mt-3 space-y-3"> | |
| 460 | + | <Field label="Above this many prompt tokens" hint="The whole request is charged at the prices below once its prompt (input and cache tokens) is longer."> | |
| 461 | + | <Input name="threshold" inputMode="numeric" className="font-mono" defaultValue={value("threshold", prices.threshold ? String(prices.threshold) : "")} /> | |
| 462 | + | </Field> | |
| 463 | + | <div className="grid grid-cols-2 gap-3 sm:grid-cols-5"> | |
| 464 | + | {OVER_FIELDS.map((field) => ( | |
| 465 | + | <Field key={field.name} label={field.label}> | |
| 466 | + | <Input name={field.name} inputMode="decimal" className="font-mono" defaultValue={value(field.name, prices[field.key] ? perMillion(prices[field.key]) : "")} /> | |
| 467 | + | </Field> | |
| 468 | + | ))} | |
| 469 | + | </div> | |
| 470 | + | </div> | |
| 471 | + | </details> | |
| 472 | + | <Field label="Why" hint="Kept with the model, and in the audit log."> | |
| 473 | + | <Input name="reason" required maxLength={MAX_REASON} placeholder="e.g. Prices from the provider's price page, 2026-10-08." defaultValue={value("reason", "")} /> | |
| 474 | + | </Field> | |
| 475 | + | {failure && <Notice tone="error">{failure.error}</Notice>} | |
| 476 | + | <div className="flex justify-end"> | |
| 477 | + | <Button type="submit" variant="lavender"> | |
| 478 | + | <Check size={14} /> | |
| 479 | + | Approve | |
| 480 | + | </Button> | |
| 481 | + | </div> | |
| 482 | + | </form> | |
| 483 | + | <Decide model={model} intent="retire" label="Retire instead" /> | |
| 484 | + | </li> | |
| 485 | + | ); | |
| 486 | + | } | |
| 487 | + | ||
| 488 | + | /** A retire or restore form, folded away until opened. */ | |
| 489 | + | function Decide({ model, intent, label }: { model: CatalogueModel; intent: "retire" | "restore"; label: string }) { | |
| 490 | + | return ( | |
| 491 | + | <details className="mt-3"> | |
| 492 | + | <summary className="cursor-pointer text-xs text-muted hover:text-fg">{label}</summary> | |
| 493 | + | <form method="post" action={`/agents#model-${model.model}`} className="mt-2 flex flex-col gap-2 sm:flex-row sm:items-center"> | |
| 494 | + | <input type="hidden" name="intent" value={intent} /> | |
| 495 | + | <input type="hidden" name="model" value={model.model} /> | |
| 496 | + | <Input name="reason" required maxLength={MAX_REASON} placeholder="Why" aria-label={`Why ${intent} ${model.name}`} /> | |
| 497 | + | <Button type="submit" variant={intent === "retire" ? "danger" : "quiet"}> | |
| 498 | + | {intent === "retire" ? "Retire" : "Restore"} | |
| 499 | + | </Button> | |
| 500 | + | </form> | |
| 501 | + | </details> | |
| 502 | + | ); | |
| 503 | + | } | |
| 504 | + | ||
| 505 | + | function Catalogue({ catalogue, failure }: { catalogue: CatalogueModel[]; failure: Failure }) { | |
| 506 | + | const listed = catalogue.filter((m) => m.status !== "new"); | |
| 507 | + | return ( | |
| 508 | + | <div className="-mx-4 overflow-x-auto sm:-mx-5"> | |
| 509 | + | <table className="w-full min-w-[56rem] text-sm"> | |
| 510 | + | <thead> | |
| 511 | + | <tr className="border-b border-line text-left text-xs text-muted"> | |
| 512 | + | <th className="px-4 py-2 font-medium sm:px-5">Model</th> | |
| 513 | + | <th className="px-4 py-2 font-medium">Status</th> | |
| 514 | + | <th className="px-4 py-2 font-medium">Suits</th> | |
| 515 | + | <th className="px-4 py-2 text-right font-medium">Context</th> | |
| 516 | + | <th className="px-4 py-2 text-right font-medium">In / out</th> | |
| 517 | + | <th className="px-4 py-2 text-right font-medium">Cache read</th> | |
| 518 | + | <th className="px-4 py-2 text-right font-medium">Typical run</th> | |
| 519 | + | <th className="px-4 py-2 font-medium sm:pr-5">Listed</th> | |
| 520 | + | </tr> | |
| 521 | + | </thead> | |
| 522 | + | <tbody> | |
| 523 | + | {listed.map((model) => { | |
| 524 | + | const status = STATUS[model.status] ?? STATUS.available; | |
| 525 | + | const error = failure?.target === `model-${model.model}` ? failure : null; | |
| 526 | + | return ( | |
| 527 | + | <tr key={model.model} id={`model-${model.model}`} className="scroll-mt-20 border-b border-line align-top last:border-0"> | |
| 528 | + | <td className="px-4 py-2.5 sm:px-5"> | |
| 529 | + | <span className="font-medium">{model.name}</span> | |
| 530 | + | <code className="block text-xs text-muted">{model.model}</code> | |
| 531 | + | {model.aliases.length > 0 && <span className="block text-xs text-faint">also {model.aliases.join(", ")}</span>} | |
| 532 | + | <span className="block text-xs text-faint">{PROVIDER[model.provider] ?? model.provider}</span> | |
| 533 | + | {error && <span className="mt-1 block text-xs text-danger">{error.error}</span>} | |
| 534 | + | </td> | |
| 535 | + | <td className="px-4 py-2.5"> | |
| 536 | + | <Badge tone={status.tone}>{status.label}</Badge> | |
| 537 | + | {!model.priced && ( | |
| 538 | + | <span className="mt-1 block"> | |
| 539 | + | <Badge tone="warn">Unpriced</Badge> | |
| 540 | + | </span> | |
| 541 | + | )} | |
| 542 | + | {model.status === "available" ? ( | |
| 543 | + | <Decide model={model} intent="retire" label="Retire" /> | |
| 544 | + | ) : ( | |
| 545 | + | <Decide model={model} intent="restore" label="Restore" /> | |
| 546 | + | )} | |
| 547 | + | </td> | |
| 548 | + | <td className="px-4 py-2.5 text-muted">{model.kind === "embeddings" ? `Embeddings${model.dimensions ? `, ${model.dimensions}` : ""}` : TIER_LABELS[model.tierHint] ?? "—"}</td> | |
| 549 | + | <td className="px-4 py-2.5 text-right font-mono tabular-nums">{tokens(model.contextWindow)}</td> | |
| 550 | + | <td className="px-4 py-2.5 text-right font-mono tabular-nums whitespace-nowrap"> | |
| 551 | + | ${perMillion(model.inputMicros)} | |
| 552 | + | {model.kind !== "embeddings" && <> / ${perMillion(model.outputMicros)}</>} | |
| 553 | + | {model.threshold ? <span className="block text-xs text-faint">over {tokens(model.threshold)}: ${perMillion(model.overInputMicros)} / ${perMillion(model.overOutputMicros)}</span> : null} | |
| 554 | + | </td> | |
| 555 | + | <td className="px-4 py-2.5 text-right font-mono tabular-nums">{model.kind === "embeddings" ? "—" : `$${perMillion(model.cacheReadMicros)}`}</td> | |
| 556 | + | <td className="px-4 py-2.5 text-right font-mono tabular-nums">{runCost(model.typicalRunMicros)}</td> | |
| 557 | + | <td className="px-4 py-2.5 text-xs text-muted sm:pr-5"> | |
| 558 | + | {model.missingSince ? ( | |
| 559 | + | <span className="text-warn"> | |
| 560 | + | Not since <When at={model.missingSince} /> | |
| 561 | + | </span> | |
| 562 | + | ) : model.lastSeenAt ? ( | |
| 563 | + | <When at={model.lastSeenAt} /> | |
| 564 | + | ) : ( | |
| 565 | + | "Not checked yet" | |
| 566 | + | )} | |
| 567 | + | </td> | |
| 568 | + | </tr> | |
| 569 | + | ); | |
| 570 | + | })} | |
| 571 | + | </tbody> | |
| 572 | + | </table> | |
| 573 | + | </div> | |
| 574 | + | ); | |
| 575 | + | } |
| 8 | 8 | EVENTS: ServiceBinding; | |
| 9 | 9 | /** apps/status's `StatusAdmin` entrypoint: see `StatusAdminApi`. */ | |
| 10 | 10 | STATUS: Fetcher; | |
| 11 | + | /** services/models's `Discovery` entrypoint: see `ModelDiscoveryApi`. */ | |
| 12 | + | MODELS: Fetcher; | |
| 11 | 13 | ASSETS: Fetcher; | |
| 12 | 14 | ACCESS_TEAM_DOMAIN: string; | |
| 13 | 15 | ACCESS_AUD: string; |
| 23 | 23 | { "binding": "EVENTS", "service": "g1t-events" }, | |
| 24 | 24 | // The status page's staff-only entrypoint: posting incidents to | |
| 25 | 25 | // status.g1t.sh (apps/status). Only bindings reach it. | |
| 26 | − | { "binding": "STATUS", "service": "g1t-status", "entrypoint": "StatusAdmin" } | |
| 26 | + | { "binding": "STATUS", "service": "g1t-status", "entrypoint": "StatusAdmin" }, | |
| 27 | + | // "Check for new models" on Agents & models: the model proxy's | |
| 28 | + | // `Discovery` entrypoint lists each provider's models now and records | |
| 29 | + | // them with billing's catalogue. Only bindings reach it. | |
| 30 | + | { "binding": "MODELS", "service": "g1t-models", "entrypoint": "Discovery" } | |
| 27 | 31 | ], | |
| 28 | 32 | "vars": { | |
| 29 | 33 | // The Zero Trust team domain, such as `g1t.cloudflareaccess.com`. |
| 3 | 3 | ||
| 4 | 4 | import { | |
| 5 | 5 | type Merged, | |
| 6 | + | placePushes, | |
| 6 | 7 | change, | |
| 7 | 8 | checksFact, | |
| 8 | 9 | confidenceAsk, | |
| 370 | 371 | assert.match(why, /self-hosted runner/); | |
| 371 | 372 | assert.doesNotMatch(why, /agent/); | |
| 372 | 373 | }); | |
| 374 | + | ||
| 375 | + | test("each push to the default branch places the commits it brought, at its time, from one read of the history", () => { | |
| 376 | + | const c = (hash: string, parents = ["p"], author = "Chase Pierce") => ({ hash, parents, author: { name: author } }); | |
| 377 | + | // Newest first: a Wednesday push of two, a Monday push of one, a merge, and g1t's own commit. | |
| 378 | + | const history = [c("w2"), c("w1"), c("m1"), c("merge", ["a", "b"]), c("bot", ["p"], "g1t"), c("old")]; | |
| 379 | + | const pushes = [ | |
| 380 | + | { time: "2026-10-07T12:00:00Z", data: { after: "w2", before: "m1" } }, | |
| 381 | + | { time: "2026-10-05T12:00:00Z", data: { after: "m1", before: "old" } }, | |
| 382 | + | { time: "2026-10-01T12:00:00Z", data: { after: "gone", before: "older" } }, | |
| 383 | + | ]; | |
| 384 | + | assert.deepEqual(placePushes(history, pushes), [ | |
| 385 | + | { hash: "m1", at: "2026-10-05T12:00:00Z" }, | |
| 386 | + | { hash: "w2", at: "2026-10-07T12:00:00Z" }, | |
| 387 | + | { hash: "w1", at: "2026-10-07T12:00:00Z" }, | |
| 388 | + | ]); | |
| 389 | + | }); |
| 448 | 448 | return brought.filter((commit) => commit.parents.length <= 1 && !isAgent(commit.author.name)); | |
| 449 | 449 | } | |
| 450 | 450 | ||
| 451 | + | /** | |
| 452 | + | * Each commit of `history` (newest first) a push in `pushes` (newest | |
| 453 | + | * first) brought, at that push's time. A commit pushed twice (after a | |
| 454 | + | * force push, say) counts once, at its first landing; a push whose `after` | |
| 455 | + | * is no longer in the history (rewritten) brings nothing. | |
| 456 | + | */ | |
| 457 | + | export function placePushes<C extends { hash: string; parents: string[]; author: { name: string } }>( | |
| 458 | + | history: C[], | |
| 459 | + | pushes: { time: string; data: { after: string; before?: string } }[], | |
| 460 | + | ): { hash: string; at: string }[] { | |
| 461 | + | const index = new Map(history.map((commit, i) => [commit.hash, i])); | |
| 462 | + | const seen = new Map<string, string>(); | |
| 463 | + | for (const push of [...pushes].reverse()) { | |
| 464 | + | const start = index.get(push.data.after); | |
| 465 | + | if (start === undefined) continue; | |
| 466 | + | const end = push.data.before ? index.get(push.data.before) : undefined; | |
| 467 | + | for (const commit of pushedCommits(history.slice(start, end ?? history.length), undefined)) { | |
| 468 | + | if (!seen.has(commit.hash)) seen.set(commit.hash, push.time); | |
| 469 | + | } | |
| 470 | + | } | |
| 471 | + | return [...seen].map(([hash, at]) => ({ hash, at })); | |
| 472 | + | } | |
| 473 | + | ||
| 451 | 474 | export type Merged = { | |
| 452 | 475 | repo: RepoPath; | |
| 453 | 476 | number: number; |
| 39 | 39 | import { | |
| 40 | 40 | type Fact, | |
| 41 | 41 | type Merged, | |
| 42 | − | pushedCommits, | |
| 42 | + | placePushes, | |
| 43 | 43 | type NeedRow, | |
| 44 | 44 | type QuickAction, | |
| 45 | 45 | RUN_LABEL, | |
| 152 | 152 | return data<DelegateResult>({ error: null, notStarted: refused }); | |
| 153 | 153 | } | |
| 154 | 154 | ||
| 155 | − | /** Pushes to the default branch read for the week, per project, and commits read back from each. */ | |
| 156 | − | const PUSHES_READ = 40; | |
| 157 | − | const PUSH_DEPTH = 60; | |
| 155 | + | /** | |
| 156 | + | * Push events read per project, a page at a time, back two weeks. Pushes | |
| 157 | + | * to every branch are in the log, so a fixed count (it was 40) lost a busy | |
| 158 | + | * week's earlier days to agents' branches. | |
| 159 | + | */ | |
| 160 | + | const PUSH_PAGE = 200; | |
| 161 | + | const PUSH_PAGES = 5; | |
| 162 | + | /** Commits of the default branch read once, to place each push's commits. */ | |
| 163 | + | const HISTORY_READ = 1000; | |
| 158 | 164 | ||
| 159 | 165 | /** | |
| 160 | 166 | * The commits people pushed straight to a project's default branch in the | |
| 163 | 169 | */ | |
| 164 | 170 | async function directCommits(repo: Repo, viewer: Viewer): Promise<{ hash: string; at: string }[]> { | |
| 165 | 171 | const since = Date.now() - 14 * TIME.DAY; | |
| 166 | − | const pushes = (await eventLog.list({ repoId: repo.id, types: ["git.push"], limit: PUSHES_READ })).filter( | |
| 167 | − | (event): event is G1tEvent<"git.push"> => event.type === "git.push" && event.data.defaultBranch && Date.parse(event.time) >= since, | |
| 168 | − | ); | |
| 172 | + | const pushes: G1tEvent<"git.push">[] = []; | |
| 173 | + | let before: string | undefined; | |
| 174 | + | for (let page = 0; page < PUSH_PAGES; page++) { | |
| 175 | + | const batch = await eventLog.list({ repoId: repo.id, types: ["git.push"], limit: PUSH_PAGE, ...(before ? { before } : {}) }); | |
| 176 | + | for (const event of batch) { | |
| 177 | + | if (event.type === "git.push" && event.data.defaultBranch && Date.parse(event.time) >= since) pushes.push(event as G1tEvent<"git.push">); | |
| 178 | + | } | |
| 179 | + | const oldest = batch.at(-1); | |
| 180 | + | if (batch.length < PUSH_PAGE || !oldest || Date.parse(oldest.time) < since) break; | |
| 181 | + | before = oldest.id; | |
| 182 | + | } | |
| 183 | + | if (pushes.length === 0) return []; | |
| 184 | + | // One read of the branch from the newest push back; each push brought | |
| 185 | + | // what lies between its `after` and its `before` in that history. | |
| 169 | 186 | const path = { namespace: repo.namespace, name: repo.name }; | |
| 170 | − | const read = await Promise.all( | |
| 171 | − | pushes.map(async (push) => { | |
| 172 | − | const history = await reposApi.log(path, viewer, push.data.after, PUSH_DEPTH).catch(() => null); | |
| 173 | − | return history?.ok ? pushedCommits(history.value, push.data.before).map((commit) => ({ hash: commit.hash, at: push.time })) : []; | |
| 174 | − | }), | |
| 175 | − | ); | |
| 176 | − | // A commit pushed twice (after a force push, say) counts once, at its first landing. | |
| 177 | − | const seen = new Map<string, string>(); | |
| 178 | − | for (const commit of read.flat().reverse()) if (!seen.has(commit.hash)) seen.set(commit.hash, commit.at); | |
| 179 | − | return [...seen].map(([hash, at]) => ({ hash, at })); | |
| 187 | + | const history = await reposApi.log(path, viewer, pushes[0].data.after, HISTORY_READ).catch(() => null); | |
| 188 | + | if (!history?.ok) return []; | |
| 189 | + | return placePushes(history.value, pushes); | |
| 180 | 190 | } | |
| 181 | 191 | ||
| 192 | + | ||
| 182 | 193 | export async function loader({ context, request }: Route.LoaderArgs) { | |
| 183 | 194 | const viewer = getViewer(context); | |
| 184 | 195 | if (!viewer) { |
| 237 | 237 | failing holds it for a person. There is no model or | |
| 238 | 238 | agent count to choose: to put more agents to work, assign more issues. | |
| 239 | 239 | On g1t's hosted models, Auto routes each job to the cheapest of three | |
| 240 | − | tiers that can do it, fast, standard and most capable: catching up, | |
| 241 | − | answering and reviews of small changes that touch no sensitive path | |
| 242 | − | start fast; making changes, revising, planning and other reviews start | |
| 243 | − | standard; reviews of very large changes and issues labelled | |
| 240 | + | tiers that can do it, fast, standard and most capable (the model behind | |
| 241 | + | each is today's, and moves to newer models as g1t adopts them; a | |
| 242 | + | retired model is never used): catching up, answering, planning and | |
| 243 | + | reviews of small changes that touch no sensitive path start fast; | |
| 244 | + | making changes, revising and other reviews start standard; reviews of | |
| 245 | + | very large changes and issues labelled | |
| 244 | 246 | `architecture` start most capable. A failed attempt moves the next one | |
| 245 | 247 | up a tier (two in a row: most capable), and a repository's own recent | |
| 246 | 248 | runs move work down or up. Each run states its model and why in one |
| 408 | 408 | } | |
| 409 | 409 | } | |
| 410 | 410 | if spec.contains_key("services") { | |
| 411 | − | note(Severity::Unsupported, Some(id), "`services` containers (such as a database) are not started on g1t yet.".to_owned()); | |
| 411 | + | note( | |
| 412 | + | Severity::Info, | |
| 413 | + | Some(id), | |
| 414 | + | "`services`: each service runs in Docker beside the steps and is reached at `localhost:<port>`. On g1t's machines it is the job's own Docker Engine, the service is also reached by its name, and two services cannot listen on the same port.".to_owned(), | |
| 415 | + | ); | |
| 412 | 416 | } | |
| 413 | 417 | if spec.contains_key("container") { | |
| 414 | − | note(Severity::Warning, Some(id), "`container`: steps run on g1t's runner image instead of that container.".to_owned()); | |
| 418 | + | note( | |
| 419 | + | Severity::Info, | |
| 420 | + | Some(id), | |
| 421 | + | "`container`: the steps run inside that image, in Docker (on g1t's machines, the job's own Engine), with the workspace at the same path as on the runner (`/home/runner/work`), not `/__w`.".to_owned(), | |
| 422 | + | ); | |
| 415 | 423 | } | |
| 416 | 424 | if spec.contains_key("environment") { | |
| 417 | 425 | note(Severity::Info, Some(id), "`environment`: the job gets the values its secrets and variables give this environment; protection rules (approvals, wait timers, branch limits) are not enforced on g1t yet. Unless it says `deployment: false`, the run records a deployment to it.".to_owned()); | |
| 472 | 480 | /// `caches`: the step sets a `cache` input. | |
| 473 | 481 | fn action_note(uses: &str, caches: bool) -> Option<(Severity, String)> { | |
| 474 | 482 | if uses.starts_with("docker://") { | |
| 475 | − | return Some((Severity::Unsupported, format!("`{uses}`: Docker actions do not run on g1t yet."))); | |
| 483 | + | return Some((Severity::Info, format!("`{uses}` runs in Docker (on g1t's machines, the job's own Engine)."))); | |
| 476 | 484 | } | |
| 477 | 485 | let name = uses.split('@').next().unwrap_or(uses).to_ascii_lowercase(); | |
| 478 | 486 | match name.as_str() { | |
| 586 | 594 | workflow.notes.iter().filter(|n| n.severity == Severity::Unsupported).map(|n| n.message.as_str()).collect(); | |
| 587 | 595 | assert!(unsupported.iter().any(|m| m.contains("`release`"))); | |
| 588 | 596 | assert!(unsupported.iter().any(|m| m.contains("windows-latest"))); | |
| 589 | − | assert!(unsupported.iter().any(|m| m.contains("services"))); | |
| 590 | − | assert!(unsupported.iter().any(|m| m.contains("docker://alpine"))); | |
| 597 | + | assert!(!unsupported.iter().any(|m| m.contains("services"))); | |
| 598 | + | assert!(!unsupported.iter().any(|m| m.contains("docker://alpine"))); | |
| 599 | + | assert!(workflow.notes.iter().any(|n| n.severity == Severity::Info && n.message.contains("own Docker Engine") && n.message.contains("localhost"))); | |
| 600 | + | assert!(workflow.notes.iter().any(|n| n.severity == Severity::Info && n.message.starts_with("`docker://alpine`"))); | |
| 591 | 601 | assert!(unsupported.iter().any(|m| m.contains("pwsh"))); | |
| 592 | 602 | assert!(workflow.notes.iter().any(|n| n.severity == Severity::Info && n.message.contains("actions/cache"))); | |
| 593 | 603 | assert!(workflow.notes.iter().any(|n| n.severity == Severity::Warning && n.message.contains("actions/setup-node"))); |
| 174 | 174 | let deploy = read("deploy.yml"); | |
| 175 | 175 | for stage in ["core", "edge", "front"] { | |
| 176 | 176 | let job = deploy.jobs.iter().find(|j| j.id == stage).unwrap(); | |
| 177 | − | let on = |rust: bool| { | |
| 177 | + | let on = |rust: bool, image: bool| { | |
| 178 | 178 | let mut contexts = Map::new(); | |
| 179 | − | contexts.insert("matrix".into(), json!({ "group": "g", "units": "u", "rust": rust })); | |
| 179 | + | contexts.insert("matrix".into(), json!({ "group": "g", "units": "u", "rust": rust, "image": image })); | |
| 180 | 180 | let scope = Scope { contexts: &contexts, status: Status::Success, hash_files: None }; | |
| 181 | 181 | expr::interpolate_value(&job.runs_on, &scope).unwrap() | |
| 182 | 182 | }; | |
| 183 | − | assert_eq!(on(true), json!("g1t-4core"), "{stage}"); | |
| 184 | − | assert_eq!(on(false), json!("ubuntu-latest"), "{stage}"); | |
| 183 | + | assert_eq!(on(true, false), json!("g1t-4core"), "{stage}"); | |
| 184 | + | // The runner's image is built with the job's own Docker Engine. | |
| 185 | + | assert_eq!(on(false, true), json!("g1t-4core"), "{stage}"); | |
| 186 | + | assert_eq!(on(false, false), json!("ubuntu-latest"), "{stage}"); | |
| 185 | 187 | } | |
| 186 | 188 | let source = std::fs::read_to_string(workflows_dir().join("deploy.yml")).unwrap(); | |
| 187 | 189 | assert!(source.contains("target/wasm32-unknown-unknown/release")); | |
| 188 | 190 | assert!(source.contains("!target/**/incremental")); | |
| 191 | + | assert!(source.contains("target/x86_64-unknown-linux-musl/release")); | |
| 189 | 192 | } | |
| 190 | 193 | ||
| 191 | 194 | #[test] |
| 539 | 539 | "anthropic".to_owned() | |
| 540 | 540 | } | |
| 541 | 541 | ||
| 542 | + | // --- The model catalogue ---------------------------------------------------- | |
| 543 | + | // | |
| 544 | + | // Every model g1t can use, in one table (billing's `gateway_models`): the | |
| 545 | + | // models agents run on, the AI Gateway's, and the embeddings model. New | |
| 546 | + | // models are found by the models service listing each provider daily | |
| 547 | + | // (`record_discovery`) and wait as `new` until staff approve them in sudo. | |
| 548 | + | // Which model each purpose uses by default is staff's choice | |
| 549 | + | // (`model_defaults`), read by the runner and the AI Gateway. | |
| 550 | + | ||
| 551 | + | /// Where a model stands. Only `available` models are routed to; the AI | |
| 552 | + | /// Gateway offers `available` and `deprecated` ones that have a price. | |
| 553 | + | pub mod model_status { | |
| 554 | + | /// Approved and priced: routed to and offered. | |
| 555 | + | pub const AVAILABLE: &str = "available"; | |
| 556 | + | /// Found by discovery and not approved yet: never routed to, offered or charged. | |
| 557 | + | pub const NEW: &str = "new"; | |
| 558 | + | /// The provider stopped listing it: still offered to anyone who names | |
| 559 | + | /// it, but no default routes to it. | |
| 560 | + | pub const DEPRECATED: &str = "deprecated"; | |
| 561 | + | /// Staff retired it: neither routed to nor offered. | |
| 562 | + | pub const RETIRED: &str = "retired"; | |
| 563 | + | } | |
| 564 | + | ||
| 565 | + | /// One model in the catalogue: its prices (as the AI Gateway reads them) | |
| 566 | + | /// and what g1t knows about it. `admin_models` returns these. | |
| 567 | + | #[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] | |
| 568 | + | #[serde(rename_all = "camelCase")] | |
| 569 | + | pub struct CatalogueModel { | |
| 570 | + | #[serde(flatten)] | |
| 571 | + | pub prices: GatewayModel, | |
| 572 | + | /// Other ids the provider lists it by, such as a dated one. | |
| 573 | + | #[serde(default)] | |
| 574 | + | pub aliases: Vec<String>, | |
| 575 | + | /// `haiku`, `sonnet`, `opus`, `fable`, or a Workers AI author. | |
| 576 | + | #[serde(default)] | |
| 577 | + | pub family: String, | |
| 578 | + | /// The agent tier it suits: `small`, `large`, `frontier`, or empty. | |
| 579 | + | #[serde(default)] | |
| 580 | + | pub tier_hint: String, | |
| 581 | + | /// Tokens it reads at most; 0 when not known. | |
| 582 | + | #[serde(default)] | |
| 583 | + | pub context_window: u64, | |
| 584 | + | /// Tokens it writes at most; 0 when not known. | |
| 585 | + | #[serde(default)] | |
| 586 | + | pub max_output: u64, | |
| 587 | + | /// Any of `effort`, `thinking`, `tools`, `vision`, `embeddings`. | |
| 588 | + | #[serde(default)] | |
| 589 | + | pub capabilities: Vec<String>, | |
| 590 | + | /// An embeddings model's vector length; 0 otherwise or when not known. | |
| 591 | + | #[serde(default)] | |
| 592 | + | pub dimensions: u32, | |
| 593 | + | /// `available`, `new`, `deprecated` or `retired` (`model_status`). | |
| 594 | + | pub status: String, | |
| 595 | + | /// Whether its prices are known. An unpriced model is never routed to, | |
| 596 | + | /// offered or charged for. | |
| 597 | + | pub priced: bool, | |
| 598 | + | /// `discovered` (found by listing its provider) or `staff`. | |
| 599 | + | pub source: String, | |
| 600 | + | #[serde(default)] | |
| 601 | + | pub first_seen_at: Option<String>, | |
| 602 | + | /// When its provider last listed it. | |
| 603 | + | #[serde(default)] | |
| 604 | + | pub last_seen_at: Option<String>, | |
| 605 | + | /// Since when its provider has not listed it. | |
| 606 | + | #[serde(default)] | |
| 607 | + | pub missing_since: Option<String>, | |
| 608 | + | #[serde(default)] | |
| 609 | + | pub approved_by: Option<String>, | |
| 610 | + | #[serde(default)] | |
| 611 | + | pub approved_at: Option<String>, | |
| 612 | + | #[serde(default)] | |
| 613 | + | pub note: String, | |
| 614 | + | /// What a typical agent run would cost on it, in millionths of a | |
| 615 | + | /// dollar, from its prices (`typical_run` in billing's catalogue.rs); | |
| 616 | + | /// 0 for an embeddings or unpriced model. | |
| 617 | + | #[serde(default)] | |
| 618 | + | pub typical_run_micros: i64, | |
| 619 | + | } | |
| 620 | + | ||
| 621 | + | /// A provider's list price per million tokens, as its listing gives it, in | |
| 622 | + | /// millionths of a dollar. | |
| 623 | + | #[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)] | |
| 624 | + | #[serde(rename_all = "camelCase")] | |
| 625 | + | pub struct ListedPrice { | |
| 626 | + | pub input_micros: i64, | |
| 627 | + | #[serde(default)] | |
| 628 | + | pub output_micros: i64, | |
| 629 | + | } | |
| 630 | + | ||
| 631 | + | /// One model as its provider lists it, from the models service's | |
| 632 | + | /// discovery. | |
| 633 | + | #[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)] | |
| 634 | + | #[serde(rename_all = "camelCase")] | |
| 635 | + | pub struct ProviderModel { | |
| 636 | + | /// The provider's id: `claude-haiku-5-5`, `@cf/openai/gpt-oss-120b`. | |
| 637 | + | pub id: String, | |
| 638 | + | /// For people, as the provider names it. | |
| 639 | + | #[serde(default)] | |
| 640 | + | pub name: String, | |
| 641 | + | /// `chat`, `embeddings`, or anything else (not added to the catalogue, | |
| 642 | + | /// but still counted as listed). | |
| 643 | + | #[serde(default)] | |
| 644 | + | pub kind: String, | |
| 645 | + | #[serde(default)] | |
| 646 | + | pub context_window: u64, | |
| 647 | + | #[serde(default)] | |
| 648 | + | pub max_output: u64, | |
| 649 | + | #[serde(default)] | |
| 650 | + | pub capabilities: Vec<String>, | |
| 651 | + | /// Workers AI lists a price with each model; Anthropic does not. | |
| 652 | + | #[serde(default)] | |
| 653 | + | pub price: Option<ListedPrice>, | |
| 654 | + | } | |
| 655 | + | ||
| 656 | + | /// `record_discovery`: what one provider lists now, from the models | |
| 657 | + | /// service (daily, or when staff press "Check for new models"). Billing | |
| 658 | + | /// adds new ids as `new`, marks ones no longer listed `deprecated`, records | |
| 659 | + | /// the check and emails staff about anything new. With `error` (the listing | |
| 660 | + | /// failed) only the check is recorded. Returns `DiscoveryResult`. | |
| 661 | + | #[derive(Clone, Debug, Default, Serialize, Deserialize)] | |
| 662 | + | #[serde(rename_all = "camelCase")] | |
| 663 | + | pub struct RecordDiscoveryArgs { | |
| 664 | + | /// `anthropic` or `workers-ai`. | |
| 665 | + | pub provider: String, | |
| 666 | + | #[serde(default)] | |
| 667 | + | pub models: Vec<ProviderModel>, | |
| 668 | + | /// The staff member who asked, or `schedule`. | |
| 669 | + | pub by: String, | |
| 670 | + | #[serde(default)] | |
| 671 | + | pub error: Option<String>, | |
| 672 | + | } | |
| 673 | + | ||
| 674 | + | /// What one check found. | |
| 675 | + | #[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)] | |
| 676 | + | #[serde(rename_all = "camelCase")] | |
| 677 | + | pub struct DiscoveryResult { | |
| 678 | + | pub provider: String, | |
| 679 | + | pub checked_at: String, | |
| 680 | + | pub by: String, | |
| 681 | + | /// Ids the provider listed. | |
| 682 | + | pub listed: u32, | |
| 683 | + | /// Ids added to the catalogue as `new`. | |
| 684 | + | pub added: Vec<String>, | |
| 685 | + | /// Catalogue models the provider no longer lists, now `deprecated`. | |
| 686 | + | pub deprecated: Vec<String>, | |
| 687 | + | /// Deprecated models listed again. | |
| 688 | + | pub restored: Vec<String>, | |
| 689 | + | /// The listing failed: nothing changed. | |
| 690 | + | #[serde(default)] | |
| 691 | + | pub error: Option<String>, | |
| 692 | + | } | |
| 693 | + | ||
| 694 | + | /// Which model, tier or effort one purpose uses by default, as staff last | |
| 695 | + | /// set it. | |
| 696 | + | #[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] | |
| 697 | + | #[serde(rename_all = "camelCase")] | |
| 698 | + | pub struct ModelDefault { | |
| 699 | + | /// `tier_small`, `tier_large`, `tier_frontier`, `background`, | |
| 700 | + | /// `gateway_first`, or `job_<kind>` for `implement`, `revise`, | |
| 701 | + | /// `answer`, `review`, `update` and `plan`. | |
| 702 | + | pub purpose: String, | |
| 703 | + | /// The model, for a model purpose. | |
| 704 | + | #[serde(default)] | |
| 705 | + | pub model: Option<String>, | |
| 706 | + | /// For a job: `small`, `large`, `frontier`, or `change` (sized by the change). | |
| 707 | + | #[serde(default)] | |
| 708 | + | pub tier: Option<String>, | |
| 709 | + | /// For a job: `low`, `medium`, `high`, `xhigh` or `max`; none for the harness's own. | |
| 710 | + | #[serde(default)] | |
| 711 | + | pub effort: Option<String>, | |
| 712 | + | pub updated_at: String, | |
| 713 | + | pub updated_by: String, | |
| 714 | + | #[serde(default)] | |
| 715 | + | pub reason: String, | |
| 716 | + | } | |
| 717 | + | ||
| 718 | + | /// A model purpose's default as it applies now: the chosen model, or the | |
| 719 | + | /// one routing falls back to when the chosen one cannot be used. | |
| 720 | + | #[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] | |
| 721 | + | #[serde(rename_all = "camelCase")] | |
| 722 | + | pub struct ResolvedModel { | |
| 723 | + | pub purpose: String, | |
| 724 | + | /// The model staff chose. | |
| 725 | + | pub chosen: String, | |
| 726 | + | /// The model to use, with its prices; none when neither the chosen | |
| 727 | + | /// model nor any other suits (callers keep their own fallback). | |
| 728 | + | #[serde(default)] | |
| 729 | + | pub model: Option<GatewayModel>, | |
| 730 | + | #[serde(default)] | |
| 731 | + | pub capabilities: Vec<String>, | |
| 732 | + | /// Why it is not the chosen one, in a sentence: `Claude Haiku 5.5 is | |
| 733 | + | /// retired; using Claude Haiku 4.5.` | |
| 734 | + | #[serde(default)] | |
| 735 | + | pub note: Option<String>, | |
| 736 | + | } | |
| 737 | + | ||
| 738 | + | /// One kind of agent job's starting tier and effort. | |
| 739 | + | #[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] | |
| 740 | + | #[serde(rename_all = "camelCase")] | |
| 741 | + | pub struct JobDefault { | |
| 742 | + | /// `implement`, `revise`, `answer`, `review`, `update` or `plan`. | |
| 743 | + | pub kind: String, | |
| 744 | + | /// `small`, `large`, `frontier` or `change`. | |
| 745 | + | pub tier: String, | |
| 746 | + | #[serde(default)] | |
| 747 | + | pub effort: Option<String>, | |
| 748 | + | } | |
| 749 | + | ||
| 750 | + | /// `model_defaults` takes nothing and returns this: every purpose's model | |
| 751 | + | /// as it applies now, and each job's tier and effort. Read by the runner | |
| 752 | + | /// (cached a minute) and the AI Gateway. | |
| 753 | + | #[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)] | |
| 754 | + | #[serde(rename_all = "camelCase")] | |
| 755 | + | pub struct ModelDefaults { | |
| 756 | + | pub models: Vec<ResolvedModel>, | |
| 757 | + | pub jobs: Vec<JobDefault>, | |
| 758 | + | } | |
| 759 | + | ||
| 760 | + | /// A check of one provider, as `model_checks` keeps it. | |
| 761 | + | #[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] | |
| 762 | + | #[serde(rename_all = "camelCase")] | |
| 763 | + | pub struct ModelCheck { | |
| 764 | + | pub id: String, | |
| 765 | + | pub provider: String, | |
| 766 | + | pub checked_at: String, | |
| 767 | + | pub by: String, | |
| 768 | + | pub listed: u32, | |
| 769 | + | pub added: Vec<String>, | |
| 770 | + | pub deprecated: Vec<String>, | |
| 771 | + | #[serde(default)] | |
| 772 | + | pub error: Option<String>, | |
| 773 | + | } | |
| 774 | + | ||
| 775 | + | /// `admin_models` takes nothing and returns this: sudo's Agents & models. | |
| 776 | + | #[derive(Clone, Debug, Default, Serialize, Deserialize)] | |
| 777 | + | #[serde(rename_all = "camelCase")] | |
| 778 | + | pub struct AdminModels { | |
| 779 | + | /// Every model, `new` ones first, then by provider and position. | |
| 780 | + | pub catalogue: Vec<CatalogueModel>, | |
| 781 | + | pub defaults: Vec<ModelDefault>, | |
| 782 | + | pub resolved: ModelDefaults, | |
| 783 | + | /// The latest checks, newest first. | |
| 784 | + | pub checks: Vec<ModelCheck>, | |
| 785 | + | /// The token mix `typical_run_micros` prices, for the page to state. | |
| 786 | + | pub typical: TypicalRun, | |
| 787 | + | } | |
| 788 | + | ||
| 789 | + | /// The tokens of the typical agent run estimates are priced from. | |
| 790 | + | #[derive(Clone, Copy, Debug, Default, PartialEq, Serialize, Deserialize)] | |
| 791 | + | #[serde(rename_all = "camelCase")] | |
| 792 | + | pub struct TypicalRun { | |
| 793 | + | pub requests: u64, | |
| 794 | + | /// Per request. | |
| 795 | + | pub input: u64, | |
| 796 | + | pub output: u64, | |
| 797 | + | pub cache_read: u64, | |
| 798 | + | pub cache_write: u64, | |
| 799 | + | } | |
| 800 | + | ||
| 801 | + | /// A model's prices as staff confirm them, per million tokens in | |
| 802 | + | /// millionths of a dollar. | |
| 803 | + | #[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)] | |
| 804 | + | pub struct ModelPrices { | |
| 805 | + | pub input_micros: i64, | |
| 806 | + | pub output_micros: i64, | |
| 807 | + | pub cache_read_micros: i64, | |
| 808 | + | pub cache_write_micros: i64, | |
| 809 | + | #[serde(default)] | |
| 810 | + | pub cache_write_1h_micros: i64, | |
| 811 | + | #[serde(default)] | |
| 812 | + | pub threshold: u64, | |
| 813 | + | #[serde(default)] | |
| 814 | + | pub over_input_micros: i64, | |
| 815 | + | #[serde(default)] | |
| 816 | + | pub over_output_micros: i64, | |
| 817 | + | #[serde(default)] | |
| 818 | + | pub over_cache_read_micros: i64, | |
| 819 | + | #[serde(default)] | |
| 820 | + | pub over_cache_write_micros: i64, | |
| 821 | + | #[serde(default)] | |
| 822 | + | pub over_cache_write_1h_micros: i64, | |
| 823 | + | } | |
| 824 | + | ||
| 825 | + | /// `admin_decide_model`: `approve` a model (its prices confirmed, made | |
| 826 | + | /// available), `retire` one, or `restore` a retired or deprecated one. | |
| 827 | + | /// Audited. Returns `Outcome<CatalogueModel>`. | |
| 828 | + | #[derive(Clone, Debug, Default, Serialize, Deserialize)] | |
| 829 | + | pub struct AdminDecideModelArgs { | |
| 830 | + | pub model: String, | |
| 831 | + | pub decision: String, | |
| 832 | + | /// On approval: the name people see, and the prices. | |
| 833 | + | #[serde(default)] | |
| 834 | + | pub name: Option<String>, | |
| 835 | + | #[serde(default)] | |
| 836 | + | pub tier_hint: Option<String>, | |
| 837 | + | #[serde(default)] | |
| 838 | + | pub prices: Option<ModelPrices>, | |
| 839 | + | pub reason: String, | |
| 840 | + | pub by: String, | |
| 841 | + | } | |
| 842 | + | ||
| 843 | + | /// `admin_set_model_default`: one purpose's default. A model purpose takes | |
| 844 | + | /// `model` (available, priced, and suited to the purpose); a job takes | |
| 845 | + | /// `tier` and `effort`. Audited with the old and new values and why. | |
| 846 | + | /// Returns `Outcome<ModelDefault>`. | |
| 847 | + | #[derive(Clone, Debug, Default, Serialize, Deserialize)] | |
| 848 | + | pub struct AdminSetModelDefaultArgs { | |
| 849 | + | pub purpose: String, | |
| 850 | + | #[serde(default)] | |
| 851 | + | pub model: Option<String>, | |
| 852 | + | #[serde(default)] | |
| 853 | + | pub tier: Option<String>, | |
| 854 | + | #[serde(default)] | |
| 855 | + | pub effort: Option<String>, | |
| 856 | + | pub reason: String, | |
| 857 | + | pub by: String, | |
| 858 | + | } | |
| 859 | + | ||
| 542 | 860 | /// `gateway_admit`: whether a workspace's next AI Gateway request may go to | |
| 543 | 861 | /// g1t's models. Fails with `payment_required` and what to do when it may | |
| 544 | 862 | /// not: over its spend limit, out of AI credit, or not on the plan. Returns |
| 1 | + | //! Containers a job asks for, as GitHub's runner starts them, with the | |
| 2 | + | //! `docker` command: its `services:` (a database beside the steps), its | |
| 3 | + | //! `container:` (every step run inside an image), `uses: docker://image` | |
| 4 | + | //! steps and Docker actions (`runs.using: docker`). | |
| 5 | + | //! | |
| 6 | + | //! On g1t's machines the Engine is the job's own (crate::docker). Every | |
| 7 | + | //! container shares the job's network there, so a service is reached at | |
| 8 | + | //! `localhost:<port>` as on GitHub's runner, and by its name as from a job | |
| 9 | + | //! container: the API proxy makes each service's name mean 127.0.0.1. | |
| 10 | + | ||
| 11 | + | use std::collections::{BTreeMap, BTreeSet}; | |
| 12 | + | use std::path::{Path, PathBuf}; | |
| 13 | + | use std::process::Command; | |
| 14 | + | use std::time::{Duration, Instant}; | |
| 15 | + | ||
| 16 | + | use g1t_actions::expr; | |
| 17 | + | use serde_json::{Map, Value, json}; | |
| 18 | + | ||
| 19 | + | use super::files::StepFiles; | |
| 20 | + | use super::process::{self, Commands, Ended}; | |
| 21 | + | use super::Job; | |
| 22 | + | ||
| 23 | + | /// Set in a job a self-hosted runner started inside its `container:` image. | |
| 24 | + | pub(crate) const IN_JOB_CONTAINER: &str = "G1T_JOB_CONTAINER"; | |
| 25 | + | /// Where a job container finds the runner's Node, for JavaScript actions. | |
| 26 | + | pub(crate) const CONTAINER_NODE: &str = "/__e/node24/bin/node"; | |
| 27 | + | /// GitHub's folders inside a Docker action's container. | |
| 28 | + | const GITHUB_WORKSPACE: &str = "/github/workspace"; | |
| 29 | + | const GITHUB_HOME: &str = "/github/home"; | |
| 30 | + | const GITHUB_WORKFLOW: &str = "/github/workflow"; | |
| 31 | + | const GITHUB_FILE_COMMANDS: &str = "/github/file_commands"; | |
| 32 | + | ||
| 33 | + | /// The job's own container, when it has `container:`. | |
| 34 | + | pub(crate) struct JobContainer { | |
| 35 | + | pub(crate) id: String, | |
| 36 | + | /// `bash`, or `sh` in an image without it. | |
| 37 | + | pub(crate) shell: &'static str, | |
| 38 | + | /// Whether the runner's Node runs in it. | |
| 39 | + | pub(crate) node: bool, | |
| 40 | + | /// The image's own `PATH`. | |
| 41 | + | pub(crate) path: String, | |
| 42 | + | } | |
| 43 | + | ||
| 44 | + | /// A `services:` entry or `container:`, read. | |
| 45 | + | #[derive(Debug, Default, PartialEq)] | |
| 46 | + | pub(crate) struct ContainerSpec { | |
| 47 | + | pub(crate) image: String, | |
| 48 | + | pub(crate) env: BTreeMap<String, String>, | |
| 49 | + | pub(crate) ports: Vec<String>, | |
| 50 | + | pub(crate) volumes: Vec<String>, | |
| 51 | + | pub(crate) options: Vec<String>, | |
| 52 | + | pub(crate) credentials: Option<(String, String)>, | |
| 53 | + | pub(crate) command: Vec<String>, | |
| 54 | + | pub(crate) entrypoint: Option<String>, | |
| 55 | + | } | |
| 56 | + | ||
| 57 | + | /// Splits a command line as a shell would: words, with `'…'`, `"…"` and | |
| 58 | + | /// `\` quoting. No variables are expanded. | |
| 59 | + | pub(crate) fn split_words(text: &str) -> Vec<String> { | |
| 60 | + | let mut words = Vec::new(); | |
| 61 | + | let mut word = String::new(); | |
| 62 | + | let mut started = false; | |
| 63 | + | let mut chars = text.chars().peekable(); | |
| 64 | + | while let Some(c) = chars.next() { | |
| 65 | + | match c { | |
| 66 | + | '\'' => { | |
| 67 | + | started = true; | |
| 68 | + | for c in chars.by_ref() { | |
| 69 | + | if c == '\'' { | |
| 70 | + | break; | |
| 71 | + | } | |
| 72 | + | word.push(c); | |
| 73 | + | } | |
| 74 | + | } | |
| 75 | + | '"' => { | |
| 76 | + | started = true; | |
| 77 | + | while let Some(c) = chars.next() { | |
| 78 | + | match c { | |
| 79 | + | '"' => break, | |
| 80 | + | '\\' if matches!(chars.peek(), Some('"' | '\\' | '$' | '`')) => word.push(chars.next().unwrap_or('\\')), | |
| 81 | + | c => word.push(c), | |
| 82 | + | } | |
| 83 | + | } | |
| 84 | + | } | |
| 85 | + | '\\' => { | |
| 86 | + | started = true; | |
| 87 | + | if let Some(next) = chars.next() | |
| 88 | + | && next != '\n' | |
| 89 | + | { | |
| 90 | + | word.push(next); | |
| 91 | + | } | |
| 92 | + | } | |
| 93 | + | c if c.is_whitespace() => { | |
| 94 | + | if started { | |
| 95 | + | words.push(std::mem::take(&mut word)); | |
| 96 | + | started = false; | |
| 97 | + | } | |
| 98 | + | } | |
| 99 | + | c => { | |
| 100 | + | started = true; | |
| 101 | + | word.push(c); | |
| 102 | + | } | |
| 103 | + | } | |
| 104 | + | } | |
| 105 | + | if started { | |
| 106 | + | words.push(word); | |
| 107 | + | } | |
| 108 | + | words | |
| 109 | + | } | |
| 110 | + | ||
| 111 | + | fn texts(value: Option<&Value>) -> Vec<String> { | |
| 112 | + | match value { | |
| 113 | + | Some(Value::Array(items)) => items.iter().map(expr::to_text).filter(|s| !s.is_empty()).collect(), | |
| 114 | + | Some(Value::Null) | None => Vec::new(), | |
| 115 | + | Some(other) => vec![expr::to_text(other)].into_iter().filter(|s| !s.is_empty()).collect(), | |
| 116 | + | } | |
| 117 | + | } | |
| 118 | + | ||
| 119 | + | /// Reads a `services:` entry or `container:`, its expressions already | |
| 120 | + | /// read: a string (the image) or a mapping. | |
| 121 | + | pub(crate) fn container_spec(value: &Value) -> ContainerSpec { | |
| 122 | + | match value { | |
| 123 | + | Value::String(image) => ContainerSpec { image: image.trim().to_owned(), ..ContainerSpec::default() }, | |
| 124 | + | Value::Object(fields) => ContainerSpec { | |
| 125 | + | image: fields.get("image").map(expr::to_text).unwrap_or_default().trim().to_owned(), | |
| 126 | + | env: fields | |
| 127 | + | .get("env") | |
| 128 | + | .and_then(Value::as_object) | |
| 129 | + | .map(|env| env.iter().map(|(k, v)| (k.clone(), expr::to_text(v))).collect()) | |
| 130 | + | .unwrap_or_default(), | |
| 131 | + | ports: texts(fields.get("ports")), | |
| 132 | + | volumes: texts(fields.get("volumes")), | |
| 133 | + | options: fields.get("options").map(|o| split_words(&expr::to_text(o))).unwrap_or_default(), | |
| 134 | + | credentials: fields.get("credentials").and_then(Value::as_object).and_then(|c| { | |
| 135 | + | let username = c.get("username").map(expr::to_text).unwrap_or_default(); | |
| 136 | + | let password = c.get("password").map(expr::to_text).unwrap_or_default(); | |
| 137 | + | (!username.is_empty() || !password.is_empty()).then_some((username, password)) | |
| 138 | + | }), | |
| 139 | + | command: fields.get("command").map(|c| split_words(&expr::to_text(c))).unwrap_or_default(), | |
| 140 | + | entrypoint: fields.get("entrypoint").map(expr::to_text).filter(|e| !e.is_empty()), | |
| 141 | + | }, | |
| 142 | + | _ => ContainerSpec::default(), | |
| 143 | + | } | |
| 144 | + | } | |
| 145 | + | ||
| 146 | + | /// `job.services.<id>.ports`: each container port, and the host port it | |
| 147 | + | /// is reached on. On g1t's machines a port left for Docker to choose is | |
| 148 | + | /// the container's own. | |
| 149 | + | pub(crate) fn port_map(ports: &[String]) -> BTreeMap<String, String> { | |
| 150 | + | let mut map = BTreeMap::new(); | |
| 151 | + | for port in ports { | |
| 152 | + | let without_protocol = port.split('/').next().unwrap_or(port); | |
| 153 | + | let parts: Vec<&str> = without_protocol.split(':').collect(); | |
| 154 | + | let (host, container) = match parts.as_slice() { | |
| 155 | + | [container] => (*container, *container), | |
| 156 | + | [host, container] => (if host.is_empty() { *container } else { *host }, *container), | |
| 157 | + | [_, host, container] => (if host.is_empty() { *container } else { *host }, *container), | |
| 158 | + | _ => continue, | |
| 159 | + | }; | |
| 160 | + | if !container.is_empty() { | |
| 161 | + | map.insert(container.to_owned(), host.to_owned()); | |
| 162 | + | } | |
| 163 | + | } | |
| 164 | + | map | |
| 165 | + | } | |
| 166 | + | ||
| 167 | + | /// The registry an image is pulled from, for signing in with | |
| 168 | + | /// `credentials:`. | |
| 169 | + | pub(crate) fn registry_of(image: &str) -> String { | |
| 170 | + | match image.split_once('/') { | |
| 171 | + | Some((first, _)) if first.contains('.') || first.contains(':') || first == "localhost" => first.to_owned(), | |
| 172 | + | _ => "https://index.docker.io/v1/".to_owned(), | |
| 173 | + | } | |
| 174 | + | } | |
| 175 | + | ||
| 176 | + | /// A name for Docker from any text: lower case, letters, digits, `_`, | |
| 177 | + | /// `.` and `-`. | |
| 178 | + | pub(crate) fn docker_name(text: &str) -> String { | |
| 179 | + | let name: String = text | |
| 180 | + | .to_ascii_lowercase() | |
| 181 | + | .chars() | |
| 182 | + | .map(|c| if c.is_ascii_alphanumeric() || matches!(c, '_' | '.' | '-') { c } else { '_' }) | |
| 183 | + | .collect(); | |
| 184 | + | name.trim_matches(['_', '.', '-']).chars().take(100).collect() | |
| 185 | + | } | |
| 186 | + | ||
| 187 | + | /// The `docker create` arguments of a container. `env` is passed by name | |
| 188 | + | /// (`-e NAME`), its values in the command's environment, so secrets never | |
| 189 | + | /// stand on a command line. `keep_alive`: a job container, which waits | |
| 190 | + | /// while steps run in it. | |
| 191 | + | pub(crate) fn create_args(name: &str, network: Option<&str>, alias: Option<&str>, spec: &ContainerSpec, mounts: &[(String, String)], keep_alive: bool) -> Vec<String> { | |
| 192 | + | let mut args: Vec<String> = vec!["create".into(), "--name".into(), name.into(), "--label".into(), "g1t-job".into()]; | |
| 193 | + | if let Some(network) = network { | |
| 194 | + | args.extend(["--network".into(), network.into()]); | |
| 195 | + | if let Some(alias) = alias { | |
| 196 | + | args.extend(["--network-alias".into(), alias.into()]); | |
| 197 | + | } | |
| 198 | + | } | |
| 199 | + | for port in &spec.ports { | |
| 200 | + | args.extend(["-p".into(), port.clone()]); | |
| 201 | + | } | |
| 202 | + | for volume in &spec.volumes { | |
| 203 | + | args.extend(["-v".into(), volume.clone()]); | |
| 204 | + | } | |
| 205 | + | for (from, to) in mounts { | |
| 206 | + | args.extend(["-v".into(), format!("{from}:{to}")]); | |
| 207 | + | } | |
| 208 | + | for name in spec.env.keys() { | |
| 209 | + | args.extend(["-e".into(), name.clone()]); | |
| 210 | + | } | |
| 211 | + | args.extend(spec.options.iter().cloned()); | |
| 212 | + | if keep_alive { | |
| 213 | + | args.extend(["--entrypoint".into(), "tail".into(), spec.image.clone(), "-f".into(), "/dev/null".into()]); | |
| 214 | + | } else { | |
| 215 | + | if let Some(entrypoint) = &spec.entrypoint { | |
| 216 | + | args.extend(["--entrypoint".into(), entrypoint.clone()]); | |
| 217 | + | } | |
| 218 | + | args.push(spec.image.clone()); | |
| 219 | + | args.extend(spec.command.iter().cloned()); | |
| 220 | + | } | |
| 221 | + | args | |
| 222 | + | } | |
| 223 | + | ||
| 224 | + | /// A Docker action's run, or a `docker://` step's. | |
| 225 | + | #[derive(Clone, Debug)] | |
| 226 | + | pub(crate) struct DockerRun { | |
| 227 | + | pub(crate) image: String, | |
| 228 | + | pub(crate) entrypoint: Option<String>, | |
| 229 | + | pub(crate) args: Vec<String>, | |
| 230 | + | /// The step's variables, `INPUT_*` included. | |
| 231 | + | pub(crate) env: BTreeMap<String, String>, | |
| 232 | + | } | |
| 233 | + | ||
| 234 | + | /// What a command line shows: secrets are passed by name, so nothing | |
| 235 | + | /// needs hiding, but long lists are kept readable. | |
| 236 | + | fn shown(args: &[String]) -> String { | |
| 237 | + | args.iter().map(|a| if a.contains(' ') || a.is_empty() { format!("'{a}'") } else { a.clone() }).collect::<Vec<_>>().join(" ") | |
| 238 | + | } | |
| 239 | + | ||
| 240 | + | impl Job { | |
| 241 | + | /// Runs `docker` with `args`, its output in the log. `env`: the | |
| 242 | + | /// variables it passes to a container by name. | |
| 243 | + | pub(crate) fn docker(&mut self, args: &[String], env: &BTreeMap<String, String>, timeout: Duration) -> bool { | |
| 244 | + | self.log.line(&format!("[command]docker {}", shown(args))); | |
| 245 | + | let mut command = Command::new("docker"); | |
| 246 | + | command.args(args).env_clear().envs(self.docker_cli_env()).envs(env); | |
| 247 | + | let mut commands = Commands::default(); | |
| 248 | + | matches!(process::run(command, timeout.min(self.remaining_time()), &mut self.log, &mut commands), Ok(Ended::Exited(0))) | |
| 249 | + | } | |
| 250 | + | ||
| 251 | + | /// What `docker` prints, or None if it fails. | |
| 252 | + | pub(crate) fn docker_output(&self, args: &[&str]) -> Option<String> { | |
| 253 | + | let output = Command::new("docker").args(args).env_clear().envs(self.docker_cli_env()).output().ok()?; | |
| 254 | + | output.status.success().then(|| String::from_utf8_lossy(&output.stdout).trim().to_owned()) | |
| 255 | + | } | |
| 256 | + | ||
| 257 | + | /// What the `docker` command itself needs of the sandbox's variables: | |
| 258 | + | /// where it is, where its config is, and how to reach the Engine. | |
| 259 | + | fn docker_cli_env(&self) -> BTreeMap<String, String> { | |
| 260 | + | ["PATH", "HOME", "DOCKER_HOST", "DOCKER_CONFIG", "DOCKER_CONTEXT", "DOCKER_CERT_PATH", "DOCKER_TLS_VERIFY"] | |
| 261 | + | .iter() | |
| 262 | + | .filter_map(|name| self.base_env_value(name).map(|value| (name.to_string(), value))) | |
| 263 | + | .collect() | |
| 264 | + | } | |
| 265 | + | ||
| 266 | + | /// Makes sure Docker answers before a job's containers start: the | |
| 267 | + | /// job's own Engine, on g1t's machines. | |
| 268 | + | fn docker_ready(&mut self) -> bool { | |
| 269 | + | if self.docker_hosted { | |
| 270 | + | let started = crate::docker::engine::ensure_started(); | |
| 271 | + | self.log_docker_notes(); | |
| 272 | + | // Why it could not start is among the notes just logged. | |
| 273 | + | return started.is_ok(); | |
| 274 | + | } | |
| 275 | + | if self.docker_output(&["version", "--format", "{{.Server.Version}}"]).is_some() { | |
| 276 | + | return true; | |
| 277 | + | } | |
| 278 | + | self.log.line("##[error]This job needs Docker (`services`, `container` or a Docker action), and Docker does not answer here. On a self-hosted runner, run the runner directly on a machine with Docker (`--no-docker`)."); | |
| 279 | + | false | |
| 280 | + | } | |
| 281 | + | ||
| 282 | + | pub(crate) fn log_docker_notes(&mut self) { | |
| 283 | + | for line in crate::docker::take_notes() { | |
| 284 | + | self.log.line(&line); | |
| 285 | + | } | |
| 286 | + | } | |
| 287 | + | ||
| 288 | + | /// Pulls an image, signed in with `credentials` if it has them. | |
| 289 | + | fn pull(&mut self, image: &str, credentials: Option<&(String, String)>) -> bool { | |
| 290 | + | let Some((username, password)) = credentials else { | |
| 291 | + | return self.docker(&["pull".into(), image.into()], &BTreeMap::new(), Duration::from_secs(1800)); | |
| 292 | + | }; | |
| 293 | + | // A config of its own, so the credentials go no further than this pull. | |
| 294 | + | let config = self.temp.join(format!("docker-config-{:x}", super::rand_id())); | |
| 295 | + | let _ = std::fs::create_dir_all(&config); | |
| 296 | + | let registry = registry_of(image); | |
| 297 | + | let config_text = config.display().to_string(); | |
| 298 | + | self.log.line(&format!("[command]docker --config {config_text} login {registry} --username *** --password-stdin")); | |
| 299 | + | let login = Command::new("docker") | |
| 300 | + | .args(["--config", &config_text, "login", ®istry, "--username", username, "--password-stdin"]) | |
| 301 | + | .env_clear() | |
| 302 | + | .envs(self.docker_cli_env()) | |
| 303 | + | .stdin(std::process::Stdio::piped()) | |
| 304 | + | .stdout(std::process::Stdio::piped()) | |
| 305 | + | .stderr(std::process::Stdio::piped()) | |
| 306 | + | .spawn() | |
| 307 | + | .and_then(|mut child| { | |
| 308 | + | use std::io::Write; | |
| 309 | + | if let Some(mut stdin) = child.stdin.take() { | |
| 310 | + | let _ = stdin.write_all(password.as_bytes()); | |
| 311 | + | } | |
| 312 | + | child.wait_with_output() | |
| 313 | + | }); | |
| 314 | + | let ok = match login { | |
| 315 | + | Ok(output) if output.status.success() => { | |
| 316 | + | self.docker(&["--config".into(), config_text.clone(), "pull".into(), image.into()], &BTreeMap::new(), Duration::from_secs(1800)) | |
| 317 | + | } | |
| 318 | + | Ok(output) => { | |
| 319 | + | self.log.line(&format!("##[error]Could not sign in to {registry}: {}", String::from_utf8_lossy(&output.stderr).trim())); | |
| 320 | + | false | |
| 321 | + | } | |
| 322 | + | Err(error) => { | |
| 323 | + | self.log.line(&format!("##[error]Could not run docker login: {error}")); | |
| 324 | + | false | |
| 325 | + | } | |
| 326 | + | }; | |
| 327 | + | let _ = std::fs::remove_dir_all(&config); | |
| 328 | + | ok | |
| 329 | + | } | |
| 330 | + | ||
| 331 | + | /// Reads a `services:` entry or `container:` with the job's contexts. | |
| 332 | + | fn read_container(&self, value: &Value) -> ContainerSpec { | |
| 333 | + | let frame = super::Frame::default(); | |
| 334 | + | let env = self.env_context(&frame); | |
| 335 | + | let contexts = self.contexts_for(&frame, &env); | |
| 336 | + | let value = self.with_scope(&contexts, |scope| expr::interpolate_value(value, scope)).unwrap_or(Value::Null); | |
| 337 | + | container_spec(&value) | |
| 338 | + | } | |
| 339 | + | ||
| 340 | + | /// Starts the job's services and its container, before its steps: | |
| 341 | + | /// GitHub's "Initialize containers". Returns whether they all started. | |
| 342 | + | pub(crate) fn start_containers(&mut self) -> bool { | |
| 343 | + | let services: Vec<(String, Value)> = self.spec["spec"]["services"].as_object().map(|s| s.iter().map(|(k, v)| (k.clone(), v.clone())).collect()).unwrap_or_default(); | |
| 344 | + | let container = self.spec["spec"].get("container").filter(|c| !c.is_null()).cloned(); | |
| 345 | + | let container = container.map(|c| self.read_container(&c)).filter(|c| !c.image.is_empty()); | |
| 346 | + | let services: Vec<(String, ContainerSpec)> = services.into_iter().map(|(name, value)| (name, self.read_container(&value))).filter(|(_, spec)| !spec.image.is_empty()).collect(); | |
| 347 | + | // A self-hosted runner in Docker mode started this job in its | |
| 348 | + | // `container:` image already (selfhosted/exec.rs). | |
| 349 | + | let container = container.filter(|_| std::env::var_os(IN_JOB_CONTAINER).is_none()); | |
| 350 | + | if services.is_empty() && container.is_none() { | |
| 351 | + | return true; | |
| 352 | + | } | |
| 353 | + | // A self-hosted machine without Docker runs the steps as before, | |
| 354 | + | // on the machine, without the containers. | |
| 355 | + | if !self.docker_hosted && self.docker_output(&["version", "--format", "{{.Server.Version}}"]).is_none() { | |
| 356 | + | self.log.line("##[warning]Docker does not answer on this runner, so the job's `services` and `container` are not started and its steps run on the machine. Run the runner directly on a machine with Docker (`--no-docker`) to start them."); | |
| 357 | + | return true; | |
| 358 | + | } | |
| 359 | + | self.log.line("##[group]Initialize containers"); | |
| 360 | + | let ok = self.docker_ready() && self.start_containers_now(services, container); | |
| 361 | + | self.log.line("##[endgroup]"); | |
| 362 | + | ok | |
| 363 | + | } | |
| 364 | + | ||
| 365 | + | fn start_containers_now(&mut self, services: Vec<(String, ContainerSpec)>, container: Option<ContainerSpec>) -> bool { | |
| 366 | + | let job_id = docker_name(&format!("{}_{:x}", self.spec["name"].as_str().unwrap_or("job"), super::rand_id() & 0xffff_ffff)); | |
| 367 | + | let network = format!("g1t_{job_id}"); | |
| 368 | + | if !self.docker(&["network".into(), "create".into(), "--label".into(), "g1t-job".into(), network.clone()], &BTreeMap::new(), Duration::from_secs(60)) { | |
| 369 | + | self.log.line("##[error]Could not make the job's network."); | |
| 370 | + | return false; | |
| 371 | + | } | |
| 372 | + | self.network = Some(network.clone()); | |
| 373 | + | ||
| 374 | + | let mut services_context = Map::new(); | |
| 375 | + | for (service, spec) in &services { | |
| 376 | + | if !self.pull(&spec.image, spec.credentials.as_ref()) { | |
| 377 | + | self.log.line(&format!("##[error]Could not pull {} for the service `{service}`.", spec.image)); | |
| 378 | + | return false; | |
| 379 | + | } | |
| 380 | + | let name = docker_name(&format!("{service}_{job_id}")); | |
| 381 | + | let args = create_args(&name, Some(&network), Some(service), spec, &[], false); | |
| 382 | + | if !self.docker(&args, &spec.env, Duration::from_secs(300)) { | |
| 383 | + | self.log.line(&format!("##[error]Could not create the service `{service}`.")); | |
| 384 | + | return false; | |
| 385 | + | } | |
| 386 | + | self.services.push((service.clone(), name.clone())); | |
| 387 | + | if !self.docker(&["start".into(), name.clone()], &BTreeMap::new(), Duration::from_secs(300)) { | |
| 388 | + | self.log.line(&format!("##[error]Could not start the service `{service}`.")); | |
| 389 | + | return false; | |
| 390 | + | } | |
| 391 | + | let id = self.docker_output(&["inspect", "--format", "{{.Id}}", &name]).unwrap_or_default(); | |
| 392 | + | let ports: Map<String, Value> = port_map(&spec.ports).into_iter().map(|(k, v)| (k, json!(v))).collect(); | |
| 393 | + | services_context.insert(service.clone(), json!({ "id": id, "network": network, "ports": ports })); | |
| 394 | + | } | |
| 395 | + | ||
| 396 | + | if let Some(spec) = container { | |
| 397 | + | if !self.pull(&spec.image, spec.credentials.as_ref()) { | |
| 398 | + | self.log.line(&format!("##[error]Could not pull {} for the job's container.", spec.image)); | |
| 399 | + | return false; | |
| 400 | + | } | |
| 401 | + | let name = docker_name(&format!("job_{job_id}")); | |
| 402 | + | let mounts = self.container_mounts(); | |
| 403 | + | let mut spec = spec; | |
| 404 | + | spec.env.insert("HOME".into(), GITHUB_HOME.into()); | |
| 405 | + | spec.env.insert("CI".into(), "true".into()); | |
| 406 | + | spec.env.insert("GITHUB_ACTIONS".into(), "true".into()); | |
| 407 | + | let mut args = create_args(&name, Some(&network), None, &spec, &mounts, true); | |
| 408 | + | // Steps start in the workspace. | |
| 409 | + | args.splice(1..1, ["-w".to_owned(), self.workspace.display().to_string()]); | |
| 410 | + | if !self.docker(&args, &spec.env, Duration::from_secs(300)) || !self.docker(&["start".into(), name.clone()], &BTreeMap::new(), Duration::from_secs(300)) { | |
| 411 | + | self.log.line("##[error]Could not start the job's container."); | |
| 412 | + | return false; | |
| 413 | + | } | |
| 414 | + | let id = self.docker_output(&["inspect", "--format", "{{.Id}}", &name]).unwrap_or_default(); | |
| 415 | + | let has_bash = self.docker_output(&["exec", &name, "sh", "-c", "command -v bash"]).is_some_and(|out| !out.is_empty()); | |
| 416 | + | let node = self.docker_output(&["exec", &name, CONTAINER_NODE, "--version"]).is_some(); | |
| 417 | + | let path = self.docker_output(&["exec", &name, "sh", "-c", "printf %s \"$PATH\""]).unwrap_or_else(|| "/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin".into()); | |
| 418 | + | if !node { | |
| 419 | + | self.log.line("##[warning]The runner's Node does not run in this image (it needs glibc and libstdc++), so JavaScript actions run beside the container, on g1t's image, with the same files."); | |
| 420 | + | } | |
| 421 | + | self.job_context.insert("container".into(), json!({ "id": id, "network": network })); | |
| 422 | + | self.container = Some(JobContainer { id: name, shell: if has_bash { "bash" } else { "sh" }, node, path }); | |
| 423 | + | } | |
| 424 | + | ||
| 425 | + | // Services with a health check are waited for. | |
| 426 | + | for (service, name) in self.services.clone() { | |
| 427 | + | if !self.wait_healthy(&service, &name) { | |
| 428 | + | return false; | |
| 429 | + | } | |
| 430 | + | } | |
| 431 | + | if !services_context.is_empty() { | |
| 432 | + | self.job_context.insert("services".into(), Value::Object(services_context)); | |
| 433 | + | } | |
| 434 | + | self.log_docker_notes(); | |
| 435 | + | true | |
| 436 | + | } | |
| 437 | + | ||
| 438 | + | /// GitHub's runner's folders, at the same paths, and Docker's socket. | |
| 439 | + | fn container_mounts(&self) -> Vec<(String, String)> { | |
| 440 | + | let home = super::paths::under_home(super::paths::HOME_RUNNER); | |
| 441 | + | let github_home = self.temp.join("_github_home"); | |
| 442 | + | let _ = std::fs::create_dir_all(&github_home); | |
| 443 | + | let mut mounts: Vec<(String, String)> = ["work", "_temp", "_actions", "_tool"] | |
| 444 | + | .iter() | |
| 445 | + | .map(|dir| { | |
| 446 | + | let path = home.join(dir); | |
| 447 | + | let _ = std::fs::create_dir_all(&path); | |
| 448 | + | (path.display().to_string(), path.display().to_string()) | |
| 449 | + | }) | |
| 450 | + | .collect(); | |
| 451 | + | mounts.push((github_home.display().to_string(), GITHUB_HOME.into())); | |
| 452 | + | if let Some(node) = which_node() { | |
| 453 | + | mounts.push((node.display().to_string(), format!("{CONTAINER_NODE}:ro"))); | |
| 454 | + | } | |
| 455 | + | mounts.push((crate::docker::engine::DOCKER_SOCKET.into(), crate::docker::engine::DOCKER_SOCKET.into())); | |
| 456 | + | mounts | |
| 457 | + | } | |
| 458 | + | ||
| 459 | + | fn wait_healthy(&mut self, service: &str, name: &str) -> bool { | |
| 460 | + | let limit = Duration::from_secs(600).min(self.remaining_time()); | |
| 461 | + | let until = Instant::now() + limit; | |
| 462 | + | let mut wait = Duration::from_secs(1); | |
| 463 | + | loop { | |
| 464 | + | let status = self.docker_output(&["inspect", "--format", "{{if .Config.Healthcheck}}{{print .State.Health.Status}}{{end}}", name]).unwrap_or_default(); | |
| 465 | + | match status.as_str() { | |
| 466 | + | "" => return true, | |
| 467 | + | "healthy" => { | |
| 468 | + | self.log.line(&format!("{service} is healthy.")); | |
| 469 | + | return true; | |
| 470 | + | } | |
| 471 | + | "unhealthy" => { | |
| 472 | + | self.log.line(&format!("##[error]The service `{service}` is unhealthy.")); | |
| 473 | + | self.service_logs(service, name); | |
| 474 | + | return false; | |
| 475 | + | } | |
| 476 | + | _ => {} | |
| 477 | + | } | |
| 478 | + | if Instant::now() >= until { | |
| 479 | + | self.log.line(&format!("##[error]The service `{service}` did not become healthy in {} s.", limit.as_secs())); | |
| 480 | + | self.service_logs(service, name); | |
| 481 | + | return false; | |
| 482 | + | } | |
| 483 | + | self.log.line(&format!("Waiting for {service} to be healthy ({status}).")); | |
| 484 | + | std::thread::sleep(wait); | |
| 485 | + | wait = (wait * 2).min(Duration::from_secs(8)); | |
| 486 | + | } | |
| 487 | + | } | |
| 488 | + | ||
| 489 | + | fn service_logs(&mut self, service: &str, name: &str) { | |
| 490 | + | self.log.line(&format!("##[group]Service container {service}")); | |
| 491 | + | self.docker(&["logs".into(), "--tail".into(), "200".into(), name.into()], &BTreeMap::new(), Duration::from_secs(60)); | |
| 492 | + | self.log.line("##[endgroup]"); | |
| 493 | + | } | |
| 494 | + | ||
| 495 | + | /// Whether the job has containers to stop when it ends. | |
| 496 | + | pub(crate) fn has_containers(&self) -> bool { | |
| 497 | + | self.network.is_some() | |
| 498 | + | } | |
| 499 | + | ||
| 500 | + | /// GitHub's "Stop containers": each service's log, then the job's | |
| 501 | + | /// containers and network removed. | |
| 502 | + | pub(crate) fn stop_containers(&mut self) -> bool { | |
| 503 | + | for (service, name) in self.services.clone() { | |
| 504 | + | self.service_logs(&service, &name); | |
| 505 | + | } | |
| 506 | + | let mut names: Vec<String> = self.services.iter().map(|(_, name)| name.clone()).collect(); | |
| 507 | + | if let Some(container) = &self.container { | |
| 508 | + | names.push(container.id.clone()); | |
| 509 | + | } | |
| 510 | + | if !names.is_empty() { | |
| 511 | + | let mut args = vec!["rm".to_owned(), "--force".to_owned()]; | |
| 512 | + | args.extend(names); | |
| 513 | + | self.docker(&args, &BTreeMap::new(), Duration::from_secs(120)); | |
| 514 | + | } | |
| 515 | + | if let Some(network) = self.network.take() { | |
| 516 | + | self.docker(&["network".into(), "rm".into(), network], &BTreeMap::new(), Duration::from_secs(60)); | |
| 517 | + | } | |
| 518 | + | self.container = None; | |
| 519 | + | true | |
| 520 | + | } | |
| 521 | + | ||
| 522 | + | /// The variables a process inside a container is given: the step's, | |
| 523 | + | /// GitHub's and the job's, but not the sandbox's own (its `PATH`, | |
| 524 | + | /// `HOME` and the like, which mean nothing in another image). | |
| 525 | + | pub(crate) fn container_env(&self, step_env: &BTreeMap<String, String>, full: BTreeMap<String, String>, image_path: &str) -> BTreeMap<String, String> { | |
| 526 | + | let mut out: BTreeMap<String, String> = full | |
| 527 | + | .into_iter() | |
| 528 | + | .filter(|(name, _)| step_env.contains_key(name) || !self.host_env.contains(name) || name.starts_with("GITHUB_") || name.starts_with("RUNNER_")) | |
| 529 | + | .collect(); | |
| 530 | + | if !step_env.contains_key("PATH") { | |
| 531 | + | out.remove("PATH"); | |
| 532 | + | if !self.path_prepend_entries().is_empty() { | |
| 533 | + | out.insert("PATH".into(), format!("{}:{image_path}", self.path_prepend_entries().join(":"))); | |
| 534 | + | } | |
| 535 | + | } | |
| 536 | + | if !step_env.contains_key("HOME") { | |
| 537 | + | out.remove("HOME"); | |
| 538 | + | } | |
| 539 | + | out | |
| 540 | + | } | |
| 541 | + | ||
| 542 | + | /// A step's command, run inside the job's container instead. | |
| 543 | + | pub(crate) fn in_container(&self, program: &str, args: &[String], dir: &Path, env: BTreeMap<String, String>) -> Option<Command> { | |
| 544 | + | let container = self.container.as_ref()?; | |
| 545 | + | let mut command = Command::new("docker"); | |
| 546 | + | command.args(["exec", "-w", &dir.display().to_string()]); | |
| 547 | + | for name in env.keys() { | |
| 548 | + | command.args(["-e", name]); | |
| 549 | + | } | |
| 550 | + | command.arg(&container.id).arg(program).args(args); | |
| 551 | + | command.env_clear().envs(self.docker_cli_env()).envs(env); | |
| 552 | + | Some(command) | |
| 553 | + | } | |
| 554 | + | ||
| 555 | + | /// Runs a container for a Docker action or a `docker://` step, as | |
| 556 | + | /// GitHub's runner does: the workspace at /github/workspace, the step's | |
| 557 | + | /// files at /github/file_commands, the job's network. | |
| 558 | + | pub(crate) fn run_docker(&mut self, run: &DockerRun) -> (bool, BTreeMap<String, String>, BTreeMap<String, String>) { | |
| 559 | + | let fail = (false, BTreeMap::new(), BTreeMap::new()); | |
| 560 | + | if !self.docker_ready() { | |
| 561 | + | return fail; | |
| 562 | + | } | |
| 563 | + | let id = format!("{:x}", super::rand_id()); | |
| 564 | + | let Ok(files) = StepFiles::new(&self.temp, &id) else { return fail }; | |
| 565 | + | let commands_dir = self.temp.join("_runner_file_commands"); | |
| 566 | + | let workflow_dir = self.temp.join("_github_workflow"); | |
| 567 | + | let home_dir = self.temp.join("_github_home"); | |
| 568 | + | for dir in [&workflow_dir, &home_dir] { | |
| 569 | + | let _ = std::fs::create_dir_all(dir); | |
| 570 | + | } | |
| 571 | + | let _ = std::fs::copy(self.temp.join("event.json"), workflow_dir.join("event.json")); | |
| 572 | + | ||
| 573 | + | let full = self.process_env(&run.env, &files); | |
| 574 | + | let mut env = self.container_env(&run.env, full, ""); | |
| 575 | + | env.remove("PATH"); | |
| 576 | + | // Paths as the container sees them. | |
| 577 | + | let moved = |path: &Path| format!("{GITHUB_FILE_COMMANDS}/{}", path.file_name().map(|n| n.to_string_lossy().into_owned()).unwrap_or_default()); | |
| 578 | + | env.insert("GITHUB_OUTPUT".into(), moved(&files.output)); | |
| 579 | + | env.insert("GITHUB_ENV".into(), moved(&files.env)); | |
| 580 | + | env.insert("GITHUB_PATH".into(), moved(&files.path)); | |
| 581 | + | env.insert("GITHUB_STATE".into(), moved(&files.state)); | |
| 582 | + | env.insert("GITHUB_STEP_SUMMARY".into(), moved(&files.summary)); | |
| 583 | + | env.insert("GITHUB_WORKSPACE".into(), GITHUB_WORKSPACE.into()); | |
| 584 | + | env.insert("GITHUB_EVENT_PATH".into(), format!("{GITHUB_WORKFLOW}/event.json")); | |
| 585 | + | env.insert("HOME".into(), GITHUB_HOME.into()); | |
| 586 | + | env.remove("GITHUB_ACTION_PATH"); | |
| 587 | + | ||
| 588 | + | let mut args: Vec<String> = vec!["run".into(), "--rm".into(), "--label".into(), "g1t-job".into(), "--workdir".into(), GITHUB_WORKSPACE.into()]; | |
| 589 | + | if let Some(network) = &self.network { | |
| 590 | + | args.extend(["--network".into(), network.clone()]); | |
| 591 | + | } | |
| 592 | + | for (from, to) in [ | |
| 593 | + | (self.workspace.clone(), GITHUB_WORKSPACE), | |
| 594 | + | (home_dir, GITHUB_HOME), | |
| 595 | + | (workflow_dir, GITHUB_WORKFLOW), | |
| 596 | + | (commands_dir, GITHUB_FILE_COMMANDS), | |
| 597 | + | (PathBuf::from(crate::docker::engine::DOCKER_SOCKET), crate::docker::engine::DOCKER_SOCKET), | |
| 598 | + | ] { | |
| 599 | + | args.extend(["-v".into(), format!("{}:{to}", from.display())]); | |
| 600 | + | } | |
| 601 | + | for name in env.keys() { | |
| 602 | + | args.extend(["-e".into(), name.clone()]); | |
| 603 | + | } | |
| 604 | + | if let Some(entrypoint) = &run.entrypoint { | |
| 605 | + | args.extend(["--entrypoint".into(), entrypoint.clone()]); | |
| 606 | + | } | |
| 607 | + | args.push(run.image.clone()); | |
| 608 | + | args.extend(run.args.iter().cloned()); | |
| 609 | + | ||
| 610 | + | crate::abuse::touch(); | |
| 611 | + | if let Some(miner) = crate::abuse::miner_in(&shown(&args)) { | |
| 612 | + | self.log.line(&format!("##[error]g1t does not run cryptocurrency miners ({miner}). This step was not run.")); | |
| 613 | + | return fail; | |
| 614 | + | } | |
| 615 | + | self.log.line(&format!("[command]docker {}", shown(&args))); | |
| 616 | + | let mut command = Command::new("docker"); | |
| 617 | + | command.args(&args).env_clear().envs(self.docker_cli_env()).envs(&env); | |
| 618 | + | let mut commands = Commands::default(); | |
| 619 | + | let ended = process::run(command, self.remaining_time(), &mut self.log, &mut commands); | |
| 620 | + | self.log_docker_notes(); | |
| 621 | + | let ok = match ended { | |
| 622 | + | Ok(Ended::Exited(0)) => true, | |
| 623 | + | Ok(Ended::Exited(code)) => { | |
| 624 | + | self.log.line(&format!("##[error]Docker run failed with exit code {code}.")); | |
| 625 | + | false | |
| 626 | + | } | |
| 627 | + | Ok(Ended::TimedOut) => { | |
| 628 | + | self.log.line("##[error]The step ran past its time limit and was stopped."); | |
| 629 | + | false | |
| 630 | + | } | |
| 631 | + | Err(error) => { | |
| 632 | + | self.log.line(&format!("##[error]docker could not be started: {error}")); | |
| 633 | + | false | |
| 634 | + | } | |
| 635 | + | }; | |
| 636 | + | let (outputs, state) = self.absorb(&files, &commands); | |
| 637 | + | (ok, outputs, state) | |
| 638 | + | } | |
| 639 | + | ||
| 640 | + | /// Builds a Docker action's image from its Dockerfile, once per job. | |
| 641 | + | pub(crate) fn build_action_image(&mut self, dir: &Path, dockerfile: &str, tag_of: &str) -> Option<String> { | |
| 642 | + | let tag = format!("g1t-action/{}", docker_name(tag_of)); | |
| 643 | + | if self.built_actions.contains(&tag) { | |
| 644 | + | return Some(tag); | |
| 645 | + | } | |
| 646 | + | if !self.docker_ready() { | |
| 647 | + | return None; | |
| 648 | + | } | |
| 649 | + | let file = dir.join(dockerfile); | |
| 650 | + | let args = vec!["build".into(), "-t".into(), tag.clone(), "-f".into(), file.display().to_string(), dir.display().to_string()]; | |
| 651 | + | if !self.docker(&args, &BTreeMap::new(), Duration::from_secs(1800)) { | |
| 652 | + | self.log.line("##[error]The action's image did not build."); | |
| 653 | + | return None; | |
| 654 | + | } | |
| 655 | + | self.built_actions.insert(tag.clone()); | |
| 656 | + | Some(tag) | |
| 657 | + | } | |
| 658 | + | ||
| 659 | + | /// `docker/setup-buildx-action` on g1t's machines: the job's own | |
| 660 | + | /// Engine is the builder (BuildKit, the `docker` driver, with the | |
| 661 | + | /// containerd image store, so cache export and attestations work). | |
| 662 | + | pub(crate) fn setup_buildx(&mut self, with: &BTreeMap<String, String>) -> (bool, BTreeMap<String, String>) { | |
| 663 | + | if !self.docker_ready() { | |
| 664 | + | return (false, BTreeMap::new()); | |
| 665 | + | } | |
| 666 | + | if let Some(driver) = with.get("driver").filter(|d| !d.is_empty() && *d != "docker") { | |
| 667 | + | self.log.line(&format!("`driver: {driver}` is not used on g1t's machines: builds run on this job's own Docker Engine (BuildKit, the `docker` driver), which keeps the sandbox's network and guardrails.")); | |
| 668 | + | } | |
| 669 | + | for input in ["driver-opts", "buildkitd-flags", "buildkitd-config", "buildkitd-config-inline", "endpoint"] { | |
| 670 | + | if with.get(input).is_some_and(|v| !v.trim().is_empty()) { | |
| 671 | + | self.log.line(&format!("`{input}` is not used on g1t's machines.")); | |
| 672 | + | } | |
| 673 | + | } | |
| 674 | + | self.docker(&["buildx".into(), "version".into()], &BTreeMap::new(), Duration::from_secs(60)); | |
| 675 | + | let inspect = self.docker_output(&["buildx", "inspect", "default"]).unwrap_or_default(); | |
| 676 | + | let platforms = inspect | |
| 677 | + | .lines() | |
| 678 | + | .find_map(|line| line.trim().strip_prefix("Platforms:")) | |
| 679 | + | .map(|p| p.split(',').map(|s| s.trim().trim_end_matches('*').to_owned()).filter(|s| !s.is_empty()).collect::<Vec<_>>().join(",")) | |
| 680 | + | .unwrap_or_else(|| "linux/amd64".into()); | |
| 681 | + | self.log.line(&format!("Builder: default (this job's Docker Engine), platforms {platforms}")); | |
| 682 | + | let mut outputs = BTreeMap::new(); | |
| 683 | + | outputs.insert("name".into(), "default".into()); | |
| 684 | + | outputs.insert("driver".into(), "docker".into()); | |
| 685 | + | outputs.insert("platforms".into(), platforms.clone()); | |
| 686 | + | outputs.insert("endpoint".into(), "default".into()); | |
| 687 | + | outputs.insert("status".into(), "running".into()); | |
| 688 | + | outputs.insert("flags".into(), String::new()); | |
| 689 | + | outputs.insert( | |
| 690 | + | "nodes".into(), | |
| 691 | + | json!([{ "name": "default", "endpoint": "default", "status": "running", "platforms": platforms }]).to_string(), | |
| 692 | + | ); | |
| 693 | + | (true, outputs) | |
| 694 | + | } | |
| 695 | + | } | |
| 696 | + | ||
| 697 | + | /// The runner's Node, its real file (not a link), to mount into a job | |
| 698 | + | /// container. | |
| 699 | + | fn which_node() -> Option<PathBuf> { | |
| 700 | + | let path = std::env::var_os("PATH")?; | |
| 701 | + | std::env::split_paths(&path).map(|dir| dir.join("node")).find(|p| p.is_file()).and_then(|p| std::fs::canonicalize(p).ok()) | |
| 702 | + | } | |
| 703 | + | ||
| 704 | + | /// The job context's `container` and `services`, merged with its status. | |
| 705 | + | pub(crate) fn job_context(status: &str, extra: &Map<String, Value>) -> Value { | |
| 706 | + | let mut job = Map::new(); | |
| 707 | + | job.insert("status".into(), json!(status)); | |
| 708 | + | for (key, value) in extra { | |
| 709 | + | job.insert(key.clone(), value.clone()); | |
| 710 | + | } | |
| 711 | + | Value::Object(job) | |
| 712 | + | } | |
| 713 | + | ||
| 714 | + | /// Names a set of tags already built in this job. | |
| 715 | + | pub(crate) type Built = BTreeSet<String>; | |
| 716 | + | ||
| 717 | + | #[cfg(test)] | |
| 718 | + | mod tests { | |
| 719 | + | use super::*; | |
| 720 | + | ||
| 721 | + | #[test] | |
| 722 | + | fn options_split_as_a_shell_would() { | |
| 723 | + | assert_eq!( | |
| 724 | + | split_words(r#"--health-cmd "pg_isready -U postgres" --health-interval 10s --health-retries=5"#), | |
| 725 | + | vec!["--health-cmd", "pg_isready -U postgres", "--health-interval", "10s", "--health-retries=5"] | |
| 726 | + | ); | |
| 727 | + | assert_eq!(split_words("--health-cmd 'redis-cli ping' --tmpfs /var/lib/x"), vec!["--health-cmd", "redis-cli ping", "--tmpfs", "/var/lib/x"]); | |
| 728 | + | assert_eq!(split_words(r#"a\ b "c\"d" ''"#), vec!["a b", "c\"d", ""]); | |
| 729 | + | assert!(split_words(" ").is_empty()); | |
| 730 | + | } | |
| 731 | + | ||
| 732 | + | #[test] | |
| 733 | + | fn services_are_read_from_either_form() { | |
| 734 | + | assert_eq!(container_spec(&json!("redis:7")).image, "redis:7"); | |
| 735 | + | let spec = container_spec(&json!({ | |
| 736 | + | "image": "postgres:17", | |
| 737 | + | "env": { "POSTGRES_PASSWORD": "secret", "POSTGRES_DB": "app" }, | |
| 738 | + | "ports": ["5432:5432", 6543], | |
| 739 | + | "volumes": ["/data:/var/lib/postgresql/data"], | |
| 740 | + | "options": "--health-cmd pg_isready --health-interval 10s", | |
| 741 | + | "credentials": { "username": "me", "password": "token" }, | |
| 742 | + | })); | |
| 743 | + | assert_eq!(spec.image, "postgres:17"); | |
| 744 | + | assert_eq!(spec.env["POSTGRES_DB"], "app"); | |
| 745 | + | assert_eq!(spec.ports, vec!["5432:5432", "6543"]); | |
| 746 | + | assert_eq!(spec.options, vec!["--health-cmd", "pg_isready", "--health-interval", "10s"]); | |
| 747 | + | assert_eq!(spec.credentials, Some(("me".into(), "token".into()))); | |
| 748 | + | // An empty image is a service the job leaves out, as on GitHub. | |
| 749 | + | assert_eq!(container_spec(&json!({ "image": "" })).image, ""); | |
| 750 | + | } | |
| 751 | + | ||
| 752 | + | #[test] | |
| 753 | + | fn ports_map_container_to_host() { | |
| 754 | + | let map = port_map(&["5432:5432".into(), "6543:5432/tcp".into(), "6379".into(), "127.0.0.1:8080:80".into(), ":9000".into()]); | |
| 755 | + | assert_eq!(map["5432"], "6543"); | |
| 756 | + | assert_eq!(map["6379"], "6379"); | |
| 757 | + | assert_eq!(map["80"], "8080"); | |
| 758 | + | assert_eq!(map["9000"], "9000"); | |
| 759 | + | } | |
| 760 | + | ||
| 761 | + | #[test] | |
| 762 | + | fn credentials_sign_in_to_the_images_registry() { | |
| 763 | + | assert_eq!(registry_of("ghcr.io/acme/db:1"), "ghcr.io"); | |
| 764 | + | assert_eq!(registry_of("g1t.sh/acme/web"), "g1t.sh"); | |
| 765 | + | assert_eq!(registry_of("localhost:5000/x"), "localhost:5000"); | |
| 766 | + | assert_eq!(registry_of("acme/private"), "https://index.docker.io/v1/"); | |
| 767 | + | assert_eq!(registry_of("postgres"), "https://index.docker.io/v1/"); | |
| 768 | + | } | |
| 769 | + | ||
| 770 | + | #[test] | |
| 771 | + | fn a_service_is_created_with_its_values_passed_by_name() { | |
| 772 | + | let spec = container_spec(&json!({ | |
| 773 | + | "image": "postgres:17", | |
| 774 | + | "env": { "POSTGRES_PASSWORD": "secret" }, | |
| 775 | + | "ports": ["5432:5432"], | |
| 776 | + | "options": "--health-cmd pg_isready", | |
| 777 | + | })); | |
| 778 | + | let args = create_args("postgres_job", Some("g1t_job"), Some("postgres"), &spec, &[], false); | |
| 779 | + | assert_eq!( | |
| 780 | + | args, | |
| 781 | + | vec![ | |
| 782 | + | "create", "--name", "postgres_job", "--label", "g1t-job", "--network", "g1t_job", "--network-alias", "postgres", "-p", "5432:5432", "-e", | |
| 783 | + | "POSTGRES_PASSWORD", "--health-cmd", "pg_isready", "postgres:17" | |
| 784 | + | ] | |
| 785 | + | ); | |
| 786 | + | assert!(!args.iter().any(|a| a.contains("secret"))); | |
| 787 | + | let job = create_args("job_x", Some("g1t_job"), None, &container_spec(&json!("node:24")), &[("/home/runner/work".into(), "/home/runner/work".into())], true); | |
| 788 | + | assert!(job.ends_with(&["--entrypoint".into(), "tail".into(), "node:24".into(), "-f".into(), "/dev/null".into()])); | |
| 789 | + | assert!(job.contains(&"/home/runner/work:/home/runner/work".to_owned())); | |
| 790 | + | } | |
| 791 | + | ||
| 792 | + | #[test] | |
| 793 | + | fn names_are_docker_names() { | |
| 794 | + | assert_eq!(docker_name("Build & test_1a2b"), "build___test_1a2b"); | |
| 795 | + | assert_eq!(docker_name("docker/login-action@v3"), "docker_login-action_v3"); | |
| 796 | + | } | |
| 797 | + | ||
| 798 | + | #[test] | |
| 799 | + | fn the_job_context_carries_containers() { | |
| 800 | + | let mut extra = Map::new(); | |
| 801 | + | extra.insert("services".into(), json!({ "db": { "ports": { "5432": "5432" } } })); | |
| 802 | + | let job = job_context("success", &extra); | |
| 803 | + | assert_eq!(job["status"], "success"); | |
| 804 | + | assert_eq!(job["services"]["db"]["ports"]["5432"], "5432"); | |
| 805 | + | } | |
| 806 | + | } |
| 9 | 9 | //! job's definition, its contexts and its secrets, is fetched with them. | |
| 10 | 10 | ||
| 11 | 11 | mod blobs; | |
| 12 | + | mod containers; | |
| 12 | 13 | mod files; | |
| 13 | 14 | mod glob; | |
| 14 | 15 | mod paths; | |
| 17 | 18 | mod uses; | |
| 18 | 19 | mod zip; | |
| 19 | 20 | ||
| 20 | − | use std::collections::BTreeMap; | |
| 21 | + | use std::collections::{BTreeMap, BTreeSet}; | |
| 21 | 22 | use std::path::{Path, PathBuf}; | |
| 22 | 23 | use std::process::Command; | |
| 23 | 24 | use std::time::{Duration, Instant}; | |
| 58 | 59 | pub(crate) enum PostRun { | |
| 59 | 60 | Node { action_dir: PathBuf, script: String }, | |
| 60 | 61 | CacheSave { key: String, paths: Vec<String> }, | |
| 62 | + | /// A Docker action's `post-entrypoint`. | |
| 63 | + | Docker(containers::DockerRun), | |
| 61 | 64 | } | |
| 62 | 65 | ||
| 63 | 66 | pub(crate) struct Job { | |
| 83 | 86 | /// What the last Node process left, for the step that ran it. | |
| 84 | 87 | pub(crate) last_node_outputs: BTreeMap<String, String>, | |
| 85 | 88 | pub(crate) last_node_state: BTreeMap<String, String>, | |
| 89 | + | /// The names of the sandbox's own variables, which a container does | |
| 90 | + | /// not get. | |
| 91 | + | host_env: BTreeSet<String>, | |
| 92 | + | /// Whether this job has a Docker Engine of its own (g1t's machines). | |
| 93 | + | pub(crate) docker_hosted: bool, | |
| 94 | + | /// The job's network, once its containers have one. | |
| 95 | + | pub(crate) network: Option<String>, | |
| 96 | + | /// `services:`, by their names, and their containers' names. | |
| 97 | + | pub(crate) services: Vec<(String, String)>, | |
| 98 | + | /// `container:`, once started. | |
| 99 | + | pub(crate) container: Option<containers::JobContainer>, | |
| 100 | + | /// The `job` context's `container` and `services`. | |
| 101 | + | pub(crate) job_context: Map<String, Value>, | |
| 102 | + | /// Docker actions' images built in this job. | |
| 103 | + | pub(crate) built_actions: containers::Built, | |
| 86 | 104 | } | |
| 87 | 105 | ||
| 88 | 106 | fn text_map(value: Option<&Value>) -> BTreeMap<String, String> { | |
| 107 | 125 | self.base_env.get(name).cloned() | |
| 108 | 126 | } | |
| 109 | 127 | ||
| 128 | + | /// What earlier steps added to `PATH`, newest first. | |
| 129 | + | pub(crate) fn path_prepend_entries(&self) -> &[String] { | |
| 130 | + | &self.path_prepend | |
| 131 | + | } | |
| 132 | + | ||
| 110 | 133 | fn status(&self) -> Status { | |
| 111 | 134 | if self.failed { Status::Failure } else { Status::Success } | |
| 112 | 135 | } | |
| 116 | 139 | let mut contexts = self.contexts.clone(); | |
| 117 | 140 | contexts.insert("env".into(), Value::Object(env.iter().map(|(k, v)| (k.clone(), Value::String(v.clone()))).collect())); | |
| 118 | 141 | contexts.insert("steps".into(), Value::Object(frame.steps.clone())); | |
| 119 | − | contexts.insert("job".into(), json!({ "status": if self.failed { "failure" } else { "success" } })); | |
| 142 | + | contexts.insert("job".into(), containers::job_context(if self.failed { "failure" } else { "success" }, &self.job_context)); | |
| 120 | 143 | if let Some(inputs) = &frame.inputs { | |
| 121 | 144 | contexts.insert("inputs".into(), inputs.clone()); | |
| 122 | 145 | } | |
| 142 | 165 | ||
| 143 | 166 | /// The `env` context for a step: the workflow's, the job's, what earlier | |
| 144 | 167 | /// steps wrote to `GITHUB_ENV`, and the frame's. | |
| 145 | − | fn env_context(&self, frame: &Frame) -> BTreeMap<String, String> { | |
| 168 | + | pub(crate) fn env_context(&self, frame: &Frame) -> BTreeMap<String, String> { | |
| 146 | 169 | let mut env = self.added_env.clone(); | |
| 147 | 170 | env.extend(self.workflow_env.clone()); | |
| 148 | 171 | env.extend(self.job_env.clone()); | |
| 230 | 253 | let (program, args, extension): (String, Vec<String>, &str) = match shell { | |
| 231 | 254 | // A self-hosted Windows runner, as GitHub's: PowerShell. | |
| 232 | 255 | None if cfg!(windows) => (windows_powershell(), powershell_args(), "ps1"), | |
| 256 | + | // A job container without bash, as GitHub's runner does. | |
| 257 | + | None if self.container.as_ref().is_some_and(|c| c.shell == "sh") => ("sh".into(), vec!["-e".into(), "{0}".into()], "sh"), | |
| 233 | 258 | None => ("bash".into(), vec!["-e".into(), "{0}".into()], "sh"), | |
| 234 | 259 | Some("bash") => ("bash".into(), vec!["--noprofile".into(), "--norc".into(), "-eo".into(), "pipefail".into(), "{0}".into()], "sh"), | |
| 235 | 260 | Some("sh") => ("sh".into(), vec!["-e".into(), "{0}".into()], "sh"), | |
| 276 | 301 | Some(dir) => self.workspace.join(dir), | |
| 277 | 302 | None => self.workspace.clone(), | |
| 278 | 303 | }; | |
| 279 | − | let mut command = Command::new(&program); | |
| 280 | − | command.args(&args).current_dir(&dir).env_clear().envs(self.process_env(env, &files)); | |
| 304 | + | let full = self.process_env(env, &files); | |
| 305 | + | let in_container = self.container.as_ref().map(|c| c.path.clone()).and_then(|image_path| { | |
| 306 | + | let inside = self.container_env(env, full.clone(), &image_path); | |
| 307 | + | self.in_container(&program, &args, &dir, inside) | |
| 308 | + | }); | |
| 309 | + | let command = match in_container { | |
| 310 | + | Some(command) => command, | |
| 311 | + | None => { | |
| 312 | + | let mut command = Command::new(&program); | |
| 313 | + | command.args(&args).current_dir(&dir).env_clear().envs(full); | |
| 314 | + | command | |
| 315 | + | } | |
| 316 | + | }; | |
| 281 | 317 | let mut commands = Commands { | |
| 282 | 318 | debug: self.debug, | |
| 283 | 319 | ..Commands::default() | |
| 483 | 519 | std::fs::create_dir_all(&temp).context("could not make the temporary folder")?; | |
| 484 | 520 | std::fs::write(temp.join("event.json"), serde_json::to_string_pretty(&spec["event"])?)?; | |
| 485 | 521 | ||
| 522 | + | let host_env: BTreeSet<String> = std::env::vars().map(|(name, _)| name).collect(); | |
| 523 | + | // Docker of the job's own, on g1t's machines (crate::docker). | |
| 524 | + | let docker_hosted = cfg!(target_os = "linux") | |
| 525 | + | && std::env::var("G1T_DOCKER").as_deref() == Ok("on") | |
| 526 | + | && spec["variables"]["RUNNER_ENVIRONMENT"].as_str() != Some("self-hosted"); | |
| 486 | 527 | // This process's environment, less what only it should see. | |
| 487 | 528 | let mut base_env: BTreeMap<String, String> = | |
| 488 | 529 | std::env::vars().filter(|(name, _)| !matches!(name.as_str(), "ACTIONS_TOKEN" | "ACTIONS_JOB" | "MODE") && !name.starts_with("G1T_")).collect(); | |
| 522 | 563 | debug, | |
| 523 | 564 | last_node_outputs: BTreeMap::new(), | |
| 524 | 565 | last_node_state: BTreeMap::new(), | |
| 566 | + | host_env, | |
| 567 | + | docker_hosted, | |
| 568 | + | network: None, | |
| 569 | + | services: Vec::new(), | |
| 570 | + | container: None, | |
| 571 | + | job_context: Map::new(), | |
| 572 | + | built_actions: containers::Built::new(), | |
| 525 | 573 | }; | |
| 526 | 574 | ||
| 527 | 575 | // The workflow's env reads github, secrets, inputs and vars; the job's | |
| 577 | 625 | { | |
| 578 | 626 | job.log.line(&format!("Matrix: {}", serde_json::to_string(matrix).unwrap_or_default())); | |
| 579 | 627 | } | |
| 628 | + | if job.docker_hosted { | |
| 629 | + | let registry = job.contexts["github"]["server_url"].as_str().and_then(crate::docker::engine::registry_host); | |
| 630 | + | let token = job.contexts.get("secrets").and_then(|s| s.get("G1T_TOKEN")).map(expr::to_text).filter(|t| !t.is_empty()); | |
| 631 | + | let options = crate::docker::engine::Options { registry: registry.zip(token) }; | |
| 632 | + | if let Err(problem) = crate::docker::engine::enable(options) { | |
| 633 | + | job.log.line(&format!("##[warning]Docker is not available in this job: {problem}")); | |
| 634 | + | job.docker_hosted = false; | |
| 635 | + | } | |
| 636 | + | } | |
| 637 | + | let containers_started = job.start_containers(); | |
| 580 | 638 | job.log.flush(); | |
| 581 | 639 | ||
| 582 | 640 | let mut frame = Frame::default(); | |
| 583 | − | for (index, step) in steps.iter().enumerate() { | |
| 641 | + | if !containers_started { | |
| 642 | + | job.failed = true; | |
| 643 | + | for (index, name) in job.step_names.clone().iter().enumerate() { | |
| 644 | + | job.log.step_state(index as u32 + 1, name, "completed", Some("skipped")); | |
| 645 | + | } | |
| 646 | + | } | |
| 647 | + | for (index, step) in steps.iter().enumerate().filter(|_| containers_started) { | |
| 584 | 648 | job.step(&mut frame, step, index as u32 + 1, true, &defaults); | |
| 649 | + | job.log_docker_notes(); | |
| 585 | 650 | if job.remaining().is_zero() { | |
| 586 | 651 | job.log.line("##[error]The job ran past its time limit."); | |
| 587 | 652 | job.failed = true; | |
| 606 | 671 | let ok = match &post.run { | |
| 607 | 672 | PostRun::Node { action_dir, script } => job.run_node(action_dir, script, &post.env), | |
| 608 | 673 | PostRun::CacheSave { key, paths } => job.cache_save(key, paths), | |
| 674 | + | PostRun::Docker(run) => job.run_docker(run).0, | |
| 609 | 675 | }; | |
| 610 | 676 | job.log.step_state(number, &post.name, "completed", Some(if ok { "success" } else { "failure" })); | |
| 611 | 677 | if !ok { | |
| 613 | 679 | } | |
| 614 | 680 | } | |
| 615 | 681 | ||
| 682 | + | // GitHub's "Stop containers": services' logs, and everything removed. | |
| 683 | + | if job.has_containers() { | |
| 684 | + | let number = job.step_names.len() as u32 + 1; | |
| 685 | + | job.step_names.push("Stop containers".into()); | |
| 686 | + | job.report_steps(); | |
| 687 | + | job.log.step(number); | |
| 688 | + | job.log.step_state(number, "Stop containers", "in_progress", None); | |
| 689 | + | job.stop_containers(); | |
| 690 | + | job.log.step_state(number, "Stop containers", "completed", Some("success")); | |
| 691 | + | } | |
| 692 | + | ||
| 616 | 693 | // The job's outputs, read now that every step has run. | |
| 617 | 694 | let env = job.env_context(&frame); | |
| 618 | 695 | let contexts = job.contexts_for(&frame, &env); |
| 165 | 165 | log.line(&shown); | |
| 166 | 166 | } | |
| 167 | 167 | } | |
| 168 | − | Err(mpsc::RecvTimeoutError::Timeout) => log.tick(), | |
| 168 | + | Err(mpsc::RecvTimeoutError::Timeout) => { | |
| 169 | + | // The job's Docker Engine starting, from its own thread. | |
| 170 | + | for note in crate::docker::take_notes() { | |
| 171 | + | log.line(¬e); | |
| 172 | + | } | |
| 173 | + | log.tick(); | |
| 174 | + | } | |
| 169 | 175 | Err(mpsc::RecvTimeoutError::Disconnected) => break, | |
| 170 | 176 | } | |
| 171 | 177 | if Instant::now() >= deadline { |
| 1 | 1 | //! `uses:` steps: `actions/checkout` done natively against g1t, actions | |
| 2 | − | //! fetched from GitHub and run as they are (JavaScript and composite), and | |
| 3 | − | //! a few of GitHub's own whose services g1t does not have yet. | |
| 2 | + | //! fetched from GitHub and run as they are (JavaScript, composite and | |
| 3 | + | //! Docker), `docker://` images, and a few of GitHub's own whose services | |
| 4 | + | //! g1t does not have yet. | |
| 4 | 5 | ||
| 5 | 6 | use std::collections::BTreeMap; | |
| 6 | 7 | use std::path::{Path, PathBuf}; | |
| 13 | 14 | use g1t_actions::workflow::yaml_to_json; | |
| 14 | 15 | use serde_json::{Map, Value, json}; | |
| 15 | 16 | ||
| 17 | + | use super::containers::{self, DockerRun}; | |
| 16 | 18 | use super::files::StepFiles; | |
| 17 | 19 | use super::process::{self, Commands, Ended}; | |
| 18 | 20 | use super::{Frame, Job, Post, PostRun}; | |
| 201 | 203 | pub(crate) fn run_node(&mut self, action_dir: &Path, script: &str, env: &BTreeMap<String, String>) -> bool { | |
| 202 | 204 | let id = format!("node{}", super::rand_id()); | |
| 203 | 205 | let Ok(files) = StepFiles::new(&self.temp, &id) else { return false }; | |
| 204 | − | let mut command = Command::new("node"); | |
| 205 | − | command.arg(action_dir.join(script)).current_dir(&self.workspace).env_clear().envs(self.process_env(env, &files)); | |
| 206 | + | let full = self.process_env(env, &files); | |
| 207 | + | let script_path = action_dir.join(script); | |
| 208 | + | // In a job container whose image runs the runner's Node, the action | |
| 209 | + | // runs there, as on GitHub. | |
| 210 | + | let in_container = self.container.as_ref().filter(|c| c.node).map(|c| c.path.clone()).and_then(|image_path| { | |
| 211 | + | let inside = self.container_env(env, full.clone(), &image_path); | |
| 212 | + | self.in_container(containers::CONTAINER_NODE, &[script_path.display().to_string()], &self.workspace, inside) | |
| 213 | + | }); | |
| 214 | + | let command = match in_container { | |
| 215 | + | Some(command) => command, | |
| 216 | + | None => { | |
| 217 | + | let mut command = Command::new("node"); | |
| 218 | + | command.arg(&script_path).current_dir(&self.workspace).env_clear().envs(full); | |
| 219 | + | command | |
| 220 | + | } | |
| 221 | + | }; | |
| 206 | 222 | let mut commands = Commands { | |
| 207 | 223 | debug: false, | |
| 208 | 224 | ..Commands::default() | |
| 237 | 253 | _timeout: Duration, | |
| 238 | 254 | ) -> (bool, BTreeMap<String, String>) { | |
| 239 | 255 | let uses = uses.trim(); | |
| 240 | − | if uses.starts_with("docker://") { | |
| 241 | − | self.log.line(&format!("##[error]`{uses}`: Docker actions do not run on g1t yet.")); | |
| 242 | − | return (false, BTreeMap::new()); | |
| 256 | + | if let Some(image) = uses.strip_prefix("docker://") { | |
| 257 | + | // `with.args` and `with.entrypoint` are the container's; every | |
| 258 | + | // input is also an `INPUT_` variable, as on GitHub. | |
| 259 | + | let mut step_env = env.clone(); | |
| 260 | + | for (input, value) in with { | |
| 261 | + | step_env.insert(format!("INPUT_{}", input.replace(' ', "_").to_ascii_uppercase()), value.clone()); | |
| 262 | + | } | |
| 263 | + | let run = DockerRun { | |
| 264 | + | image: image.to_owned(), | |
| 265 | + | entrypoint: with.get("entrypoint").filter(|e| !e.is_empty()).cloned(), | |
| 266 | + | args: with.get("args").map(|a| containers::split_words(a)).unwrap_or_default(), | |
| 267 | + | env: step_env, | |
| 268 | + | }; | |
| 269 | + | let (ok, outputs, _) = self.run_docker(&run); | |
| 270 | + | return (ok, outputs); | |
| 243 | 271 | } | |
| 244 | 272 | let (name, git_ref) = uses.split_once('@').unwrap_or((uses, "")); | |
| 245 | 273 | let lower = name.to_ascii_lowercase(); | |
| 274 | + | if lower == "docker/setup-buildx-action" && self.docker_hosted { | |
| 275 | + | return self.setup_buildx(with); | |
| 276 | + | } | |
| 246 | 277 | match lower.as_str() { | |
| 247 | 278 | "actions/checkout" => return self.checkout(with), | |
| 248 | 279 | "actions/upload-artifact" => return self.upload_artifact(with), | |
| 397 | 428 | return (ok, outputs); | |
| 398 | 429 | } | |
| 399 | 430 | if using == "docker" { | |
| 400 | − | self.log.line(&format!("##[error]`{uses}` is a Docker action, which does not run on g1t yet.")); | |
| 401 | − | return (false, BTreeMap::new()); | |
| 431 | + | return self.docker_action(uses, &dir, &runs, &inputs, &step_env, frame, title); | |
| 402 | 432 | } | |
| 403 | 433 | self.log.line(&format!("##[error]`{uses}` runs with `{using}`, which g1t does not know.")); | |
| 404 | 434 | (false, BTreeMap::new()) | |
| 405 | 435 | } | |
| 436 | + | ||
| 437 | + | /// A Docker action: its image built from its Dockerfile (or pulled, | |
| 438 | + | /// for `docker://`), then run with its `args`, `entrypoint` and `env`, | |
| 439 | + | /// its inputs as `INPUT_` variables, and `pre-entrypoint` and | |
| 440 | + | /// `post-entrypoint` around it. | |
| 441 | + | #[allow(clippy::too_many_arguments)] | |
| 442 | + | fn docker_action( | |
| 443 | + | &mut self, | |
| 444 | + | uses: &str, | |
| 445 | + | dir: &Path, | |
| 446 | + | runs: &Value, | |
| 447 | + | inputs: &BTreeMap<String, String>, | |
| 448 | + | step_env: &BTreeMap<String, String>, | |
| 449 | + | frame: &Frame, | |
| 450 | + | title: &str, | |
| 451 | + | ) -> (bool, BTreeMap<String, String>) { | |
| 452 | + | let image = runs.get("image").map(expr::to_text).unwrap_or_default(); | |
| 453 | + | let image = if let Some(pulled) = image.strip_prefix("docker://") { | |
| 454 | + | pulled.to_owned() | |
| 455 | + | } else if image.is_empty() { | |
| 456 | + | self.log.line(&format!("##[error]`{uses}` has no `runs.image`.")); | |
| 457 | + | return (false, BTreeMap::new()); | |
| 458 | + | } else { | |
| 459 | + | match self.build_action_image(dir, &image, uses) { | |
| 460 | + | Some(tag) => tag, | |
| 461 | + | None => return (false, BTreeMap::new()), | |
| 462 | + | } | |
| 463 | + | }; | |
| 464 | + | // `args` and `env` read with the action's own inputs. | |
| 465 | + | let mut env = step_env.clone(); | |
| 466 | + | for (input, value) in inputs { | |
| 467 | + | env.insert(format!("INPUT_{}", input.replace(' ', "_").to_ascii_uppercase()), value.clone()); | |
| 468 | + | } | |
| 469 | + | let mut scope_frame = frame.clone(); | |
| 470 | + | scope_frame.inputs = Some(Value::Object(inputs.iter().map(|(k, v)| (k.clone(), json!(v))).collect())); | |
| 471 | + | let contexts = self.contexts_for(&scope_frame, &env); | |
| 472 | + | if let Some(Value::Object(own)) = runs.get("env") { | |
| 473 | + | for (name, value) in own { | |
| 474 | + | let value = self.with_scope(&contexts, |scope| expr::interpolate_value(value, scope)).unwrap_or(Value::Null); | |
| 475 | + | env.insert(name.clone(), expr::to_text(&value)); | |
| 476 | + | } | |
| 477 | + | } | |
| 478 | + | let args: Vec<String> = match runs.get("args") { | |
| 479 | + | Some(Value::Array(items)) => items | |
| 480 | + | .iter() | |
| 481 | + | .map(|item| { | |
| 482 | + | let value = self.with_scope(&contexts, |scope| expr::interpolate_value(item, scope)).unwrap_or(Value::Null); | |
| 483 | + | expr::to_text(&value) | |
| 484 | + | }) | |
| 485 | + | .collect(), | |
| 486 | + | _ => Vec::new(), | |
| 487 | + | }; | |
| 488 | + | let entry = |key: &str| runs.get(key).map(expr::to_text).filter(|e| !e.is_empty()); | |
| 489 | + | if let Some(pre) = entry("pre-entrypoint") { | |
| 490 | + | let run = DockerRun { image: image.clone(), entrypoint: Some(pre), args: Vec::new(), env: env.clone() }; | |
| 491 | + | if !self.run_docker(&run).0 { | |
| 492 | + | return (false, BTreeMap::new()); | |
| 493 | + | } | |
| 494 | + | } | |
| 495 | + | let run = DockerRun { image: image.clone(), entrypoint: entry("entrypoint"), args, env: env.clone() }; | |
| 496 | + | let (ok, outputs, state) = self.run_docker(&run); | |
| 497 | + | if let Some(post) = entry("post-entrypoint") { | |
| 498 | + | let mut post_env = env; | |
| 499 | + | for (name, value) in state { | |
| 500 | + | post_env.insert(format!("STATE_{name}"), value); | |
| 501 | + | } | |
| 502 | + | self.posts.push(Post { | |
| 503 | + | name: format!("Post {title}"), | |
| 504 | + | condition: runs.get("post-if").map(expr::to_text).unwrap_or_else(|| "always()".into()), | |
| 505 | + | env: BTreeMap::new(), | |
| 506 | + | run: PostRun::Docker(DockerRun { image, entrypoint: Some(post), args: Vec::new(), env: post_env }), | |
| 507 | + | }); | |
| 508 | + | } | |
| 509 | + | (ok, outputs) | |
| 510 | + | } | |
| 406 | 511 | } |
| 1 | + | //! The Engine API as a job sees it: `/var/run/docker.sock` is this proxy, | |
| 2 | + | //! and the job's own Docker Engine is behind it. It passes everything | |
| 3 | + | //! through, and changes the few requests that would not work in a | |
| 4 | + | //! sandbox as they are: | |
| 5 | + | //! | |
| 6 | + | //! - **A container's network.** A sandbox cannot route a container's | |
| 7 | + | //! bridge network out (Cloudflare Containers allow no iptables and no IP | |
| 8 | + | //! forwarding), so containers join the job's own network (`host`), the | |
| 9 | + | //! one its guardrails apply to. The names a container would have had on | |
| 10 | + | //! its network (its name, its aliases, its Compose service) resolve to | |
| 11 | + | //! 127.0.0.1 in later containers and in the job's own steps, and a port | |
| 12 | + | //! published under another number (`-p 8080:80`) is forwarded to it. | |
| 13 | + | //! `docker inspect` reports the ports as published, for tools that look | |
| 14 | + | //! a container's port up. | |
| 15 | + | //! - **Networks joined later** (`docker network connect`): the container | |
| 16 | + | //! already has the job's network, so the request only adds its aliases. | |
| 17 | + | //! - **The classic builder** (`DOCKER_BUILDKIT=0`): its `RUN` steps use the | |
| 18 | + | //! job's network too. BuildKit's are handled where they start (oci.rs). | |
| 19 | + | //! - **Miners**: a container whose image or command names one is refused, | |
| 20 | + | //! as a step's script would be. | |
| 21 | + | ||
| 22 | + | use std::collections::{BTreeMap, BTreeSet}; | |
| 23 | + | use std::io::{self, BufReader, Read, Write}; | |
| 24 | + | use std::sync::mpsc; | |
| 25 | + | use std::sync::{Arc, Mutex}; | |
| 26 | + | ||
| 27 | + | use serde_json::{Map, Value, json}; | |
| 28 | + | ||
| 29 | + | use super::http; | |
| 30 | + | ||
| 31 | + | /// What the proxy remembers across connections. | |
| 32 | + | #[derive(Default)] | |
| 33 | + | pub(crate) struct State { | |
| 34 | + | /// Every name a container has been given, which all now mean 127.0.0.1. | |
| 35 | + | pub(crate) aliases: BTreeSet<String>, | |
| 36 | + | /// Containers moved to the job's network: by id and by name, the ports | |
| 37 | + | /// they publish (`80/tcp` → the host port, as text). | |
| 38 | + | pub(crate) published: BTreeMap<String, BTreeMap<String, String>>, | |
| 39 | + | } | |
| 40 | + | ||
| 41 | + | /// What the proxy asks of the sandbox around it. | |
| 42 | + | pub(crate) trait Host: Send + Sync { | |
| 43 | + | /// Makes names resolve to 127.0.0.1 in the job's own steps. | |
| 44 | + | fn add_hosts(&self, names: &[String]); | |
| 45 | + | /// Forwards a host port to a container's port on the job's network. | |
| 46 | + | fn forward(&self, host_port: u16, container_port: u16); | |
| 47 | + | /// Makes sure the Engine is running; why not, if it cannot be. | |
| 48 | + | fn ensure_engine(&self) -> Result<(), String>; | |
| 49 | + | /// Connects to the Engine itself. | |
| 50 | + | fn connect(&self) -> io::Result<Box<dyn Duplex>>; | |
| 51 | + | } | |
| 52 | + | ||
| 53 | + | /// A connection, readable and writable from two threads. | |
| 54 | + | pub(crate) trait Duplex: Read + Write + Send { | |
| 55 | + | fn try_clone_box(&self) -> io::Result<Box<dyn Duplex>>; | |
| 56 | + | fn shutdown_both(&self); | |
| 57 | + | /// Ends what this side sends, and goes on reading. | |
| 58 | + | fn shutdown_write(&self); | |
| 59 | + | } | |
| 60 | + | ||
| 61 | + | impl Duplex for std::net::TcpStream { | |
| 62 | + | fn try_clone_box(&self) -> io::Result<Box<dyn Duplex>> { | |
| 63 | + | Ok(Box::new(self.try_clone()?)) | |
| 64 | + | } | |
| 65 | + | fn shutdown_both(&self) { | |
| 66 | + | let _ = self.shutdown(std::net::Shutdown::Both); | |
| 67 | + | } | |
| 68 | + | fn shutdown_write(&self) { | |
| 69 | + | let _ = self.shutdown(std::net::Shutdown::Write); | |
| 70 | + | } | |
| 71 | + | } | |
| 72 | + | ||
| 73 | + | #[cfg(unix)] | |
| 74 | + | impl Duplex for std::os::unix::net::UnixStream { | |
| 75 | + | fn try_clone_box(&self) -> io::Result<Box<dyn Duplex>> { | |
| 76 | + | Ok(Box::new(self.try_clone()?)) | |
| 77 | + | } | |
| 78 | + | fn shutdown_both(&self) { | |
| 79 | + | let _ = self.shutdown(std::net::Shutdown::Both); | |
| 80 | + | } | |
| 81 | + | fn shutdown_write(&self) { | |
| 82 | + | let _ = self.shutdown(std::net::Shutdown::Write); | |
| 83 | + | } | |
| 84 | + | } | |
| 85 | + | ||
| 86 | + | /// The Engine's routes start with an optional `/v1.NN`. | |
| 87 | + | fn route(target: &str) -> (&str, &str) { | |
| 88 | + | let (path, query) = target.split_once('?').unwrap_or((target, "")); | |
| 89 | + | let path = match path.strip_prefix("/v") { | |
| 90 | + | Some(rest) if rest.starts_with(|c: char| c.is_ascii_digit()) => rest.find('/').map_or(path, |slash| &rest[slash..]), | |
| 91 | + | _ => path, | |
| 92 | + | }; | |
| 93 | + | (path, query) | |
| 94 | + | } | |
| 95 | + | ||
| 96 | + | fn query_value<'a>(query: &'a str, name: &str) -> Option<&'a str> { | |
| 97 | + | query.split('&').find_map(|pair| pair.split_once('=').filter(|(key, _)| *key == name).map(|(_, value)| value)) | |
| 98 | + | } | |
| 99 | + | ||
| 100 | + | fn decode(text: &str) -> String { | |
| 101 | + | let bytes = text.as_bytes(); | |
| 102 | + | let mut out = Vec::with_capacity(bytes.len()); | |
| 103 | + | let mut i = 0; | |
| 104 | + | while i < bytes.len() { | |
| 105 | + | let hex = |b: u8| (b as char).to_digit(16); | |
| 106 | + | match bytes[i] { | |
| 107 | + | b'%' if i + 2 < bytes.len() && hex(bytes[i + 1]).is_some() && hex(bytes[i + 2]).is_some() => { | |
| 108 | + | out.push((hex(bytes[i + 1]).unwrap_or(0) * 16 + hex(bytes[i + 2]).unwrap_or(0)) as u8); | |
| 109 | + | i += 3; | |
| 110 | + | continue; | |
| 111 | + | } | |
| 112 | + | b'+' => out.push(b' '), | |
| 113 | + | byte => out.push(byte), | |
| 114 | + | } | |
| 115 | + | i += 1; | |
| 116 | + | } | |
| 117 | + | String::from_utf8_lossy(&out).into_owned() | |
| 118 | + | } | |
| 119 | + | ||
| 120 | + | /// What a request is, to the proxy. | |
| 121 | + | #[derive(Debug, PartialEq)] | |
| 122 | + | pub(crate) enum Kind { | |
| 123 | + | CreateContainer { name: Option<String> }, | |
| 124 | + | InspectContainer { id: String }, | |
| 125 | + | ConnectNetwork, | |
| 126 | + | DisconnectNetwork, | |
| 127 | + | ClassicBuild, | |
| 128 | + | Other, | |
| 129 | + | } | |
| 130 | + | ||
| 131 | + | pub(crate) fn classify(method: &str, target: &str) -> Kind { | |
| 132 | + | let (path, query) = route(target); | |
| 133 | + | let parts: Vec<&str> = path.trim_matches('/').split('/').collect(); | |
| 134 | + | match (method, parts.as_slice()) { | |
| 135 | + | ("POST", ["containers", "create"]) => Kind::CreateContainer { name: query_value(query, "name").map(decode).filter(|n| !n.is_empty()) }, | |
| 136 | + | ("GET", ["containers", id, "json"]) => Kind::InspectContainer { id: decode(id) }, | |
| 137 | + | ("POST", ["networks", _, "connect"]) => Kind::ConnectNetwork, | |
| 138 | + | ("POST", ["networks", _, "disconnect"]) => Kind::DisconnectNetwork, | |
| 139 | + | ("POST", ["build"]) => Kind::ClassicBuild, | |
| 140 | + | _ => Kind::Other, | |
| 141 | + | } | |
| 142 | + | } | |
| 143 | + | ||
| 144 | + | /// Whether a network mode is one a sandbox can run as it is. | |
| 145 | + | fn keeps_network(mode: &str) -> bool { | |
| 146 | + | matches!(mode, "host" | "none") || mode.starts_with("container:") | |
| 147 | + | } | |
| 148 | + | ||
| 149 | + | /// Whether a name can stand in `/etc/hosts`. | |
| 150 | + | fn host_name(name: &str) -> bool { | |
| 151 | + | !name.is_empty() | |
| 152 | + | && name.len() <= 253 | |
| 153 | + | && name != "localhost" | |
| 154 | + | && name.chars().all(|c| c.is_ascii_alphanumeric() || matches!(c, '-' | '.' | '_')) | |
| 155 | + | && !name.starts_with(['-', '.']) | |
| 156 | + | } | |
| 157 | + | ||
| 158 | + | /// What changing a container's create request came to. | |
| 159 | + | #[derive(Debug, Default, PartialEq)] | |
| 160 | + | pub(crate) struct Created { | |
| 161 | + | /// The network it asked for, now the job's. | |
| 162 | + | pub(crate) moved_from: Option<String>, | |
| 163 | + | /// Its own names, now meaning 127.0.0.1. | |
| 164 | + | pub(crate) aliases: Vec<String>, | |
| 165 | + | /// `(host port, container port)` pairs to forward. | |
| 166 | + | pub(crate) forwards: Vec<(u16, u16)>, | |
| 167 | + | /// What `docker inspect` should say it publishes. | |
| 168 | + | pub(crate) published: BTreeMap<String, String>, | |
| 169 | + | } | |
| 170 | + | ||
| 171 | + | fn strings(value: Option<&Value>) -> Vec<String> { | |
| 172 | + | value.and_then(Value::as_array).map(|items| items.iter().filter_map(|v| v.as_str().map(str::to_owned)).collect()).unwrap_or_default() | |
| 173 | + | } | |
| 174 | + | ||
| 175 | + | /// Moves a container to the job's network, unless it asked for `host`, | |
| 176 | + | /// `none` or another container's. `known` is every name given so far. | |
| 177 | + | pub(crate) fn rewrite_create(body: &mut Value, name: Option<&str>, known: &BTreeSet<String>) -> Created { | |
| 178 | + | let mut created = Created::default(); | |
| 179 | + | let Some(config) = body.as_object_mut() else { return created }; | |
| 180 | + | let host_config = config.entry("HostConfig").or_insert_with(|| json!({})); | |
| 181 | + | if !host_config.is_object() { | |
| 182 | + | *host_config = json!({}); | |
| 183 | + | } | |
| 184 | + | let mode = host_config.get("NetworkMode").and_then(Value::as_str).unwrap_or_default().to_owned(); | |
| 185 | + | if keeps_network(&mode) { | |
| 186 | + | return created; | |
| 187 | + | } | |
| 188 | + | created.moved_from = Some(if mode.is_empty() || mode == "default" { "bridge".to_owned() } else { mode.clone() }); | |
| 189 | + | ||
| 190 | + | // Its names: its own, its aliases on each network, its links' aliases | |
| 191 | + | // and its Compose service. | |
| 192 | + | let mut aliases: Vec<String> = Vec::new(); | |
| 193 | + | if let Some(name) = name { | |
| 194 | + | aliases.push(name.trim_start_matches('/').to_owned()); | |
| 195 | + | } | |
| 196 | + | if let Some(Value::Object(endpoints)) = config.get("NetworkingConfig").and_then(|n| n.get("EndpointsConfig")) { | |
| 197 | + | for endpoint in endpoints.values() { | |
| 198 | + | aliases.extend(strings(endpoint.get("Aliases"))); | |
| 199 | + | aliases.extend(strings(endpoint.get("DNSNames"))); | |
| 200 | + | } | |
| 201 | + | } | |
| 202 | + | if let Some(service) = config.get("Labels").and_then(|l| l.get("com.docker.compose.service")).and_then(Value::as_str) { | |
| 203 | + | aliases.push(service.to_owned()); | |
| 204 | + | } | |
| 205 | + | let host_config = config.get_mut("HostConfig").and_then(Value::as_object_mut).expect("made above"); | |
| 206 | + | for link in strings(host_config.get("Links")) { | |
| 207 | + | // `name:alias`, or `/name:/container/alias` as the Engine stores them. | |
| 208 | + | if let Some((_, alias)) = link.split_once(':') { | |
| 209 | + | aliases.push(alias.rsplit('/').next().unwrap_or(alias).to_owned()); | |
| 210 | + | } | |
| 211 | + | } | |
| 212 | + | let mut seen = BTreeSet::new(); | |
| 213 | + | aliases.retain(|alias| host_name(alias) && seen.insert(alias.clone())); | |
| 214 | + | created.aliases = aliases.clone(); | |
| 215 | + | ||
| 216 | + | // Ports: published under the same number, they need nothing; under | |
| 217 | + | // another, a forward. A port left to the Engine to choose is the | |
| 218 | + | // container's own. | |
| 219 | + | if let Some(Value::Object(bindings)) = host_config.get("PortBindings") { | |
| 220 | + | for (port, hosts) in bindings { | |
| 221 | + | let (number, protocol) = port.split_once('/').unwrap_or((port, "tcp")); | |
| 222 | + | let Ok(container_port) = number.parse::<u16>() else { continue }; | |
| 223 | + | let host_port = hosts | |
| 224 | + | .as_array() | |
| 225 | + | .and_then(|list| list.iter().find_map(|h| h.get("HostPort").and_then(Value::as_str).filter(|p| !p.is_empty()))) | |
| 226 | + | .and_then(|p| p.parse::<u16>().ok()) | |
| 227 | + | .unwrap_or(container_port); | |
| 228 | + | created.published.insert(format!("{container_port}/{protocol}"), host_port.to_string()); | |
| 229 | + | if host_port != container_port && protocol == "tcp" { | |
| 230 | + | created.forwards.push((host_port, container_port)); | |
| 231 | + | } | |
| 232 | + | } | |
| 233 | + | } | |
| 234 | + | ||
| 235 | + | host_config.insert("NetworkMode".into(), json!("host")); | |
| 236 | + | host_config.remove("Links"); | |
| 237 | + | host_config.insert("PublishAllPorts".into(), json!(false)); | |
| 238 | + | let mut extra = strings(host_config.get("ExtraHosts")); | |
| 239 | + | for alias in known.iter().chain(aliases.iter()) { | |
| 240 | + | let entry = format!("{alias}:127.0.0.1"); | |
| 241 | + | if !extra.iter().any(|e| e.split(':').next() == Some(alias.as_str())) { | |
| 242 | + | extra.push(entry); | |
| 243 | + | } | |
| 244 | + | } | |
| 245 | + | host_config.insert("ExtraHosts".into(), json!(extra)); | |
| 246 | + | config.remove("NetworkingConfig"); | |
| 247 | + | config.remove("MacAddress"); | |
| 248 | + | created | |
| 249 | + | } | |
| 250 | + | ||
| 251 | + | /// What `docker network connect` adds: the container's aliases there. | |
| 252 | + | pub(crate) fn connect_aliases(body: &Value) -> Vec<String> { | |
| 253 | + | let mut aliases = strings(body.get("EndpointConfig").and_then(|e| e.get("Aliases"))); | |
| 254 | + | aliases.extend(strings(body.get("EndpointConfig").and_then(|e| e.get("DNSNames")))); | |
| 255 | + | aliases.retain(|alias| host_name(alias)); | |
| 256 | + | aliases | |
| 257 | + | } | |
| 258 | + | ||
| 259 | + | /// A container's inspection, with the ports it publishes filled in. | |
| 260 | + | pub(crate) fn rewrite_inspect(body: &mut Value, published: &BTreeMap<String, String>) { | |
| 261 | + | let ports: Map<String, Value> = published | |
| 262 | + | .iter() | |
| 263 | + | .map(|(port, host)| (port.clone(), json!([{ "HostIp": "0.0.0.0", "HostPort": host }]))) | |
| 264 | + | .collect(); | |
| 265 | + | if let Some(settings) = body.get_mut("NetworkSettings").and_then(Value::as_object_mut) { | |
| 266 | + | settings.insert("Ports".into(), Value::Object(ports)); | |
| 267 | + | } | |
| 268 | + | } | |
| 269 | + | ||
| 270 | + | /// The classic builder's `RUN` steps on the job's network. | |
| 271 | + | pub(crate) fn rewrite_build(target: &str) -> String { | |
| 272 | + | let (path, query) = target.split_once('?').unwrap_or((target, "")); | |
| 273 | + | let mode = query_value(query, "networkmode").unwrap_or_default(); | |
| 274 | + | if keeps_network(mode) { | |
| 275 | + | return target.to_owned(); | |
| 276 | + | } | |
| 277 | + | let mut pairs: Vec<&str> = query.split('&').filter(|pair| !pair.is_empty() && !pair.starts_with("networkmode=")).collect(); | |
| 278 | + | pairs.push("networkmode=host"); | |
| 279 | + | format!("{path}?{}", pairs.join("&")) | |
| 280 | + | } | |
| 281 | + | ||
| 282 | + | /// What the response thread is to do with the next response. | |
| 283 | + | enum Pending { | |
| 284 | + | /// Pass it through. | |
| 285 | + | Plain { method: String }, | |
| 286 | + | /// A container was created: note its id against its ports. | |
| 287 | + | Created { name: Option<String>, published: BTreeMap<String, String> }, | |
| 288 | + | /// Fill in an inspection's ports. | |
| 289 | + | Inspect { published: BTreeMap<String, String> }, | |
| 290 | + | /// Answer it here: the Engine was never asked. | |
| 291 | + | Answer(Vec<u8>), | |
| 292 | + | /// The connection leaves HTTP after this response. | |
| 293 | + | Upgrade, | |
| 294 | + | } | |
| 295 | + | ||
| 296 | + | /// Serves one connection from a client, until either side closes it. | |
| 297 | + | pub(crate) fn serve(client: Box<dyn Duplex>, host: Arc<dyn Host>, state: Arc<Mutex<State>>) { | |
| 298 | + | if let Err(problem) = host.ensure_engine() { | |
| 299 | + | let mut client = client; | |
| 300 | + | let mut reader = BufReader::new(client.try_clone_box().expect("a clone")); | |
| 301 | + | // Answer whatever was asked, so the CLI says why. | |
| 302 | + | if let Ok(Some(head)) = http::read_head(&mut reader) { | |
| 303 | + | let _ = http::read_body(&mut reader, http::request_body(&head)); | |
| 304 | + | let _ = client.write_all(&http::json_response(503, "Service Unavailable", &json!({ "message": problem }))); | |
| 305 | + | } | |
| 306 | + | client.shutdown_both(); | |
| 307 | + | return; | |
| 308 | + | } | |
| 309 | + | let upstream = match host.connect() { | |
| 310 | + | Ok(upstream) => upstream, | |
| 311 | + | Err(_) => { | |
| 312 | + | client.shutdown_both(); | |
| 313 | + | return; | |
| 314 | + | } | |
| 315 | + | }; | |
| 316 | + | let (Ok(client_writer), Ok(upstream_reader)) = (client.try_clone_box(), upstream.try_clone_box()) else { return }; | |
| 317 | + | let (sender, pending) = mpsc::channel::<Pending>(); | |
| 318 | + | let responses = { | |
| 319 | + | let state = state.clone(); | |
| 320 | + | std::thread::spawn(move || answer(upstream_reader, client_writer, pending, state)) | |
| 321 | + | }; | |
| 322 | + | let _ = forward(client, upstream, sender, &host, &state); | |
| 323 | + | let _ = responses.join(); | |
| 324 | + | } | |
| 325 | + | ||
| 326 | + | /// Requests, client to Engine. | |
| 327 | + | fn forward(client: Box<dyn Duplex>, mut upstream: Box<dyn Duplex>, pending: mpsc::Sender<Pending>, host: &Arc<dyn Host>, state: &Arc<Mutex<State>>) -> io::Result<()> { | |
| 328 | + | let closer = client.try_clone_box()?; | |
| 329 | + | let upstream_closer = upstream.try_clone_box()?; | |
| 330 | + | let mut reader = BufReader::new(client); | |
| 331 | + | let result = (|| -> io::Result<()> { | |
| 332 | + | loop { | |
| 333 | + | let Some(mut head) = http::read_head(&mut reader)? else { return Ok(()) }; | |
| 334 | + | let body = http::request_body(&head); | |
| 335 | + | let method = head.method().to_owned(); | |
| 336 | + | match classify(&method, head.target()) { | |
| 337 | + | Kind::CreateContainer { name } => { | |
| 338 | + | let raw = http::read_body(&mut reader, body)?; | |
| 339 | + | let Ok(mut config) = serde_json::from_slice::<Value>(&raw) else { | |
| 340 | + | upstream.write_all(&http::with_length(head, &raw))?; | |
| 341 | + | let _ = pending.send(Pending::Plain { method }); | |
| 342 | + | continue; | |
| 343 | + | }; | |
| 344 | + | if let Some(miner) = miner_in_config(&config) { | |
| 345 | + | let refusal = json!({ "message": format!("g1t does not run cryptocurrency miners ({miner}). This container was not created.") }); | |
| 346 | + | let _ = pending.send(Pending::Answer(http::json_response(403, "Forbidden", &refusal))); | |
| 347 | + | continue; | |
| 348 | + | } | |
| 349 | + | let known = state.lock().map(|s| s.aliases.clone()).unwrap_or_default(); | |
| 350 | + | let created = rewrite_create(&mut config, name.as_deref(), &known); | |
| 351 | + | if created.moved_from.is_some() { | |
| 352 | + | let fresh: Vec<String> = created.aliases.iter().filter(|a| !known.contains(*a)).cloned().collect(); | |
| 353 | + | if let Ok(mut state) = state.lock() { | |
| 354 | + | state.aliases.extend(created.aliases.iter().cloned()); | |
| 355 | + | // By name now; by id once the Engine says it. | |
| 356 | + | if let Some(name) = &name { | |
| 357 | + | state.published.insert(name.trim_start_matches('/').to_owned(), created.published.clone()); | |
| 358 | + | } | |
| 359 | + | } | |
| 360 | + | if !fresh.is_empty() { | |
| 361 | + | host.add_hosts(&fresh); | |
| 362 | + | } | |
| 363 | + | for (host_port, container_port) in &created.forwards { | |
| 364 | + | host.forward(*host_port, *container_port); | |
| 365 | + | } | |
| 366 | + | } | |
| 367 | + | let text = serde_json::to_vec(&config).unwrap_or(raw); | |
| 368 | + | upstream.write_all(&http::with_length(head, &text))?; | |
| 369 | + | let _ = pending.send(if created.moved_from.is_some() { | |
| 370 | + | Pending::Created { name, published: created.published } | |
| 371 | + | } else { | |
| 372 | + | Pending::Plain { method } | |
| 373 | + | }); | |
| 374 | + | } | |
| 375 | + | Kind::InspectContainer { id } => { | |
| 376 | + | let published = state.lock().ok().and_then(|s| published_for(&s, &id)); | |
| 377 | + | upstream.write_all(&head.to_bytes())?; | |
| 378 | + | http::copy_body(&mut reader, &mut upstream, body)?; | |
| 379 | + | let _ = pending.send(match published { | |
| 380 | + | Some(published) => Pending::Inspect { published }, | |
| 381 | + | None => Pending::Plain { method }, | |
| 382 | + | }); | |
| 383 | + | } | |
| 384 | + | kind @ (Kind::ConnectNetwork | Kind::DisconnectNetwork) => { | |
| 385 | + | let raw = http::read_body(&mut reader, body)?; | |
| 386 | + | let value: Value = serde_json::from_slice(&raw).unwrap_or(Value::Null); | |
| 387 | + | let container = value.get("Container").and_then(Value::as_str).unwrap_or_default().to_owned(); | |
| 388 | + | let moved = state.lock().ok().is_some_and(|s| published_for(&s, &container).is_some()); | |
| 389 | + | if moved { | |
| 390 | + | // The container is on the job's network already. | |
| 391 | + | if kind == Kind::ConnectNetwork { | |
| 392 | + | let aliases = connect_aliases(&value); | |
| 393 | + | let fresh: Vec<String> = match state.lock() { | |
| 394 | + | Ok(mut state) => aliases.into_iter().filter(|a| state.aliases.insert(a.clone())).collect(), | |
| 395 | + | Err(_) => Vec::new(), | |
| 396 | + | }; | |
| 397 | + | if !fresh.is_empty() { | |
| 398 | + | host.add_hosts(&fresh); | |
| 399 | + | } | |
| 400 | + | } | |
| 401 | + | let _ = pending.send(Pending::Answer(http::empty_ok())); | |
| 402 | + | } else { | |
| 403 | + | upstream.write_all(&http::with_length(head, &raw))?; | |
| 404 | + | let _ = pending.send(Pending::Plain { method }); | |
| 405 | + | } | |
| 406 | + | } | |
| 407 | + | Kind::ClassicBuild => { | |
| 408 | + | let target = rewrite_build(head.target()); | |
| 409 | + | head.set_target(&target); | |
| 410 | + | upstream.write_all(&head.to_bytes())?; | |
| 411 | + | http::copy_body(&mut reader, &mut upstream, body)?; | |
| 412 | + | let _ = pending.send(Pending::Plain { method }); | |
| 413 | + | } | |
| 414 | + | Kind::Other => { | |
| 415 | + | let upgrade = head.upgrades(); | |
| 416 | + | upstream.write_all(&head.to_bytes())?; | |
| 417 | + | if upgrade { | |
| 418 | + | let _ = pending.send(Pending::Upgrade); | |
| 419 | + | // From here the two ends speak to each other. | |
| 420 | + | io::copy(&mut reader, &mut upstream)?; | |
| 421 | + | upstream_closer.shutdown_write(); | |
| 422 | + | return Ok(()); | |
| 423 | + | } | |
| 424 | + | http::copy_body(&mut reader, &mut upstream, body)?; | |
| 425 | + | let _ = pending.send(Pending::Plain { method }); | |
| 426 | + | } | |
| 427 | + | } | |
| 428 | + | } | |
| 429 | + | })(); | |
| 430 | + | // The client is done asking; the Engine's answers may still be coming. | |
| 431 | + | drop(pending); | |
| 432 | + | if result.is_err() { | |
| 433 | + | closer.shutdown_both(); | |
| 434 | + | upstream_closer.shutdown_both(); | |
| 435 | + | } | |
| 436 | + | result | |
| 437 | + | } | |
| 438 | + | ||
| 439 | + | /// Responses, Engine to client, in the order they were asked for. | |
| 440 | + | fn answer(upstream: Box<dyn Duplex>, mut client: Box<dyn Duplex>, pending: mpsc::Receiver<Pending>, state: Arc<Mutex<State>>) { | |
| 441 | + | let closer = upstream.try_clone_box().ok(); | |
| 442 | + | let mut reader = BufReader::new(upstream); | |
| 443 | + | let result = (|| -> io::Result<()> { | |
| 444 | + | while let Ok(next) = pending.recv() { | |
| 445 | + | if let Pending::Answer(bytes) = next { | |
| 446 | + | client.write_all(&bytes)?; | |
| 447 | + | client.flush()?; | |
| 448 | + | continue; | |
| 449 | + | } | |
| 450 | + | let Some(head) = http::read_head(&mut reader)? else { return Ok(()) }; | |
| 451 | + | match next { | |
| 452 | + | Pending::Upgrade => { | |
| 453 | + | client.write_all(&head.to_bytes())?; | |
| 454 | + | client.flush()?; | |
| 455 | + | io::copy(&mut reader, &mut client)?; | |
| 456 | + | client.shutdown_write(); | |
| 457 | + | return Err(io::Error::other("upgraded")); | |
| 458 | + | } | |
| 459 | + | Pending::Plain { method } => { | |
| 460 | + | let body = http::response_body(&head, &method); | |
| 461 | + | client.write_all(&head.to_bytes())?; | |
| 462 | + | http::copy_body(&mut reader, &mut client, body)?; | |
| 463 | + | } | |
| 464 | + | Pending::Created { name, published } => { | |
| 465 | + | let body = http::read_body(&mut reader, http::response_body(&head, "POST"))?; | |
| 466 | + | if (200..300).contains(&head.status()) | |
| 467 | + | && let Some(id) = serde_json::from_slice::<Value>(&body).ok().and_then(|v| v.get("Id").and_then(Value::as_str).map(str::to_owned)) | |
| 468 | + | && let Ok(mut state) = state.lock() | |
| 469 | + | { | |
| 470 | + | state.published.insert(id, published.clone()); | |
| 471 | + | if let Some(name) = name { | |
| 472 | + | state.published.insert(name.trim_start_matches('/').to_owned(), published); | |
| 473 | + | } | |
| 474 | + | } | |
| 475 | + | client.write_all(&http::with_length(head, &body))?; | |
| 476 | + | } | |
| 477 | + | Pending::Inspect { published } => { | |
| 478 | + | let body = http::read_body(&mut reader, http::response_body(&head, "GET"))?; | |
| 479 | + | let rewritten = match serde_json::from_slice::<Value>(&body) { | |
| 480 | + | Ok(mut value) if head.status() == 200 => { | |
| 481 | + | rewrite_inspect(&mut value, &published); | |
| 482 | + | serde_json::to_vec(&value).unwrap_or(body) | |
| 483 | + | } | |
| 484 | + | _ => body, | |
| 485 | + | }; | |
| 486 | + | client.write_all(&http::with_length(head, &rewritten))?; | |
| 487 | + | } | |
| 488 | + | Pending::Answer(_) => unreachable!("answered above"), | |
| 489 | + | } | |
| 490 | + | client.flush()?; | |
| 491 | + | } | |
| 492 | + | Ok(()) | |
| 493 | + | })(); | |
| 494 | + | if matches!(&result, Err(error) if error.to_string() == "upgraded") { | |
| 495 | + | // A hijacked connection ends when both ends have said so. | |
| 496 | + | return; | |
| 497 | + | } | |
| 498 | + | client.shutdown_both(); | |
| 499 | + | if let Some(closer) = closer { | |
| 500 | + | closer.shutdown_both(); | |
| 501 | + | } | |
| 502 | + | } | |
| 503 | + | ||
| 504 | + | /// The ports of a container moved to the job's network, by its id, the | |
| 505 | + | /// start of its id, or its name. | |
| 506 | + | fn published_for(state: &State, id: &str) -> Option<BTreeMap<String, String>> { | |
| 507 | + | let id = id.trim_start_matches('/'); | |
| 508 | + | if id.is_empty() { | |
| 509 | + | return None; | |
| 510 | + | } | |
| 511 | + | if let Some(found) = state.published.get(id) { | |
| 512 | + | return Some(found.clone()); | |
| 513 | + | } | |
| 514 | + | if id.len() >= 6 && id.chars().all(|c| c.is_ascii_hexdigit()) { | |
| 515 | + | let mut matches = state.published.iter().filter(|(key, _)| key.len() == 64 && key.starts_with(id)); | |
| 516 | + | if let (Some((_, found)), None) = (matches.next(), matches.next()) { | |
| 517 | + | return Some(found.clone()); | |
| 518 | + | } | |
| 519 | + | } | |
| 520 | + | None | |
| 521 | + | } | |
| 522 | + | ||
| 523 | + | /// The miner a container's image or command names, if any. | |
| 524 | + | fn miner_in_config(config: &Value) -> Option<&'static str> { | |
| 525 | + | let mut words = vec![config.get("Image").and_then(Value::as_str).unwrap_or_default().to_owned()]; | |
| 526 | + | for key in ["Entrypoint", "Cmd"] { | |
| 527 | + | match config.get(key) { | |
| 528 | + | Some(Value::String(text)) => words.push(text.clone()), | |
| 529 | + | other => words.extend(strings(other)), | |
| 530 | + | } | |
| 531 | + | } | |
| 532 | + | crate::abuse::miner_in(&words.join(" ")) | |
| 533 | + | } | |
| 534 | + | ||
| 535 | + | #[cfg(test)] | |
| 536 | + | mod tests { | |
| 537 | + | use super::*; | |
| 538 | + | ||
| 539 | + | #[test] | |
| 540 | + | fn routes_are_read_with_or_without_a_version() { | |
| 541 | + | assert_eq!(classify("POST", "/v1.47/containers/create?name=db"), Kind::CreateContainer { name: Some("db".into()) }); | |
| 542 | + | assert_eq!(classify("POST", "/containers/create"), Kind::CreateContainer { name: None }); | |
| 543 | + | assert_eq!(classify("GET", "/v1.51/containers/abc123/json?size=false"), Kind::InspectContainer { id: "abc123".into() }); | |
| 544 | + | assert_eq!(classify("POST", "/v1.47/networks/app_default/connect"), Kind::ConnectNetwork); | |
| 545 | + | assert_eq!(classify("POST", "/v1.47/build?t=x"), Kind::ClassicBuild); | |
| 546 | + | assert_eq!(classify("GET", "/v1.47/containers/json"), Kind::Other); | |
| 547 | + | assert_eq!(classify("POST", "/v1.47/containers/abc/start"), Kind::Other); | |
| 548 | + | } | |
| 549 | + | ||
| 550 | + | #[test] | |
| 551 | + | fn a_compose_service_moves_to_the_jobs_network_and_keeps_its_names() { | |
| 552 | + | let mut body = json!({ | |
| 553 | + | "Image": "postgres:17", | |
| 554 | + | "Labels": { "com.docker.compose.service": "db", "com.docker.compose.project": "app" }, | |
| 555 | + | "HostConfig": { | |
| 556 | + | "NetworkMode": "app_default", | |
| 557 | + | "PortBindings": { "5432/tcp": [{ "HostIp": "", "HostPort": "15432" }], "8080/tcp": [{ "HostPort": "" }] }, | |
| 558 | + | "Links": ["/cache:/app-db-1/redis"], | |
| 559 | + | "ExtraHosts": ["host.docker.internal:host-gateway"], | |
| 560 | + | }, | |
| 561 | + | "NetworkingConfig": { "EndpointsConfig": { "app_default": { "Aliases": ["db", "app-db-1"], "MacAddress": "x" } } }, | |
| 562 | + | }); | |
| 563 | + | let known: BTreeSet<String> = ["cache".to_owned()].into(); | |
| 564 | + | let created = rewrite_create(&mut body, Some("app-db-1"), &known); | |
| 565 | + | assert_eq!(created.moved_from.as_deref(), Some("app_default")); | |
| 566 | + | assert_eq!(created.aliases, vec!["app-db-1", "db", "redis"]); | |
| 567 | + | assert_eq!(created.forwards, vec![(15432, 5432)]); | |
| 568 | + | assert_eq!(created.published["5432/tcp"], "15432"); | |
| 569 | + | assert_eq!(created.published["8080/tcp"], "8080"); | |
| 570 | + | assert_eq!(body["HostConfig"]["NetworkMode"], "host"); | |
| 571 | + | assert!(body.get("NetworkingConfig").is_none()); | |
| 572 | + | assert!(body["HostConfig"].get("Links").is_none()); | |
| 573 | + | let extra = strings(body["HostConfig"].get("ExtraHosts")); | |
| 574 | + | assert!(extra.contains(&"host.docker.internal:host-gateway".to_owned())); | |
| 575 | + | for name in ["cache", "db", "app-db-1", "redis"] { | |
| 576 | + | assert!(extra.contains(&format!("{name}:127.0.0.1")), "{name} in {extra:?}"); | |
| 577 | + | } | |
| 578 | + | } | |
| 579 | + | ||
| 580 | + | #[test] | |
| 581 | + | fn host_none_and_shared_networks_are_left_alone() { | |
| 582 | + | for mode in ["host", "none", "container:abc"] { | |
| 583 | + | let mut body = json!({ "Image": "alpine", "HostConfig": { "NetworkMode": mode } }); | |
| 584 | + | let before = body.clone(); | |
| 585 | + | assert_eq!(rewrite_create(&mut body, Some("x"), &BTreeSet::new()), Created::default()); | |
| 586 | + | assert_eq!(body, before); | |
| 587 | + | } | |
| 588 | + | // The default network, named or not. | |
| 589 | + | let mut body = json!({ "Image": "alpine" }); | |
| 590 | + | assert_eq!(rewrite_create(&mut body, None, &BTreeSet::new()).moved_from.as_deref(), Some("bridge")); | |
| 591 | + | assert_eq!(body["HostConfig"]["NetworkMode"], "host"); | |
| 592 | + | } | |
| 593 | + | ||
| 594 | + | #[test] | |
| 595 | + | fn names_that_cannot_be_hosts_are_skipped() { | |
| 596 | + | let mut body = json!({ "HostConfig": {}, "NetworkingConfig": { "EndpointsConfig": { "n": { "Aliases": ["ok-name", "bad name", "localhost", ""] } } } }); | |
| 597 | + | assert_eq!(rewrite_create(&mut body, None, &BTreeSet::new()).aliases, vec!["ok-name"]); | |
| 598 | + | } | |
| 599 | + | ||
| 600 | + | #[test] | |
| 601 | + | fn inspections_report_the_ports_published() { | |
| 602 | + | let mut body = json!({ "Id": "abc", "NetworkSettings": { "Ports": {}, "Networks": { "host": {} } } }); | |
| 603 | + | rewrite_inspect(&mut body, &[("5432/tcp".to_owned(), "15432".to_owned())].into()); | |
| 604 | + | assert_eq!(body["NetworkSettings"]["Ports"]["5432/tcp"][0]["HostPort"], "15432"); | |
| 605 | + | } | |
| 606 | + | ||
| 607 | + | #[test] | |
| 608 | + | fn the_classic_builder_runs_on_the_jobs_network() { | |
| 609 | + | assert_eq!(rewrite_build("/v1.47/build?t=app&networkmode=default"), "/v1.47/build?t=app&networkmode=host"); | |
| 610 | + | assert_eq!(rewrite_build("/build"), "/build?networkmode=host"); | |
| 611 | + | assert_eq!(rewrite_build("/build?networkmode=none"), "/build?networkmode=none"); | |
| 612 | + | } | |
| 613 | + | ||
| 614 | + | #[test] | |
| 615 | + | fn ids_are_found_by_their_start_or_a_name() { | |
| 616 | + | let mut state = State::default(); | |
| 617 | + | let id = "a".repeat(64); | |
| 618 | + | state.published.insert(id.clone(), [("80/tcp".to_owned(), "8080".to_owned())].into()); | |
| 619 | + | state.published.insert("web".into(), [("80/tcp".to_owned(), "8080".to_owned())].into()); | |
| 620 | + | assert!(published_for(&state, "aaaaaaaaaaaa").is_some()); | |
| 621 | + | assert!(published_for(&state, "/web").is_some()); | |
| 622 | + | assert!(published_for(&state, "aaa").is_none()); | |
| 623 | + | assert!(published_for(&state, "other").is_none()); | |
| 624 | + | } | |
| 625 | + | ||
| 626 | + | #[test] | |
| 627 | + | fn miners_are_refused_by_image_or_command() { | |
| 628 | + | assert!(miner_in_config(&json!({ "Image": "metal3d/xmrig" })).is_some()); | |
| 629 | + | assert!(miner_in_config(&json!({ "Image": "alpine", "Cmd": ["sh", "-c", "./xmrig -o stratum+tcp://pool"] })).is_some()); | |
| 630 | + | assert!(miner_in_config(&json!({ "Image": "postgres:17", "Cmd": ["postgres"] })).is_none()); | |
| 631 | + | } | |
| 632 | + | ||
| 633 | + | /// A pretend Engine on TCP, and the proxy in front of it, end to end. | |
| 634 | + | #[test] | |
| 635 | + | fn requests_and_responses_pass_through_in_order() { | |
| 636 | + | use std::net::{TcpListener, TcpStream}; | |
| 637 | + | let engine = TcpListener::bind("127.0.0.1:0").unwrap(); | |
| 638 | + | let engine_addr = engine.local_addr().unwrap(); | |
| 639 | + | // The Engine: a create, an inspect, a chunked stream, then a hijack. | |
| 640 | + | let fake = std::thread::spawn(move || { | |
| 641 | + | let (stream, _) = engine.accept().unwrap(); | |
| 642 | + | let mut reader = BufReader::new(stream.try_clone().unwrap()); | |
| 643 | + | let mut writer = stream; | |
| 644 | + | let mut seen = Vec::new(); | |
| 645 | + | while let Some(head) = http::read_head(&mut reader).unwrap() { | |
| 646 | + | let body = http::read_body(&mut reader, http::request_body(&head)).unwrap(); | |
| 647 | + | seen.push((head.start.clone(), String::from_utf8_lossy(&body).into_owned())); | |
| 648 | + | if head.target().contains("/containers/create") { | |
| 649 | + | writer.write_all(&http::json_response(201, "Created", &json!({ "Id": "c".repeat(64), "Warnings": [] }))).unwrap(); | |
| 650 | + | } else if head.target().contains("/json") { | |
| 651 | + | writer | |
| 652 | + | .write_all(b"HTTP/1.1 200 OK\r\nContent-Type: application/json\r\nTransfer-Encoding: chunked\r\n\r\n") | |
| 653 | + | .unwrap(); | |
| 654 | + | let text = json!({ "Id": "c".repeat(64), "NetworkSettings": { "Ports": {} } }).to_string(); | |
| 655 | + | writer.write_all(format!("{:x}\r\n{text}\r\n0\r\n\r\n", text.len()).as_bytes()).unwrap(); | |
| 656 | + | } else if head.target().contains("/logs") { | |
| 657 | + | writer.write_all(b"HTTP/1.1 200 OK\r\nTransfer-Encoding: chunked\r\n\r\n3\r\none\r\n3\r\ntwo\r\n0\r\n\r\n").unwrap(); | |
| 658 | + | } else if head.upgrades() { | |
| 659 | + | writer.write_all(b"HTTP/1.1 101 UPGRADED\r\nConnection: Upgrade\r\nUpgrade: tcp\r\n\r\n").unwrap(); | |
| 660 | + | let mut buffer = [0u8; 5]; | |
| 661 | + | reader.read_exact(&mut buffer).unwrap(); | |
| 662 | + | writer.write_all(&buffer).unwrap(); | |
| 663 | + | break; | |
| 664 | + | } | |
| 665 | + | } | |
| 666 | + | seen | |
| 667 | + | }); | |
| 668 | + | ||
| 669 | + | struct Fake(std::net::SocketAddr, Mutex<Vec<(u16, u16)>>, Mutex<Vec<String>>); | |
| 670 | + | impl Host for Fake { | |
| 671 | + | fn add_hosts(&self, names: &[String]) { | |
| 672 | + | self.2.lock().unwrap().extend(names.iter().cloned()); | |
| 673 | + | } | |
| 674 | + | fn forward(&self, host_port: u16, container_port: u16) { | |
| 675 | + | self.1.lock().unwrap().push((host_port, container_port)); | |
| 676 | + | } | |
| 677 | + | fn ensure_engine(&self) -> Result<(), String> { | |
| 678 | + | Ok(()) | |
| 679 | + | } | |
| 680 | + | fn connect(&self) -> io::Result<Box<dyn Duplex>> { | |
| 681 | + | Ok(Box::new(TcpStream::connect(self.0)?)) | |
| 682 | + | } | |
| 683 | + | } | |
| 684 | + | let host = Arc::new(Fake(engine_addr, Mutex::new(Vec::new()), Mutex::new(Vec::new()))); | |
| 685 | + | let state = Arc::new(Mutex::new(State::default())); | |
| 686 | + | let proxy = TcpListener::bind("127.0.0.1:0").unwrap(); | |
| 687 | + | let proxy_addr = proxy.local_addr().unwrap(); | |
| 688 | + | let (host2, state2) = (host.clone() as Arc<dyn Host>, state.clone()); | |
| 689 | + | std::thread::spawn(move || { | |
| 690 | + | let (stream, _) = proxy.accept().unwrap(); | |
| 691 | + | serve(Box::new(stream), host2, state2); | |
| 692 | + | }); | |
| 693 | + | ||
| 694 | + | let client = TcpStream::connect(proxy_addr).unwrap(); | |
| 695 | + | let mut reader = BufReader::new(client.try_clone().unwrap()); | |
| 696 | + | let mut writer = client; | |
| 697 | + | let create = json!({ "Image": "redis", "HostConfig": { "PortBindings": { "6379/tcp": [{ "HostPort": "16379" }] } }, "NetworkingConfig": { "EndpointsConfig": { "job": { "Aliases": ["redis"] } } } }).to_string(); | |
| 698 | + | // Pipelined: all asked before any answer is read. | |
| 699 | + | writer | |
| 700 | + | .write_all(format!("POST /v1.47/containers/create?name=cache HTTP/1.1\r\nHost: docker\r\nContent-Type: application/json\r\nContent-Length: {}\r\n\r\n{create}", create.len()).as_bytes()) | |
| 701 | + | .unwrap(); | |
| 702 | + | writer.write_all(b"GET /v1.47/containers/cache/json HTTP/1.1\r\nHost: docker\r\n\r\n").unwrap(); | |
| 703 | + | writer.write_all(b"GET /v1.47/containers/cache/logs?follow=1 HTTP/1.1\r\nHost: docker\r\n\r\n").unwrap(); | |
| 704 | + | writer.write_all(b"POST /v1.47/containers/cache/attach?stream=1 HTTP/1.1\r\nHost: docker\r\nConnection: Upgrade\r\nUpgrade: tcp\r\n\r\nhello").unwrap(); | |
| 705 | + | ||
| 706 | + | let created = http::read_head(&mut reader).unwrap().unwrap(); | |
| 707 | + | assert_eq!(created.status(), 201); | |
| 708 | + | let body: Value = serde_json::from_slice(&http::read_body(&mut reader, http::response_body(&created, "POST")).unwrap()).unwrap(); | |
| 709 | + | assert_eq!(body["Id"].as_str().unwrap().len(), 64); | |
| 710 | + | let inspected = http::read_head(&mut reader).unwrap().unwrap(); | |
| 711 | + | let body: Value = serde_json::from_slice(&http::read_body(&mut reader, http::response_body(&inspected, "GET")).unwrap()).unwrap(); | |
| 712 | + | assert_eq!(body["NetworkSettings"]["Ports"]["6379/tcp"][0]["HostPort"], "16379"); | |
| 713 | + | let logs = http::read_head(&mut reader).unwrap().unwrap(); | |
| 714 | + | assert_eq!(http::read_body(&mut reader, http::response_body(&logs, "GET")).unwrap(), b"onetwo"); | |
| 715 | + | let upgraded = http::read_head(&mut reader).unwrap().unwrap(); | |
| 716 | + | assert_eq!(upgraded.status(), 101); | |
| 717 | + | let mut echoed = [0u8; 5]; | |
| 718 | + | reader.read_exact(&mut echoed).unwrap(); | |
| 719 | + | assert_eq!(&echoed, b"hello"); | |
| 720 | + | ||
| 721 | + | let seen = fake.join().unwrap(); | |
| 722 | + | assert!(seen[0].1.contains("\"NetworkMode\":\"host\""), "{}", seen[0].1); | |
| 723 | + | assert!(seen[0].1.contains("redis:127.0.0.1")); | |
| 724 | + | assert_eq!(*host.1.lock().unwrap(), vec![(16379, 6379)]); | |
| 725 | + | assert_eq!(*host.2.lock().unwrap(), vec!["cache".to_owned(), "redis".to_owned()]); | |
| 726 | + | } | |
| 727 | + | } |
| 1 | + | //! The job's own Docker Engine: set up when the job starts (a socket, and | |
| 2 | + | //! nothing running), and started the first time the socket is used, or a | |
| 3 | + | //! job's `services:` or `container:` need it. | |
| 4 | + | //! | |
| 5 | + | //! What Cloudflare Containers allow, and so how it is started: `dockerd` | |
| 6 | + | //! as root (a rootless Engine does not start there), with | |
| 7 | + | //! `--iptables=false --ip6tables=false --ip-forward=false`, since a sandbox | |
| 8 | + | //! may not change its packet filter or forward packets. Containers on a | |
| 9 | + | //! bridge network therefore have no way out, which is why the API proxy | |
| 10 | + | //! puts them on the job's network (api.rs). The Engine's data goes on the | |
| 11 | + | //! sandbox's disk with the overlay filesystem when the disk takes it, and | |
| 12 | + | //! with plain copies (containerd's `native` snapshotter) when it does not. Docker Hub's images come | |
| 13 | + | //! through Google's public mirror of it first, so jobs from many machines | |
| 14 | + | //! sharing addresses do not run into Docker Hub's anonymous limits. | |
| 15 | + | ||
| 16 | + | use std::path::Path; | |
| 17 | + | ||
| 18 | + | use serde_json::{Value, json}; | |
| 19 | + | ||
| 20 | + | /// Everything of the Engine's that is not its data. | |
| 21 | + | pub(crate) const DIR: &str = "/run/g1t-docker"; | |
| 22 | + | /// What the job's steps reach: the API proxy. | |
| 23 | + | pub(crate) const PROXY_SOCKET: &str = "/run/g1t-docker/docker.sock"; | |
| 24 | + | /// The Engine itself. | |
| 25 | + | pub(crate) const ENGINE_SOCKET: &str = "/run/g1t-docker/engine.sock"; | |
| 26 | + | /// Where the job's steps look for Docker. | |
| 27 | + | pub(crate) const DOCKER_SOCKET: &str = "/var/run/docker.sock"; | |
| 28 | + | pub(crate) const MIRROR: &str = "https://mirror.gcr.io"; | |
| 29 | + | ||
| 30 | + | /// What the job tells the Engine when it sets it up. | |
| 31 | + | #[derive(Clone, Default)] | |
| 32 | + | pub(crate) struct Options { | |
| 33 | + | /// g1t's container registry (`g1t.sh`) and the run's token, to be | |
| 34 | + | /// signed in to from the start. None for a run without secrets. | |
| 35 | + | pub(crate) registry: Option<(String, String)>, | |
| 36 | + | } | |
| 37 | + | ||
| 38 | + | /// The Engine's `daemon.json`. `group`: the group whose members may use | |
| 39 | + | /// its socket (the job's user). `copies`: the disk does not take overlays. | |
| 40 | + | pub(crate) fn daemon_config(group: &str, copies: bool) -> Value { | |
| 41 | + | let mut config = json!({ | |
| 42 | + | "hosts": [format!("unix://{ENGINE_SOCKET}")], | |
| 43 | + | "group": group, | |
| 44 | + | "pidfile": format!("{DIR}/dockerd.pid"), | |
| 45 | + | "iptables": false, | |
| 46 | + | "ip6tables": false, | |
| 47 | + | "ip-forward": false, | |
| 48 | + | "registry-mirrors": [MIRROR], | |
| 49 | + | "log-driver": "json-file", | |
| 50 | + | "log-opts": { "max-size": "20m", "max-file": "2" }, | |
| 51 | + | }); | |
| 52 | + | if copies { | |
| 53 | + | // The containerd image store's name for plain copies (vfs). | |
| 54 | + | config["storage-driver"] = json!("native"); | |
| 55 | + | } | |
| 56 | + | config | |
| 57 | + | } | |
| 58 | + | ||
| 59 | + | /// `~/.docker/config.json`, signed in to `registry` with `token` unless it | |
| 60 | + | /// is already signed in there. | |
| 61 | + | pub(crate) fn with_login(mut config: Value, registry: &str, token: &str) -> Option<Value> { | |
| 62 | + | use base64::Engine; | |
| 63 | + | if !config.is_object() { | |
| 64 | + | config = json!({}); | |
| 65 | + | } | |
| 66 | + | let auths = config.as_object_mut()?.entry("auths").or_insert_with(|| json!({})); | |
| 67 | + | let auths = auths.as_object_mut()?; | |
| 68 | + | if auths.contains_key(registry) { | |
| 69 | + | return None; | |
| 70 | + | } | |
| 71 | + | let auth = base64::engine::general_purpose::STANDARD.encode(format!("g1t:{token}")); | |
| 72 | + | auths.insert(registry.to_owned(), json!({ "auth": auth })); | |
| 73 | + | Some(config) | |
| 74 | + | } | |
| 75 | + | ||
| 76 | + | /// The host of a server URL: `https://g1t.sh` is `g1t.sh`. | |
| 77 | + | pub(crate) fn registry_host(server_url: &str) -> Option<String> { | |
| 78 | + | let rest = server_url.split_once("://").map_or(server_url, |(_, rest)| rest); | |
| 79 | + | let host = rest.split('/').next().unwrap_or_default(); | |
| 80 | + | (!host.is_empty()).then(|| host.to_ascii_lowercase()) | |
| 81 | + | } | |
| 82 | + | ||
| 83 | + | /// The last lines of a file, for a message. | |
| 84 | + | fn tail(path: &Path, lines: usize) -> String { | |
| 85 | + | let text = std::fs::read_to_string(path).unwrap_or_default(); | |
| 86 | + | let all: Vec<&str> = text.lines().filter(|l| !l.trim().is_empty()).collect(); | |
| 87 | + | all[all.len().saturating_sub(lines)..].join("\n") | |
| 88 | + | } | |
| 89 | + | ||
| 90 | + | #[cfg(target_os = "linux")] | |
| 91 | + | pub(crate) use linux::{enable, ensure_started}; | |
| 92 | + | ||
| 93 | + | #[cfg(not(target_os = "linux"))] | |
| 94 | + | pub(crate) fn enable(_options: Options) -> Result<(), String> { | |
| 95 | + | Err("Docker runs in jobs on g1t's own Linux machines.".into()) | |
| 96 | + | } | |
| 97 | + | ||
| 98 | + | #[cfg(not(target_os = "linux"))] | |
| 99 | + | pub(crate) fn ensure_started() -> Result<String, String> { | |
| 100 | + | Err("Docker runs in jobs on g1t's own Linux machines.".into()) | |
| 101 | + | } | |
| 102 | + | ||
| 103 | + | #[cfg(target_os = "linux")] | |
| 104 | + | mod linux { | |
| 105 | + | use std::collections::BTreeSet; | |
| 106 | + | use std::io::{self, Write}; | |
| 107 | + | use std::net::{TcpListener, TcpStream}; | |
| 108 | + | use std::os::unix::fs::{MetadataExt, PermissionsExt}; | |
| 109 | + | use std::os::unix::net::{UnixListener, UnixStream}; | |
| 110 | + | use std::path::Path; | |
| 111 | + | use std::process::{Child, Command, Stdio}; | |
| 112 | + | use std::sync::{Arc, Mutex, OnceLock}; | |
| 113 | + | use std::time::{Duration, Instant}; | |
| 114 | + | ||
| 115 | + | use serde_json::Value; | |
| 116 | + | ||
| 117 | + | use super::super::api::{self, Duplex, Host, State}; | |
| 118 | + | use super::{DIR, DOCKER_SOCKET, ENGINE_SOCKET, Options, PROXY_SOCKET, daemon_config, tail, with_login}; | |
| 119 | + | ||
| 120 | + | const LOG: &str = "/run/g1t-docker/dockerd.log"; | |
| 121 | + | /// From moby's `hack/dind`: the sandbox's processes into a group of | |
| 122 | + | /// their own, then every controller enabled for the groups below. | |
| 123 | + | const CGROUP_NESTING: &str = "if [ -f /sys/fs/cgroup/cgroup.controllers ] && [ -z \"$(cat /sys/fs/cgroup/cgroup.subtree_control)\" ]; then \ | |
| 124 | + | mkdir -p /sys/fs/cgroup/init && xargs -rn1 < /sys/fs/cgroup/cgroup.procs > /sys/fs/cgroup/init/cgroup.procs 2>/dev/null; \ | |
| 125 | + | sed -e 's/ / +/g' -e 's/^/+/' < /sys/fs/cgroup/cgroup.controllers > /sys/fs/cgroup/cgroup.subtree_control; fi"; | |
| 126 | + | const CERTS: &str = "/run/g1t-docker/certs"; | |
| 127 | + | const BIN: &str = "/run/g1t-docker/bin"; | |
| 128 | + | ||
| 129 | + | /// The options the job set up with, once set up. | |
| 130 | + | static OPTIONS: OnceLock<Options> = OnceLock::new(); | |
| 131 | + | /// How starting went: the Engine's version, or why not. Held while | |
| 132 | + | /// starting, so everything that needs the Engine waits for it. | |
| 133 | + | static STARTED: Mutex<Option<Result<String, String>>> = Mutex::new(None); | |
| 134 | + | /// Host ports forwarded so far. | |
| 135 | + | static FORWARDED: Mutex<BTreeSet<u16>> = Mutex::new(BTreeSet::new()); | |
| 136 | + | ||
| 137 | + | fn sudo(args: &[&str]) -> bool { | |
| 138 | + | Command::new("sudo").arg("-n").args(args).stdin(Stdio::null()).stdout(Stdio::null()).stderr(Stdio::null()).status().is_ok_and(|s| s.success()) | |
| 139 | + | } | |
| 140 | + | ||
| 141 | + | fn sudo_with_input(args: &[&str], input: &str) -> bool { | |
| 142 | + | let Ok(mut child) = Command::new("sudo").arg("-n").args(args).stdin(Stdio::piped()).stdout(Stdio::null()).stderr(Stdio::null()).spawn() else { | |
| 143 | + | return false; | |
| 144 | + | }; | |
| 145 | + | if let Some(mut stdin) = child.stdin.take() { | |
| 146 | + | let _ = stdin.write_all(input.as_bytes()); | |
| 147 | + | } | |
| 148 | + | child.wait().is_ok_and(|s| s.success()) | |
| 149 | + | } | |
| 150 | + | ||
| 151 | + | fn ids() -> (u32, u32) { | |
| 152 | + | std::fs::metadata("/proc/self").map(|m| (m.uid(), m.gid())).unwrap_or((1000, 1000)) | |
| 153 | + | } | |
| 154 | + | ||
| 155 | + | fn group_name() -> String { | |
| 156 | + | Command::new("id").arg("-gn").output().ok().map(|o| String::from_utf8_lossy(&o.stdout).trim().to_owned()).filter(|g| !g.is_empty()).unwrap_or_else(|| "node".into()) | |
| 157 | + | } | |
| 158 | + | ||
| 159 | + | /// Sets the socket up, with no Engine behind it yet. | |
| 160 | + | pub(crate) fn enable(options: Options) -> Result<(), String> { | |
| 161 | + | if !Path::new("/usr/bin/dockerd").exists() { | |
| 162 | + | return Err("this sandbox's image has no Docker Engine".into()); | |
| 163 | + | } | |
| 164 | + | let (uid, gid) = ids(); | |
| 165 | + | if !sudo(&["install", "-d", "-m", "0755", "-o", &uid.to_string(), "-g", &gid.to_string(), DIR]) { | |
| 166 | + | return Err(format!("could not make {DIR}")); | |
| 167 | + | } | |
| 168 | + | let _ = std::fs::remove_file(PROXY_SOCKET); | |
| 169 | + | let listener = UnixListener::bind(PROXY_SOCKET).map_err(|e| format!("could not listen on {PROXY_SOCKET}: {e}"))?; | |
| 170 | + | // Every process in the sandbox is the job's, root or not. | |
| 171 | + | let _ = std::fs::set_permissions(PROXY_SOCKET, std::fs::Permissions::from_mode(0o666)); | |
| 172 | + | if !sudo(&["ln", "-sfn", PROXY_SOCKET, DOCKER_SOCKET]) { | |
| 173 | + | return Err(format!("could not link {DOCKER_SOCKET}")); | |
| 174 | + | } | |
| 175 | + | let _ = OPTIONS.set(options); | |
| 176 | + | let host: Arc<dyn Host> = Arc::new(Sandbox); | |
| 177 | + | let state = Arc::new(Mutex::new(State::default())); | |
| 178 | + | std::thread::spawn(move || { | |
| 179 | + | for client in listener.incoming().flatten() { | |
| 180 | + | let (host, state) = (host.clone(), state.clone()); | |
| 181 | + | std::thread::spawn(move || api::serve(Box::new(client), host, state)); | |
| 182 | + | } | |
| 183 | + | }); | |
| 184 | + | Ok(()) | |
| 185 | + | } | |
| 186 | + | ||
| 187 | + | /// Starts the Engine, once; its version, or why it could not start. | |
| 188 | + | pub(crate) fn ensure_started() -> Result<String, String> { | |
| 189 | + | let mut started = STARTED.lock().unwrap_or_else(|poisoned| poisoned.into_inner()); | |
| 190 | + | if let Some(result) = &*started { | |
| 191 | + | return result.clone(); | |
| 192 | + | } | |
| 193 | + | let begun = Instant::now(); | |
| 194 | + | let result = start(); | |
| 195 | + | match &result { | |
| 196 | + | Ok(version) => super::super::note(format!( | |
| 197 | + | "Docker: started this job's own Docker Engine {version} in {:.1}s. Its containers run in this job's sandbox, on the job's network and under its guardrails, and end with the job.", | |
| 198 | + | begun.elapsed().as_secs_f64() | |
| 199 | + | )), | |
| 200 | + | Err(problem) => super::super::note(format!("##[error]Docker could not start: {problem}")), | |
| 201 | + | } | |
| 202 | + | *started = Some(result.clone()); | |
| 203 | + | result | |
| 204 | + | } | |
| 205 | + | ||
| 206 | + | /// Whether the overlay filesystem works where the Engine keeps its data. | |
| 207 | + | fn overlay_works() -> bool { | |
| 208 | + | let script = "d=/var/lib/docker/.g1t-probe; rm -rf $d; mkdir -p $d/l $d/u $d/w $d/m && echo x > $d/l/f && mount -t overlay overlay -o lowerdir=$d/l,upperdir=$d/u,workdir=$d/w $d/m && umount $d/m; s=$?; rm -rf $d; exit $s"; | |
| 209 | + | sudo(&["sh", "-c", script]) | |
| 210 | + | } | |
| 211 | + | ||
| 212 | + | fn start() -> Result<String, String> { | |
| 213 | + | // This program, as the runc the Engine finds first (oci.rs). | |
| 214 | + | let exe = std::env::current_exe().map_err(|e| e.to_string())?; | |
| 215 | + | std::fs::create_dir_all(BIN).map_err(|e| format!("could not make {BIN}: {e}"))?; | |
| 216 | + | let shim = Path::new(BIN).join("runc"); | |
| 217 | + | let _ = std::fs::remove_file(&shim); | |
| 218 | + | std::os::unix::fs::symlink(&exe, &shim).map_err(|e| format!("could not link runc: {e}"))?; | |
| 219 | + | ||
| 220 | + | // A guarded job's certificate, for its containers. | |
| 221 | + | let mut ca_dir = None; | |
| 222 | + | if let Ok(ca) = std::env::var("G1T_EGRESS_CA") | |
| 223 | + | && Path::new(&ca).exists() | |
| 224 | + | { | |
| 225 | + | let _ = std::fs::create_dir_all(CERTS); | |
| 226 | + | let copied = std::fs::copy(&ca, Path::new(CERTS).join(super::super::oci::EGRESS)).is_ok() | |
| 227 | + | && std::fs::copy("/etc/ssl/certs/ca-certificates.crt", Path::new(CERTS).join(super::super::oci::BUNDLE)).is_ok(); | |
| 228 | + | if copied { | |
| 229 | + | ca_dir = Some(CERTS.to_owned()); | |
| 230 | + | } | |
| 231 | + | } | |
| 232 | + | // `-p 80:8080` is forwarded by this process, which is not root. | |
| 233 | + | let _ = sudo(&["sysctl", "-q", "-w", "net.ipv4.ip_unprivileged_port_start=0"]); | |
| 234 | + | // Containers' resource limits (`--cpus`, `--memory`) need the | |
| 235 | + | // cgroup controllers handed down, which cgroup v2 allows only from | |
| 236 | + | // a group with no processes of its own: move ours aside first, as | |
| 237 | + | // the Engine's own Docker-in-Docker image does. | |
| 238 | + | let _ = sudo(&["sh", "-c", CGROUP_NESTING]); | |
| 239 | + | ||
| 240 | + | let mut vfs = !overlay_works(); | |
| 241 | + | let group = group_name(); | |
| 242 | + | let mut last = String::new(); | |
| 243 | + | for _ in 0..2 { | |
| 244 | + | let config = daemon_config(&group, vfs); | |
| 245 | + | std::fs::write(format!("{DIR}/daemon.json"), serde_json::to_vec_pretty(&config).unwrap_or_default()) | |
| 246 | + | .map_err(|e| format!("could not write daemon.json: {e}"))?; | |
| 247 | + | let mut child = spawn(ca_dir.as_deref())?; | |
| 248 | + | match wait_ready(&mut child, Duration::from_secs(90)) { | |
| 249 | + | Ok(()) => { | |
| 250 | + | let version = engine_version().unwrap_or_else(|| "?".into()); | |
| 251 | + | sign_in(); | |
| 252 | + | return Ok(if vfs { format!("{version} (plain-copy storage: this disk takes no overlays, so images take more room)") } else { version }); | |
| 253 | + | } | |
| 254 | + | Err(problem) => { | |
| 255 | + | last = problem; | |
| 256 | + | let _ = child.kill(); | |
| 257 | + | let _ = sudo(&["pkill", "-x", "dockerd"]); | |
| 258 | + | let _ = sudo(&["pkill", "-x", "containerd"]); | |
| 259 | + | if vfs { | |
| 260 | + | break; | |
| 261 | + | } | |
| 262 | + | // Overlays that mount but do not work for the Engine. | |
| 263 | + | vfs = true; | |
| 264 | + | } | |
| 265 | + | } | |
| 266 | + | } | |
| 267 | + | Err(last) | |
| 268 | + | } | |
| 269 | + | ||
| 270 | + | fn spawn(ca_dir: Option<&str>) -> Result<Child, String> { | |
| 271 | + | let log = std::fs::File::create(LOG).map_err(|e| format!("could not write {LOG}: {e}"))?; | |
| 272 | + | let err = log.try_clone().map_err(|e| e.to_string())?; | |
| 273 | + | let path = format!("{BIN}:/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin"); | |
| 274 | + | let mut command = Command::new("sudo"); | |
| 275 | + | command.args(["-n", "env", &format!("PATH={path}"), "G1T_REAL_RUNC=/usr/bin/runc"]); | |
| 276 | + | if let Some(dir) = ca_dir { | |
| 277 | + | command.arg(format!("G1T_DOCKER_CA_DIR={dir}")); | |
| 278 | + | } | |
| 279 | + | command.args(["dockerd", "--config-file", &format!("{DIR}/daemon.json")]); | |
| 280 | + | command.stdin(Stdio::null()).stdout(log).stderr(err); | |
| 281 | + | command.spawn().map_err(|e| format!("could not run dockerd: {e}")) | |
| 282 | + | } | |
| 283 | + | ||
| 284 | + | /// A request to the Engine; its status and body. | |
| 285 | + | fn ask(method: &str, path: &str) -> io::Result<(u16, Vec<u8>)> { | |
| 286 | + | let mut stream = UnixStream::connect(ENGINE_SOCKET)?; | |
| 287 | + | stream.set_read_timeout(Some(Duration::from_secs(10)))?; | |
| 288 | + | stream.write_all(format!("{method} {path} HTTP/1.1\r\nHost: docker\r\nConnection: close\r\n\r\n").as_bytes())?; | |
| 289 | + | let mut reader = io::BufReader::new(stream); | |
| 290 | + | let head = super::super::http::read_head(&mut reader)?.ok_or_else(|| io::Error::other("no answer"))?; | |
| 291 | + | let body = super::super::http::read_body(&mut reader, super::super::http::response_body(&head, method))?; | |
| 292 | + | Ok((head.status(), body)) | |
| 293 | + | } | |
| 294 | + | ||
| 295 | + | fn wait_ready(child: &mut Child, limit: Duration) -> Result<(), String> { | |
| 296 | + | let until = Instant::now() + limit; | |
| 297 | + | loop { | |
| 298 | + | if matches!(ask("GET", "/_ping"), Ok((200, _))) { | |
| 299 | + | return Ok(()); | |
| 300 | + | } | |
| 301 | + | if let Ok(Some(status)) = child.try_wait() { | |
| 302 | + | return Err(format!("dockerd stopped ({status}):\n{}", tail(Path::new(LOG), 15))); | |
| 303 | + | } | |
| 304 | + | if Instant::now() >= until { | |
| 305 | + | return Err(format!("dockerd did not answer in {} s:\n{}", limit.as_secs(), tail(Path::new(LOG), 15))); | |
| 306 | + | } | |
| 307 | + | std::thread::sleep(Duration::from_millis(100)); | |
| 308 | + | } | |
| 309 | + | } | |
| 310 | + | ||
| 311 | + | fn engine_version() -> Option<String> { | |
| 312 | + | let (_, body) = ask("GET", "/version").ok()?; | |
| 313 | + | let value: Value = serde_json::from_slice(&body).ok()?; | |
| 314 | + | value.get("Version").and_then(Value::as_str).map(str::to_owned) | |
| 315 | + | } | |
| 316 | + | ||
| 317 | + | /// Signs the job in to g1t's registry with the run's own token. | |
| 318 | + | fn sign_in() { | |
| 319 | + | let Some((registry, token)) = OPTIONS.get().and_then(|o| o.registry.clone()) else { return }; | |
| 320 | + | let home = std::env::var("HOME").unwrap_or_else(|_| "/home/node".into()); | |
| 321 | + | let path = Path::new(&home).join(".docker").join("config.json"); | |
| 322 | + | let current: Value = std::fs::read(&path).ok().and_then(|t| serde_json::from_slice(&t).ok()).unwrap_or(Value::Null); | |
| 323 | + | if let Some(config) = with_login(current, ®istry, &token) { | |
| 324 | + | let _ = std::fs::create_dir_all(path.parent().expect("a folder")); | |
| 325 | + | if std::fs::write(&path, serde_json::to_vec_pretty(&config).unwrap_or_default()).is_ok() { | |
| 326 | + | let _ = std::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o600)); | |
| 327 | + | super::super::note(format!("Docker: signed in to {registry} with this run's token.")); | |
| 328 | + | } | |
| 329 | + | } | |
| 330 | + | } | |
| 331 | + | ||
| 332 | + | /// The sandbox, as the API proxy sees it. | |
| 333 | + | struct Sandbox; | |
| 334 | + | ||
| 335 | + | impl Host for Sandbox { | |
| 336 | + | fn add_hosts(&self, names: &[String]) { | |
| 337 | + | let lines: String = names.iter().map(|name| format!("127.0.0.1\t{name}\n")).collect(); | |
| 338 | + | if !sudo_with_input(&["sh", "-c", "cat >> /etc/hosts"], &lines) { | |
| 339 | + | super::super::note(format!("Docker: could not add {} to /etc/hosts.", names.join(", "))); | |
| 340 | + | } | |
| 341 | + | } | |
| 342 | + | ||
| 343 | + | fn forward(&self, host_port: u16, container_port: u16) { | |
| 344 | + | if !FORWARDED.lock().is_ok_and(|mut set| set.insert(host_port)) { | |
| 345 | + | return; | |
| 346 | + | } | |
| 347 | + | let listener = match TcpListener::bind(("0.0.0.0", host_port)) { | |
| 348 | + | Ok(listener) => listener, | |
| 349 | + | Err(error) => { | |
| 350 | + | super::super::note(format!("##[warning]Docker: port {host_port} could not be published for port {container_port}: {error}")); | |
| 351 | + | return; | |
| 352 | + | } | |
| 353 | + | }; | |
| 354 | + | std::thread::spawn(move || { | |
| 355 | + | for client in listener.incoming().flatten() { | |
| 356 | + | std::thread::spawn(move || { | |
| 357 | + | let Ok(target) = TcpStream::connect(("127.0.0.1", container_port)) else { return }; | |
| 358 | + | pipe(client, target); | |
| 359 | + | }); | |
| 360 | + | } | |
| 361 | + | }); | |
| 362 | + | } | |
| 363 | + | ||
| 364 | + | fn ensure_engine(&self) -> Result<(), String> { | |
| 365 | + | ensure_started().map(|_| ()) | |
| 366 | + | } | |
| 367 | + | ||
| 368 | + | fn connect(&self) -> io::Result<Box<dyn Duplex>> { | |
| 369 | + | Ok(Box::new(UnixStream::connect(ENGINE_SOCKET)?)) | |
| 370 | + | } | |
| 371 | + | } | |
| 372 | + | ||
| 373 | + | /// Copies two connections into each other until both are done. | |
| 374 | + | fn pipe(a: TcpStream, b: TcpStream) { | |
| 375 | + | let (Ok(mut a_read), Ok(mut b_read)) = (a.try_clone(), b.try_clone()) else { return }; | |
| 376 | + | let (mut a_write, mut b_write) = (a, b); | |
| 377 | + | let back = std::thread::spawn(move || { | |
| 378 | + | let _ = io::copy(&mut b_read, &mut a_write); | |
| 379 | + | let _ = a_write.shutdown(std::net::Shutdown::Write); | |
| 380 | + | }); | |
| 381 | + | let _ = io::copy(&mut a_read, &mut b_write); | |
| 382 | + | let _ = b_write.shutdown(std::net::Shutdown::Write); | |
| 383 | + | let _ = back.join(); | |
| 384 | + | } | |
| 385 | + | } | |
| 386 | + | ||
| 387 | + | #[cfg(test)] | |
| 388 | + | mod tests { | |
| 389 | + | use super::*; | |
| 390 | + | ||
| 391 | + | #[test] | |
| 392 | + | fn the_engine_runs_as_a_sandbox_allows() { | |
| 393 | + | let config = daemon_config("node", false); | |
| 394 | + | assert_eq!(config["iptables"], false); | |
| 395 | + | assert_eq!(config["ip6tables"], false); | |
| 396 | + | assert_eq!(config["ip-forward"], false); | |
| 397 | + | assert_eq!(config["group"], "node"); | |
| 398 | + | assert_eq!(config["hosts"][0], "unix:///run/g1t-docker/engine.sock"); | |
| 399 | + | assert_eq!(config["registry-mirrors"][0], MIRROR); | |
| 400 | + | assert!(config.get("storage-driver").is_none()); | |
| 401 | + | assert_eq!(daemon_config("node", true)["storage-driver"], "native"); | |
| 402 | + | } | |
| 403 | + | ||
| 404 | + | #[test] | |
| 405 | + | fn the_run_signs_in_to_g1t_unless_already_signed_in() { | |
| 406 | + | let config = with_login(json!({ "auths": { "ghcr.io": { "auth": "x" } } }), "g1t.sh", "tok").unwrap(); | |
| 407 | + | assert_eq!(config["auths"]["g1t.sh"]["auth"], "ZzF0OnRvaw=="); | |
| 408 | + | assert_eq!(config["auths"]["ghcr.io"]["auth"], "x"); | |
| 409 | + | assert!(with_login(config, "g1t.sh", "other").is_none()); | |
| 410 | + | assert!(with_login(Value::Null, "g1t.sh", "tok").is_some()); | |
| 411 | + | assert_eq!(registry_host("https://g1t.sh").as_deref(), Some("g1t.sh")); | |
| 412 | + | assert_eq!(registry_host("http://localhost:8787/").as_deref(), Some("localhost:8787")); | |
| 413 | + | } | |
| 414 | + | } |
| 1 | + | //! Just enough HTTP/1.1 to stand between the Docker CLI and the Engine: | |
| 2 | + | //! message heads, and bodies framed by `Content-Length`, chunked, or the | |
| 3 | + | //! end of the connection. Bodies pass through as they come (a chunk at a | |
| 4 | + | //! time, so `docker logs -f` and a pull's progress keep streaming), unless | |
| 5 | + | //! the proxy reads one whole to change it. | |
| 6 | + | ||
| 7 | + | use std::io::{self, BufRead, Read, Write}; | |
| 8 | + | ||
| 9 | + | /// The most a message head may be. The Engine's and the CLI's are a few | |
| 10 | + | /// hundred bytes. | |
| 11 | + | const MAX_HEAD: usize = 64 * 1024; | |
| 12 | + | /// The most a body read whole may be: a container's config or inspection | |
| 13 | + | /// is a few kilobytes. | |
| 14 | + | pub(crate) const MAX_BODY: u64 = 16 * 1024 * 1024; | |
| 15 | + | ||
| 16 | + | /// A request's or a response's start line and headers. | |
| 17 | + | #[derive(Clone, Debug, PartialEq)] | |
| 18 | + | pub(crate) struct Head { | |
| 19 | + | pub(crate) start: String, | |
| 20 | + | pub(crate) headers: Vec<(String, String)>, | |
| 21 | + | } | |
| 22 | + | ||
| 23 | + | impl Head { | |
| 24 | + | pub(crate) fn header(&self, name: &str) -> Option<&str> { | |
| 25 | + | self.headers.iter().find(|(key, _)| key.eq_ignore_ascii_case(name)).map(|(_, value)| value.as_str()) | |
| 26 | + | } | |
| 27 | + | ||
| 28 | + | pub(crate) fn remove_header(&mut self, name: &str) { | |
| 29 | + | self.headers.retain(|(key, _)| !key.eq_ignore_ascii_case(name)); | |
| 30 | + | } | |
| 31 | + | ||
| 32 | + | pub(crate) fn set_header(&mut self, name: &str, value: &str) { | |
| 33 | + | self.remove_header(name); | |
| 34 | + | self.headers.push((name.to_owned(), value.to_owned())); | |
| 35 | + | } | |
| 36 | + | ||
| 37 | + | /// A request's method. | |
| 38 | + | pub(crate) fn method(&self) -> &str { | |
| 39 | + | self.start.split(' ').next().unwrap_or_default() | |
| 40 | + | } | |
| 41 | + | ||
| 42 | + | /// A request's target: its path and query. | |
| 43 | + | pub(crate) fn target(&self) -> &str { | |
| 44 | + | self.start.split(' ').nth(1).unwrap_or_default() | |
| 45 | + | } | |
| 46 | + | ||
| 47 | + | pub(crate) fn set_target(&mut self, target: &str) { | |
| 48 | + | let mut parts: Vec<&str> = self.start.splitn(3, ' ').collect(); | |
| 49 | + | if parts.len() == 3 { | |
| 50 | + | parts[1] = target; | |
| 51 | + | self.start = parts.join(" "); | |
| 52 | + | } | |
| 53 | + | } | |
| 54 | + | ||
| 55 | + | /// A response's status code. | |
| 56 | + | pub(crate) fn status(&self) -> u16 { | |
| 57 | + | self.start.split(' ').nth(1).and_then(|code| code.parse().ok()).unwrap_or(0) | |
| 58 | + | } | |
| 59 | + | ||
| 60 | + | /// Whether the request asks to leave HTTP (`docker attach`, `exec`, | |
| 61 | + | /// BuildKit's `/grpc` and `/session`). | |
| 62 | + | pub(crate) fn upgrades(&self) -> bool { | |
| 63 | + | self.header("upgrade").is_some() || self.header("connection").is_some_and(|value| value.to_ascii_lowercase().contains("upgrade")) | |
| 64 | + | } | |
| 65 | + | ||
| 66 | + | pub(crate) fn to_bytes(&self) -> Vec<u8> { | |
| 67 | + | let mut out = String::with_capacity(256); | |
| 68 | + | out.push_str(&self.start); | |
| 69 | + | out.push_str("\r\n"); | |
| 70 | + | for (name, value) in &self.headers { | |
| 71 | + | out.push_str(name); | |
| 72 | + | out.push_str(": "); | |
| 73 | + | out.push_str(value); | |
| 74 | + | out.push_str("\r\n"); | |
| 75 | + | } | |
| 76 | + | out.push_str("\r\n"); | |
| 77 | + | out.into_bytes() | |
| 78 | + | } | |
| 79 | + | } | |
| 80 | + | ||
| 81 | + | /// Reads a message head. `None` when the connection ends before one starts. | |
| 82 | + | pub(crate) fn read_head<R: BufRead>(reader: &mut R) -> io::Result<Option<Head>> { | |
| 83 | + | let mut lines: Vec<String> = Vec::new(); | |
| 84 | + | let mut size = 0; | |
| 85 | + | loop { | |
| 86 | + | let mut line = Vec::new(); | |
| 87 | + | let read = reader.read_until(b'\n', &mut line)?; | |
| 88 | + | if read == 0 { | |
| 89 | + | if lines.is_empty() { | |
| 90 | + | return Ok(None); | |
| 91 | + | } | |
| 92 | + | return Err(io::Error::new(io::ErrorKind::UnexpectedEof, "the connection ended inside a message head")); | |
| 93 | + | } | |
| 94 | + | size += read; | |
| 95 | + | if size > MAX_HEAD { | |
| 96 | + | return Err(io::Error::new(io::ErrorKind::InvalidData, "a message head too large")); | |
| 97 | + | } | |
| 98 | + | let text = String::from_utf8_lossy(&line).trim_end_matches(['\r', '\n']).to_owned(); | |
| 99 | + | if text.is_empty() { | |
| 100 | + | // Blank lines before a request are allowed, and skipped. | |
| 101 | + | if lines.is_empty() { | |
| 102 | + | continue; | |
| 103 | + | } | |
| 104 | + | break; | |
| 105 | + | } | |
| 106 | + | lines.push(text); | |
| 107 | + | } | |
| 108 | + | let start = lines.remove(0); | |
| 109 | + | let headers = lines | |
| 110 | + | .into_iter() | |
| 111 | + | .filter_map(|line| line.split_once(':').map(|(name, value)| (name.trim().to_owned(), value.trim().to_owned()))) | |
| 112 | + | .collect(); | |
| 113 | + | Ok(Some(Head { start, headers })) | |
| 114 | + | } | |
| 115 | + | ||
| 116 | + | /// How a message's body ends. | |
| 117 | + | #[derive(Clone, Copy, Debug, PartialEq)] | |
| 118 | + | pub(crate) enum Body { | |
| 119 | + | None, | |
| 120 | + | Length(u64), | |
| 121 | + | Chunked, | |
| 122 | + | /// Until the connection closes: a response with no length. | |
| 123 | + | UntilClose, | |
| 124 | + | } | |
| 125 | + | ||
| 126 | + | fn chunked(head: &Head) -> bool { | |
| 127 | + | head.header("transfer-encoding").is_some_and(|value| value.to_ascii_lowercase().contains("chunked")) | |
| 128 | + | } | |
| 129 | + | ||
| 130 | + | fn length(head: &Head) -> Option<u64> { | |
| 131 | + | head.header("content-length").and_then(|value| value.trim().parse().ok()) | |
| 132 | + | } | |
| 133 | + | ||
| 134 | + | /// A request's body: chunked, a length, or none. | |
| 135 | + | pub(crate) fn request_body(head: &Head) -> Body { | |
| 136 | + | if chunked(head) { | |
| 137 | + | Body::Chunked | |
| 138 | + | } else { | |
| 139 | + | match length(head) { | |
| 140 | + | Some(0) | None => Body::None, | |
| 141 | + | Some(n) => Body::Length(n), | |
| 142 | + | } | |
| 143 | + | } | |
| 144 | + | } | |
| 145 | + | ||
| 146 | + | /// A response's body, which also depends on what was asked. | |
| 147 | + | pub(crate) fn response_body(head: &Head, method: &str) -> Body { | |
| 148 | + | let status = head.status(); | |
| 149 | + | if method.eq_ignore_ascii_case("HEAD") || (100..200).contains(&status) || status == 204 || status == 304 { | |
| 150 | + | return Body::None; | |
| 151 | + | } | |
| 152 | + | if chunked(head) { | |
| 153 | + | return Body::Chunked; | |
| 154 | + | } | |
| 155 | + | match length(head) { | |
| 156 | + | Some(0) => Body::None, | |
| 157 | + | Some(n) => Body::Length(n), | |
| 158 | + | None => Body::UntilClose, | |
| 159 | + | } | |
| 160 | + | } | |
| 161 | + | ||
| 162 | + | /// Copies a body as it is framed, flushing as each piece arrives. | |
| 163 | + | pub(crate) fn copy_body<R: BufRead, W: Write>(reader: &mut R, writer: &mut W, body: Body) -> io::Result<()> { | |
| 164 | + | match body { | |
| 165 | + | Body::None => Ok(()), | |
| 166 | + | Body::Length(n) => { | |
| 167 | + | let copied = io::copy(&mut reader.take(n), writer)?; | |
| 168 | + | writer.flush()?; | |
| 169 | + | if copied < n { | |
| 170 | + | return Err(io::Error::new(io::ErrorKind::UnexpectedEof, "the body ended early")); | |
| 171 | + | } | |
| 172 | + | Ok(()) | |
| 173 | + | } | |
| 174 | + | Body::UntilClose => { | |
| 175 | + | io::copy(reader, writer)?; | |
| 176 | + | writer.flush() | |
| 177 | + | } | |
| 178 | + | Body::Chunked => loop { | |
| 179 | + | let mut line = Vec::new(); | |
| 180 | + | if reader.read_until(b'\n', &mut line)? == 0 { | |
| 181 | + | return Err(io::Error::new(io::ErrorKind::UnexpectedEof, "a chunked body ended early")); | |
| 182 | + | } | |
| 183 | + | writer.write_all(&line)?; | |
| 184 | + | let size = chunk_size(&line)?; | |
| 185 | + | if size == 0 { | |
| 186 | + | // Trailers, then a blank line. | |
| 187 | + | loop { | |
| 188 | + | let mut trailer = Vec::new(); | |
| 189 | + | if reader.read_until(b'\n', &mut trailer)? == 0 { | |
| 190 | + | break; | |
| 191 | + | } | |
| 192 | + | writer.write_all(&trailer)?; | |
| 193 | + | if trailer == b"\r\n" || trailer == b"\n" { | |
| 194 | + | break; | |
| 195 | + | } | |
| 196 | + | } | |
| 197 | + | writer.flush()?; | |
| 198 | + | return Ok(()); | |
| 199 | + | } | |
| 200 | + | // The chunk and its CRLF. | |
| 201 | + | let copied = io::copy(&mut reader.take(size + 2), writer)?; | |
| 202 | + | writer.flush()?; | |
| 203 | + | if copied < size + 2 { | |
| 204 | + | return Err(io::Error::new(io::ErrorKind::UnexpectedEof, "a chunk ended early")); | |
| 205 | + | } | |
| 206 | + | }, | |
| 207 | + | } | |
| 208 | + | } | |
| 209 | + | ||
| 210 | + | fn chunk_size(line: &[u8]) -> io::Result<u64> { | |
| 211 | + | let text = String::from_utf8_lossy(line); | |
| 212 | + | let hex = text.trim().split(';').next().unwrap_or_default().trim(); | |
| 213 | + | u64::from_str_radix(hex, 16).map_err(|_| io::Error::new(io::ErrorKind::InvalidData, format!("not a chunk size: {hex:?}"))) | |
| 214 | + | } | |
| 215 | + | ||
| 216 | + | /// Reads a whole body, unframed. | |
| 217 | + | pub(crate) fn read_body<R: BufRead>(reader: &mut R, body: Body) -> io::Result<Vec<u8>> { | |
| 218 | + | let mut out = Vec::new(); | |
| 219 | + | match body { | |
| 220 | + | Body::None => {} | |
| 221 | + | Body::Length(n) => { | |
| 222 | + | if n > MAX_BODY { | |
| 223 | + | return Err(io::Error::new(io::ErrorKind::InvalidData, "a body too large to read")); | |
| 224 | + | } | |
| 225 | + | reader.take(n).read_to_end(&mut out)?; | |
| 226 | + | if (out.len() as u64) < n { | |
| 227 | + | return Err(io::Error::new(io::ErrorKind::UnexpectedEof, "the body ended early")); | |
| 228 | + | } | |
| 229 | + | } | |
| 230 | + | Body::UntilClose => { | |
| 231 | + | reader.take(MAX_BODY).read_to_end(&mut out)?; | |
| 232 | + | } | |
| 233 | + | Body::Chunked => loop { | |
| 234 | + | let mut line = Vec::new(); | |
| 235 | + | if reader.read_until(b'\n', &mut line)? == 0 { | |
| 236 | + | return Err(io::Error::new(io::ErrorKind::UnexpectedEof, "a chunked body ended early")); | |
| 237 | + | } | |
| 238 | + | let size = chunk_size(&line)?; | |
| 239 | + | if size == 0 { | |
| 240 | + | loop { | |
| 241 | + | let mut trailer = Vec::new(); | |
| 242 | + | if reader.read_until(b'\n', &mut trailer)? == 0 || trailer == b"\r\n" || trailer == b"\n" { | |
| 243 | + | break; | |
| 244 | + | } | |
| 245 | + | } | |
| 246 | + | break; | |
| 247 | + | } | |
| 248 | + | if out.len() as u64 + size > MAX_BODY { | |
| 249 | + | return Err(io::Error::new(io::ErrorKind::InvalidData, "a body too large to read")); | |
| 250 | + | } | |
| 251 | + | let mut chunk = Vec::new(); | |
| 252 | + | reader.take(size).read_to_end(&mut chunk)?; | |
| 253 | + | out.extend_from_slice(&chunk); | |
| 254 | + | let mut crlf = Vec::new(); | |
| 255 | + | reader.read_until(b'\n', &mut crlf)?; | |
| 256 | + | }, | |
| 257 | + | } | |
| 258 | + | Ok(out) | |
| 259 | + | } | |
| 260 | + | ||
| 261 | + | /// A head with its body framed by length, for a body the proxy rewrote. | |
| 262 | + | pub(crate) fn with_length(mut head: Head, body: &[u8]) -> Vec<u8> { | |
| 263 | + | head.remove_header("transfer-encoding"); | |
| 264 | + | head.set_header("Content-Length", &body.len().to_string()); | |
| 265 | + | let mut out = head.to_bytes(); | |
| 266 | + | out.extend_from_slice(body); | |
| 267 | + | out | |
| 268 | + | } | |
| 269 | + | ||
| 270 | + | /// A whole JSON response, as the Engine words its errors. | |
| 271 | + | pub(crate) fn json_response(status: u16, reason: &str, body: &serde_json::Value) -> Vec<u8> { | |
| 272 | + | let text = body.to_string(); | |
| 273 | + | let head = Head { | |
| 274 | + | start: format!("HTTP/1.1 {status} {reason}"), | |
| 275 | + | headers: vec![("Content-Type".into(), "application/json".into())], | |
| 276 | + | }; | |
| 277 | + | with_length(head, text.as_bytes()) | |
| 278 | + | } | |
| 279 | + | ||
| 280 | + | /// An empty `200 OK`. | |
| 281 | + | pub(crate) fn empty_ok() -> Vec<u8> { | |
| 282 | + | with_length(Head { start: "HTTP/1.1 200 OK".into(), headers: Vec::new() }, b"") | |
| 283 | + | } | |
| 284 | + | ||
| 285 | + | #[cfg(test)] | |
| 286 | + | mod tests { | |
| 287 | + | use super::*; | |
| 288 | + | use std::io::BufReader; | |
| 289 | + | ||
| 290 | + | #[test] | |
| 291 | + | fn heads_are_read_and_written_back() { | |
| 292 | + | let raw = b"POST /v1.47/containers/create?name=db HTTP/1.1\r\nHost: api.moby.localhost\r\nContent-Type: application/json\r\nContent-Length: 2\r\n\r\n{}"; | |
| 293 | + | let mut reader = BufReader::new(&raw[..]); | |
| 294 | + | let head = read_head(&mut reader).unwrap().unwrap(); | |
| 295 | + | assert_eq!(head.method(), "POST"); | |
| 296 | + | assert_eq!(head.target(), "/v1.47/containers/create?name=db"); | |
| 297 | + | assert_eq!(head.header("content-length"), Some("2")); | |
| 298 | + | assert_eq!(request_body(&head), Body::Length(2)); | |
| 299 | + | assert_eq!(read_body(&mut reader, Body::Length(2)).unwrap(), b"{}"); | |
| 300 | + | assert!(read_head(&mut reader).unwrap().is_none()); | |
| 301 | + | let mut again = head.clone(); | |
| 302 | + | again.set_target("/v1.47/containers/create"); | |
| 303 | + | assert!(String::from_utf8(again.to_bytes()).unwrap().starts_with("POST /v1.47/containers/create HTTP/1.1\r\n")); | |
| 304 | + | } | |
| 305 | + | ||
| 306 | + | #[test] | |
| 307 | + | fn chunked_bodies_copy_verbatim_and_read_unframed() { | |
| 308 | + | let raw = b"5\r\nhello\r\n6;ext=1\r\n world\r\n0\r\n\r\nNEXT"; | |
| 309 | + | let mut copied = Vec::new(); | |
| 310 | + | let mut reader = BufReader::new(&raw[..]); | |
| 311 | + | copy_body(&mut reader, &mut copied, Body::Chunked).unwrap(); | |
| 312 | + | assert_eq!(copied, &raw[..raw.len() - 4]); | |
| 313 | + | let mut rest = String::new(); | |
| 314 | + | reader.read_to_string(&mut rest).unwrap(); | |
| 315 | + | assert_eq!(rest, "NEXT"); | |
| 316 | + | let mut reader = BufReader::new(&raw[..]); | |
| 317 | + | assert_eq!(read_body(&mut reader, Body::Chunked).unwrap(), b"hello world"); | |
| 318 | + | } | |
| 319 | + | ||
| 320 | + | #[test] | |
| 321 | + | fn response_bodies_follow_the_request_and_the_status() { | |
| 322 | + | let head = |start: &str, headers: &[(&str, &str)]| Head { | |
| 323 | + | start: start.into(), | |
| 324 | + | headers: headers.iter().map(|(k, v)| (k.to_string(), v.to_string())).collect(), | |
| 325 | + | }; | |
| 326 | + | assert_eq!(response_body(&head("HTTP/1.1 200 OK", &[("Content-Length", "10")]), "HEAD"), Body::None); | |
| 327 | + | assert_eq!(response_body(&head("HTTP/1.1 204 No Content", &[]), "POST"), Body::None); | |
| 328 | + | assert_eq!(response_body(&head("HTTP/1.1 101 UPGRADED", &[]), "POST"), Body::None); | |
| 329 | + | assert_eq!(response_body(&head("HTTP/1.1 200 OK", &[("Transfer-Encoding", "chunked")]), "GET"), Body::Chunked); | |
| 330 | + | assert_eq!(response_body(&head("HTTP/1.1 200 OK", &[]), "GET"), Body::UntilClose); | |
| 331 | + | assert!(head("POST /grpc HTTP/1.1", &[("Connection", "Upgrade"), ("Upgrade", "h2c")]).upgrades()); | |
| 332 | + | } | |
| 333 | + | ||
| 334 | + | #[test] | |
| 335 | + | fn a_rewritten_body_gets_its_length() { | |
| 336 | + | let head = Head { start: "HTTP/1.1 200 OK".into(), headers: vec![("Transfer-Encoding".into(), "chunked".into())] }; | |
| 337 | + | let out = String::from_utf8(with_length(head, b"{\"a\":1}")).unwrap(); | |
| 338 | + | assert!(out.contains("Content-Length: 7\r\n")); | |
| 339 | + | assert!(!out.to_ascii_lowercase().contains("chunked")); | |
| 340 | + | assert!(out.ends_with("\r\n\r\n{\"a\":1}")); | |
| 341 | + | } | |
| 342 | + | } |
| 1 | + | //! Docker in a workflow job on g1t's own machines: a Docker Engine of the | |
| 2 | + | //! job's own, inside its sandbox, started the first time anything asks | |
| 3 | + | //! for it, and gone with the sandbox when the job ends. | |
| 4 | + | //! | |
| 5 | + | //! - `engine` starts it, as root inside the sandbox (as Cloudflare | |
| 6 | + | //! Containers run Docker), with no iptables and no IP forwarding, which | |
| 7 | + | //! a sandbox does not have. | |
| 8 | + | //! - `api` is `/var/run/docker.sock`: the Engine's API, with containers | |
| 9 | + | //! moved to the job's own network, where its guardrails apply. | |
| 10 | + | //! - `oci` is the `runc` the Engine runs containers with: build steps on | |
| 11 | + | //! the job's network, and a guarded job's egress certificate in every | |
| 12 | + | //! container. | |
| 13 | + | //! - `http` is the HTTP/1.1 the proxy reads. | |
| 14 | + | //! | |
| 15 | + | //! Nothing here is shared with another job: each job has its own sandbox, | |
| 16 | + | //! and so its own Engine, images and build cache. | |
| 17 | + | ||
| 18 | + | // Off g1t's Linux machines only the pure parts are used, by tests. | |
| 19 | + | #![cfg_attr(not(target_os = "linux"), allow(dead_code))] | |
| 20 | + | ||
| 21 | + | pub(crate) mod api; | |
| 22 | + | pub(crate) mod engine; | |
| 23 | + | pub(crate) mod http; | |
| 24 | + | pub(crate) mod oci; | |
| 25 | + | ||
| 26 | + | use std::sync::Mutex; | |
| 27 | + | ||
| 28 | + | /// Lines for the job's log, from threads that do not hold it: the Engine | |
| 29 | + | /// starting, a port that could not be forwarded. | |
| 30 | + | static NOTES: Mutex<Vec<String>> = Mutex::new(Vec::new()); | |
| 31 | + | ||
| 32 | + | pub(crate) fn note(line: impl Into<String>) { | |
| 33 | + | if let Ok(mut notes) = NOTES.lock() { | |
| 34 | + | notes.push(line.into()); | |
| 35 | + | } | |
| 36 | + | } | |
| 37 | + | ||
| 38 | + | /// The lines noted since the last call. | |
| 39 | + | pub(crate) fn take_notes() -> Vec<String> { | |
| 40 | + | NOTES.lock().map(|mut notes| std::mem::take(&mut *notes)).unwrap_or_default() | |
| 41 | + | } |
| 1 | + | //! `runc`, as the job's Docker Engine finds it. The Engine is started with | |
| 2 | + | //! a folder of g1t's first on its `PATH`, holding this program under the | |
| 3 | + | //! name `runc`, so every container it and its builder start passes | |
| 4 | + | //! through here on its way to the real runc. Two changes, then the real | |
| 5 | + | //! runc runs with the same arguments: | |
| 6 | + | //! | |
| 7 | + | //! - **BuildKit's `RUN` steps** that would join a bridge network join the | |
| 8 | + | //! job's own network instead (their network namespace and libnetwork's | |
| 9 | + | //! hook are taken out of the container's config), because a sandbox has | |
| 10 | + | //! no route out of a bridge. `RUN --network=none` stays without one. | |
| 11 | + | //! - **In a guarded job**, every container (`docker run`, services, build | |
| 12 | + | //! steps, `docker exec`) gets the certificate the job's HTTPS is | |
| 13 | + | //! re-signed with: the folder of certificates at `/dev/g1t-egress`, and | |
| 14 | + | //! the variables that point common tools at it, unless the container | |
| 15 | + | //! sets them itself. `/dev` is a filesystem of the container's own, so | |
| 16 | + | //! nothing of this is ever written into an image's layers. | |
| 17 | + | //! | |
| 18 | + | //! Anything unexpected leaves the config as it was: this never stops a | |
| 19 | + | //! container from starting. | |
| 20 | + | ||
| 21 | + | use serde_json::{Value, json}; | |
| 22 | + | ||
| 23 | + | /// Where the certificates are seen inside a container. | |
| 24 | + | pub(crate) const CA_MOUNT: &str = "/dev/g1t-egress"; | |
| 25 | + | /// The system's bundle, with the egress certificate added. | |
| 26 | + | pub(crate) const BUNDLE: &str = "ca-certificates.crt"; | |
| 27 | + | /// The egress certificate alone. | |
| 28 | + | pub(crate) const EGRESS: &str = "egress-ca.crt"; | |
| 29 | + | ||
| 30 | + | /// The variables a container is given in a guarded job, as the sandbox's | |
| 31 | + | /// own (services/runner egress.ts `EGRESS_ENV`) point its tools. | |
| 32 | + | pub(crate) fn ca_variables() -> Vec<(&'static str, String)> { | |
| 33 | + | let bundle = format!("{CA_MOUNT}/{BUNDLE}"); | |
| 34 | + | vec![ | |
| 35 | + | ("SSL_CERT_FILE", bundle.clone()), | |
| 36 | + | ("NODE_EXTRA_CA_CERTS", format!("{CA_MOUNT}/{EGRESS}")), | |
| 37 | + | ("REQUESTS_CA_BUNDLE", bundle.clone()), | |
| 38 | + | ("CURL_CA_BUNDLE", bundle.clone()), | |
| 39 | + | ("PIP_CERT", bundle.clone()), | |
| 40 | + | ("GIT_SSL_CAINFO", bundle.clone()), | |
| 41 | + | ("CARGO_HTTP_CAINFO", bundle), | |
| 42 | + | ] | |
| 43 | + | } | |
| 44 | + | ||
| 45 | + | /// What runc was asked to do, from its arguments. | |
| 46 | + | #[derive(Debug, Default, PartialEq)] | |
| 47 | + | pub(crate) struct Call { | |
| 48 | + | pub(crate) command: Option<String>, | |
| 49 | + | pub(crate) bundle: Option<String>, | |
| 50 | + | /// `runc exec --process <file>`. | |
| 51 | + | pub(crate) process: Option<String>, | |
| 52 | + | } | |
| 53 | + | ||
| 54 | + | /// runc's global flags that take a value. | |
| 55 | + | const GLOBAL_VALUES: &[&str] = &["--root", "--log", "--log-format", "--criu", "--rootless"]; | |
| 56 | + | ||
| 57 | + | pub(crate) fn parse(args: &[String]) -> Call { | |
| 58 | + | let mut call = Call::default(); | |
| 59 | + | let mut iter = args.iter().peekable(); | |
| 60 | + | while let Some(arg) = iter.next() { | |
| 61 | + | if call.command.is_none() { | |
| 62 | + | if GLOBAL_VALUES.contains(&arg.as_str()) { | |
| 63 | + | iter.next(); | |
| 64 | + | } else if !arg.starts_with('-') { | |
| 65 | + | call.command = Some(arg.clone()); | |
| 66 | + | } | |
| 67 | + | continue; | |
| 68 | + | } | |
| 69 | + | let (flag, inline) = match arg.split_once('=') { | |
| 70 | + | Some((flag, value)) if flag.starts_with("--") => (flag, Some(value.to_owned())), | |
| 71 | + | _ => (arg.as_str(), None), | |
| 72 | + | }; | |
| 73 | + | let mut value = || inline.clone().or_else(|| iter.next().cloned()); | |
| 74 | + | match flag { | |
| 75 | + | "--bundle" | "-b" => call.bundle = value(), | |
| 76 | + | "--process" | "-p" => call.process = value(), | |
| 77 | + | _ => {} | |
| 78 | + | } | |
| 79 | + | } | |
| 80 | + | call | |
| 81 | + | } | |
| 82 | + | ||
| 83 | + | /// Whether a hook entry is libnetwork's, which joins a container to a | |
| 84 | + | /// network the Engine set up. | |
| 85 | + | fn libnetwork_hook(hook: &Value) -> bool { | |
| 86 | + | hook.get("args") | |
| 87 | + | .and_then(Value::as_array) | |
| 88 | + | .is_some_and(|args| args.iter().any(|a| a.as_str().is_some_and(|a| a.contains("libnetwork-setkey")))) | |
| 89 | + | } | |
| 90 | + | ||
| 91 | + | /// Adds the certificates and their variables to a process's environment. | |
| 92 | + | fn add_variables(process: &mut Value) -> bool { | |
| 93 | + | let Some(env) = process.get_mut("env").and_then(Value::as_array_mut) else { return false }; | |
| 94 | + | let mut changed = false; | |
| 95 | + | for (name, value) in ca_variables() { | |
| 96 | + | let prefix = format!("{name}="); | |
| 97 | + | if !env.iter().any(|e| e.as_str().is_some_and(|e| e.starts_with(&prefix))) { | |
| 98 | + | env.push(json!(format!("{name}={value}"))); | |
| 99 | + | changed = true; | |
| 100 | + | } | |
| 101 | + | } | |
| 102 | + | changed | |
| 103 | + | } | |
| 104 | + | ||
| 105 | + | /// Changes a container's `config.json`. `ca_dir`: the folder of | |
| 106 | + | /// certificates to give it, in a guarded job. Returns whether it changed. | |
| 107 | + | pub(crate) fn patch_config(config: &mut Value, bundle: &str, ca_dir: Option<&str>) -> bool { | |
| 108 | + | let mut changed = false; | |
| 109 | + | // A BuildKit step bound for a bridge network: the job's network instead. | |
| 110 | + | if bundle.contains("/buildkit/") { | |
| 111 | + | let mut bridged = false; | |
| 112 | + | if let Some(hooks) = config.get_mut("hooks").and_then(Value::as_object_mut) { | |
| 113 | + | for list in hooks.values_mut() { | |
| 114 | + | if let Some(entries) = list.as_array_mut() { | |
| 115 | + | let before = entries.len(); | |
| 116 | + | entries.retain(|hook| !libnetwork_hook(hook)); | |
| 117 | + | bridged |= entries.len() != before; | |
| 118 | + | } | |
| 119 | + | } | |
| 120 | + | } | |
| 121 | + | if bridged && let Some(namespaces) = config.pointer_mut("/linux/namespaces").and_then(Value::as_array_mut) { | |
| 122 | + | namespaces.retain(|ns| ns.get("type").and_then(Value::as_str) != Some("network")); | |
| 123 | + | changed = true; | |
| 124 | + | } | |
| 125 | + | } | |
| 126 | + | if let Some(dir) = ca_dir { | |
| 127 | + | if let Some(mounts) = config.get_mut("mounts").and_then(Value::as_array_mut) | |
| 128 | + | && !mounts.iter().any(|m| m.get("destination").and_then(Value::as_str) == Some(CA_MOUNT)) | |
| 129 | + | { | |
| 130 | + | mounts.push(json!({ | |
| 131 | + | "destination": CA_MOUNT, | |
| 132 | + | "type": "bind", | |
| 133 | + | "source": dir, | |
| 134 | + | "options": ["rbind", "ro", "nosuid", "nodev", "noexec"], | |
| 135 | + | })); | |
| 136 | + | changed = true; | |
| 137 | + | } | |
| 138 | + | if let Some(process) = config.get_mut("process") { | |
| 139 | + | changed |= add_variables(process); | |
| 140 | + | } | |
| 141 | + | } | |
| 142 | + | changed | |
| 143 | + | } | |
| 144 | + | ||
| 145 | + | /// Changes the process of a `runc exec` (`docker exec`), which has an | |
| 146 | + | /// environment of its own. | |
| 147 | + | pub(crate) fn patch_process(process: &mut Value, ca_dir: Option<&str>) -> bool { | |
| 148 | + | ca_dir.is_some() && add_variables(process) | |
| 149 | + | } | |
| 150 | + | ||
| 151 | + | /// Whether this program was started as `runc`. | |
| 152 | + | pub(crate) fn invoked_as_runc() -> bool { | |
| 153 | + | std::env::args_os() | |
| 154 | + | .next() | |
| 155 | + | .and_then(|arg| std::path::Path::new(&arg).file_name().map(|name| name == "runc")) | |
| 156 | + | .unwrap_or(false) | |
| 157 | + | } | |
| 158 | + | ||
| 159 | + | /// Rewrites a JSON file in place, if `change` changes it. | |
| 160 | + | #[cfg(unix)] | |
| 161 | + | fn rewrite(path: &std::path::Path, change: impl FnOnce(&mut Value) -> bool) { | |
| 162 | + | let Ok(text) = std::fs::read(path) else { return }; | |
| 163 | + | let Ok(mut value) = serde_json::from_slice::<Value>(&text) else { return }; | |
| 164 | + | if change(&mut value) | |
| 165 | + | && let Ok(out) = serde_json::to_vec(&value) | |
| 166 | + | { | |
| 167 | + | let staged = path.with_extension("g1t"); | |
| 168 | + | if std::fs::write(&staged, out).is_ok() { | |
| 169 | + | let _ = std::fs::rename(&staged, path); | |
| 170 | + | } | |
| 171 | + | } | |
| 172 | + | } | |
| 173 | + | ||
| 174 | + | /// `runc …`: changes the container's config, then becomes the real runc. | |
| 175 | + | #[cfg(unix)] | |
| 176 | + | pub(crate) fn main() -> ! { | |
| 177 | + | use std::os::unix::process::CommandExt; | |
| 178 | + | let args: Vec<String> = std::env::args().skip(1).collect(); | |
| 179 | + | let call = parse(&args); | |
| 180 | + | let ca_dir = std::env::var("G1T_DOCKER_CA_DIR").ok().filter(|dir| std::path::Path::new(dir).is_dir()); | |
| 181 | + | match (call.command.as_deref(), &call.bundle, &call.process) { | |
| 182 | + | (Some("create" | "run"), Some(bundle), _) => { | |
| 183 | + | rewrite(&std::path::Path::new(bundle).join("config.json"), |config| patch_config(config, bundle, ca_dir.as_deref())); | |
| 184 | + | } | |
| 185 | + | (Some("exec"), _, Some(process)) => rewrite(std::path::Path::new(process), |process| patch_process(process, ca_dir.as_deref())), | |
| 186 | + | _ => {} | |
| 187 | + | } | |
| 188 | + | let real = std::env::var("G1T_REAL_RUNC").unwrap_or_else(|_| "/usr/bin/runc".into()); | |
| 189 | + | let error = std::process::Command::new(&real).arg0("runc").args(&args).exec(); | |
| 190 | + | eprintln!("g1t-runner: could not run {real}: {error}"); | |
| 191 | + | std::process::exit(127) | |
| 192 | + | } | |
| 193 | + | ||
| 194 | + | #[cfg(not(unix))] | |
| 195 | + | pub(crate) fn main() -> ! { | |
| 196 | + | eprintln!("g1t-runner: runc runs on Linux only"); | |
| 197 | + | std::process::exit(127) | |
| 198 | + | } | |
| 199 | + | ||
| 200 | + | #[cfg(test)] | |
| 201 | + | mod tests { | |
| 202 | + | use super::*; | |
| 203 | + | ||
| 204 | + | fn args(text: &str) -> Vec<String> { | |
| 205 | + | text.split_whitespace().map(str::to_owned).collect() | |
| 206 | + | } | |
| 207 | + | ||
| 208 | + | #[test] | |
| 209 | + | fn calls_are_read_as_containerd_and_buildkit_make_them() { | |
| 210 | + | let call = parse(&args( | |
| 211 | + | "--root /var/run/docker/runtime-runc/moby --log /x/log.json --log-format json create --bundle /var/run/docker/containerd/daemon/io.containerd.runtime.v2.task/moby/abc --pid-file /x/init.pid abc", | |
| 212 | + | )); | |
| 213 | + | assert_eq!(call.command.as_deref(), Some("create")); | |
| 214 | + | assert_eq!(call.bundle.as_deref(), Some("/var/run/docker/containerd/daemon/io.containerd.runtime.v2.task/moby/abc")); | |
| 215 | + | let call = parse(&args("--log /var/lib/docker/buildkit/executor/runc-log.json --log-format json run --bundle /var/lib/docker/buildkit/executor/x1 --keep x1")); | |
| 216 | + | assert_eq!((call.command.as_deref(), call.bundle.as_deref()), (Some("run"), Some("/var/lib/docker/buildkit/executor/x1"))); | |
| 217 | + | let call = parse(&args("--root /r exec --process /tmp/runc-process1 --detach --pid-file /p abc")); | |
| 218 | + | assert_eq!((call.command.as_deref(), call.process.as_deref()), (Some("exec"), Some("/tmp/runc-process1"))); | |
| 219 | + | assert_eq!(parse(&args("--root=/r start abc")).command.as_deref(), Some("start")); | |
| 220 | + | assert_eq!(parse(&args("create --bundle=/b x")).bundle.as_deref(), Some("/b")); | |
| 221 | + | assert_eq!(parse(&args("--version")), Call::default()); | |
| 222 | + | } | |
| 223 | + | ||
| 224 | + | fn build_step(hooked: bool) -> Value { | |
| 225 | + | let hooks = if hooked { | |
| 226 | + | json!({ "prestart": [{ "path": "/proc/21/exe", "args": ["libnetwork-setkey", "-exec-root=/var/run/docker", "abc", "def"] }] }) | |
| 227 | + | } else { | |
| 228 | + | json!({}) | |
| 229 | + | }; | |
| 230 | + | json!({ | |
| 231 | + | "hostname": "buildkitsandbox", | |
| 232 | + | "process": { "env": ["PATH=/usr/bin", "SSL_CERT_FILE=/mine.pem"] }, | |
| 233 | + | "mounts": [{ "destination": "/proc" }, { "destination": "/dev", "type": "tmpfs" }], | |
| 234 | + | "linux": { "namespaces": [{ "type": "pid" }, { "type": "network" }, { "type": "mount" }] }, | |
| 235 | + | "hooks": hooks, | |
| 236 | + | }) | |
| 237 | + | } | |
| 238 | + | ||
| 239 | + | #[test] | |
| 240 | + | fn build_steps_bound_for_a_bridge_join_the_jobs_network() { | |
| 241 | + | let mut config = build_step(true); | |
| 242 | + | assert!(patch_config(&mut config, "/var/lib/docker/buildkit/executor/x1", None)); | |
| 243 | + | let namespaces: Vec<&str> = config["linux"]["namespaces"].as_array().unwrap().iter().map(|n| n["type"].as_str().unwrap()).collect(); | |
| 244 | + | assert_eq!(namespaces, vec!["pid", "mount"]); | |
| 245 | + | assert!(config["hooks"]["prestart"].as_array().unwrap().is_empty()); | |
| 246 | + | // RUN --network=none: no libnetwork hook, so its own empty network. | |
| 247 | + | let mut config = build_step(false); | |
| 248 | + | assert!(!patch_config(&mut config, "/var/lib/docker/buildkit/executor/x2", None)); | |
| 249 | + | assert_eq!(config["linux"]["namespaces"].as_array().unwrap().len(), 3); | |
| 250 | + | // A container the Engine runs is the API proxy's business, not this. | |
| 251 | + | let mut config = build_step(true); | |
| 252 | + | assert!(!patch_config(&mut config, "/run/containerd/io.containerd.runtime.v2.task/moby/abc", None)); | |
| 253 | + | } | |
| 254 | + | ||
| 255 | + | #[test] | |
| 256 | + | fn guarded_containers_get_the_certificates_without_losing_their_own_settings() { | |
| 257 | + | let mut config = build_step(false); | |
| 258 | + | assert!(patch_config(&mut config, "/run/containerd/io.containerd.runtime.v2.task/moby/abc", Some("/run/g1t-docker/certs"))); | |
| 259 | + | let mounts = config["mounts"].as_array().unwrap(); | |
| 260 | + | let last = mounts.last().unwrap(); | |
| 261 | + | assert_eq!(last["destination"], CA_MOUNT); | |
| 262 | + | assert_eq!(last["source"], "/run/g1t-docker/certs"); | |
| 263 | + | assert!(last["options"].as_array().unwrap().contains(&json!("ro"))); | |
| 264 | + | let env: Vec<&str> = config["process"]["env"].as_array().unwrap().iter().map(|e| e.as_str().unwrap()).collect(); | |
| 265 | + | assert!(env.contains(&"SSL_CERT_FILE=/mine.pem")); | |
| 266 | + | assert!(!env.contains(&"SSL_CERT_FILE=/dev/g1t-egress/ca-certificates.crt")); | |
| 267 | + | assert!(env.contains(&"NODE_EXTRA_CA_CERTS=/dev/g1t-egress/egress-ca.crt")); | |
| 268 | + | // Patching twice changes nothing more. | |
| 269 | + | assert!(!patch_config(&mut config, "/run/containerd/io.containerd.runtime.v2.task/moby/abc", Some("/run/g1t-docker/certs"))); | |
| 270 | + | let mut process = json!({ "env": ["PATH=/bin"] }); | |
| 271 | + | assert!(patch_process(&mut process, Some("/run/g1t-docker/certs"))); | |
| 272 | + | assert!(!patch_process(&mut json!({ "env": [] }), None)); | |
| 273 | + | } | |
| 274 | + | } |
| 40 | 40 | mod clone; | |
| 41 | 41 | mod confidence; | |
| 42 | 42 | mod deploy; | |
| 43 | + | mod docker; | |
| 43 | 44 | mod guard; | |
| 44 | 45 | mod harness; | |
| 45 | 46 | mod learned; | |
| 206 | 207 | } | |
| 207 | 208 | ||
| 208 | 209 | fn main() { | |
| 210 | + | // The runc the job's Docker Engine starts containers with (docker/oci.rs). | |
| 211 | + | if docker::oci::invoked_as_runc() { | |
| 212 | + | docker::oci::main(); | |
| 213 | + | } | |
| 209 | 214 | // A self-hosted runner's commands; the modes below are what it, and | |
| 210 | 215 | // g1t's sandboxes, run work with. | |
| 211 | 216 | let args: Vec<String> = std::env::args().skip(1).collect(); |
| 140 | 140 | let mut command = Command::new("docker"); | |
| 141 | 141 | command.args(["run", "--rm", "--name", &name, "--label", &format!("sh.g1t.runner={}", config.runner)]); | |
| 142 | 142 | command.args(["--pull", "missing", "--init"]); | |
| 143 | + | // The job is in its `container:` image already: the harness inside | |
| 144 | + | // does not start it again (actions/containers.rs). | |
| 145 | + | command.args(["-e", "G1T_JOB_CONTAINER=1"]); | |
| 143 | 146 | for name in env.keys() { | |
| 144 | 147 | command.args(["-e", name]); | |
| 145 | 148 | } |
| 303 | 303 | keeps `runs.gateway_note`, its correction says why, and it raises the | |
| 304 | 304 | **Unpriced** drift. For agent runs g1t keeps no token rates of its own: | |
| 305 | 305 | the first figure is Claude Code's, the final one the gateway's. AI Gateway | |
| 306 | − | requests are the exception: they are charged from `gateway_models` (one | |
| 307 | − | row per model offered), which has to follow the provider's price list by | |
| 306 | + | requests are the exception: they are charged from `gateway_models` (the | |
| 307 | + | model catalogue, below), which has to follow the provider's price list by | |
| 308 | 308 | hand until they are settled like runs. It is not part of the price book's | |
| 309 | − | `price_versions`: a price change is a migration that updates the row and | |
| 310 | − | its `updated_at` (as 0047 did for Sonnet 5.5's cache reads), and the | |
| 311 | − | `gateway_models` meter's markup is the only price-book number on it. Open | |
| 309 | + | `price_versions`: a new model's prices are confirmed when staff approve it | |
| 310 | + | in sudo, and a changed price on a model already offered is a migration that | |
| 311 | + | updates the row and its `updated_at` (as 0047 did for Sonnet 5.5's cache | |
| 312 | + | reads). The `gateway_models` meter's markup is the only price-book number | |
| 313 | + | on it. Open | |
| 312 | 314 | models need `WORKERS_AI_TOKEN` (a Cloudflare API token with Workers AI on | |
| 313 | 315 | g1t's account) on the model proxy; without it, and without | |
| 314 | 316 | `AI_GATEWAY_TOKEN` holding that permission, they are refused with `503`. | |
| 344 | 346 | Every usage path goes through this: `finish_run`, settling, sandbox time, | |
| 345 | 347 | features and builds (`charge_feature`), and the month-end meters. | |
| 346 | 348 | ||
| 349 | + | ## The model catalogue | |
| 350 | + | ||
| 351 | + | Every model g1t can use is one row of `gateway_models` (migrations 0045, | |
| 352 | + | 0047 and `0048_model_catalogue.sql`; code in `catalogue.rs`): the agents' | |
| 353 | + | tiers, the AI Gateway's Claude and open models, and the embeddings model. | |
| 354 | + | Billing keeps it because billing owns prices: the gateway charges from the | |
| 355 | + | same row, so there is one price per model, not two that drift. Page: sudo | |
| 356 | + | **Agents & models** (`/agents`). | |
| 357 | + | ||
| 358 | + | | Column | What | | |
| 359 | + | | --- | --- | | |
| 360 | + | | `model`, `name`, `provider`, `kind` | The provider's id, the name people see, `anthropic` or `workers-ai`, `chat` or `embeddings` | | |
| 361 | + | | `aliases` | Other ids the provider lists it by, comma separated (a dated id such as `claude-haiku-4-5-20251001`) | | |
| 362 | + | | `family`, `tier_hint` | `haiku`, `sonnet`, `opus`, `fable` (or a Workers AI author); the agent tier it suits: `small`, `large`, `frontier` | | |
| 363 | + | | `context_window`, `max_output`, `capabilities`, `dimensions` | From the provider's list where it gives them: `effort`, `thinking`, `tools`, `vision`, `embeddings`; an embeddings model's vector length | | |
| 364 | + | | Prices | Per million tokens in millionths: input, output, cache read, five-minute and hour-long cache writes, and for a model priced by prompt length the `threshold` and `over_` prices | | |
| 365 | + | | `status` | `available` (routed to and offered), `new` (found, not approved), `deprecated` (the provider stopped listing it), `retired` (staff took it out) | | |
| 366 | + | | `priced` | 1 when its prices are known | | |
| 367 | + | | `source`, `first_seen_at`, `last_seen_at`, `missing_since`, `approved_by`, `approved_at`, `note` | Where it came from (`discovered` or `staff`) and its history | | |
| 368 | + | ||
| 369 | + | What each status allows: | |
| 370 | + | ||
| 371 | + | | Status | Default for a purpose | Offered on the AI Gateway | Charged | | |
| 372 | + | | --- | --- | --- | --- | | |
| 373 | + | | `available`, priced | Yes | Yes | Yes | | |
| 374 | + | | `new`, or unpriced | No | No: refused before it reaches a provider | No | | |
| 375 | + | | `deprecated` | No: a default that chose it falls back | Yes, to whoever names it | Yes | | |
| 376 | + | | `retired` | No: a default that chose it falls back | No | No | | |
| 377 | + | ||
| 378 | + | ### Discovery | |
| 379 | + | ||
| 380 | + | The models service lists each provider once a day (`29 5 * * *`, after | |
| 381 | + | billing's daily run) and whenever staff press **Check for new models** | |
| 382 | + | (`services/models/src/discover.ts`, through its `Discovery` entrypoint, | |
| 383 | + | which only sudo binds). Listing models is free; nothing calls a model. | |
| 384 | + | ||
| 385 | + | | Provider | How it is listed | Credentials (on g1t-models) | | |
| 386 | + | | --- | --- | --- | | |
| 387 | + | | Anthropic | `GET /v1/models` through g1t's AI Gateway (`…/anthropic/v1/models`, tagged `task: discovery`), or straight to Anthropic with g1t's key when there is no gateway | `AI_GATEWAY_TOKEN` (the gateway holds Anthropic's key), or `ANTHROPIC_API_KEY` | | |
| 388 | + | | Workers AI | `GET /accounts/{account}/ai/models/search` | `WORKERS_AI_TOKEN` (Workers AI Read), else `AI_GATEWAY_TOKEN` | | |
| 389 | + | ||
| 390 | + | Each provider's list goes to billing's `record_discovery`, which compares | |
| 391 | + | it with the catalogue: | |
| 392 | + | ||
| 393 | + | - **An id it has never seen** is added as `new`. Chat and embeddings | |
| 394 | + | models only. Anthropic models are priced from the price table in | |
| 395 | + | `catalogue.rs` (`ANTHROPIC_PRICES`: Anthropic's list prices by model, a | |
| 396 | + | dated id priced as its model) when it has them; Workers AI models from | |
| 397 | + | the price their listing gives, with cached tokens at the input price. | |
| 398 | + | Anything else is added unpriced. Its family and tier hint come from its | |
| 399 | + | id (`claude-haiku-*` is fast, `claude-sonnet-*` standard, | |
| 400 | + | `claude-opus-*` and `claude-fable-*` most capable). | |
| 401 | + | - **A dated id of a model it has** (`claude-sonnet-5-5-20261001`) is that | |
| 402 | + | model: the id is added to its aliases. | |
| 403 | + | - **A model it has that is listed** gets `last_seen_at`, and its context | |
| 404 | + | window, output limit and capabilities from the list. | |
| 405 | + | - **A model it has that is not listed** (available or new) becomes | |
| 406 | + | `deprecated`, with `missing_since`. Listed again, it goes back to | |
| 407 | + | `available` if it was ever approved, else to `new`. | |
| 408 | + | - **An empty list or a failed one** changes nothing: it is recorded as a | |
| 409 | + | failed check with the provider's answer (never a key). | |
| 410 | + | ||
| 411 | + | Every check is kept 90 days in `model_checks` and shown on the page. When a | |
| 412 | + | check adds, deprecates or restores anything, it is in the audit log | |
| 413 | + | (`models_discovered`, as `schedule` or the staff member) and staff are | |
| 414 | + | emailed at `COSTS_ALERT_EMAIL` with a link to Agents & models. | |
| 415 | + | ||
| 416 | + | When Anthropic publishes a new model's price, add a row to | |
| 417 | + | `ANTHROPIC_PRICES`, so the next one of its kind arrives priced. Until then | |
| 418 | + | staff enter the prices when they approve it. | |
| 419 | + | ||
| 420 | + | ### Approving, retiring and restoring | |
| 421 | + | ||
| 422 | + | On Agents & models, **New models** lists every `new` model with its prices | |
| 423 | + | filled in where they are known. To approve one: | |
| 424 | + | ||
| 425 | + | 1. Check the name people see and the tier it suits. | |
| 426 | + | 2. Check or enter its prices per million tokens against the provider's | |
| 427 | + | price page: input, output, cache read, cache writes (five-minute and | |
| 428 | + | hour-long; an hour-long price left empty is the five-minute one), and | |
| 429 | + | under **Priced by prompt length** the threshold and the prices above it. | |
| 430 | + | 3. Say why (for example where the prices came from), and **Approve** | |
| 431 | + | (`admin_decide_model`, `approve`). | |
| 432 | + | ||
| 433 | + | It is `available` at once: the AI Gateway offers it within five minutes | |
| 434 | + | (the proxy keeps the catalogue that long) and it can be chosen as a | |
| 435 | + | default. **Retire** takes a model out of routing and the gateway; any | |
| 436 | + | default that chose it falls back to the next suitable model until staff | |
| 437 | + | choose another, and the audit line names those defaults. **Restore** puts | |
| 438 | + | a retired or deprecated model back: `available` if it was ever approved, | |
| 439 | + | else `new`. Each needs a reason and is in the audit log (`model_approved`, | |
| 440 | + | `model_retired`, `model_restored`). | |
| 441 | + | ||
| 442 | + | ### Defaults | |
| 443 | + | ||
| 444 | + | `model_defaults` holds staff's choice per purpose, each with when, who and | |
| 445 | + | why (`admin_set_model_default`; the audit log, `model_default`, has the old | |
| 446 | + | value, the new one and why): | |
| 447 | + | ||
| 448 | + | | Purpose | What it chooses | Read by | | |
| 449 | + | | --- | --- | --- | | |
| 450 | + | | `tier_small`, `tier_large`, `tier_frontier` | The model behind Auto's fast, standard and most capable tiers | The runner | | |
| 451 | + | | `background` | The harness's own small tasks in every run on g1t's tiers (`ANTHROPIC_SMALL_FAST_MODEL`, `ANTHROPIC_DEFAULT_HAIKU_MODEL`) | The runner | | |
| 452 | + | | `gateway_first` | The Claude listed first by `GET /openai/v1/models` | Billing's `gateway_models` | | |
| 453 | + | | `job_implement`, `job_revise`, `job_answer`, `job_review`, `job_update`, `job_plan` | Each kind of job's starting tier (`small`, `large`, `frontier`; a review also `change`, sized by its change) and effort (`low` to `max`, or none for the harness's own) | The runner | | |
| 454 | + | ||
| 455 | + | A model purpose takes only an available, priced Claude chat model (the | |
| 456 | + | harness speaks Anthropic's API). The migration seeded each with what | |
| 457 | + | `AGENT_ROUTING` had. | |
| 458 | + | ||
| 459 | + | Before a default is saved, sudo shows it beside the current one with what | |
| 460 | + | a **typical run** would cost on each: 40 requests of 2,000 input, 1,500 | |
| 461 | + | output, 45,000 cache-read and 4,000 cache-write tokens, each request priced | |
| 462 | + | on its own at the catalogue's prices (`catalogue::TYPICAL`; on 2026-10-08, | |
| 463 | + | $0.076 on Claude Haiku 5.5, $1.34 on Sonnet 5.5, $2.68 on Opus 5.5). It is | |
| 464 | + | an estimate for comparing models, never a charge. | |
| 465 | + | ||
| 466 | + | **How the runner reads them.** `model_defaults` (the RPC) returns each | |
| 467 | + | purpose's model as it applies now, with its prices and capabilities, and | |
| 468 | + | each job's tier and effort. The runner reads it at most once a minute per | |
| 469 | + | isolate and puts it over `AGENT_ROUTING` (`withDefaults` in | |
| 470 | + | `services/runner/src/model-env.ts`), so a change reaches runs within a | |
| 471 | + | minute. When billing cannot be read, the runner uses `AGENT_ROUTING` (and | |
| 472 | + | `DEFAULT_ROUTING` under it) alone and asks again ten seconds later. The | |
| 473 | + | labels, change sizes, `frontierAfter` and learning always come from | |
| 474 | + | `AGENT_ROUTING`. Effort is not sent on a model whose catalogue entry lacks | |
| 475 | + | `effort` (Claude Haiku 4.5). | |
| 476 | + | ||
| 477 | + | **Never a retired model.** A chosen model that is deprecated, retired or | |
| 478 | + | unpriced is not handed out: the purpose falls back to the first available, | |
| 479 | + | priced Claude with the same tier hint (any Claude for `gateway_first`), in | |
| 480 | + | the catalogue's order, with a sentence such as *Claude Haiku 5.5 is | |
| 481 | + | retired; using Claude Haiku 4.5 instead.* The runner adds that sentence to | |
| 482 | + | the run's reason line, and sudo shows it beside the default. With no model | |
| 483 | + | left for a purpose, the runner keeps `AGENT_ROUTING`'s. | |
| 484 | + | ||
| 485 | + | **Not a default:** the context hub's embeddings model | |
| 486 | + | (`@cf/baai/bge-base-en-v1.5`, in `services/context`). Its vectors are | |
| 487 | + | only comparable with others from the same model, so changing it means a | |
| 488 | + | new index, rebuilt; it is pinned in code, and listed in the catalogue with | |
| 489 | + | its price. No other g1t service calls a model. | |
| 490 | + | ||
| 491 | + | Customers never choose among these: they keep **Auto**, or pin a tier per | |
| 492 | + | kind of work, and nothing customer-facing names the catalogue. | |
| 493 | + | ||
| 347 | 494 | ## Discounts | |
| 348 | 495 | ||
| 349 | 496 | An account's terms are standard, or custom: a **discount** from 1 to 100%, |
| 198 | 198 | ||
| 199 | 199 | | Image | Built from | Holds | Rebuilt | | |
| 200 | 200 | | --- | --- | --- | --- | | |
| 201 | − | | **Base**, `g1t-runner:base-<date>-<inputs>` | `services/runner/base/Dockerfile` | Debian bookworm, Node 24, Python 3.11, Go (from go.dev), Rust stable for the `node` user with rustfmt, clippy and the `wasm32-unknown-unknown` target, build-essential, git, ripgrep, jq, zstd, sudo, and the pinned Claude Code CLI on top | When its folder changes, weekly, or by hand (`build-base`) | | |
| 201 | + | | **Base**, `g1t-runner:base-<date>-<inputs>` | `services/runner/base/Dockerfile` | Debian bookworm, Node 24, Python 3.11, Docker (Engine, Buildx, Compose, from Docker's apt repository), Go (from go.dev), Rust stable for the `node` user with rustfmt, clippy and the `wasm32-unknown-unknown` target, build-essential, musl-tools, git, ripgrep, jq, zstd, sudo, and the pinned Claude Code CLI on top | When its folder changes, weekly, or by hand (`build-base`) | | |
| 202 | 202 | | **Runner**, `g1t-runner:<content hash>` | `services/runner/Dockerfile`: `FROM` the base, plus one file | The g1t runner, a static binary | When the binary or the base changes | | |
| 203 | 203 | ||
| 204 | 204 | Both are pushed to one repository of Cloudflare's registry, | |
| 255 | 255 | 1. A deploy computes the tag and asks the registry whether it is there | |
| 256 | 256 | (a `HEAD` of its manifest, with credentials from Wrangler; no Docker). | |
| 257 | 257 | 2. If it is, nothing is built: the deploy uses it. | |
| 258 | − | 3. If not, and Docker is here, it builds the binary and the image (seconds | |
| 259 | − | on a warm machine) and pushes it. | |
| 260 | − | 4. If not, and Docker is not here (a g1t Actions sandbox), the unit fails | |
| 261 | − | saying to run `node scripts/deploy.mjs image` on a machine with Docker; | |
| 262 | − | then re-run the workflow. | |
| 258 | + | 3. If not, it builds the binary and the image (seconds on a warm machine) | |
| 259 | + | and pushes it. In `deploy.yml` that is the `runner-image` job, on | |
| 260 | + | `g1t-4core`, with the job's own Docker Engine (see | |
| 261 | + | [Docker in workflow jobs](#docker-in-workflow-jobs)): it adds the musl | |
| 262 | + | target, builds the binary natively (the base has `musl-gcc`), pulls the | |
| 263 | + | base from Cloudflare's registry, builds, and pushes one layer. | |
| 264 | + | 4. If Docker does not answer (a machine without it, or jobs with Docker | |
| 265 | + | turned off), the unit fails saying to run `node scripts/deploy.mjs | |
| 266 | + | image` on a machine with Docker; then re-run the workflow. | |
| 263 | 267 | ||
| 264 | 268 | Then `wrangler deploy` is given the image by reference, from a generated | |
| 265 | 269 | config (`services/runner/wrangler.deploy.json`, deleted after, ignored by | |
| 277 | 281 | ||
| 278 | 282 | `.g1t/workflows/runner-base.yml` does the same weekly (and when the | |
| 279 | 283 | base's folder changes on `main`), and opens a pull request with | |
| 280 | − | `base.json`. It needs Docker, so it runs on a self-hosted runner with the | |
| 281 | − | `docker` label (`runs-on: [self-hosted, docker]`). Until one is | |
| 282 | − | registered, its runs wait for one; run `build-base` by hand instead. | |
| 284 | + | `base.json`. It runs on a self-hosted runner with the `docker` label | |
| 285 | + | (`runs-on: [self-hosted, docker]`): g1t's own machines have Docker now, | |
| 286 | + | but the base's build downloads from Docker's apt repository over HTTPS, | |
| 287 | + | which does not trust a guarded job's egress certificate, so it stays on an | |
| 288 | + | open network. Until a runner is registered, its runs wait for one; run | |
| 289 | + | `build-base` by hand instead. | |
| 283 | 290 | ||
| 284 | 291 | **Sandboxes start from the image.** Cloudflare pulls an image to a | |
| 285 | 292 | machine the first time a sandbox lands there, and keeps it. A smaller base | |
| 298 | 305 | their CPU (see the public billing guide). The account's Containers limits | |
| 299 | 306 | must allow `standard-4`; Wrangler refuses the deploy otherwise. | |
| 300 | 307 | ||
| 308 | + | #### Docker in workflow jobs | |
| 309 | + | ||
| 310 | + | Workflow jobs on g1t's machines have a Docker Engine of their own | |
| 311 | + | (`crates/runner/src/docker/`; the public guide is | |
| 312 | + | `apps/docs/src/content/docs/guides/actions.md`, "Docker"). What Cloudflare | |
| 313 | + | Containers allow decides how it runs (findings in `docs/PLAN.md`, | |
| 314 | + | "Docker in workflow jobs"): | |
| 315 | + | ||
| 316 | + | | Piece | What it does | | |
| 317 | + | | --- | --- | | |
| 318 | + | | `dockerd` | Started as root with `sudo`, only when the job first uses Docker or has `services:` or `container:`. Flags: `--iptables=false --ip6tables=false --ip-forward=false` (Containers allow neither), the containerd image store, Docker Hub through `mirror.gcr.io`. Its config, socket and log are in `/run/g1t-docker` (`dockerd.log` is the place to look); its data in `/var/lib/docker`, on overlays when the disk takes them, plain copies (`native`) when not. | | |
| 319 | + | | `/var/run/docker.sock` | The runner's API proxy (`docker/api.rs`), which starts the Engine on the first connection. Containers that ask for a bridge network get the job's own (`host`), the names they would have had resolve to 127.0.0.1, and ports published under another number are forwarded. | | |
| 320 | + | | `runc` | The Engine finds the runner binary first on its `PATH` as `runc` (`docker/oci.rs`): BuildKit's `RUN` steps join the job's network, and in a guarded job every container gets the egress certificate at `/dev/g1t-egress`. Then the real `/usr/bin/runc` runs. | | |
| 321 | + | | cgroups | Before the Engine starts, the sandbox's processes move to a cgroup of their own and every controller is handed down, as Docker's own Docker-in-Docker image does, so `--cpus` and `--memory` work. | | |
| 322 | + | ||
| 323 | + | - **Turning it off:** set the runner Worker's `DOCKER` var to `off` | |
| 324 | + | (`services/runner/wrangler.jsonc`) and deploy the runner: new jobs get | |
| 325 | + | no Engine (`G1T_DOCKER=off`), and jobs that need one fail saying Docker | |
| 326 | + | does not answer. Anything else is on. | |
| 327 | + | - **Network:** containers share the job's network, so the guardrails, the | |
| 328 | + | workflow-only domains and the egress Worker apply to them unchanged. The | |
| 329 | + | public registries are in `BUILD_HOSTS` (`services/runner/src/egress.ts`). | |
| 330 | + | - **Isolation:** one Engine per job, inside the job's sandbox (its own | |
| 331 | + | VM), gone with it. The job already had root through `sudo`; Docker adds | |
| 332 | + | no reach beyond the sandbox, and no host socket is ever mounted into one. | |
| 333 | + | - **Checked locally** (2026-10-08, Docker Desktop, the base and runner | |
| 334 | + | image built from this tree, a privileged container standing in for a | |
| 335 | + | sandbox, a pretend API): services with health checks, `localhost` and | |
| 336 | + | names, port forwarding, `docker build` with a networked `RUN`, Compose | |
| 337 | + | with a healthy dependency, `docker://` steps, a Dockerfile action, a | |
| 338 | + | `container:` job with a JavaScript action, an Alpine job container, the | |
| 339 | + | egress certificate in `run`, `exec` and build steps and in no layer, | |
| 340 | + | plain-copy storage, and the deploy's own build and push to a registry. | |
| 341 | + | Not yet seen on Cloudflare itself: watch the first runs' logs for the | |
| 342 | + | `Docker: started` line, and `dockerd.log` if it does not come. | |
| 343 | + | ||
| 301 | 344 | ## Build speed | |
| 302 | 345 | ||
| 303 | 346 | Measured on the development machine (Windows, 32 cores, warm Cargo cache), | |
| 366 | 409 | - **Build groups:** a stage's units are split so each job shares a build: | |
| 367 | 410 | Rust workers at most four to a job (each a 4-vCPU `g1t-4core` machine), the | |
| 368 | 411 | TypeScript Workers together, each site alone, and a unit whose image must | |
| 369 | − | be rebuilt alone. `fail-fast: false`, so one failed job does not cut | |
| 412 | + | be rebuilt alone (`image: true` in the matrix, also on `g1t-4core`, where | |
| 413 | + | it builds and pushes the image with the job's own Docker Engine). `fail-fast: false`, so one failed job does not cut | |
| 370 | 414 | another off mid-upload; the next stage then does not start. | |
| 371 | 415 | - **Tests:** there is no CI workflow on g1t yet; `main` is kept passing by | |
| 372 | 416 | the merge queue's checks. `check` runs the deploy tool's own tests. When a | |
| 373 | 417 | CI workflow is added, make `plan` wait for it (`workflow_run`, or a job in | |
| 374 | 418 | this file). | |
| 375 | − | - **Machines:** Rust jobs run on `g1t-4core` (4 vCPUs, 12 GiB), the | |
| 376 | − | others on the standard machine (`runs-on: ${{ matrix.rust && 'g1t-4core' || 'ubuntu-latest' }}`). | |
| 419 | + | - **Machines:** Rust jobs and the runner's image run on `g1t-4core` (4 vCPUs, | |
| 420 | + | 12 GiB, 20 GB), the others on the standard machine | |
| 421 | + | (`runs-on: ${{ (matrix.rust || matrix.image) && 'g1t-4core' || 'ubuntu-latest' }}`). | |
| 422 | + | The image job needs the room: the base it builds on is about 3.2 GB | |
| 423 | + | unpacked. | |
| 377 | 424 | - **Caching** (`actions/cache`: up to 2 GB an entry, 10 GB a repository, | |
| 378 | 425 | kept until unused for 7 days): the worker-build binary, worker-build's | |
| 379 | 426 | downloaded tools, `~/.cargo/registry/cache`, and the Cargo target's | |
| 395 | 442 | ### What the sandbox has | |
| 396 | 443 | ||
| 397 | 444 | The base image (`services/runner/base/Dockerfile`) has Node 24, npm, git, | |
| 398 | − | Go, zstd, and Rust stable for the `node` user with rustfmt, clippy and the | |
| 399 | − | `wasm32-unknown-unknown` target, but not worker-build or Docker. The | |
| 400 | − | workflow's `rustup target add wasm32-unknown-unknown` is then a no-op, and | |
| 401 | − | worker-build is restored from the cache, installed on a miss. worker-build | |
| 445 | + | Go, zstd, Docker, musl-tools, and Rust stable for the `node` user with | |
| 446 | + | rustfmt, clippy and the `wasm32-unknown-unknown` target, but not | |
| 447 | + | worker-build. The workflow's `rustup target add wasm32-unknown-unknown` is | |
| 448 | + | then a no-op, and worker-build is restored from the cache, installed on a | |
| 449 | + | miss. The image job adds `x86_64-unknown-linux-musl` (about 30 MB from | |
| 450 | + | `static.rust-lang.org`) and keeps its Cargo target in the cache. worker-build | |
| 402 | 451 | fetches wasm-bindgen and wasm-opt from GitHub releases and esbuild from | |
| 403 | 452 | npm. All of those hosts are on the list every workflow job may reach. | |
| 404 | 453 | ||
| 418 | 467 | ``` | |
| 419 | 468 | ||
| 420 | 469 | `api.cloudflare.com` is Wrangler's API; `registry.cloudflare.com` is where | |
| 421 | − | the deploy asks whether the runner's image is already built (and where | |
| 422 | − | `runner-base.yml` pushes). Only `deploy.yml`'s and `runner-base.yml`'s | |
| 470 | + | the deploy asks whether the runner's image is already built, and where the | |
| 471 | + | `runner-image` job pulls the base from and pushes the runner's image to | |
| 472 | + | (as `runner-base.yml` pushes the base). The job's Docker Engine shares the | |
| 473 | + | job's network, so these lines are what let it reach the registry. If a pull | |
| 474 | + | is refused with `g1t guardrails: <host> is not on this project's allowed | |
| 475 | + | domains`, the registry sent the layers from another host: add that host on | |
| 476 | + | the same line. Only `deploy.yml`'s and `runner-base.yml`'s | |
| 423 | 477 | jobs with `environment: production` reach them, which are also the only | |
| 424 | 478 | jobs that can read `CLOUDFLARE_API_TOKEN`. Each change to the list is in | |
| 425 | 479 | the workspace's audit log as `update_guardrails`. | |
| 436 | 490 | | Account | Queues: Edit | Attaching each unit's queue consumers on deploy | | |
| 437 | 491 | | Account | Workers R2 Storage: Read | Wrangler checks `og`'s bucket binding | | |
| 438 | 492 | | Account | Account Settings: Read | Wrangler reads the account | | |
| 439 | − | | Account | Containers: Edit | The runner's deploy updates its applications (the image reference, the three classes), and gets registry credentials to look for its image. `runner-base.yml` pushes images with it. | | |
| 493 | + | | Account | Containers: Edit | The runner's deploy updates its applications (the image reference, the three classes), and gets registry credentials (`wrangler containers registries credentials --push`, one hour) to look for, pull and push its image. `runner-base.yml` pushes images with it. | | |
| 440 | 494 | | Zone (`g1t.sh`, `g1t.page`) | Workers Routes: Edit | `pages`' zone routes, and custom domains | | |
| 441 | 495 | | Zone (`g1t.sh`, `g1t.page`) | DNS: Edit | Custom domains (`api`, `mcp`, `og`, `models`, `status`, `sudo`, `docs`, `g1t.sh`, `g1t.page`) keep their DNS records | | |
| 442 | 496 | | Zone (`g1t.sh`, `g1t.page`) | Zone: Read | Finding the zone a route names | | |
| 584 | 638 | repository from the git remote on g1t.sh. A report that fails is one line | |
| 585 | 639 | in the log and never fails the deploy. | |
| 586 | 640 | ||
| 587 | − | When CI cannot finish a deploy, for example a runner image that must be | |
| 588 | − | built (hosted runners have no Docker), deploy from a machine with Docker. | |
| 641 | + | When CI cannot finish a deploy, for example a runner image that will not | |
| 642 | + | build there, deploy from a machine with Docker. | |
| 589 | 643 | The report records it, and production shows the commit that really runs. | |
| 590 | 644 | ||
| 591 | 645 | ## Rolling back |
| 479 | 479 | commands declared in `.g1t/checks.yaml`, run in sandboxes on every pull request | |
| 480 | 480 | and on every combined state in the landing queue. | |
| 481 | 481 | ||
| 482 | + | ### Docker in workflow jobs (built 2026-10-08) | |
| 483 | + | ||
| 484 | + | > **2026-10-08:** "Why didn't we give CI docker then? We need GitHub | |
| 485 | + | > Actions functionality maxxed baby but with all the good good security | |
| 486 | + | > etc." The trigger: `deploy.yml`'s runner-image job needs `docker build` | |
| 487 | + | > and `docker push`, and failed on hosted runners. | |
| 488 | + | ||
| 489 | + | **What Cloudflare Containers allow** (their FAQ, updated 2026-10-05, and | |
| 490 | + | the Docker-in-Docker guide and example it links): | |
| 491 | + | ||
| 492 | + | - Docker runs inside a container: `docker:dind`, with `dockerd` as | |
| 493 | + | **root**. A rootless Engine does not start there. | |
| 494 | + | - **No iptables**: `--iptables=false --ip6tables=false`, or the Engine | |
| 495 | + | fails setting up its rules. Containers with the `durable_object` | |
| 496 | + | scheduling policy (ours: each sandbox is a Durable Object's) cannot turn | |
| 497 | + | IP forwarding on either: `--ip-forward=false`, or the Engine exits. | |
| 498 | + | - So **a bridge network has no way out**: the guide's answer is | |
| 499 | + | `--network=host` for `docker run` and `docker build`, which gives | |
| 500 | + | containers the outer container's network. | |
| 501 | + | - Built images and containers are lost when the sandbox stops. | |
| 502 | + | ||
| 503 | + | **What was found by trying** (Docker Engine 29.8.2 in a privileged | |
| 504 | + | container standing in for a sandbox, with the same flags): | |
| 505 | + | ||
| 506 | + | - `--bridge=none` makes BuildKit's `RUN` steps fail outright ("network | |
| 507 | + | bridge not found"); with the default bridge they run with no route out. | |
| 508 | + | Containers default to the bridge too. The default bridge is created | |
| 509 | + | fine without iptables, as the FAQ's own example relies on. | |
| 510 | + | - The overlay snapshotter does not work on an overlay root filesystem, and | |
| 511 | + | the containerd image store does not fall back by itself: the Engine | |
| 512 | + | starts, then every container fails to mount. Whether a sandbox's disk | |
| 513 | + | takes overlays is not documented, so the runner tries a mount first and | |
| 514 | + | uses `native` (plain copies) when it fails. `vfs` is not a name the | |
| 515 | + | containerd store accepts. | |
| 516 | + | - `--cpus` and `--memory` need cgroup v2 controllers handed down from a | |
| 517 | + | cgroup with no processes, which `docker:dind`'s entrypoint does and a | |
| 518 | + | plain `dockerd` does not. | |
| 519 | + | - A `runc` earlier on the Engine's `PATH` is used both for containers (via | |
| 520 | + | containerd's shim) and by BuildKit's executor, with the bundle's | |
| 521 | + | `config.json` written before it runs. BuildKit's bridged steps carry | |
| 522 | + | libnetwork's `libnetwork-setkey` prestart hook; `RUN --network=none` | |
| 523 | + | does not. | |
| 524 | + | - Buildx skips `type=gha` caches when the job has no GitHub cache service, | |
| 525 | + | and the build succeeds without one. | |
| 526 | + | - Registries' layer hosts: Docker Hub sends layers from | |
| 527 | + | `production.cloudfront.docker.com` (and `production.cloudflare.docker.com`), | |
| 528 | + | Quay from `cdn0N.quay.io`, Microsoft's from regional | |
| 529 | + | `*.data.mcr.microsoft.com`, public ECR from a CloudFront host; | |
| 530 | + | `mirror.gcr.io` serves its own. | |
| 531 | + | ||
| 532 | + | **What was built** (`crates/runner/src/docker`, `actions/containers.rs`): | |
| 533 | + | ||
| 534 | + | - **One Engine per job, started lazily.** The runner listens on | |
| 535 | + | `/var/run/docker.sock` itself and starts `dockerd` (root, the flags | |
| 536 | + | above, containerd image store, `mirror.gcr.io` first for Docker Hub) on | |
| 537 | + | the first connection, or when the job has `services:` or `container:`. | |
| 538 | + | A log line says it started and how long it took. | |
| 539 | + | - **Containers on the job's network.** The socket is an API proxy: a | |
| 540 | + | container that asks for a bridge or user network gets `host`; its names | |
| 541 | + | (container name, aliases, Compose service, links) resolve to 127.0.0.1 | |
| 542 | + | in later containers (`ExtraHosts`) and in the job's steps | |
| 543 | + | (`/etc/hosts`); `-p 8080:80` is forwarded; `docker inspect` reports the | |
| 544 | + | ports as published; `network connect` adds aliases. Sharing the job's | |
| 545 | + | network is also what keeps the guardrails on every container: the | |
| 546 | + | egress Worker sees their traffic as the job's. | |
| 547 | + | - **A `runc` shim.** The runner binary, as `runc`: BuildKit steps bound for | |
| 548 | + | a bridge lose the network namespace and the libnetwork hook (so they use | |
| 549 | + | the job's network); in a guarded job every container (run, exec, build | |
| 550 | + | step) gets the egress certificate at `/dev/g1t-egress` and the | |
| 551 | + | variables that point tools at it. `/dev` is the container's own tmpfs, | |
| 552 | + | so none of it lands in a layer; checked by saving a built image. | |
| 553 | + | - **Job features:** `services:` (pull, credentials, health waits, logs at | |
| 554 | + | the end, `job.services.*`), `container:` (steps and JavaScript actions | |
| 555 | + | through `docker exec`, Node mounted from the runner, Alpine falls back to | |
| 556 | + | running actions beside it), `docker://` steps and Dockerfile actions in | |
| 557 | + | GitHub's `/github/*` layout, `docker/setup-buildx-action` answered | |
| 558 | + | natively (the job's Engine is the builder), sign-in to g1t's registry | |
| 559 | + | with the run's token. | |
| 560 | + | - **Kill switch:** the runner Worker's `DOCKER` var (`off`). | |
| 561 | + | ||
| 562 | + | **Rejected:** rootless Docker or BuildKit (does not start in Containers); | |
| 563 | + | Podman or buildah with `vfs` (no better networking, less compatible, slow); | |
| 564 | + | a standalone `buildkitd --oci-worker-net=host` as the default builder | |
| 565 | + | (images not in the Engine's store, so `FROM` a just-built image and | |
| 566 | + | `docker run` of a build fail without `--load` round trips); a `docker` CLI | |
| 567 | + | wrapper adding `--network=host` (misses Compose, SDKs and testcontainers, | |
| 568 | + | which speak the API). | |
| 569 | + | ||
| 570 | + | **Not yet:** | |
| 571 | + | ||
| 572 | + | - Seen on Cloudflare itself: whether a sandbox's disk takes overlays, | |
| 573 | + | `--privileged`, and the first deploy's pull of the base from | |
| 574 | + | `registry.cloudflare.com` (its layer host may need a workflow-only line). | |
| 575 | + | - `type=gha` build caches backed by g1t's Actions cache. | |
| 576 | + | - Multi-platform builds (QEMU's `binfmt_misc` in a sandbox). | |
| 577 | + | - Docker for agents and checks, not just workflow jobs. | |
| 578 | + | - `runner-base.yml` on g1t's machines: the base's apt step needs | |
| 579 | + | `Acquire::https::CAInfo` pointed at the egress certificate first. | |
| 580 | + | - A deploy that appends the runner binary as a layer through the registry | |
| 581 | + | API, with no Docker and no 3 GB pull. | |
| 582 | + | ||
| 482 | 583 | Agents can also reach integrations directly: an agent definition lists MCP | |
| 483 | 584 | servers (Sentry, Linear and so on) it may use while working. | |
| 484 | 585 | ||
| 1180 | 1281 | - **Tiers and catalogue.** `small` (Claude Haiku 5.5 since 2026-10-08, | |
| 1181 | 1282 | $0.10/$0.50 per million input/output up to 100k-token prompts, five | |
| 1182 | 1283 | times that above; it was Haiku 4.5 at $1/$5), `large` (Claude Sonnet 5.5, $2/$10) and `frontier` | |
| 1183 | − | (Claude Opus 5.5, $4/$20). Models, names and list prices are | |
| 1184 | − | configuration (`AGENT_ROUTING`), never code; prices there are for | |
| 1185 | − | estimates only, runs are charged what AI Gateway priced them at. | |
| 1284 | + | (Claude Opus 5.5, $4/$20). Models, names and list prices are data, | |
| 1285 | + | never code: since 2026-10-08 billing's model catalogue | |
| 1286 | + | (`gateway_models`, one row per model g1t can use) and staff's defaults | |
| 1287 | + | in sudo, **Agents & models** (`model_defaults`: each tier's model, the | |
| 1288 | + | harness's background model, the AI Gateway's first Claude, each job's | |
| 1289 | + | starting tier and effort), read by the runner once a minute over | |
| 1290 | + | `AGENT_ROUTING`, which is only the fallback when billing cannot be read. | |
| 1291 | + | Catalogue prices are for estimates only; runs are charged what AI | |
| 1292 | + | Gateway priced them at. | |
| 1293 | + | - **Keeping up with new models (built 2026-10-08).** The models service | |
| 1294 | + | lists Anthropic's models (through the AI Gateway) and Workers AI's daily | |
| 1295 | + | and on demand; a new id lands in the catalogue as `new`, priced from a | |
| 1296 | + | maintained table of Anthropic's list prices or Workers AI's listing, or | |
| 1297 | + | unpriced, and staff are emailed. Nothing routes to it, offers it or | |
| 1298 | + | charges for it until staff approve it with its prices. A model a provider | |
| 1299 | + | stops listing is `deprecated`; routing never sends work to a deprecated | |
| 1300 | + | or retired model, falling back to the next model of the tier and saying | |
| 1301 | + | so on the run. Customers keep Auto: the catalogue is staff's. See | |
| 1302 | + | [BILLING_OPERATIONS.md](BILLING_OPERATIONS.md#the-model-catalogue). | |
| 1186 | 1303 | - **Starting tier by job.** Catch-up, answering a question, and reviews of | |
| 1187 | 1304 | at most 10 files and 200 lines touching no sensitive path: small. | |
| 1188 | 1305 | Plans: small at high effort (Haiku 5.5 takes an effort level). | |
| 1201 | 1318 | *Used a fast model (Claude Haiku 5.5): small change, 3 files and 80 | |
| 1202 | 1319 | lines.* Effort per kind of job (`effort` in `AGENT_ROUTING`: plan high, | |
| 1203 | 1320 | answer medium, update low) is sent as `CLAUDE_CODE_EFFORT_LEVEL` on | |
| 1204 | − | g1t's tiers and named in that line. | |
| 1321 | + | g1t's tiers (never on a model the catalogue says takes none) and named | |
| 1322 | + | in that line, with the catalogue's name for the model. | |
| 1205 | 1323 | - **Chosen instead.** `model_routes` rows to g1t's models name `small`, | |
| 1206 | 1324 | `large` or `frontier`, or nothing for Auto (Integrations → Models). | |
| 1207 | 1325 | A workspace's own Anthropic key with no model named is routed by Auto |
| 681 | 681 | setCostMapping(mapping: CostMappingInput, by: string): Promise<Result<CostMapping>>; | |
| 682 | 682 | /** Reads Cloudflare's bill and reconciles now, as the daily run does. */ | |
| 683 | 683 | runCosts(by: string): Promise<Result<CostsRun>>; | |
| 684 | + | /** Agents & models: the catalogue, each purpose's default, and the latest checks. */ | |
| 685 | + | models(): Promise<AdminModels>; | |
| 686 | + | /** Approve a model (its prices confirmed), retire it, or restore it. Needs a reason. */ | |
| 687 | + | decideModel( | |
| 688 | + | model: string, | |
| 689 | + | decision: "approve" | "retire" | "restore", | |
| 690 | + | details: { name?: string | null; tierHint?: string | null; prices?: ModelPrices | null }, | |
| 691 | + | reason: string, | |
| 692 | + | by: string, | |
| 693 | + | ): Promise<Result<CatalogueModel>>; | |
| 694 | + | /** One purpose's default: a model, or for a job its tier and effort. Needs a reason. */ | |
| 695 | + | setModelDefault( | |
| 696 | + | purpose: string, | |
| 697 | + | value: { model?: string | null; tier?: string | null; effort?: string | null }, | |
| 698 | + | reason: string, | |
| 699 | + | by: string, | |
| 700 | + | ): Promise<Result<ModelDefault>>; | |
| 684 | 701 | } | |
| 685 | 702 | ||
| 686 | 703 | /** How much a workspace has earned g1t's trust with money. */ | |
| 923 | 940 | */ | |
| 924 | 941 | /** What the AI Gateway offers on g1t's key, with prices per million tokens. */ | |
| 925 | 942 | gatewayModels(): Promise<GatewayModel[]>; | |
| 943 | + | /** Every purpose's default model as it applies now, and each job's tier and effort. */ | |
| 944 | + | modelDefaults(): Promise<ModelDefaults>; | |
| 945 | + | /** What one provider lists now, from the models service's discovery. */ | |
| 946 | + | recordDiscovery(provider: string, models: ProviderModel[], by: string, error?: string | null): Promise<DiscoveryResult>; | |
| 926 | 947 | /** | |
| 927 | 948 | * Whether a workspace's next AI Gateway request may go to g1t's models: | |
| 928 | 949 | * fails with `payment_required` and what to do when it is over its spend | |
| 1208 | 1229 | overCacheWrite1hMicros?: number; | |
| 1209 | 1230 | }; | |
| 1210 | 1231 | ||
| 1232 | + | // --- The model catalogue ------------------------------------------------------ | |
| 1233 | + | ||
| 1234 | + | /** Where a model stands: only `available` ones are routed to. */ | |
| 1235 | + | export type ModelStatus = "available" | "new" | "deprecated" | "retired"; | |
| 1236 | + | ||
| 1237 | + | /** One model in g1t's catalogue: its prices and what g1t knows about it. */ | |
| 1238 | + | export type CatalogueModel = GatewayModel & { | |
| 1239 | + | /** Other ids the provider lists it by, such as a dated one. */ | |
| 1240 | + | aliases: string[]; | |
| 1241 | + | family: string; | |
| 1242 | + | /** The agent tier it suits: `small`, `large`, `frontier`, or empty. */ | |
| 1243 | + | tierHint: string; | |
| 1244 | + | contextWindow: number; | |
| 1245 | + | maxOutput: number; | |
| 1246 | + | /** Any of `effort`, `thinking`, `tools`, `vision`, `embeddings`. */ | |
| 1247 | + | capabilities: string[]; | |
| 1248 | + | dimensions: number; | |
| 1249 | + | status: ModelStatus; | |
| 1250 | + | /** Its prices are known. An unpriced model is never routed to, offered or charged for. */ | |
| 1251 | + | priced: boolean; | |
| 1252 | + | source: "discovered" | "staff"; | |
| 1253 | + | firstSeenAt: string | null; | |
| 1254 | + | lastSeenAt: string | null; | |
| 1255 | + | missingSince: string | null; | |
| 1256 | + | approvedBy: string | null; | |
| 1257 | + | approvedAt: string | null; | |
| 1258 | + | note: string; | |
| 1259 | + | /** A typical agent run on it, in millionths of a dollar; 0 when unpriced or embeddings. */ | |
| 1260 | + | typicalRunMicros: number; | |
| 1261 | + | }; | |
| 1262 | + | ||
| 1263 | + | /** One model as its provider lists it, from the models service's discovery. */ | |
| 1264 | + | export type ProviderModel = { | |
| 1265 | + | id: string; | |
| 1266 | + | name: string; | |
| 1267 | + | /** `chat`, `embeddings`, or anything else (counted as listed, never added). */ | |
| 1268 | + | kind: string; | |
| 1269 | + | contextWindow: number; | |
| 1270 | + | maxOutput: number; | |
| 1271 | + | capabilities: string[]; | |
| 1272 | + | /** Workers AI lists a price per million tokens; Anthropic does not. */ | |
| 1273 | + | price: { inputMicros: number; outputMicros: number } | null; | |
| 1274 | + | }; | |
| 1275 | + | ||
| 1276 | + | /** What one check of a provider found. */ | |
| 1277 | + | export type DiscoveryResult = { | |
| 1278 | + | provider: string; | |
| 1279 | + | checkedAt: string; | |
| 1280 | + | by: string; | |
| 1281 | + | listed: number; | |
| 1282 | + | added: string[]; | |
| 1283 | + | deprecated: string[]; | |
| 1284 | + | restored: string[]; | |
| 1285 | + | error: string | null; | |
| 1286 | + | }; | |
| 1287 | + | ||
| 1288 | + | /** The purposes a default model is chosen for. */ | |
| 1289 | + | export type ModelPurpose = "tier_small" | "tier_large" | "tier_frontier" | "background" | "gateway_first"; | |
| 1290 | + | ||
| 1291 | + | /** One purpose's default, as staff last set it. */ | |
| 1292 | + | export type ModelDefault = { | |
| 1293 | + | /** A `ModelPurpose`, or `job_<kind>`. */ | |
| 1294 | + | purpose: string; | |
| 1295 | + | model: string | null; | |
| 1296 | + | /** For a job: `small`, `large`, `frontier` or `change`. */ | |
| 1297 | + | tier: string | null; | |
| 1298 | + | effort: string | null; | |
| 1299 | + | updatedAt: string; | |
| 1300 | + | updatedBy: string; | |
| 1301 | + | reason: string; | |
| 1302 | + | }; | |
| 1303 | + | ||
| 1304 | + | /** A model purpose's default as it applies now. */ | |
| 1305 | + | export type ResolvedModel = { | |
| 1306 | + | purpose: string; | |
| 1307 | + | chosen: string; | |
| 1308 | + | /** The model to use; null when nothing suits (callers keep their own fallback). */ | |
| 1309 | + | model: GatewayModel | null; | |
| 1310 | + | capabilities: string[]; | |
| 1311 | + | /** Why it is not the chosen model, in a sentence. */ | |
| 1312 | + | note: string | null; | |
| 1313 | + | }; | |
| 1314 | + | ||
| 1315 | + | /** One kind of agent job's starting tier and effort. */ | |
| 1316 | + | export type JobDefault = { kind: string; tier: string; effort: string | null }; | |
| 1317 | + | ||
| 1318 | + | /** Every purpose's model as it applies now, and each job's tier and effort. */ | |
| 1319 | + | export type ModelDefaults = { models: ResolvedModel[]; jobs: JobDefault[] }; | |
| 1320 | + | ||
| 1321 | + | /** One check of one provider. */ | |
| 1322 | + | export type ModelCheck = { | |
| 1323 | + | id: string; | |
| 1324 | + | provider: string; | |
| 1325 | + | checkedAt: string; | |
| 1326 | + | by: string; | |
| 1327 | + | listed: number; | |
| 1328 | + | added: string[]; | |
| 1329 | + | deprecated: string[]; | |
| 1330 | + | error: string | null; | |
| 1331 | + | }; | |
| 1332 | + | ||
| 1333 | + | /** The tokens of the typical agent run estimates are priced from. */ | |
| 1334 | + | export type TypicalRun = { requests: number; input: number; output: number; cacheRead: number; cacheWrite: number }; | |
| 1335 | + | ||
| 1336 | + | /** sudo's Agents & models. */ | |
| 1337 | + | export type AdminModels = { | |
| 1338 | + | catalogue: CatalogueModel[]; | |
| 1339 | + | defaults: ModelDefault[]; | |
| 1340 | + | resolved: ModelDefaults; | |
| 1341 | + | checks: ModelCheck[]; | |
| 1342 | + | typical: TypicalRun; | |
| 1343 | + | }; | |
| 1344 | + | ||
| 1345 | + | /** A model's prices as staff confirm them, per million tokens in millionths of a dollar. */ | |
| 1346 | + | export type ModelPrices = { | |
| 1347 | + | inputMicros: number; | |
| 1348 | + | outputMicros: number; | |
| 1349 | + | cacheReadMicros: number; | |
| 1350 | + | cacheWriteMicros: number; | |
| 1351 | + | cacheWrite1hMicros: number; | |
| 1352 | + | threshold: number; | |
| 1353 | + | overInputMicros: number; | |
| 1354 | + | overOutputMicros: number; | |
| 1355 | + | overCacheReadMicros: number; | |
| 1356 | + | overCacheWriteMicros: number; | |
| 1357 | + | overCacheWrite1hMicros: number; | |
| 1358 | + | }; | |
| 1359 | + | ||
| 1360 | + | /** The models service's `Discovery` entrypoint, for sudo's "Check for new models". */ | |
| 1361 | + | export interface ModelDiscoveryApi { | |
| 1362 | + | /** Lists every provider's models now and records what changed: one result per provider. */ | |
| 1363 | + | check(by: string): Promise<DiscoveryResult[]>; | |
| 1364 | + | } | |
| 1365 | + | ||
| 1211 | 1366 | /** The format a gateway request was sent in. */ | |
| 1212 | 1367 | export type GatewayFormat = "anthropic" | "openai"; | |
| 1213 | 1368 |
| 487 | 487 | call("token_usage", { workspace, viewer, person: options.person ?? null, days: options.days ?? null }), | |
| 488 | 488 | recordTokens: (usage) => call("record_tokens", usage), | |
| 489 | 489 | gatewayModels: () => call("gateway_models", {}), | |
| 490 | + | modelDefaults: () => call("model_defaults", {}), | |
| 491 | + | recordDiscovery: (provider, models, by, error = null) => call("record_discovery", { provider, models, by, error }), | |
| 490 | 492 | gatewayAdmit: (workspace) => call("gateway_admit", { workspace }), | |
| 491 | 493 | recordGateway: (record) => call("record_gateway", record), | |
| 492 | 494 | gatewayRequests: (workspace, viewer, options = {}) => | |
| 619 | 621 | by, | |
| 620 | 622 | }), | |
| 621 | 623 | runCosts: (by) => call("admin_run_costs", { by }), | |
| 624 | + | models: () => call("admin_models", {}), | |
| 625 | + | decideModel: (model, decision, details, reason, by) => | |
| 626 | + | call("admin_decide_model", { | |
| 627 | + | model, | |
| 628 | + | decision, | |
| 629 | + | name: details.name ?? null, | |
| 630 | + | tier_hint: details.tierHint ?? null, | |
| 631 | + | prices: details.prices | |
| 632 | + | ? { | |
| 633 | + | input_micros: details.prices.inputMicros, | |
| 634 | + | output_micros: details.prices.outputMicros, | |
| 635 | + | cache_read_micros: details.prices.cacheReadMicros, | |
| 636 | + | cache_write_micros: details.prices.cacheWriteMicros, | |
| 637 | + | cache_write_1h_micros: details.prices.cacheWrite1hMicros, | |
| 638 | + | threshold: details.prices.threshold, | |
| 639 | + | over_input_micros: details.prices.overInputMicros, | |
| 640 | + | over_output_micros: details.prices.overOutputMicros, | |
| 641 | + | over_cache_read_micros: details.prices.overCacheReadMicros, | |
| 642 | + | over_cache_write_micros: details.prices.overCacheWriteMicros, | |
| 643 | + | over_cache_write_1h_micros: details.prices.overCacheWrite1hMicros, | |
| 644 | + | } | |
| 645 | + | : null, | |
| 646 | + | reason, | |
| 647 | + | by, | |
| 648 | + | }), | |
| 649 | + | setModelDefault: (purpose, value, reason, by) => | |
| 650 | + | call("admin_set_model_default", { | |
| 651 | + | purpose, | |
| 652 | + | model: value.model ?? null, | |
| 653 | + | tier: value.tier ?? null, | |
| 654 | + | effort: value.effort ?? null, | |
| 655 | + | reason, | |
| 656 | + | by, | |
| 657 | + | }), | |
| 622 | 658 | }; | |
| 623 | 659 | } | |
| 624 | 660 |
| 265 | 265 | } | |
| 266 | 266 | if (!docker) { | |
| 267 | 267 | throw new Error( | |
| 268 | − | `its image ${tag} is not in the registry, and Docker is not available here. Build and push it from a machine with Docker (node scripts/deploy.mjs image), then run this again.`, | |
| 268 | + | `its image ${tag} is not in the registry, and Docker does not answer here. Build and push it where Docker runs (a g1t Actions job, or a machine with Docker: node scripts/deploy.mjs image), then run this again.`, | |
| 269 | 269 | ); | |
| 270 | 270 | } | |
| 271 | 271 | const started = Date.now(); |
| 455 | 455 | const data = planJson(stack, decisions, { events: { pending: ["0003_x.sql"] }, repos: { pending: [] } }, HEAD); | |
| 456 | 456 | assert.deepEqual(data.migrations, [{ unit: "events", database: "g1t-events", pending: ["0003_x.sql"] }]); | |
| 457 | 457 | assert.deepEqual(data.stages.core.jobs, [ | |
| 458 | − | { group: "rust", units: "events,repos", rust: true }, | |
| 459 | − | { group: "ts", units: "projects", rust: false }, | |
| 458 | + | { group: "rust", units: "events,repos", rust: true, image: false }, | |
| 459 | + | { group: "ts", units: "projects", rust: false, image: false }, | |
| 460 | 460 | ]); | |
| 461 | − | assert.deepEqual(data.stages.edge.jobs, [{ group: "rust", units: "api", rust: true }]); | |
| 461 | + | assert.deepEqual(data.stages.edge.jobs, [{ group: "rust", units: "api", rust: true, image: false }]); | |
| 462 | 462 | assert.deepEqual(data.stages.front.jobs, [ | |
| 463 | − | { group: "web", units: "web", rust: false }, | |
| 464 | − | { group: "docs", units: "docs", rust: false }, | |
| 463 | + | { group: "web", units: "web", rust: false, image: false }, | |
| 464 | + | { group: "docs", units: "docs", rust: false, image: false }, | |
| 465 | 465 | ]); | |
| 466 | 466 | assert.deepEqual(data.stage_order, ["core", "edge", "front"]); | |
| 467 | 467 | assert.deepEqual(buildGroups([]), []); | |
| 469 | 469 | const core = stack.units.filter((u) => u.stage === "core"); | |
| 470 | 470 | const jobs = buildGroups(core, ["runner"]); | |
| 471 | 471 | assert.deepEqual(jobs.filter((j) => j.rust).map((j) => j.units.split(",").length), [4, 4, 3]); | |
| 472 | − | assert.ok(jobs.some((j) => j.group === "runner-image" && j.units === "runner")); | |
| 472 | + | assert.ok(jobs.some((j) => j.group === "runner-image" && j.units === "runner" && j.image && !j.rust)); | |
| 473 | 473 | assert.ok(!jobs.find((j) => j.group === "ts").units.includes("runner")); | |
| 474 | 474 | }); | |
| 475 | 475 |
| 215 | 215 | 'echo "rust=$(rustc --version | cut -d" " -f2)"', | |
| 216 | 216 | 'echo "git=$(git --version | cut -d" " -f3)"', | |
| 217 | 217 | 'echo "claude_code=$(claude --version | cut -d" " -f1)"', | |
| 218 | + | 'echo "docker=$(dockerd --version | cut -d" " -f3 | tr -d ,)"', | |
| 218 | 219 | 'echo "debian=$(cat /etc/debian_version)"', | |
| 219 | 220 | ].join("; "); | |
| 220 | 221 | const found = await exec("docker", ["run", "--rm", "--platform", "linux/amd64", "--entrypoint", "bash", ref, "-c", script]); |
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
This change is too large to show in full.