Skip to content

Commit 354a001

Browse files
faster execution time for containers images list (#10136)
Listing images was getting noticeably slower with more images as we make 1 API call to get repos and N API calls to get tags. Refactoring so that we only make 1 API call to get images and tags to minimize the delay on `containers images list`.
1 parent 463bfd2 commit 354a001

File tree

3 files changed

+99
-142
lines changed

3 files changed

+99
-142
lines changed

.changeset/orange-queens-throw.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
"wrangler": patch
3+
---
4+
5+
Update `wrangler containers images list` to make fewer API calls to improve command runtime

packages/wrangler/src/__tests__/cloudchamber/images.test.ts

Lines changed: 78 additions & 111 deletions
Original file line numberDiff line numberDiff line change
@@ -205,11 +205,11 @@ describe("cloudchamber image list", () => {
205205
it("should list images", async () => {
206206
setIsTTY(false);
207207
setWranglerConfig({});
208-
const tags: Map<string, string[]> = new Map([
209-
["one", ["hundred", "ten", "sha256:239a0dfhasdfui235"]],
210-
["two", ["thousand", "twenty", "sha256:badfga4mag0vhjakf"]],
211-
["three", ["million", "thirty", "sha256:23f0adfgbja0f0jf0"]],
212-
]);
208+
const tags = {
209+
one: ["hundred", "ten", "sha256:239a0dfhasdfui235"],
210+
two: ["thousand", "twenty", "sha256:badfga4mag0vhjakf"],
211+
three: ["million", "thirty", "sha256:23f0adfgbja0f0jf0"],
212+
};
213213

214214
msw.use(
215215
http.post("*/registries/:domain/credentials", async ({ params }) => {
@@ -221,16 +221,8 @@ describe("cloudchamber image list", () => {
221221
password: "bar",
222222
});
223223
}),
224-
http.get("*/v2/_catalog", async () => {
225-
return HttpResponse.json({ repositories: ["one", "two", "three"] });
226-
}),
227-
http.get("*/v2/:repo/tags/list", async ({ params }) => {
228-
const repo = String(params["repo"]);
229-
const t = tags.get(repo);
230-
return HttpResponse.json({
231-
name: `${repo}`,
232-
tags: t,
233-
});
224+
http.get("*/v2/_catalog?tags=true", async () => {
225+
return HttpResponse.json({ repositories: tags });
234226
})
235227
);
236228
await runWrangler("cloudchamber images list");
@@ -249,11 +241,11 @@ describe("cloudchamber image list", () => {
249241
it("should list images with a filter", async () => {
250242
setIsTTY(false);
251243
setWranglerConfig({});
252-
const tags: Map<string, string[]> = new Map([
253-
["one", ["hundred", "ten", "sha256:239a0dfhasdfui235"]],
254-
["two", ["thousand", "twenty", "sha256:badfga4mag0vhjakf"]],
255-
["three", ["million", "thirty", "sha256:23f0adfgbja0f0jf0"]],
256-
]);
244+
const tags = {
245+
one: ["hundred", "ten", "sha256:239a0dfhasdfui235"],
246+
two: ["thousand", "twenty", "sha256:badfga4mag0vhjakf"],
247+
three: ["million", "thirty", "sha256:23f0adfgbja0f0jf0"],
248+
};
257249

258250
msw.use(
259251
http.post("*/registries/:domain/credentials", async ({ params }) => {
@@ -265,16 +257,8 @@ describe("cloudchamber image list", () => {
265257
password: "bar",
266258
});
267259
}),
268-
http.get("*/v2/_catalog", async () => {
269-
return HttpResponse.json({ repositories: ["one", "two", "three"] });
270-
}),
271-
http.get("*/v2/:repo/tags/list", async ({ params }) => {
272-
const repo = String(params["repo"]);
273-
const t = tags.get(repo);
274-
return HttpResponse.json({
275-
name: `${repo}`,
276-
tags: t,
277-
});
260+
http.get("*/v2/_catalog?tags=true", async () => {
261+
return HttpResponse.json({ repositories: tags });
278262
})
279263
);
280264
await runWrangler("cloudchamber images list --filter '^two$'");
@@ -289,13 +273,13 @@ describe("cloudchamber image list", () => {
289273
it("should filter out repos with no non-sha tags", async () => {
290274
setIsTTY(false);
291275
setWranglerConfig({});
292-
const tags: Map<string, string[]> = new Map([
293-
["one", ["hundred", "ten", "sha256:239a0dfhasdfui235"]],
294-
["two", ["thousand", "twenty", "sha256:badfga4mag0vhjakf"]],
295-
["three", ["million", "thirty", "sha256:23f0adfgbja0f0jf0"]],
296-
["empty", []],
297-
["shaonly", ["sha256:23f0adfgbja0f0jf0"]],
298-
]);
276+
const tags = {
277+
one: ["hundred", "ten", "sha256:239a0dfhasdfui235"],
278+
two: ["thousand", "twenty", "sha256:badfga4mag0vhjakf"],
279+
three: ["million", "thirty", "sha256:23f0adfgbja0f0jf0"],
280+
empty: [],
281+
shaonly: ["sha256:23f0adfgbja0f0jf0"],
282+
};
299283

300284
msw.use(
301285
http.post("*/registries/:domain/credentials", async ({ params }) => {
@@ -307,16 +291,8 @@ describe("cloudchamber image list", () => {
307291
password: "bar",
308292
});
309293
}),
310-
http.get("*/v2/_catalog", async () => {
311-
return HttpResponse.json({ repositories: ["one", "two", "three"] });
312-
}),
313-
http.get("*/v2/:repo/tags/list", async ({ params }) => {
314-
const repo = String(params["repo"]);
315-
const t = tags.get(repo);
316-
return HttpResponse.json({
317-
name: `${repo}`,
318-
tags: t,
319-
});
294+
http.get("*/v2/_catalog?tags=true", async () => {
295+
return HttpResponse.json({ repositories: tags });
320296
})
321297
);
322298
await runWrangler("cloudchamber images list");
@@ -335,11 +311,11 @@ describe("cloudchamber image list", () => {
335311
it("should list repos with json flag set", async () => {
336312
setIsTTY(false);
337313
setWranglerConfig({});
338-
const tags: Map<string, string[]> = new Map([
339-
["one", ["hundred", "ten", "sha256:239a0dfhasdfui235"]],
340-
["two", ["thousand", "twenty", "sha256:badfga4mag0vhjakf"]],
341-
["three", ["million", "thirty", "sha256:23f0adfgbja0f0jf0"]],
342-
]);
314+
const tags = {
315+
one: ["hundred", "ten", "sha256:239a0dfhasdfui235"],
316+
two: ["thousand", "twenty", "sha256:badfga4mag0vhjakf"],
317+
three: ["million", "thirty", "sha256:23f0adfgbja0f0jf0"],
318+
};
343319

344320
msw.use(
345321
http.post("*/registries/:domain/credentials", async ({ params }) => {
@@ -351,16 +327,8 @@ describe("cloudchamber image list", () => {
351327
password: "bar",
352328
});
353329
}),
354-
http.get("*/v2/_catalog", async () => {
355-
return HttpResponse.json({ repositories: ["one", "two", "three"] });
356-
}),
357-
http.get("*/v2/:repo/tags/list", async ({ params }) => {
358-
const repo = String(params["repo"]);
359-
const t = tags.get(repo);
360-
return HttpResponse.json({
361-
name: `${repo}`,
362-
tags: t,
363-
});
330+
http.get("*/v2/_catalog?tags=true", async () => {
331+
return HttpResponse.json({ repositories: tags });
364332
})
365333
);
366334
await runWrangler("cloudchamber images list --json");
@@ -395,13 +363,13 @@ describe("cloudchamber image list", () => {
395363
it("should filter out repos with no non-sha tags in json output", async () => {
396364
setIsTTY(false);
397365
setWranglerConfig({});
398-
const tags: Map<string, string[]> = new Map([
399-
["one", ["hundred", "ten", "sha256:239a0dfhasdfui235"]],
400-
["two", ["thousand", "twenty", "sha256:badfga4mag0vhjakf"]],
401-
["three", ["million", "thirty", "sha256:23f0adfgbja0f0jf0"]],
402-
["empty", []],
403-
["shaonly", ["sha256:23f0adfgbja0f0jf0"]],
404-
]);
366+
const tags = {
367+
one: ["hundred", "ten", "sha256:239a0dfhasdfui235"],
368+
two: ["thousand", "twenty", "sha256:badfga4mag0vhjakf"],
369+
three: ["million", "thirty", "sha256:23f0adfgbja0f0jf0"],
370+
empty: [],
371+
shaonly: ["sha256:23f0adfgbja0f0jf0"],
372+
};
405373

406374
msw.use(
407375
http.post("*/registries/:domain/credentials", async ({ params }) => {
@@ -413,16 +381,8 @@ describe("cloudchamber image list", () => {
413381
password: "bar",
414382
});
415383
}),
416-
http.get("*/v2/_catalog", async () => {
417-
return HttpResponse.json({ repositories: ["one", "two", "three"] });
418-
}),
419-
http.get("*/v2/:repo/tags/list", async ({ params }) => {
420-
const repo = String(params["repo"]);
421-
const t = tags.get(repo);
422-
return HttpResponse.json({
423-
name: `${repo}`,
424-
tags: t,
425-
});
384+
http.get("*/v2/_catalog?tags=true", async () => {
385+
return HttpResponse.json({ repositories: tags });
426386
})
427387
);
428388
await runWrangler("cloudchamber images list --json");
@@ -453,15 +413,49 @@ describe("cloudchamber image list", () => {
453413
]"
454414
`);
455415
});
416+
});
417+
418+
describe("cloudchamber image delete", () => {
419+
const std = mockConsoleMethods();
420+
const { setIsTTY } = useMockIsTTY();
421+
422+
const REGISTRY = getCloudflareContainerRegistry();
423+
424+
mockAccountId();
425+
mockApiToken();
426+
beforeEach(mockAccount);
427+
runInTempDir();
428+
afterEach(() => {
429+
patchConsole(() => {});
430+
msw.resetHandlers();
431+
});
432+
433+
it("should help", async () => {
434+
setIsTTY(false);
435+
setWranglerConfig({});
436+
await runWrangler("cloudchamber images delete --help");
437+
expect(std.err).toMatchInlineSnapshot(`""`);
438+
expect(std.out).toMatchInlineSnapshot(`
439+
"wrangler cloudchamber images delete <image>
440+
441+
Remove an image from the Cloudflare managed registry
442+
443+
POSITIONALS
444+
image Image and tag to delete, of the form IMAGE:TAG [string] [required]
445+
446+
GLOBAL FLAGS
447+
-c, --config Path to Wrangler configuration file [string]
448+
--cwd Run as if Wrangler was started in the specified directory instead of the current working directory [string]
449+
-e, --env Environment to use for operations, and for selecting .env and .dev.vars files [string]
450+
--env-file Path to an .env file to load - can be specified multiple times - values from earlier files are overridden by values in later files [array]
451+
-h, --help Show help [boolean]
452+
-v, --version Show version number [boolean]"
453+
`);
454+
});
456455

457456
it("should delete images", async () => {
458457
setIsTTY(false);
459458
setWranglerConfig({});
460-
const tags: Map<string, string[]> = new Map([
461-
["one", ["hundred", "ten", "sha256:239a0dfhasdfui235"]],
462-
["two", ["thousand", "twenty", "sha256:badfga4mag0vhjakf"]],
463-
["three", ["million", "thirty", "sha256:23f0adfgbja0f0jf0"]],
464-
]);
465459

466460
msw.use(
467461
http.post("*/registries/:domain/credentials", async ({ params }) => {
@@ -473,17 +467,6 @@ describe("cloudchamber image list", () => {
473467
password: "bar",
474468
});
475469
}),
476-
http.get("*/v2/_catalog", async () => {
477-
return HttpResponse.json({ repositories: ["one", "two", "three"] });
478-
}),
479-
http.get("*/v2/:repo/tags/list", async ({ params }) => {
480-
const repo = String(params["repo"]);
481-
const t = tags.get(repo);
482-
return HttpResponse.json({
483-
name: `${repo}`,
484-
tags: t,
485-
});
486-
}),
487470
http.head("*/v2/:accountId/:image/manifests/:tag", async ({ params }) => {
488471
expect(params.accountId).toEqual("some-account-id");
489472
expect(params.image).toEqual("one");
@@ -516,11 +499,6 @@ describe("cloudchamber image list", () => {
516499
it("should error when provided a repo without a tag", async () => {
517500
setIsTTY(false);
518501
setWranglerConfig({});
519-
const tags: Map<string, string[]> = new Map([
520-
["one", ["hundred", "ten", "sha256:239a0dfhasdfui235"]],
521-
["two", ["thousand", "twenty", "sha256:badfga4mag0vhjakf"]],
522-
["three", ["million", "thirty", "sha256:23f0adfgbja0f0jf0"]],
523-
]);
524502

525503
msw.use(
526504
http.post("*/registries/:domain/credentials", async ({ params }) => {
@@ -531,17 +509,6 @@ describe("cloudchamber image list", () => {
531509
username: "foo",
532510
password: "bar",
533511
});
534-
}),
535-
http.get("*/v2/_catalog", async () => {
536-
return HttpResponse.json({ repositories: ["one", "two", "three"] });
537-
}),
538-
http.get("*/v2/:repo/tags/list", async ({ params }) => {
539-
const repo = String(params["repo"]);
540-
const t = tags.get(repo);
541-
return HttpResponse.json({
542-
name: `${repo}`,
543-
tags: t,
544-
});
545512
})
546513
);
547514
await expect(runWrangler("cloudchamber images delete one")).rejects

packages/wrangler/src/cloudchamber/images/images.ts

Lines changed: 16 additions & 31 deletions
Original file line numberDiff line numberDiff line change
@@ -15,11 +15,12 @@ import type {
1515
import type { cloudchamberScope } from "../common";
1616
import type { ImageRegistryPermissions } from "@cloudflare/containers-shared";
1717

18-
interface CatalogResponse {
19-
repositories: string[];
18+
interface CatalogWithTagsResponse {
19+
repositories: Record<string, string[]>;
20+
cursor?: string;
2021
}
2122

22-
interface TagsResponse {
23+
interface Repository {
2324
name: string;
2425
tags: string[];
2526
}
@@ -123,22 +124,20 @@ async function handleListImagesCommand(
123124
) {
124125
const responses = await promiseSpinner(
125126
getCreds().then(async (creds) => {
126-
const repos = await listRepos(creds);
127-
const responses_: TagsResponse[] = [];
127+
const repos = await listReposWithTags(creds);
128+
const processed: Repository[] = [];
128129
const accountId = config.account_id || (await getAccountId(config));
129130
const accountIdPrefix = new RegExp(`^${accountId}/`);
130131
const filter = new RegExp(args.filter ?? "");
131-
for (const repo of repos) {
132+
for (const [repo, tags] of Object.entries(repos)) {
132133
const stripped = repo.replace(/^\/+/, "");
133134
if (filter.test(stripped)) {
134-
// get all tags for repo
135-
const tags = await listTags(stripped, creds);
136135
const name = stripped.replace(accountIdPrefix, "");
137-
responses_.push({ name, tags });
136+
processed.push({ name, tags });
138137
}
139138
}
140139

141-
return responses_;
140+
return processed;
142141
}),
143142
{ message: "Listing" }
144143
);
@@ -147,7 +146,7 @@ async function handleListImagesCommand(
147146
}
148147

149148
async function listImages(
150-
responses: TagsResponse[],
149+
responses: Repository[],
151150
digests: boolean = false,
152151
json: boolean = false
153152
) {
@@ -184,25 +183,11 @@ async function listImages(
184183
}
185184
}
186185

187-
async function listTags(repo: string, creds: string): Promise<string[]> {
188-
const url = new URL(`https://${getCloudflareContainerRegistry()}`);
189-
const baseUrl = `${url.protocol}//${url.host}`;
190-
const tagsUrl = `${baseUrl}/v2/${repo}/tags/list`;
191-
192-
const tagsResponse = await fetch(tagsUrl, {
193-
method: "GET",
194-
headers: {
195-
Authorization: `Basic ${creds}`,
196-
},
197-
});
198-
const tagsData = (await tagsResponse.json()) as TagsResponse;
199-
return tagsData.tags || [];
200-
}
201-
202-
async function listRepos(creds: string): Promise<string[]> {
186+
async function listReposWithTags(
187+
creds: string
188+
): Promise<Record<string, string[]>> {
203189
const url = new URL(`https://${getCloudflareContainerRegistry()}`);
204-
205-
const catalogUrl = `${url.protocol}//${url.host}/v2/_catalog`;
190+
const catalogUrl = `${url.protocol}//${url.host}/v2/_catalog?tags=true`;
206191

207192
const response = await fetch(catalogUrl, {
208193
method: "GET",
@@ -217,9 +202,9 @@ async function listRepos(creds: string): Promise<string[]> {
217202
);
218203
}
219204

220-
const data = (await response.json()) as CatalogResponse;
205+
const data = (await response.json()) as CatalogWithTagsResponse;
221206

222-
return data.repositories || [];
207+
return data.repositories ?? {};
223208
}
224209

225210
async function deleteTag(

0 commit comments

Comments
 (0)