Pick any line to see why it is the way it is: the commit, the pull request and issue it came from, and what the agent was thinking.
| Merge GitHub pull request #8 (claude/cool-newton-7nm8r9): Docker deployments on g1t.page behind a switch that stays off until Cloudflare's container APIs are checked live, workflows a workspace manages in its repositories, tarball and zipball downloads, refs/pull/* kept to g1t alone, framework detection for g1t.page builds, and a workflow's event payload matching GitHub's. | 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 | * | |
| 26 | * Pure, with no Workers imports, so it can be tested under Node. | |
| 27 | */ | |
| 28 | ||
| 29 | import { | |
| 30 | APP_INSTANCES, | |
| 31 | CONTAINER_SLEEP_MINUTES, | |
| 32 | DEFAULT_APP_INSTANCE, | |
| 33 | appInstance, | |
| 34 | containerSecondMicros, | |
| 35 | type ContainerCosts, | |
| 36 | type ContainerOffer, | |
| 37 | } from "@g1t/contracts"; | |
| 38 | ||
| 39 | /** Cloudflare's container registry, which Containers pulls from. */ | |
| 40 | export const REGISTRY_HOST = "registry.cloudflare.com"; | |
| 41 | ||
| 42 | /** The Durable Object class of every container app's Worker. */ | |
| 43 | export const APP_CLASS = "G1tApp"; | |
| 44 | ||
| 45 | /** How long a build's push login lasts. */ | |
| 46 | export const PUSH_LOGIN_MINUTES = 15; | |
| 47 | ||
| 48 | /** The repository an app's images are pushed to: `<account>/g1t-<script>`. */ | |
| 49 | export function repositoryFor(account: string, script: string): string { | |
| 50 | return `${account}/g1t-${script}`; | |
| 51 | } | |
| 52 | ||
| 53 | /** What `POST /accounts/{id}/containers/registries/{domain}/credentials` is asked for: a push-only login, for minutes. */ | |
| 54 | export function pushLoginRequest(): { permissions: ["push"]; expiration_minutes: number } { | |
| 55 | return { permissions: ["push"], expiration_minutes: PUSH_LOGIN_MINUTES }; | |
| 56 | } | |
| 57 | ||
| 58 | /** The most an image may weigh to run on `instanceType`: its disk, as Cloudflare limits it. */ | |
| 59 | export function maxImageBytes(instanceType: string): number { | |
| 60 | return appInstance(instanceType).diskGb * 1_000_000_000; | |
| 61 | } | |
| 62 | ||
| 63 | /** An instance type the project may choose, or the default. */ | |
| 64 | export function instanceTypeOf(value: unknown): string { | |
| 65 | return typeof value === "string" && APP_INSTANCES.some((instance) => instance.type === value) ? value : DEFAULT_APP_INSTANCE; | |
| 66 | } | |
| 67 | ||
| 68 | /** | |
| 69 | * What a build reported it pushed, if it is an image this build may run: | |
| 70 | * a digest in its app's own repository and nothing else, with the port | |
| 71 | * it listens on. | |
| 72 | */ | |
| 73 | export function pushedImage( | |
| 74 | value: unknown, | |
| 75 | repository: string, | |
| 76 | ): { image: string; digest: string; port: number; sizeBytes: number } | null { | |
| 77 | if (!value || typeof value !== "object") return null; | |
| 78 | const report = value as Record<string, unknown>; | |
| 79 | const image = typeof report.image === "string" ? report.image : ""; | |
| 80 | const match = image.match(/^([a-z0-9.-]+)\/(.+)@(sha256:[0-9a-f]{64})$/); | |
| 81 | if (!match || match[1] !== REGISTRY_HOST || match[2] !== repository) return null; | |
| 82 | const port = Number(report.port); | |
| 83 | return { | |
| 84 | image, | |
| 85 | digest: match[3], | |
| 86 | port: Number.isInteger(port) && port > 0 && port < 65536 ? port : 8080, | |
| 87 | sizeBytes: Math.max(0, Number(report.sizeBytes) || 0), | |
| 88 | }; | |
| 89 | } | |
| 90 | ||
| 91 | /** The settings an app's Worker reads, besides the project's variables and secrets. */ | |
| 92 | export type AppWorkerSettings = { | |
| 93 | mode: "serve" | "paused" | "down" | "redirect"; | |
| 94 | /** For `redirect`: the hostname to send visitors to. */ | |
| 95 | redirect?: string; | |
| 96 | port: number; | |
| 97 | instanceType: string; | |
| 98 | /** Where it reports what it ran, with `meterToken`. */ | |
| 99 | meterUrl: string; | |
| 100 | meterToken: string; | |
| 101 | }; | |
| 102 | ||
| 103 | /** Names the app's Worker keeps for itself: never passed to the container. */ | |
| 104 | export const INTERNAL_BINDINGS = ["APP", "G1T_MODE", "G1T_REDIRECT", "G1T_PORT", "G1T_SLEEP_MS", "G1T_METER_URL", "G1T_METER_TOKEN", "G1T_INSTANCE"]; | |
| 105 | ||
| 106 | /** | |
| 107 | * The upload's metadata for a container app's Worker: `APP_WORKER` as its | |
| 108 | * one module, its Durable Object class with a container, g1t's settings, | |
| 109 | * and the project's variables and secrets, which the Worker passes to the | |
| 110 | * container as its environment. A variable named like one of g1t's own is | |
| 111 | * left out. | |
| 112 | */ | |
| 113 | export function appWorkerMetadata( | |
| 114 | settings: AppWorkerSettings, | |
| 115 | runtime: { secrets: Record<string, string>; variables: Record<string, string> }, | |
| 116 | tags: string[], | |
| 117 | ): Record<string, unknown> { | |
| 118 | const own = new Set(INTERNAL_BINDINGS); | |
| 119 | const bindings: object[] = [{ type: "durable_object_namespace", name: "APP", class_name: APP_CLASS }]; | |
| 120 | const plain: Record<string, string> = { | |
| 121 | G1T_MODE: settings.mode, | |
| 122 | G1T_PORT: String(settings.port), | |
| 123 | G1T_SLEEP_MS: String(CONTAINER_SLEEP_MINUTES * 60 * 1000), | |
| 124 | G1T_METER_URL: settings.meterUrl, | |
| 125 | G1T_INSTANCE: settings.instanceType, | |
| 126 | }; | |
| 127 | if (settings.redirect) plain.G1T_REDIRECT = settings.redirect; | |
| 128 | for (const [name, text] of Object.entries(plain)) bindings.push({ type: "plain_text", name, text }); | |
| 129 | bindings.push({ type: "secret_text", name: "G1T_METER_TOKEN", text: settings.meterToken }); | |
| 130 | for (const [name, text] of Object.entries(runtime.variables)) { | |
| 131 | if (!own.has(name) && !(name in runtime.secrets)) bindings.push({ type: "plain_text", name, text }); | |
| 132 | } | |
| 133 | for (const [name, text] of Object.entries(runtime.secrets)) { | |
| 134 | if (!own.has(name)) bindings.push({ type: "secret_text", name, text }); | |
| 135 | } | |
| 136 | return { | |
| 137 | main_module: "index.js", | |
| 138 | compatibility_date: "2026-09-26", | |
| 139 | compatibility_flags: [], | |
| 140 | bindings, | |
| 141 | tags, | |
| 142 | // Declared, so uploading it again changes nothing about the class. | |
| 143 | exports: { [APP_CLASS]: { type: "durable-object", storage: "sqlite" } }, | |
| 144 | containers: [{ class_name: APP_CLASS }], | |
| 145 | }; | |
| 146 | } | |
| 147 | ||
| 148 | /** `POST /accounts/{id}/containers/applications`: the app's container, for its Durable Object namespace. */ | |
| 149 | export function applicationRequest(name: string, image: string, instanceType: string, namespaceId: string): Record<string, unknown> { | |
| 150 | return { | |
| 151 | name, | |
| 152 | scheduling_policy: "default", | |
| 153 | configuration: { image, instance_type: instanceTypeOf(instanceType) }, | |
| 154 | instances: 0, | |
| 155 | // One instance per app: its Durable Object is named `app`. | |
| 156 | max_instances: 1, | |
| 157 | durable_objects: { namespace_id: namespaceId }, | |
| 158 | }; | |
| 159 | } | |
| 160 | ||
| 161 | /** `POST /accounts/{id}/containers/applications/{app}/rollouts`: a new image or size, all at once. */ | |
| 162 | export function rolloutRequest(image: string, instanceType: string, description: string): Record<string, unknown> { | |
| 163 | return { | |
| 164 | description, | |
| 165 | strategy: "rolling", | |
| 166 | kind: "full_auto", | |
| 167 | step_percentage: 100, | |
| 168 | target_configuration: { image, instance_type: instanceTypeOf(instanceType) }, | |
| 169 | }; | |
| 170 | } | |
| 171 | ||
| 172 | /** The name of an app's Container application. */ | |
| 173 | export function applicationName(script: string): string { | |
| 174 | return `g1t-${script}`; | |
| 175 | } | |
| 176 | ||
| 177 | /** What an app's Worker reports: how long its container has run this month, and what it sent. */ | |
| 178 | export type MeterReport = { | |
| 179 | token: string; | |
| 180 | /** The Durable Object's own counter: a fresh one starts again at zero. */ | |
| 181 | epoch: string; | |
| 182 | month: string; | |
| 183 | awakeSeconds: number; | |
| 184 | egressBytes: number; | |
| 185 | instanceType: string; | |
| 186 | }; | |
| 187 | ||
| 188 | /** A report from an app's Worker, if it is one. */ | |
| 189 | export function meterReport(body: unknown): MeterReport | null { | |
| 190 | if (!body || typeof body !== "object") return null; | |
| 191 | const b = body as Record<string, unknown>; | |
| 192 | const awake = Number(b.awakeSeconds); | |
| 193 | const egress = Number(b.egressBytes ?? 0); | |
| 194 | if (typeof b.token !== "string" || typeof b.epoch !== "string" || !/^[\w-]{8,64}$/.test(b.epoch)) return null; | |
| 195 | if (typeof b.month !== "string" || !/^\d{4}-\d{2}$/.test(b.month)) return null; | |
| 196 | if (!Number.isFinite(awake) || awake < 0 || !Number.isFinite(egress) || egress < 0) return null; | |
| 197 | // No month has more seconds than this. | |
| 198 | if (awake > 31 * 24 * 60 * 60) return null; | |
| 199 | return { | |
| 200 | token: b.token, | |
| 201 | epoch: b.epoch, | |
| 202 | month: b.month, | |
| 203 | awakeSeconds: Math.ceil(awake), | |
| 204 | egressBytes: Math.ceil(egress), | |
| 205 | instanceType: instanceTypeOf(b.instanceType), | |
| 206 | }; | |
| 207 | } | |
| 208 | ||
| 209 | /** One app's container time in a month, as the `container_meters` table keeps it. */ | |
| 210 | export type ContainerMeter = { instance_type: string; awake_seconds: number; egress_bytes: number }; | |
| 211 | ||
| 212 | /** What a month of container time cost g1t, in millionths of a dollar, rounded up, and in words. */ | |
| 213 | export function containerCost(rows: ContainerMeter[], costs: ContainerCosts): { micros: number; seconds: number; detail: string | null } { | |
| 214 | let micros = 0; | |
| 215 | let seconds = 0; | |
| 216 | let bytes = 0; | |
| 217 | for (const row of rows) { | |
| 218 | const awake = Math.max(0, row.awake_seconds); | |
| 219 | micros += awake * containerSecondMicros(appInstance(row.instance_type), costs); | |
| 220 | micros += (Math.max(0, row.egress_bytes) / 1_000_000_000) * costs.egressGb; | |
| 221 | seconds += awake; | |
| 222 | bytes += Math.max(0, row.egress_bytes); | |
| 223 | } | |
| 224 | const hours = Math.round((seconds / 3600) * 10) / 10; | |
| 225 | const detail = seconds || bytes ? `${hours.toLocaleString("en-US")} container hours and ${(Math.round(bytes / 1e7) / 100).toLocaleString("en-US")} GB sent` : null; | |
| 226 | return { micros: Math.ceil(micros), seconds, detail }; | |
| 227 | } | |
| 228 | ||
| 229 | /** What each size costs an hour awake, at most, with `marginPercent` on top: for Settings, Deployments and the docs. */ | |
| 230 | export function containerOffer(costs: ContainerCosts, marginPercent: number, available: boolean): ContainerOffer { | |
| 231 | const withMargin = (micros: number) => Math.ceil(micros * (1 + marginPercent / 100)); | |
| 232 | return { | |
| 233 | available, | |
| 234 | prices: APP_INSTANCES.map((instance) => ({ ...instance, hourlyMicros: withMargin(containerSecondMicros(instance, costs) * 3600) })), | |
| 235 | egressGbMicros: withMargin(costs.egressGb), | |
| 236 | sleepMinutes: CONTAINER_SLEEP_MINUTES, | |
| 237 | }; | |
| 238 | } | |
| 239 | ||
| 240 | /** | |
| 241 | * The Worker every container app runs as, g1t's own. `G1tApp` holds the | |
| 242 | * container: it starts it with the project's variables and secrets and | |
| 243 | * `PORT`, waits up to 90 seconds for it to answer on its port, proxies to | |
| 244 | * it, and stops it once no request has come for `G1T_SLEEP_MS`. Every | |
| 245 | * minute it is up is metered from its start, ahead (a minute begun is a | |
| 246 | * minute charged), with the bytes it sent back, and reported to g1t. | |
| 247 | */ | |
| 248 | export const APP_WORKER = `import { DurableObject } from "cloudflare:workers"; | |
| 249 | ||
| 250 | const INTERNAL = new Set(${JSON.stringify(INTERNAL_BINDINGS)}); | |
| 251 | const TICK_MS = 60000; | |
| 252 | const READY_MS = 90000; | |
| 253 | ||
| 254 | function page(status, title, text) { | |
| 255 | 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" } }); | |
| 256 | } | |
| 257 | ||
| 258 | /** What the app answers while it is not serving: paused, down, or moved. */ | |
| 259 | function notServing(env, request) { | |
| 260 | if (env.G1T_MODE === "redirect" && env.G1T_REDIRECT) { | |
| 261 | const url = new URL(request.url); | |
| 262 | return new Response(null, { status: 301, headers: { location: "https://" + env.G1T_REDIRECT + url.pathname + url.search, "cache-control": "public, max-age=3600" } }); | |
| 263 | } | |
| 264 | 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."); | |
| 265 | return page(404, "This app is not up", "It was taken down. Deploying it again brings it back."); | |
| 266 | } | |
| 267 | ||
| 268 | const monthOf = (at) => new Date(at).toISOString().slice(0, 7); | |
| 269 | ||
| 270 | export class ${APP_CLASS} extends DurableObject { | |
| 271 | constructor(ctx, env) { | |
| 272 | super(ctx, env); | |
| 273 | this.listening = false; | |
| 274 | this.lastRequest = Date.now(); | |
| 275 | this.sent = 0; | |
| 276 | } | |
| 277 | ||
| 278 | async fetch(request) { | |
| 279 | const env = this.env; | |
| 280 | const container = this.ctx.container; | |
| 281 | if (env.G1T_MODE !== "serve") { | |
| 282 | if (container && container.running) await container.destroy(); | |
| 283 | return notServing(env, request); | |
| 284 | } | |
| 285 | this.lastRequest = Date.now(); | |
| 286 | const port = Number(env.G1T_PORT) || 8080; | |
| 287 | if (!container.running) { | |
| 288 | const vars = { PORT: String(port) }; | |
| 289 | for (const [name, value] of Object.entries(env)) if (!INTERNAL.has(name) && typeof value === "string") vars[name] = value; | |
| 290 | container.start({ env: vars, enableInternet: true }); | |
| 291 | this.listening = false; | |
| 292 | await this.tick(); | |
| 293 | } | |
| 294 | // A backstop: g1t stops it itself once it has slept long enough. | |
| 295 | await container.setInactivityTimeout((Number(env.G1T_SLEEP_MS) || 300000) + 2 * TICK_MS).catch(() => undefined); | |
| 296 | if (!(await this.ready(container, port))) { | |
| 297 | 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."); | |
| 298 | } | |
| 299 | let response; | |
| 300 | try { | |
| 301 | response = await container.getTcpPort(port).fetch(request); | |
| 302 | } catch (error) { | |
| 303 | return page(502, "The app failed", "It closed the connection before it answered."); | |
| 304 | } | |
| 305 | return this.counted(response); | |
| 306 | } | |
| 307 | ||
| 308 | /** Whether the container answers on its port, waiting for it to start. */ | |
| 309 | async ready(container, port) { | |
| 310 | if (this.listening) return true; | |
| 311 | const until = Date.now() + READY_MS; | |
| 312 | while (Date.now() < until) { | |
| 313 | try { | |
| 314 | await container.getTcpPort(port).fetch("http://container/", { method: "HEAD" }); | |
| 315 | this.listening = true; | |
| 316 | return true; | |
| 317 | } catch {} | |
| 318 | if (!container.running) return false; | |
| 319 | await new Promise((resolve) => setTimeout(resolve, 250)); | |
| 320 | } | |
| 321 | return false; | |
| 322 | } | |
| 323 | ||
| 324 | /** The response, with the bytes it sends counted. */ | |
| 325 | counted(response) { | |
| 326 | if (!response.body) return response; | |
| 327 | const self = this; | |
| 328 | const count = new TransformStream({ | |
| 329 | transform(chunk, controller) { | |
| 330 | self.sent += chunk.byteLength; | |
| 331 | controller.enqueue(chunk); | |
| 332 | }, | |
| 333 | }); | |
| 334 | return new Response(response.body.pipeThrough(count), response); | |
| 335 | } | |
| 336 | ||
| 337 | async meter(now) { | |
| 338 | let meter = await this.ctx.storage.get("meter"); | |
| 339 | const instance = this.env.G1T_INSTANCE; | |
| 340 | if (meter && meter.instance !== instance) { | |
| 341 | // A new size is counted apart, since it costs differently. | |
| 342 | await this.report(meter); | |
| 343 | meter = null; | |
| 344 | } | |
| 345 | if (!meter) meter = { epoch: crypto.randomUUID(), instance, month: monthOf(now), awakeMs: 0, egressBytes: 0, billedUntil: 0 }; | |
| 346 | if (meter.month !== monthOf(now)) { | |
| 347 | await this.report(meter); | |
| 348 | meter = { ...meter, month: monthOf(now), awakeMs: 0, egressBytes: 0 }; | |
| 349 | } | |
| 350 | return meter; | |
| 351 | } | |
| 352 | ||
| 353 | /** Charges the next minute up (a minute begun is charged), and wakes again at its end. */ | |
| 354 | async tick() { | |
| 355 | const now = Date.now(); | |
| 356 | const meter = await this.meter(now); | |
| 357 | const until = now + TICK_MS; | |
| 358 | meter.awakeMs += until - Math.max(meter.billedUntil, now); | |
| 359 | meter.billedUntil = until; | |
| 360 | meter.egressBytes += this.sent; | |
| 361 | this.sent = 0; | |
| 362 | await this.ctx.storage.put("meter", meter); | |
| 363 | await this.ctx.storage.setAlarm(until); | |
| 364 | await this.report(meter); | |
| 365 | } | |
| 366 | ||
| 367 | async alarm() { | |
| 368 | const container = this.ctx.container; | |
| 369 | const sleep = Number(this.env.G1T_SLEEP_MS) || 300000; | |
| 370 | if (container && container.running && this.env.G1T_MODE === "serve" && Date.now() - this.lastRequest < sleep) { | |
| 371 | await this.tick(); | |
| 372 | return; | |
| 373 | } | |
| 374 | // Asleep: nothing runs, and nothing more is charged. | |
| 375 | if (container && container.running) await container.destroy(); | |
| 376 | const meter = await this.meter(Date.now()); | |
| 377 | meter.egressBytes += this.sent; | |
| 378 | this.sent = 0; | |
| 379 | await this.ctx.storage.put("meter", meter); | |
| 380 | await this.report(meter); | |
| 381 | } | |
| 382 | ||
| 383 | /** Tells g1t what has run this month; a report that fails is caught up by the next. */ | |
| 384 | async report(meter) { | |
| 385 | await fetch(this.env.G1T_METER_URL, { | |
| 386 | method: "POST", | |
| 387 | headers: { "content-type": "application/json" }, | |
| 388 | body: JSON.stringify({ | |
| 389 | token: this.env.G1T_METER_TOKEN, | |
| 390 | epoch: meter.epoch, | |
| 391 | month: meter.month, | |
| 392 | awakeSeconds: Math.ceil(meter.awakeMs / 1000), | |
| 393 | egressBytes: meter.egressBytes, | |
| 394 | instanceType: meter.instance, | |
| 395 | }), | |
| 396 | }).catch(() => undefined); | |
| 397 | } | |
| 398 | } | |
| 399 | ||
| 400 | export default { | |
| 401 | async fetch(request, env, ctx) { | |
| 402 | if (env.G1T_MODE !== "serve") { | |
| 403 | // Its Durable Object stops its container, if it is still up. | |
| 404 | ctx.waitUntil(env.APP.get(env.APP.idFromName("app")).fetch(request).catch(() => undefined)); | |
| 405 | return notServing(env, request); | |
| 406 | } | |
| 407 | return env.APP.get(env.APP.idFromName("app")).fetch(request); | |
| 408 | }, | |
| 409 | }; | |
| 410 | `; |
This file's history is long; its oldest lines are credited to the oldest commit read.