Merge branch 'worktree-agent-a4d0c5ee9c2f877aa'
17 files+721−1490/17 viewed
| 365 | 365 | ||
| 366 | 366 | ## Deployments | |
| 367 | 367 | ||
| 368 | − | ### Containers are not switched on yet | |
| 369 | − | ||
| 370 | − | [Docker deployments](/guides/deployments/#docker-deployments) are built, | |
| 371 | − | but off on g1t.page: a project that runs as a container fails to deploy, | |
| 372 | − | saying so, and one with a site besides its `Dockerfile` is built as that | |
| 373 | − | site. | |
| 374 | − | ||
| 375 | − | - **Why.** Cloudflare's container APIs (applications in a Workers for | |
| 376 | − | Platforms namespace, registry logins) have to be checked against g1t's | |
| 377 | − | own account first. | |
| 378 | − | - **Instead.** Build a framework app as a static export or with its | |
| 379 | − | Cloudflare adapter, as [Frameworks](/guides/deployments/#frameworks) | |
| 380 | − | lists, or set **Build as** to Static site. Or deploy the server from a | |
| 381 | − | workflow to wherever it runs today, with its credentials in | |
| 382 | − | [secrets](/guides/secrets-and-variables/). | |
| 383 | − | ||
| 384 | 368 | ### A container is one instance, and keeps nothing | |
| 385 | 369 | ||
| 386 | 370 | A container app runs one instance at a time, sleeps after five minutes |
| 25 | 25 | ||
| 26 | 26 | 1. a Workers project, from `wrangler deploy --dry-run`; | |
| 27 | 27 | 2. a framework it knows, such as Vite, Astro, Next.js or SvelteKit; | |
| 28 | − | 3. a container, which g1t.sh does not run yet (`CONTAINERS` is `off`); | |
| 28 | + | 3. a container: a `Dockerfile` that runs a server, even beside a site, or a | |
| 29 | + | server it recognises, for which it writes one | |
| 30 | + | ([Docker deployments](/guides/deployments/#docker-deployments)); | |
| 29 | 31 | 4. a static site. | |
| 30 | 32 | ||
| 33 | + | A container's image is pushed to Cloudflare's registry with a push-only | |
| 34 | + | login `deployments` mints for the build, and runs on Cloudflare | |
| 35 | + | Containers behind a Worker of g1t's own in the same namespace. A | |
| 36 | + | self-hosted g1t whose account has no Containers sets `CONTAINERS` to | |
| 37 | + | `off`. | |
| 38 | + | ||
| 31 | 39 | The sandbox is given a key that can upload the app's static files and | |
| 32 | 40 | nothing else. `deployments` uploads the built Worker itself to the Workers | |
| 33 | 41 | for Platforms dispatch namespace `g1t-deployments`. A site with only static |
| 317 | 317 | ## Docker deployments | |
| 318 | 318 | ||
| 319 | 319 | Container apps ([Docker deployments](/guides/deployments/#docker-deployments)) | |
| 320 | − | are off until `CONTAINERS` is `on` in `services/deployments/wrangler.jsonc`. | |
| 321 | − | The code follows Cloudflare's documentation and Wrangler's own client for | |
| 322 | − | these APIs, but none of them has been called against g1t's account yet. | |
| 323 | − | Before turning it on, check each of these with a test project: | |
| 320 | + | run when `CONTAINERS` is `on` in `services/deployments/wrangler.jsonc`, as | |
| 321 | + | it is for g1t.sh. On an account without Containers, set it to `off`: a | |
| 322 | + | container build then fails, saying so, and a project with a site beside | |
| 323 | + | its `Dockerfile` is built as that site. | |
| 324 | + | ||
| 325 | + | The calls follow Cloudflare's API reference and Wrangler's own client. | |
| 326 | + | On a new account, deploy a small server (an Express app with a | |
| 327 | + | `Dockerfile`, or none) and check each of these: | |
| 324 | 328 | ||
| 325 | 329 | 1. **The token.** `CLOUDFLARE_API_TOKEN` of `services/deployments` has | |
| 326 | 330 | **Containers: Edit** (and the account has Containers, on Workers Paid). | |
| ⋯ | |||
| 340 | 344 | `exports: { "G1tApp": { "type": "durable-object", "storage": "sqlite" } }` | |
| 341 | 345 | and `containers: [{ "class_name": "G1tApp" }]` in its metadata is taken, | |
| 342 | 346 | and taken again on the next deploy with nothing changed about the class. | |
| 343 | − | 5. **Its Durable Object namespace.** | |
| 347 | + | 5. **Its Durable Object namespace.** The app's Worker's `APP` binding | |
| 348 | + | (`GET …/dispatch/namespaces/g1t-deployments/scripts/<app>/bindings`) | |
| 349 | + | names its `namespace_id`; failing that, | |
| 344 | 350 | `GET /accounts/{id}/workers/durable_objects/namespaces` lists it with | |
| 345 | − | `class` `G1tApp`, `script` the app's name and `dispatch_namespace` | |
| 346 | − | `g1t-deployments`. | |
| 351 | + | `class` `G1tApp` and `script` the app's name. | |
| 347 | 352 | 6. **The Container application.** `POST /accounts/{id}/containers/applications` | |
| 348 | 353 | with `scheduling_policy` `default`, the image by digest, `instance_type`, | |
| 349 | 354 | `max_instances` 1 and `durable_objects.namespace_id` creates it; a | |
| ⋯ | |||
| 362 | 367 | 9. **Pausing and taking down.** A workspace over its limit gets its | |
| 363 | 368 | container app's Worker put back paused, which stops the container, and | |
| 364 | 369 | taking an app down deletes its application before its Worker. | |
| 365 | − | ||
| 366 | − | Then set `CONTAINERS` to `on`, deploy `services/deployments`, and the | |
| 367 | − | container build of a project with a `Dockerfile` goes live; the | |
| 368 | − | [limitations page](/about/limitations/#containers-are-not-switched-on-yet) | |
| 369 | − | and the caution in the deployments guide come out in the same change. | |
| 370 | + | 10. **Old images.** After a second deploy of the app, the registry's tag | |
| 371 | + | list for `<account>/g1t-<app>` has only the two newest builds' tags | |
| 372 | + | (`GET /v2/<account>/g1t-<app>/tags/list`), and none once the app is | |
| 373 | + | taken down. The account holds 50 GB of images in all. | |
| 370 | 374 | ||
| 371 | 375 | ## Rolling back | |
| 372 | 376 | ||
| 99 | 99 | | `wrangler.jsonc`, `wrangler.json` or `wrangler.toml` | Builds it as a **Workers project**: installs dependencies, runs `wrangler deploy --dry-run` to bundle it (which runs the config's own `build` command), and deploys the bundle with the config's static assets, `compatibility_date`, `compatibility_flags` and `vars`. | | |
| 100 | 100 | | A framework from the table below | Installs dependencies, builds it with the framework's command, and serves the folder it writes as a **static site**, or deploys the Worker its Cloudflare adapter writes as a **Workers project**. | | |
| 101 | 101 | | A framework set up to need a server (Next.js without a static export, Astro's `@astrojs/node`, SvelteKit's `adapter-node`, a server-rendered React Router or Remix app) | Builds it as a **container**: from its `Dockerfile`, or one g1t writes for it. | | |
| 102 | − | | A `Dockerfile` or `Containerfile`, and no framework above | Builds the image and runs it as a **container**. | | |
| 103 | − | | A Node.js, Python, Go, Ruby or PHP server, with no `Dockerfile` | Writes a Dockerfile for it, shows it in the build's log, and runs it as a **container**. See [Docker deployments](#docker-deployments). | | |
| 102 | + | | A `Dockerfile` or `Containerfile` that runs a server (`CMD`, `ENTRYPOINT` or `EXPOSE`), even beside a site or a framework above | Builds the image and runs it as a **container**. | | |
| 103 | + | | A `Dockerfile` and nothing else to serve | Builds the image and runs it as a **container**. | | |
| 104 | + | | A Node.js, Python, Go, Ruby, PHP or Rust server, with no `Dockerfile` | Writes a Dockerfile for it, shows it in the build's log, and runs it as a **container**. See [Docker deployments](#docker-deployments). | | |
| 104 | 105 | | A `build` script in `package.json` | Installs dependencies, runs `npm run build`, and serves the output as a **static site**. | | |
| 105 | 106 | | An `index.html` and nothing to build | Serves it as it is. | | |
| 106 | 107 | | Nothing g1t recognises | Fails, saying to add a `Dockerfile`. | | |
| ⋯ | |||
| 211 | 212 | behind the app's Worker, at the same addresses, custom domains and | |
| 212 | 213 | previews as any other app. | |
| 213 | 214 | ||
| 214 | − | :::caution[Not switched on yet] | |
| 215 | − | Docker deployments are built but off on g1t.page until Cloudflare's | |
| 216 | − | container APIs are checked against g1t's account. Until then a container | |
| 217 | − | build fails with "Docker deployments are not switched on for g1t.page | |
| 218 | − | yet", and a project that is a container only for having a `Dockerfile`, | |
| 219 | − | beside a site g1t could serve, is built as that site, as before. | |
| 220 | − | ::: | |
| 221 | − | ||
| 222 | − | What a project is built as is decided in this order: | |
| 215 | + | There is nothing to set: push a project with a `Dockerfile`, or a server | |
| 216 | + | g1t recognises, and it deploys as a container. What a project is built as | |
| 217 | + | is decided in this order: | |
| 223 | 218 | ||
| 224 | 219 | 1. **Build as**, under **Settings → Deployments**: **Static site**, | |
| 225 | 220 | **Workers** or **Docker** is what it is built as, whatever else it has. | |
| 226 | 221 | 2. **Auto** (the default) finds it from its files: | |
| 227 | − | 1. A Wrangler config is a Workers project. | |
| 228 | − | 2. A framework that builds a static site or a Worker is that, even | |
| 229 | − | beside a `Dockerfile`: it costs nothing while no one visits. | |
| 230 | − | 3. A framework set up to need a server is a container, from its | |
| 222 | + | 1. A Wrangler config, or a framework's Cloudflare adapter, is a | |
| 223 | + | Workers project. | |
| 224 | + | 2. A site (a framework that builds one, a `build` script, or an | |
| 225 | + | `index.html`) beside a `Dockerfile` is a container when the | |
| 226 | + | Dockerfile runs a server: its last stage has `CMD` or `ENTRYPOINT`, | |
| 227 | + | or `EXPOSE` on an image that is not only a file server. A | |
| 228 | + | Dockerfile that only copies the site into `nginx`, `httpd`, `caddy`, | |
| 229 | + | `lighttpd`, `busybox` or another file server, or says none of those, | |
| 230 | + | leaves it a site, which g1t serves itself for nothing while no one | |
| 231 | + | visits. | |
| 232 | + | 3. A framework that builds a static site, with no such Dockerfile, is | |
| 233 | + | that site. | |
| 234 | + | 4. A framework set up to need a server is a container, from its | |
| 231 | 235 | `Dockerfile`, or one g1t writes for it. | |
| 232 | − | 4. A project with no framework g1t knows is a container when it has a | |
| 236 | + | 5. A project with no framework g1t knows is a container when it has a | |
| 233 | 237 | `Dockerfile` (or `Containerfile`, or the path set as its | |
| 234 | 238 | **Dockerfile**), or is a server g1t recognises. | |
| 235 | − | 5. Otherwise a static site or plain HTML, as above; with nothing to | |
| 239 | + | 6. Otherwise a static site or plain HTML, as above; with nothing to | |
| 236 | 240 | serve, the build fails, saying to add a `Dockerfile`. | |
| 237 | 241 | ||
| 242 | + | When a project could be either, the build's log says which it chose and | |
| 243 | + | why, near its top: for example, "Building it as a container from | |
| 244 | + | Dockerfile, not as the static site it also has: its Dockerfile starts a | |
| 245 | + | server of its own (`CMD`). Set **Build as** to Static site to serve the | |
| 246 | + | site instead." | |
| 247 | + | ||
| 238 | 248 | g1t writes a Dockerfile for these, when there is none: | |
| 239 | 249 | ||
| 240 | 250 | | The project has | Runs with | | |
| 241 | 251 | | --- | --- | | |
| 242 | 252 | | `package.json` with a `start` script, or a server package (`express`, `fastify`, `koa`, `@nestjs/core`, `@hono/node-server` and others) and a main file | `npm start`, or `node <main>`, on `node:22-slim` (or the major release in `.nvmrc` or `.node-version`), after `npm run build` when there is one | | |
| 243 | − | | Next.js, Astro with `@astrojs/node`, SvelteKit with `adapter-node`, React Router or Remix | `next start`, `node ./dist/server/entry.mjs`, `node build`, `react-router-serve` or `remix-serve`, after the build | | |
| 253 | + | | Next.js, Astro with `@astrojs/node`, SvelteKit with `adapter-node`, React Router or Remix | `next start` (or `node .next/standalone/server.js`, with its static files beside it, for `output: 'standalone'`), `node ./dist/server/entry.mjs`, `node build`, `react-router-serve` or `remix-serve`, after the build | | |
| 244 | 254 | | `requirements.txt`, `pyproject.toml` or `Pipfile`, and `manage.py` (Django) | `gunicorn <project>.wsgi` on `python:3.12-slim` | | |
| 245 | 255 | | The same, with Flask and an `app.py`, `main.py`, `server.py`, `wsgi.py` or `asgi.py` | `gunicorn <module>:app` | | |
| 246 | 256 | | The same, with FastAPI or Starlette | `uvicorn <module>:app` | | |
| ⋯ | |||
| 249 | 259 | | A `Gemfile` with Rails | `rails server`, after `assets:precompile`, on `ruby:3.3-slim` | | |
| 250 | 260 | | A `Gemfile` and a `config.ru` | `rackup` | | |
| 251 | 261 | | `composer.json`, and `artisan` with `public/index.php` (Laravel), or an `index.php` | Apache with PHP 8.3, after `composer install` | | |
| 262 | + | | `Cargo.toml` with one program and `axum`, `actix-web`, `rocket`, `warp`, `poem`, `salvo` or `tide` | The program, built with `rust:1` into `debian:bookworm-slim` | | |
| 252 | 263 | ||
| 253 | 264 | The Dockerfile g1t writes is printed whole in the build's log. To change | |
| 254 | 265 | how the app is built or run, commit it as `Dockerfile` and edit it. It | |
| ⋯ | |||
| 257 | 268 | What a container gets, and what it must do: | |
| 258 | 269 | ||
| 259 | 270 | - **Listen on `0.0.0.0` at `PORT`.** `PORT` is the port its Dockerfile's | |
| 260 | − | first `EXPOSE` names (in its last stage), else `8080`. The first request | |
| 271 | + | first `EXPOSE` names (in its last stage), else the `PORT` it sets with | |
| 272 | + | `ENV`, else `8080`. A Dockerfile g1t writes sets `HOST` and `HOSTNAME` | |
| 273 | + | to `0.0.0.0` too. The first request | |
| 261 | 274 | waits up to 90 seconds for it to answer there; one that never does gets | |
| 262 | 275 | a 503 page saying so. | |
| 263 | 276 | - **The project's [secrets and variables](#secrets-and-variables)** are | |
| ⋯ | |||
| 271 | 284 | - **Its size** is **Container size** under **Settings → Deployments**: | |
| 272 | 285 | `lite` (the default) to `standard-4`, as [What it costs](#what-it-costs) | |
| 273 | 286 | lists. The image may be as large as the size's disk, 2 GB for `lite`; | |
| 274 | − | a larger image fails the build, saying how large it was. | |
| 287 | + | a larger image fails the build, saying how large it was. An app keeps | |
| 288 | + | the image it runs and the one before it; older ones are deleted once a | |
| 289 | + | deploy is live, and all of them when the app is taken down. | |
| 275 | 290 | - **Built for `linux/amd64`**, with Docker, in the build's sandbox (or the | |
| 276 | 291 | workflow job's Docker, for [`g1t-runner deploy`](#deploying-from-a-workflow)), | |
| 277 | 292 | with network access as any build has. **Build command** is not used: | |
| ⋯ | |||
| 864 | 879 | | A deployment **Skipped**, saying it reached its spend limit or that new compute is paused | The build was refused before it started. Follow the link in the message: raise the spend limit, prepay, or answer the spike, on Billing. | | |
| 865 | 880 | | "Stopped: unusual CPU use" | The build looked like it was mining. Contact support if it was a real build; see [abuse and mining](/guides/guardrails/#abuse-and-mining). | | |
| 866 | 881 | | "found nothing to serve" | Add a `Dockerfile`, a `build` script, an `index.html`, or a Workers config; or set **Output directory**. | | |
| 867 | − | | "Docker deployments are not switched on for g1t.page yet" | The project is a container, which g1t.page does not run yet. Set **Build as** to Static site or Workers if it can be built as one. See [Docker deployments](#docker-deployments). | | |
| 882 | + | | "Docker deployments are switched off on this g1t" | The project is a container, and this g1t (a self-hosted one) runs none. Set **Build as** to Static site or Workers if it can be built as one. See [Docker deployments](#docker-deployments). | | |
| 868 | 883 | | "**Build as** is Docker, but this project has no Dockerfile" | Add a `Dockerfile`, or set **Dockerfile** to its path. | | |
| 869 | 884 | | "The image is 2.4 GB, more than the 2.0 GB an app of its size (lite) can hold" | Make the image smaller (a slim base image, a multi-stage build), or choose a larger **Container size**. | | |
| 870 | 885 | | A container app says "The app did not start" | It did not answer on its port within 90 seconds. Make it listen on `0.0.0.0` at `PORT`, or `EXPOSE` the port it uses in its Dockerfile. Its output is in the build's log only while it builds; run the image locally with `docker run -e PORT=8080 -p 8080:8080` to see it start. | | |
| ⋯ | |||
| 894 | 909 | `npx wrangler secret put CLOUDFLARE_API_TOKEN` in `services/deployments`. | |
| 895 | 910 | 4. Deploy `services/deployments`, `services/pages`, and the runner. | |
| 896 | 911 | ||
| 897 | − | Docker deployments also need the token to have **Containers: Edit**, a | |
| 898 | − | Workers Paid plan with Containers, and `CONTAINERS` set to `on` in | |
| 899 | − | `services/deployments/wrangler.jsonc`; see | |
| 912 | + | Docker deployments also need the token to have **Containers: Edit** and | |
| 913 | + | a Workers Paid plan with Containers; `CONTAINERS` in | |
| 914 | + | `services/deployments/wrangler.jsonc` is `on`, and `off` turns them off | |
| 915 | + | for an account without Containers. See | |
| 900 | 916 | [Docker deployments](/guides/deploy-to-cloudflare/#docker-deployments) | |
| 901 | − | for what to check first. | |
| 917 | + | for what to check on a new account. | |
| 902 | 918 | ||
| 903 | 919 | Custom domains also need Cloudflare for SaaS on the apps' zone. Turn it on | |
| 904 | 920 | under the zone's **SSL/TLS → Custom Hostnames**, give the token **SSL and | |
| 92 | 92 | | [Security](/guides/security/) | Secret push protection, history scanning and pull requests that upgrade vulnerable dependencies. | <Status is="live" /> | | |
| 93 | 93 | | [Deployments](/guides/deployments/) | A live preview of every pull request on g1t.page, production on merge, and custom domains. | <Status is="live" /> | | |
| 94 | 94 | | Preview data | Each preview gets its own copy of the database and bucket, deleted when the pull request closes. | <Status is="coming" /> | | |
| 95 | − | | [Docker deployments](/guides/deployments/#docker-deployments) | Any app with a Dockerfile, or a Node.js, Python, Go, Ruby or PHP server, runs as a container that sleeps when no one visits. | <Status is="coming" /> | | |
| 95 | + | | [Docker deployments](/guides/deployments/#docker-deployments) | Any app with a Dockerfile, or a Node.js, Python, Go, Ruby or PHP server, runs as a container that sleeps when no one visits. | <Status is="live" /> | | |
| 96 | 96 | | [Observability](/guides/observability/) | Requests, logs, traces, metrics and grouped errors, by deployment: from apps on g1t.page with nothing to set up, and from anywhere else over OpenTelemetry. | <Status is="live" /> | | |
| 97 | 97 | | Rollback and always on | One click back to any deploy, and container apps kept warm. | <Status is="coming" /> | | |
| 98 | 98 | | [GitHub](/guides/github/) | Import repositories with every branch and tag, and their issues. | <Status is="live" /> | |
| 110 | 110 | <p className="text-xs text-muted"> | |
| 111 | 111 | Charged by the minute while awake, at what Cloudflare charges g1t plus 20%, with every vCPU counted as busy; data a | |
| 112 | 112 | container sends is {money(offer.egressGbMicros, { precise: true })} a GB. | |
| 113 | − | {!offer.available && " Docker deployments are not switched on for g1t.page yet: until they are, a container build fails, or builds the site the project is without its Dockerfile."} | |
| 113 | + | {!offer.available && " Docker deployments are switched off on this g1t: a container build fails, or builds the site the project is without its Dockerfile."} | |
| 114 | 114 | </p> | |
| 115 | 115 | </div> | |
| 116 | 116 | ); | |
| ⋯ | |||
| 172 | 172 | Build through a workflow g1t manages for this project, listed under Actions with its runs and logs. It runs on g1t's machines like any workflow, and a workflow named g1t in .g1t/workflows takes its place. | |
| 173 | 173 | </Check> | |
| 174 | 174 | <div className="grid gap-4 md:grid-cols-2"> | |
| 175 | − | <Field label="Build as" hint="Auto finds it from the project's files: a Wrangler config is Workers, a framework that builds a static site stays one, and a Dockerfile or a server makes it a container."> | |
| 175 | + | <Field label="Build as" hint="Auto finds it from the project's files: a Wrangler config is Workers; a Dockerfile that runs a server, or a server g1t recognises, is a container; anything else that builds a site stays one. The build's log says which it chose and why."> | |
| 176 | 176 | <SelectField name="buildAs" defaultValue={settings.buildAs ?? "auto"} aria-label="Build as" options={BUILD_AS_OPTIONS} /> | |
| 177 | 177 | </Field> | |
| 178 | 178 | <Field label="Dockerfile" hint="For a container; Dockerfile or Containerfile is found by itself when empty."> | |
| 655 | 655 | /// For a container: its Dockerfile, or the one g1t writes. | |
| 656 | 656 | pub container: Option<ContainerBuild>, | |
| 657 | 657 | /// What it would be served as without its Dockerfile, when it is a | |
| 658 | − | /// container only for having one: built so while g1t.page runs no | |
| 659 | − | /// containers (`not_enabled` from the registry step). | |
| 658 | + | /// container only for having one: built so where Docker deployments | |
| 659 | + | /// are switched off (`not_enabled` from the registry step). | |
| 660 | 660 | fallback: Option<Box<Detected>>, | |
| 661 | + | /// Why it is built as this and not as the other thing it could be (a | |
| 662 | + | /// site beside a Dockerfile), for the build's log. | |
| 663 | + | pub reason: Option<String>, | |
| 661 | 664 | } | |
| 662 | 665 | ||
| 663 | 666 | /// How a container is built. | |
| ⋯ | |||
| 685 | 688 | pre_build: None, | |
| 686 | 689 | container: None, | |
| 687 | 690 | fallback: None, | |
| 691 | + | reason: None, | |
| 688 | 692 | } | |
| 689 | 693 | } | |
| 690 | 694 | ||
| ⋯ | |||
| 733 | 737 | pre_build: None, | |
| 734 | 738 | container: Some(build), | |
| 735 | 739 | fallback: None, | |
| 740 | + | reason: None, | |
| 736 | 741 | }) | |
| 737 | 742 | } | |
| 738 | 743 | ||
| ⋯ | |||
| 747 | 752 | /// - **Docker**: a container, from its Dockerfile or one g1t writes. | |
| 748 | 753 | /// - **Static site**: built and served as files, whatever else it has. | |
| 749 | 754 | /// - **Workers**: its Wrangler config or a framework adapter's Worker. | |
| 750 | − | /// - **Auto**: a Wrangler config is a Workers project; a framework that | |
| 751 | − | /// builds a static site or a Worker is that, which costs nothing while | |
| 752 | − | /// no one visits; a framework set up to need a server is a container | |
| 755 | + | /// - **Auto**, in order: a Wrangler config (or a framework's Workers | |
| 756 | + | /// adapter) is a Workers project; a site beside a Dockerfile of the | |
| 757 | + | /// project's own is a container when the Dockerfile runs a server | |
| 758 | + | /// (`container::runs_server`: `CMD`, `ENTRYPOINT`, or `EXPOSE` on an | |
| 759 | + | /// image that is not only a file server), else the site, which costs | |
| 760 | + | /// nothing while no one visits; any other framework that builds a site | |
| 761 | + | /// is that site; a framework set up to need a server is a container | |
| 753 | 762 | /// when it has a Dockerfile, or when g1t can write one; a project with | |
| 754 | 763 | /// no framework g1t knows is a container when it has a Dockerfile or is | |
| 755 | − | /// a server g1t recognises (a Node.js, Python, Go, Ruby or PHP app), | |
| 756 | − | /// else a static site, else nothing, saying to add a Dockerfile. | |
| 764 | + | /// a server g1t recognises (a Node.js, Python, Go, Ruby, PHP or Rust | |
| 765 | + | /// app), else a static site, else nothing, saying to add a Dockerfile. | |
| 766 | + | /// Each choice between a site and a container says why (`reason`). | |
| 757 | 767 | pub(crate) fn detect_as( | |
| 758 | 768 | dir: &Path, | |
| 759 | 769 | custom_build: Option<&str>, | |
| ⋯ | |||
| 791 | 801 | } | |
| 792 | 802 | BuildAs::Auto => { | |
| 793 | 803 | let site = detect_site(dir, custom_build, chosen_output, false)?; | |
| 794 | − | if site.kind == "workers" || (site.framework.is_some() && site.problem.is_none()) { | |
| 804 | + | if site.kind == "workers" { | |
| 795 | 805 | return Ok(site); | |
| 796 | 806 | } | |
| 807 | + | // A site beside a Dockerfile of the project's own: the | |
| 808 | + | // Dockerfile, when it runs a server; else the site, which g1t | |
| 809 | + | // serves for nothing while no one visits. | |
| 810 | + | let serves = site.problem.is_none() && (site.kind == "static" || site.kind == "html"); | |
| 811 | + | if serves && let Some(path) = &own { | |
| 812 | + | let text = std::fs::read_to_string(dir.join(path)).unwrap_or_default(); | |
| 813 | + | let (server, why) = container::runs_server(&text); | |
| 814 | + | let what = if site.kind == "html" { "plain HTML site" } else { "static site" }; | |
| 815 | + | if !server { | |
| 816 | + | let mut site = site; | |
| 817 | + | site.reason = Some(format!( | |
| 818 | + | "Building it as a {what}, not from {path}: {why}, and g1t serves the site itself. \ | |
| 819 | + | Set **Build as** to Docker to run the container instead." | |
| 820 | + | )); | |
| 821 | + | return Ok(site); | |
| 822 | + | } | |
| 823 | + | let mut found = as_container(dir, site.framework.clone(), own.clone()).context("its Dockerfile was not read")?; | |
| 824 | + | found.reason = Some(format!( | |
| 825 | + | "Building it as a container from {path}, not as the {what} it also has: {why}. \ | |
| 826 | + | Set **Build as** to Static site to serve the site instead." | |
| 827 | + | )); | |
| 828 | + | found.fallback = Some(Box::new(site)); | |
| 829 | + | return Ok(found); | |
| 830 | + | } | |
| 831 | + | if site.framework.is_some() && site.problem.is_none() { | |
| 832 | + | return Ok(site); | |
| 833 | + | } | |
| 797 | 834 | if let Some(problem) = site.problem.clone() { | |
| 798 | 835 | // A framework set up to need a server. | |
| 799 | 836 | let found = as_container(dir, site.framework.clone(), own); | |
| ⋯ | |||
| 801 | 838 | Detected::nothing(site.framework, site.install, format!("{problem} Or add a `Dockerfile` to run it as a container.")) | |
| 802 | 839 | })); | |
| 803 | 840 | } | |
| 804 | − | let has_own = own.is_some(); | |
| 841 | + | // A Dockerfile with nothing else to serve, or a server g1t | |
| 842 | + | // recognises, which it writes one for. | |
| 805 | 843 | if let Some(mut found) = as_container(dir, None, own) { | |
| 806 | − | // A Dockerfile beside a site that could be served as it is. | |
| 807 | − | if has_own && (site.kind == "static" || site.kind == "html") { | |
| 808 | − | found.fallback = Some(Box::new(site)); | |
| 844 | + | if let (Some(server), Some(folder)) = (found.container.as_ref().and_then(|c| c.generated.as_ref()), &site.output_dir) { | |
| 845 | + | found.reason = Some(format!( | |
| 846 | + | "Building it as a {} server, not as a site of `{folder}`. Set **Build as** to Static site to serve only `{folder}` instead.", | |
| 847 | + | server.name | |
| 848 | + | )); | |
| 809 | 849 | } | |
| 810 | 850 | return Ok(found); | |
| 811 | 851 | } | |
| ⋯ | |||
| 848 | 888 | pre_build, | |
| 849 | 889 | container: None, | |
| 850 | 890 | fallback: None, | |
| 891 | + | reason: None, | |
| 851 | 892 | }); | |
| 852 | 893 | } | |
| 853 | 894 | let build = custom_build | |
| ⋯ | |||
| 868 | 909 | problem: None, | |
| 869 | 910 | container: None, | |
| 870 | 911 | fallback: None, | |
| 912 | + | reason: None, | |
| 871 | 913 | }); | |
| 872 | 914 | } | |
| 873 | 915 | // A command or a folder of the project's own says it knows better, as | |
| ⋯ | |||
| 889 | 931 | (None, None) => "none", | |
| 890 | 932 | _ => "static", | |
| 891 | 933 | }; | |
| 892 | − | Ok(Detected { kind, framework, install, pre_build: build.clone(), build, output_dir, problem: None, container: None, fallback: None }) | |
| 934 | + | Ok(Detected { kind, framework, install, pre_build: build.clone(), build, output_dir, problem: None, container: None, fallback: None, reason: None }) | |
| 893 | 935 | } | |
| 894 | 936 | ||
| 895 | 937 | /// The compatibility date a Worker gets when its config names none. | |
| ⋯ | |||
| 936 | 978 | bundle.with_file_name(format!("{}.wrangler.json", bundle.file_name().unwrap_or_default().to_string_lossy())) | |
| 937 | 979 | } | |
| 938 | 980 | ||
| 939 | − | /// What a container build says when g1t.page runs no containers. | |
| 940 | − | const CONTAINERS_OFF: &str = "This project deploys as a container, and Docker deployments are not switched on for g1t.page yet. \ | |
| 981 | + | /// What a container build says where Docker deployments are switched off | |
| 982 | + | /// (a self-hosted g1t with `CONTAINERS` set to `off`). | |
| 983 | + | const CONTAINERS_OFF: &str = "This project deploys as a container, and Docker deployments are switched off on this g1t. \ | |
| 941 | 984 | Set **Build as** to Static site or Workers under Settings, Deployments if it can be built as one."; | |
| 942 | 985 | ||
| 943 | 986 | /// Where a container's registry login is kept for the build: its own | |
| ⋯ | |||
| 1063 | 1106 | std::fs::create_dir_all(&config)?; | |
| 1064 | 1107 | let image = format!("{}/{}:{}", grant.registry, grant.repository, grant.tag); | |
| 1065 | 1108 | let quote = |path: &Path| format!("'{}'", path.display().to_string().replace('\'', r"'\''")); | |
| 1109 | + | // One image for one platform, with no attestation beside it: what | |
| 1110 | + | // `wrangler containers build` pushes, so the digest pushed is the | |
| 1111 | + | // image's own manifest, not an index Containers would have to choose from. | |
| 1066 | 1112 | let built = step( | |
| 1067 | 1113 | log, | |
| 1068 | − | &format!("docker build --platform linux/amd64 --pull -f {} -t {image} .", quote(&dockerfile)), | |
| 1114 | + | &format!("docker build --platform linux/amd64 --provenance=false --pull -f {} -t {image} .", quote(&dockerfile)), | |
| 1069 | 1115 | &secrets, | |
| 1070 | 1116 | ); | |
| 1071 | 1117 | let pushed = built.and_then(|()| { | |
| ⋯ | |||
| 1109 | 1155 | if let Some(framework) = &plan.framework { | |
| 1110 | 1156 | log.line(&format!("Detected {}: framework={}.", framework.name, framework.id)); | |
| 1111 | 1157 | } | |
| 1158 | + | if let Some(reason) = &plan.reason { | |
| 1159 | + | log.line(reason); | |
| 1160 | + | } | |
| 1112 | 1161 | // Said before anything is installed: it would fail after, less clearly. | |
| 1113 | 1162 | if let Some(problem) = &plan.problem { | |
| 1114 | 1163 | bail!("{problem}"); | |
| ⋯ | |||
| 1125 | 1174 | container: Some(image), | |
| 1126 | 1175 | }); | |
| 1127 | 1176 | } | |
| 1128 | − | // g1t.page runs no containers yet: the site it is without its | |
| 1129 | − | // Dockerfile, as it was built before. | |
| 1177 | + | // Docker deployments are switched off here: the site it is | |
| 1178 | + | // without its Dockerfile. | |
| 1130 | 1179 | None => match plan.fallback.take() { | |
| 1131 | 1180 | Some(site) => { | |
| 1132 | 1181 | log.line(&format!("Building it as a {} site instead, as it is without its Dockerfile.", if site.kind == "html" { "plain HTML" } else { "static" })); | |
| ⋯ | |||
| 1580 | 1629 | ||
| 1581 | 1630 | #[test] | |
| 1582 | 1631 | fn a_refusal_reaches_the_step_with_its_reason() { | |
| 1583 | − | let base = answering("409 Conflict", r#"{"ok":false,"error":{"code":"not_enabled","message":"Docker deployments are not switched on for g1t.page yet."}}"#); | |
| 1632 | + | let base = answering("409 Conflict", r#"{"ok":false,"error":{"code":"not_enabled","message":"Docker deployments are switched off on this g1t."}}"#); | |
| 1584 | 1633 | let answer = Reporter { base, token: "t".into() }.send("registry", json!({})).unwrap(); | |
| 1585 | 1634 | assert_eq!(answer["ok"], false); | |
| 1586 | 1635 | assert_eq!(answer["error"]["code"], "not_enabled"); | |
| ⋯ | |||
| 1730 | 1779 | pre_build: None, | |
| 1731 | 1780 | container: None, | |
| 1732 | 1781 | fallback: None, | |
| 1782 | + | reason: None, | |
| 1733 | 1783 | }; | |
| 1734 | 1784 | assert_eq!(output_lines(&found), "kind=static\ninstall=npm ci\nbuild=a b\noutput-dir=\n"); | |
| 1735 | 1785 | let vite = project("lines", &[("package.json", r#"{ "devDependencies": { "vite": "7" } }"#)]); | |
| ⋯ | |||
| 2096 | 2146 | fn build_as_and_dockerfiles_decide_in_order() { | |
| 2097 | 2147 | let detect_with = |dir: &Path, build_as: BuildAs, dockerfile: Option<&str>| detect_as(dir, None, None, build_as, dockerfile).unwrap(); | |
| 2098 | 2148 | // A framework that builds a static site stays one beside a | |
| 2099 | − | // Dockerfile: it costs nothing while no one visits. | |
| 2149 | + | // Dockerfile that only serves its files: it costs nothing while no | |
| 2150 | + | // one visits. | |
| 2100 | 2151 | let vite = project("p-vite", &[("package.json", r#"{ "devDependencies": { "vite": "7" } }"#), ("Dockerfile", "FROM nginx\nEXPOSE 80\n")]); | |
| 2101 | − | assert_eq!(detect_with(&vite, BuildAs::Auto, None).kind, "static"); | |
| 2152 | + | let found = detect_with(&vite, BuildAs::Auto, None); | |
| 2153 | + | assert_eq!(found.kind, "static"); | |
| 2154 | + | assert!(found.reason.unwrap().contains("only serves files with `nginx`")); | |
| 2155 | + | // Beside a Dockerfile that runs a server of its own, the server: | |
| 2156 | + | // the site alone would leave its back end out. | |
| 2157 | + | let full = project( | |
| 2158 | + | "p-vite-server", | |
| 2159 | + | &[ | |
| 2160 | + | ("package.json", r#"{ "devDependencies": { "vite": "7" } }"#), | |
| 2161 | + | ( | |
| 2162 | + | "Dockerfile", | |
| 2163 | + | "FROM node:22-slim AS build\nRUN npm ci && npm run build\nFROM node:22-slim\nCOPY --from=build /app /app\nENV PORT=3000\nCMD [\"node\", \"server.js\"]\n", | |
| 2164 | + | ), | |
| 2165 | + | ], | |
| 2166 | + | ); | |
| 2167 | + | let found = detect_with(&full, BuildAs::Auto, None); | |
| 2168 | + | assert_eq!((found.kind, found.framework_id()), ("container", Some("vite"))); | |
| 2169 | + | assert_eq!(found.container.as_ref().unwrap().port, 3000); | |
| 2170 | + | assert_eq!(found.fallback.as_ref().map(|site| site.kind), Some("static")); | |
| 2171 | + | assert!(found.reason.unwrap().contains("not as the static site it also has: its Dockerfile starts a server of its own (`CMD`)")); | |
| 2102 | 2172 | // Unless the project says Docker. | |
| 2103 | 2173 | let found = detect_with(&vite, BuildAs::Docker, None); | |
| 2104 | 2174 | assert_eq!((found.kind, found.framework_id()), ("container", Some("vite"))); | |
| ⋯ | |||
| 2113 | 2183 | let found = detect_with(&html, BuildAs::Workers, None); | |
| 2114 | 2184 | assert_eq!(found.kind, "none"); | |
| 2115 | 2185 | assert!(found.problem.unwrap().contains("no Wrangler config")); | |
| 2116 | − | // No framework and a Dockerfile: a container, built as the site it | |
| 2117 | − | // is without one while g1t.page runs no containers. | |
| 2118 | − | let both = project("p-both", &[("index.html", ""), ("Containerfile", "FROM nginx")]); | |
| 2186 | + | // No framework, a site and a Dockerfile that runs a server: a | |
| 2187 | + | // container, built as the site it is without one where Docker | |
| 2188 | + | // deployments are switched off. | |
| 2189 | + | let both = project("p-both", &[("index.html", ""), ("Containerfile", "FROM python:3.12-slim\nEXPOSE 5000\n")]); | |
| 2119 | 2190 | let found = detect_with(&both, BuildAs::Auto, None); | |
| 2120 | 2191 | assert_eq!((found.kind, found.framework_id()), ("container", Some("dockerfile"))); | |
| 2121 | − | assert_eq!(found.container.as_ref().unwrap().port, 8080); | |
| 2192 | + | assert_eq!(found.container.as_ref().unwrap().port, 5000); | |
| 2122 | 2193 | assert_eq!(found.fallback.as_ref().map(|site| site.kind), Some("html")); | |
| 2194 | + | // A site and a Dockerfile that says neither how it starts nor | |
| 2195 | + | // where it listens: the site. | |
| 2196 | + | let bare = project("p-bare", &[("index.html", ""), ("Dockerfile", "FROM nginx")]); | |
| 2197 | + | let found = detect_with(&bare, BuildAs::Auto, None); | |
| 2198 | + | assert_eq!(found.kind, "html"); | |
| 2199 | + | assert!(found.reason.unwrap().contains("neither starts a server")); | |
| 2200 | + | // A Dockerfile and nothing else is a container, whatever it says. | |
| 2201 | + | let alone = project("p-alone", &[("Dockerfile", "FROM nginx")]); | |
| 2202 | + | assert_eq!(detect_with(&alone, BuildAs::Auto, None).kind, "container"); | |
| 2123 | 2203 | // A Dockerfile at a path of the project's choosing. | |
| 2124 | 2204 | let chosen = project("p-chosen", &[("ops/web.Dockerfile", "FROM node\nEXPOSE 3000")]); | |
| 2125 | 2205 | assert_eq!(detect_with(&chosen, BuildAs::Auto, None).kind, "none"); | |
| ⋯ | |||
| 2148 | 2228 | // OpenNext's adapter means Workers: it is not run as a server. | |
| 2149 | 2229 | let opennext = project("p-opennext", &[("package.json", r#"{ "dependencies": { "next": "16", "@opennextjs/cloudflare": "1" } }"#)]); | |
| 2150 | 2230 | assert_eq!(detect_with(&opennext, BuildAs::Auto, None).kind, "none"); | |
| 2151 | − | for dir in [vite, workers, html, both, chosen, express, nothing, next, opennext] { | |
| 2231 | + | // A server g1t recognises beside a folder it could serve: the | |
| 2232 | + | // server, saying so. | |
| 2233 | + | let public = project( | |
| 2234 | + | "p-public", | |
| 2235 | + | &[("package.json", r#"{ "scripts": { "start": "node index.js" }, "dependencies": { "express": "5" } }"#), ("public/index.html", "")], | |
| 2236 | + | ); | |
| 2237 | + | let found = detect_with(&public, BuildAs::Auto, None); | |
| 2238 | + | assert_eq!(found.kind, "container"); | |
| 2239 | + | assert!(found.reason.unwrap().contains("as a Node.js server, not as a site of `public`")); | |
| 2240 | + | for dir in [vite, full, workers, html, both, bare, alone, chosen, express, nothing, next, opennext, public] { | |
| 2152 | 2241 | let _ = std::fs::remove_dir_all(dir); | |
| 2153 | 2242 | } | |
| 2154 | 2243 | } | |
| 59 | 59 | Ok(DOCKERFILES.iter().find(|name| dir.join(name).is_file()).map(|name| (*name).to_owned())) | |
| 60 | 60 | } | |
| 61 | 61 | ||
| 62 | − | /// The port a Dockerfile's last stage says it listens on (its first | |
| 63 | − | /// `EXPOSE` there), if it says. | |
| 64 | − | pub(crate) fn exposed_port(dockerfile: &str) -> Option<u16> { | |
| 65 | − | let mut port = None; | |
| 62 | + | /// The instructions of a Dockerfile's last stage, the one that runs: each | |
| 63 | + | /// as its keyword in capitals and the rest of its line. Lines continued | |
| 64 | + | /// with `\` are joined, and comments dropped. | |
| 65 | + | fn last_stage(dockerfile: &str) -> Vec<(String, String)> { | |
| 66 | + | let mut joined = Vec::new(); | |
| 67 | + | let mut current = String::new(); | |
| 66 | 68 | for line in dockerfile.lines() { | |
| 67 | − | let line = line.trim(); | |
| 68 | − | let mut words = line.split_whitespace(); | |
| 69 | − | let Some(word) = words.next() else { continue }; | |
| 70 | − | if word.eq_ignore_ascii_case("FROM") { | |
| 69 | + | let trimmed = line.trim(); | |
| 70 | + | if current.is_empty() && (trimmed.is_empty() || trimmed.starts_with('#')) { | |
| 71 | + | continue; | |
| 72 | + | } | |
| 73 | + | match trimmed.strip_suffix('\\') { | |
| 74 | + | Some(start) => { | |
| 75 | + | current.push_str(start); | |
| 76 | + | current.push(' '); | |
| 77 | + | } | |
| 78 | + | None => { | |
| 79 | + | current.push_str(trimmed); | |
| 80 | + | joined.push(std::mem::take(&mut current)); | |
| 81 | + | } | |
| 82 | + | } | |
| 83 | + | } | |
| 84 | + | if !current.is_empty() { | |
| 85 | + | joined.push(current); | |
| 86 | + | } | |
| 87 | + | let mut stage = Vec::new(); | |
| 88 | + | for line in joined { | |
| 89 | + | let (word, rest) = line.split_once(char::is_whitespace).unwrap_or((line.as_str(), "")); | |
| 90 | + | let word = word.to_ascii_uppercase(); | |
| 91 | + | if word == "FROM" { | |
| 71 | 92 | // Each stage starts afresh: only the last one's is what runs. | |
| 72 | − | port = None; | |
| 73 | − | } else if word.eq_ignore_ascii_case("EXPOSE") && port.is_none() { | |
| 74 | − | port = words.next().and_then(|p| p.split('/').next()).and_then(|p| p.parse::<u16>().ok()).filter(|p| *p > 0); | |
| 93 | + | stage.clear(); | |
| 75 | 94 | } | |
| 95 | + | stage.push((word, rest.trim().to_owned())); | |
| 76 | 96 | } | |
| 77 | − | port | |
| 97 | + | stage | |
| 78 | 98 | } | |
| 79 | 99 | ||
| 100 | + | /// The port a Dockerfile's last stage says it listens on: its first | |
| 101 | + | /// `EXPOSE` there, else the `PORT` it sets with `ENV`, if it says. | |
| 102 | + | pub(crate) fn exposed_port(dockerfile: &str) -> Option<u16> { | |
| 103 | + | let stage = last_stage(dockerfile); | |
| 104 | + | let port = |text: &str| text.split('/').next().and_then(|p| p.trim_matches('"').parse::<u16>().ok()).filter(|p| *p > 0); | |
| 105 | + | let exposed = stage.iter().filter(|(word, _)| word == "EXPOSE").find_map(|(_, rest)| rest.split_whitespace().next().and_then(port)); | |
| 106 | + | exposed.or_else(|| { | |
| 107 | + | stage.iter().rev().filter(|(word, _)| word == "ENV").find_map(|(_, rest)| { | |
| 108 | + | // `ENV PORT=3000 OTHER=x`, or the old `ENV PORT 3000`. | |
| 109 | + | if let Some(value) = rest.split_whitespace().find_map(|pair| pair.strip_prefix("PORT=")) { | |
| 110 | + | return port(value); | |
| 111 | + | } | |
| 112 | + | let mut words = rest.split_whitespace(); | |
| 113 | + | (words.next() == Some("PORT")).then(|| words.next().and_then(port)).flatten() | |
| 114 | + | }) | |
| 115 | + | }) | |
| 116 | + | } | |
| 117 | + | ||
| 118 | + | /// Images that serve a folder of files and nothing else, as the base of a | |
| 119 | + | /// Dockerfile that only copies a site into them. | |
| 120 | + | const FILE_SERVERS: [&str; 8] = [ | |
| 121 | + | "nginx", | |
| 122 | + | "nginxinc/nginx-unprivileged", | |
| 123 | + | "httpd", | |
| 124 | + | "caddy", | |
| 125 | + | "lighttpd", | |
| 126 | + | "joseluisq/static-web-server", | |
| 127 | + | "pierrezemb/gostatic", | |
| 128 | + | "busybox", | |
| 129 | + | ]; | |
| 130 | + | ||
| 131 | + | /// What a Dockerfile's last stage is: whether it runs a server of the | |
| 132 | + | /// project's own, and in words, why, for the build's log. | |
| 133 | + | /// | |
| 134 | + | /// It does when that stage says how it starts (`CMD` or `ENTRYPOINT`), or | |
| 135 | + | /// says it listens (`EXPOSE`) on an image that is not only a file server | |
| 136 | + | /// (nginx, httpd, Caddy…). A Dockerfile that only copies a built site into | |
| 137 | + | /// such an image, or says neither, is taken to serve files, which g1t | |
| 138 | + | /// serves itself, for nothing while no one visits. | |
| 139 | + | pub(crate) fn runs_server(dockerfile: &str) -> (bool, String) { | |
| 140 | + | let stage = last_stage(dockerfile); | |
| 141 | + | let has = |word: &str| stage.iter().any(|(w, _)| w == word); | |
| 142 | + | // `FROM [--platform=…] image[:tag|@digest] [AS name]` | |
| 143 | + | let base = stage | |
| 144 | + | .first() | |
| 145 | + | .filter(|(word, _)| word == "FROM") | |
| 146 | + | .and_then(|(_, rest)| rest.split_whitespace().find(|w| !w.starts_with("--"))) | |
| 147 | + | .map(|image| { | |
| 148 | + | let image = image.split('@').next().unwrap_or(image); | |
| 149 | + | // A tag is after the last `/`, so a registry's port is kept. | |
| 150 | + | let name = match image.rsplit_once(':') { | |
| 151 | + | Some((name, tag)) if !tag.contains('/') => name, | |
| 152 | + | _ => image, | |
| 153 | + | }; | |
| 154 | + | name.trim_start_matches("docker.io/").trim_start_matches("library/").to_ascii_lowercase() | |
| 155 | + | }) | |
| 156 | + | .unwrap_or_default(); | |
| 157 | + | let starts = ["CMD", "ENTRYPOINT"].into_iter().find(|word| has(word)); | |
| 158 | + | if let Some(word) = starts { | |
| 159 | + | return (true, format!("its Dockerfile starts a server of its own (`{word}`)")); | |
| 160 | + | } | |
| 161 | + | if has("EXPOSE") { | |
| 162 | + | if FILE_SERVERS.contains(&base.as_str()) { | |
| 163 | + | return (false, format!("its Dockerfile only serves files with `{base}`")); | |
| 164 | + | } | |
| 165 | + | return (true, "its Dockerfile runs a server (`EXPOSE`)".to_owned()); | |
| 166 | + | } | |
| 167 | + | (false, "its Dockerfile neither starts a server (`CMD`, `ENTRYPOINT`) nor listens on a port (`EXPOSE`)".to_owned()) | |
| 168 | + | } | |
| 169 | + | ||
| 80 | 170 | /// A server g1t can write a Dockerfile for. | |
| 81 | 171 | #[derive(Debug, Clone, PartialEq, Eq)] | |
| 82 | 172 | pub(crate) struct Server { | |
| 83 | 173 | /// As `g1t-runner detect` prints it when no framework names it: | |
| 84 | 174 | /// `node`, `django`, `flask`, `fastapi`, `python`, `go`, `rails`, | |
| 85 | − | /// `rack`, `laravel`, `php`. | |
| 175 | + | /// `rack`, `laravel`, `php`, `rust`. | |
| 86 | 176 | pub id: &'static str, | |
| 87 | 177 | /// For the build's log: `Node.js`, `Django`… | |
| 88 | 178 | pub name: &'static str, | |
| ⋯ | |||
| 166 | 256 | COPY . .\n\ | |
| 167 | 257 | RUN {install}\n\ | |
| 168 | 258 | {build}\ | |
| 169 | − | ENV NODE_ENV=production HOST=0.0.0.0 PORT={DEFAULT_PORT}\n\ | |
| 259 | + | ENV NODE_ENV=production HOST=0.0.0.0 HOSTNAME=0.0.0.0 PORT={DEFAULT_PORT}\n\ | |
| 170 | 260 | EXPOSE {DEFAULT_PORT}\n\ | |
| 171 | 261 | CMD {start}\n", | |
| 172 | 262 | header = header(name), | |
| ⋯ | |||
| 175 | 265 | ) | |
| 176 | 266 | } | |
| 177 | 267 | ||
| 268 | + | /// Whether a Next.js app builds a standalone server (`output: | |
| 269 | + | /// 'standalone'`), which `next start` does not run. | |
| 270 | + | fn next_standalone(dir: &Path) -> bool { | |
| 271 | + | ["next.config.js", "next.config.mjs", "next.config.ts", "next.config.cjs"].iter().any(|name| { | |
| 272 | + | std::fs::read_to_string(dir.join(name)).is_ok_and(|text| { | |
| 273 | + | let text: String = text.chars().filter(|c| !c.is_whitespace()).collect(); | |
| 274 | + | ["output:\"standalone\"", "output:'standalone'", "output:`standalone`"].iter().any(|form| text.contains(form)) | |
| 275 | + | }) | |
| 276 | + | }) | |
| 277 | + | } | |
| 278 | + | ||
| 178 | 279 | /// The command a Node.js server starts with, as a Dockerfile's exec form. | |
| 179 | 280 | fn exec(words: &[&str]) -> String { | |
| 180 | 281 | let quoted: Vec<String> = words.iter().map(|w| format!("\"{w}\"")).collect(); | |
| ⋯ | |||
| 197 | 298 | let has_build = script("build").is_some(); | |
| 198 | 299 | let start = |fallback: &[&str]| if script("start").is_some() { exec(&["npm", "start"]) } else { exec(fallback) }; | |
| 199 | 300 | let (name, command, own_build) = match framework { | |
| 301 | + | // A standalone build is its own server, with the static files it | |
| 302 | + | // serves copied beside it, as Next.js's guide does. | |
| 303 | + | "nextjs" if next_standalone(dir) => { | |
| 304 | + | let build = if has_build { "npm run build" } else { "npx next build" }; | |
| 305 | + | let build = format!( | |
| 306 | + | "{build} && cp -r .next/static .next/standalone/.next/static && if [ -d public ]; then cp -r public .next/standalone/public; fi" | |
| 307 | + | ); | |
| 308 | + | return Some(Server { | |
| 309 | + | id: "node", | |
| 310 | + | name: "Next.js", | |
| 311 | + | dockerfile: node_dockerfile(dir, "Next.js", &exec(&["node", ".next/standalone/server.js"]), Some(&build)), | |
| 312 | + | }); | |
| 313 | + | } | |
| 200 | 314 | "nextjs" => ("Next.js", start(&["npx", "next", "start"]), "npx next build"), | |
| 201 | 315 | // `astro dev` is a common `start` script, so Astro's node adapter's | |
| 202 | 316 | // own server is run instead. | |
| ⋯ | |||
| 214 | 328 | /// A server g1t recognises in a project with no framework it knows, and | |
| 215 | 329 | /// the Dockerfile it writes for it. | |
| 216 | 330 | pub(crate) fn server(dir: &Path) -> Option<Server> { | |
| 217 | − | node_server(dir).or_else(|| python(dir)).or_else(|| go(dir)).or_else(|| ruby(dir)).or_else(|| php(dir)) | |
| 331 | + | node_server(dir) | |
| 332 | + | .or_else(|| python(dir)) | |
| 333 | + | .or_else(|| go(dir)) | |
| 334 | + | .or_else(|| ruby(dir)) | |
| 335 | + | .or_else(|| php(dir)) | |
| 336 | + | .or_else(|| rust(dir)) | |
| 218 | 337 | } | |
| 219 | 338 | ||
| 339 | + | /// Crates that make a Rust program a web server. | |
| 340 | + | const RUST_SERVERS: [&str; 7] = ["axum", "actix-web", "rocket", "warp", "poem", "salvo", "tide"]; | |
| 341 | + | ||
| 342 | + | /// A Rust web server: a `Cargo.toml` with one program, depending on a web | |
| 343 | + | /// framework. | |
| 344 | + | fn rust(dir: &Path) -> Option<Server> { | |
| 345 | + | let manifest: toml::Value = toml::from_str(&std::fs::read_to_string(dir.join("Cargo.toml")).ok()?).ok()?; | |
| 346 | + | let package = manifest.get("package")?.get("name")?.as_str()?; | |
| 347 | + | let dependencies = manifest.get("dependencies")?.as_table()?; | |
| 348 | + | if !RUST_SERVERS.iter().any(|name| dependencies.contains_key(*name)) { | |
| 349 | + | return None; | |
| 350 | + | } | |
| 351 | + | // Its one `[[bin]]`, else the package's own program. | |
| 352 | + | let bins: Vec<&str> = manifest | |
| 353 | + | .get("bin") | |
| 354 | + | .and_then(|bins| bins.as_array()) | |
| 355 | + | .map(|bins| bins.iter().filter_map(|bin| bin.get("name")?.as_str()).collect()) | |
| 356 | + | .unwrap_or_default(); | |
| 357 | + | let program = match bins.as_slice() { | |
| 358 | + | [] if dir.join("src/main.rs").is_file() => package, | |
| 359 | + | [one] => one, | |
| 360 | + | // None, or more than one: which to run is the project's to say. | |
| 361 | + | _ => return None, | |
| 362 | + | }; | |
| 363 | + | if !program.chars().all(|c| c.is_ascii_alphanumeric() || c == '-' || c == '_') { | |
| 364 | + | return None; | |
| 365 | + | } | |
| 366 | + | let dockerfile = format!( | |
| 367 | + | "{header}FROM rust:1-bookworm AS build\n\ | |
| 368 | + | WORKDIR /src\n\ | |
| 369 | + | COPY . .\n\ | |
| 370 | + | RUN cargo build --release --locked --bin {program} || cargo build --release --bin {program}\n\ | |
| 371 | + | RUN cp target/release/{program} /app\n\ | |
| 372 | + | \n\ | |
| 373 | + | FROM debian:bookworm-slim\n\ | |
| 374 | + | RUN apt-get update && apt-get install -y --no-install-recommends ca-certificates && rm -rf /var/lib/apt/lists/*\n\ | |
| 375 | + | COPY --from=build /app /app\n\ | |
| 376 | + | ENV PORT={DEFAULT_PORT}\n\ | |
| 377 | + | EXPOSE {DEFAULT_PORT}\n\ | |
| 378 | + | ENTRYPOINT [\"/app\"]\n", | |
| 379 | + | header = header("Rust"), | |
| 380 | + | ); | |
| 381 | + | Some(Server { id: "rust", name: "Rust", dockerfile }) | |
| 382 | + | } | |
| 383 | + | ||
| 220 | 384 | /// A Node.js server: a `start` script, with a server package or nothing | |
| 221 | 385 | /// that looks like a static site (no `build` script and no `index.html`). | |
| 222 | 386 | fn node_server(dir: &Path) -> Option<Server> { | |
| ⋯ | |||
| 459 | 623 | assert_eq!(exposed_port("FROM golang AS build\nEXPOSE 9000\nFROM scratch\nexpose 8000/tcp"), Some(8000)); | |
| 460 | 624 | assert_eq!(exposed_port("FROM golang AS build\nEXPOSE 9000\nFROM scratch\n"), None); | |
| 461 | 625 | assert_eq!(exposed_port("FROM x\nEXPOSE $PORT"), None); | |
| 626 | + | // Without an `EXPOSE`, the `PORT` it sets. | |
| 627 | + | assert_eq!(exposed_port("FROM node\nENV NODE_ENV=production PORT=3000\nCMD node ."), Some(3000)); | |
| 628 | + | assert_eq!(exposed_port("FROM node\nENV PORT 4000"), Some(4000)); | |
| 629 | + | assert_eq!(exposed_port("FROM node\nENV PORT=1\nFROM node\nENV PORT=5000\nEXPOSE 7000"), Some(7000)); | |
| 630 | + | // Continued lines and comments. | |
| 631 | + | assert_eq!(exposed_port("FROM node\n# EXPOSE 1\nRUN a \\\n && b\nEXPOSE \\\n 9000"), Some(9000)); | |
| 632 | + | } | |
| 633 | + | ||
| 634 | + | #[test] | |
| 635 | + | fn a_dockerfile_runs_a_server_unless_it_only_serves_files() { | |
| 636 | + | let (server, why) = runs_server("FROM node:22-slim\nCOPY . .\nCMD [\"node\", \"server.js\"]\n"); | |
| 637 | + | assert!(server); | |
| 638 | + | assert_eq!(why, "its Dockerfile starts a server of its own (`CMD`)"); | |
| 639 | + | assert!(runs_server("FROM golang AS build\nRUN go build\nFROM scratch\nENTRYPOINT [\"/app\"]").0); | |
| 640 | + | assert!(runs_server("FROM python:3.12\nEXPOSE 5000").0); | |
| 641 | + | // A site copied into a file server, by any of its names. | |
| 642 | + | for base in ["nginx", "nginx:1.27-alpine", "docker.io/library/nginx:latest", "httpd@sha256:abc", "--platform=linux/amd64 caddy:2"] { | |
| 643 | + | let (server, why) = runs_server(&format!("FROM node AS build\nCMD x\nFROM {base}\nCOPY --from=build /app/dist /srv\nEXPOSE 80\n")); | |
| 644 | + | assert!(!server, "{base}"); | |
| 645 | + | assert!(why.contains("only serves files"), "{why}"); | |
| 646 | + | } | |
| 647 | + | // nginx with a start of its own is the project's to say. | |
| 648 | + | assert!(runs_server("FROM nginx\nCOPY . .\nCMD [\"nginx\", \"-g\", \"daemon off;\"]").0); | |
| 649 | + | assert!(!runs_server("FROM node\nCOPY . .").0); | |
| 650 | + | // A registry with a port is not a tag. | |
| 651 | + | assert!(runs_server("FROM registry.local:5000/nginx\nEXPOSE 80").0); | |
| 652 | + | } | |
| 653 | + | ||
| 654 | + | #[test] | |
| 655 | + | fn rust_servers_are_recognised() { | |
| 656 | + | let axum = project("axum", &[("Cargo.toml", "[package]\nname = \"todo-api\"\n\n[dependencies]\naxum = \"0.8\"\n"), ("src/main.rs", "fn main() {}")]); | |
| 657 | + | let found = server(&axum).unwrap(); | |
| 658 | + | assert_eq!((found.id, found.name), ("rust", "Rust")); | |
| 659 | + | assert!(found.dockerfile.contains("--bin todo-api")); | |
| 660 | + | assert!(found.dockerfile.contains("ENTRYPOINT [\"/app\"]")); | |
| 661 | + | assert!(runs_server(&found.dockerfile).0); | |
| 662 | + | let lib = project("rust-lib", &[("Cargo.toml", "[package]\nname = \"x\"\n[dependencies]\nserde = \"1\"\n"), ("src/main.rs", "")]); | |
| 663 | + | assert_eq!(server(&lib), None, "not a web server"); | |
| 664 | + | let two = project("rust-two", &[("Cargo.toml", "[package]\nname = \"x\"\n[dependencies]\naxum = \"0.8\"\n[[bin]]\nname = \"a\"\n[[bin]]\nname = \"b\"\n")]); | |
| 665 | + | assert_eq!(server(&two), None, "which program runs is the project's to say"); | |
| 666 | + | } | |
| 667 | + | ||
| 668 | + | #[test] | |
| 669 | + | fn every_dockerfile_g1t_writes_runs_a_server_on_its_port() { | |
| 670 | + | let projects = [ | |
| 671 | + | project("w-node", &[("package.json", r#"{ "scripts": { "start": "node i.js" }, "dependencies": { "express": "5" } }"#)]), | |
| 672 | + | project("w-flask", &[("requirements.txt", "flask\n"), ("app.py", "")]), | |
| 673 | + | project("w-go", &[("go.mod", "module x\n"), ("main.go", "package main")]), | |
| 674 | + | project("w-rack", &[("Gemfile", "gem 'sinatra'\n"), ("config.ru", "")]), | |
| 675 | + | project("w-php", &[("composer.json", "{}"), ("index.php", "")]), | |
| 676 | + | ]; | |
| 677 | + | for dir in &projects { | |
| 678 | + | let found = server(dir).unwrap(); | |
| 679 | + | assert_eq!(exposed_port(&found.dockerfile), Some(DEFAULT_PORT), "{}", found.id); | |
| 680 | + | // PHP's Apache image starts itself: it has no `CMD` of the file's own. | |
| 681 | + | assert!(runs_server(&found.dockerfile).0, "{}", found.id); | |
| 682 | + | } | |
| 683 | + | } | |
| 684 | + | ||
| 685 | + | #[test] | |
| 686 | + | fn a_standalone_next_app_runs_its_own_server() { | |
| 687 | + | let next = project( | |
| 688 | + | "next-standalone", | |
| 689 | + | &[ | |
| 690 | + | ("package.json", r#"{ "scripts": { "build": "next build", "start": "next start" }, "dependencies": { "next": "16" } }"#), | |
| 691 | + | ("next.config.mjs", "export default {\n output: \"standalone\",\n};\n"), | |
| 692 | + | ], | |
| 693 | + | ); | |
| 694 | + | let found = for_framework(&next, "nextjs").unwrap(); | |
| 695 | + | assert!(found.dockerfile.contains("RUN npm run build && cp -r .next/static .next/standalone/.next/static")); | |
| 696 | + | assert!(found.dockerfile.contains("CMD [\"node\", \".next/standalone/server.js\"]")); | |
| 697 | + | assert!(found.dockerfile.contains("HOSTNAME=0.0.0.0")); | |
| 462 | 698 | } | |
| 463 | 699 | ||
| 464 | 700 | #[test] | |
| 211 | 211 | next = next.static_site(package.build_or("npx next build"), "out"); | |
| 212 | 212 | } else if !wrangler { | |
| 213 | 213 | next.problem = Some( | |
| 214 | − | "This is a Next.js app that needs a server, and g1t.page does not run servers. Set `output: 'export'` in \ | |
| 215 | − | `next.config` to build it as a static site (served from `out`), or deploy it as a Worker with OpenNext's \ | |
| 216 | − | Cloudflare adapter, `@opennextjs/cloudflare`, and a `wrangler.jsonc`." | |
| 214 | + | "This is a Next.js app that needs a server, and g1t could not write a Dockerfile to run it. Set \ | |
| 215 | + | `output: 'export'` in `next.config` to build it as a static site (served from `out`), or deploy it as a \ | |
| 216 | + | Worker with OpenNext's Cloudflare adapter, `@opennextjs/cloudflare`, and a `wrangler.jsonc`." | |
| 217 | 217 | .to_owned(), | |
| 218 | 218 | ); | |
| 219 | 219 | } | |
| ⋯ | |||
| 231 | 231 | } | |
| 232 | 232 | } else if !wrangler && (sets(&text, "output", "server") || sets(&text, "output", "hybrid")) { | |
| 233 | 233 | astro.problem = Some( | |
| 234 | − | "This Astro site renders on a server (`output: 'server'`), and g1t.page does not run servers. Use \ | |
| 235 | − | `output: 'static'` (the default) to build a static site, or add the `@astrojs/cloudflare` adapter to run \ | |
| 236 | − | it as a Worker." | |
| 234 | + | "This Astro site renders on a server (`output: 'server'`) with no adapter g1t can start. Add \ | |
| 235 | + | `@astrojs/node` to run it as a container, use `output: 'static'` (the default) to build a static site, \ | |
| 236 | + | or add the `@astrojs/cloudflare` adapter to run it as a Worker." | |
| 237 | 237 | .to_owned(), | |
| 238 | 238 | ); | |
| 239 | 239 | } | |
| ⋯ | |||
| 278 | 278 | None => { | |
| 279 | 279 | kit.problem = Some( | |
| 280 | 280 | "This SvelteKit app has no adapter g1t.page can serve. Use `@sveltejs/adapter-static` to build a static \ | |
| 281 | − | site, or `@sveltejs/adapter-cloudflare` to run it as a Worker; `adapter-auto`, `adapter-node` and the \ | |
| 282 | − | others need a host of their own." | |
| 281 | + | site, `@sveltejs/adapter-cloudflare` to run it as a Worker, or `@sveltejs/adapter-node` to run it as a \ | |
| 282 | + | container; `adapter-auto` and the others need a host of their own." | |
| 283 | 283 | .to_owned(), | |
| 284 | 284 | ); | |
| 285 | 285 | } | |
| ⋯ | |||
| 322 | 322 | } else { | |
| 323 | 323 | let file = if id == "remix" { "the Remix plugin's options in `vite.config`" } else { "`react-router.config.ts`" }; | |
| 324 | 324 | found.problem = Some(format!( | |
| 325 | − | "This {name} app renders on a server, and g1t.page does not run servers. Set `ssr: false` in {file} \ | |
| 325 | + | "This {name} app renders on a server, and g1t could not write a Dockerfile to run it. Set `ssr: false` in {file} \ | |
| 326 | 326 | to build it as a single-page app (served from `build/client`), or deploy it as a Worker with \ | |
| 327 | 327 | `@cloudflare/vite-plugin` and a `wrangler.jsonc`." | |
| 328 | 328 | )); | |
| 155 | 155 | } | |
| 156 | 156 | ||
| 157 | 157 | #[test] | |
| 158 | − | fn a_container_is_built_as_its_site_while_g1t_page_runs_none() { | |
| 158 | + | fn a_container_is_built_as_its_site_where_containers_are_switched_off() { | |
| 159 | 159 | let (api, seen) = fake_api(); | |
| 160 | − | let dir = checkout("container-site", &[("index.html", "<h1>hello</h1>"), ("Dockerfile", "FROM nginx")]); | |
| 160 | + | let dir = checkout( | |
| 161 | + | "container-site", | |
| 162 | + | &[("index.html", "<h1>hello</h1>"), ("server.js", ""), ("Dockerfile", "FROM node:22-slim\nCOPY . .\nCMD [\"node\", \"server.js\"]\n")], | |
| 163 | + | ); | |
| 161 | 164 | let output = deploy(&dir, &api, &[]); | |
| 162 | 165 | let stdout = String::from_utf8_lossy(&output.stdout); | |
| 163 | 166 | assert!(output.status.success(), "{stdout} | |
| 164 | 167 | {}", String::from_utf8_lossy(&output.stderr)); | |
| 168 | + | assert!(stdout.contains("Building it as a container from Dockerfile, not as the plain HTML site it also has"), "{stdout}"); | |
| 165 | 169 | assert!(stdout.contains("as a plain HTML site instead"), "{stdout}"); | |
| 166 | 170 | let seen = seen.lock().unwrap(); | |
| 167 | 171 | let steps: Vec<&str> = seen.iter().map(|(path, _)| path.as_str()).collect(); | |
| 168 | 172 | assert_eq!(steps[1], "/deployments/jobs/dpl_1/registry"); | |
| 169 | 173 | assert_eq!(seen.last().unwrap().1["detected"], "html"); | |
| 174 | + | drop(seen); | |
| 175 | + | // A Dockerfile that only serves the site with nginx: the site, with no | |
| 176 | + | // image asked for. | |
| 177 | + | let (api, seen) = fake_api(); | |
| 178 | + | let files = checkout("files-only", &[("index.html", "<h1>hello</h1>"), ("Dockerfile", "FROM nginx:alpine\nCOPY . /usr/share/nginx/html\nEXPOSE 80\n")]); | |
| 179 | + | let output = deploy(&files, &api, &[]); | |
| 180 | + | let stdout = String::from_utf8_lossy(&output.stdout); | |
| 181 | + | assert!(output.status.success(), "{stdout}"); | |
| 182 | + | assert!(stdout.contains("its Dockerfile only serves files with `nginx`"), "{stdout}"); | |
| 183 | + | let seen = seen.lock().unwrap(); | |
| 184 | + | assert!(!seen.iter().any(|(path, _)| path.ends_with("/registry"))); | |
| 185 | + | assert_eq!(seen.last().unwrap().1["detected"], "html"); | |
| 186 | + | drop(seen); | |
| 170 | 187 | // A server with nothing else to serve fails, saying so. | |
| 171 | − | drop(seen); | |
| 172 | 188 | let server = checkout("container-only", &[("package.json", r#"{ "scripts": { "start": "node index.js" }, "dependencies": { "express": "5" } }"#)]); | |
| 173 | 189 | let output = deploy(&server, &api, &[]); | |
| 174 | 190 | assert_eq!(output.status.code(), Some(1)); | |
| 175 | − | assert!(String::from_utf8_lossy(&output.stdout).contains("Docker deployments are not switched on")); | |
| 191 | + | assert!(String::from_utf8_lossy(&output.stdout).contains("Docker deployments are switched off")); | |
| 176 | 192 | // Build as Static site skips the Dockerfile altogether. | |
| 177 | 193 | let output = deploy(&dir, &api, &[("BUILD_AS", "static")]); | |
| 178 | 194 | assert!(output.status.success()); |
| 181 | 181 | rack: "Rack", | |
| 182 | 182 | laravel: "Laravel", | |
| 183 | 183 | php: "PHP", | |
| 184 | + | rust: "Rust", | |
| 184 | 185 | }; | |
| 185 | 186 | ||
| 186 | 187 | /** A framework id a build reported, if it is one g1t knows. */ |
| 51 | 51 | }; | |
| 52 | 52 | `; | |
| 53 | 53 | ||
| 54 | + | /** The manifests the registry is asked for: an image or an index of them, in either format. */ | |
| 55 | + | const MANIFEST_TYPES = | |
| 56 | + | "application/vnd.oci.image.index.v1+json, application/vnd.oci.image.manifest.v1+json, application/vnd.docker.distribution.manifest.v2+json, application/vnd.docker.distribution.manifest.list.v2+json"; | |
| 57 | + | ||
| 58 | + | /** | |
| 59 | + | * A request to Cloudflare's registry with a login of g1t's own: sent with | |
| 60 | + | * it as Basic, as wrangler does, and again with a token for `scope` when | |
| 61 | + | * the registry answers with the token exchange registries use | |
| 62 | + | * (`WWW-Authenticate: Bearer`). | |
| 63 | + | */ | |
| 64 | + | async function registryFetch( | |
| 65 | + | login: { username: string; password: string }, | |
| 66 | + | path: string, | |
| 67 | + | method: string, | |
| 68 | + | scope: string, | |
| 69 | + | body?: string, | |
| 70 | + | ): Promise<Response> { | |
| 71 | + | const basic = `Basic ${btoa(`${login.username}:${login.password}`)}`; | |
| 72 | + | const url = `https://${REGISTRY_HOST}${path}`; | |
| 73 | + | const headers = (authorization: string): Record<string, string> => ({ | |
| 74 | + | authorization, | |
| 75 | + | accept: MANIFEST_TYPES, | |
| 76 | + | ...(body === undefined ? {} : { "content-type": "application/json" }), | |
| 77 | + | }); | |
| 78 | + | const response = await fetch(url, { method, headers: headers(basic), body }); | |
| 79 | + | const challenge = response.headers.get("www-authenticate") ?? ""; | |
| 80 | + | if (response.status !== 401 || !/^bearer /i.test(challenge)) return response; | |
| 81 | + | const params = Object.fromEntries([...challenge.matchAll(/(\w+)="([^"]*)"/g)].map((m) => [m[1], m[2]])); | |
| 82 | + | if (!params.realm) return response; | |
| 83 | + | const token = new URL(params.realm); | |
| 84 | + | if (params.service) token.searchParams.set("service", params.service); | |
| 85 | + | token.searchParams.set("scope", params.scope ?? scope); | |
| 86 | + | const answer = (await (await fetch(token, { headers: { authorization: basic } })).json().catch(() => null)) as { | |
| 87 | + | token?: string; | |
| 88 | + | access_token?: string; | |
| 89 | + | } | null; | |
| 90 | + | const bearer = answer?.token ?? answer?.access_token; | |
| 91 | + | if (!bearer) return response; | |
| 92 | + | return fetch(url, { method, headers: headers(`Bearer ${bearer}`), body }); | |
| 93 | + | } | |
| 94 | + | ||
| 95 | + | /** | |
| 96 | + | * The tags in `repository` (`<account>/<image>`): from the registry's tag | |
| 97 | + | * list, else from its catalog with tags, which is what Cloudflare's own | |
| 98 | + | * tools read (its names there may lack the account, or start with `/`). | |
| 99 | + | */ | |
| 100 | + | async function listTags(login: { username: string; password: string }, repository: string, account: string): Promise<string[]> { | |
| 101 | + | const scope = `repository:${repository}:pull`; | |
| 102 | + | const listed = await registryFetch(login, `/v2/${repository}/tags/list`, "GET", scope); | |
| 103 | + | if (listed.ok) { | |
| 104 | + | const answer = (await listed.json().catch(() => null)) as { tags?: string[] | null } | null; | |
| 105 | + | return answer?.tags ?? []; | |
| 106 | + | } | |
| 107 | + | if (listed.status === 404) return []; | |
| 108 | + | const catalog = await registryFetch(login, "/v2/_catalog?tags=true", "GET", "registry:catalog:*"); | |
| 109 | + | if (!catalog.ok) throw new Error(`The registry would not list ${repository}'s images (${listed.status}, ${catalog.status}).`); | |
| 110 | + | const answer = (await catalog.json().catch(() => null)) as { repositories?: Record<string, string[]> } | null; | |
| 111 | + | const image = repository.slice(account.length + 1); | |
| 112 | + | for (const [name, tags] of Object.entries(answer?.repositories ?? {})) { | |
| 113 | + | const bare = name.replace(/^\/+/, ""); | |
| 114 | + | if (bare === repository || bare === image) return tags ?? []; | |
| 115 | + | } | |
| 116 | + | return []; | |
| 117 | + | } | |
| 118 | + | ||
| 54 | 119 | const HTML_HANDLING =["auto-trailing-slash", "force-trailing-slash", "drop-trailing-slash", "none"]; | |
| 55 | 120 | const NOT_FOUND_HANDLING = ["single-page-application", "404-page", "none"]; | |
| 56 | 121 | ||
| ⋯ | |||
| 188 | 253 | */ | |
| 189 | 254 | async hasImage(repository: string, digest: string): Promise<boolean> { | |
| 190 | 255 | const login = await this.registryLogin(["pull"]); | |
| 191 | − | const basic = `Basic ${btoa(`${login.username}:${login.password}`)}`; | |
| 192 | − | const url = `https://${REGISTRY_HOST}/v2/${repository}/manifests/${digest}`; | |
| 193 | − | const accept = "application/vnd.oci.image.index.v1+json, application/vnd.oci.image.manifest.v1+json, application/vnd.docker.distribution.manifest.v2+json, application/vnd.docker.distribution.manifest.list.v2+json"; | |
| 194 | − | let response = await fetch(url, { method: "HEAD", headers: { authorization: basic, accept } }); | |
| 195 | − | const challenge = response.headers.get("www-authenticate") ?? ""; | |
| 196 | − | if (response.status === 401 && /^bearer /i.test(challenge)) { | |
| 197 | − | const params = Object.fromEntries([...challenge.matchAll(/(\w+)="([^"]*)"/g)].map((m) => [m[1], m[2]])); | |
| 198 | − | const token = new URL(params.realm); | |
| 199 | − | if (params.service) token.searchParams.set("service", params.service); | |
| 200 | − | token.searchParams.set("scope", params.scope ?? `repository:${repository}:pull`); | |
| 201 | − | const answer = (await (await fetch(token, { headers: { authorization: basic } })).json().catch(() => null)) as { | |
| 202 | − | token?: string; | |
| 203 | − | access_token?: string; | |
| 204 | − | } | null; | |
| 205 | − | const bearer = answer?.token ?? answer?.access_token; | |
| 206 | − | if (!bearer) return false; | |
| 207 | − | response = await fetch(url, { method: "HEAD", headers: { authorization: `Bearer ${bearer}`, accept } }); | |
| 208 | − | } | |
| 256 | + | const response = await registryFetch(login, `/v2/${repository}/manifests/${digest}`, "HEAD", `repository:${repository}:pull`); | |
| 209 | 257 | return response.ok; | |
| 210 | 258 | } | |
| 211 | 259 | ||
| 260 | + | /** | |
| 261 | + | * Deletes the images in `repository` whose tags are not in `keep`, then | |
| 262 | + | * has the registry collect the layers no image uses any more: an app | |
| 263 | + | * keeps its running image and the one before it, so the account stays | |
| 264 | + | * under Containers' image storage limit (`IMAGE_STORAGE_GB`). With | |
| 265 | + | * `keep` empty, every image of the app goes, as when it is taken down. | |
| 266 | + | * Returns the tags deleted. Asked with a login of g1t's own that can pull | |
| 267 | + | * and push, as `wrangler containers images delete` is. | |
| 268 | + | */ | |
| 269 | + | async pruneImages(repository: string, keep: string[]): Promise<string[]> { | |
| 270 | + | const login = await this.registryLogin(["pull", "push"]); | |
| 271 | + | const scope = `repository:${repository}:pull,push,delete`; | |
| 272 | + | const tags = await listTags(login, repository, this.account); | |
| 273 | + | const gone = tags.filter((tag) => !keep.includes(tag)); | |
| 274 | + | for (const tag of gone) { | |
| 275 | + | const response = await registryFetch(login, `/v2/${repository}/manifests/${encodeURIComponent(tag)}`, "DELETE", scope); | |
| 276 | + | if (!response.ok && response.status !== 404) throw new Error(`The registry would not delete ${repository}:${tag} (${response.status}).`); | |
| 277 | + | } | |
| 278 | + | if (gone.length > 0) { | |
| 279 | + | const collected = await registryFetch(login, "/v2/gc/layers", "PUT", scope, "{}"); | |
| 280 | + | if (!collected.ok) console.error("the registry did not collect unused layers", collected.status); | |
| 281 | + | } | |
| 282 | + | return gone; | |
| 283 | + | } | |
| 284 | + | ||
| 212 | 285 | /** Puts a container app's Worker (`APP_WORKER`, g1t's own) in the namespace, with `metadata`. */ | |
| 213 | 286 | async putContainerWorker(script: string, metadata: Record<string, unknown>): Promise<void> { | |
| 214 | 287 | const form = new FormData(); | |
| ⋯ | |||
| 217 | 290 | await this.call("PUT", this.scriptPath(script), form); | |
| 218 | 291 | } | |
| 219 | 292 | ||
| 220 | − | /** The Durable Object namespace of a container app's Worker: what its Container application is attached to. */ | |
| 293 | + | /** | |
| 294 | + | * The Durable Object namespace of a container app's Worker: what its | |
| 295 | + | * Container application is attached to. Read from the Worker's own | |
| 296 | + | * `APP` binding, which names it; else looked for among the account's | |
| 297 | + | * namespaces, by class and script (a namespace there is matched to this | |
| 298 | + | * dispatch namespace when Cloudflare says which it is in, which its | |
| 299 | + | * documented schema does not promise). | |
| 300 | + | */ | |
| 221 | 301 | async appNamespace(script: string): Promise<string> { | |
| 302 | + | const bindings = await this.call<{ type: string; name: string; namespace_id?: string }[]>("GET", `${this.scriptPath(script)}/bindings`).catch( | |
| 303 | + | () => [], | |
| 304 | + | ); | |
| 305 | + | const bound = bindings.find((b) => b.type === "durable_object_namespace" && b.name === "APP" && b.namespace_id); | |
| 306 | + | if (bound?.namespace_id) return bound.namespace_id; | |
| 222 | 307 | for (let page = 1; page <= 20; page++) { | |
| 223 | 308 | const namespaces = await this.call<{ id: string; class: string; script: string; dispatch_namespace?: string }[]>( | |
| 224 | 309 | "GET", | |
| 225 | 310 | `/accounts/${this.account}/workers/durable_objects/namespaces?per_page=1000&page=${page}`, | |
| 226 | 311 | ); | |
| 227 | − | const found = namespaces.find((n) => n.class === APP_CLASS && n.script === script && n.dispatch_namespace === this.namespace); | |
| 312 | + | const found = namespaces.find( | |
| 313 | + | (n) => n.class === APP_CLASS && n.script === script && (n.dispatch_namespace === undefined || n.dispatch_namespace === this.namespace), | |
| 314 | + | ); | |
| 228 | 315 | if (found) return found.id; | |
| 229 | 316 | if (namespaces.length < 1000) break; | |
| 230 | 317 | } | |
| 105 | 105 | let uploads: Record<string, any>[] = []; | |
| 106 | 106 | let applications: { id: string; name: string }[] = []; | |
| 107 | 107 | let registryHas = true; | |
| 108 | + | /** The tags the registry holds in the app's repository. */ | |
| 109 | + | let registryTags: string[] = []; | |
| 110 | + | /** The namespace the app's Worker's `APP` binding names, if Cloudflare says. */ | |
| 111 | + | let boundNamespace: string | null = null; | |
| 112 | + | let applicationBodies: any[] = []; | |
| 108 | 113 | const realFetch = globalThis.fetch; | |
| 109 | 114 | ||
| 110 | 115 | function setUp(containers: string) { | |
| ⋯ | |||
| 113 | 118 | uploads = []; | |
| 114 | 119 | applications = []; | |
| 115 | 120 | registryHas = true; | |
| 121 | + | registryTags = []; | |
| 122 | + | boundNamespace = null; | |
| 123 | + | applicationBodies = []; | |
| 116 | 124 | globalThis.fetch = (async (input: string | Request, init?: RequestInit) => { | |
| 117 | 125 | const url = new URL(typeof input === "string" ? input : input.url); | |
| 118 | 126 | const method = init?.method ?? "GET"; | |
| 119 | 127 | calls.push(`${method} ${url.host === "api.cloudflare.com" ? url.pathname.replace("/client/v4/accounts/acct", "") : url.host + url.pathname}`); | |
| 120 | 128 | const result = (value: unknown) => Response.json({ success: true, result: value }); | |
| 121 | − | if (url.host === "registry.cloudflare.com") return new Response(null, { status: registryHas ? 200 : 404 }); | |
| 129 | + | if (url.host === "registry.cloudflare.com") { | |
| 130 | + | if (url.pathname.endsWith("/tags/list")) return Response.json({ name: `acct/g1t-${SCRIPT}`, tags: registryTags }); | |
| 131 | + | if (method === "DELETE") { | |
| 132 | + | const tag = url.pathname.split("/").pop()!; | |
| 133 | + | registryTags = registryTags.filter((t) => t !== tag); | |
| 134 | + | return new Response(null, { status: 202 }); | |
| 135 | + | } | |
| 136 | + | if (url.pathname === "/v2/gc/layers") return new Response(null, { status: 200 }); | |
| 137 | + | return new Response(null, { status: registryHas ? 200 : 404 }); | |
| 138 | + | } | |
| 122 | 139 | if (url.pathname.endsWith("/credentials")) return result({ username: "v1", password: "push-secret", registry_host: "registry.cloudflare.com" }); | |
| 123 | 140 | if (method === "PUT" && url.pathname.includes("/scripts/")) { | |
| 124 | 141 | const form = init?.body as FormData; | |
| 125 | 142 | uploads.push(JSON.parse(String(form.get("metadata")))); | |
| 126 | 143 | return result({}); | |
| 127 | 144 | } | |
| 145 | + | if (url.pathname.endsWith(`/scripts/${SCRIPT}/bindings`)) { | |
| 146 | + | return result(boundNamespace ? [{ type: "durable_object_namespace", name: "APP", class_name: "G1tApp", namespace_id: boundNamespace }] : []); | |
| 147 | + | } | |
| 128 | 148 | if (url.pathname.endsWith("/durable_objects/namespaces")) { | |
| 129 | 149 | return result([{ id: "ns1", class: "G1tApp", script: SCRIPT, dispatch_namespace: "g1t-deployments" }]); | |
| 130 | 150 | } | |
| 131 | 151 | if (url.pathname.endsWith("/containers/applications") && method === "GET") return result(applications); | |
| 132 | 152 | if (url.pathname.endsWith("/containers/applications") && method === "POST") { | |
| 153 | + | applicationBodies.push(JSON.parse(String(init?.body))); | |
| 133 | 154 | applications.push({ id: "app1", name: JSON.parse(String(init?.body)).name }); | |
| 134 | 155 | return result({ id: "app1" }); | |
| 135 | 156 | } | |
| ⋯ | |||
| 276 | 297 | assert.ok(!paused.bindings.some((b: any) => b.name === "DATABASE_URL"), "a paused app holds no secrets"); | |
| 277 | 298 | assert.ok(rows("SELECT paused_at FROM apps")[0].paused_at); | |
| 278 | 299 | }); | |
| 300 | + | ||
| 301 | + | test("the app's Container application is attached to the namespace its Worker's binding names", async () => { | |
| 302 | + | boundNamespace = "ns-bound"; | |
| 303 | + | await finish({ image: IMAGE, port: 3000 }); | |
| 304 | + | assert.equal(applicationBodies[0].durable_objects.namespace_id, "ns-bound"); | |
| 305 | + | assert.ok(!calls.some((call) => call.includes("/durable_objects/namespaces")), "no listing is needed"); | |
| 306 | + | // Without it, the account's namespaces are searched, by class and script. | |
| 307 | + | setUp("on"); | |
| 308 | + | await finish({ image: IMAGE, port: 3000 }); | |
| 309 | + | assert.equal(applicationBodies[0].durable_objects.namespace_id, "ns1"); | |
| 310 | + | }); | |
| 311 | + | ||
| 312 | + | test("an app keeps the image it runs, the one before it and those still being built, and none once it is down", async () => { | |
| 313 | + | registryTags = ["dpl_0", "dpl_1"]; | |
| 314 | + | await finish({ image: IMAGE, port: 3000 }); | |
| 315 | + | // Nothing replaced yet: only the older image goes. | |
| 316 | + | assert.deepEqual(registryTags, ["dpl_1"]); | |
| 317 | + | assert.ok(calls.includes("PUT registry.cloudflare.com/v2/gc/layers"), "unused layers are collected"); | |
| 318 | + | // The next build replaces it, while another is being built. | |
| 319 | + | open("dpl_2"); | |
| 320 | + | open("dpl_3"); | |
| 321 | + | registryTags = ["dpl_0", "dpl_1", "dpl_2", "dpl_3"]; | |
| 322 | + | await post("/jobs/dpl_2/finish", { token: TOKEN, detected: "container", container: { image: IMAGE, port: 3000 } }); | |
| 323 | + | assert.deepEqual(registryTags, ["dpl_1", "dpl_2", "dpl_3"]); | |
| 324 | + | // Taken down, it takes its images with it, but a build still under way keeps its own. | |
| 325 | + | await post("/rpc/take_down", { actor: { id: "usr_1", username: "a", kind: "user", workspaces: [{ slug: "acme", role: "owner" }] }, project: { workspace: "acme", slug: "web" }, branch: null }); | |
| 326 | + | assert.deepEqual(registryTags, ["dpl_3"]); | |
| 327 | + | }); | |
| 77 | 77 | assert.deepEqual(applicationRequest("g1t-web", image, "basic", "ns1"), { | |
| 78 | 78 | name: "g1t-web", | |
| 79 | 79 | scheduling_policy: "default", | |
| 80 | − | configuration: { image, instance_type: "basic" }, | |
| 80 | + | configuration: { image, instance_type: "basic", observability: { logs: { enabled: true } } }, | |
| 81 | 81 | instances: 0, | |
| 82 | 82 | max_instances: 1, | |
| 83 | 83 | durable_objects: { namespace_id: "ns1" }, | |
| 84 | 84 | }); | |
| 85 | 85 | const rollout = rolloutRequest(image, "nonsense", "dpl_1"); | |
| 86 | − | assert.deepEqual(rollout.target_configuration, { image, instance_type: "lite" }); | |
| 86 | + | assert.deepEqual(rollout.target_configuration, { image, instance_type: "lite", observability: { logs: { enabled: true } } }); | |
| 87 | 87 | assert.equal(rollout.step_percentage, 100); | |
| 88 | 88 | }); | |
| 89 | 89 | ||
| ⋯ | |||
| 215 | 215 | assert.equal(reports.at(-2)?.month, "2026-10", "the month that ended is reported first"); | |
| 216 | 216 | }); | |
| 217 | 217 | ||
| 218 | + | test("an app that stops before it answers says how it ended", async (t) => { | |
| 219 | + | const realFetch = globalThis.fetch; | |
| 220 | + | globalThis.fetch = (async () => new Response("{}")) as typeof fetch; | |
| 221 | + | t.after(() => { | |
| 222 | + | globalThis.fetch = realFetch; | |
| 223 | + | }); | |
| 224 | + | const { G1tApp } = await appWorker(); | |
| 225 | + | const { ctx, container } = runtime(); | |
| 226 | + | // It exits at once, with an error, and never listens. | |
| 227 | + | let exit: (error: unknown) => void = () => {}; | |
| 228 | + | Object.assign(container, { | |
| 229 | + | monitor: () => new Promise((_, reject) => (exit = reject)), | |
| 230 | + | getTcpPort: () => ({ | |
| 231 | + | fetch: async () => { | |
| 232 | + | throw new Error("connection refused"); | |
| 233 | + | }, | |
| 234 | + | }), | |
| 235 | + | }); | |
| 236 | + | const app = new G1tApp(ctx, { G1T_MODE: "serve", G1T_PORT: "3000", G1T_METER_URL: "https://api.g1t.sh/m", APP: {} }); | |
| 237 | + | const answer = app.fetch(new Request("https://web.g1t.page/")); | |
| 238 | + | await new Promise((resolve) => setTimeout(resolve, 0)); | |
| 239 | + | exit(Object.assign(new Error("exited"), { exitCode: 1 })); | |
| 240 | + | container.running = false; | |
| 241 | + | const response = await answer; | |
| 242 | + | assert.equal(response.status, 503); | |
| 243 | + | assert.match(await response.text(), /It stopped \(exit code 1\) before it answered on port 3000/); | |
| 244 | + | }); | |
| 245 | + | ||
| 218 | 246 | test("a paused app answers with a notice and its container stops", async () => { | |
| 219 | 247 | const { G1tApp } = await appWorker(); | |
| 220 | 248 | const { ctx, container } = runtime(); | |
| 22 | 22 | * build to its app's: it names the repository, deploys only an image by | |
| 23 | 23 | * digest in that repository, and checks the digest is there before it | |
| 24 | 24 | * does. The login can never pull, so no build can read another app's image. | |
| 25 | + | * Once a deploy is live, the app's older images are deleted, keeping the | |
| 26 | + | * one it replaced; an app taken down takes its images with it. | |
| 25 | 27 | * | |
| 26 | 28 | * Pure, with no Workers imports, so it can be tested under Node. | |
| 27 | 29 | */ | |
| ⋯ | |||
| 46 | 48 | /** How long a build's push login lasts. */ | |
| 47 | 49 | export const PUSH_LOGIN_MINUTES = 15; | |
| 48 | 50 | ||
| 51 | + | /** | |
| 52 | + | * What Cloudflare Containers holds of an account's images, in all. Each | |
| 53 | + | * app keeps only the image it runs and the one before it | |
| 54 | + | * (`Cloudflare.pruneImages`), and none once it is taken down. | |
| 55 | + | */ | |
| 56 | + | export const IMAGE_STORAGE_GB = 50; | |
| 57 | + | ||
| 49 | 58 | /** The repository an app's images are pushed to: `<account>/g1t-<script>`. */ | |
| 50 | 59 | export function repositoryFor(account: string, script: string): string { | |
| 51 | 60 | return `${account}/g1t-${script}`; | |
| ⋯ | |||
| 147 | 156 | }; | |
| 148 | 157 | } | |
| 149 | 158 | ||
| 159 | + | /** A container's output, kept in Workers Logs. */ | |
| 160 | + | const CONTAINER_LOGS = { logs: { enabled: true } }; | |
| 161 | + | ||
| 150 | 162 | /** `POST /accounts/{id}/containers/applications`: the app's container, for its Durable Object namespace. */ | |
| 151 | 163 | export function applicationRequest(name: string, image: string, instanceType: string, namespaceId: string): Record<string, unknown> { | |
| 152 | 164 | return { | |
| 153 | 165 | name, | |
| 154 | 166 | scheduling_policy: "default", | |
| 155 | − | configuration: { image, instance_type: instanceTypeOf(instanceType) }, | |
| 167 | + | // Its output goes to Workers Logs on g1t's account, where it can be read. | |
| 168 | + | configuration: { image, instance_type: instanceTypeOf(instanceType), observability: CONTAINER_LOGS }, | |
| 156 | 169 | instances: 0, | |
| 157 | 170 | // One instance per app: its Durable Object is named `app`. | |
| 158 | 171 | max_instances: 1, | |
| ⋯ | |||
| 167 | 180 | strategy: "rolling", | |
| 168 | 181 | kind: "full_auto", | |
| 169 | 182 | step_percentage: 100, | |
| 170 | − | target_configuration: { image, instance_type: instanceTypeOf(instanceType) }, | |
| 183 | + | target_configuration: { image, instance_type: instanceTypeOf(instanceType), observability: CONTAINER_LOGS }, | |
| 171 | 184 | }; | |
| 172 | 185 | } | |
| 173 | 186 | ||
| ⋯ | |||
| 291 | 304 | for (const [name, value] of Object.entries(env)) if (!INTERNAL.has(name) && typeof value === "string") vars[name] = value; | |
| 292 | 305 | container.start({ env: vars, enableInternet: true }); | |
| 293 | 306 | this.listening = false; | |
| 307 | + | // How it ended, if it ends: said on the page of a request it never answered. | |
| 308 | + | this.exited = null; | |
| 309 | + | if (typeof container.monitor === "function") { | |
| 310 | + | container.monitor().then( | |
| 311 | + | () => { this.exited = "0"; }, | |
| 312 | + | (error) => { this.exited = error && error.exitCode !== undefined ? String(error.exitCode) : "unknown"; }, | |
| 313 | + | ); | |
| 314 | + | } | |
| 294 | 315 | await this.tick(); | |
| 295 | 316 | } | |
| 296 | 317 | // A backstop: g1t stops it itself once it has slept long enough. | |
| 297 | 318 | await container.setInactivityTimeout((Number(env.G1T_SLEEP_MS) || 300000) + 2 * TICK_MS).catch(() => undefined); | |
| 298 | 319 | if (!(await this.ready(container, port))) { | |
| 299 | − | return page(503, "The app did not start", "It is not answering on port " + port + ". It must listen on 0.0.0.0 at the port in the PORT environment variable (or the one its Dockerfile exposes). Its build's log on g1t has its output."); | |
| 320 | + | const ended = this.exited === null || this.exited === undefined ? "It is not answering on port " + port + "." : "It stopped (exit code " + this.exited + ") before it answered on port " + port + "."; | |
| 321 | + | return page(503, "The app did not start", ended + " It must listen on 0.0.0.0 at the port in the PORT environment variable (the one its Dockerfile exposes, else 8080)."); | |
| 300 | 322 | } | |
| 301 | 323 | let response; | |
| 302 | 324 | try { | |
| 141 | 141 | /** The g1t.page zone, where custom hostnames are added (Cloudflare for SaaS). */ | |
| 142 | 142 | CUSTOM_HOSTNAMES_ZONE_ID?: string; | |
| 143 | 143 | /** | |
| 144 | − | * `on` runs container apps (Docker deployments, src/containers.ts); off | |
| 145 | − | * until Cloudflare's container APIs are checked against g1t's account. | |
| 146 | − | * While off, a container build is refused at its `registry` step with | |
| 144 | + | * `on` runs container apps (Docker deployments, src/containers.ts), as | |
| 145 | + | * g1t.sh does; `off` where the account has no Containers (a self-hosted | |
| 146 | + | * g1t). While off, a container build is refused at its `registry` step with | |
| 147 | 147 | * `not_enabled`, and a project with a site besides its Dockerfile is | |
| 148 | 148 | * built as that site. | |
| 149 | 149 | */ | |
| ⋯ | |||
| 339 | 339 | runtime?: string | null; | |
| 340 | 340 | container_app_id?: string | null; | |
| 341 | 341 | instance_type?: string | null; | |
| 342 | + | /** The build it serves (migration 0015). */ | |
| 343 | + | deployment_id?: string | null; | |
| 342 | 344 | }; | |
| 343 | 345 | ||
| 344 | 346 | function toDeployment(row: DeploymentRow): Deployment { | |
| ⋯ | |||
| 1254 | 1256 | if (!this.containersOn) { | |
| 1255 | 1257 | // Its own code, which the build reads to build the site the | |
| 1256 | 1258 | // project is without its Dockerfile, when it is one. | |
| 1257 | − | const error = { code: "not_enabled", message: "Docker deployments are not switched on for g1t.page yet." }; | |
| 1259 | + | const error = { code: "not_enabled", message: "Docker deployments are switched off on this g1t." }; | |
| 1258 | 1260 | return Response.json({ ok: false, error }, { status: 409 }); | |
| 1259 | 1261 | } | |
| 1260 | 1262 | const instanceType = instanceTypeOf((await this.settingsRow(row.project_id))?.instance_type); | |
| ⋯ | |||
| 1285 | 1287 | const tags = [`workspace:${row.workspace}`, `project:${row.workspace}/${row.slug}`, row.kind, ...telemetryTags(row.project_id, id)]; | |
| 1286 | 1288 | const app: AppTelemetry = { project: row.project_id, deployment: id, environment: row.kind, service: row.slug, commit: row.commit_sha }; | |
| 1287 | 1289 | let container: { appId: string; instanceType: string; meterHash: string; image: string } | null = null; | |
| 1290 | + | let before: AppRow | null = null; | |
| 1288 | 1291 | try { | |
| 1289 | 1292 | // Running apps' secrets and variables are bound here, by g1t: | |
| 1290 | 1293 | // they never pass through the build's sandbox. | |
| ⋯ | |||
| 1299 | 1302 | app, | |
| 1300 | 1303 | row.trusted ? await this.telemetryToken(row.project_id) : null, | |
| 1301 | 1304 | ); | |
| 1302 | − | const before = await this.db.prepare("SELECT runtime, container_app_id FROM apps WHERE script = ?").bind(row.script).first<AppRow>(); | |
| 1305 | + | before = await this.db.prepare("SELECT runtime, container_app_id, deployment_id FROM apps WHERE script = ?").bind(row.script).first<AppRow>(); | |
| 1303 | 1306 | if (detected === "container") { | |
| 1304 | 1307 | container = await this.runContainer(cloudflare, row, body.container, runtime, tags); | |
| 1305 | 1308 | } else { | |
| ⋯ | |||
| 1308 | 1311 | if (before?.runtime === "container") { | |
| 1309 | 1312 | await cloudflare.deleteContainer(row.script, before.container_app_id); | |
| 1310 | 1313 | await cloudflare.deleteScript(row.script); | |
| 1314 | + | await this.dropImages(cloudflare, row.script, []); | |
| 1311 | 1315 | } | |
| 1312 | 1316 | await cloudflare.putScript(row.script, worker, typeof body.completionJwt === "string" ? body.completionJwt : null, tags, runtime); | |
| 1313 | 1317 | } | |
| ⋯ | |||
| 1377 | 1381 | if (row.kind === "production") { | |
| 1378 | 1382 | await this.domains.follow(row.project_id, row.script).catch((error) => console.error("could not point domains", error)); | |
| 1379 | 1383 | } | |
| 1384 | + | // Its images but this one and the one it replaced go. | |
| 1385 | + | if (container) await this.dropImages(cloudflare, row.script, [id, before?.deployment_id ?? null]); | |
| 1380 | 1386 | await this.chargeBuild(row, seconds); | |
| 1381 | 1387 | await this.notePeak(row.workspace); | |
| 1382 | 1388 | await this.statusFor(row, "success", row.kind === "preview" ? "Preview is live" : "Production is live", appUrl(row.script)); | |
| ⋯ | |||
| 1412 | 1418 | runtime: { secrets: Record<string, string>; variables: Record<string, string> }, | |
| 1413 | 1419 | tags: string[], | |
| 1414 | 1420 | ): Promise<{ appId: string; instanceType: string; meterHash: string; image: string }> { | |
| 1415 | − | if (!this.containersOn) throw new Error("Docker deployments are not switched on for g1t.page yet."); | |
| 1421 | + | if (!this.containersOn) throw new Error("Docker deployments are switched off on this g1t."); | |
| 1416 | 1422 | const repository = repositoryFor(this.env.CLOUDFLARE_ACCOUNT_ID, row.script); | |
| 1417 | 1423 | const pushed = pushedImage(report, repository); | |
| 1418 | 1424 | if (!pushed) throw new Error(`the build did not report an image by digest in ${REGISTRY_HOST}/${repository}.`); | |
| ⋯ | |||
| 1425 | 1431 | return { appId, instanceType, meterHash: await sha256(meterToken), image: pushed.image }; | |
| 1426 | 1432 | } | |
| 1427 | 1433 | ||
| 1434 | + | /** | |
| 1435 | + | * Deletes an app's images but those its builds still use: the tags in | |
| 1436 | + | * `keep`, and those of its builds under way, which may have pushed an | |
| 1437 | + | * image they have yet to report. Containers holds an account's images | |
| 1438 | + | * to `IMAGE_STORAGE_GB` in all, so nothing is kept that will not run. | |
| 1439 | + | * A failure is logged: the app's next deploy or removal tries again. | |
| 1440 | + | */ | |
| 1441 | + | private async dropImages(cloudflare: Cloudflare, script: string, keep: (string | null | undefined)[]): Promise<void> { | |
| 1442 | + | try { | |
| 1443 | + | const building = await this.db | |
| 1444 | + | .prepare("SELECT id FROM deployments WHERE script = ? AND status IN ('queued', 'building')") | |
| 1445 | + | .bind(script) | |
| 1446 | + | .all<{ id: string }>(); | |
| 1447 | + | const held = [...keep, ...building.results.map((b) => b.id)].filter((tag): tag is string => !!tag); | |
| 1448 | + | const gone = await cloudflare.pruneImages(repositoryFor(this.env.CLOUDFLARE_ACCOUNT_ID, script), held); | |
| 1449 | + | if (gone.length) console.log("deleted images", script, gone.join(", ")); | |
| 1450 | + | } catch (error) { | |
| 1451 | + | console.error("could not delete images", script, error); | |
| 1452 | + | } | |
| 1453 | + | } | |
| 1454 | + | ||
| 1428 | 1455 | /** Where a container app's Worker reports its running time: the API, which forwards it here. */ | |
| 1429 | 1456 | private meterUrl(script: string): string { | |
| 1430 | 1457 | return `${(this.env.G1T_API ?? "https://api.g1t.sh").replace(/\/+$/, "")}/deployments/apps/${script}/meter`; | |
| ⋯ | |||
| 1737 | 1764 | .prepare("SELECT runtime, container_app_id FROM apps WHERE script = ?") | |
| 1738 | 1765 | .bind(script) | |
| 1739 | 1766 | .first<{ runtime: string | null; container_app_id: string | null }>(); | |
| 1740 | − | // A container app's container goes first, with any instance it has up. | |
| 1767 | + | // A container app's container goes first, with any instance it has up, | |
| 1768 | + | // and its images after it. | |
| 1741 | 1769 | if (app?.runtime === "container") await this.cloudflare?.deleteContainer(script, app.container_app_id); | |
| 1742 | 1770 | await this.cloudflare?.deleteScript(script); | |
| 1771 | + | if (app?.runtime === "container" && this.cloudflare) await this.dropImages(this.cloudflare, script, []); | |
| 1743 | 1772 | await this.db.batch([ | |
| 1744 | 1773 | this.db.prepare("DELETE FROM apps WHERE script = ?").bind(script), | |
| 1745 | 1774 | // Its build is no longer live anywhere. | |
| ⋯ | |||
| 2597 | 2626 | // Container application still: it goes too. | |
| 2598 | 2627 | await cloudflare.deleteContainer(script.id).catch((error) => console.error("could not remove container", script.id, error)); | |
| 2599 | 2628 | await cloudflare.deleteScript(script.id); | |
| 2629 | + | if (this.containersOn) await this.dropImages(cloudflare, script.id, []); | |
| 2600 | 2630 | } | |
| 2601 | 2631 | } | |
| 2602 | 2632 | } | |
| 51 | 51 | // The g1t.page zone: custom domains are Cloudflare for SaaS custom | |
| 52 | 52 | // hostnames on it (see src/domains.ts). | |
| 53 | 53 | "CUSTOM_HOSTNAMES_ZONE_ID": "45d1c969bdd9d593756e70d5f45c2cbb", | |
| 54 | − | // Docker deployments (src/containers.ts): "on" runs container apps. | |
| 55 | − | // Off until Cloudflare's container APIs are checked live against this | |
| 56 | − | // account ("Docker deployments" in docs.g1t.sh/guides/deploy-to-cloudflare/); | |
| 57 | − | // while off, a container build is refused at its registry step. | |
| 58 | − | "CONTAINERS": "off", | |
| 54 | + | // Docker deployments (src/containers.ts): "on" runs container apps, | |
| 55 | + | // found from a project's files with nothing to set. "off" for an | |
| 56 | + | // account without Containers (a self-hosted g1t): a container build | |
| 57 | + | // is then refused at its registry step, or built as the site the | |
| 58 | + | // project is without its Dockerfile ("Docker deployments" in | |
| 59 | + | // docs.g1t.sh/guides/deploy-to-cloudflare/). | |
| 60 | + | "CONTAINERS": "on", | |
| 59 | 61 | // Where container apps' Workers report how long they ran. | |
| 60 | 62 | "G1T_API": "https://api.g1t.sh", | |
| 61 | 63 | // Cloudflare's own tracing of each app (a span for every fetch, KV, |