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.

453 linesCodeBlameRaw
1/**
2 * Container apps: a project built as a Docker image (crates/runner
3 * deploy/container.rs) runs on Cloudflare Containers behind a Worker of
4 * g1t's own, one per app, in the same Workers for Platforms namespace as
5 * every other app, so the `*.g1t.page` dispatcher (services/pages) and
6 * custom domains reach it by its script's name as they reach any app.
7 *
8 * Each app's Worker is `APP_WORKER` below, never the project's code: its
9 * Durable Object class `G1tApp` has the app's container (a Container
10 * application with the default scheduling policy, the app's image and
11 * instance type, at most one instance), starts it on the first request
12 * with the project's variables and secrets as its environment and `PORT`,
13 * waits for it to answer on its port, proxies requests to it, and stops it
14 * after `CONTAINER_SLEEP_MINUTES` without a request. While it runs it
15 * meters itself by the started minute, and reports to g1t with a token of
16 * its own (`POST /deployments/apps/<script>/meter`), which billing charges
17 * at cost plus the margin.
18 *
19 * The build's sandbox pushes the image with a login this service mints
20 * from Cloudflare's registry API: push only, for 15 minutes. Cloudflare's
21 * registry credentials cannot be held to one repository, so g1t holds the
22 * build to its app's: it names the repository, deploys only an image by
23 * digest in that repository, and checks the digest is there before it
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.
27 *
28 * Pure, with no Workers imports, so it can be tested under Node.
29 */
30
31import { TAIL_CONSUMERS } from "./telemetry";
32import {
33 APP_INSTANCES,
34 CONTAINER_SLEEP_MINUTES,
35 DEFAULT_APP_INSTANCE,
36 appInstance,
37 containerSecondMicros,
38 type ContainerCosts,
39 type ContainerOffer,
40} from "@g1t/contracts";
41
42/** Cloudflare's container registry, which Containers pulls from. */
43export const REGISTRY_HOST = "registry.cloudflare.com";
44
45/** The Durable Object class of every container app's Worker. */
46export const APP_CLASS = "G1tApp";
47
48/** How long a build's push login lasts. */
49export const PUSH_LOGIN_MINUTES = 15;
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 */
56export const IMAGE_STORAGE_GB = 50;
57
58/** The repository an app's images are pushed to: `<account>/g1t-<script>`. */
59export function repositoryFor(account: string, script: string): string {
60 return `${account}/g1t-${script}`;
61}
62
63/** What `POST /accounts/{id}/containers/registries/{domain}/credentials` is asked for: a push-only login, for minutes. */
64export function pushLoginRequest(): { permissions: ["push"]; expiration_minutes: number } {
65 return { permissions: ["push"], expiration_minutes: PUSH_LOGIN_MINUTES };
66}
67
68/** The most an image may weigh to run on `instanceType`: its disk, as Cloudflare limits it. */
69export function maxImageBytes(instanceType: string): number {
70 return appInstance(instanceType).diskGb * 1_000_000_000;
71}
72
73/** An instance type the project may choose, or the default. */
74export function instanceTypeOf(value: unknown): string {
75 return typeof value === "string" && APP_INSTANCES.some((instance) => instance.type === value) ? value : DEFAULT_APP_INSTANCE;
76}
77
78/**
79 * What a build reported it pushed, if it is an image this build may run:
80 * a digest in its app's own repository and nothing else, with the port
81 * it listens on.
82 */
83export function pushedImage(value: unknown, repository: string): { image: string; digest: string; port: number; sizeBytes: number } | null {
84 if (!value || typeof value !== "object") return null;
85 const report = value as Record<string, unknown>;
86 const image = typeof report.image === "string" ? report.image : "";
87 const match = image.match(/^([a-z0-9.-]+)\/(.+)@(sha256:[0-9a-f]{64})$/);
88 if (!match || match[1] !== REGISTRY_HOST || match[2] !== repository) return null;
89 const port = Number(report.port);
90 return {
91 image,
92 digest: match[3],
93 port: Number.isInteger(port) && port > 0 && port < 65536 ? port : 8080,
94 sizeBytes: Math.max(0, Number(report.sizeBytes) || 0),
95 };
96}
97
98/** The settings an app's Worker reads, besides the project's variables and secrets. */
99export type AppWorkerSettings = {
100 mode: "serve" | "paused" | "down" | "redirect";
101 /** For `redirect`: the hostname to send visitors to. */
102 redirect?: string;
103 port: number;
104 instanceType: string;
105 /** Where it reports what it ran, with `meterToken`. */
106 meterUrl: string;
107 meterToken: string;
108};
109
110/** Names the app's Worker keeps for itself: never passed to the container. */
111export const INTERNAL_BINDINGS = ["APP", "G1T_MODE", "G1T_REDIRECT", "G1T_PORT", "G1T_SLEEP_MS", "G1T_METER_URL", "G1T_METER_TOKEN", "G1T_INSTANCE"];
112
113/**
114 * The upload's metadata for a container app's Worker: `APP_WORKER` as its
115 * one module, its Durable Object class with a container, g1t's settings,
116 * and the project's variables and secrets, which the Worker passes to the
117 * container as its environment. A variable named like one of g1t's own is
118 * left out.
119 */
120export function appWorkerMetadata(
121 settings: AppWorkerSettings,
122 runtime: { secrets: Record<string, string>; variables: Record<string, string> },
123 tags: string[],
124): Record<string, unknown> {
125 const own = new Set(INTERNAL_BINDINGS);
126 const bindings: object[] = [{ type: "durable_object_namespace", name: "APP", class_name: APP_CLASS }];
127 const plain: Record<string, string> = {
128 G1T_MODE: settings.mode,
129 G1T_PORT: String(settings.port),
130 G1T_SLEEP_MS: String(CONTAINER_SLEEP_MINUTES * 60 * 1000),
131 G1T_METER_URL: settings.meterUrl,
132 G1T_INSTANCE: settings.instanceType,
133 };
134 if (settings.redirect) plain.G1T_REDIRECT = settings.redirect;
135 for (const [name, text] of Object.entries(plain)) bindings.push({ type: "plain_text", name, text });
136 bindings.push({ type: "secret_text", name: "G1T_METER_TOKEN", text: settings.meterToken });
137 for (const [name, text] of Object.entries(runtime.variables)) {
138 if (!own.has(name) && !(name in runtime.secrets)) bindings.push({ type: "plain_text", name, text });
139 }
140 for (const [name, text] of Object.entries(runtime.secrets)) {
141 if (!own.has(name)) bindings.push({ type: "secret_text", name, text });
142 }
143 return {
144 main_module: "index.js",
145 compatibility_date: "2026-09-26",
146 compatibility_flags: [],
147 bindings,
148 tags,
149 tail_consumers: TAIL_CONSUMERS,
150 // Declared, so uploading it again changes nothing about the class.
151 exports: { [APP_CLASS]: { type: "durable-object", storage: "sqlite" } },
152 containers: [{ class_name: APP_CLASS }],
153 };
154}
155
156/** A container's output, kept in Workers Logs. */
157const CONTAINER_LOGS = { logs: { enabled: true } };
158
159/** `POST /accounts/{id}/containers/applications`: the app's container, for its Durable Object namespace. */
160export function applicationRequest(name: string, image: string, instanceType: string, namespaceId: string): Record<string, unknown> {
161 return {
162 name,
163 scheduling_policy: "default",
164 // Its output goes to Workers Logs on g1t's account, where it can be read.
165 configuration: { image, instance_type: instanceTypeOf(instanceType), observability: CONTAINER_LOGS },
166 instances: 0,
167 // One instance per app: its Durable Object is named `app`.
168 max_instances: 1,
169 durable_objects: { namespace_id: namespaceId },
170 };
171}
172
173/** `POST /accounts/{id}/containers/applications/{app}/rollouts`: a new image or size, all at once. */
174export function rolloutRequest(image: string, instanceType: string, description: string): Record<string, unknown> {
175 return {
176 description,
177 strategy: "rolling",
178 kind: "full_auto",
179 step_percentage: 100,
180 target_configuration: { image, instance_type: instanceTypeOf(instanceType), observability: CONTAINER_LOGS },
181 };
182}
183
184/** The name of an app's Container application. */
185export function applicationName(script: string): string {
186 return `g1t-${script}`;
187}
188
189/** What an app's Worker reports: how long its container has run this month, and what it sent. */
190export type MeterReport = {
191 token: string;
192 /** The Durable Object's own counter: a fresh one starts again at zero. */
193 epoch: string;
194 month: string;
195 awakeSeconds: number;
196 egressBytes: number;
197 instanceType: string;
198};
199
200/** A report from an app's Worker, if it is one. */
201export function meterReport(body: unknown): MeterReport | null {
202 if (!body || typeof body !== "object") return null;
203 const b = body as Record<string, unknown>;
204 const awake = Number(b.awakeSeconds);
205 const egress = Number(b.egressBytes ?? 0);
206 if (typeof b.token !== "string" || typeof b.epoch !== "string" || !/^[\w-]{8,64}$/.test(b.epoch)) return null;
207 if (typeof b.month !== "string" || !/^\d{4}-\d{2}$/.test(b.month)) return null;
208 if (!Number.isFinite(awake) || awake < 0 || !Number.isFinite(egress) || egress < 0) return null;
209 // No month has more seconds than this.
210 if (awake > 31 * 24 * 60 * 60) return null;
211 return {
212 token: b.token,
213 epoch: b.epoch,
214 month: b.month,
215 awakeSeconds: Math.ceil(awake),
216 egressBytes: Math.ceil(egress),
217 instanceType: instanceTypeOf(b.instanceType),
218 };
219}
220
221/** One app's container time in a month, as the `container_meters` table keeps it. */
222export type ContainerMeter = { instance_type: string; awake_seconds: number; egress_bytes: number };
223
224/** What a month of container time cost g1t, in millionths of a dollar, rounded up, and in words. */
225export function containerCost(rows: ContainerMeter[], costs: ContainerCosts): { micros: number; seconds: number; detail: string | null } {
226 let micros = 0;
227 let seconds = 0;
228 let bytes = 0;
229 for (const row of rows) {
230 const awake = Math.max(0, row.awake_seconds);
231 micros += awake * containerSecondMicros(appInstance(row.instance_type), costs);
232 micros += (Math.max(0, row.egress_bytes) / 1_000_000_000) * costs.egressGb;
233 seconds += awake;
234 bytes += Math.max(0, row.egress_bytes);
235 }
236 const hours = Math.round((seconds / 3600) * 10) / 10;
237 const detail =
238 seconds || bytes ? `${hours.toLocaleString("en-US")} container hours and ${(Math.round(bytes / 1e7) / 100).toLocaleString("en-US")} GB sent` : null;
239 return { micros: Math.ceil(micros), seconds, detail };
240}
241
242/** What each size costs an hour awake, at most, with `marginPercent` on top: for Settings, Deployments and the docs. */
243export function containerOffer(costs: ContainerCosts, marginPercent: number, available: boolean): ContainerOffer {
244 const withMargin = (micros: number) => Math.ceil(micros * (1 + marginPercent / 100));
245 return {
246 available,
247 prices: APP_INSTANCES.map((instance) => ({ ...instance, hourlyMicros: withMargin(containerSecondMicros(instance, costs) * 3600) })),
248 egressGbMicros: withMargin(costs.egressGb),
249 sleepMinutes: CONTAINER_SLEEP_MINUTES,
250 };
251}
252
253/**
254 * The Worker every container app runs as, g1t's own. `G1tApp` holds the
255 * container: it starts it with the project's variables and secrets and
256 * `PORT`, waits up to 90 seconds for it to answer on its port, proxies to
257 * it, and stops it once no request has come for `G1T_SLEEP_MS`. Every
258 * minute it is up is metered from its start, ahead (a minute begun is a
259 * minute charged), with the bytes it sent back, and reported to g1t.
260 */
261export const APP_WORKER = `import { DurableObject } from "cloudflare:workers";
262
263const INTERNAL = new Set(${JSON.stringify(INTERNAL_BINDINGS)});
264const TICK_MS = 60000;
265const READY_MS = 90000;
266
267function page(status, title, text) {
268 return new Response("<!doctype html><meta charset=utf-8><meta name=viewport content='width=device-width, initial-scale=1'><meta name=robots content=noindex><title>" + title + "</title><body style='font:16px system-ui;background:#121214;color:#ececf1;display:grid;place-items:center;min-height:100vh;margin:0;padding:0 16px'><main style='max-width:32rem'><h1>" + title + "</h1><p style='color:#9a9aa6'>" + text + "</p></main>", { status, headers: { "content-type": "text/html; charset=utf-8", "x-robots-tag": "noindex", "cache-control": "no-store" } });
269}
270
271/** What the app answers while it is not serving: paused, down, or moved. */
272function notServing(env, request) {
273 if (env.G1T_MODE === "redirect" && env.G1T_REDIRECT) {
274 const url = new URL(request.url);
275 return new Response(null, { status: 301, headers: { location: "https://" + env.G1T_REDIRECT + url.pathname + url.search, "cache-control": "public, max-age=3600" } });
276 }
277 if (env.G1T_MODE === "paused") return page(402, "This app is paused", "The workspace it belongs to reached its usage limit on g1t. It comes back by itself once the workspace is under it again.");
278 return page(404, "This app is not up", "It was taken down. Deploying it again brings it back.");
279}
280
281const monthOf = (at) => new Date(at).toISOString().slice(0, 7);
282
283export class ${APP_CLASS} extends DurableObject {
284 constructor(ctx, env) {
285 super(ctx, env);
286 this.listening = false;
287 this.lastRequest = Date.now();
288 this.sent = 0;
289 }
290
291 async fetch(request) {
292 const env = this.env;
293 const container = this.ctx.container;
294 if (env.G1T_MODE !== "serve") {
295 if (container && container.running) await container.destroy();
296 return notServing(env, request);
297 }
298 this.lastRequest = Date.now();
299 const port = Number(env.G1T_PORT) || 8080;
300 if (!container.running) {
301 const vars = { PORT: String(port) };
302 for (const [name, value] of Object.entries(env)) if (!INTERNAL.has(name) && typeof value === "string") vars[name] = value;
303 container.start({ env: vars, enableInternet: true });
304 this.listening = false;
305 // How it ended, if it ends: said on the page of a request it never answered.
306 this.exited = null;
307 this.why = null;
308 if (typeof container.monitor === "function") {
309 container.monitor().then(
310 () => { this.exited = "0"; },
311 (error) => {
312 this.exited = error && error.exitCode !== undefined ? String(error.exitCode) : "unknown";
313 // Why it ended, when Cloudflare says: in the app's logs and on its page.
314 this.why = error && error.message ? String(error.message).slice(0, 300) : null;
315 console.error("g1t-container-ended", JSON.stringify({ exitCode: error?.exitCode ?? null, message: this.why }));
316 },
317 );
318 }
319 await this.tick();
320 }
321 // A backstop: g1t stops it itself once it has slept long enough.
322 await container.setInactivityTimeout((Number(env.G1T_SLEEP_MS) || 300000) + 2 * TICK_MS).catch(() => undefined);
323 if (!(await this.ready(container, port))) {
324 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 + ".") + (this.why ? " Cloudflare said: " + this.why : "");
325 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).");
326 }
327 let response;
328 try {
329 // To the container over plain HTTP, as the app listens: the visitor's
330 // https:// address would have the port asked for TLS, and the app
331 // close the connection. The path, query, method, headers and body
332 // stay as they came; the original host and scheme go along as
333 // X-Forwarded-* headers.
334 const url = new URL(request.url);
335 const headers = new Headers(request.headers);
336 headers.set("x-forwarded-proto", url.protocol.replace(":", ""));
337 headers.set("x-forwarded-host", url.host);
338 const inner = new Request("http://container" + url.pathname + url.search, {
339 method: request.method,
340 headers,
341 body: request.body,
342 redirect: "manual",
343 });
344 response = await container.getTcpPort(port).fetch(inner);
345 } catch (error) {
346 return page(502, "The app failed", "It closed the connection before it answered.");
347 }
348 return this.counted(response);
349 }
350
351 /** Whether the container answers on its port, waiting for it to start. */
352 async ready(container, port) {
353 if (this.listening) return true;
354 const until = Date.now() + READY_MS;
355 while (Date.now() < until) {
356 try {
357 await container.getTcpPort(port).fetch("http://container/", { method: "HEAD" });
358 this.listening = true;
359 return true;
360 } catch {}
361 if (!container.running) return false;
362 await new Promise((resolve) => setTimeout(resolve, 250));
363 }
364 return false;
365 }
366
367 /** The response, with the bytes it sends counted. */
368 counted(response) {
369 if (!response.body) return response;
370 const self = this;
371 const count = new TransformStream({
372 transform(chunk, controller) {
373 self.sent += chunk.byteLength;
374 controller.enqueue(chunk);
375 },
376 });
377 return new Response(response.body.pipeThrough(count), response);
378 }
379
380 async meter(now) {
381 let meter = await this.ctx.storage.get("meter");
382 const instance = this.env.G1T_INSTANCE;
383 if (meter && meter.instance !== instance) {
384 // A new size is counted apart, since it costs differently.
385 await this.report(meter);
386 meter = null;
387 }
388 if (!meter) meter = { epoch: crypto.randomUUID(), instance, month: monthOf(now), awakeMs: 0, egressBytes: 0, billedUntil: 0 };
389 if (meter.month !== monthOf(now)) {
390 await this.report(meter);
391 meter = { ...meter, month: monthOf(now), awakeMs: 0, egressBytes: 0 };
392 }
393 return meter;
394 }
395
396 /** Charges the next minute up (a minute begun is charged), and wakes again at its end. */
397 async tick() {
398 const now = Date.now();
399 const meter = await this.meter(now);
400 const until = now + TICK_MS;
401 meter.awakeMs += until - Math.max(meter.billedUntil, now);
402 meter.billedUntil = until;
403 meter.egressBytes += this.sent;
404 this.sent = 0;
405 await this.ctx.storage.put("meter", meter);
406 await this.ctx.storage.setAlarm(until);
407 await this.report(meter);
408 }
409
410 async alarm() {
411 const container = this.ctx.container;
412 const sleep = Number(this.env.G1T_SLEEP_MS) || 300000;
413 if (container && container.running && this.env.G1T_MODE === "serve" && Date.now() - this.lastRequest < sleep) {
414 await this.tick();
415 return;
416 }
417 // Asleep: nothing runs, and nothing more is charged.
418 if (container && container.running) await container.destroy();
419 const meter = await this.meter(Date.now());
420 meter.egressBytes += this.sent;
421 this.sent = 0;
422 await this.ctx.storage.put("meter", meter);
423 await this.report(meter);
424 }
425
426 /** Tells g1t what has run this month; a report that fails is caught up by the next. */
427 async report(meter) {
428 await fetch(this.env.G1T_METER_URL, {
429 method: "POST",
430 headers: { "content-type": "application/json" },
431 body: JSON.stringify({
432 token: this.env.G1T_METER_TOKEN,
433 epoch: meter.epoch,
434 month: meter.month,
435 awakeSeconds: Math.ceil(meter.awakeMs / 1000),
436 egressBytes: meter.egressBytes,
437 instanceType: meter.instance,
438 }),
439 }).catch(() => undefined);
440 }
441}
442
443export default {
444 async fetch(request, env, ctx) {
445 if (env.G1T_MODE !== "serve") {
446 // Its Durable Object stops its container, if it is still up.
447 ctx.waitUntil(env.APP.get(env.APP.idFromName("app")).fetch(request).catch(() => undefined));
448 return notServing(env, request);
449 }
450 return env.APP.get(env.APP.idFromName("app")).fetch(request);
451 },
452};
453`;