diff --git a/packages/wrangler/src/__tests__/cloudchamber/create.test.ts b/packages/wrangler/src/__tests__/cloudchamber/create.test.ts index eda9c5261c37..f67c7f25e264 100644 --- a/packages/wrangler/src/__tests__/cloudchamber/create.test.ts +++ b/packages/wrangler/src/__tests__/cloudchamber/create.test.ts @@ -1,3 +1,5 @@ +import * as fs from "node:fs"; +import * as TOML from "@iarna/toml"; import { http, HttpResponse } from "msw"; import patchConsole from "patch-console"; import { mockAccountId, mockApiToken } from "../helpers/mock-account-id"; @@ -10,6 +12,58 @@ import { runWrangler } from "../helpers/run-wrangler"; import { mockAccount, setWranglerConfig } from "./utils"; import type { SSHPublicKeyItem } from "../../cloudchamber/client"; +const MOCK_DEPLOYMENTS_COMPLEX_RESPONSE = ` + "{ + \\"id\\": \\"1\\", + \\"type\\": \\"default\\", + \\"created_at\\": \\"123\\", + \\"account_id\\": \\"123\\", + \\"vcpu\\": 4, + \\"memory\\": \\"400MB\\", + \\"version\\": 1, + \\"image\\": \\"hello\\", + \\"location\\": { + \\"name\\": \\"sfo06\\", + \\"enabled\\": true + }, + \\"network\\": { + \\"ipv4\\": \\"1.1.1.1\\" + }, + \\"placements_ref\\": \\"http://ref\\", + \\"node_group\\": \\"metal\\" + }" + `; + +function mockDeploymentPost() { + msw.use( + http.post( + "*/deployments/v2", + async ({ request }) => { + expect(await request.text()).toMatchInlineSnapshot( + `"{\\"image\\":\\"hello:world\\",\\"location\\":\\"sfo06\\",\\"ssh_public_key_ids\\":[],\\"environment_variables\\":[{\\"name\\":\\"HELLO\\",\\"value\\":\\"WORLD\\"},{\\"name\\":\\"YOU\\",\\"value\\":\\"CONQUERED\\"}],\\"vcpu\\":3,\\"memory\\":\\"400GB\\",\\"network\\":{\\"assign_ipv4\\":\\"predefined\\"}}"` + ); + return HttpResponse.json(MOCK_DEPLOYMENTS_COMPLEX[0]); + }, + { once: true } + ) + ); +} + +function mockGetKey() { + msw.use( + http.get( + "*/ssh-public-keys", + async () => { + const keys = [ + { id: "1", name: "hello", public_key: "hello-world" }, + ] as SSHPublicKeyItem[]; + return HttpResponse.json(keys); + }, + { once: true } + ) + ); +} + describe("cloudchamber create", () => { const std = mockConsoleMethods(); const { setIsTTY } = useMockIsTTY(); @@ -62,6 +116,25 @@ describe("cloudchamber create", () => { ); }); + it("should fail with a nice message when parameters are mistyped", async () => { + setIsTTY(false); + fs.writeFileSync( + "./wrangler.toml", + TOML.stringify({ + name: "my-container", + cloudchamber: { image: true }, + }), + + "utf-8" + ); + await expect( + runWrangler("cloudchamber create ") + ).rejects.toThrowErrorMatchingInlineSnapshot( + ` [Error: Processing wrangler.toml configuration: + - "cloudchamber" bindings should, optionally, have a string "image" field but got {"image":true}.]` + ); + }); + it("should fail with a nice message when parameters are missing (json)", async () => { setIsTTY(false); setWranglerConfig({}); @@ -75,57 +148,37 @@ describe("cloudchamber create", () => { it("should create deployment (detects no interactivity)", async () => { setIsTTY(false); setWranglerConfig({}); - msw.use( - http.get( - "*/ssh-public-keys", - async () => { - const keys = [ - { id: "1", name: "hello", public_key: "hello-world" }, - ] as SSHPublicKeyItem[]; - return HttpResponse.json(keys); - }, - { once: true } - ) - ); - msw.use( - http.post( - "*/deployments/v2", - async ({ request }) => { - expect(await request.text()).toMatchInlineSnapshot( - `"{\\"image\\":\\"hello:world\\",\\"location\\":\\"sfo06\\",\\"ssh_public_key_ids\\":[],\\"environment_variables\\":[{\\"name\\":\\"HELLO\\",\\"value\\":\\"WORLD\\"},{\\"name\\":\\"YOU\\",\\"value\\":\\"CONQUERED\\"}],\\"vcpu\\":3,\\"memory\\":\\"400GB\\",\\"network\\":{\\"assign_ipv4\\":\\"predefined\\"}}"` - ); - return HttpResponse.json(MOCK_DEPLOYMENTS_COMPLEX[0]); - }, - { once: true } - ) - ); + mockGetKey(); + mockDeploymentPost(); expect(std.err).toMatchInlineSnapshot(`""`); await runWrangler( "cloudchamber create --image hello:world --location sfo06 --var HELLO:WORLD --var YOU:CONQUERED --vcpu 3 --memory 400GB --ipv4 true" ); // 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\\", - \\"version\\": 1, - \\"image\\": \\"hello\\", - \\"location\\": { - \\"name\\": \\"sfo06\\", - \\"enabled\\": true - }, - \\"network\\": { - \\"ipv4\\": \\"1.1.1.1\\" - }, - \\"placements_ref\\": \\"http://ref\\", - \\"node_group\\": \\"metal\\" - }" - `); + expect(std.out).toMatchInlineSnapshot(MOCK_DEPLOYMENTS_COMPLEX_RESPONSE); + }); + + it("properly reads wrangler config", async () => { + // This is very similar to the previous test except config + // is set in wrangler and not overridden by the CLI + setIsTTY(false); + setWranglerConfig({ + image: "hello:world", + ipv4: true, + vcpu: 3, + memory: "400GB", + location: "sfo06", + }); + // if values are not read by wrangler, this mock won't work + // since the wrangler command wont get the right parameters + mockGetKey(); + mockDeploymentPost(); + await runWrangler( + "cloudchamber create --var HELLO:WORLD --var YOU:CONQUERED" + ); + expect(std.out).toMatchInlineSnapshot(MOCK_DEPLOYMENTS_COMPLEX_RESPONSE); + expect(std.err).toMatchInlineSnapshot(`""`); }); it("should create deployment indicating ssh keys (detects no interactivity)", async () => { @@ -134,18 +187,7 @@ describe("cloudchamber create", () => { vcpu: 40, memory: "300MB", }); - msw.use( - http.get( - "*/ssh-public-keys", - async () => { - const keys = [ - { id: "1", name: "hello", public_key: "hello-world" }, - ] as SSHPublicKeyItem[]; - return HttpResponse.json(keys); - }, - { once: true } - ) - ); + mockGetKey(); msw.use( http.post( "*/deployments/v2", @@ -161,27 +203,7 @@ 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(` - "{ - \\"id\\": \\"1\\", - \\"type\\": \\"default\\", - \\"created_at\\": \\"123\\", - \\"account_id\\": \\"123\\", - \\"vcpu\\": 4, - \\"memory\\": \\"400MB\\", - \\"version\\": 1, - \\"image\\": \\"hello\\", - \\"location\\": { - \\"name\\": \\"sfo06\\", - \\"enabled\\": true - }, - \\"network\\": { - \\"ipv4\\": \\"1.1.1.1\\" - }, - \\"placements_ref\\": \\"http://ref\\", - \\"node_group\\": \\"metal\\" - }" - `); + expect(std.out).toMatchInlineSnapshot(MOCK_DEPLOYMENTS_COMPLEX_RESPONSE); }); it("can't create deployment due to lack of fields (json)", async () => { diff --git a/packages/wrangler/src/__tests__/cloudchamber/modify.test.ts b/packages/wrangler/src/__tests__/cloudchamber/modify.test.ts index 1027c4bc9b9f..b46cc2462245 100644 --- a/packages/wrangler/src/__tests__/cloudchamber/modify.test.ts +++ b/packages/wrangler/src/__tests__/cloudchamber/modify.test.ts @@ -9,6 +9,43 @@ import { runInTempDir } from "../helpers/run-in-tmp"; import { runWrangler } from "../helpers/run-wrangler"; import { mockAccount, setWranglerConfig } from "./utils"; +function mockDeployment() { + msw.use( + http.patch( + "*/deployments/1234/v2", + async ({ request }) => { + expect(await request.text()).toMatchInlineSnapshot( + `"{\\"image\\":\\"hello:modify\\",\\"location\\":\\"sfo06\\",\\"environment_variables\\":[{\\"name\\":\\"HELLO\\",\\"value\\":\\"WORLD\\"},{\\"name\\":\\"YOU\\",\\"value\\":\\"CONQUERED\\"}],\\"vcpu\\":3,\\"memory\\":\\"40MB\\"}"` + ); + return HttpResponse.json(MOCK_DEPLOYMENTS_COMPLEX[0]); + }, + { once: true } + ) + ); +} + +const EXPECTED_RESULT = ` + "{ + \\"id\\": \\"1\\", + \\"type\\": \\"default\\", + \\"created_at\\": \\"123\\", + \\"account_id\\": \\"123\\", + \\"vcpu\\": 4, + \\"memory\\": \\"400MB\\", + \\"version\\": 1, + \\"image\\": \\"hello\\", + \\"location\\": { + \\"name\\": \\"sfo06\\", + \\"enabled\\": true + }, + \\"network\\": { + \\"ipv4\\": \\"1.1.1.1\\" + }, + \\"placements_ref\\": \\"http://ref\\", + \\"node_group\\": \\"metal\\" + }" + `; + describe("cloudchamber modify", () => { const std = mockConsoleMethods(); const { setIsTTY } = useMockIsTTY(); @@ -55,45 +92,30 @@ describe("cloudchamber modify", () => { it("should modify deployment (detects no interactivity)", async () => { setIsTTY(false); setWranglerConfig({}); - msw.use( - http.patch( - "*/deployments/1234/v2", - async ({ request }) => { - expect(await request.text()).toMatchInlineSnapshot( - `"{\\"image\\":\\"hello:modify\\",\\"environment_variables\\":[{\\"name\\":\\"HELLO\\",\\"value\\":\\"WORLD\\"},{\\"name\\":\\"YOU\\",\\"value\\":\\"CONQUERED\\"}],\\"vcpu\\":3,\\"memory\\":\\"40MB\\"}"` - ); - return HttpResponse.json(MOCK_DEPLOYMENTS_COMPLEX[0]); - }, - { once: true } - ) - ); + mockDeployment(); await runWrangler( - "cloudchamber modify 1234 --image hello:modify --var HELLO:WORLD --var YOU:CONQUERED --vcpu 3 --memory 40MB" + "cloudchamber modify 1234 --image hello:modify --location sfo06 --var HELLO:WORLD --var YOU:CONQUERED --vcpu 3 --memory 40MB" ); 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(` - "{ - \\"id\\": \\"1\\", - \\"type\\": \\"default\\", - \\"created_at\\": \\"123\\", - \\"account_id\\": \\"123\\", - \\"vcpu\\": 4, - \\"memory\\": \\"400MB\\", - \\"version\\": 1, - \\"image\\": \\"hello\\", - \\"location\\": { - \\"name\\": \\"sfo06\\", - \\"enabled\\": true - }, - \\"network\\": { - \\"ipv4\\": \\"1.1.1.1\\" - }, - \\"placements_ref\\": \\"http://ref\\", - \\"node_group\\": \\"metal\\" - }" - `); + expect(std.out).toMatchInlineSnapshot(EXPECTED_RESULT); + }); + + it("should modify deployment with wrangler args (detects no interactivity)", async () => { + setIsTTY(false); + setWranglerConfig({ + image: "hello:modify", + vcpu: 3, + memory: "40MB", + location: "sfo06", + }); + mockDeployment(); + await runWrangler( + "cloudchamber modify 1234 --var HELLO:WORLD --var YOU:CONQUERED" + ); + expect(std.err).toMatchInlineSnapshot(`""`); + expect(std.out).toMatchInlineSnapshot(EXPECTED_RESULT); }); it("can't modify deployment due to lack of deploymentId (json)", async () => { diff --git a/packages/wrangler/src/__tests__/configuration.test.ts b/packages/wrangler/src/__tests__/configuration.test.ts index 72f554c8ed2a..1e852e0ee260 100644 --- a/packages/wrangler/src/__tests__/configuration.test.ts +++ b/packages/wrangler/src/__tests__/configuration.test.ts @@ -2068,6 +2068,90 @@ describe("normalizeAndValidateConfig()", () => { }); }); + describe("[cloudchamber]", () => { + it("should error if cloudchamber is null", () => { + const { diagnostics } = normalizeAndValidateConfig( + { cloudchamber: null } as unknown as RawConfig, + undefined, + { env: undefined } + ); + + expect(diagnostics.hasWarnings()).toBe(false); + expect(diagnostics.renderErrors()).toMatchInlineSnapshot(` + "Processing wrangler configuration: + - \\"cloudchamber\\" should be an object, but got null" + `); + }); + + it("should error if cloudchamber is an array", () => { + const { diagnostics } = normalizeAndValidateConfig( + { cloudchamber: [] } as unknown as RawConfig, + undefined, + { env: undefined } + ); + + expect(diagnostics.hasWarnings()).toBe(false); + expect(diagnostics.renderErrors()).toMatchInlineSnapshot(` + "Processing wrangler configuration: + - \\"cloudchamber\\" should be an object, but got []" + `); + }); + + it("should error if cloudchamber is a string", () => { + const { diagnostics } = normalizeAndValidateConfig( + { cloudchamber: "test" } as unknown as RawConfig, + undefined, + { env: undefined } + ); + + expect(diagnostics.hasWarnings()).toBe(false); + expect(diagnostics.renderErrors()).toMatchInlineSnapshot(` + "Processing wrangler configuration: + - \\"cloudchamber\\" should be an object, but got \\"test\\"" + `); + }); + + it("should error if cloudchamber is a number", () => { + const { diagnostics } = normalizeAndValidateConfig( + { cloudchamber: 22 } as unknown as RawConfig, + undefined, + { env: undefined } + ); + + expect(diagnostics.hasWarnings()).toBe(false); + expect(diagnostics.renderErrors()).toMatchInlineSnapshot(` + "Processing wrangler configuration: + - \\"cloudchamber\\" should be an object, but got 22" + `); + }); + + it("should error if cloudchamber bindings are not valid", () => { + const { diagnostics } = normalizeAndValidateConfig( + { + cloudchamber: { + image: 123, // should be a string + location: 123, // should be a string + vcpu: "invalid", // should be a number + memory: 123, // should be a string + ipv4: "invalid", // should be a boolean + }, + } as unknown as RawConfig, + undefined, + { env: undefined } + ); + + expect(diagnostics.hasWarnings()).toBe(false); + expect(diagnostics.renderErrors()).toMatchInlineSnapshot(` + "Processing wrangler configuration: + - \\"cloudchamber\\" bindings should, optionally, have a string \\"memory\\" field but got {\\"image\\":123,\\"location\\":123,\\"vcpu\\":\\"invalid\\",\\"memory\\":123,\\"ipv4\\":\\"invalid\\"}. + - \\"cloudchamber\\" bindings should, optionally, have a string \\"image\\" field but got {\\"image\\":123,\\"location\\":123,\\"vcpu\\":\\"invalid\\",\\"memory\\":123,\\"ipv4\\":\\"invalid\\"}. + - \\"cloudchamber\\" bindings should, optionally, have a string \\"location\\" field but got {\\"image\\":123,\\"location\\":123,\\"vcpu\\":\\"invalid\\",\\"memory\\":123,\\"ipv4\\":\\"invalid\\"}. + - \\"cloudchamber\\" bindings should, optionally, have a boolean \\"ipv4\\" field but got {\\"image\\":123,\\"location\\":123,\\"vcpu\\":\\"invalid\\",\\"memory\\":123,\\"ipv4\\":\\"invalid\\"}. + - \\"cloudchamber\\" bindings should, optionally, have a number \\"vcpu\\" field but got {\\"image\\":123,\\"location\\":123,\\"vcpu\\":\\"invalid\\",\\"memory\\":123,\\"ipv4\\":\\"invalid\\"}." + `); + }); + }); + describe("[kv_namespaces]", () => { it("should error if kv_namespaces is an object", () => { const { diagnostics } = normalizeAndValidateConfig( diff --git a/packages/wrangler/src/cloudchamber/create.ts b/packages/wrangler/src/cloudchamber/create.ts index f97d6647cd9f..c5fa9774504a 100644 --- a/packages/wrangler/src/cloudchamber/create.ts +++ b/packages/wrangler/src/cloudchamber/create.ts @@ -111,14 +111,24 @@ export async function createCommand( args.var ); const labels = collectLabels(args.label); - if (!interactWithUser(args)) { + if (config.cloudchamber.image != undefined && args.image == undefined) { + args.image = config.cloudchamber.image; + } + if ( + config.cloudchamber.location != undefined && + args.location == undefined + ) { + args.location = config.cloudchamber.location; + } + const body = checkEverythingIsSet(args, ["image", "location"]); const keysToAdd = args.allSshKeys ? (await pollSSHKeysUntilCondition(() => true)).map((key) => key.id) : []; + const useIpv4 = args.ipv4 ?? config.cloudchamber.ipv4; const network = - args.ipv4 === true ? { assign_ipv4: AssignIPv4.PREDEFINED } : undefined; + useIpv4 === true ? { assign_ipv4: AssignIPv4.PREDEFINED } : undefined; const deployment = await DeploymentsService.createDeploymentV2({ image: body.image, location: body.location, @@ -223,7 +233,8 @@ export async function handleCreateCommand( ) { startSection("Create a Cloudflare container", "Step 1 of 2"); const sshKeyID = await promptForSSHKeyAndGetAddedSSHKey(args); - const image = await processArgument({ image: args.image }, "image", { + const givenImage = args.image ?? config.cloudchamber.image; + const image = await processArgument({ image: givenImage }, "image", { question: whichImageQuestion, label: "image", validate: (value) => { @@ -237,15 +248,19 @@ export async function handleCreateCommand( return "we don't allow :latest tags"; } }, - defaultValue: args.image ?? "", - initialValue: args.image ?? "", + defaultValue: givenImage ?? "", + initialValue: givenImage ?? "", helpText: 'i.e. "docker.io/org/app:1.2", :latest tags are not allowed!', type: "text", }); - const location = await getLocation(args); + const location = await getLocation({ + location: args.location ?? config.cloudchamber.location, + }); const keys = await askWhichSSHKeysDoTheyWantToAdd(args, sshKeyID); - const network = await getNetworkInput(args); + const network = await getNetworkInput({ + ipv4: args.ipv4 ?? config.cloudchamber.ipv4, + }); const selectedEnvironmentVariables = await promptForEnvironmentVariables( environmentVariables, diff --git a/packages/wrangler/src/cloudchamber/modify.ts b/packages/wrangler/src/cloudchamber/modify.ts index f94b441a6a11..ed06e02c68f4 100644 --- a/packages/wrangler/src/cloudchamber/modify.ts +++ b/packages/wrangler/src/cloudchamber/modify.ts @@ -105,8 +105,8 @@ export async function modifyCommand( const deployment = await DeploymentsService.modifyDeploymentV2( modifyArgs.deploymentId, { - image: modifyArgs.image, - location: modifyArgs.location, + image: modifyArgs.image ?? config.cloudchamber.image, + location: modifyArgs.location ?? config.cloudchamber.location, environment_variables: environmentVariables, labels: labels, ssh_public_key_ids: modifyArgs.sshPublicKeyId, @@ -186,8 +186,9 @@ async function handleModifyCommand( const deployment = await pickDeployment(args.deploymentId); const keys = await handleSSH(args, config, deployment); + const givenImage = args.image ?? config.cloudchamber.image; const imagePrompt = await processArgument( - { image: args.image }, + { image: givenImage }, "image", { question: modifyImageQuestion, @@ -200,15 +201,18 @@ async function handleModifyCommand( return "we don't allow :latest tags"; } }, - defaultValue: args.image ?? "", - initialValue: args.image ?? "", + defaultValue: givenImage ?? "", + initialValue: givenImage ?? "", helpText: "if you don't want to modify the image, press return", type: "text", } ); const image = !imagePrompt ? undefined : imagePrompt; - const locationPick = await getLocation(args, { skipLocation: true }); + const locationPick = await getLocation( + { location: args.location ?? config.cloudchamber.location }, + { skipLocation: true } + ); const location = locationPick === "Skip" ? undefined : locationPick; const environmentVariables = collectEnvironmentVariables( diff --git a/packages/wrangler/src/config/environment.ts b/packages/wrangler/src/config/environment.ts index a6324f4ecd8e..bf6020274842 100644 --- a/packages/wrangler/src/config/environment.ts +++ b/packages/wrangler/src/config/environment.ts @@ -32,8 +32,11 @@ export type Route = * Configuration in wrangler for Cloudchamber */ export type CloudchamberConfig = { + image?: string; + location?: string; vcpu?: number; memory?: string; + ipv4?: boolean; }; /** diff --git a/packages/wrangler/src/config/validation-helpers.ts b/packages/wrangler/src/config/validation-helpers.ts index 5d2fe74dce3a..d0ec2be44ea1 100644 --- a/packages/wrangler/src/config/validation-helpers.ts +++ b/packages/wrangler/src/config/validation-helpers.ts @@ -617,7 +617,7 @@ const isRecord = ( /** * JavaScript `typeof` operator return values. */ -type TypeofType = +export type TypeofType = | "string" | "number" | "bigint" diff --git a/packages/wrangler/src/config/validation.ts b/packages/wrangler/src/config/validation.ts index 1e199d505d8a..c676632fd917 100644 --- a/packages/wrangler/src/config/validation.ts +++ b/packages/wrangler/src/config/validation.ts @@ -39,7 +39,7 @@ import type { Rule, TailConsumer, } from "./environment"; -import type { ValidatorFn } from "./validation-helpers"; +import type { TypeofType, ValidatorFn } from "./validation-helpers"; export type NormalizeAndValidateConfigArgs = { name?: string; @@ -2282,25 +2282,33 @@ const validateBindingArray = }; const validateCloudchamberConfig: ValidatorFn = (diagnostics, field, value) => { - if (typeof value !== "object" || value === null) { + if (typeof value !== "object" || value === null || Array.isArray(value)) { diagnostics.errors.push( `"cloudchamber" should be an object, but got ${JSON.stringify(value)}` ); return false; } + const optionalAttrsByType = { + string: ["memory", "image", "location"], + boolean: ["ipv4"], + number: ["vcpu"], + }; + let isValid = true; - const requiredKeys: string[] = []; - requiredKeys.forEach((key) => { - if (!isRequiredProperty(value, key, "string")) { - diagnostics.errors.push( - `"${field}" bindings should have a string "${key}" field but got ${JSON.stringify( - value - )}.` - ); - isValid = false; - } + Object.entries(optionalAttrsByType).forEach(([attrType, attrNames]) => { + attrNames.forEach((key) => { + if (!isOptionalProperty(value, key, attrType as TypeofType)) { + diagnostics.errors.push( + `"${field}" bindings should, optionally, have a ${attrType} "${key}" field but got ${JSON.stringify( + value + )}.` + ); + isValid = false; + } + }); }); + return isValid; };