diff --git a/.changeset/calm-bobcats-chew.md b/.changeset/calm-bobcats-chew.md new file mode 100644 index 0000000000..d692a6120f --- /dev/null +++ b/.changeset/calm-bobcats-chew.md @@ -0,0 +1,7 @@ +--- +"wrangler": minor +--- + +Rename the documented containers SSH config option to `ssh` + +Wrangler now accepts and documents `containers.ssh` in config files while continuing to accept `containers.wrangler_ssh` as an undocumented backwards-compatible alias. Wrangler still sends and reads `wrangler_ssh` when talking to the containers API. diff --git a/packages/workers-utils/src/config/environment.ts b/packages/workers-utils/src/config/environment.ts index aaac98544a..0b5293abe5 100644 --- a/packages/workers-utils/src/config/environment.ts +++ b/packages/workers-utils/src/config/environment.ts @@ -171,7 +171,7 @@ export type ContainerApp = { disk_mb?: number; }; - wrangler_ssh?: { + ssh?: { /** * If enabled, those with write access to a container will be able to SSH into it through Wrangler. * @default false @@ -184,6 +184,15 @@ export type ContainerApp = { port?: number; }; + /** + * @deprecated Use `ssh` instead. + * @hidden + */ + wrangler_ssh?: { + enabled: boolean; + port?: number; + }; + /** * SSH public keys to put in the container's authorized_keys file. */ diff --git a/packages/workers-utils/src/config/validation.ts b/packages/workers-utils/src/config/validation.ts index 4b5428b69b..f37d6c3d2d 100644 --- a/packages/workers-utils/src/config/validation.ts +++ b/packages/workers-utils/src/config/validation.ts @@ -41,6 +41,7 @@ import type { Config, DevConfig, RawConfig, RawDevConfig } from "./config"; import type { Assets, CacheOptions, + ContainerApp, DispatchNamespaceOutbound, Environment, Observability, @@ -3335,6 +3336,11 @@ function validateContainerApp( `"containers.durable_objects" is deprecated. Use the "class_name" field instead.` ); } + if ("wrangler_ssh" in containerAppOptional) { + diagnostics.warnings.push( + `"containers.wrangler_ssh" is deprecated. Use "containers.ssh" instead.` + ); + } // unsafe.containers if ("unsafe" in containerAppOptional) { @@ -3365,6 +3371,7 @@ function validateContainerApp( "class_name", "scheduling_policy", "instance_type", + "ssh", "wrangler_ssh", "authorized_keys", "trusted_user_ca_keys", @@ -3387,30 +3394,40 @@ function validateContainerApp( ); } - if ("wrangler_ssh" in containerAppOptional) { - if ( - !isRequiredProperty( - containerAppOptional.wrangler_ssh, - "enabled", - "boolean" - ) - ) { + let sshField: "ssh" | "wrangler_ssh" | undefined; + let sshConfig: + | ContainerApp["ssh"] + | ContainerApp["wrangler_ssh"] + | undefined; + + if ("ssh" in containerAppOptional) { + sshField = "ssh"; + sshConfig = containerAppOptional.ssh; + containerAppOptional.wrangler_ssh = containerAppOptional.ssh; + delete containerAppOptional.ssh; + } else if ("wrangler_ssh" in containerAppOptional) { + sshField = "wrangler_ssh"; + sshConfig = containerAppOptional.wrangler_ssh; + } + + if (sshField !== undefined) { + const sshConfigObject = + typeof sshConfig === "object" && sshConfig !== null ? sshConfig : {}; + + if (!isRequiredProperty(sshConfigObject, "enabled", "boolean")) { diagnostics.errors.push( - `${field}.wrangler_ssh.enabled must be a boolean` + `${field}.${sshField}.enabled must be a boolean` ); } + const sshPort = + "port" in sshConfigObject ? sshConfigObject.port : undefined; if ( - !isOptionalProperty( - containerAppOptional.wrangler_ssh, - "port", - "number" - ) || - containerAppOptional.wrangler_ssh.port < 1 || - containerAppOptional.wrangler_ssh.port > 65535 + !isOptionalProperty(sshConfigObject, "port", "number") || + (typeof sshPort === "number" && (sshPort < 1 || sshPort > 65535)) ) { diagnostics.errors.push( - `${field}.wrangler_ssh.port must be a number between 1 and 65535 inclusive` + `${field}.${sshField}.port must be a number between 1 and 65535 inclusive` ); } } diff --git a/packages/wrangler/src/__tests__/containers/deploy.test.ts b/packages/wrangler/src/__tests__/containers/deploy.test.ts index e6002d325a..221d39a6ad 100644 --- a/packages/wrangler/src/__tests__/containers/deploy.test.ts +++ b/packages/wrangler/src/__tests__/containers/deploy.test.ts @@ -1824,7 +1824,7 @@ describe("wrangler deploy with containers", () => { containers: [ { ...DEFAULT_CONTAINER_FROM_REGISTRY, - wrangler_ssh: { + ssh: { enabled: true, port: 1010, }, @@ -1884,7 +1884,7 @@ describe("wrangler deploy with containers", () => { │ image = "registry.cloudflare.com/some-account-id/hello:world" │ instance_type = "lite" │ - │ [containers.configuration.wrangler_ssh] + │ [containers.configuration.ssh] │ enabled = true │ port = 1010 │ @@ -1916,7 +1916,7 @@ describe("wrangler deploy with containers", () => { containers: [ { ...DEFAULT_CONTAINER_FROM_REGISTRY, - wrangler_ssh: { + ssh: { enabled: true, }, authorized_keys: [ @@ -1994,7 +1994,7 @@ describe("wrangler deploy with containers", () => { │ + [[containers.configuration.authorized_keys]] │ + name = "jeff" │ + public_key = "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIC0chNcjRotdsxXTwPPNoqVCGn4EcEWdUkkBPNm/v4gm" - │ + [containers.configuration.wrangler_ssh] + │ + [containers.configuration.ssh] │ + enabled = true │ [containers.durable_objects] │ namespace_id = "1" @@ -2010,6 +2010,106 @@ describe("wrangler deploy with containers", () => { `); }); + it("enables ssh when provided in wrangler.jsonc", async ({ expect }) => { + mockGetVersion("Galaxy-Class"); + writeWranglerConfig( + { + ...DEFAULT_DURABLE_OBJECTS, + containers: [ + { + ...DEFAULT_CONTAINER_FROM_REGISTRY, + ssh: { + enabled: true, + port: 2022, + }, + }, + ], + }, + "./wrangler.jsonc" + ); + + mockGetApplications([]); + + mockCreateApplication(expect, { + name: "my-container", + max_instances: 10, + scheduling_policy: SchedulingPolicy.DEFAULT, + configuration: { + image: "registry.cloudflare.com/some-account-id/hello:world", + wrangler_ssh: { + enabled: true, + port: 2022, + }, + }, + }); + + await runWrangler("deploy index.js --config ./wrangler.jsonc"); + + expect(std.warn).toBe(""); + expect(std.err).toBe(""); + }); + + it("accepts wrangler_ssh as a backward-compatible alias", async ({ + expect, + }) => { + mockGetVersion("Galaxy-Class"); + writeWranglerConfig({ + ...DEFAULT_DURABLE_OBJECTS, + containers: [ + { + ...DEFAULT_CONTAINER_FROM_REGISTRY, + wrangler_ssh: { + enabled: true, + port: 2222, + }, + }, + ], + }); + + mockGetApplications([]); + + mockCreateApplication(expect, { + name: "my-container", + max_instances: 10, + scheduling_policy: SchedulingPolicy.DEFAULT, + configuration: { + image: "registry.cloudflare.com/some-account-id/hello:world", + wrangler_ssh: { + enabled: true, + port: 2222, + }, + }, + }); + + await runWrangler("deploy index.js"); + + expect(std.warn).toContain("Processing wrangler.toml configuration:"); + expect(std.warn).toContain( + '"containers.wrangler_ssh" is deprecated. Use "containers.ssh" instead.' + ); + expect(std.err).toBe(""); + }); + + it("should validate containers.ssh fields", async ({ expect }) => { + writeWranglerConfig({ + ...DEFAULT_DURABLE_OBJECTS, + containers: [ + { + ...DEFAULT_CONTAINER_FROM_REGISTRY, + ssh: { + // @ts-expect-error - intentionally invalid to test config validation + enabled: "true", + port: 70000, + }, + }, + ], + }); + + await expect(runWrangler("deploy index.js")).rejects.toThrow( + /containers\.ssh\.enabled must be a boolean[\s\S]*containers\.ssh\.port must be a number between 1 and 65535 inclusive/ + ); + }); + describe("ctx.exports", async () => { // note how mockGetVersion is NOT mocked in any of these, unlike the other tests. // instead we mock the list durable objects endpoint, which the ctx.exports path uses instead diff --git a/packages/wrangler/src/__tests__/containers/schema.test.ts b/packages/wrangler/src/__tests__/containers/schema.test.ts new file mode 100644 index 0000000000..8e3c85d527 --- /dev/null +++ b/packages/wrangler/src/__tests__/containers/schema.test.ts @@ -0,0 +1,21 @@ +import fs from "node:fs"; +import path from "node:path"; +import { describe, it } from "vitest"; + +describe("containers config schema", () => { + it("documents ssh without exposing wrangler_ssh", ({ expect }) => { + const schemaFile = path.join(__dirname, "../../../config-schema.json"); + const schema = JSON.parse(fs.readFileSync(schemaFile, "utf-8")) as { + definitions: { + ContainerApp: { + properties: Record; + }; + }; + }; + + expect(schema.definitions.ContainerApp.properties).toHaveProperty("ssh"); + expect(schema.definitions.ContainerApp.properties).not.toHaveProperty( + "wrangler_ssh" + ); + }); +}); diff --git a/packages/wrangler/src/containers/deploy.ts b/packages/wrangler/src/containers/deploy.ts index a8f0950f7d..f90c28280a 100644 --- a/packages/wrangler/src/containers/deploy.ts +++ b/packages/wrangler/src/containers/deploy.ts @@ -315,6 +315,36 @@ function containerConfigToCreateRequest( }; } +function formatContainerSnippetForDisplay< + T extends { + configuration?: ModifyApplicationRequestBody["configuration"]; + }, +>(container: T, configPath: Config["configPath"]) { + // Normalize field names from the API into the Wrangler specific format + // Example: `container.configuration.wrangler_ssh` (API) => `container.configuration.ssh` (Wrangler) + const configurationForDisplay = + container.configuration === undefined + ? undefined + : Object.fromEntries( + Object.entries(container.configuration).map(([key, value]) => [ + key === "wrangler_ssh" ? "ssh" : key, + value, + ]) + ); + + return formatConfigSnippet( + { + containers: [ + { + ...container, + configuration: configurationForDisplay, + } as unknown as ContainerApp, + ], + }, + configPath + ); +} + export async function apply( args: { imageRef: ImageRef; @@ -404,15 +434,13 @@ export async function apply( sortObjectRecursive(modifyReq) ); - const prev = formatConfigSnippet( - // note this really is a CreateApplicationRequest, not a ContainerApp - // but this function doesn't actually care about the type - { containers: [normalisedPrevApp as ContainerApp] }, + const prev = formatContainerSnippetForDisplay( + normalisedPrevApp, config.configPath ); - const now = formatConfigSnippet( - { containers: [nowContainer as ContainerApp] }, + const now = formatContainerSnippetForDisplay( + nowContainer, config.configPath ); const diff = new Diff(prev, now); @@ -453,8 +481,8 @@ export async function apply( // print the header of the app updateStatus(bold.underline(green.underline("NEW")) + ` ${appConfig.name}`); - const configStr = formatConfigSnippet( - { containers: [appConfig as ContainerApp] }, + const configStr = formatContainerSnippetForDisplay( + appConfig, config.configPath );