diff --git a/.changeset/hungry-turtles-beam.md b/.changeset/hungry-turtles-beam.md new file mode 100644 index 000000000000..6eb60a9faaa9 --- /dev/null +++ b/.changeset/hungry-turtles-beam.md @@ -0,0 +1,17 @@ +--- +"wrangler": patch +--- + +Add support for custom instance limits for containers. For example, instead of +having to use the preconfigured dev/standard/basic instance types, you can now +set: + +``` +instance_type: { + vcpu: 1, + memory_mib: 1024, + disk_mb: 4000 +} +``` + +This feature is currently only available to customers on an enterprise plan. diff --git a/packages/wrangler/src/__tests__/cloudchamber/apply.test.ts b/packages/wrangler/src/__tests__/cloudchamber/apply.test.ts index ba8164b86618..72da77363ffd 100644 --- a/packages/wrangler/src/__tests__/cloudchamber/apply.test.ts +++ b/packages/wrangler/src/__tests__/cloudchamber/apply.test.ts @@ -1614,6 +1614,63 @@ describe("cloudchamber apply", () => { expect(std.stderr).toMatchInlineSnapshot(`""`); }); + test("can apply a simple application (custom instance type)", async () => { + setIsTTY(false); + writeWranglerConfig({ + name: "my-container", + containers: [ + { + name: "my-container-app", + instances: 3, + class_name: "DurableObjectClass", + instance_type: { + vcpu: 1, + memory_mib: 1024, + disk_mb: 2000, + }, + image: "docker.io/beep:boop", + constraints: { + tier: 2, + }, + }, + ], + }); + mockGetApplications([]); + mockCreateApplication({ id: "abc" }); + await runWrangler("cloudchamber apply"); + expect(std.stdout).toMatchInlineSnapshot(` + "╭ Deploy a container application deploy changes to your application + │ + │ Container application changes + │ + ├ NEW my-container-app + │ + │ [[containers]] + │ name = \\"my-container-app\\" + │ instances = 3 + │ scheduling_policy = \\"default\\" + │ + │ [containers.constraints] + │ tier = 2 + │ + │ [containers.configuration] + │ image = \\"docker.io/beep:boop\\" + │ vcpu = 1 + │ memory_mib = 1_024 + │ + │ [containers.configuration.disk] + │ size_mb = 2_000 + │ + │ + │ SUCCESS Created application my-container-app (Application ID: abc) + │ + ╰ Applied changes + + " + `); + expect(std.stderr).toMatchInlineSnapshot(`""`); + }); + test("can apply a simple existing application (instance type)", async () => { setIsTTY(false); writeWranglerConfig({ @@ -1691,6 +1748,96 @@ describe("cloudchamber apply", () => { expect(app.configuration?.instance_type).toEqual("standard"); }); + test("can apply a simple existing application (custom instance type)", async () => { + setIsTTY(false); + writeWranglerConfig({ + name: "my-container", + containers: [ + { + name: "my-container-app", + instances: 4, + class_name: "DurableObjectClass", + instance_type: { + vcpu: 1, + memory_mib: 1024, + disk_mb: 6000, + }, + image: "docker.io/beep:boop", + constraints: { + tier: 2, + }, + }, + ], + }); + mockGetApplications([ + { + id: "abc", + name: "my-container-app", + instances: 3, + created_at: new Date().toString(), + version: 1, + account_id: "1", + scheduling_policy: SchedulingPolicy.REGIONAL, + configuration: { + image: "docker.io/beep:boop", + disk: { + size: "2GB", + size_mb: 2000, + }, + vcpu: 0.0625, + memory: "256MB", + memory_mib: 256, + }, + constraints: { + tier: 3, + }, + }, + ]); + const applicationReqBodyPromise = mockModifyApplication(); + await runWrangler("cloudchamber apply"); + expect(std.stdout).toMatchInlineSnapshot(` + "╭ Deploy a container application deploy changes to your application + │ + │ Container application changes + │ + ├ EDIT my-container-app + │ + │ [[containers]] + │ - instances = 3 + │ + instances = 4 + │ name = \\"my-container-app\\" + │ scheduling_policy = \\"regional\\" + │ + │ [containers.configuration] + │ image = \\"docker.io/beep:boop\\" + │ memory = \\"256MB\\" + │ - memory_mib = 256 + │ + memory_mib = 1_024 + │ + │ - vcpu = 0.0625 + │ + vcpu = 1 + │ + │ [containers.configuration.disk] + │ size = \\"2GB\\" + │ - size_mb = 2_000 + │ + size_mb = 6_000 + │ + │ [containers.constraints] + │ - tier = 3 + │ + tier = 2 + │ + │ + │ SUCCESS Modified application my-container-app + │ + ╰ Applied changes + + " + `); + expect(std.stderr).toMatchInlineSnapshot(`""`); + const app = await applicationReqBodyPromise; + expect(app.configuration?.instance_type).toBeUndefined(); + }); + test("falls back on dev instance type when instance type is absent", async () => { setIsTTY(false); writeWranglerConfig({ diff --git a/packages/wrangler/src/__tests__/config/configuration.test.ts b/packages/wrangler/src/__tests__/config/configuration.test.ts index 2f276455dbf2..7f8541dc2238 100644 --- a/packages/wrangler/src/__tests__/config/configuration.test.ts +++ b/packages/wrangler/src/__tests__/config/configuration.test.ts @@ -2454,10 +2454,10 @@ describe("normalizeAndValidateConfig()", () => { - The image \\"something\\" does not appear to be a valid path to a Dockerfile, or a valid image registry path: If this is an image registry path, it needs to include at least a tag ':' (e.g: docker.io/httpd:1) - Expected \\"containers.rollout_kind\\" field to be one of [\\"full_auto\\",\\"full_manual\\",\\"none\\"] but got \\"invalid\\". - - Expected \\"containers.instance_type\\" field to be one of [\\"dev\\",\\"basic\\",\\"standard\\"] but got \\"invalid\\". - Expected \\"containers.max_instances\\" to be of type number but got \\"invalid\\". - Expected \\"containers.image_vars\\" to be of type object but got \\"invalid\\". - - Expected \\"containers.scheduling_policy\\" field to be one of [\\"regional\\",\\"moon\\",\\"default\\"] but got \\"invalid\\"." + - Expected \\"containers.scheduling_policy\\" field to be one of [\\"regional\\",\\"moon\\",\\"default\\"] but got \\"invalid\\". + - Expected \\"containers.instance_type\\" field to be one of [\\"dev\\",\\"basic\\",\\"standard\\"] but got \\"invalid\\"." `); }); @@ -2485,7 +2485,7 @@ describe("normalizeAndValidateConfig()", () => { expect(diagnostics.renderWarnings()).toMatchInlineSnapshot(` "Processing wrangler configuration: - - \\"containers.configuration\\" is deprecated. Use top level \\"containers\\" fields instead. \\"configuration.image\\" should be \\"image\\", \\"configuration.disk\\" should be set via \\"instance_type\\". + - \\"containers.configuration\\" is deprecated. Use top level \\"containers\\" fields instead. \\"configuration.image\\" should be \\"image\\", limits should be set via \\"instance_type\\". - \\"containers.instances\\" is deprecated. Use \\"containers.max_instances\\" instead. - \\"containers.durable_objects\\" is deprecated. Use the \\"class_name\\" field instead." `); @@ -2519,7 +2519,7 @@ describe("normalizeAndValidateConfig()", () => { expect(diagnostics.renderWarnings()).toMatchInlineSnapshot(` "Processing wrangler configuration: - - \\"containers.configuration\\" is deprecated. Use top level \\"containers\\" fields instead. \\"configuration.image\\" should be \\"image\\", \\"configuration.disk\\" should be set via \\"instance_type\\". + - \\"containers.configuration\\" is deprecated. Use top level \\"containers\\" fields instead. \\"configuration.image\\" should be \\"image\\", limits should be set via \\"instance_type\\". - Unexpected fields found in containers.configuration field: \\"memory\\",\\"invalid_field\\",\\"another_invalid\\"" `); }); diff --git a/packages/wrangler/src/__tests__/containers/config.test.ts b/packages/wrangler/src/__tests__/containers/config.test.ts index 1d67615f2926..1c14a91c948d 100644 --- a/packages/wrangler/src/__tests__/containers/config.test.ts +++ b/packages/wrangler/src/__tests__/containers/config.test.ts @@ -196,6 +196,7 @@ describe("getNormalizedContainerOptions", () => { }); it("should handle custom limit configuration", async () => { + // deprecated path for setting custom limits const config: Config = { name: "test-worker", configPath: "/test/wrangler.toml", @@ -240,6 +241,95 @@ describe("getNormalizedContainerOptions", () => { }); }); + it("should handle custom limit configuration through instance_type", async () => { + // updated path for setting custom limits + const config: Config = { + name: "test-worker", + configPath: "/test/wrangler.toml", + userConfigPath: "/test/wrangler.toml", + topLevelName: "test-worker", + containers: [ + { + name: "test-container", + class_name: "TestContainer", + image: "registry.example.com/test:latest", + instance_type: { + disk_mb: 5000, + memory_mib: 1024, + vcpu: 2, + }, + }, + ], + durable_objects: { + bindings: [ + { + name: "TEST_DO", + class_name: "TestContainer", + }, + ], + }, + } as Partial as Config; + + const result = await getNormalizedContainerOptions(config); + expect(result).toHaveLength(1); + expect(result[0]).toMatchObject({ + name: "test-container", + class_name: "TestContainer", + max_instances: 0, + scheduling_policy: "default", + rollout_step_percentage: 25, + rollout_kind: "full_auto", + disk_bytes: 5_000_000_000, // 5000 MB in bytes + memory_mib: 1024, + vcpu: 2, + image_uri: "registry.example.com/test:latest", + constraints: { tier: 1 }, + }); + }); + + it("should normalize and set defaults for custom limits to dev instance type", async () => { + const config: Config = { + name: "test-worker", + configPath: "/test/wrangler.toml", + userConfigPath: "/test/wrangler.toml", + topLevelName: "test-worker", + containers: [ + { + name: "test-container", + class_name: "TestContainer", + image: "registry.example.com/test:latest", + instance_type: { + vcpu: 2, + }, + }, + ], + durable_objects: { + bindings: [ + { + name: "TEST_DO", + class_name: "TestContainer", + }, + ], + }, + } as Partial as Config; + + const result = await getNormalizedContainerOptions(config); + expect(result).toHaveLength(1); + expect(result[0]).toMatchObject({ + name: "test-container", + class_name: "TestContainer", + max_instances: 0, + scheduling_policy: "default", + rollout_step_percentage: 25, + rollout_kind: "full_auto", + disk_bytes: 2_000_000_000, // 2000 MB in bytes + memory_mib: 256, + vcpu: 2, + image_uri: "registry.example.com/test:latest", + constraints: { tier: 1 }, + }); + }); + it("should handle instance type configuration", async () => { const config: Config = { name: "test-worker", diff --git a/packages/wrangler/src/__tests__/containers/deploy.test.ts b/packages/wrangler/src/__tests__/containers/deploy.test.ts index fe15402130c7..172fe784dd0c 100644 --- a/packages/wrangler/src/__tests__/containers/deploy.test.ts +++ b/packages/wrangler/src/__tests__/containers/deploy.test.ts @@ -222,6 +222,7 @@ describe("wrangler deploy with containers", () => { }); it("should be able to deploy a new container with custom instance limits", async () => { + // this test checks the deprecated path for setting custom instance limits // note no docker commands have been mocked here! mockGetVersion("Galaxy-Class"); writeWranglerConfig({ @@ -268,6 +269,102 @@ describe("wrangler deploy with containers", () => { https://test-name.test-sub-domain.workers.dev Current Version ID: Galaxy-Class" `); + expect(std.warn).toMatchInlineSnapshot(` + "▲ [WARNING] Processing wrangler.toml configuration: + + - \\"containers.configuration\\" is deprecated. Use top level \\"containers\\" fields instead. + \\"configuration.image\\" should be \\"image\\", limits should be set via \\"instance_type\\". + + " + `); + expect(std.err).toMatchInlineSnapshot(`""`); + + expect(cliStd.stdout).toMatchInlineSnapshot(` + "╭ Deploy a container application deploy changes to your application + │ + │ Container application changes + │ + ├ NEW my-container + │ + │ [[containers]] + │ name = \\"my-container\\" + │ scheduling_policy = \\"default\\" + │ instances = 0 + │ max_instances = 10 + │ + │ [containers.configuration] + │ image = \\"docker.io/hello:world\\" + │ memory_mib = 1_000 + │ vcpu = 1 + │ + │ [containers.configuration.disk] + │ size_mb = 2_000 + │ + │ [containers.constraints] + │ tier = 1 + │ + │ [containers.durable_objects] + │ namespace_id = \\"1\\" + │ + │ + │ SUCCESS Created application my-container (Application ID: undefined) + │ + ╰ Applied changes + + " + `); + }); + + it("should be able to deploy a new container with custom instance limits (instance_type)", async () => { + // tests the preferred method for setting custom instance limits + // note no docker commands have been mocked here! + mockGetVersion("Galaxy-Class"); + writeWranglerConfig({ + ...DEFAULT_DURABLE_OBJECTS, + containers: [ + { + ...DEFAULT_CONTAINER_FROM_REGISTRY, + instance_type: { + vcpu: 1, + memory_mib: 1000, + disk_mb: 2000, + }, + }, + ], + }); + + mockGetApplications([]); + + mockCreateApplication({ + name: "my-container", + max_instances: 10, + scheduling_policy: SchedulingPolicy.DEFAULT, + configuration: { + image: "docker.io/hello:world", + disk: { + size_mb: 2000, + }, + vcpu: 1, + memory_mib: 1000, + }, + }); + + await runWrangler("deploy index.js"); + + expect(std.out).toMatchInlineSnapshot(` + "Total Upload: xx KiB / gzip: xx KiB + Worker Startup Time: 100 ms + Your Worker has access to the following bindings: + Binding Resource + env.EXAMPLE_DO_BINDING (ExampleDurableObject) Durable Object + + Uploaded test-name (TIMINGS) + Deployed test-name triggers (TIMINGS) + https://test-name.test-sub-domain.workers.dev + Current Version ID: Galaxy-Class" + `); + // no deprecation warnings should show up on this run + expect(std.warn).toMatchInlineSnapshot(`""`); expect(std.err).toMatchInlineSnapshot(`""`); expect(cliStd.stdout).toMatchInlineSnapshot(` diff --git a/packages/wrangler/src/cloudchamber/apply.ts b/packages/wrangler/src/cloudchamber/apply.ts index 600c666fe40d..1a655af21847 100644 --- a/packages/wrangler/src/cloudchamber/apply.ts +++ b/packages/wrangler/src/cloudchamber/apply.ts @@ -188,22 +188,34 @@ function observabilityToConfiguration( function containerAppToInstanceType( containerApp: ContainerApp -): InstanceType | undefined { +): Partial { + let configuration = (containerApp.configuration ?? + {}) as Partial; + if (containerApp.instance_type !== undefined) { - return containerApp.instance_type as InstanceType; + if (typeof containerApp.instance_type === "string") { + return { instance_type: containerApp.instance_type as InstanceType }; + } + + configuration = { + vcpu: containerApp.instance_type.vcpu, + memory_mib: containerApp.instance_type.memory_mib, + disk: { + size_mb: containerApp.instance_type.disk_mb, + }, + }; } // if no other configuration is set, we fall back to the default "dev" instance type - const configuration = - containerApp.configuration as UserDeploymentConfiguration; if ( - configuration.disk === undefined && + configuration.disk?.size_mb === undefined && configuration.vcpu === undefined && - configuration.memory === undefined && configuration.memory_mib === undefined ) { - return InstanceType.DEV; + return { instance_type: InstanceType.DEV }; } + + return configuration; } function containerAppToCreateApplication( @@ -220,8 +232,8 @@ function containerAppToCreateApplication( const instanceType = containerAppToInstanceType(containerApp); const configuration: UserDeploymentConfiguration = { ...(containerApp.configuration as UserDeploymentConfiguration), + ...instanceType, observability: observabilityConfiguration, - instance_type: instanceType, }; // this should have been set to a default value of worker-name-class-name if unspecified by the user diff --git a/packages/wrangler/src/config/environment.ts b/packages/wrangler/src/config/environment.ts index d52ba7e05ced..55d9a67ebb57 100644 --- a/packages/wrangler/src/config/environment.ts +++ b/packages/wrangler/src/config/environment.ts @@ -98,13 +98,30 @@ export type ContainerApp = { /** * The instance type to be used for the container. - * dev = 1/16 vCPU, 256 MiB memory, and 2 GB disk - * basic = 1/4 vCPU, 1 GiB memory, and 4 GB disk - * standard = 1/2 vCPU, 4 GiB memory, and 4 GB disk + * Select from one of the following named instance types: + * - dev: 1/16 vCPU, 256 MiB memory, and 2 GB disk + * - basic: 1/4 vCPU, 1 GiB memory, and 4 GB disk + * - standard: 1/2 vCPU, 4 GiB memory, and 4 GB disk + * + * Customers on an enterprise plan have the additional option to set custom limits. + * * @optional * @default "dev" */ - instance_type?: "dev" | "basic" | "standard"; + instance_type?: + | "dev" + | "basic" + | "standard" + | { + /** @defaults to 0.0625 (1/16 vCPU) */ + vcpu?: number; + + /** @defaults to 256 MiB */ + memory_mib?: number; + + /** @defaults to 2 GB */ + disk_mb?: number; + }; /** * @deprecated Use top level `containers` fields instead. diff --git a/packages/wrangler/src/config/validation.ts b/packages/wrangler/src/config/validation.ts index 3452cc439a3c..03872401fd3f 100644 --- a/packages/wrangler/src/config/validation.ts +++ b/packages/wrangler/src/config/validation.ts @@ -2428,13 +2428,13 @@ function validateContainerApp( !containerAppOptional.image ) { diagnostics.errors.push( - `"containers.image" field must be defined for each container app. This should be the path to your Dockerfile or a image URI pointing to the Cloudflare registry.` + `"containers.image" field must be defined for each container app. This should be the path to your Dockerfile or an image URI pointing to the Cloudflare registry.` ); } if ("configuration" in containerAppOptional) { diagnostics.warnings.push( - `"containers.configuration" is deprecated. Use top level "containers" fields instead. "configuration.image" should be "image", "configuration.disk" should be set via "instance_type".` + `"containers.configuration" is deprecated. Use top level "containers" fields instead. "configuration.image" should be "image", limits should be set via "instance_type".` ); if ( typeof containerAppOptional.configuration !== "object" || @@ -2509,14 +2509,6 @@ function validateContainerApp( "string", ["full_auto", "full_manual", "none"] ); - validateOptionalProperty( - diagnostics, - field, - "instance_type", - containerAppOptional.instance_type, - "string", - ["dev", "basic", "standard"] - ); validateOptionalProperty( diagnostics, field, @@ -2589,6 +2581,56 @@ function validateContainerApp( ["image", "secrets", "labels", "disk", "vcpu", "memory_mib"] ); } + + // Instance Type validation: When present, the instance type should be either (1) a string + // representing a predefined instance type or (2) an object that optionally defines vcpu, + // memory, and disk. + // + // If an instance type is not set, a 'dev' instance type will be used. If a custom instance + // type doesn't set a value, that value will default to the corresponding value in a 'dev' + // instance type + if (typeof containerAppOptional.instance_type === "string") { + // validate named instance type + validateOptionalProperty( + diagnostics, + field, + "instance_type", + containerAppOptional.instance_type, + "string", + ["dev", "basic", "standard"] + ); + } else if ( + validateOptionalProperty( + diagnostics, + field, + "instance_type", + containerAppOptional.instance_type, + "object" + ) && + containerAppOptional.instance_type + ) { + // validate custom instance type + const instanceTypeProperties = ["vcpu", "memory_mib", "disk_mb"]; + instanceTypeProperties.forEach((key) => { + if ( + !isOptionalProperty( + containerAppOptional.instance_type, + key, + "number" + ) + ) { + diagnostics.errors.push( + `"containers.instance_type.${key}", when present, should be a number.` + ); + } + }); + validateAdditionalProperties( + diagnostics, + `${field}.instance_type`, + Object.keys(containerAppOptional.instance_type), + instanceTypeProperties + ); + } } if (diagnostics.errors.length > 0) { diff --git a/packages/wrangler/src/containers/config.ts b/packages/wrangler/src/containers/config.ts index 9fde21b6c022..6258f0cb1cef 100644 --- a/packages/wrangler/src/containers/config.ts +++ b/packages/wrangler/src/containers/config.ts @@ -75,25 +75,36 @@ export const getNormalizedContainerOptions = async ( }, }; - let instanceTypeOrDisk: InstanceTypeOrLimits; - + let instanceTypeOrLimits: InstanceTypeOrLimits; + const MB = 1000 * 1000; if ( container.configuration?.disk !== undefined || container.configuration?.vcpu !== undefined || container.configuration?.memory_mib !== undefined ) { - const MB = 1000 * 1000; + // deprecated path to set a custom instance type // if an individual limit is not set, default to the dev instance type values - instanceTypeOrDisk = { - disk_bytes: (container.configuration.disk?.size_mb ?? 2000) * MB, // defaults to 2GB in bytes + instanceTypeOrLimits = { + disk_bytes: (container.configuration?.disk?.size_mb ?? 2000) * MB, // defaults to 2GB in bytes vcpu: container.configuration?.vcpu ?? 0.0625, memory_mib: container.configuration?.memory_mib ?? 256, }; - } else { - instanceTypeOrDisk = { + } else if ( + typeof container.instance_type === "string" || + container.instance_type === undefined + ) { + instanceTypeOrLimits = { instance_type: (container.instance_type ?? InstanceType.DEV) as InstanceType, }; + } else { + // set a custom instance type + // any limits that are not set will default to a dev instance type + instanceTypeOrLimits = { + disk_bytes: (container.instance_type.disk_mb ?? 2000) * MB, + vcpu: container.instance_type.vcpu ?? 0.0625, + memory_mib: container.instance_type.memory_mib ?? 256, + }; } const maybeDockerfile = isDockerfile(container.image, config.configPath); @@ -111,7 +122,7 @@ export const getNormalizedContainerOptions = async ( ); normalizedContainers.push({ ...shared, - ...instanceTypeOrDisk, + ...instanceTypeOrLimits, dockerfile: container.image, image_build_context: imageBuildContext, image_vars: container.image_vars, @@ -120,7 +131,7 @@ export const getNormalizedContainerOptions = async ( const accountId = await getAccountId(config); normalizedContainers.push({ ...shared, - ...instanceTypeOrDisk, + ...instanceTypeOrLimits, image_uri: resolveImageName(accountId, container.image), // if it is not a dockerfile, it must be an image uri or have thrown an error }); }