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

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.

syntaqxcommitted Parent4ee36edBrowse files
5 files+249−330/5 viewed
+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 }

Comments

Sign in to comment.