Skip to content

Where people and agents ship software together. The open-source git platform for the whole job: issues, agents, checks and deploys to the edge.

g1t.sh

Commit

Merge branch 'worktree-agent-a4d0c5ee9c2f877aa'

syntaqxcommitted Parents0267f1389afb2bBrowse files
17 files+721−1490/17 viewed
+0−16
365365
366366 ## Deployments
367367
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−
384368 ### A container is one instance, and keeps nothing
385369
386370 A container app runs one instance at a time, sleeps after five minutes
+9−1
2525
2626 1. a Workers project, from `wrangler deploy --dry-run`;
2727 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));
2931 4. a static site.
3032
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+
3139 The sandbox is given a key that can upload the app's static files and
3240 nothing else. `deployments` uploads the built Worker itself to the Workers
3341 for Platforms dispatch namespace `g1t-deployments`. A site with only static
+16−12
317317 ## Docker deployments
318318
319319 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:
324328
325329 1. **The token.** `CLOUDFLARE_API_TOKEN` of `services/deployments` has
326330 **Containers: Edit** (and the account has Containers, on Workers Paid).
340344 `exports: { "G1tApp": { "type": "durable-object", "storage": "sqlite" } }`
341345 and `containers: [{ "class_name": "G1tApp" }]` in its metadata is taken,
342346 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,
344350 `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.
347352 6. **The Container application.** `POST /accounts/{id}/containers/applications`
348353 with `scheduling_policy` `default`, the image by digest, `instance_type`,
349354 `max_instances` 1 and `durable_objects.namespace_id` creates it; a
362367 9. **Pausing and taking down.** A workspace over its limit gets its
363368 container app's Worker put back paused, which stops the container, and
364369 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.
370374
371375 ## Rolling back
372376
+41−25
9999 | `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`. |
100100 | 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**. |
101101 | 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). |
104105 | A `build` script in `package.json` | Installs dependencies, runs `npm run build`, and serves the output as a **static site**. |
105106 | An `index.html` and nothing to build | Serves it as it is. |
106107 | Nothing g1t recognises | Fails, saying to add a `Dockerfile`. |
211212 behind the app's Worker, at the same addresses, custom domains and
212213 previews as any other app.
213214
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:
223218
224219 1. **Build as**, under **Settings → Deployments**: **Static site**,
225220 **Workers** or **Docker** is what it is built as, whatever else it has.
226221 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
231235 `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
233237 `Dockerfile` (or `Containerfile`, or the path set as its
234238 **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
236240 serve, the build fails, saying to add a `Dockerfile`.
237241
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+
238248 g1t writes a Dockerfile for these, when there is none:
239249
240250 | The project has | Runs with |
241251 | --- | --- |
242252 | `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 |
244254 | `requirements.txt`, `pyproject.toml` or `Pipfile`, and `manage.py` (Django) | `gunicorn <project>.wsgi` on `python:3.12-slim` |
245255 | The same, with Flask and an `app.py`, `main.py`, `server.py`, `wsgi.py` or `asgi.py` | `gunicorn <module>:app` |
246256 | The same, with FastAPI or Starlette | `uvicorn <module>:app` |
249259 | A `Gemfile` with Rails | `rails server`, after `assets:precompile`, on `ruby:3.3-slim` |
250260 | A `Gemfile` and a `config.ru` | `rackup` |
251261 | `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` |
252263
253264 The Dockerfile g1t writes is printed whole in the build's log. To change
254265 how the app is built or run, commit it as `Dockerfile` and edit it. It
257268 What a container gets, and what it must do:
258269
259270 - **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
261274 waits up to 90 seconds for it to answer there; one that never does gets
262275 a 503 page saying so.
263276 - **The project's [secrets and variables](#secrets-and-variables)** are
271284 - **Its size** is **Container size** under **Settings → Deployments**:
272285 `lite` (the default) to `standard-4`, as [What it costs](#what-it-costs)
273286 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.
275290 - **Built for `linux/amd64`**, with Docker, in the build's sandbox (or the
276291 workflow job's Docker, for [`g1t-runner deploy`](#deploying-from-a-workflow)),
277292 with network access as any build has. **Build command** is not used:
864879 | 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. |
865880 | "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). |
866881 | "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). |
868883 | "**Build as** is Docker, but this project has no Dockerfile" | Add a `Dockerfile`, or set **Dockerfile** to its path. |
869884 | "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**. |
870885 | 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. |
894909 `npx wrangler secret put CLOUDFLARE_API_TOKEN` in `services/deployments`.
895910 4. Deploy `services/deployments`, `services/pages`, and the runner.
896911
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
900916 [Docker deployments](/guides/deploy-to-cloudflare/#docker-deployments)
901−for what to check first.
917+for what to check on a new account.
902918
903919 Custom domains also need Cloudflare for SaaS on the apps' zone. Turn it on
904920 under the zone's **SSL/TLS → Custom Hostnames**, give the token **SSL and
+1−1
9292 | [Security](/guides/security/) | Secret push protection, history scanning and pull requests that upgrade vulnerable dependencies. | <Status is="live" /> |
9393 | [Deployments](/guides/deployments/) | A live preview of every pull request on g1t.page, production on merge, and custom domains. | <Status is="live" /> |
9494 | 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" /> |
9696 | [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" /> |
9797 | Rollback and always on | One click back to any deploy, and container apps kept warm. | <Status is="coming" /> |
9898 | [GitHub](/guides/github/) | Import repositories with every branch and tag, and their issues. | <Status is="live" /> |
+2−2
110110 <p className="text-xs text-muted">
111111 Charged by the minute while awake, at what Cloudflare charges g1t plus 20%, with every vCPU counted as busy; data a
112112 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."}
114114 </p>
115115 </div>
116116 );
172172 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.
173173 </Check>
174174 <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.">
176176 <SelectField name="buildAs" defaultValue={settings.buildAs ?? "auto"} aria-label="Build as" options={BUILD_AS_OPTIONS} />
177177 </Field>
178178 <Field label="Dockerfile" hint="For a container; Dockerfile or Containerfile is found by itself when empty.">
+115−26
655655 /// For a container: its Dockerfile, or the one g1t writes.
656656 pub container: Option<ContainerBuild>,
657657 /// 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).
660660 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>,
661664 }
662665
663666 /// How a container is built.
685688 pre_build: None,
686689 container: None,
687690 fallback: None,
691+ reason: None,
688692 }
689693 }
690694
733737 pre_build: None,
734738 container: Some(build),
735739 fallback: None,
740+ reason: None,
736741 })
737742 }
738743
747752 /// - **Docker**: a container, from its Dockerfile or one g1t writes.
748753 /// - **Static site**: built and served as files, whatever else it has.
749754 /// - **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
753762 /// when it has a Dockerfile, or when g1t can write one; a project with
754763 /// 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`).
757767 pub(crate) fn detect_as(
758768 dir: &Path,
759769 custom_build: Option<&str>,
791801 }
792802 BuildAs::Auto => {
793803 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" {
795805 return Ok(site);
796806 }
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+ }
797834 if let Some(problem) = site.problem.clone() {
798835 // A framework set up to need a server.
799836 let found = as_container(dir, site.framework.clone(), own);
801838 Detected::nothing(site.framework, site.install, format!("{problem} Or add a `Dockerfile` to run it as a container."))
802839 }));
803840 }
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.
805843 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+ ));
809849 }
810850 return Ok(found);
811851 }
848888 pre_build,
849889 container: None,
850890 fallback: None,
891+ reason: None,
851892 });
852893 }
853894 let build = custom_build
868909 problem: None,
869910 container: None,
870911 fallback: None,
912+ reason: None,
871913 });
872914 }
873915 // A command or a folder of the project's own says it knows better, as
889931 (None, None) => "none",
890932 _ => "static",
891933 };
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 })
893935 }
894936
895937 /// The compatibility date a Worker gets when its config names none.
936978 bundle.with_file_name(format!("{}.wrangler.json", bundle.file_name().unwrap_or_default().to_string_lossy()))
937979 }
938980
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. \
941984 Set **Build as** to Static site or Workers under Settings, Deployments if it can be built as one.";
942985
943986 /// Where a container's registry login is kept for the build: its own
10631106 std::fs::create_dir_all(&config)?;
10641107 let image = format!("{}/{}:{}", grant.registry, grant.repository, grant.tag);
10651108 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.
10661112 let built = step(
10671113 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)),
10691115 &secrets,
10701116 );
10711117 let pushed = built.and_then(|()| {
11091155 if let Some(framework) = &plan.framework {
11101156 log.line(&format!("Detected {}: framework={}.", framework.name, framework.id));
11111157 }
1158+ if let Some(reason) = &plan.reason {
1159+ log.line(reason);
1160+ }
11121161 // Said before anything is installed: it would fail after, less clearly.
11131162 if let Some(problem) = &plan.problem {
11141163 bail!("{problem}");
11251174 container: Some(image),
11261175 });
11271176 }
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.
11301179 None => match plan.fallback.take() {
11311180 Some(site) => {
11321181 log.line(&format!("Building it as a {} site instead, as it is without its Dockerfile.", if site.kind == "html" { "plain HTML" } else { "static" }));
15801629
15811630 #[test]
15821631 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."}}"#);
15841633 let answer = Reporter { base, token: "t".into() }.send("registry", json!({})).unwrap();
15851634 assert_eq!(answer["ok"], false);
15861635 assert_eq!(answer["error"]["code"], "not_enabled");
17301779 pre_build: None,
17311780 container: None,
17321781 fallback: None,
1782+ reason: None,
17331783 };
17341784 assert_eq!(output_lines(&found), "kind=static\ninstall=npm ci\nbuild=a b\noutput-dir=\n");
17351785 let vite = project("lines", &[("package.json", r#"{ "devDependencies": { "vite": "7" } }"#)]);
20962146 fn build_as_and_dockerfiles_decide_in_order() {
20972147 let detect_with = |dir: &Path, build_as: BuildAs, dockerfile: Option<&str>| detect_as(dir, None, None, build_as, dockerfile).unwrap();
20982148 // 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.
21002151 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`)"));
21022172 // Unless the project says Docker.
21032173 let found = detect_with(&vite, BuildAs::Docker, None);
21042174 assert_eq!((found.kind, found.framework_id()), ("container", Some("vite")));
21132183 let found = detect_with(&html, BuildAs::Workers, None);
21142184 assert_eq!(found.kind, "none");
21152185 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")]);
21192190 let found = detect_with(&both, BuildAs::Auto, None);
21202191 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);
21222193 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");
21232203 // A Dockerfile at a path of the project's choosing.
21242204 let chosen = project("p-chosen", &[("ops/web.Dockerfile", "FROM node\nEXPOSE 3000")]);
21252205 assert_eq!(detect_with(&chosen, BuildAs::Auto, None).kind, "none");
21482228 // OpenNext's adapter means Workers: it is not run as a server.
21492229 let opennext = project("p-opennext", &[("package.json", r#"{ "dependencies": { "next": "16", "@opennextjs/cloudflare": "1" } }"#)]);
21502230 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] {
21522241 let _ = std::fs::remove_dir_all(dir);
21532242 }
21542243 }
+251−15
5959 Ok(DOCKERFILES.iter().find(|name| dir.join(name).is_file()).map(|name| (*name).to_owned()))
6060 }
6161
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();
6668 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" {
7192 // 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();
7594 }
95+ stage.push((word, rest.trim().to_owned()));
7696 }
77− port
97+ stage
7898 }
7999
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+
80170 /// A server g1t can write a Dockerfile for.
81171 #[derive(Debug, Clone, PartialEq, Eq)]
82172 pub(crate) struct Server {
83173 /// As `g1t-runner detect` prints it when no framework names it:
84174 /// `node`, `django`, `flask`, `fastapi`, `python`, `go`, `rails`,
85− /// `rack`, `laravel`, `php`.
175+ /// `rack`, `laravel`, `php`, `rust`.
86176 pub id: &'static str,
87177 /// For the build's log: `Node.js`, `Django`…
88178 pub name: &'static str,
166256 COPY . .\n\
167257 RUN {install}\n\
168258 {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\
170260 EXPOSE {DEFAULT_PORT}\n\
171261 CMD {start}\n",
172262 header = header(name),
175265 )
176266 }
177267
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+
178279 /// The command a Node.js server starts with, as a Dockerfile's exec form.
179280 fn exec(words: &[&str]) -> String {
180281 let quoted: Vec<String> = words.iter().map(|w| format!("\"{w}\"")).collect();
197298 let has_build = script("build").is_some();
198299 let start = |fallback: &[&str]| if script("start").is_some() { exec(&["npm", "start"]) } else { exec(fallback) };
199300 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+ }
200314 "nextjs" => ("Next.js", start(&["npx", "next", "start"]), "npx next build"),
201315 // `astro dev` is a common `start` script, so Astro's node adapter's
202316 // own server is run instead.
214328 /// A server g1t recognises in a project with no framework it knows, and
215329 /// the Dockerfile it writes for it.
216330 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))
218337 }
219338
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+
220384 /// A Node.js server: a `start` script, with a server package or nothing
221385 /// that looks like a static site (no `build` script and no `index.html`).
222386 fn node_server(dir: &Path) -> Option<Server> {
459623 assert_eq!(exposed_port("FROM golang AS build\nEXPOSE 9000\nFROM scratch\nexpose 8000/tcp"), Some(8000));
460624 assert_eq!(exposed_port("FROM golang AS build\nEXPOSE 9000\nFROM scratch\n"), None);
461625 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"));
462698 }
463699
464700 #[test]
+9−9
211211 next = next.static_site(package.build_or("npx next build"), "out");
212212 } else if !wrangler {
213213 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`."
217217 .to_owned(),
218218 );
219219 }
231231 }
232232 } else if !wrangler && (sets(&text, "output", "server") || sets(&text, "output", "hybrid")) {
233233 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."
237237 .to_owned(),
238238 );
239239 }
278278 None => {
279279 kit.problem = Some(
280280 "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."
283283 .to_owned(),
284284 );
285285 }
322322 } else {
323323 let file = if id == "remix" { "the Remix plugin's options in `vite.config`" } else { "`react-router.config.ts`" };
324324 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} \
326326 to build it as a single-page app (served from `build/client`), or deploy it as a Worker with \
327327 `@cloudflare/vite-plugin` and a `wrangler.jsonc`."
328328 ));
+20−4
155155 }
156156
157157 #[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() {
159159 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+ );
161164 let output = deploy(&dir, &api, &[]);
162165 let stdout = String::from_utf8_lossy(&output.stdout);
163166 assert!(output.status.success(), "{stdout}
164167 {}", 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}");
165169 assert!(stdout.contains("as a plain HTML site instead"), "{stdout}");
166170 let seen = seen.lock().unwrap();
167171 let steps: Vec<&str> = seen.iter().map(|(path, _)| path.as_str()).collect();
168172 assert_eq!(steps[1], "/deployments/jobs/dpl_1/registry");
169173 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);
170187 // A server with nothing else to serve fails, saying so.
171− drop(seen);
172188 let server = checkout("container-only", &[("package.json", r#"{ "scripts": { "start": "node index.js" }, "dependencies": { "express": "5" } }"#)]);
173189 let output = deploy(&server, &api, &[]);
174190 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"));
176192 // Build as Static site skips the Dockerfile altogether.
177193 let output = deploy(&dir, &api, &[("BUILD_AS", "static")]);
178194 assert!(output.status.success());
+1−0
181181 rack: "Rack",
182182 laravel: "Laravel",
183183 php: "PHP",
184+ rust: "Rust",
184185 };
185186
186187 /** A framework id a build reported, if it is one g1t knows. */
+107−20
5151 };
5252 `;
5353
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+
54119 const HTML_HANDLING =["auto-trailing-slash", "force-trailing-slash", "drop-trailing-slash", "none"];
55120 const NOT_FOUND_HANDLING = ["single-page-application", "404-page", "none"];
56121
188253 */
189254 async hasImage(repository: string, digest: string): Promise<boolean> {
190255 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`);
209257 return response.ok;
210258 }
211259
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+
212285 /** Puts a container app's Worker (`APP_WORKER`, g1t's own) in the namespace, with `metadata`. */
213286 async putContainerWorker(script: string, metadata: Record<string, unknown>): Promise<void> {
214287 const form = new FormData();
217290 await this.call("PUT", this.scriptPath(script), form);
218291 }
219292
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+ */
221301 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;
222307 for (let page = 1; page <= 20; page++) {
223308 const namespaces = await this.call<{ id: string; class: string; script: string; dispatch_namespace?: string }[]>(
224309 "GET",
225310 `/accounts/${this.account}/workers/durable_objects/namespaces?per_page=1000&page=${page}`,
226311 );
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+ );
228315 if (found) return found.id;
229316 if (namespaces.length < 1000) break;
230317 }
+50−1
105105 let uploads: Record<string, any>[] = [];
106106 let applications: { id: string; name: string }[] = [];
107107 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[] = [];
108113 const realFetch = globalThis.fetch;
109114
110115 function setUp(containers: string) {
113118 uploads = [];
114119 applications = [];
115120 registryHas = true;
121+ registryTags = [];
122+ boundNamespace = null;
123+ applicationBodies = [];
116124 globalThis.fetch = (async (input: string | Request, init?: RequestInit) => {
117125 const url = new URL(typeof input === "string" ? input : input.url);
118126 const method = init?.method ?? "GET";
119127 calls.push(`${method} ${url.host === "api.cloudflare.com" ? url.pathname.replace("/client/v4/accounts/acct", "") : url.host + url.pathname}`);
120128 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+ }
122139 if (url.pathname.endsWith("/credentials")) return result({ username: "v1", password: "push-secret", registry_host: "registry.cloudflare.com" });
123140 if (method === "PUT" && url.pathname.includes("/scripts/")) {
124141 const form = init?.body as FormData;
125142 uploads.push(JSON.parse(String(form.get("metadata"))));
126143 return result({});
127144 }
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+ }
128148 if (url.pathname.endsWith("/durable_objects/namespaces")) {
129149 return result([{ id: "ns1", class: "G1tApp", script: SCRIPT, dispatch_namespace: "g1t-deployments" }]);
130150 }
131151 if (url.pathname.endsWith("/containers/applications") && method === "GET") return result(applications);
132152 if (url.pathname.endsWith("/containers/applications") && method === "POST") {
153+ applicationBodies.push(JSON.parse(String(init?.body)));
133154 applications.push({ id: "app1", name: JSON.parse(String(init?.body)).name });
134155 return result({ id: "app1" });
135156 }
276297 assert.ok(!paused.bindings.some((b: any) => b.name === "DATABASE_URL"), "a paused app holds no secrets");
277298 assert.ok(rows("SELECT paused_at FROM apps")[0].paused_at);
278299 });
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+});
+30−2
7777 assert.deepEqual(applicationRequest("g1t-web", image, "basic", "ns1"), {
7878 name: "g1t-web",
7979 scheduling_policy: "default",
80− configuration: { image, instance_type: "basic" },
80+ configuration: { image, instance_type: "basic", observability: { logs: { enabled: true } } },
8181 instances: 0,
8282 max_instances: 1,
8383 durable_objects: { namespace_id: "ns1" },
8484 });
8585 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 } } });
8787 assert.equal(rollout.step_percentage, 100);
8888 });
8989
215215 assert.equal(reports.at(-2)?.month, "2026-10", "the month that ended is reported first");
216216 });
217217
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+
218246 test("a paused app answers with a notice and its container stops", async () => {
219247 const { G1tApp } = await appWorker();
220248 const { ctx, container } = runtime();
+25−3
2222 * build to its app's: it names the repository, deploys only an image by
2323 * digest in that repository, and checks the digest is there before it
2424 * 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.
2527 *
2628 * Pure, with no Workers imports, so it can be tested under Node.
2729 */
4648 /** How long a build's push login lasts. */
4749 export const PUSH_LOGIN_MINUTES = 15;
4850
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+
4958 /** The repository an app's images are pushed to: `<account>/g1t-<script>`. */
5059 export function repositoryFor(account: string, script: string): string {
5160 return `${account}/g1t-${script}`;
147156 };
148157 }
149158
159+/** A container's output, kept in Workers Logs. */
160+const CONTAINER_LOGS = { logs: { enabled: true } };
161+
150162 /** `POST /accounts/{id}/containers/applications`: the app's container, for its Durable Object namespace. */
151163 export function applicationRequest(name: string, image: string, instanceType: string, namespaceId: string): Record<string, unknown> {
152164 return {
153165 name,
154166 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 },
156169 instances: 0,
157170 // One instance per app: its Durable Object is named `app`.
158171 max_instances: 1,
167180 strategy: "rolling",
168181 kind: "full_auto",
169182 step_percentage: 100,
170− target_configuration: { image, instance_type: instanceTypeOf(instanceType) },
183+ target_configuration: { image, instance_type: instanceTypeOf(instanceType), observability: CONTAINER_LOGS },
171184 };
172185 }
173186
291304 for (const [name, value] of Object.entries(env)) if (!INTERNAL.has(name) && typeof value === "string") vars[name] = value;
292305 container.start({ env: vars, enableInternet: true });
293306 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+ }
294315 await this.tick();
295316 }
296317 // A backstop: g1t stops it itself once it has slept long enough.
297318 await container.setInactivityTimeout((Number(env.G1T_SLEEP_MS) || 300000) + 2 * TICK_MS).catch(() => undefined);
298319 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).");
300322 }
301323 let response;
302324 try {
+37−7
141141 /** The g1t.page zone, where custom hostnames are added (Cloudflare for SaaS). */
142142 CUSTOM_HOSTNAMES_ZONE_ID?: string;
143143 /**
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
147147 * `not_enabled`, and a project with a site besides its Dockerfile is
148148 * built as that site.
149149 */
339339 runtime?: string | null;
340340 container_app_id?: string | null;
341341 instance_type?: string | null;
342+ /** The build it serves (migration 0015). */
343+ deployment_id?: string | null;
342344 };
343345
344346 function toDeployment(row: DeploymentRow): Deployment {
12541256 if (!this.containersOn) {
12551257 // Its own code, which the build reads to build the site the
12561258 // 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." };
12581260 return Response.json({ ok: false, error }, { status: 409 });
12591261 }
12601262 const instanceType = instanceTypeOf((await this.settingsRow(row.project_id))?.instance_type);
12851287 const tags = [`workspace:${row.workspace}`, `project:${row.workspace}/${row.slug}`, row.kind, ...telemetryTags(row.project_id, id)];
12861288 const app: AppTelemetry = { project: row.project_id, deployment: id, environment: row.kind, service: row.slug, commit: row.commit_sha };
12871289 let container: { appId: string; instanceType: string; meterHash: string; image: string } | null = null;
1290+ let before: AppRow | null = null;
12881291 try {
12891292 // Running apps' secrets and variables are bound here, by g1t:
12901293 // they never pass through the build's sandbox.
12991302 app,
13001303 row.trusted ? await this.telemetryToken(row.project_id) : null,
13011304 );
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>();
13031306 if (detected === "container") {
13041307 container = await this.runContainer(cloudflare, row, body.container, runtime, tags);
13051308 } else {
13081311 if (before?.runtime === "container") {
13091312 await cloudflare.deleteContainer(row.script, before.container_app_id);
13101313 await cloudflare.deleteScript(row.script);
1314+ await this.dropImages(cloudflare, row.script, []);
13111315 }
13121316 await cloudflare.putScript(row.script, worker, typeof body.completionJwt === "string" ? body.completionJwt : null, tags, runtime);
13131317 }
13771381 if (row.kind === "production") {
13781382 await this.domains.follow(row.project_id, row.script).catch((error) => console.error("could not point domains", error));
13791383 }
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]);
13801386 await this.chargeBuild(row, seconds);
13811387 await this.notePeak(row.workspace);
13821388 await this.statusFor(row, "success", row.kind === "preview" ? "Preview is live" : "Production is live", appUrl(row.script));
14121418 runtime: { secrets: Record<string, string>; variables: Record<string, string> },
14131419 tags: string[],
14141420 ): 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.");
14161422 const repository = repositoryFor(this.env.CLOUDFLARE_ACCOUNT_ID, row.script);
14171423 const pushed = pushedImage(report, repository);
14181424 if (!pushed) throw new Error(`the build did not report an image by digest in ${REGISTRY_HOST}/${repository}.`);
14251431 return { appId, instanceType, meterHash: await sha256(meterToken), image: pushed.image };
14261432 }
14271433
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+
14281455 /** Where a container app's Worker reports its running time: the API, which forwards it here. */
14291456 private meterUrl(script: string): string {
14301457 return `${(this.env.G1T_API ?? "https://api.g1t.sh").replace(/\/+$/, "")}/deployments/apps/${script}/meter`;
17371764 .prepare("SELECT runtime, container_app_id FROM apps WHERE script = ?")
17381765 .bind(script)
17391766 .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.
17411769 if (app?.runtime === "container") await this.cloudflare?.deleteContainer(script, app.container_app_id);
17421770 await this.cloudflare?.deleteScript(script);
1771+ if (app?.runtime === "container" && this.cloudflare) await this.dropImages(this.cloudflare, script, []);
17431772 await this.db.batch([
17441773 this.db.prepare("DELETE FROM apps WHERE script = ?").bind(script),
17451774 // Its build is no longer live anywhere.
25972626 // Container application still: it goes too.
25982627 await cloudflare.deleteContainer(script.id).catch((error) => console.error("could not remove container", script.id, error));
25992628 await cloudflare.deleteScript(script.id);
2629+ if (this.containersOn) await this.dropImages(cloudflare, script.id, []);
26002630 }
26012631 }
26022632 }
+7−5
5151 // The g1t.page zone: custom domains are Cloudflare for SaaS custom
5252 // hostnames on it (see src/domains.ts).
5353 "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",
5961 // Where container apps' Workers report how long they ran.
6062 "G1T_API": "https://api.g1t.sh",
6163 // Cloudflare's own tracing of each app (a span for every fetch, KV,