A container app keeps only the image it runs and the one before it, and none once it is taken down: each build pushed another image to Cloudflare's registry and nothing ever deleted one, against an account's 50 GB of images. Its Container application is attached to the Durable Object namespace its Worker's APP binding names, since the account's list of namespaces does not promise to say which dispatch namespace each is in; its container's output goes to Workers Logs; and a container that exits before it answers says its exit code on the page, where it pointed at a build log that has none of it.
5 files+249−330/5 viewed
| 51 | 51 | }; | |
| 52 | 52 | `; | |
| 53 | 53 | ||
| 54 | + | /** The manifests the registry is asked for: an image or an index of them, in either format. */ | |
| 55 | + | const MANIFEST_TYPES = | |
| 56 | + | "application/vnd.oci.image.index.v1+json, application/vnd.oci.image.manifest.v1+json, application/vnd.docker.distribution.manifest.v2+json, application/vnd.docker.distribution.manifest.list.v2+json"; | |
| 57 | + | ||
| 58 | + | /** | |
| 59 | + | * A request to Cloudflare's registry with a login of g1t's own: sent with | |
| 60 | + | * it as Basic, as wrangler does, and again with a token for `scope` when | |
| 61 | + | * the registry answers with the token exchange registries use | |
| 62 | + | * (`WWW-Authenticate: Bearer`). | |
| 63 | + | */ | |
| 64 | + | async function registryFetch( | |
| 65 | + | login: { username: string; password: string }, | |
| 66 | + | path: string, | |
| 67 | + | method: string, | |
| 68 | + | scope: string, | |
| 69 | + | body?: string, | |
| 70 | + | ): Promise<Response> { | |
| 71 | + | const basic = `Basic ${btoa(`${login.username}:${login.password}`)}`; | |
| 72 | + | const url = `https://${REGISTRY_HOST}${path}`; | |
| 73 | + | const headers = (authorization: string): Record<string, string> => ({ | |
| 74 | + | authorization, | |
| 75 | + | accept: MANIFEST_TYPES, | |
| 76 | + | ...(body === undefined ? {} : { "content-type": "application/json" }), | |
| 77 | + | }); | |
| 78 | + | const response = await fetch(url, { method, headers: headers(basic), body }); | |
| 79 | + | const challenge = response.headers.get("www-authenticate") ?? ""; | |
| 80 | + | if (response.status !== 401 || !/^bearer /i.test(challenge)) return response; | |
| 81 | + | const params = Object.fromEntries([...challenge.matchAll(/(\w+)="([^"]*)"/g)].map((m) => [m[1], m[2]])); | |
| 82 | + | if (!params.realm) return response; | |
| 83 | + | const token = new URL(params.realm); | |
| 84 | + | if (params.service) token.searchParams.set("service", params.service); | |
| 85 | + | token.searchParams.set("scope", params.scope ?? scope); | |
| 86 | + | const answer = (await (await fetch(token, { headers: { authorization: basic } })).json().catch(() => null)) as { | |
| 87 | + | token?: string; | |
| 88 | + | access_token?: string; | |
| 89 | + | } | null; | |
| 90 | + | const bearer = answer?.token ?? answer?.access_token; | |
| 91 | + | if (!bearer) return response; | |
| 92 | + | return fetch(url, { method, headers: headers(`Bearer ${bearer}`), body }); | |
| 93 | + | } | |
| 94 | + | ||
| 95 | + | /** | |
| 96 | + | * The tags in `repository` (`<account>/<image>`): from the registry's tag | |
| 97 | + | * list, else from its catalog with tags, which is what Cloudflare's own | |
| 98 | + | * tools read (its names there may lack the account, or start with `/`). | |
| 99 | + | */ | |
| 100 | + | async function listTags(login: { username: string; password: string }, repository: string, account: string): Promise<string[]> { | |
| 101 | + | const scope = `repository:${repository}:pull`; | |
| 102 | + | const listed = await registryFetch(login, `/v2/${repository}/tags/list`, "GET", scope); | |
| 103 | + | if (listed.ok) { | |
| 104 | + | const answer = (await listed.json().catch(() => null)) as { tags?: string[] | null } | null; | |
| 105 | + | return answer?.tags ?? []; | |
| 106 | + | } | |
| 107 | + | if (listed.status === 404) return []; | |
| 108 | + | const catalog = await registryFetch(login, "/v2/_catalog?tags=true", "GET", "registry:catalog:*"); | |
| 109 | + | if (!catalog.ok) throw new Error(`The registry would not list ${repository}'s images (${listed.status}, ${catalog.status}).`); | |
| 110 | + | const answer = (await catalog.json().catch(() => null)) as { repositories?: Record<string, string[]> } | null; | |
| 111 | + | const image = repository.slice(account.length + 1); | |
| 112 | + | for (const [name, tags] of Object.entries(answer?.repositories ?? {})) { | |
| 113 | + | const bare = name.replace(/^\/+/, ""); | |
| 114 | + | if (bare === repository || bare === image) return tags ?? []; | |
| 115 | + | } | |
| 116 | + | return []; | |
| 117 | + | } | |
| 118 | + | ||
| 54 | 119 | const HTML_HANDLING =["auto-trailing-slash", "force-trailing-slash", "drop-trailing-slash", "none"]; | |
| 55 | 120 | const NOT_FOUND_HANDLING = ["single-page-application", "404-page", "none"]; | |
| 56 | 121 | ||
| ⋯ | |||
| 188 | 253 | */ | |
| 189 | 254 | async hasImage(repository: string, digest: string): Promise<boolean> { | |
| 190 | 255 | const login = await this.registryLogin(["pull"]); | |
| 191 | − | const basic = `Basic ${btoa(`${login.username}:${login.password}`)}`; | |
| 192 | − | const url = `https://${REGISTRY_HOST}/v2/${repository}/manifests/${digest}`; | |
| 193 | − | const accept = "application/vnd.oci.image.index.v1+json, application/vnd.oci.image.manifest.v1+json, application/vnd.docker.distribution.manifest.v2+json, application/vnd.docker.distribution.manifest.list.v2+json"; | |
| 194 | − | let response = await fetch(url, { method: "HEAD", headers: { authorization: basic, accept } }); | |
| 195 | − | const challenge = response.headers.get("www-authenticate") ?? ""; | |
| 196 | − | if (response.status === 401 && /^bearer /i.test(challenge)) { | |
| 197 | − | const params = Object.fromEntries([...challenge.matchAll(/(\w+)="([^"]*)"/g)].map((m) => [m[1], m[2]])); | |
| 198 | − | const token = new URL(params.realm); | |
| 199 | − | if (params.service) token.searchParams.set("service", params.service); | |
| 200 | − | token.searchParams.set("scope", params.scope ?? `repository:${repository}:pull`); | |
| 201 | − | const answer = (await (await fetch(token, { headers: { authorization: basic } })).json().catch(() => null)) as { | |
| 202 | − | token?: string; | |
| 203 | − | access_token?: string; | |
| 204 | − | } | null; | |
| 205 | − | const bearer = answer?.token ?? answer?.access_token; | |
| 206 | − | if (!bearer) return false; | |
| 207 | − | response = await fetch(url, { method: "HEAD", headers: { authorization: `Bearer ${bearer}`, accept } }); | |
| 208 | − | } | |
| 256 | + | const response = await registryFetch(login, `/v2/${repository}/manifests/${digest}`, "HEAD", `repository:${repository}:pull`); | |
| 209 | 257 | return response.ok; | |
| 210 | 258 | } | |
| 211 | 259 | ||
| 260 | + | /** | |
| 261 | + | * Deletes the images in `repository` whose tags are not in `keep`, then | |
| 262 | + | * has the registry collect the layers no image uses any more: an app | |
| 263 | + | * keeps its running image and the one before it, so the account stays | |
| 264 | + | * under Containers' image storage limit (`IMAGE_STORAGE_GB`). With | |
| 265 | + | * `keep` empty, every image of the app goes, as when it is taken down. | |
| 266 | + | * Returns the tags deleted. Asked with a login of g1t's own that can pull | |
| 267 | + | * and push, as `wrangler containers images delete` is. | |
| 268 | + | */ | |
| 269 | + | async pruneImages(repository: string, keep: string[]): Promise<string[]> { | |
| 270 | + | const login = await this.registryLogin(["pull", "push"]); | |
| 271 | + | const scope = `repository:${repository}:pull,push,delete`; | |
| 272 | + | const tags = await listTags(login, repository, this.account); | |
| 273 | + | const gone = tags.filter((tag) => !keep.includes(tag)); | |
| 274 | + | for (const tag of gone) { | |
| 275 | + | const response = await registryFetch(login, `/v2/${repository}/manifests/${encodeURIComponent(tag)}`, "DELETE", scope); | |
| 276 | + | if (!response.ok && response.status !== 404) throw new Error(`The registry would not delete ${repository}:${tag} (${response.status}).`); | |
| 277 | + | } | |
| 278 | + | if (gone.length > 0) { | |
| 279 | + | const collected = await registryFetch(login, "/v2/gc/layers", "PUT", scope, "{}"); | |
| 280 | + | if (!collected.ok) console.error("the registry did not collect unused layers", collected.status); | |
| 281 | + | } | |
| 282 | + | return gone; | |
| 283 | + | } | |
| 284 | + | ||
| 212 | 285 | /** Puts a container app's Worker (`APP_WORKER`, g1t's own) in the namespace, with `metadata`. */ | |
| 213 | 286 | async putContainerWorker(script: string, metadata: Record<string, unknown>): Promise<void> { | |
| 214 | 287 | const form = new FormData(); | |
| ⋯ | |||
| 217 | 290 | await this.call("PUT", this.scriptPath(script), form); | |
| 218 | 291 | } | |
| 219 | 292 | ||
| 220 | − | /** The Durable Object namespace of a container app's Worker: what its Container application is attached to. */ | |
| 293 | + | /** | |
| 294 | + | * The Durable Object namespace of a container app's Worker: what its | |
| 295 | + | * Container application is attached to. Read from the Worker's own | |
| 296 | + | * `APP` binding, which names it; else looked for among the account's | |
| 297 | + | * namespaces, by class and script (a namespace there is matched to this | |
| 298 | + | * dispatch namespace when Cloudflare says which it is in, which its | |
| 299 | + | * documented schema does not promise). | |
| 300 | + | */ | |
| 221 | 301 | async appNamespace(script: string): Promise<string> { | |
| 302 | + | const bindings = await this.call<{ type: string; name: string; namespace_id?: string }[]>("GET", `${this.scriptPath(script)}/bindings`).catch( | |
| 303 | + | () => [], | |
| 304 | + | ); | |
| 305 | + | const bound = bindings.find((b) => b.type === "durable_object_namespace" && b.name === "APP" && b.namespace_id); | |
| 306 | + | if (bound?.namespace_id) return bound.namespace_id; | |
| 222 | 307 | for (let page = 1; page <= 20; page++) { | |
| 223 | 308 | const namespaces = await this.call<{ id: string; class: string; script: string; dispatch_namespace?: string }[]>( | |
| 224 | 309 | "GET", | |
| 225 | 310 | `/accounts/${this.account}/workers/durable_objects/namespaces?per_page=1000&page=${page}`, | |
| 226 | 311 | ); | |
| 227 | − | const found = namespaces.find((n) => n.class === APP_CLASS && n.script === script && n.dispatch_namespace === this.namespace); | |
| 312 | + | const found = namespaces.find( | |
| 313 | + | (n) => n.class === APP_CLASS && n.script === script && (n.dispatch_namespace === undefined || n.dispatch_namespace === this.namespace), | |
| 314 | + | ); | |
| 228 | 315 | if (found) return found.id; | |
| 229 | 316 | if (namespaces.length < 1000) break; | |
| 230 | 317 | } | |
| 105 | 105 | let uploads: Record<string, any>[] = []; | |
| 106 | 106 | let applications: { id: string; name: string }[] = []; | |
| 107 | 107 | let registryHas = true; | |
| 108 | + | /** The tags the registry holds in the app's repository. */ | |
| 109 | + | let registryTags: string[] = []; | |
| 110 | + | /** The namespace the app's Worker's `APP` binding names, if Cloudflare says. */ | |
| 111 | + | let boundNamespace: string | null = null; | |
| 112 | + | let applicationBodies: any[] = []; | |
| 108 | 113 | const realFetch = globalThis.fetch; | |
| 109 | 114 | ||
| 110 | 115 | function setUp(containers: string) { | |
| ⋯ | |||
| 113 | 118 | uploads = []; | |
| 114 | 119 | applications = []; | |
| 115 | 120 | registryHas = true; | |
| 121 | + | registryTags = []; | |
| 122 | + | boundNamespace = null; | |
| 123 | + | applicationBodies = []; | |
| 116 | 124 | globalThis.fetch = (async (input: string | Request, init?: RequestInit) => { | |
| 117 | 125 | const url = new URL(typeof input === "string" ? input : input.url); | |
| 118 | 126 | const method = init?.method ?? "GET"; | |
| 119 | 127 | calls.push(`${method} ${url.host === "api.cloudflare.com" ? url.pathname.replace("/client/v4/accounts/acct", "") : url.host + url.pathname}`); | |
| 120 | 128 | const result = (value: unknown) => Response.json({ success: true, result: value }); | |
| 121 | − | if (url.host === "registry.cloudflare.com") return new Response(null, { status: registryHas ? 200 : 404 }); | |
| 129 | + | if (url.host === "registry.cloudflare.com") { | |
| 130 | + | if (url.pathname.endsWith("/tags/list")) return Response.json({ name: `acct/g1t-${SCRIPT}`, tags: registryTags }); | |
| 131 | + | if (method === "DELETE") { | |
| 132 | + | const tag = url.pathname.split("/").pop()!; | |
| 133 | + | registryTags = registryTags.filter((t) => t !== tag); | |
| 134 | + | return new Response(null, { status: 202 }); | |
| 135 | + | } | |
| 136 | + | if (url.pathname === "/v2/gc/layers") return new Response(null, { status: 200 }); | |
| 137 | + | return new Response(null, { status: registryHas ? 200 : 404 }); | |
| 138 | + | } | |
| 122 | 139 | if (url.pathname.endsWith("/credentials")) return result({ username: "v1", password: "push-secret", registry_host: "registry.cloudflare.com" }); | |
| 123 | 140 | if (method === "PUT" && url.pathname.includes("/scripts/")) { | |
| 124 | 141 | const form = init?.body as FormData; | |
| 125 | 142 | uploads.push(JSON.parse(String(form.get("metadata")))); | |
| 126 | 143 | return result({}); | |
| 127 | 144 | } | |
| 145 | + | if (url.pathname.endsWith(`/scripts/${SCRIPT}/bindings`)) { | |
| 146 | + | return result(boundNamespace ? [{ type: "durable_object_namespace", name: "APP", class_name: "G1tApp", namespace_id: boundNamespace }] : []); | |
| 147 | + | } | |
| 128 | 148 | if (url.pathname.endsWith("/durable_objects/namespaces")) { | |
| 129 | 149 | return result([{ id: "ns1", class: "G1tApp", script: SCRIPT, dispatch_namespace: "g1t-deployments" }]); | |
| 130 | 150 | } | |
| 131 | 151 | if (url.pathname.endsWith("/containers/applications") && method === "GET") return result(applications); | |
| 132 | 152 | if (url.pathname.endsWith("/containers/applications") && method === "POST") { | |
| 153 | + | applicationBodies.push(JSON.parse(String(init?.body))); | |
| 133 | 154 | applications.push({ id: "app1", name: JSON.parse(String(init?.body)).name }); | |
| 134 | 155 | return result({ id: "app1" }); | |
| 135 | 156 | } | |
| ⋯ | |||
| 276 | 297 | assert.ok(!paused.bindings.some((b: any) => b.name === "DATABASE_URL"), "a paused app holds no secrets"); | |
| 277 | 298 | assert.ok(rows("SELECT paused_at FROM apps")[0].paused_at); | |
| 278 | 299 | }); | |
| 300 | + | ||
| 301 | + | test("the app's Container application is attached to the namespace its Worker's binding names", async () => { | |
| 302 | + | boundNamespace = "ns-bound"; | |
| 303 | + | await finish({ image: IMAGE, port: 3000 }); | |
| 304 | + | assert.equal(applicationBodies[0].durable_objects.namespace_id, "ns-bound"); | |
| 305 | + | assert.ok(!calls.some((call) => call.includes("/durable_objects/namespaces")), "no listing is needed"); | |
| 306 | + | // Without it, the account's namespaces are searched, by class and script. | |
| 307 | + | setUp("on"); | |
| 308 | + | await finish({ image: IMAGE, port: 3000 }); | |
| 309 | + | assert.equal(applicationBodies[0].durable_objects.namespace_id, "ns1"); | |
| 310 | + | }); | |
| 311 | + | ||
| 312 | + | test("an app keeps the image it runs, the one before it and those still being built, and none once it is down", async () => { | |
| 313 | + | registryTags = ["dpl_0", "dpl_1"]; | |
| 314 | + | await finish({ image: IMAGE, port: 3000 }); | |
| 315 | + | // Nothing replaced yet: only the older image goes. | |
| 316 | + | assert.deepEqual(registryTags, ["dpl_1"]); | |
| 317 | + | assert.ok(calls.includes("PUT registry.cloudflare.com/v2/gc/layers"), "unused layers are collected"); | |
| 318 | + | // The next build replaces it, while another is being built. | |
| 319 | + | open("dpl_2"); | |
| 320 | + | open("dpl_3"); | |
| 321 | + | registryTags = ["dpl_0", "dpl_1", "dpl_2", "dpl_3"]; | |
| 322 | + | await post("/jobs/dpl_2/finish", { token: TOKEN, detected: "container", container: { image: IMAGE, port: 3000 } }); | |
| 323 | + | assert.deepEqual(registryTags, ["dpl_1", "dpl_2", "dpl_3"]); | |
| 324 | + | // Taken down, it takes its images with it, but a build still under way keeps its own. | |
| 325 | + | await post("/rpc/take_down", { actor: { id: "usr_1", username: "a", kind: "user", workspaces: [{ slug: "acme", role: "owner" }] }, project: { workspace: "acme", slug: "web" }, branch: null }); | |
| 326 | + | assert.deepEqual(registryTags, ["dpl_3"]); | |
| 327 | + | }); | |
| 77 | 77 | assert.deepEqual(applicationRequest("g1t-web", image, "basic", "ns1"), { | |
| 78 | 78 | name: "g1t-web", | |
| 79 | 79 | scheduling_policy: "default", | |
| 80 | − | configuration: { image, instance_type: "basic" }, | |
| 80 | + | configuration: { image, instance_type: "basic", observability: { logs: { enabled: true } } }, | |
| 81 | 81 | instances: 0, | |
| 82 | 82 | max_instances: 1, | |
| 83 | 83 | durable_objects: { namespace_id: "ns1" }, | |
| 84 | 84 | }); | |
| 85 | 85 | const rollout = rolloutRequest(image, "nonsense", "dpl_1"); | |
| 86 | − | assert.deepEqual(rollout.target_configuration, { image, instance_type: "lite" }); | |
| 86 | + | assert.deepEqual(rollout.target_configuration, { image, instance_type: "lite", observability: { logs: { enabled: true } } }); | |
| 87 | 87 | assert.equal(rollout.step_percentage, 100); | |
| 88 | 88 | }); | |
| 89 | 89 | ||
| ⋯ | |||
| 215 | 215 | assert.equal(reports.at(-2)?.month, "2026-10", "the month that ended is reported first"); | |
| 216 | 216 | }); | |
| 217 | 217 | ||
| 218 | + | test("an app that stops before it answers says how it ended", async (t) => { | |
| 219 | + | const realFetch = globalThis.fetch; | |
| 220 | + | globalThis.fetch = (async () => new Response("{}")) as typeof fetch; | |
| 221 | + | t.after(() => { | |
| 222 | + | globalThis.fetch = realFetch; | |
| 223 | + | }); | |
| 224 | + | const { G1tApp } = await appWorker(); | |
| 225 | + | const { ctx, container } = runtime(); | |
| 226 | + | // It exits at once, with an error, and never listens. | |
| 227 | + | let exit: (error: unknown) => void = () => {}; | |
| 228 | + | Object.assign(container, { | |
| 229 | + | monitor: () => new Promise((_, reject) => (exit = reject)), | |
| 230 | + | getTcpPort: () => ({ | |
| 231 | + | fetch: async () => { | |
| 232 | + | throw new Error("connection refused"); | |
| 233 | + | }, | |
| 234 | + | }), | |
| 235 | + | }); | |
| 236 | + | const app = new G1tApp(ctx, { G1T_MODE: "serve", G1T_PORT: "3000", G1T_METER_URL: "https://api.g1t.sh/m", APP: {} }); | |
| 237 | + | const answer = app.fetch(new Request("https://web.g1t.page/")); | |
| 238 | + | await new Promise((resolve) => setTimeout(resolve, 0)); | |
| 239 | + | exit(Object.assign(new Error("exited"), { exitCode: 1 })); | |
| 240 | + | container.running = false; | |
| 241 | + | const response = await answer; | |
| 242 | + | assert.equal(response.status, 503); | |
| 243 | + | assert.match(await response.text(), /It stopped \(exit code 1\) before it answered on port 3000/); | |
| 244 | + | }); | |
| 245 | + | ||
| 218 | 246 | test("a paused app answers with a notice and its container stops", async () => { | |
| 219 | 247 | const { G1tApp } = await appWorker(); | |
| 220 | 248 | const { ctx, container } = runtime(); | |
| 22 | 22 | * build to its app's: it names the repository, deploys only an image by | |
| 23 | 23 | * digest in that repository, and checks the digest is there before it | |
| 24 | 24 | * does. The login can never pull, so no build can read another app's image. | |
| 25 | + | * Once a deploy is live, the app's older images are deleted, keeping the | |
| 26 | + | * one it replaced; an app taken down takes its images with it. | |
| 25 | 27 | * | |
| 26 | 28 | * Pure, with no Workers imports, so it can be tested under Node. | |
| 27 | 29 | */ | |
| ⋯ | |||
| 46 | 48 | /** How long a build's push login lasts. */ | |
| 47 | 49 | export const PUSH_LOGIN_MINUTES = 15; | |
| 48 | 50 | ||
| 51 | + | /** | |
| 52 | + | * What Cloudflare Containers holds of an account's images, in all. Each | |
| 53 | + | * app keeps only the image it runs and the one before it | |
| 54 | + | * (`Cloudflare.pruneImages`), and none once it is taken down. | |
| 55 | + | */ | |
| 56 | + | export const IMAGE_STORAGE_GB = 50; | |
| 57 | + | ||
| 49 | 58 | /** The repository an app's images are pushed to: `<account>/g1t-<script>`. */ | |
| 50 | 59 | export function repositoryFor(account: string, script: string): string { | |
| 51 | 60 | return `${account}/g1t-${script}`; | |
| ⋯ | |||
| 147 | 156 | }; | |
| 148 | 157 | } | |
| 149 | 158 | ||
| 159 | + | /** A container's output, kept in Workers Logs. */ | |
| 160 | + | const CONTAINER_LOGS = { logs: { enabled: true } }; | |
| 161 | + | ||
| 150 | 162 | /** `POST /accounts/{id}/containers/applications`: the app's container, for its Durable Object namespace. */ | |
| 151 | 163 | export function applicationRequest(name: string, image: string, instanceType: string, namespaceId: string): Record<string, unknown> { | |
| 152 | 164 | return { | |
| 153 | 165 | name, | |
| 154 | 166 | scheduling_policy: "default", | |
| 155 | − | configuration: { image, instance_type: instanceTypeOf(instanceType) }, | |
| 167 | + | // Its output goes to Workers Logs on g1t's account, where it can be read. | |
| 168 | + | configuration: { image, instance_type: instanceTypeOf(instanceType), observability: CONTAINER_LOGS }, | |
| 156 | 169 | instances: 0, | |
| 157 | 170 | // One instance per app: its Durable Object is named `app`. | |
| 158 | 171 | max_instances: 1, | |
| ⋯ | |||
| 167 | 180 | strategy: "rolling", | |
| 168 | 181 | kind: "full_auto", | |
| 169 | 182 | step_percentage: 100, | |
| 170 | − | target_configuration: { image, instance_type: instanceTypeOf(instanceType) }, | |
| 183 | + | target_configuration: { image, instance_type: instanceTypeOf(instanceType), observability: CONTAINER_LOGS }, | |
| 171 | 184 | }; | |
| 172 | 185 | } | |
| 173 | 186 | ||
| ⋯ | |||
| 291 | 304 | for (const [name, value] of Object.entries(env)) if (!INTERNAL.has(name) && typeof value === "string") vars[name] = value; | |
| 292 | 305 | container.start({ env: vars, enableInternet: true }); | |
| 293 | 306 | this.listening = false; | |
| 307 | + | // How it ended, if it ends: said on the page of a request it never answered. | |
| 308 | + | this.exited = null; | |
| 309 | + | if (typeof container.monitor === "function") { | |
| 310 | + | container.monitor().then( | |
| 311 | + | () => { this.exited = "0"; }, | |
| 312 | + | (error) => { this.exited = error && error.exitCode !== undefined ? String(error.exitCode) : "unknown"; }, | |
| 313 | + | ); | |
| 314 | + | } | |
| 294 | 315 | await this.tick(); | |
| 295 | 316 | } | |
| 296 | 317 | // A backstop: g1t stops it itself once it has slept long enough. | |
| 297 | 318 | await container.setInactivityTimeout((Number(env.G1T_SLEEP_MS) || 300000) + 2 * TICK_MS).catch(() => undefined); | |
| 298 | 319 | if (!(await this.ready(container, port))) { | |
| 299 | − | return page(503, "The app did not start", "It is not answering on port " + port + ". It must listen on 0.0.0.0 at the port in the PORT environment variable (or the one its Dockerfile exposes). Its build's log on g1t has its output."); | |
| 320 | + | const ended = this.exited === null || this.exited === undefined ? "It is not answering on port " + port + "." : "It stopped (exit code " + this.exited + ") before it answered on port " + port + "."; | |
| 321 | + | return page(503, "The app did not start", ended + " It must listen on 0.0.0.0 at the port in the PORT environment variable (the one its Dockerfile exposes, else 8080)."); | |
| 300 | 322 | } | |
| 301 | 323 | let response; | |
| 302 | 324 | try { | |
| 141 | 141 | /** The g1t.page zone, where custom hostnames are added (Cloudflare for SaaS). */ | |
| 142 | 142 | CUSTOM_HOSTNAMES_ZONE_ID?: string; | |
| 143 | 143 | /** | |
| 144 | − | * `on` runs container apps (Docker deployments, src/containers.ts); off | |
| 145 | − | * until Cloudflare's container APIs are checked against g1t's account. | |
| 146 | − | * While off, a container build is refused at its `registry` step with | |
| 144 | + | * `on` runs container apps (Docker deployments, src/containers.ts), as | |
| 145 | + | * g1t.sh does; `off` where the account has no Containers (a self-hosted | |
| 146 | + | * g1t). While off, a container build is refused at its `registry` step with | |
| 147 | 147 | * `not_enabled`, and a project with a site besides its Dockerfile is | |
| 148 | 148 | * built as that site. | |
| 149 | 149 | */ | |
| ⋯ | |||
| 339 | 339 | runtime?: string | null; | |
| 340 | 340 | container_app_id?: string | null; | |
| 341 | 341 | instance_type?: string | null; | |
| 342 | + | /** The build it serves (migration 0015). */ | |
| 343 | + | deployment_id?: string | null; | |
| 342 | 344 | }; | |
| 343 | 345 | ||
| 344 | 346 | function toDeployment(row: DeploymentRow): Deployment { | |
| ⋯ | |||
| 1254 | 1256 | if (!this.containersOn) { | |
| 1255 | 1257 | // Its own code, which the build reads to build the site the | |
| 1256 | 1258 | // project is without its Dockerfile, when it is one. | |
| 1257 | − | const error = { code: "not_enabled", message: "Docker deployments are not switched on for g1t.page yet." }; | |
| 1259 | + | const error = { code: "not_enabled", message: "Docker deployments are switched off on this g1t." }; | |
| 1258 | 1260 | return Response.json({ ok: false, error }, { status: 409 }); | |
| 1259 | 1261 | } | |
| 1260 | 1262 | const instanceType = instanceTypeOf((await this.settingsRow(row.project_id))?.instance_type); | |
| ⋯ | |||
| 1285 | 1287 | const tags = [`workspace:${row.workspace}`, `project:${row.workspace}/${row.slug}`, row.kind, ...telemetryTags(row.project_id, id)]; | |
| 1286 | 1288 | const app: AppTelemetry = { project: row.project_id, deployment: id, environment: row.kind, service: row.slug, commit: row.commit_sha }; | |
| 1287 | 1289 | let container: { appId: string; instanceType: string; meterHash: string; image: string } | null = null; | |
| 1290 | + | let before: AppRow | null = null; | |
| 1288 | 1291 | try { | |
| 1289 | 1292 | // Running apps' secrets and variables are bound here, by g1t: | |
| 1290 | 1293 | // they never pass through the build's sandbox. | |
| ⋯ | |||
| 1299 | 1302 | app, | |
| 1300 | 1303 | row.trusted ? await this.telemetryToken(row.project_id) : null, | |
| 1301 | 1304 | ); | |
| 1302 | − | const before = await this.db.prepare("SELECT runtime, container_app_id FROM apps WHERE script = ?").bind(row.script).first<AppRow>(); | |
| 1305 | + | before = await this.db.prepare("SELECT runtime, container_app_id, deployment_id FROM apps WHERE script = ?").bind(row.script).first<AppRow>(); | |
| 1303 | 1306 | if (detected === "container") { | |
| 1304 | 1307 | container = await this.runContainer(cloudflare, row, body.container, runtime, tags); | |
| 1305 | 1308 | } else { | |
| ⋯ | |||
| 1308 | 1311 | if (before?.runtime === "container") { | |
| 1309 | 1312 | await cloudflare.deleteContainer(row.script, before.container_app_id); | |
| 1310 | 1313 | await cloudflare.deleteScript(row.script); | |
| 1314 | + | await this.dropImages(cloudflare, row.script, []); | |
| 1311 | 1315 | } | |
| 1312 | 1316 | await cloudflare.putScript(row.script, worker, typeof body.completionJwt === "string" ? body.completionJwt : null, tags, runtime); | |
| 1313 | 1317 | } | |
| ⋯ | |||
| 1377 | 1381 | if (row.kind === "production") { | |
| 1378 | 1382 | await this.domains.follow(row.project_id, row.script).catch((error) => console.error("could not point domains", error)); | |
| 1379 | 1383 | } | |
| 1384 | + | // Its images but this one and the one it replaced go. | |
| 1385 | + | if (container) await this.dropImages(cloudflare, row.script, [id, before?.deployment_id ?? null]); | |
| 1380 | 1386 | await this.chargeBuild(row, seconds); | |
| 1381 | 1387 | await this.notePeak(row.workspace); | |
| 1382 | 1388 | await this.statusFor(row, "success", row.kind === "preview" ? "Preview is live" : "Production is live", appUrl(row.script)); | |
| ⋯ | |||
| 1412 | 1418 | runtime: { secrets: Record<string, string>; variables: Record<string, string> }, | |
| 1413 | 1419 | tags: string[], | |
| 1414 | 1420 | ): Promise<{ appId: string; instanceType: string; meterHash: string; image: string }> { | |
| 1415 | − | if (!this.containersOn) throw new Error("Docker deployments are not switched on for g1t.page yet."); | |
| 1421 | + | if (!this.containersOn) throw new Error("Docker deployments are switched off on this g1t."); | |
| 1416 | 1422 | const repository = repositoryFor(this.env.CLOUDFLARE_ACCOUNT_ID, row.script); | |
| 1417 | 1423 | const pushed = pushedImage(report, repository); | |
| 1418 | 1424 | if (!pushed) throw new Error(`the build did not report an image by digest in ${REGISTRY_HOST}/${repository}.`); | |
| ⋯ | |||
| 1425 | 1431 | return { appId, instanceType, meterHash: await sha256(meterToken), image: pushed.image }; | |
| 1426 | 1432 | } | |
| 1427 | 1433 | ||
| 1434 | + | /** | |
| 1435 | + | * Deletes an app's images but those its builds still use: the tags in | |
| 1436 | + | * `keep`, and those of its builds under way, which may have pushed an | |
| 1437 | + | * image they have yet to report. Containers holds an account's images | |
| 1438 | + | * to `IMAGE_STORAGE_GB` in all, so nothing is kept that will not run. | |
| 1439 | + | * A failure is logged: the app's next deploy or removal tries again. | |
| 1440 | + | */ | |
| 1441 | + | private async dropImages(cloudflare: Cloudflare, script: string, keep: (string | null | undefined)[]): Promise<void> { | |
| 1442 | + | try { | |
| 1443 | + | const building = await this.db | |
| 1444 | + | .prepare("SELECT id FROM deployments WHERE script = ? AND status IN ('queued', 'building')") | |
| 1445 | + | .bind(script) | |
| 1446 | + | .all<{ id: string }>(); | |
| 1447 | + | const held = [...keep, ...building.results.map((b) => b.id)].filter((tag): tag is string => !!tag); | |
| 1448 | + | const gone = await cloudflare.pruneImages(repositoryFor(this.env.CLOUDFLARE_ACCOUNT_ID, script), held); | |
| 1449 | + | if (gone.length) console.log("deleted images", script, gone.join(", ")); | |
| 1450 | + | } catch (error) { | |
| 1451 | + | console.error("could not delete images", script, error); | |
| 1452 | + | } | |
| 1453 | + | } | |
| 1454 | + | ||
| 1428 | 1455 | /** Where a container app's Worker reports its running time: the API, which forwards it here. */ | |
| 1429 | 1456 | private meterUrl(script: string): string { | |
| 1430 | 1457 | return `${(this.env.G1T_API ?? "https://api.g1t.sh").replace(/\/+$/, "")}/deployments/apps/${script}/meter`; | |
| ⋯ | |||
| 1737 | 1764 | .prepare("SELECT runtime, container_app_id FROM apps WHERE script = ?") | |
| 1738 | 1765 | .bind(script) | |
| 1739 | 1766 | .first<{ runtime: string | null; container_app_id: string | null }>(); | |
| 1740 | − | // A container app's container goes first, with any instance it has up. | |
| 1767 | + | // A container app's container goes first, with any instance it has up, | |
| 1768 | + | // and its images after it. | |
| 1741 | 1769 | if (app?.runtime === "container") await this.cloudflare?.deleteContainer(script, app.container_app_id); | |
| 1742 | 1770 | await this.cloudflare?.deleteScript(script); | |
| 1771 | + | if (app?.runtime === "container" && this.cloudflare) await this.dropImages(this.cloudflare, script, []); | |
| 1743 | 1772 | await this.db.batch([ | |
| 1744 | 1773 | this.db.prepare("DELETE FROM apps WHERE script = ?").bind(script), | |
| 1745 | 1774 | // Its build is no longer live anywhere. | |
| ⋯ | |||
| 2597 | 2626 | // Container application still: it goes too. | |
| 2598 | 2627 | await cloudflare.deleteContainer(script.id).catch((error) => console.error("could not remove container", script.id, error)); | |
| 2599 | 2628 | await cloudflare.deleteScript(script.id); | |
| 2629 | + | if (this.containersOn) await this.dropImages(cloudflare, script.id, []); | |
| 2600 | 2630 | } | |
| 2601 | 2631 | } | |
| 2602 | 2632 | } | |
Comments
Sign in to comment.