diff --git a/packages/wrangler/src/__tests__/cloudchamber/create.test.ts b/packages/wrangler/src/__tests__/cloudchamber/create.test.ts index 870fbcccf65c..f1e503861eaa 100644 --- a/packages/wrangler/src/__tests__/cloudchamber/create.test.ts +++ b/packages/wrangler/src/__tests__/cloudchamber/create.test.ts @@ -13,30 +13,6 @@ import { runWrangler } from "../helpers/run-wrangler"; import { mockAccount, setWranglerConfig } from "./utils"; import type { SSHPublicKeyItem } from "@cloudflare/containers-shared"; -const MOCK_DEPLOYMENTS_COMPLEX_RESPONSE = ` - "{ - \\"id\\": \\"1\\", - \\"type\\": \\"default\\", - \\"created_at\\": \\"123\\", - \\"account_id\\": \\"123\\", - \\"vcpu\\": 4, - \\"memory\\": \\"400MB\\", - \\"memory_mib\\": 400, - \\"version\\": 1, - \\"image\\": \\"hello\\", - \\"location\\": { - \\"name\\": \\"sfo06\\", - \\"enabled\\": true - }, - \\"network\\": { - \\"mode\\": \\"public\\", - \\"ipv4\\": \\"1.1.1.1\\" - }, - \\"placements_ref\\": \\"http://ref\\", - \\"node_group\\": \\"metal\\" - }" - `; - function mockDeploymentPost() { msw.use( http.post( @@ -103,7 +79,7 @@ describe("cloudchamber create", () => { expect(std.out).toMatchInlineSnapshot(` "wrangler cloudchamber create - Create a new deployment + Create a new deployment [alpha] GLOBAL FLAGS -c, --config Path to Wrangler configuration file [string] @@ -230,7 +206,29 @@ describe("cloudchamber create", () => { ); // so testing the actual UI will be harder than expected // TODO: think better on how to test UI actions - expect(std.out).toMatchInlineSnapshot(MOCK_DEPLOYMENTS_COMPLEX_RESPONSE); + expect(std.out).toMatchInlineSnapshot(` + "{ + \\"id\\": \\"1\\", + \\"type\\": \\"default\\", + \\"created_at\\": \\"123\\", + \\"account_id\\": \\"123\\", + \\"vcpu\\": 4, + \\"memory\\": \\"400MB\\", + \\"memory_mib\\": 400, + \\"version\\": 1, + \\"image\\": \\"hello\\", + \\"location\\": { + \\"name\\": \\"sfo06\\", + \\"enabled\\": true + }, + \\"network\\": { + \\"mode\\": \\"public\\", + \\"ipv4\\": \\"1.1.1.1\\" + }, + \\"placements_ref\\": \\"http://ref\\", + \\"node_group\\": \\"metal\\" + }" + `); }); it("should create deployment with instance type (detects no interactivity)", async () => { @@ -274,7 +272,29 @@ describe("cloudchamber create", () => { await runWrangler( "cloudchamber create --var HELLO:WORLD --var YOU:CONQUERED" ); - expect(std.out).toMatchInlineSnapshot(MOCK_DEPLOYMENTS_COMPLEX_RESPONSE); + expect(std.out).toMatchInlineSnapshot(` + "{ + \\"id\\": \\"1\\", + \\"type\\": \\"default\\", + \\"created_at\\": \\"123\\", + \\"account_id\\": \\"123\\", + \\"vcpu\\": 4, + \\"memory\\": \\"400MB\\", + \\"memory_mib\\": 400, + \\"version\\": 1, + \\"image\\": \\"hello\\", + \\"location\\": { + \\"name\\": \\"sfo06\\", + \\"enabled\\": true + }, + \\"network\\": { + \\"mode\\": \\"public\\", + \\"ipv4\\": \\"1.1.1.1\\" + }, + \\"placements_ref\\": \\"http://ref\\", + \\"node_group\\": \\"metal\\" + }" + `); expect(std.err).toMatchInlineSnapshot(`""`); }); @@ -349,7 +369,29 @@ describe("cloudchamber create", () => { await runWrangler( "cloudchamber create --image hello:world --location sfo06 --var HELLO:WORLD --var YOU:CONQUERED --all-ssh-keys --ipv4" ); - expect(std.out).toMatchInlineSnapshot(MOCK_DEPLOYMENTS_COMPLEX_RESPONSE); + expect(std.out).toMatchInlineSnapshot(` + "{ + \\"id\\": \\"1\\", + \\"type\\": \\"default\\", + \\"created_at\\": \\"123\\", + \\"account_id\\": \\"123\\", + \\"vcpu\\": 4, + \\"memory\\": \\"400MB\\", + \\"memory_mib\\": 400, + \\"version\\": 1, + \\"image\\": \\"hello\\", + \\"location\\": { + \\"name\\": \\"sfo06\\", + \\"enabled\\": true + }, + \\"network\\": { + \\"mode\\": \\"public\\", + \\"ipv4\\": \\"1.1.1.1\\" + }, + \\"placements_ref\\": \\"http://ref\\", + \\"node_group\\": \\"metal\\" + }" + `); }); it("can't create deployment due to lack of fields (json)", async () => { @@ -364,8 +406,8 @@ describe("cloudchamber create", () => { // so testing the actual UI will be harder than expected // TODO: think better on how to test UI actions expect(std.out).toMatchInlineSnapshot(` - " - If you think this is a bug then please create an issue at https://github.com/cloudflare/workers-sdk/issues/new/choose" - `); + " + If you think this is a bug then please create an issue at https://github.com/cloudflare/workers-sdk/issues/new/choose" + `); }); }); diff --git a/packages/wrangler/src/__tests__/cloudchamber/curl.test.ts b/packages/wrangler/src/__tests__/cloudchamber/curl.test.ts index 777bce43fbea..4d86a39cf8c1 100644 --- a/packages/wrangler/src/__tests__/cloudchamber/curl.test.ts +++ b/packages/wrangler/src/__tests__/cloudchamber/curl.test.ts @@ -34,7 +34,7 @@ describe("cloudchamber curl", () => { expect(helpStd.out).toMatchInlineSnapshot(` "wrangler cloudchamber curl - Send a request to an arbitrary Cloudchamber endpoint + Send a request to an arbitrary Cloudchamber endpoint [alpha] POSITIONALS path [string] [required] [default: \\"/\\"] diff --git a/packages/wrangler/src/__tests__/cloudchamber/delete.test.ts b/packages/wrangler/src/__tests__/cloudchamber/delete.test.ts index 3565aef2bff0..df24b5d67f6d 100644 --- a/packages/wrangler/src/__tests__/cloudchamber/delete.test.ts +++ b/packages/wrangler/src/__tests__/cloudchamber/delete.test.ts @@ -30,10 +30,10 @@ describe("cloudchamber delete", () => { expect(std.out).toMatchInlineSnapshot(` "wrangler cloudchamber delete [deploymentId] - Delete an existing deployment that is running in the Cloudflare edge + Delete an existing deployment that is running in the Cloudflare edge [alpha] POSITIONALS - deploymentId deployment you want to delete [string] + deploymentId Deployment you want to delete [string] GLOBAL FLAGS -c, --config Path to Wrangler configuration file [string] @@ -98,8 +98,8 @@ describe("cloudchamber delete", () => { // so testing the actual UI will be harder than expected // TODO: think better on how to test UI actions expect(std.out).toMatchInlineSnapshot(` - " - If you think this is a bug then please create an issue at https://github.com/cloudflare/workers-sdk/issues/new/choose" - `); + " + If you think this is a bug then please create an issue at https://github.com/cloudflare/workers-sdk/issues/new/choose" + `); }); }); diff --git a/packages/wrangler/src/__tests__/cloudchamber/images.test.ts b/packages/wrangler/src/__tests__/cloudchamber/images.test.ts index 1919c6cbb136..1acf57f274d6 100644 --- a/packages/wrangler/src/__tests__/cloudchamber/images.test.ts +++ b/packages/wrangler/src/__tests__/cloudchamber/images.test.ts @@ -29,13 +29,13 @@ describe("cloudchamber image", () => { expect(std.out).toMatchInlineSnapshot(` "wrangler cloudchamber registries - Configure registries via Cloudchamber + Configure registries via Cloudchamber [alpha] COMMANDS - wrangler cloudchamber registries configure Configure Cloudchamber to pull from specific registries - wrangler cloudchamber registries credentials [domain] get a temporary password for a specific domain - wrangler cloudchamber registries remove [domain] removes the registry at the given domain - wrangler cloudchamber registries list list registries configured for this account + wrangler cloudchamber registries configure Configure Cloudchamber to pull from specific registries [alpha] + wrangler cloudchamber registries credentials Get a temporary password for a specific domain [alpha] + wrangler cloudchamber registries remove Remove the registry at the given domain [alpha] + wrangler cloudchamber registries list List registries configured for this account [alpha] GLOBAL FLAGS -c, --config Path to Wrangler configuration file [string] @@ -182,7 +182,7 @@ describe("cloudchamber image list", () => { expect(std.out).toMatchInlineSnapshot(` "wrangler cloudchamber images list - List images in the Cloudflare managed registry + List images in the Cloudflare managed registry [alpha] GLOBAL FLAGS -c, --config Path to Wrangler configuration file [string] @@ -434,7 +434,7 @@ describe("cloudchamber image delete", () => { expect(std.out).toMatchInlineSnapshot(` "wrangler cloudchamber images delete - Remove an image from the Cloudflare managed registry + Remove an image from the Cloudflare managed registry [alpha] POSITIONALS image Image and tag to delete, of the form IMAGE:TAG [string] [required] diff --git a/packages/wrangler/src/__tests__/cloudchamber/list.test.ts b/packages/wrangler/src/__tests__/cloudchamber/list.test.ts index aab9cc9bd697..b26701079308 100644 --- a/packages/wrangler/src/__tests__/cloudchamber/list.test.ts +++ b/packages/wrangler/src/__tests__/cloudchamber/list.test.ts @@ -30,11 +30,10 @@ describe("cloudchamber list", () => { expect(std.out).toMatchInlineSnapshot(` "wrangler cloudchamber list [deploymentIdPrefix] - List and view status of deployments + List and view status of deployments [alpha] POSITIONALS - deploymentIdPrefix Optional deploymentId to filter deployments - This means that 'list' will only showcase deployments that contain this ID prefix [string] + deploymentIdPrefix Optional deploymentId to filter deployments. This means that 'list' will only showcase deployments that contain this ID prefix [string] GLOBAL FLAGS -c, --config Path to Wrangler configuration file [string] @@ -71,112 +70,112 @@ describe("cloudchamber list", () => { // so testing the actual UI will be harder than expected // TODO: think better on how to test UI actions expect(std.out).toMatchInlineSnapshot(` - "[ - { - \\"id\\": \\"1\\", - \\"type\\": \\"default\\", - \\"created_at\\": \\"123\\", - \\"account_id\\": \\"123\\", - \\"vcpu\\": 4, - \\"memory\\": \\"400MB\\", - \\"memory_mib\\": 400, - \\"version\\": 1, - \\"image\\": \\"hello\\", - \\"location\\": { - \\"name\\": \\"sfo06\\", - \\"enabled\\": true - }, - \\"network\\": { - \\"mode\\": \\"public\\", - \\"ipv4\\": \\"1.1.1.1\\" - }, - \\"placements_ref\\": \\"http://ref\\", - \\"node_group\\": \\"metal\\" - }, - { - \\"id\\": \\"2\\", - \\"type\\": \\"default\\", - \\"created_at\\": \\"1234\\", - \\"account_id\\": \\"123\\", - \\"vcpu\\": 4, - \\"memory\\": \\"400MB\\", - \\"memory_mib\\": 400, - \\"version\\": 2, - \\"image\\": \\"hello\\", - \\"location\\": { - \\"name\\": \\"sfo06\\", - \\"enabled\\": true - }, - \\"network\\": { - \\"mode\\": \\"public\\", - \\"ipv4\\": \\"1.1.1.2\\" - }, - \\"current_placement\\": { - \\"deployment_version\\": 2, - \\"status\\": { - \\"health\\": \\"running\\" - }, - \\"deployment_id\\": \\"2\\", - \\"terminate\\": false, - \\"created_at\\": \\"123\\", - \\"id\\": \\"1\\" - }, - \\"placements_ref\\": \\"http://ref\\", - \\"node_group\\": \\"metal\\" - }, - { - \\"id\\": \\"3\\", - \\"type\\": \\"default\\", - \\"created_at\\": \\"123\\", - \\"account_id\\": \\"123\\", - \\"vcpu\\": 4, - \\"memory\\": \\"400MB\\", - \\"memory_mib\\": 400, - \\"version\\": 1, - \\"image\\": \\"hello\\", - \\"location\\": { - \\"name\\": \\"sfo06\\", - \\"enabled\\": true - }, - \\"network\\": { - \\"mode\\": \\"public\\", - \\"ipv4\\": \\"1.1.1.1\\" - }, - \\"placements_ref\\": \\"http://ref\\", - \\"node_group\\": \\"metal\\" - }, - { - \\"id\\": \\"4\\", - \\"type\\": \\"default\\", - \\"created_at\\": \\"1234\\", - \\"account_id\\": \\"123\\", - \\"vcpu\\": 4, - \\"memory\\": \\"400MB\\", - \\"memory_mib\\": 400, - \\"version\\": 2, - \\"image\\": \\"hello\\", - \\"location\\": { - \\"name\\": \\"sfo06\\", - \\"enabled\\": true - }, - \\"network\\": { - \\"mode\\": \\"public\\", - \\"ipv4\\": \\"1.1.1.2\\" - }, - \\"current_placement\\": { - \\"deployment_version\\": 2, - \\"status\\": { - \\"health\\": \\"running\\" - }, - \\"deployment_id\\": \\"2\\", - \\"terminate\\": false, - \\"created_at\\": \\"123\\", - \\"id\\": \\"1\\" - }, - \\"placements_ref\\": \\"http://ref\\", - \\"node_group\\": \\"metal\\" - } - ]" - `); + "[ + { + \\"id\\": \\"1\\", + \\"type\\": \\"default\\", + \\"created_at\\": \\"123\\", + \\"account_id\\": \\"123\\", + \\"vcpu\\": 4, + \\"memory\\": \\"400MB\\", + \\"memory_mib\\": 400, + \\"version\\": 1, + \\"image\\": \\"hello\\", + \\"location\\": { + \\"name\\": \\"sfo06\\", + \\"enabled\\": true + }, + \\"network\\": { + \\"mode\\": \\"public\\", + \\"ipv4\\": \\"1.1.1.1\\" + }, + \\"placements_ref\\": \\"http://ref\\", + \\"node_group\\": \\"metal\\" + }, + { + \\"id\\": \\"2\\", + \\"type\\": \\"default\\", + \\"created_at\\": \\"1234\\", + \\"account_id\\": \\"123\\", + \\"vcpu\\": 4, + \\"memory\\": \\"400MB\\", + \\"memory_mib\\": 400, + \\"version\\": 2, + \\"image\\": \\"hello\\", + \\"location\\": { + \\"name\\": \\"sfo06\\", + \\"enabled\\": true + }, + \\"network\\": { + \\"mode\\": \\"public\\", + \\"ipv4\\": \\"1.1.1.2\\" + }, + \\"current_placement\\": { + \\"deployment_version\\": 2, + \\"status\\": { + \\"health\\": \\"running\\" + }, + \\"deployment_id\\": \\"2\\", + \\"terminate\\": false, + \\"created_at\\": \\"123\\", + \\"id\\": \\"1\\" + }, + \\"placements_ref\\": \\"http://ref\\", + \\"node_group\\": \\"metal\\" + }, + { + \\"id\\": \\"3\\", + \\"type\\": \\"default\\", + \\"created_at\\": \\"123\\", + \\"account_id\\": \\"123\\", + \\"vcpu\\": 4, + \\"memory\\": \\"400MB\\", + \\"memory_mib\\": 400, + \\"version\\": 1, + \\"image\\": \\"hello\\", + \\"location\\": { + \\"name\\": \\"sfo06\\", + \\"enabled\\": true + }, + \\"network\\": { + \\"mode\\": \\"public\\", + \\"ipv4\\": \\"1.1.1.1\\" + }, + \\"placements_ref\\": \\"http://ref\\", + \\"node_group\\": \\"metal\\" + }, + { + \\"id\\": \\"4\\", + \\"type\\": \\"default\\", + \\"created_at\\": \\"1234\\", + \\"account_id\\": \\"123\\", + \\"vcpu\\": 4, + \\"memory\\": \\"400MB\\", + \\"memory_mib\\": 400, + \\"version\\": 2, + \\"image\\": \\"hello\\", + \\"location\\": { + \\"name\\": \\"sfo06\\", + \\"enabled\\": true + }, + \\"network\\": { + \\"mode\\": \\"public\\", + \\"ipv4\\": \\"1.1.1.2\\" + }, + \\"current_placement\\": { + \\"deployment_version\\": 2, + \\"status\\": { + \\"health\\": \\"running\\" + }, + \\"deployment_id\\": \\"2\\", + \\"terminate\\": false, + \\"created_at\\": \\"123\\", + \\"id\\": \\"1\\" + }, + \\"placements_ref\\": \\"http://ref\\", + \\"node_group\\": \\"metal\\" + } + ]" + `); }); }); diff --git a/packages/wrangler/src/__tests__/cloudchamber/modify.test.ts b/packages/wrangler/src/__tests__/cloudchamber/modify.test.ts index c4009dfb3ecf..076c421505ff 100644 --- a/packages/wrangler/src/__tests__/cloudchamber/modify.test.ts +++ b/packages/wrangler/src/__tests__/cloudchamber/modify.test.ts @@ -25,30 +25,6 @@ function mockDeployment() { ); } -const EXPECTED_RESULT = ` - "{ - \\"id\\": \\"1\\", - \\"type\\": \\"default\\", - \\"created_at\\": \\"123\\", - \\"account_id\\": \\"123\\", - \\"vcpu\\": 4, - \\"memory\\": \\"400MB\\", - \\"memory_mib\\": 400, - \\"version\\": 1, - \\"image\\": \\"hello\\", - \\"location\\": { - \\"name\\": \\"sfo06\\", - \\"enabled\\": true - }, - \\"network\\": { - \\"mode\\": \\"public\\", - \\"ipv4\\": \\"1.1.1.1\\" - }, - \\"placements_ref\\": \\"http://ref\\", - \\"node_group\\": \\"metal\\" - }" - `; - describe("cloudchamber modify", () => { const std = mockConsoleMethods(); const { setIsTTY } = useMockIsTTY(); @@ -68,7 +44,7 @@ describe("cloudchamber modify", () => { expect(std.out).toMatchInlineSnapshot(` "wrangler cloudchamber modify [deploymentId] - Modify an existing deployment + Modify an existing deployment [alpha] POSITIONALS deploymentId The deployment you want to modify [string] @@ -103,7 +79,29 @@ describe("cloudchamber modify", () => { expect(std.err).toMatchInlineSnapshot(`""`); // so testing the actual UI will be harder than expected // TODO: think better on how to test UI actions - expect(std.out).toMatchInlineSnapshot(EXPECTED_RESULT); + expect(std.out).toMatchInlineSnapshot(` + "{ + \\"id\\": \\"1\\", + \\"type\\": \\"default\\", + \\"created_at\\": \\"123\\", + \\"account_id\\": \\"123\\", + \\"vcpu\\": 4, + \\"memory\\": \\"400MB\\", + \\"memory_mib\\": 400, + \\"version\\": 1, + \\"image\\": \\"hello\\", + \\"location\\": { + \\"name\\": \\"sfo06\\", + \\"enabled\\": true + }, + \\"network\\": { + \\"mode\\": \\"public\\", + \\"ipv4\\": \\"1.1.1.1\\" + }, + \\"placements_ref\\": \\"http://ref\\", + \\"node_group\\": \\"metal\\" + }" + `); }); it("should modify deployment with wrangler args (detects no interactivity)", async () => { @@ -119,7 +117,29 @@ describe("cloudchamber modify", () => { "cloudchamber modify 1234 --var HELLO:WORLD --var YOU:CONQUERED --label appname:helloworld --label region:wnam" ); expect(std.err).toMatchInlineSnapshot(`""`); - expect(std.out).toMatchInlineSnapshot(EXPECTED_RESULT); + expect(std.out).toMatchInlineSnapshot(` + "{ + \\"id\\": \\"1\\", + \\"type\\": \\"default\\", + \\"created_at\\": \\"123\\", + \\"account_id\\": \\"123\\", + \\"vcpu\\": 4, + \\"memory\\": \\"400MB\\", + \\"memory_mib\\": 400, + \\"version\\": 1, + \\"image\\": \\"hello\\", + \\"location\\": { + \\"name\\": \\"sfo06\\", + \\"enabled\\": true + }, + \\"network\\": { + \\"mode\\": \\"public\\", + \\"ipv4\\": \\"1.1.1.1\\" + }, + \\"placements_ref\\": \\"http://ref\\", + \\"node_group\\": \\"metal\\" + }" + `); }); it("can't modify deployment due to lack of deploymentId (json)", async () => { @@ -134,8 +154,8 @@ describe("cloudchamber modify", () => { // so testing the actual UI will be harder than expected // TODO: think better on how to test UI actions expect(std.out).toMatchInlineSnapshot(` - " - If you think this is a bug then please create an issue at https://github.com/cloudflare/workers-sdk/issues/new/choose" - `); + " + If you think this is a bug then please create an issue at https://github.com/cloudflare/workers-sdk/issues/new/choose" + `); }); }); diff --git a/packages/wrangler/src/__tests__/containers/delete.test.ts b/packages/wrangler/src/__tests__/containers/delete.test.ts index 2c6175d3aed9..7ec1aa9a929e 100644 --- a/packages/wrangler/src/__tests__/containers/delete.test.ts +++ b/packages/wrangler/src/__tests__/containers/delete.test.ts @@ -28,12 +28,12 @@ describe("containers delete", () => { await runWrangler("containers delete --help"); expect(std.err).toMatchInlineSnapshot(`""`); expect(std.out).toMatchInlineSnapshot(` - "wrangler containers delete ID + "wrangler containers delete - Delete a container + Delete a container [open beta] POSITIONALS - ID id of the containers to delete [string] [required] + ID ID of the container to delete [string] [required] GLOBAL FLAGS -c, --config Path to Wrangler configuration file [string] diff --git a/packages/wrangler/src/__tests__/containers/images.test.ts b/packages/wrangler/src/__tests__/containers/images.test.ts new file mode 100644 index 000000000000..61d18affccbc --- /dev/null +++ b/packages/wrangler/src/__tests__/containers/images.test.ts @@ -0,0 +1,289 @@ +import { getCloudflareContainerRegistry } from "@cloudflare/containers-shared"; +import { http, HttpResponse } from "msw"; +import patchConsole from "patch-console"; +import { afterEach, beforeEach, describe, expect, it } from "vitest"; +import { mockAccount, setWranglerConfig } from "../cloudchamber/utils"; +import { mockAccountId, mockApiToken } from "../helpers/mock-account-id"; +import { mockConsoleMethods } from "../helpers/mock-console"; +import { useMockIsTTY } from "../helpers/mock-istty"; +import { msw } from "../helpers/msw"; +import { runInTempDir } from "../helpers/run-in-tmp"; +import { runWrangler } from "../helpers/run-wrangler"; + +// Helper to wrap responses in v4 API schema format for containers endpoint +function wrapV4Response(result: T) { + return { success: true, result }; +} + +describe("containers images list", () => { + const std = mockConsoleMethods(); + const { setIsTTY } = useMockIsTTY(); + + const REGISTRY = getCloudflareContainerRegistry(); + + mockAccountId(); + mockApiToken(); + beforeEach(mockAccount); + runInTempDir(); + afterEach(() => { + patchConsole(() => {}); + msw.resetHandlers(); + }); + + it("should help", async () => { + setIsTTY(false); + setWranglerConfig({}); + await runWrangler("containers images list --help"); + expect(std.err).toMatchInlineSnapshot(`""`); + expect(std.out).toMatchInlineSnapshot(` + "wrangler containers images list + + List images in the Cloudflare managed registry [open beta] + + GLOBAL FLAGS + -c, --config Path to Wrangler configuration file [string] + --cwd Run as if Wrangler was started in the specified directory instead of the current working directory [string] + -e, --env Environment to use for operations, and for selecting .env and .dev.vars files [string] + --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] + -h, --help Show help [boolean] + -v, --version Show version number [boolean] + + OPTIONS + --filter Regex to filter results [string] + --json Format output as JSON [boolean] [default: false]" + `); + }); + + it("should list images", async () => { + setIsTTY(false); + setWranglerConfig({}); + const tags = { + one: ["hundred", "ten", "sha256:239a0dfhasdfui235"], + two: ["thousand", "twenty", "sha256:badfga4mag0vhjakf"], + three: ["million", "thirty", "sha256:23f0adfgbja0f0jf0"], + }; + + msw.use( + http.post("*/registries/:domain/credentials", async ({ params }) => { + expect(params.domain).toEqual(REGISTRY); + return HttpResponse.json( + wrapV4Response({ + account_id: "1234", + registry_host: REGISTRY, + username: "foo", + password: "bar", + }) + ); + }), + http.get("*/v2/_catalog?tags=true", async () => { + return HttpResponse.json({ repositories: tags }); + }) + ); + + await runWrangler("containers images list"); + expect(std.err).toMatchInlineSnapshot(`""`); + expect(std.out).toMatchInlineSnapshot(` + "REPOSITORY TAG + one hundred + one ten + two thousand + two twenty + three million + three thirty" + `); + }); + + it("should list images with a filter", async () => { + setIsTTY(false); + setWranglerConfig({}); + const tags = { + one: ["hundred", "ten", "sha256:239a0dfhasdfui235"], + two: ["thousand", "twenty", "sha256:badfga4mag0vhjakf"], + three: ["million", "thirty", "sha256:23f0adfgbja0f0jf0"], + }; + + msw.use( + http.post("*/registries/:domain/credentials", async ({ params }) => { + expect(params.domain).toEqual(REGISTRY); + return HttpResponse.json( + wrapV4Response({ + account_id: "1234", + registry_host: REGISTRY, + username: "foo", + password: "bar", + }) + ); + }), + http.get("*/v2/_catalog?tags=true", async () => { + return HttpResponse.json({ repositories: tags }); + }) + ); + await runWrangler("containers images list --filter '^two$'"); + expect(std.err).toMatchInlineSnapshot(`""`); + expect(std.out).toMatchInlineSnapshot(` + "REPOSITORY TAG + two thousand + two twenty" + `); + }); + + it("should list repos with json flag set", async () => { + setWranglerConfig({}); + const tags = { + one: ["hundred", "ten", "sha256:239a0dfhasdfui235"], + two: ["thousand", "twenty", "sha256:badfga4mag0vhjakf"], + three: ["million", "thirty", "sha256:23f0adfgbja0f0jf0"], + }; + + msw.use( + http.post("*/registries/:domain/credentials", async ({ params }) => { + expect(params.domain).toEqual(REGISTRY); + return HttpResponse.json( + wrapV4Response({ + account_id: "1234", + registry_host: REGISTRY, + username: "foo", + password: "bar", + }) + ); + }), + http.get("*/v2/_catalog?tags=true", async () => { + return HttpResponse.json({ repositories: tags }); + }) + ); + await runWrangler("containers images list --json"); + expect(std.err).toMatchInlineSnapshot(`""`); + expect(std.out).toMatchInlineSnapshot(` + "[ + { + \\"name\\": \\"one\\", + \\"tags\\": [ + \\"hundred\\", + \\"ten\\" + ] + }, + { + \\"name\\": \\"two\\", + \\"tags\\": [ + \\"thousand\\", + \\"twenty\\" + ] + }, + { + \\"name\\": \\"three\\", + \\"tags\\": [ + \\"million\\", + \\"thirty\\" + ] + } + ]" + `); + }); +}); + +describe("containers images delete", () => { + const std = mockConsoleMethods(); + const { setIsTTY } = useMockIsTTY(); + + const REGISTRY = getCloudflareContainerRegistry(); + + mockAccountId(); + mockApiToken(); + beforeEach(mockAccount); + runInTempDir(); + afterEach(() => { + patchConsole(() => {}); + msw.resetHandlers(); + }); + + it("should help", async () => { + setIsTTY(false); + setWranglerConfig({}); + await runWrangler("containers images delete --help"); + expect(std.err).toMatchInlineSnapshot(`""`); + expect(std.out).toMatchInlineSnapshot(` + "wrangler containers images delete + + Remove an image from the Cloudflare managed registry [open beta] + + POSITIONALS + image Image and tag to delete, of the form IMAGE:TAG [string] [required] + + GLOBAL FLAGS + -c, --config Path to Wrangler configuration file [string] + --cwd Run as if Wrangler was started in the specified directory instead of the current working directory [string] + -e, --env Environment to use for operations, and for selecting .env and .dev.vars files [string] + --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] + -h, --help Show help [boolean] + -v, --version Show version number [boolean]" + `); + }); + + it("should delete images", async () => { + setIsTTY(false); + setWranglerConfig({}); + + msw.use( + http.post("*/registries/:domain/credentials", async ({ params }) => { + expect(params.domain).toEqual(REGISTRY); + return HttpResponse.json( + wrapV4Response({ + account_id: "1234", + registry_host: REGISTRY, + username: "foo", + password: "bar", + }) + ); + }), + http.head("*/v2/:accountId/:image/manifests/:tag", async ({ params }) => { + expect(params.accountId).toEqual("some-account-id"); + expect(params.image).toEqual("one"); + expect(params.tag).toEqual("hundred"); + return new HttpResponse("", { + status: 200, + headers: { "Docker-Content-Digest": "some-digest" }, + }); + }), + http.delete( + "*/v2/:accountId/:image/manifests/:tag", + async ({ params }) => { + expect(params.accountId).toEqual("some-account-id"); + expect(params.image).toEqual("one"); + expect(params.tag).toEqual("hundred"); + return new HttpResponse("", { status: 200 }); + } + ), + http.put("*/v2/gc/layers", async () => { + return new HttpResponse("", { status: 200 }); + }) + ); + await runWrangler("containers images delete one:hundred"); + expect(std.err).toMatchInlineSnapshot(`""`); + expect(std.out).toMatchInlineSnapshot( + `"Deleted one:hundred (some-digest)"` + ); + }); + + it("should error when provided a repo without a tag", async () => { + setIsTTY(false); + setWranglerConfig({}); + + msw.use( + http.post("*/registries/:domain/credentials", async ({ params }) => { + expect(params.domain).toEqual(REGISTRY); + return HttpResponse.json( + wrapV4Response({ + account_id: "1234", + registry_host: REGISTRY, + username: "foo", + password: "bar", + }) + ); + }) + ); + await expect(runWrangler("containers images delete one")).rejects + .toThrowErrorMatchingInlineSnapshot(` + [Error: Invalid image format. Expected IMAGE:TAG] + `); + }); +}); diff --git a/packages/wrangler/src/__tests__/containers/info.test.ts b/packages/wrangler/src/__tests__/containers/info.test.ts index 990993222536..8dbcec9450f6 100644 --- a/packages/wrangler/src/__tests__/containers/info.test.ts +++ b/packages/wrangler/src/__tests__/containers/info.test.ts @@ -28,12 +28,12 @@ describe("containers info", () => { await runWrangler("containers info --help"); expect(std.err).toMatchInlineSnapshot(`""`); expect(std.out).toMatchInlineSnapshot(` - "wrangler containers info ID + "wrangler containers info - Get information about a specific container + Get information about a specific container [open beta] POSITIONALS - ID id of the containers to view [string] [required] + ID ID of the container to view [string] [required] GLOBAL FLAGS -c, --config Path to Wrangler configuration file [string] diff --git a/packages/wrangler/src/__tests__/containers/list.test.ts b/packages/wrangler/src/__tests__/containers/list.test.ts index 25e92ffe4e62..ba7f256bca37 100644 --- a/packages/wrangler/src/__tests__/containers/list.test.ts +++ b/packages/wrangler/src/__tests__/containers/list.test.ts @@ -28,7 +28,7 @@ describe("containers list", () => { expect(std.out).toMatchInlineSnapshot(` "wrangler containers list - List containers + List containers [open beta] GLOBAL FLAGS -c, --config Path to Wrangler configuration file [string] diff --git a/packages/wrangler/src/__tests__/containers/push.test.ts b/packages/wrangler/src/__tests__/containers/push.test.ts index dbe63ddc6abd..2719c45d2e39 100644 --- a/packages/wrangler/src/__tests__/containers/push.test.ts +++ b/packages/wrangler/src/__tests__/containers/push.test.ts @@ -36,12 +36,12 @@ describe("containers push", () => { await runWrangler("containers push --help"); expect(std.err).toMatchInlineSnapshot(`""`); expect(std.out).toMatchInlineSnapshot(` - "wrangler containers push TAG + "wrangler containers push - Push a tagged image to a Cloudflare managed registry + Push a local image to the Cloudflare managed registry [open beta] POSITIONALS - TAG [string] [required] + TAG The tag of the local image to push [string] [required] GLOBAL FLAGS -c, --config Path to Wrangler configuration file [string] diff --git a/packages/wrangler/src/__tests__/containers/registries.test.ts b/packages/wrangler/src/__tests__/containers/registries.test.ts index 6711eb585d7b..f09a931ad280 100644 --- a/packages/wrangler/src/__tests__/containers/registries.test.ts +++ b/packages/wrangler/src/__tests__/containers/registries.test.ts @@ -37,12 +37,12 @@ describe("containers registries configure", () => { 📦 Manage Containers [open beta] COMMANDS - wrangler containers build PATH Build a container image - wrangler containers push TAG Push a tagged image to a Cloudflare managed registry - wrangler containers images Perform operations on images in your Cloudflare managed registry - wrangler containers info ID Get information about a specific container - wrangler containers list List containers - wrangler containers delete ID Delete a container + wrangler containers list List containers [open beta] + wrangler containers info Get information about a specific container [open beta] + wrangler containers delete Delete a container [open beta] + wrangler containers build Build a container image [open beta] + wrangler containers push Push a local image to the Cloudflare managed registry [open beta] + wrangler containers images Manage images in the Cloudflare managed registry [open beta] GLOBAL FLAGS -c, --config Path to Wrangler configuration file [string] @@ -501,7 +501,10 @@ describe("containers registries delete", () => { " `); expect(std.out).toMatchInlineSnapshot(` - "? Are you sure you want to delete the registry credentials for 123456789012.dkr.ecr.us-west-2.amazonaws.com? This action cannot be undone. + " + ⛅️ wrangler x.x.x + ────────────────── + ? Are you sure you want to delete the registry credentials for 123456789012.dkr.ecr.us-west-2.amazonaws.com? This action cannot be undone. 🤖 Using fallback value in non-interactive context: yes" `); }); diff --git a/packages/wrangler/src/__tests__/containers/ssh.test.ts b/packages/wrangler/src/__tests__/containers/ssh.test.ts index 8b069a35ed9a..b4d8441d358d 100644 --- a/packages/wrangler/src/__tests__/containers/ssh.test.ts +++ b/packages/wrangler/src/__tests__/containers/ssh.test.ts @@ -24,7 +24,7 @@ describe("containers ssh", () => { await runWrangler("containers ssh --help"); expect(std.err).toMatchInlineSnapshot(`""`); expect(std.out).toMatchInlineSnapshot(` - "wrangler containers ssh ID + "wrangler containers ssh POSITIONALS ID ID of the container instance [string] [required] @@ -40,7 +40,7 @@ describe("containers ssh", () => { OPTIONS --cipher Sets \`ssh -c\`: Select the cipher specification for encrypting the session [string] --log-file Sets \`ssh -E\`: Append debug logs to log_file instead of standard error [string] - --escape-char Sets \`ssh -e\`: Set the escape character for sessions with a pty (default: ‘~’) [string] + --escape-char Sets \`ssh -e\`: Set the escape character for sessions with a pty (default: '~') [string] -F, --config-file Sets \`ssh -F\`: Specify an alternative per-user ssh configuration file [string] --pkcs11 Sets \`ssh -I\`: Specify the PKCS#11 shared library ssh should use to communicate with a PKCS#11 token providing keys for user authentication [string] -i, --identity-file Sets \`ssh -i\`: Select a file from which the identity (private key) for public key authentication is read [string] diff --git a/packages/wrangler/src/cloudchamber/apply.ts b/packages/wrangler/src/cloudchamber/apply.ts index 132b805a15be..be41fe01ec95 100644 --- a/packages/wrangler/src/cloudchamber/apply.ts +++ b/packages/wrangler/src/cloudchamber/apply.ts @@ -29,13 +29,18 @@ import { UserError, } from "@cloudflare/workers-utils"; import { configRolloutStepsToAPI } from "../containers/deploy"; +import { createCommand } from "../core/create-command"; import { getAccountId } from "../user"; import { Diff } from "../utils/diff"; import { sortObjectRecursive, stripUndefined, } from "../utils/sortObjectRecursive"; -import { promiseSpinner } from "./common"; +import { + cloudchamberScope, + fillOpenAPIConfiguration, + promiseSpinner, +} from "./common"; import { cleanForInstanceType } from "./instance-type/instance-type"; import type { CommonYargsArgv, @@ -667,3 +672,26 @@ export async function applyCommand( config ); } + +// --- New defineCommand-based command --- + +export const cloudchamberApplyCommand = createCommand({ + metadata: { + description: "Apply the changes in the container applications to deploy", + status: "alpha", + owner: "Product: Cloudchamber", + hidden: false, + }, + args: { + "skip-defaults": { + requiresArg: true, + type: "boolean", + demandOption: false, + describe: "Skips recommended defaults added by apply", + }, + }, + async handler(args, { config }) { + await fillOpenAPIConfiguration(config, cloudchamberScope); + await applyCommand(args, config); + }, +}); diff --git a/packages/wrangler/src/cloudchamber/build.ts b/packages/wrangler/src/cloudchamber/build.ts index b55569d491e3..b2e12b868b3e 100644 --- a/packages/wrangler/src/cloudchamber/build.ts +++ b/packages/wrangler/src/cloudchamber/build.ts @@ -16,8 +16,10 @@ import { getDockerPath, UserError, } from "@cloudflare/workers-utils"; +import { createCommand } from "../core/create-command"; import { logger } from "../logger"; import { getAccountId } from "../user"; +import { cloudchamberScope, fillOpenAPIConfiguration } from "./common"; import { ensureContainerLimits } from "./limits"; import { loadAccount } from "./locations"; import type { @@ -327,3 +329,80 @@ async function checkImagePlatform( ); } } + +// --- New createCommand-based commands --- + +export const cloudchamberBuildCommand = createCommand({ + metadata: { + description: "Build a container image", + status: "alpha", + owner: "Product: Cloudchamber", + hidden: false, + }, + args: { + PATH: { + type: "string", + describe: "Path for the directory containing the Dockerfile to build", + demandOption: true, + }, + tag: { + alias: "t", + type: "string", + demandOption: true, + describe: 'Name and optionally a tag (format: "name:tag")', + }, + "path-to-docker": { + type: "string", + default: "docker", + describe: "Path to your docker binary if it's not on $PATH", + demandOption: false, + }, + push: { + alias: "p", + type: "boolean", + describe: "Push the built image to Cloudflare's managed registry", + default: false, + }, + platform: { + type: "string", + default: "linux/amd64", + describe: + "Platform to build for. Defaults to the architecture support by Workers (linux/amd64)", + demandOption: false, + hidden: true, + deprecated: true, + }, + }, + positionalArgs: ["PATH"], + async handler(args, { config }) { + await fillOpenAPIConfiguration(config, cloudchamberScope); + await buildCommand(args); + }, +}); + +export const cloudchamberPushCommand = createCommand({ + metadata: { + description: "Push a local image to the Cloudflare managed registry", + status: "alpha", + owner: "Product: Cloudchamber", + hidden: false, + }, + args: { + TAG: { + type: "string", + demandOption: true, + describe: "The tag of the local image to push", + }, + "path-to-docker": { + type: "string", + default: "docker", + describe: "Path to your docker binary if it's not on $PATH", + demandOption: false, + }, + }, + positionalArgs: ["TAG"], + async handler(args, { config }) { + await fillOpenAPIConfiguration(config, cloudchamberScope); + await pushCommand(args, config); + }, +}); diff --git a/packages/wrangler/src/cloudchamber/create.ts b/packages/wrangler/src/cloudchamber/create.ts index 0b14cfaf323c..830e80e65756 100644 --- a/packages/wrangler/src/cloudchamber/create.ts +++ b/packages/wrangler/src/cloudchamber/create.ts @@ -14,14 +14,17 @@ import { DeploymentsService, } from "@cloudflare/containers-shared"; import { parseByteSize } from "@cloudflare/workers-utils"; +import { createCommand as defineCommand } from "../core/create-command"; import { isNonInteractiveOrCI } from "../is-interactive"; import { logger } from "../logger"; import { pollSSHKeysUntilCondition, waitForPlacement } from "./cli"; import { getLocation } from "./cli/locations"; import { checkEverythingIsSet, + cloudchamberScope, collectEnvironmentVariables, collectLabels, + fillOpenAPIConfiguration, parseImageName, promptForEnvironmentVariables, promptForLabels, @@ -386,3 +389,97 @@ async function handleCreateCommand( } const whichImageQuestion = "Which image should we use for your container?"; + +// --- New defineCommand-based command --- + +export const cloudchamberCreateCommand = defineCommand({ + metadata: { + description: "Create a new deployment", + status: "alpha", + owner: "Product: Cloudchamber", + hidden: false, + }, + behaviour: { + printBanner: () => !isNonInteractiveOrCI(), + }, + args: { + image: { + requiresArg: true, + type: "string", + demandOption: false, + describe: "Image to use for your deployment", + }, + location: { + requiresArg: true, + type: "string", + demandOption: false, + describe: + "Location on Cloudflare's network where your deployment will run", + }, + var: { + requiresArg: true, + type: "string", + array: true, + demandOption: false, + describe: "Container environment variables", + coerce: (arg: unknown[]) => arg.map((a) => a?.toString() ?? ""), + }, + label: { + requiresArg: true, + type: "array", + demandOption: false, + describe: "Deployment labels", + coerce: (arg: unknown[]) => arg.map((a) => a?.toString() ?? ""), + }, + "all-ssh-keys": { + requiresArg: false, + type: "boolean", + demandOption: false, + describe: + "To add all SSH keys configured on your account to be added to this deployment, set this option to true", + }, + "ssh-key-id": { + requiresArg: false, + type: "string", + array: true, + demandOption: false, + describe: "ID of the SSH key to add to the deployment", + }, + "instance-type": { + requiresArg: true, + choices: [ + "lite", + "basic", + "standard-1", + "standard-2", + "standard-3", + "standard-4", + ] as const, + demandOption: false, + describe: "Instance type to allocate to this deployment", + }, + vcpu: { + requiresArg: true, + type: "number", + demandOption: false, + describe: "Number of vCPUs to allocate to this deployment.", + }, + memory: { + requiresArg: true, + type: "string", + demandOption: false, + describe: + "Amount of memory (GiB, MiB...) to allocate to this deployment. Ex: 4GiB.", + }, + ipv4: { + requiresArg: false, + type: "boolean", + demandOption: false, + describe: "Include an IPv4 in the deployment", + }, + }, + async handler(args, { config }) { + await fillOpenAPIConfiguration(config, cloudchamberScope); + await createCommand(args, config); + }, +}); diff --git a/packages/wrangler/src/cloudchamber/curl.ts b/packages/wrangler/src/cloudchamber/curl.ts index 1509d5c38cc3..cc9e39f84974 100644 --- a/packages/wrangler/src/cloudchamber/curl.ts +++ b/packages/wrangler/src/cloudchamber/curl.ts @@ -3,7 +3,9 @@ import { logRaw } from "@cloudflare/cli"; import { bold, brandColor, cyanBright, yellow } from "@cloudflare/cli/colors"; import { ApiError, OpenAPI } from "@cloudflare/containers-shared"; import { request } from "@cloudflare/containers-shared/src/client/core/request"; +import { createCommand } from "../core/create-command"; import formatLabelledValues from "../utils/render-labelled-values"; +import { cloudchamberScope, fillOpenAPIConfiguration } from "./common"; import type { CommonYargsOptions, StrictYargsOptionsToInterface, @@ -163,3 +165,61 @@ async function requestFromCmd( } } } + +// --- New defineCommand-based command --- + +export const cloudchamberCurlCommand = createCommand({ + metadata: { + description: "Send a request to an arbitrary Cloudchamber endpoint", + status: "alpha", + owner: "Product: Cloudchamber", + hidden: false, + }, + args: { + path: { + type: "string", + default: "/", + demandOption: true, + }, + header: { + type: "array", + alias: "H", + describe: "Add headers in the form of --header :", + }, + data: { + type: "string", + describe: "Add a JSON body to the request", + alias: "d", + }, + "data-deprecated": { + type: "string", + hidden: true, + alias: "D", + }, + method: { + type: "string", + alias: "X", + default: "GET", + }, + silent: { + describe: "Only output response", + type: "boolean", + alias: "s", + }, + verbose: { + describe: "Print everything, like request id, or headers", + type: "boolean", + alias: "v", + }, + "use-stdin": { + describe: "Equivalent of using --data-binary @- in curl", + type: "boolean", + alias: "stdin", + }, + }, + positionalArgs: ["path"], + async handler(args, { config }) { + await fillOpenAPIConfiguration(config, cloudchamberScope); + await curlCommand(args, config); + }, +}); diff --git a/packages/wrangler/src/cloudchamber/delete.ts b/packages/wrangler/src/cloudchamber/delete.ts index e18430d4e893..9f7cc5328c82 100644 --- a/packages/wrangler/src/cloudchamber/delete.ts +++ b/packages/wrangler/src/cloudchamber/delete.ts @@ -2,9 +2,11 @@ import { cancel, endSection, startSection } from "@cloudflare/cli"; import { inputPrompt } from "@cloudflare/cli/interactive"; import { DeploymentsService } from "@cloudflare/containers-shared"; import { UserError } from "@cloudflare/workers-utils"; +import { createCommand } from "../core/create-command"; import { isNonInteractiveOrCI } from "../is-interactive"; import { logger } from "../logger"; import { logDeployment, pickDeployment } from "./cli/deployments"; +import { cloudchamberScope, fillOpenAPIConfiguration } from "./common"; import { wrap } from "./helpers/wrap"; import type { CommonYargsArgv, @@ -68,3 +70,30 @@ async function handleDeleteCommand( } endSection("Your container has been deleted"); } + +// --- New defineCommand-based command --- + +export const cloudchamberDeleteCommand = createCommand({ + metadata: { + description: + "Delete an existing deployment that is running in the Cloudflare edge", + status: "alpha", + owner: "Product: Cloudchamber", + hidden: false, + }, + behaviour: { + printBanner: () => !isNonInteractiveOrCI(), + }, + args: { + deploymentId: { + type: "string", + demandOption: false, + describe: "Deployment you want to delete", + }, + }, + positionalArgs: ["deploymentId"], + async handler(args, { config }) { + await fillOpenAPIConfiguration(config, cloudchamberScope); + await deleteCommand(args, config); + }, +}); diff --git a/packages/wrangler/src/cloudchamber/images/images.ts b/packages/wrangler/src/cloudchamber/images/images.ts index a67361ab4b8e..8f28ba196d1b 100644 --- a/packages/wrangler/src/cloudchamber/images/images.ts +++ b/packages/wrangler/src/cloudchamber/images/images.ts @@ -3,16 +3,22 @@ import { ImageRegistriesService, } from "@cloudflare/containers-shared"; import { fetch } from "undici"; +import { createCommand, createNamespace } from "../../core/create-command"; +import { isNonInteractiveOrCI } from "../../is-interactive"; import { logger } from "../../logger"; import { getAccountId } from "../../user"; -import { handleFailure, promiseSpinner } from "../common"; +import { + cloudchamberScope, + fillOpenAPIConfiguration, + handleFailure, + promiseSpinner, +} from "../common"; import type { containersScope } from "../../containers"; import type { CommonYargsArgv, CommonYargsArgvSanitized, StrictYargsOptionsToInterface, } from "../../yargs-types"; -import type { cloudchamberScope } from "../common"; import type { ImageRegistryPermissions } from "@cloudflare/containers-shared"; import type { Config } from "@cloudflare/workers-utils"; @@ -267,3 +273,65 @@ async function getCreds(): Promise { return Buffer.from(`v1:${credentials.password}`).toString("base64"); } + +// --- New createCommand-based commands --- + +export const cloudchamberImagesNamespace = createNamespace({ + metadata: { + description: "Manage images in the Cloudflare managed registry", + status: "alpha", + owner: "Product: Cloudchamber", + hidden: false, + }, +}); + +export const cloudchamberImagesListCommand = createCommand({ + metadata: { + description: "List images in the Cloudflare managed registry", + status: "alpha", + owner: "Product: Cloudchamber", + hidden: false, + }, + behaviour: { + printBanner: (args) => !args.json && !isNonInteractiveOrCI(), + }, + args: { + filter: { + type: "string", + description: "Regex to filter results", + }, + json: { + type: "boolean", + description: "Format output as JSON", + default: false, + }, + }, + async handler(args, { config }) { + await fillOpenAPIConfiguration(config, cloudchamberScope); + await handleListImagesCommand(args, config); + }, +}); + +export const cloudchamberImagesDeleteCommand = createCommand({ + metadata: { + description: "Remove an image from the Cloudflare managed registry", + status: "alpha", + owner: "Product: Cloudchamber", + hidden: false, + }, + behaviour: { + printBanner: () => !isNonInteractiveOrCI(), + }, + args: { + image: { + type: "string", + description: "Image and tag to delete, of the form IMAGE:TAG", + demandOption: true, + }, + }, + positionalArgs: ["image"], + async handler(args, { config }) { + await fillOpenAPIConfiguration(config, cloudchamberScope); + await handleDeleteImageCommand(args, config); + }, +}); diff --git a/packages/wrangler/src/cloudchamber/images/registries.ts b/packages/wrangler/src/cloudchamber/images/registries.ts index 1eddc9700bfd..10cc8a1134a8 100644 --- a/packages/wrangler/src/cloudchamber/images/registries.ts +++ b/packages/wrangler/src/cloudchamber/images/registries.ts @@ -13,22 +13,26 @@ import { ImageRegistryNotAllowedError, } from "@cloudflare/containers-shared"; import { UserError } from "@cloudflare/workers-utils"; +import { createCommand, createNamespace } from "../../core/create-command"; import { isNonInteractiveOrCI } from "../../is-interactive"; import { logger } from "../../logger"; import { pollRegistriesUntilCondition } from "../cli"; -import { checkEverythingIsSet, handleFailure, promiseSpinner } from "../common"; +import { + checkEverythingIsSet, + cloudchamberScope, + fillOpenAPIConfiguration, + promiseSpinner, +} from "../common"; import { wrap } from "../helpers/wrap"; -import type { containersScope } from "../../containers"; import type { CommonYargsArgv, CommonYargsArgvSanitized, StrictYargsOptionsToInterface, } from "../../yargs-types"; -import type { cloudchamberScope } from "../common"; import type { ImageRegistryPermissions } from "@cloudflare/containers-shared"; import type { Config } from "@cloudflare/workers-utils"; -function configureImageRegistryOptionalYargs(yargs: CommonYargsArgv) { +function _configureImageRegistryOptionalYargs(yargs: CommonYargsArgv) { return yargs .option("domain", { description: @@ -42,140 +46,74 @@ function configureImageRegistryOptionalYargs(yargs: CommonYargsArgv) { }); } -export const registriesCommand = ( - yargs: CommonYargsArgv, - scope: typeof containersScope | typeof cloudchamberScope -) => { - return yargs - .command( - "configure", - "Configure Cloudchamber to pull from specific registries", - (args) => configureImageRegistryOptionalYargs(args), - (args) => - handleFailure( - `wrangler cloudchamber registries configure`, - async ( - imageArgs: StrictYargsOptionsToInterface< - typeof configureImageRegistryOptionalYargs - >, - config - ) => { - // check we are in CI or if the user wants to just use JSON - if (isNonInteractiveOrCI()) { - const body = checkEverythingIsSet(imageArgs, [ - "domain", - "public", - ]); - const registry = await ImageRegistriesService.createImageRegistry( - { - domain: body.domain, - is_public: body.public, - } - ); - logger.log(JSON.stringify(registry, null, 4)); - return; - } +async function registriesConfigureHandler( + imageArgs: StrictYargsOptionsToInterface< + typeof _configureImageRegistryOptionalYargs + >, + config: Config +) { + // check we are in CI or if the user wants to just use JSON + if (isNonInteractiveOrCI()) { + const body = checkEverythingIsSet(imageArgs, ["domain", "public"]); + const registry = await ImageRegistriesService.createImageRegistry({ + domain: body.domain, + is_public: body.public, + }); + logger.log(JSON.stringify(registry, null, 4)); + return; + } - await handleConfigureImageRegistryCommand(args, config); - }, - scope - )(args) - ) - .command( - "credentials [domain]", - "get a temporary password for a specific domain", - (args) => - args - .positional("domain", { - type: "string", - demandOption: true, - }) - .option("expiration-minutes", { - type: "number", - default: 15, - }) - .option("push", { - type: "boolean", - description: "If you want these credentials to be able to push", - }) - .option("pull", { - type: "boolean", - description: "If you want these credentials to be able to pull", - }), - (args) => { - // we don't want any kind of spinners - args.json = true; - return handleFailure( - `wrangler cloudchamber registries credentials`, - async (imageArgs: typeof args, _config) => { - if (!imageArgs.pull && !imageArgs.push) { - throw new UserError( - "You have to specify either --push or --pull in the command." - ); - } + await handleConfigureImageRegistryCommand(imageArgs, config); +} - const credentials = - await ImageRegistriesService.generateImageRegistryCredentials( - imageArgs.domain, - { - expiration_minutes: imageArgs.expirationMinutes, - permissions: [ - ...(imageArgs.push ? ["push"] : []), - ...(imageArgs.pull ? ["pull"] : []), - ] as ImageRegistryPermissions[], - } - ); - logger.log(credentials.password); - }, - scope - )(args); - } - ) - .command( - "remove [domain]", - "removes the registry at the given domain", - (args) => removeImageRegistryYargs(args), - (args) => { - args.json = true; - return handleFailure( - `wrangler cloudchamber registries remove`, - async ( - imageArgs: StrictYargsOptionsToInterface< - typeof removeImageRegistryYargs - >, - _config - ) => { - const registry = await ImageRegistriesService.deleteImageRegistry( - imageArgs.domain - ); - logger.log(JSON.stringify(registry, null, 4)); - }, - scope - )(args); +async function registriesCredentialsHandler(imageArgs: { + domain: string; + expirationMinutes: number; + push?: boolean; + pull?: boolean; +}) { + if (!imageArgs.pull && !imageArgs.push) { + throw new UserError( + "You have to specify either --push or --pull in the command." + ); + } + + const credentials = + await ImageRegistriesService.generateImageRegistryCredentials( + imageArgs.domain, + { + expiration_minutes: imageArgs.expirationMinutes, + permissions: [ + ...(imageArgs.push ? ["push"] : []), + ...(imageArgs.pull ? ["pull"] : []), + ] as ImageRegistryPermissions[], } - ) - .command( - "list", - "list registries configured for this account", - (args) => args, - (args) => - handleFailure( - `wrangler cloudchamber registries list`, - async (_: CommonYargsArgvSanitized, config) => { - if (isNonInteractiveOrCI()) { - const registries = - await ImageRegistriesService.listImageRegistries(); - logger.log(JSON.stringify(registries, null, 4)); - return; - } - await handleListImageRegistriesCommand(args, config); - }, - scope - )(args) ); -}; + logger.log(credentials.password); +} + +async function registriesRemoveHandler( + imageArgs: StrictYargsOptionsToInterface +) { + const registry = await ImageRegistriesService.deleteImageRegistry( + imageArgs.domain + ); + logger.log(JSON.stringify(registry, null, 4)); +} -function removeImageRegistryYargs(yargs: CommonYargsArgv) { +async function registriesListHandler( + _args: CommonYargsArgvSanitized, + config: Config +) { + if (isNonInteractiveOrCI()) { + const registries = await ImageRegistriesService.listImageRegistries(); + logger.log(JSON.stringify(registries, null, 4)); + return; + } + await handleListImageRegistriesCommand(_args, config); +} + +function _removeImageRegistryYargs(yargs: CommonYargsArgv) { return yargs.positional("domain", { type: "string", demandOption: true, @@ -219,7 +157,7 @@ async function handleListImageRegistriesCommand( async function handleConfigureImageRegistryCommand( args: StrictYargsOptionsToInterface< - typeof configureImageRegistryOptionalYargs + typeof _configureImageRegistryOptionalYargs >, _config: Config ) { @@ -281,3 +219,117 @@ async function handleConfigureImageRegistryCommand( registry?.public_key ); } + +// --- New defineCommand-based commands --- + +export const cloudchamberRegistriesNamespace = createNamespace({ + metadata: { + description: "Configure registries via Cloudchamber", + status: "alpha", + owner: "Product: Cloudchamber", + hidden: false, + }, +}); + +export const cloudchamberRegistriesConfigureCommand = createCommand({ + metadata: { + description: "Configure Cloudchamber to pull from specific registries", + status: "alpha", + owner: "Product: Cloudchamber", + hidden: false, + }, + behaviour: { + printBanner: () => !isNonInteractiveOrCI(), + }, + args: { + domain: { + description: + "Domain of your registry. Don't include the proto part of the URL, like 'http://'", + type: "string", + }, + public: { + description: + "If the registry is public and you don't want credentials configured, set this to true", + type: "boolean", + }, + }, + async handler(args, { config }) { + await fillOpenAPIConfiguration(config, cloudchamberScope); + await registriesConfigureHandler(args, config); + }, +}); + +export const cloudchamberRegistriesCredentialsCommand = createCommand({ + metadata: { + description: "Get a temporary password for a specific domain", + status: "alpha", + owner: "Product: Cloudchamber", + hidden: false, + }, + behaviour: { + printBanner: () => !isNonInteractiveOrCI(), + }, + args: { + domain: { + type: "string", + demandOption: true, + }, + "expiration-minutes": { + type: "number", + default: 15, + }, + push: { + type: "boolean", + description: "If you want these credentials to be able to push", + }, + pull: { + type: "boolean", + description: "If you want these credentials to be able to pull", + }, + }, + positionalArgs: ["domain"], + async handler(args, { config }) { + await fillOpenAPIConfiguration(config, cloudchamberScope); + await registriesCredentialsHandler(args); + }, +}); + +export const cloudchamberRegistriesRemoveCommand = createCommand({ + metadata: { + description: "Remove the registry at the given domain", + status: "alpha", + owner: "Product: Cloudchamber", + hidden: false, + }, + behaviour: { + printBanner: () => !isNonInteractiveOrCI(), + }, + args: { + domain: { + type: "string", + demandOption: true, + }, + }, + positionalArgs: ["domain"], + async handler(args, { config }) { + await fillOpenAPIConfiguration(config, cloudchamberScope); + await registriesRemoveHandler(args); + }, +}); + +export const cloudchamberRegistriesListCommand = createCommand({ + metadata: { + description: "List registries configured for this account", + status: "alpha", + owner: "Product: Cloudchamber", + hidden: false, + }, + behaviour: { + printBanner: () => !isNonInteractiveOrCI(), + }, + args: {}, + async handler(args, { config }) { + await fillOpenAPIConfiguration(config, cloudchamberScope); + await registriesListHandler(args, config); + }, +}); diff --git a/packages/wrangler/src/cloudchamber/index.ts b/packages/wrangler/src/cloudchamber/index.ts index 363e64a1af53..119a31bdb218 100644 --- a/packages/wrangler/src/cloudchamber/index.ts +++ b/packages/wrangler/src/cloudchamber/index.ts @@ -1,131 +1,45 @@ -import { applyCommand, applyCommandOptionalYargs } from "./apply"; -import { buildCommand, buildYargs, pushCommand, pushYargs } from "./build"; -import { cloudchamberScope, handleFailure } from "./common"; -import { createCommand, createCommandOptionalYargs } from "./create"; -import { curlCommand, yargsCurl } from "./curl"; -import { deleteCommand, deleteCommandOptionalYargs } from "./delete"; -import { imagesCommand } from "./images/images"; -import { registriesCommand } from "./images/registries"; -import { listCommand, listDeploymentsYargs } from "./list"; -import { modifyCommand, modifyCommandOptionalYargs } from "./modify"; -import { sshCommand } from "./ssh/ssh"; -import type { CommonYargsArgv, CommonYargsOptions } from "../yargs-types"; -import type { CommandModule } from "yargs"; +import { createNamespace } from "../core/create-command"; -function internalCommands(args: CommonYargsArgv) { - try { - // Add dynamically an internal module that we can attach internal commands - // eslint-disable-next-line @typescript-eslint/no-require-imports - const cloudchamberInternalRequireEntry = require("./internal/index"); - return cloudchamberInternalRequireEntry.internalCommands(args); - } catch { - return args; - } -} +// --- Namespace definition --- +export const cloudchamberNamespace = createNamespace({ + metadata: { + description: "Manage Cloudchamber", + status: "alpha", + owner: "Product: Cloudchamber", + hidden: true, + }, +}); -export const cloudchamber = ( - yargs: CommonYargsArgv, - subHelp: CommandModule -) => { - yargs = internalCommands(yargs); - return yargs - .command( - "delete [deploymentId]", - "Delete an existing deployment that is running in the Cloudflare edge", - (args) => deleteCommandOptionalYargs(args), - (args) => - handleFailure( - `wrangler cloudchamber delete`, - deleteCommand, - cloudchamberScope - )(args) - ) - .command( - "create", - "Create a new deployment", - (args) => createCommandOptionalYargs(args), - (args) => - handleFailure( - `wrangler cloudchamber create`, - createCommand, - cloudchamberScope - )(args) - ) - .command( - "list [deploymentIdPrefix]", - "List and view status of deployments", - (args) => listDeploymentsYargs(args), - (args) => - handleFailure( - `wrangler cloudchamber list`, - listCommand, - cloudchamberScope - )(args) - ) - .command( - "modify [deploymentId]", - "Modify an existing deployment", - (args) => modifyCommandOptionalYargs(args), - (args) => - handleFailure( - `wrangler cloudchamber modify`, - modifyCommand, - cloudchamberScope - )(args) - ) - .command("ssh", "Manage the ssh keys of your account", (args) => - sshCommand(args, cloudchamberScope).command(subHelp) - ) - .command("registries", "Configure registries via Cloudchamber", (args) => - registriesCommand(args, cloudchamberScope).command(subHelp) - ) - .command( - "curl ", - "Send a request to an arbitrary Cloudchamber endpoint", - (args) => yargsCurl(args), - (args) => - handleFailure( - `wrangler cloudchamber curl`, - curlCommand, - cloudchamberScope - )(args) - ) - .command( - "apply", - "Apply the changes in the container applications to deploy", - (args) => applyCommandOptionalYargs(args), - (args) => - handleFailure( - `wrangler cloudchamber apply`, - applyCommand, - cloudchamberScope - )(args) - ) - .command( - "build [PATH]", - "Build a container image", - (args) => buildYargs(args), - (args) => - handleFailure( - `wrangler cloudchamber build`, - buildCommand, - cloudchamberScope - )(args) - ) - .command( - "push [TAG]", - "Push a tagged image to a Cloudflare managed registry", - (args) => pushYargs(args), - (args) => - handleFailure( - `wrangler cloudchamber push`, - pushCommand, - cloudchamberScope - )(args) - ) - .command( - "images", - "Perform operations on images in your Cloudchamber registry", - (args) => imagesCommand(args, cloudchamberScope).command(subHelp) - ); -}; +// --- Re-export commands from their respective files --- +export { cloudchamberListCommand } from "./list"; +export { cloudchamberCreateCommand } from "./create"; +export { cloudchamberDeleteCommand } from "./delete"; +export { cloudchamberModifyCommand } from "./modify"; +export { cloudchamberApplyCommand } from "./apply"; +export { cloudchamberCurlCommand } from "./curl"; + +// Build and push commands +export { cloudchamberBuildCommand, cloudchamberPushCommand } from "./build"; + +// SSH subcommands +export { + cloudchamberSshNamespace, + cloudchamberSshListCommand, + cloudchamberSshCreateCommand, +} from "./ssh/ssh"; + +// Registries subcommands +export { + cloudchamberRegistriesNamespace, + cloudchamberRegistriesConfigureCommand, + cloudchamberRegistriesCredentialsCommand, + cloudchamberRegistriesRemoveCommand, + cloudchamberRegistriesListCommand, +} from "./images/registries"; + +// Images subcommands +export { + cloudchamberImagesNamespace, + cloudchamberImagesListCommand, + cloudchamberImagesDeleteCommand, +} from "./images/images"; diff --git a/packages/wrangler/src/cloudchamber/list.ts b/packages/wrangler/src/cloudchamber/list.ts index 95eabdf1853c..409e5792cf19 100644 --- a/packages/wrangler/src/cloudchamber/list.ts +++ b/packages/wrangler/src/cloudchamber/list.ts @@ -14,12 +14,17 @@ import { DeploymentsService, PlacementsService, } from "@cloudflare/containers-shared"; +import { createCommand } from "../core/create-command"; import { isNonInteractiveOrCI } from "../is-interactive"; import { logger } from "../logger"; import { capitalize } from "../utils/strings"; import { listDeploymentsAndChoose, loadDeployments } from "./cli/deployments"; import { statusToColored } from "./cli/util"; -import { promiseSpinner } from "./common"; +import { + cloudchamberScope, + fillOpenAPIConfiguration, + promiseSpinner, +} from "./common"; import type { CommonYargsArgv, StrictYargsOptionsToInterface, @@ -187,3 +192,60 @@ const listCommandHandle = async ( stop(); } }; + +// --- New defineCommand-based command --- + +export const cloudchamberListCommand = createCommand({ + metadata: { + description: "List and view status of deployments", + status: "alpha", + owner: "Product: Cloudchamber", + hidden: false, + }, + behaviour: { + printBanner: () => !isNonInteractiveOrCI(), + }, + args: { + deploymentIdPrefix: { + describe: + "Optional deploymentId to filter deployments. This means that 'list' will only showcase deployments that contain this ID prefix", + type: "string", + }, + location: { + requiresArg: true, + type: "string", + demandOption: false, + describe: "Filter deployments by location", + }, + image: { + requiresArg: true, + type: "string", + demandOption: false, + describe: "Filter deployments by image", + }, + state: { + requiresArg: true, + type: "string", + demandOption: false, + describe: "Filter deployments by deployment state", + }, + ipv4: { + requiresArg: true, + type: "string", + demandOption: false, + describe: "Filter deployments by ipv4 address", + }, + label: { + requiresArg: true, + type: "array", + demandOption: false, + describe: "Filter deployments by labels", + coerce: (arg: unknown[]) => arg.map((a) => a?.toString() ?? ""), + }, + }, + positionalArgs: ["deploymentIdPrefix"], + async handler(args, { config }) { + await fillOpenAPIConfiguration(config, cloudchamberScope); + await listCommand(args, config); + }, +}); diff --git a/packages/wrangler/src/cloudchamber/modify.ts b/packages/wrangler/src/cloudchamber/modify.ts index 34843d9f4210..821e127157e4 100644 --- a/packages/wrangler/src/cloudchamber/modify.ts +++ b/packages/wrangler/src/cloudchamber/modify.ts @@ -2,14 +2,17 @@ import { cancel, startSection } from "@cloudflare/cli"; import { processArgument } from "@cloudflare/cli/args"; import { inputPrompt, spinner } from "@cloudflare/cli/interactive"; import { DeploymentsService } from "@cloudflare/containers-shared"; +import { createCommand } from "../core/create-command"; import { isNonInteractiveOrCI } from "../is-interactive"; import { logger } from "../logger"; import { pollSSHKeysUntilCondition, waitForPlacement } from "./cli"; import { pickDeployment } from "./cli/deployments"; import { getLocation } from "./cli/locations"; import { + cloudchamberScope, collectEnvironmentVariables, collectLabels, + fillOpenAPIConfiguration, parseImageName, promptForEnvironmentVariables, promptForLabels, @@ -308,3 +311,82 @@ async function handleModifyCommand( } const modifyImageQuestion = "URL of the image to use in your deployment"; + +// --- New defineCommand-based command --- + +export const cloudchamberModifyCommand = createCommand({ + metadata: { + description: "Modify an existing deployment", + status: "alpha", + owner: "Product: Cloudchamber", + hidden: false, + }, + behaviour: { + printBanner: () => !isNonInteractiveOrCI(), + }, + args: { + deploymentId: { + type: "string", + demandOption: false, + describe: "The deployment you want to modify", + }, + var: { + requiresArg: true, + type: "array", + demandOption: false, + describe: "Container environment variables", + coerce: (arg: unknown[]) => arg.map((a) => a?.toString() ?? ""), + }, + label: { + requiresArg: true, + type: "array", + demandOption: false, + describe: "Deployment labels", + coerce: (arg: unknown[]) => arg.map((a) => a?.toString() ?? ""), + }, + "ssh-public-key-id": { + requiresArg: true, + type: "string", + array: true, + demandOption: false, + describe: + "Public SSH key IDs to include in this container. You can add one to your account with `wrangler cloudchamber ssh create", + }, + image: { + requiresArg: true, + type: "string", + demandOption: false, + describe: "The new image that the deployment will have from now on", + }, + location: { + requiresArg: true, + type: "string", + demandOption: false, + describe: "The new location that the deployment will have from now on", + }, + "instance-type": { + requiresArg: true, + choices: ["dev", "basic", "standard"] as const, + demandOption: false, + describe: + "The new instance type that the deployment will have from now on", + }, + vcpu: { + requiresArg: true, + type: "number", + demandOption: false, + describe: "The new vcpu that the deployment will have from now on", + }, + memory: { + requiresArg: true, + type: "string", + demandOption: false, + describe: "The new memory that the deployment will have from now on", + }, + }, + positionalArgs: ["deploymentId"], + async handler(args, { config }) { + await fillOpenAPIConfiguration(config, cloudchamberScope); + await modifyCommand(args, config); + }, +}); diff --git a/packages/wrangler/src/cloudchamber/ssh/ssh.ts b/packages/wrangler/src/cloudchamber/ssh/ssh.ts index 1585ecee08b4..29a4e2a4b5b3 100644 --- a/packages/wrangler/src/cloudchamber/ssh/ssh.ts +++ b/packages/wrangler/src/cloudchamber/ssh/ssh.ts @@ -14,19 +14,22 @@ import { brandColor, dim } from "@cloudflare/cli/colors"; import { inputPrompt, spinner } from "@cloudflare/cli/interactive"; import { SshPublicKeysService } from "@cloudflare/containers-shared"; import { UserError } from "@cloudflare/workers-utils"; +import { createCommand, createNamespace } from "../../core/create-command"; import { isNonInteractiveOrCI } from "../../is-interactive"; import { logger } from "../../logger"; import { pollSSHKeysUntilCondition } from "../cli"; -import { checkEverythingIsSet, handleFailure } from "../common"; +import { + checkEverythingIsSet, + cloudchamberScope, + fillOpenAPIConfiguration, +} from "../common"; import { wrap } from "../helpers/wrap"; import { validatePublicSSHKeyCLI, validateSSHKey } from "./validate"; -import type { containersScope } from "../../containers"; import type { CommonYargsArgv, CommonYargsArgvSanitized, StrictYargsOptionsToInterface, } from "../../yargs-types"; -import type { cloudchamberScope } from "../common"; import type { ListSSHPublicKeys, SSHPublicKeyID, @@ -34,7 +37,7 @@ import type { } from "@cloudflare/containers-shared"; import type { Config } from "@cloudflare/workers-utils"; -function createSSHPublicKeyOptionalYargs(yargs: CommonYargsArgv) { +function _createSSHPublicKeyOptionalYargs(yargs: CommonYargsArgv) { return yargs .option("name", { type: "string", @@ -105,66 +108,41 @@ export async function sshPrompts( return key || undefined; } -export const sshCommand = ( - yargs: CommonYargsArgv, - scope: typeof cloudchamberScope | typeof containersScope -) => { - return yargs - .command( - "list", - "list the ssh keys added to your account", - (args) => args, - (args) => - handleFailure( - `wrangler cloudchamber ssh list`, - async (sshArgs: CommonYargsArgvSanitized, config) => { - // check we are in CI or if the user wants to just use JSON - if (isNonInteractiveOrCI()) { - const sshKeys = await SshPublicKeysService.listSshPublicKeys(); - logger.json(sshKeys); - return; - } - - await handleListSSHKeysCommand(sshArgs, config); - }, - scope - )(args) - ) - .command( - "create", - "create an ssh key", - (args) => createSSHPublicKeyOptionalYargs(args), - (args) => - handleFailure( - `wrangler cloudchamber ssh create`, - async ( - sshArgs: StrictYargsOptionsToInterface< - typeof createSSHPublicKeyOptionalYargs - >, - _config - ) => { - // check we are in CI or if the user wants to just use JSON - if (isNonInteractiveOrCI()) { - const body = checkEverythingIsSet(sshArgs, ["publicKey", "name"]); - const sshKey = await retrieveSSHKey(body.publicKey, { - json: true, - }); - const addedSSHKey = await SshPublicKeysService.createSshPublicKey( - { - ...body, - public_key: sshKey.trim(), - } - ); - logger.json(addedSSHKey); - return; - } - - await handleCreateSSHPublicKeyCommand(sshArgs); - }, - scope - )(args) - ); -}; +async function sshListHandler( + sshArgs: CommonYargsArgvSanitized, + config: Config +) { + // check we are in CI or if the user wants to just use JSON + if (isNonInteractiveOrCI()) { + const sshKeys = await SshPublicKeysService.listSshPublicKeys(); + logger.json(sshKeys); + return; + } + + await handleListSSHKeysCommand(sshArgs, config); +} + +async function sshCreateHandler( + sshArgs: StrictYargsOptionsToInterface< + typeof _createSSHPublicKeyOptionalYargs + > +) { + // check we are in CI or if the user wants to just use JSON + if (isNonInteractiveOrCI()) { + const body = checkEverythingIsSet(sshArgs, ["publicKey", "name"]); + const sshKey = await retrieveSSHKey(body.publicKey, { + json: true, + }); + const addedSSHKey = await SshPublicKeysService.createSshPublicKey({ + ...body, + public_key: sshKey.trim(), + }); + logger.json(addedSSHKey); + return; + } + + await handleCreateSSHPublicKeyCommand(sshArgs); +} async function tryToRetrieveAllDefaultSSHKeyPaths(): Promise { const HOME = homedir(); @@ -305,7 +283,7 @@ async function handleListSSHKeysCommand(_args: unknown, _config: Config) { * */ async function handleCreateSSHPublicKeyCommand( - args: StrictYargsOptionsToInterface + args: StrictYargsOptionsToInterface ) { startSection( "Choose an ssh key to add", @@ -324,7 +302,7 @@ async function handleCreateSSHPublicKeyCommand( } async function promptForSSHKey( - args: StrictYargsOptionsToInterface + args: StrictYargsOptionsToInterface ): Promise { const { username } = userInfo(); const name = await inputPrompt({ @@ -390,3 +368,59 @@ async function promptForSSHKey( return res; } + +// --- New defineCommand-based commands --- + +export const cloudchamberSshNamespace = createNamespace({ + metadata: { + description: "Manage the ssh keys of your account", + status: "alpha", + owner: "Product: Cloudchamber", + hidden: false, + }, +}); + +export const cloudchamberSshListCommand = createCommand({ + metadata: { + description: "List the ssh keys added to your account", + status: "alpha", + owner: "Product: Cloudchamber", + hidden: false, + }, + behaviour: { + printBanner: () => !isNonInteractiveOrCI(), + }, + args: {}, + async handler(args, { config }) { + await fillOpenAPIConfiguration(config, cloudchamberScope); + await sshListHandler(args, config); + }, +}); + +export const cloudchamberSshCreateCommand = createCommand({ + metadata: { + description: "Create an ssh key", + status: "alpha", + owner: "Product: Cloudchamber", + hidden: false, + }, + behaviour: { + printBanner: () => !isNonInteractiveOrCI(), + }, + args: { + name: { + type: "string", + describe: + "The alias to your ssh key, you can put a recognisable name for you here", + }, + "public-key": { + type: "string", + describe: + "An SSH public key, you can specify either a path or the ssh key directly here", + }, + }, + async handler(args, { config }) { + await fillOpenAPIConfiguration(config, cloudchamberScope); + await sshCreateHandler(args); + }, +}); diff --git a/packages/wrangler/src/containers/build.ts b/packages/wrangler/src/containers/build.ts index bf62d9d1a96b..2314c20e8112 100644 --- a/packages/wrangler/src/containers/build.ts +++ b/packages/wrangler/src/containers/build.ts @@ -1,11 +1,95 @@ -import { buildAndMaybePush } from "../cloudchamber/build"; +import { + buildAndMaybePush, + buildCommand, + pushCommand, +} from "../cloudchamber/build"; +import { fillOpenAPIConfiguration } from "../cloudchamber/common"; +import { createCommand } from "../core/create-command"; import { logger } from "../logger"; +import { containersScope } from "."; import type { ImageRef } from "../cloudchamber/build"; import type { ContainerNormalizedConfig, ImageURIConfig, } from "@cloudflare/containers-shared"; +// --- Command definitions --- + +export const containersBuildCommand = createCommand({ + metadata: { + description: "Build a container image", + status: "open beta", + owner: "Product: Cloudchamber", + }, + args: { + PATH: { + type: "string", + describe: "Path for the directory containing the Dockerfile to build", + demandOption: true, + }, + tag: { + alias: "t", + type: "string", + demandOption: true, + describe: 'Name and optionally a tag (format: "name:tag")', + }, + "path-to-docker": { + type: "string", + default: "docker", + describe: "Path to your docker binary if it's not on $PATH", + demandOption: false, + }, + push: { + alias: "p", + type: "boolean", + describe: "Push the built image to Cloudflare's managed registry", + default: false, + }, + platform: { + type: "string", + default: "linux/amd64", + describe: + "Platform to build for. Defaults to the architecture support by Workers (linux/amd64)", + demandOption: false, + hidden: true, + deprecated: true, + }, + }, + positionalArgs: ["PATH"], + async handler(args, { config }) { + await fillOpenAPIConfiguration(config, containersScope); + await buildCommand(args); + }, +}); + +export const containersPushCommand = createCommand({ + metadata: { + description: "Push a local image to the Cloudflare managed registry", + status: "open beta", + owner: "Product: Cloudchamber", + }, + args: { + TAG: { + type: "string", + demandOption: true, + describe: "The tag of the local image to push", + }, + "path-to-docker": { + type: "string", + default: "docker", + describe: "Path to your docker binary if it's not on $PATH", + demandOption: false, + }, + }, + positionalArgs: ["TAG"], + async handler(args, { config }) { + await fillOpenAPIConfiguration(config, containersScope); + await pushCommand(args, config); + }, +}); + +// --- Helper functions --- + export async function buildContainer( containerConfig: Exclude, /** just the tag component. will be prefixed with the container name */ diff --git a/packages/wrangler/src/containers/containers.ts b/packages/wrangler/src/containers/containers.ts index d989d0746953..037af18c0cdd 100644 --- a/packages/wrangler/src/containers/containers.ts +++ b/packages/wrangler/src/containers/containers.ts @@ -11,9 +11,12 @@ import { inputPrompt, spinner } from "@cloudflare/cli/interactive"; import { ApiError, ApplicationsService } from "@cloudflare/containers-shared"; import { UserError } from "@cloudflare/workers-utils"; import YAML from "yaml"; +import { fillOpenAPIConfiguration } from "../cloudchamber/common"; import { wrap } from "../cloudchamber/helpers/wrap"; +import { createCommand } from "../core/create-command"; import { isNonInteractiveOrCI } from "../is-interactive"; import { logger } from "../logger"; +import { containersScope } from "./index"; import type { CommonYargsArgv, StrictYargsOptionsToInterface, @@ -247,3 +250,64 @@ async function listContainersAndChoose( // eslint-disable-next-line @typescript-eslint/no-non-null-assertion return applications.find((a) => a.id === application)!; } + +// --- New defineCommand-based commands --- + +export const containersListCommand = createCommand({ + metadata: { + description: "List containers", + status: "open beta", + owner: "Product: Cloudchamber", + }, + behaviour: { + printBanner: () => !isNonInteractiveOrCI(), + }, + args: {}, + async handler(args, { config }) { + await fillOpenAPIConfiguration(config, containersScope); + await listCommand(args, config); + }, +}); + +export const containersInfoCommand = createCommand({ + metadata: { + description: "Get information about a specific container", + status: "open beta", + owner: "Product: Cloudchamber", + }, + behaviour: { + printBanner: () => !isNonInteractiveOrCI(), + }, + args: { + ID: { + describe: "ID of the container to view", + type: "string", + demandOption: true, + }, + }, + positionalArgs: ["ID"], + async handler(args, { config }) { + await fillOpenAPIConfiguration(config, containersScope); + await infoCommand(args, config); + }, +}); + +export const containersDeleteCommand = createCommand({ + metadata: { + description: "Delete a container", + status: "open beta", + owner: "Product: Cloudchamber", + }, + args: { + ID: { + describe: "ID of the container to delete", + type: "string", + demandOption: true, + }, + }, + positionalArgs: ["ID"], + async handler(args, { config }) { + await fillOpenAPIConfiguration(config, containersScope); + await deleteCommand(args, config); + }, +}); diff --git a/packages/wrangler/src/containers/images.ts b/packages/wrangler/src/containers/images.ts new file mode 100644 index 000000000000..cda8b78556a8 --- /dev/null +++ b/packages/wrangler/src/containers/images.ts @@ -0,0 +1,277 @@ +import { + getCloudflareContainerRegistry, + ImageRegistriesService, +} from "@cloudflare/containers-shared"; +import { fetch } from "undici"; +import { + fillOpenAPIConfiguration, + promiseSpinner, +} from "../cloudchamber/common"; +import { createCommand, createNamespace } from "../core/create-command"; +import { isNonInteractiveOrCI } from "../is-interactive"; +import { logger } from "../logger"; +import { getAccountId } from "../user"; +import { containersScope } from "."; +import type { ImageRegistryPermissions } from "@cloudflare/containers-shared"; +import type { Config } from "@cloudflare/workers-utils"; + +interface Repository { + name: string; + tags: string[]; +} + +// --- Namespace definition --- + +export const containersImagesNamespace = createNamespace({ + metadata: { + description: "Manage images in the Cloudflare managed registry", + status: "open beta", + owner: "Product: Cloudchamber", + }, +}); + +// --- Command definitions --- + +export const containersImagesListCommand = createCommand({ + metadata: { + description: "List images in the Cloudflare managed registry", + status: "open beta", + owner: "Product: Cloudchamber", + }, + behaviour: { + printBanner: (args) => !args.json && !isNonInteractiveOrCI(), + }, + args: { + filter: { + type: "string", + description: "Regex to filter results", + }, + json: { + type: "boolean", + description: "Format output as JSON", + default: false, + }, + }, + async handler(args, { config }) { + await fillOpenAPIConfiguration(config, containersScope); + await handleListImagesCommand(args, config); + }, +}); + +export const containersImagesDeleteCommand = createCommand({ + metadata: { + description: "Remove an image from the Cloudflare managed registry", + status: "open beta", + owner: "Product: Cloudchamber", + }, + behaviour: { + printBanner: () => !isNonInteractiveOrCI(), + }, + args: { + image: { + type: "string", + description: "Image and tag to delete, of the form IMAGE:TAG", + demandOption: true, + }, + }, + positionalArgs: ["image"], + async handler(args, { config }) { + await fillOpenAPIConfiguration(config, containersScope); + await handleDeleteImageCommand(args, config); + }, +}); + +// --- Handler functions --- + +async function handleDeleteImageCommand( + args: { image: string }, + config: Config +) { + if (!args.image.includes(":")) { + throw new Error("Invalid image format. Expected IMAGE:TAG"); + } + + const digest = await promiseSpinner( + getCreds().then(async (creds) => { + const accountId = await getAccountId(config); + const url = new URL(`https://${getCloudflareContainerRegistry()}`); + const baseUrl = `${url.protocol}//${url.host}`; + const [image, tag] = args.image.split(":"); + const digest_ = await deleteTag(baseUrl, accountId, image, tag, creds); + + // trigger gc + const gcUrl = `${baseUrl}/v2/gc/layers`; + const gcResponse = await fetch(gcUrl, { + method: "PUT", + headers: { + Authorization: `Basic ${creds}`, + "Content-Type": "application/json", + }, + }); + if (!gcResponse.ok) { + throw new Error( + `Failed to delete image ${args.image}: ${gcResponse.status} ${gcResponse.statusText}` + ); + } + + return digest_; + }), + { message: `Deleting ${args.image}` } + ); + + logger.log(`Deleted ${args.image} (${digest})`); +} + +async function handleListImagesCommand( + args: { filter?: string; json: boolean }, + config: Config +) { + const responses = await promiseSpinner( + getCreds().then(async (creds) => { + const repos = await listReposWithTags(creds); + const processed: Repository[] = []; + const accountId = await getAccountId(config); + const accountIdPrefix = new RegExp(`^${accountId}/`); + const filter = new RegExp(args.filter ?? ""); + for (const [repo, tags] of Object.entries(repos)) { + const stripped = repo.replace(/^\/+/, ""); + if (filter.test(stripped)) { + const name = stripped.replace(accountIdPrefix, ""); + processed.push({ name, tags }); + } + } + + return processed; + }), + { message: "Listing" } + ); + + await listImages(responses, false, args.json); +} + +async function listImages( + responses: Repository[], + digests: boolean = false, + json: boolean = false +) { + if (!digests) { + responses = responses.map((resp) => { + return { + name: resp.name, + tags: resp.tags.filter((t) => !t.startsWith("sha256")), + }; + }); + } + // Remove any repos with no tags + responses = responses.filter((resp) => { + return resp.tags !== undefined && resp.tags.length != 0; + }); + if (json) { + logger.log(JSON.stringify(responses, null, 2)); + } else { + const rows = responses.flatMap((r) => r.tags.map((t) => [r.name, t])); + const headers = ["REPOSITORY", "TAG"]; + const widths = new Array(headers.length).fill(0); + + // Find the maximum length of each column (except for the last) + for (let i = 0; i < widths.length - 1; i++) { + widths[i] = rows + .map((r) => r[i].length) + .reduce((a, b) => Math.max(a, b), headers[i].length); + } + + logger.log(headers.map((h, i) => h.padEnd(widths[i], " ")).join(" ")); + for (const row of rows) { + logger.log(row.map((v, i) => v.padEnd(widths[i], " ")).join(" ")); + } + } +} + +interface CatalogWithTagsResponse { + repositories: Record; + cursor?: string; +} + +async function listReposWithTags( + creds: string +): Promise> { + const url = new URL(`https://${getCloudflareContainerRegistry()}`); + const catalogUrl = `${url.protocol}//${url.host}/v2/_catalog?tags=true`; + + const response = await fetch(catalogUrl, { + method: "GET", + headers: { + Authorization: `Basic ${creds}`, + }, + }); + if (!response.ok) { + logger.log(JSON.stringify(response)); + throw new Error( + `Failed to fetch repository catalog: ${response.status} ${response.statusText}` + ); + } + + const data = (await response.json()) as CatalogWithTagsResponse; + + return data.repositories ?? {}; +} + +async function deleteTag( + baseUrl: string, + accountId: string, + image: string, + tag: string, + creds: string +): Promise { + const manifestAcceptHeader = + "application/vnd.oci.image.manifest.v1+json, application/vnd.docker.distribution.manifest.v2+json"; + const manifestUrl = `${baseUrl}/v2/${accountId}/${image}/manifests/${tag}`; + // grab the digest for this tag + const headResponse = await fetch(manifestUrl, { + method: "HEAD", + headers: { + Authorization: `Basic ${creds}`, + Accept: manifestAcceptHeader, + }, + }); + if (!headResponse.ok) { + throw new Error( + `Failed to retrieve info for ${image}:${tag}: ${headResponse.status} ${headResponse.statusText}` + ); + } + + const digest = headResponse.headers.get("Docker-Content-Digest"); + if (!digest) { + throw new Error(`Digest not found for ${image}:${tag}.`); + } + + const deleteUrl = `${baseUrl}/v2/${accountId}/${image}/manifests/${tag}`; + const deleteResponse = await fetch(deleteUrl, { + method: "DELETE", + headers: { + Authorization: `Basic ${creds}`, + Accept: manifestAcceptHeader, + }, + }); + + if (!deleteResponse.ok) { + throw new Error( + `Failed to delete ${image}:${tag} (digest: ${digest}): ${deleteResponse.status} ${deleteResponse.statusText}` + ); + } + + return digest; +} + +async function getCreds(): Promise { + const credentials = + await ImageRegistriesService.generateImageRegistryCredentials( + getCloudflareContainerRegistry(), + { + expiration_minutes: 5, + permissions: ["pull", "push"] as ImageRegistryPermissions[], + } + ); + + return Buffer.from(`v1:${credentials.password}`).toString("base64"); +} diff --git a/packages/wrangler/src/containers/index.ts b/packages/wrangler/src/containers/index.ts index df89e0637b4d..ba0e61975e5b 100644 --- a/packages/wrangler/src/containers/index.ts +++ b/packages/wrangler/src/containers/index.ts @@ -1,108 +1,38 @@ -import { - buildCommand, - buildYargs, - pushCommand, - pushYargs, -} from "../cloudchamber/build"; -import { handleFailure } from "../cloudchamber/common"; -import { imagesCommand } from "../cloudchamber/images/images"; -import { - deleteCommand, - deleteYargs, - infoCommand, - infoYargs, - listCommand, - listYargs, -} from "./containers"; -import { registryCommands } from "./registries"; -import { sshCommand, sshYargs } from "./ssh"; -import type { CommonYargsArgv, CommonYargsOptions } from "../yargs-types"; -import type { CommandModule } from "yargs"; +import { createNamespace } from "../core/create-command"; export const containersScope = "containers:write" as const; -export const containers = ( - yargs: CommonYargsArgv, - subHelp: CommandModule -) => { - return yargs - .command( - "build PATH", - "Build a container image", - (args) => buildYargs(args), - (args) => - handleFailure( - `wrangler containers build`, - buildCommand, - containersScope - )(args) - ) - .command( - "push TAG", - "Push a tagged image to a Cloudflare managed registry", - (args) => pushYargs(args), - (args) => - handleFailure( - `wrangler containers push`, - pushCommand, - containersScope - )(args) - ) - .command( - "images", - "Perform operations on images in your Cloudflare managed registry", - (args) => imagesCommand(args, containersScope).command(subHelp) - ) - .command( - "info ID", - "Get information about a specific container", - (args) => infoYargs(args), - (args) => - handleFailure( - `wrangler containers info`, - infoCommand, - containersScope - )(args) - ) - .command( - "list", - "List containers", - (args) => listYargs(args), - (args) => - handleFailure( - `wrangler containers list`, - listCommand, - containersScope - )(args) - ) - .command( - "delete ID", - "Delete a container", - (args) => deleteYargs(args), - (args) => - handleFailure( - `wrangler containers delete`, - deleteCommand, - containersScope - )(args) - ) - .command( - "ssh ID", - // "SSH into a container", - false, // hides it for now so it doesn't show up in help until it is ready - (args) => sshYargs(args), - (args) => - handleFailure( - `wrangler containers ssh`, - sshCommand, - containersScope - )(args) - ) - .command( - "registries", - // hide for now so it doesn't show up in help while not publicly available - // "Configure and manage non-Cloudflare registries", - false, - (args) => registryCommands(args).command(subHelp) - ); -}; +// --- Namespace definition --- +export const containersNamespace = createNamespace({ + metadata: { + description: "📦 Manage Containers", + status: "open beta", + owner: "Product: Cloudchamber", + }, +}); + +// --- Re-export commands from their respective files --- +export { + containersListCommand, + containersInfoCommand, + containersDeleteCommand, +} from "./containers"; + +export { containersSshCommand } from "./ssh"; + +export { + containersRegistriesNamespace, + containersRegistriesConfigureCommand, + containersRegistriesListCommand, + containersRegistriesDeleteCommand, +} from "./registries"; + +// Build and push commands +export { containersBuildCommand, containersPushCommand } from "./build"; + +// Images commands +export { + containersImagesNamespace, + containersImagesListCommand, + containersImagesDeleteCommand, +} from "./images"; diff --git a/packages/wrangler/src/containers/registries.ts b/packages/wrangler/src/containers/registries.ts index 054081da45ba..b7a72cb830c0 100644 --- a/packages/wrangler/src/containers/registries.ts +++ b/packages/wrangler/src/containers/registries.ts @@ -15,7 +15,11 @@ import { getCloudflareComplianceRegion, UserError, } from "@cloudflare/workers-utils"; -import { handleFailure, promiseSpinner } from "../cloudchamber/common"; +import { + fillOpenAPIConfiguration, + promiseSpinner, +} from "../cloudchamber/common"; +import { createCommand, createNamespace } from "../core/create-command"; import { confirm, prompt } from "../dialogs"; import { isNonInteractiveOrCI } from "../is-interactive"; import { logger } from "../logger"; @@ -32,43 +36,7 @@ import type { import type { ImageRegistryAuth } from "@cloudflare/containers-shared/src/client/models/ImageRegistryAuth"; import type { Config } from "@cloudflare/workers-utils"; -export const registryCommands = (yargs: CommonYargsArgv) => { - return yargs - .command( - "configure ", - "Configure credentials for a non-Cloudflare container registry", - (args) => registryConfigureYargs(args), - (args) => - handleFailure( - `wrangler containers registries configure`, - registryConfigureCommand, - containersScope - )(args) - ) - .command( - "list", - "List all configured container registries", - (args) => registryListYargs(args), - (args) => - handleFailure( - `wrangler containers registries list`, - registryListCommand, - containersScope - )(args) - ) - .command( - "delete ", - "Delete a configured container registry", - (args) => registryDeleteYargs(args), - (args) => - handleFailure( - `wrangler containers registries delete`, - registryDeleteCommand, - containersScope - )(args) - ); -}; -function registryConfigureYargs(args: CommonYargsArgv) { +function _registryConfigureYargs(args: CommonYargsArgv) { return ( args .positional("DOMAIN", { @@ -110,7 +78,7 @@ function registryConfigureYargs(args: CommonYargsArgv) { } async function registryConfigureCommand( - configureArgs: StrictYargsOptionsToInterface, + configureArgs: StrictYargsOptionsToInterface, config: Config ) { startSection("Configure a container registry"); @@ -272,7 +240,7 @@ async function getSecret(secretType?: string): Promise { return secret; } -function registryListYargs(args: CommonYargsArgv) { +function _registryListYargs(args: CommonYargsArgv) { return args.option("json", { type: "boolean", description: "Format output as JSON", @@ -281,7 +249,7 @@ function registryListYargs(args: CommonYargsArgv) { } async function registryListCommand( - listArgs: StrictYargsOptionsToInterface + listArgs: StrictYargsOptionsToInterface ) { if (!listArgs.json && !isNonInteractiveOrCI()) { startSection("List configured container registries"); @@ -313,7 +281,7 @@ async function registryListCommand( } } -const registryDeleteYargs = (yargs: CommonYargsArgv) => { +const _registryDeleteYargs = (yargs: CommonYargsArgv) => { return yargs .positional("DOMAIN", { describe: "domain of the registry to delete", @@ -328,7 +296,7 @@ const registryDeleteYargs = (yargs: CommonYargsArgv) => { }); }; async function registryDeleteCommand( - deleteArgs: StrictYargsOptionsToInterface + deleteArgs: StrictYargsOptionsToInterface ) { startSection(`Delete registry ${deleteArgs.DOMAIN}`); @@ -364,3 +332,114 @@ async function registryDeleteCommand( endSection(`Deleted registry ${deleteArgs.DOMAIN}\n`); } + +// --- New defineCommand-based commands --- + +export const containersRegistriesNamespace = createNamespace({ + metadata: { + description: "Configure and manage non-Cloudflare registries", + status: "open beta", + owner: "Product: Cloudchamber", + hidden: true, + }, +}); + +export const containersRegistriesConfigureCommand = createCommand({ + metadata: { + description: + "Configure credentials for a non-Cloudflare container registry", + status: "open beta", + owner: "Product: Cloudchamber", + hidden: true, + }, + args: { + DOMAIN: { + describe: "Domain to configure for the registry", + type: "string", + demandOption: true, + }, + "public-credential": { + type: "string", + description: + "The public part of the registry credentials, e.g. `AWS_ACCESS_KEY_ID` for ECR", + demandOption: true, + alias: "aws-access-key-id", + }, + "secret-store-id": { + type: "string", + description: + "The ID of the secret store to use to store the registry credentials.", + demandOption: false, + conflicts: "disableSecretsStore", + }, + "secret-name": { + type: "string", + description: + "The name for the secret the private registry credentials should be stored under.", + demandOption: false, + conflicts: "disableSecretsStore", + }, + disableSecretsStore: { + type: "boolean", + description: + "Whether to disable secrets store integration. This should be set iff the compliance region is FedRAMP High.", + demandOption: false, + conflicts: ["secret-store-id", "secret-name"], + }, + }, + positionalArgs: ["DOMAIN"], + async handler(args, { config }) { + await fillOpenAPIConfiguration(config, containersScope); + await registryConfigureCommand(args, config); + }, +}); + +export const containersRegistriesListCommand = createCommand({ + metadata: { + description: "List all configured container registries", + status: "open beta", + owner: "Product: Cloudchamber", + hidden: true, + }, + behaviour: { + printBanner: (args) => !args.json && !isNonInteractiveOrCI(), + }, + args: { + json: { + type: "boolean", + description: "Format output as JSON", + default: false, + }, + }, + async handler(args, { config }) { + await fillOpenAPIConfiguration(config, containersScope); + await registryListCommand(args); + }, +}); + +export const containersRegistriesDeleteCommand = createCommand({ + metadata: { + description: "Delete a configured container registry", + status: "open beta", + owner: "Product: Cloudchamber", + hidden: true, + }, + args: { + DOMAIN: { + describe: "Domain of the registry to delete", + type: "string", + demandOption: true, + }, + "skip-confirmation": { + type: "boolean", + description: "Skip confirmation prompt", + alias: "y", + default: false, + }, + }, + positionalArgs: ["DOMAIN"], + async handler(args, { config }) { + await fillOpenAPIConfiguration(config, containersScope); + await registryDeleteCommand(args); + }, +}); diff --git a/packages/wrangler/src/containers/ssh.ts b/packages/wrangler/src/containers/ssh.ts index 9babc86777cc..c5eac87d49a8 100644 --- a/packages/wrangler/src/containers/ssh.ts +++ b/packages/wrangler/src/containers/ssh.ts @@ -5,8 +5,13 @@ import { bold } from "@cloudflare/cli/colors"; import { ApiError, DeploymentsService } from "@cloudflare/containers-shared"; import { UserError } from "@cloudflare/workers-utils"; import { WebSocket } from "ws"; -import { promiseSpinner } from "../cloudchamber/common"; +import { + fillOpenAPIConfiguration, + promiseSpinner, +} from "../cloudchamber/common"; +import { createCommand } from "../core/create-command"; import { logger } from "../logger"; +import { containersScope } from "./index"; import type { CommonYargsArgv, StrictYargsOptionsToInterface, @@ -321,3 +326,74 @@ function buildSshArgs( return flags; } + +// --- New defineCommand-based command --- + +export const containersSshCommand = createCommand({ + metadata: { + description: "SSH into a container", + status: "open beta", + owner: "Product: Cloudchamber", + hidden: true, + }, + args: { + ID: { + describe: "ID of the container instance", + type: "string", + demandOption: true, + }, + cipher: { + describe: + "Sets `ssh -c`: Select the cipher specification for encrypting the session", + type: "string", + }, + "log-file": { + describe: + "Sets `ssh -E`: Append debug logs to log_file instead of standard error", + type: "string", + }, + "escape-char": { + describe: + "Sets `ssh -e`: Set the escape character for sessions with a pty (default: '~')", + type: "string", + }, + "config-file": { + alias: "F", + describe: + "Sets `ssh -F`: Specify an alternative per-user ssh configuration file", + type: "string", + }, + pkcs11: { + describe: + "Sets `ssh -I`: Specify the PKCS#11 shared library ssh should use to communicate with a PKCS#11 token providing keys for user authentication", + type: "string", + }, + "identity-file": { + alias: "i", + describe: + "Sets `ssh -i`: Select a file from which the identity (private key) for public key authentication is read", + type: "string", + }, + "mac-spec": { + describe: + "Sets `ssh -m`: A comma-separated list of MAC (message authentication code) algorithms, specified in order of preference", + type: "string", + }, + option: { + alias: "o", + describe: + "Sets `ssh -o`: Set options in the format used in the ssh configuration file. May be repeated", + type: "string", + }, + tag: { + describe: + "Sets `ssh -P`: Specify a tag name that may be used to select configuration in ssh_config", + type: "string", + }, + }, + positionalArgs: ["ID"], + async handler(args, { config }) { + await fillOpenAPIConfiguration(config, containersScope); + await sshCommand(args, config); + }, +}); diff --git a/packages/wrangler/src/index.ts b/packages/wrangler/src/index.ts index bc14d9dd4e26..cd7932a62a63 100644 --- a/packages/wrangler/src/index.ts +++ b/packages/wrangler/src/index.ts @@ -25,10 +25,46 @@ import { certUploadNamespace, } from "./cert/cert"; import { checkNamespace, checkStartupCommand } from "./check/commands"; -import { cloudchamber } from "./cloudchamber"; +import { + cloudchamberApplyCommand, + cloudchamberBuildCommand, + cloudchamberCreateCommand, + cloudchamberCurlCommand, + cloudchamberDeleteCommand, + cloudchamberImagesDeleteCommand, + cloudchamberImagesListCommand, + cloudchamberImagesNamespace, + cloudchamberListCommand, + cloudchamberModifyCommand, + cloudchamberNamespace, + cloudchamberPushCommand, + cloudchamberRegistriesConfigureCommand, + cloudchamberRegistriesCredentialsCommand, + cloudchamberRegistriesListCommand, + cloudchamberRegistriesNamespace, + cloudchamberRegistriesRemoveCommand, + cloudchamberSshCreateCommand, + cloudchamberSshListCommand, + cloudchamberSshNamespace, +} from "./cloudchamber"; import { completionsCommand } from "./complete"; import { getDefaultEnvFiles, loadDotEnv } from "./config/dot-env"; -import { containers } from "./containers"; +import { + containersBuildCommand, + containersDeleteCommand, + containersImagesDeleteCommand, + containersImagesListCommand, + containersImagesNamespace, + containersInfoCommand, + containersListCommand, + containersNamespace, + containersPushCommand, + containersRegistriesConfigureCommand, + containersRegistriesDeleteCommand, + containersRegistriesListCommand, + containersRegistriesNamespace, + containersSshCommand, +} from "./containers"; import { demandSingleValue } from "./core"; import { CommandHandledError } from "./core/CommandHandledError"; import { CommandRegistry } from "./core/CommandRegistry"; @@ -1357,18 +1393,133 @@ export function createCLIParser(argv: string[]) { registry.registerNamespace("mtls-certificate"); // cloudchamber - wrangler.command("cloudchamber", false, (cloudchamberArgs) => { - return cloudchamber(cloudchamberArgs.command(subHelp), subHelp); - }); + registry.define([ + { command: "wrangler cloudchamber", definition: cloudchamberNamespace }, + { + command: "wrangler cloudchamber list", + definition: cloudchamberListCommand, + }, + { + command: "wrangler cloudchamber create", + definition: cloudchamberCreateCommand, + }, + { + command: "wrangler cloudchamber delete", + definition: cloudchamberDeleteCommand, + }, + { + command: "wrangler cloudchamber modify", + definition: cloudchamberModifyCommand, + }, + { + command: "wrangler cloudchamber apply", + definition: cloudchamberApplyCommand, + }, + { + command: "wrangler cloudchamber curl", + definition: cloudchamberCurlCommand, + }, + { + command: "wrangler cloudchamber build", + definition: cloudchamberBuildCommand, + }, + { + command: "wrangler cloudchamber push", + definition: cloudchamberPushCommand, + }, + { + command: "wrangler cloudchamber ssh", + definition: cloudchamberSshNamespace, + }, + { + command: "wrangler cloudchamber ssh list", + definition: cloudchamberSshListCommand, + }, + { + command: "wrangler cloudchamber ssh create", + definition: cloudchamberSshCreateCommand, + }, + { + command: "wrangler cloudchamber registries", + definition: cloudchamberRegistriesNamespace, + }, + { + command: "wrangler cloudchamber registries configure", + definition: cloudchamberRegistriesConfigureCommand, + }, + { + command: "wrangler cloudchamber registries credentials", + definition: cloudchamberRegistriesCredentialsCommand, + }, + { + command: "wrangler cloudchamber registries remove", + definition: cloudchamberRegistriesRemoveCommand, + }, + { + command: "wrangler cloudchamber registries list", + definition: cloudchamberRegistriesListCommand, + }, + { + command: "wrangler cloudchamber images", + definition: cloudchamberImagesNamespace, + }, + { + command: "wrangler cloudchamber images list", + definition: cloudchamberImagesListCommand, + }, + { + command: "wrangler cloudchamber images delete", + definition: cloudchamberImagesDeleteCommand, + }, + ]); + registry.registerNamespace("cloudchamber"); // containers - wrangler.command( - "containers", - `📦 Manage Containers ${chalk.hex(betaCmdColor)("[open beta]")}`, - (containersArgs) => { - return containers(containersArgs.command(subHelp), subHelp); - } - ); + registry.define([ + { command: "wrangler containers", definition: containersNamespace }, + { command: "wrangler containers list", definition: containersListCommand }, + { command: "wrangler containers info", definition: containersInfoCommand }, + { + command: "wrangler containers delete", + definition: containersDeleteCommand, + }, + { command: "wrangler containers ssh", definition: containersSshCommand }, + { + command: "wrangler containers build", + definition: containersBuildCommand, + }, + { command: "wrangler containers push", definition: containersPushCommand }, + { + command: "wrangler containers registries", + definition: containersRegistriesNamespace, + }, + { + command: "wrangler containers registries configure", + definition: containersRegistriesConfigureCommand, + }, + { + command: "wrangler containers registries list", + definition: containersRegistriesListCommand, + }, + { + command: "wrangler containers registries delete", + definition: containersRegistriesDeleteCommand, + }, + { + command: "wrangler containers images", + definition: containersImagesNamespace, + }, + { + command: "wrangler containers images list", + definition: containersImagesListCommand, + }, + { + command: "wrangler containers images delete", + definition: containersImagesDeleteCommand, + }, + ]); + registry.registerNamespace("containers"); + registry.registerLegacyCommandCategory("containers", "Compute & AI"); // [PRIVATE BETA] pubsub wrangler.command( @@ -1727,7 +1878,6 @@ export function createCLIParser(argv: string[]) { // This set to false to allow overwrite of default behaviour wrangler.version(false); - registry.registerLegacyCommandCategory("containers", "Compute & AI"); registry.registerLegacyCommandCategory("pubsub", "Compute & AI"); registry.registerAll(); @@ -1776,9 +1926,6 @@ export async function main(argv: string[]): Promise { // Record command as Sentry breadcrumb const command = `wrangler ${args._.join(" ")}`; addBreadcrumb(command); - - // TODO: Legacy commands (cloudchamber, containers) don't use defineCommand - // and won't emit telemetry events. Migrate them to defineCommand to enable telemetry. }, /* applyBeforeValidation */ true); const startTime = Date.now();