diff --git a/.changeset/little-rice-promise.md b/.changeset/little-rice-promise.md new file mode 100644 index 000000000000..0563ae7f906a --- /dev/null +++ b/.changeset/little-rice-promise.md @@ -0,0 +1,13 @@ +--- +"wrangler": minor +--- + +feat: add `wrangler versions secret put`, `wrangler versions secret bulk` and `wrangler versions secret list` + +`wrangler versions secret put` allows for you to add/update a secret even if the latest version is not fully deployed. A new version with this secret will be created, the existing secrets and config are copied from the latest version. + +`wrangler versions secret bulk` allows you to bulk add/update multiple secrets at once, this behaves the same as `secret put` and will only make one new version. + +`wrangler versions secret list` lists the secrets available to the currently deployed versions. `wrangler versions secret list --latest-version` or `wrangler secret list` will list for the latest version. + +Additionally, we will now prompt for extra confirmation if attempting to rollback to a version with different secrets than the currently deployed. diff --git a/packages/wrangler/src/__tests__/rollback.test.ts b/packages/wrangler/src/__tests__/rollback.test.ts new file mode 100644 index 000000000000..52423e5f7fe1 --- /dev/null +++ b/packages/wrangler/src/__tests__/rollback.test.ts @@ -0,0 +1,235 @@ +import { http, HttpResponse } from "msw"; +import { describe, expect, test } from "vitest"; +import { CANNOT_ROLLBACK_WITH_MODIFIED_SECERT_CODE } from "../versions/api"; +import { collectCLIOutput } from "./helpers/collect-cli-output"; +import { mockAccountId, mockApiToken } from "./helpers/mock-account-id"; +import { mockConfirm, mockPrompt } from "./helpers/mock-dialogs"; +import { useMockIsTTY } from "./helpers/mock-istty"; +import { createFetchResult, msw } from "./helpers/msw"; +import { runWrangler } from "./helpers/run-wrangler"; +import type { ApiDeployment } from "../versions/types"; + +describe("rollback", () => { + const std = collectCLIOutput(); + const { setIsTTY } = useMockIsTTY(); + mockAccountId(); + mockApiToken(); + + function mockGetDeployments(multiVersion = false) { + const versions = multiVersion + ? [ + { version_id: "version-id-1", percentage: 50 }, + { version_id: "version-id-2", percentage: 50 }, + ] + : [{ version_id: "version-id-1", percentage: 100 }]; + + msw.use( + http.get( + `*/accounts/:accountId/workers/scripts/:scriptName/deployments`, + async ({ params }) => { + expect(params.accountId).toEqual("some-account-id"); + expect(params.scriptName).toEqual("script-name"); + + return HttpResponse.json( + createFetchResult({ + deployments: [ + { + id: "deployment-id", + source: "api", + strategy: "percentage", + versions, + }, + ] as ApiDeployment[], + }) + ); + }, + { once: true } + ) + ); + } + + function mockGetVersion(versionId: string) { + msw.use( + http.get( + `*/accounts/:accountId/workers/scripts/:scriptName/versions/${versionId}`, + async ({ params }) => { + expect(params.accountId).toEqual("some-account-id"); + expect(params.scriptName).toEqual("script-name"); + + return HttpResponse.json( + createFetchResult({ + id: versionId, + metadata: {}, + number: 2, + resources: { + bindings: [ + { + type: "secret_text", + name: "SECRET_1", + text: "First secret", + }, + { + type: "secret_text", + name: "SECRET_2", + text: "Second secret", + }, + { + type: "secret_text", + name: "SECRET_3", + text: "Third secret", + }, + ], + script: { + etag: "etag", + handlers: ["fetch"], + last_deployed_from: "api", + }, + script_runtime: { + usage_model: "standard", + limits: {}, + }, + }, + }) + ); + }, + { once: true } + ) + ); + } + + function mockPostDeployment(forced = false) { + msw.use( + http.post( + `*/accounts/:accountId/workers/scripts/:scriptName/deployments${forced ? "?force=true" : ""}`, + async ({ params }) => { + expect(params.accountId).toEqual("some-account-id"); + expect(params.scriptName).toEqual("script-name"); + + return HttpResponse.json(createFetchResult({})); + }, + { once: true } + ) + ); + } + + test("can rollback to an earlier version", async () => { + mockGetDeployments(); + mockGetVersion("version-id-1"); + mockGetVersion("rollback-version"); + mockPostDeployment(); + + mockPrompt({ + text: "Please provide a message for this rollback (120 characters max, optional)?", + result: "Test rollback", + }); + + mockConfirm({ + text: "Are you sure you want to deploy this Worker Version to 100% of traffic?", + result: true, + }); + + await runWrangler( + "rollback --name script-name --version-id rollback-version --x-versions" + ); + + // Unable to test stdout as the output has weird whitespace. Causing lint to fail with "no-irregular-whitespace" + expect(std.err).toMatchInlineSnapshot(`""`); + }); + + test("rolling back with changed secrets prompts confirmation", async () => { + mockGetDeployments(); + mockGetVersion("version-id-1"); + mockGetVersion("rollback-version"); + + // Deployment will fail due to changed secret + msw.use( + http.post( + `*/accounts/:accountId/workers/scripts/:scriptName/deployments`, + async ({ params }) => { + expect(params.accountId).toEqual("some-account-id"); + expect(params.scriptName).toEqual("script-name"); + + return HttpResponse.json( + createFetchResult(null, false, [ + { + code: CANNOT_ROLLBACK_WITH_MODIFIED_SECERT_CODE, + message: + "A secret has changed since this version was active. If you are sure this is ok, add a ?force=true query parameter to allow the rollback. The following secrets have changed: SECRET, SECRET_TWO", + }, + ]), + { status: 400 } + ); + }, + { once: true } + ) + ); + + // Now deploy again with force and succeed + mockPostDeployment(true); + + mockPrompt({ + text: "Please provide a message for this rollback (120 characters max, optional)?", + result: "Test rollback", + }); + + mockConfirm({ + text: "Are you sure you want to deploy this Worker Version to 100% of traffic?", + result: true, + }); + + // We will have an additional confirmation + mockConfirm({ + text: + "The following secrets have changed since the target version was deployed. Please confirm you wish to continue with the rollback" + + "\n * SECRET\n * SECRET_TWO", + result: true, + }); + + await runWrangler( + "rollback --name script-name --version-id rollback-version --x-versions" + ); + + // Unable to test stdout as the output has weird whitespace. Causing lint to fail with "no-irregular-whitespace" + expect(std.err).toMatchInlineSnapshot(`""`); + }); + + test("rolling back with changed secrets (non-interactive)", async () => { + setIsTTY(false); + mockGetDeployments(); + mockGetVersion("version-id-1"); + mockGetVersion("rollback-version"); + + // Deployment will fail due to changed secret + msw.use( + http.post( + `*/accounts/:accountId/workers/scripts/:scriptName/deployments`, + async ({ params }) => { + expect(params.accountId).toEqual("some-account-id"); + expect(params.scriptName).toEqual("script-name"); + + return HttpResponse.json( + createFetchResult(null, false, [ + { + code: CANNOT_ROLLBACK_WITH_MODIFIED_SECERT_CODE, + message: + "A secret has changed since this version was active. If you are sure this is ok, add a ?force=true query parameter to allow the rollback. The following secrets have changed: SECRET, SECRET_TWO", + }, + ]), + { status: 400 } + ); + }, + { once: true } + ) + ); + + // Now deploy again with force and succeed + mockPostDeployment(true); + + await runWrangler( + "rollback --name script-name --version-id rollback-version --x-versions" + ); + + // Unable to test stdout as the output has weird whitespace. Causing lint to fail with "no-irregular-whitespace" + expect(std.err).toMatchInlineSnapshot(`""`); + }); +}); diff --git a/packages/wrangler/src/__tests__/secret.test.ts b/packages/wrangler/src/__tests__/secret.test.ts index ba26ef430f7b..5cd5e26a6f0c 100644 --- a/packages/wrangler/src/__tests__/secret.test.ts +++ b/packages/wrangler/src/__tests__/secret.test.ts @@ -503,13 +503,13 @@ describe("wrangler secret", () => { mockListRequest({ scriptName: "script-name" }); await runWrangler("secret list --name script-name"); expect(std.out).toMatchInlineSnapshot(` - "[ - { - \\"name\\": \\"the-secret-name\\", - \\"type\\": \\"secret_text\\" - } - ]" - `); + "[ + { + \\"name\\": \\"the-secret-name\\", + \\"type\\": \\"secret_text\\" + } + ]" + `); expect(std.err).toMatchInlineSnapshot(`""`); }); @@ -519,13 +519,13 @@ describe("wrangler secret", () => { "secret list --name script-name --env some-env --legacy-env" ); expect(std.out).toMatchInlineSnapshot(` - "[ - { - \\"name\\": \\"the-secret-name\\", - \\"type\\": \\"secret_text\\" - } - ]" - `); + "[ + { + \\"name\\": \\"the-secret-name\\", + \\"type\\": \\"secret_text\\" + } + ]" + `); expect(std.err).toMatchInlineSnapshot(`""`); }); @@ -535,13 +535,13 @@ describe("wrangler secret", () => { "secret list --name script-name --env some-env --legacy-env false" ); expect(std.out).toMatchInlineSnapshot(` - "[ - { - \\"name\\": \\"the-secret-name\\", - \\"type\\": \\"secret_text\\" - } - ]" - `); + "[ + { + \\"name\\": \\"the-secret-name\\", + \\"type\\": \\"secret_text\\" + } + ]" + `); expect(std.err).toMatchInlineSnapshot(`""`); }); diff --git a/packages/wrangler/src/__tests__/versions/secrets/bulk.test.ts b/packages/wrangler/src/__tests__/versions/secrets/bulk.test.ts new file mode 100644 index 000000000000..7ce4db4af5f3 --- /dev/null +++ b/packages/wrangler/src/__tests__/versions/secrets/bulk.test.ts @@ -0,0 +1,159 @@ +import { writeFile } from "node:fs/promises"; +import readline from "node:readline"; +import { describe, expect, test } from "vitest"; +import { mockAccountId, mockApiToken } from "../../helpers/mock-account-id"; +import { mockConsoleMethods } from "../../helpers/mock-console"; +import { clearDialogs } from "../../helpers/mock-dialogs"; +import { runInTempDir } from "../../helpers/run-in-tmp"; +import { runWrangler } from "../../helpers/run-wrangler"; +import { mockPostVersion, mockSetupApiCalls } from "./utils"; +import type { Interface } from "node:readline"; + +describe("versions secret put", () => { + const std = mockConsoleMethods(); + runInTempDir(); + mockAccountId(); + mockApiToken(); + afterEach(() => { + clearDialogs(); + }); + + test("should fail secret bulk w/ no pipe or JSON input", async () => { + vi.spyOn(readline, "createInterface").mockImplementation( + () => null as unknown as Interface + ); + await runWrangler(`versions secret bulk --name script-name --x-versions`); + expect(std.out).toMatchInlineSnapshot( + `"🌀 Creating the secrets for the Worker \\"script-name\\" "` + ); + expect(std.err).toMatchInlineSnapshot(` + "X [ERROR] Unable to parse JSON from the input, please ensure you're passing valid JSON + + " + `); + expect(std.warn).toMatchInlineSnapshot(`""`); + }); + + test("uploading secrets from json file", async () => { + await writeFile( + "secrets.json", + JSON.stringify({ + SECRET_1: "secret-1", + SECRET_2: "secret-2", + SECRET_3: "secret-3", + }), + { encoding: "utf8" } + ); + + mockSetupApiCalls(); + mockPostVersion((metadata) => { + expect(metadata.bindings).toStrictEqual([ + { type: "secret_text", name: "SECRET_1", text: "secret-1" }, + { type: "secret_text", name: "SECRET_2", text: "secret-2" }, + { type: "secret_text", name: "SECRET_3", text: "secret-3" }, + ]); + expect(metadata.keep_bindings).toStrictEqual([ + "secret_key", + "secret_text", + ]); + }); + + await runWrangler( + `versions secret bulk secrets.json --name script-name --x-versions` + ); + expect(std.out).toMatchInlineSnapshot( + ` + "🌀 Creating the secrets for the Worker \\"script-name\\" + ✨ Successfully created secret for key: SECRET_1 + ✨ Successfully created secret for key: SECRET_2 + ✨ Successfully created secret for key: SECRET_3 + ✨ Success! Created version id with 3 secrets. To deploy this version to production traffic use the command \\"wrangler versions deploy --x-versions\\"." + ` + ); + expect(std.err).toMatchInlineSnapshot(`""`); + }); + + test("uploading secrets from stdin", async () => { + vi.spyOn(readline, "createInterface").mockImplementation( + () => + // `readline.Interface` is an async iterator: `[Symbol.asyncIterator](): AsyncIterableIterator` + JSON.stringify({ + SECRET_1: "secret-1", + SECRET_2: "secret-2", + SECRET_3: "secret-3", + }) as unknown as Interface + ); + + mockSetupApiCalls(); + mockPostVersion((metadata) => { + expect(metadata.bindings).toStrictEqual([ + { type: "secret_text", name: "SECRET_1", text: "secret-1" }, + { type: "secret_text", name: "SECRET_2", text: "secret-2" }, + { type: "secret_text", name: "SECRET_3", text: "secret-3" }, + ]); + expect(metadata.keep_bindings).toStrictEqual([ + "secret_key", + "secret_text", + ]); + }); + + await runWrangler(`versions secret bulk --name script-name --x-versions`); + expect(std.out).toMatchInlineSnapshot( + ` + "🌀 Creating the secrets for the Worker \\"script-name\\" + ✨ Successfully created secret for key: SECRET_1 + ✨ Successfully created secret for key: SECRET_2 + ✨ Successfully created secret for key: SECRET_3 + ✨ Success! Created version id with 3 secrets. To deploy this version to production traffic use the command \\"wrangler versions deploy --x-versions\\"." + ` + ); + expect(std.err).toMatchInlineSnapshot(`""`); + }); + + test("should error on invalid json file", async () => { + await writeFile("secrets.json", "not valid json :(", { encoding: "utf8" }); + + await runWrangler( + `versions secret bulk secrets.json --name script-name --x-versions` + ); + expect(std.out).toMatchInlineSnapshot( + `"🌀 Creating the secrets for the Worker \\"script-name\\" "` + ); + expect(std.err).toMatchInlineSnapshot(` + "X [ERROR] Unable to parse JSON file, please ensure the file passed is valid JSON. + + " + `); + }); + + test("should error on invalid json stdin", async () => { + vi.spyOn(readline, "createInterface").mockImplementation( + () => + // `readline.Interface` is an async iterator: `[Symbol.asyncIterator](): AsyncIterableIterator` + "hello world" as unknown as Interface + ); + + mockSetupApiCalls(); + mockPostVersion((metadata) => { + expect(metadata.bindings).toStrictEqual([ + { type: "secret_text", name: "SECRET_1", text: "secret-1" }, + { type: "secret_text", name: "SECRET_2", text: "secret-2" }, + { type: "secret_text", name: "SECRET_3", text: "secret-3" }, + ]); + expect(metadata.keep_bindings).toStrictEqual([ + "secret_key", + "secret_text", + ]); + }); + + await runWrangler(`versions secret bulk --name script-name --x-versions`); + expect(std.out).toMatchInlineSnapshot( + `"🌀 Creating the secrets for the Worker \\"script-name\\" "` + ); + expect(std.err).toMatchInlineSnapshot(` + "X [ERROR] Unable to parse JSON from the input, please ensure you're passing valid JSON + + " + `); + }); +}); diff --git a/packages/wrangler/src/__tests__/versions/secrets/delete.test.ts b/packages/wrangler/src/__tests__/versions/secrets/delete.test.ts new file mode 100644 index 000000000000..f65311183d6b --- /dev/null +++ b/packages/wrangler/src/__tests__/versions/secrets/delete.test.ts @@ -0,0 +1,103 @@ +import { describe, expect, test } from "vitest"; +import { mockAccountId, mockApiToken } from "../../helpers/mock-account-id"; +import { mockConsoleMethods } from "../../helpers/mock-console"; +import { clearDialogs, mockConfirm } from "../../helpers/mock-dialogs"; +import { useMockIsTTY } from "../../helpers/mock-istty"; +import { runInTempDir } from "../../helpers/run-in-tmp"; +import { runWrangler } from "../../helpers/run-wrangler"; +import writeWranglerToml from "../../helpers/write-wrangler-toml"; +import { mockGetVersion, mockPostVersion, mockSetupApiCalls } from "./utils"; + +describe("versions secret put", () => { + const std = mockConsoleMethods(); + const { setIsTTY } = useMockIsTTY(); + runInTempDir(); + mockAccountId(); + mockApiToken(); + afterEach(() => { + clearDialogs(); + }); + + test("can delete a new secret (interactive)", async () => { + setIsTTY(true); + + mockConfirm({ + text: "Are you sure you want to permanently delete the secret SECRET on the Worker script-name?", + result: true, + }); + + mockSetupApiCalls(); + mockGetVersion(); + mockPostVersion((metadata) => { + // We should have all secrets except the one being deleted + expect(metadata.bindings).toStrictEqual([ + { type: "inherit", name: "ANOTHER_SECRET" }, + { type: "inherit", name: "YET_ANOTHER_SECRET" }, + ]); + // We will not be inherting secret_text as that would bring back SECRET + expect(metadata.keep_bindings).toStrictEqual(["secret_key"]); + }); + await runWrangler( + "versions secret delete SECRET --name script-name --x-versions" + ); + + expect(std.out).toMatchInlineSnapshot(` + "🌀 Deleting the secret SECRET on the Worker script-name + ✨ Success! Created version id with deleted secret SECRET. To deploy this version without the secret SECRET to production traffic use the command \\"wrangler versions deploy --x-versions\\"." + `); + expect(std.err).toMatchInlineSnapshot(`""`); + }); + + test("can delete a secret (non-interactive)", async () => { + setIsTTY(false); + + mockSetupApiCalls(); + mockGetVersion(); + mockPostVersion((metadata) => { + expect(metadata.bindings).toStrictEqual([ + { type: "inherit", name: "ANOTHER_SECRET" }, + { type: "inherit", name: "YET_ANOTHER_SECRET" }, + ]); + // We will not be inherting secret_text as that would bring back SECRET + expect(metadata.keep_bindings).toStrictEqual(["secret_key"]); + }); + + await runWrangler( + "versions secret delete SECRET --name script-name --x-versions" + ); + + expect(std.out).toMatchInlineSnapshot(` + "? Are you sure you want to permanently delete the secret SECRET on the Worker script-name? + 🤖 Using fallback value in non-interactive context: yes + 🌀 Deleting the secret SECRET on the Worker script-name + ✨ Success! Created version id with deleted secret SECRET. To deploy this version without the secret SECRET to production traffic use the command \\"wrangler versions deploy --x-versions\\"." + `); + expect(std.err).toMatchInlineSnapshot(`""`); + }); + + test("can delete a secret reading Worker name from wrangler.toml", async () => { + writeWranglerToml({ name: "script-name" }); + setIsTTY(false); + + mockSetupApiCalls(); + mockGetVersion(); + mockPostVersion((metadata) => { + expect(metadata.bindings).toStrictEqual([ + { type: "inherit", name: "ANOTHER_SECRET" }, + { type: "inherit", name: "YET_ANOTHER_SECRET" }, + ]); + // We will not be inherting secret_text as that would bring back SECRET + expect(metadata.keep_bindings).toStrictEqual(["secret_key"]); + }); + + await runWrangler("versions secret delete SECRET --x-versions"); + + expect(std.out).toMatchInlineSnapshot(` + "? Are you sure you want to permanently delete the secret SECRET on the Worker script-name? + 🤖 Using fallback value in non-interactive context: yes + 🌀 Deleting the secret SECRET on the Worker script-name + ✨ Success! Created version id with deleted secret SECRET. To deploy this version without the secret SECRET to production traffic use the command \\"wrangler versions deploy --x-versions\\"." + `); + expect(std.err).toMatchInlineSnapshot(`""`); + }); +}); diff --git a/packages/wrangler/src/__tests__/versions/secrets/list.test.ts b/packages/wrangler/src/__tests__/versions/secrets/list.test.ts new file mode 100644 index 000000000000..146eebc8006b --- /dev/null +++ b/packages/wrangler/src/__tests__/versions/secrets/list.test.ts @@ -0,0 +1,256 @@ +import { http, HttpResponse } from "msw"; +import { describe, expect, test } from "vitest"; +import { mockAccountId, mockApiToken } from "../../helpers/mock-account-id"; +import { mockConsoleMethods } from "../../helpers/mock-console"; +import { createFetchResult, msw } from "../../helpers/msw"; +import { runWrangler } from "../../helpers/run-wrangler"; +import writeWranglerToml from "../../helpers/write-wrangler-toml"; +import type { ApiDeployment, ApiVersion } from "../../../versions/types"; + +describe("versions secret list", () => { + const std = mockConsoleMethods(); + mockAccountId(); + mockApiToken(); + + function mockGetDeployments(multiVersion = false) { + const versions = multiVersion + ? [ + { version_id: "version-id-1", percentage: 50 }, + { version_id: "version-id-2", percentage: 50 }, + ] + : [{ version_id: "version-id-1", percentage: 100 }]; + + msw.use( + http.get( + `*/accounts/:accountId/workers/scripts/:scriptName/deployments`, + async ({ params }) => { + expect(params.accountId).toEqual("some-account-id"); + expect(params.scriptName).toEqual("script-name"); + + return HttpResponse.json( + createFetchResult({ + deployments: [ + { + id: "deployment-id", + source: "api", + strategy: "percentage", + versions, + }, + ] as ApiDeployment[], + }) + ); + }, + { once: true } + ) + ); + } + + function mockGetVersion(versionId: string) { + msw.use( + http.get( + `*/accounts/:accountId/workers/scripts/:scriptName/versions/${versionId}`, + async ({ params }) => { + expect(params.accountId).toEqual("some-account-id"); + expect(params.scriptName).toEqual("script-name"); + + return HttpResponse.json( + createFetchResult({ + id: versionId, + metadata: {}, + number: 2, + resources: { + bindings: [ + { + type: "secret_text", + name: "SECRET_1", + text: "First secret", + }, + { + type: "secret_text", + name: "SECRET_2", + text: "Second secret", + }, + { + type: "secret_text", + name: "SECRET_3", + text: "Third secret", + }, + ], + script: { + etag: "etag", + handlers: ["fetch"], + last_deployed_from: "api", + }, + script_runtime: { + usage_model: "standard", + limits: {}, + }, + }, + }) + ); + }, + { once: true } + ) + ); + } + + test("Can list secrets in single version deployment", async () => { + mockGetDeployments(); + mockGetVersion("version-id-1"); + + await runWrangler("versions secret list --name script-name --x-versions"); + + expect(std.out).toMatchInlineSnapshot(` + "-- Version version-id-1 (100%) secrets -- + Secret Name: SECRET_1 + Secret Name: SECRET_2 + Secret Name: SECRET_3 + " + `); + expect(std.err).toMatchInlineSnapshot(`""`); + }); + + test("Can list secrets in multi-version deployment", async () => { + mockGetDeployments(true); + mockGetVersion("version-id-1"); + mockGetVersion("version-id-2"); + + await runWrangler("versions secret list --name script-name --x-versions"); + + expect(std.out).toMatchInlineSnapshot(` + "-- Version version-id-1 (50%) secrets -- + Secret Name: SECRET_1 + Secret Name: SECRET_2 + Secret Name: SECRET_3 + + -- Version version-id-2 (50%) secrets -- + Secret Name: SECRET_1 + Secret Name: SECRET_2 + Secret Name: SECRET_3 + " + `); + expect(std.err).toMatchInlineSnapshot(`""`); + }); + + test("Can list secrets in single version deployment reading from wrangler.toml", async () => { + writeWranglerToml({ name: "script-name" }); + + mockGetDeployments(); + mockGetVersion("version-id-1"); + + await runWrangler("versions secret list --x-versions"); + + expect(std.out).toMatchInlineSnapshot(` + "-- Version version-id-1 (100%) secrets -- + Secret Name: SECRET_1 + Secret Name: SECRET_2 + Secret Name: SECRET_3 + " + `); + expect(std.err).toMatchInlineSnapshot(`""`); + }); + + test("Can list secrets for latest version", async () => { + writeWranglerToml({ name: "script-name" }); + + msw.use( + http.get( + `*/accounts/:accountId/workers/scripts/:scriptName/versions`, + async ({ params }) => { + expect(params.accountId).toEqual("some-account-id"); + expect(params.scriptName).toEqual("script-name"); + + return HttpResponse.json( + createFetchResult({ + items: [ + { + id: "version-id-3", + number: 3, + resources: { + bindings: [ + { + type: "secret_text", + name: "SECRET_1", + text: "shh secret one v3", + }, + { + type: "secret_text", + name: "SECRET_1", + text: "shh secret two v3", + }, + { + type: "secret_text", + name: "SECRET_1", + text: "shh secret three v3", + }, + ], + }, + }, + { + id: "version-id-2", + number: 2, + resources: { + bindings: [ + { + type: "secret_text", + name: "SECRET_1", + text: "shh secret one v2", + }, + { + type: "secret_text", + name: "SECRET_1", + text: "shh secret two v2", + }, + { + type: "secret_text", + name: "SECRET_1", + text: "shh secret three v2", + }, + ], + }, + }, + { + id: "version-id-1", + number: 1, + resources: { + bindings: [ + { + type: "secret_text", + name: "SECRET_1", + text: "shh secret one v1", + }, + { + type: "secret_text", + name: "SECRET_1", + text: "shh secret two v1", + }, + { + type: "secret_text", + name: "SECRET_1", + text: "shh secret three v1", + }, + ], + }, + }, + ] as ApiVersion[], + }) + ); + } + ) + ); + + mockGetDeployments(); + mockGetVersion("version-id-1"); + + await runWrangler("versions secret list --latest-version --x-versions"); + + expect(std.out).toMatchInlineSnapshot(` + "-- Version version-id-3 (0%) secrets -- + Secret Name: SECRET_1 + Secret Name: SECRET_1 + Secret Name: SECRET_1 + " + `); + expect(std.err).toMatchInlineSnapshot(`""`); + }); +}); diff --git a/packages/wrangler/src/__tests__/versions/secrets/put.test.ts b/packages/wrangler/src/__tests__/versions/secrets/put.test.ts new file mode 100644 index 000000000000..e6993447a3e2 --- /dev/null +++ b/packages/wrangler/src/__tests__/versions/secrets/put.test.ts @@ -0,0 +1,223 @@ +import { describe, expect, test } from "vitest"; +import { mockAccountId, mockApiToken } from "../../helpers/mock-account-id"; +import { mockConsoleMethods } from "../../helpers/mock-console"; +import { clearDialogs, mockPrompt } from "../../helpers/mock-dialogs"; +import { useMockIsTTY } from "../../helpers/mock-istty"; +import { useMockStdin } from "../../helpers/mock-stdin"; +import { runInTempDir } from "../../helpers/run-in-tmp"; +import { runWrangler } from "../../helpers/run-wrangler"; +import writeWranglerToml from "../../helpers/write-wrangler-toml"; +import { mockPostVersion, mockSetupApiCalls } from "./utils"; + +describe("versions secret put", () => { + const std = mockConsoleMethods(); + const { setIsTTY } = useMockIsTTY(); + runInTempDir(); + mockAccountId(); + mockApiToken(); + afterEach(() => { + clearDialogs(); + }); + + test("can add a new secret (interactive)", async () => { + setIsTTY(true); + + mockPrompt({ + text: "Enter a secret value:", + options: { isSecret: true }, + result: "the-secret", + }); + + mockSetupApiCalls(); + mockPostVersion((metadata) => { + expect(metadata.bindings).toStrictEqual([ + { type: "secret_text", name: "NEW_SECRET", text: "the-secret" }, + ]); + expect(metadata.keep_bindings).toStrictEqual([ + "secret_key", + "secret_text", + ]); + }); + await runWrangler( + "versions secret put NEW_SECRET --name script-name --x-versions" + ); + + expect(std.out).toMatchInlineSnapshot(` + "🌀 Creating the secret for the Worker \\"script-name\\" + ✨ Success! Created version id with secret NEW_SECRET. To deploy this version with secret NEW_SECRET to production traffic use the command \\"wrangler versions deploy --x-versions\\"." + `); + expect(std.err).toMatchInlineSnapshot(`""`); + }); + + // For some reason, this always hangs. Not sure why + test.skip("can add a new secret (non-interactive)", async () => { + setIsTTY(false); + const mockStdIn = useMockStdin({ isTTY: false }); + + mockSetupApiCalls(); + mockPostVersion((metadata) => { + expect(metadata.bindings).toStrictEqual([ + { type: "secret_text", name: "NEW_SECRET", text: "the-secret" }, + ]); + expect(metadata.keep_bindings).toStrictEqual([ + "secret_key", + "secret_text", + ]); + }); + + mockStdIn.send( + `the`, + `-`, + `secret + ` // whitespace & newline being removed + ); + await runWrangler( + "versions secret put NEW_SECRET --name script-name --x-versions" + ); + + expect(std.out).toMatchInlineSnapshot(` + "🌀 Creating the secret for the Worker \\"script-name\\" + ✨ Success! Created version id with secret NEW_SECRET. + ➡️ To deploy this version with secret NEW_SECRET to production traffic use the command wrangler versions deploy." + `); + expect(std.err).toMatchInlineSnapshot(`""`); + }); + + test("can add a new secret, read Worker name from wrangler.toml", async () => { + writeWranglerToml({ name: "script-name" }); + + setIsTTY(true); + + mockPrompt({ + text: "Enter a secret value:", + options: { isSecret: true }, + result: "the-secret", + }); + + mockSetupApiCalls(); + mockPostVersion((metadata) => { + expect(metadata.bindings).toStrictEqual([ + { type: "secret_text", name: "NEW_SECRET", text: "the-secret" }, + ]); + expect(metadata.keep_bindings).toStrictEqual([ + "secret_key", + "secret_text", + ]); + }); + await runWrangler("versions secret put NEW_SECRET --x-versions"); + + expect(std.out).toMatchInlineSnapshot(` + "🌀 Creating the secret for the Worker \\"script-name\\" + ✨ Success! Created version id with secret NEW_SECRET. To deploy this version with secret NEW_SECRET to production traffic use the command \\"wrangler versions deploy --x-versions\\"." + `); + expect(std.err).toMatchInlineSnapshot(`""`); + }); + + test("can add a new secret with message", async () => { + setIsTTY(true); + + mockPrompt({ + text: "Enter a secret value:", + options: { isSecret: true }, + result: "the-secret", + }); + + mockSetupApiCalls(); + mockPostVersion((metadata) => { + expect(metadata.bindings).toStrictEqual([ + { type: "secret_text", name: "NEW_SECRET", text: "the-secret" }, + ]); + expect(metadata.keep_bindings).toStrictEqual([ + "secret_key", + "secret_text", + ]); + + expect(metadata.annotations).not.toBeUndefined(); + expect( + (metadata.annotations as Record)["workers/message"] + ).toBe("Deploy a new secret"); + }); + await runWrangler( + "versions secret put NEW_SECRET --name script-name --message 'Deploy a new secret' --x-versions" + ); + + expect(std.out).toMatchInlineSnapshot(` + "🌀 Creating the secret for the Worker \\"script-name\\" + ✨ Success! Created version id with secret NEW_SECRET. To deploy this version with secret NEW_SECRET to production traffic use the command \\"wrangler versions deploy --x-versions\\"." + `); + expect(std.err).toMatchInlineSnapshot(`""`); + }); + + test("can add a new secret with message + tag", async () => { + setIsTTY(true); + + mockPrompt({ + text: "Enter a secret value:", + options: { isSecret: true }, + result: "the-secret", + }); + + mockSetupApiCalls(); + mockPostVersion((metadata) => { + expect(metadata.bindings).toStrictEqual([ + { type: "secret_text", name: "NEW_SECRET", text: "the-secret" }, + ]); + expect(metadata.keep_bindings).toStrictEqual([ + "secret_key", + "secret_text", + ]); + + expect(metadata.annotations).not.toBeUndefined(); + expect( + (metadata.annotations as Record)["workers/message"] + ).toBe("Deploy a new secret"); + expect( + (metadata.annotations as Record)["workers/tag"] + ).toBe("v1"); + }); + await runWrangler( + "versions secret put NEW_SECRET --name script-name --message 'Deploy a new secret' --tag v1 --x-versions" + ); + + expect(std.out).toMatchInlineSnapshot(` + "🌀 Creating the secret for the Worker \\"script-name\\" + ✨ Success! Created version id with secret NEW_SECRET. To deploy this version with secret NEW_SECRET to production traffic use the command \\"wrangler versions deploy --x-versions\\"." + `); + expect(std.err).toMatchInlineSnapshot(`""`); + }); + + test("can update an existing secret", async () => { + setIsTTY(true); + + mockPrompt({ + text: "Enter a secret value:", + options: { isSecret: true }, + result: "the-secret", + }); + + mockSetupApiCalls(); + mockPostVersion((metadata) => { + expect(metadata.bindings).toStrictEqual([ + { type: "secret_text", name: "SECRET", text: "the-secret" }, + ]); + expect(metadata.keep_bindings).toStrictEqual([ + "secret_key", + "secret_text", + ]); + + expect(metadata.annotations).not.toBeUndefined(); + expect( + (metadata.annotations as Record)["workers/message"] + ).toBe("Deploy a new secret"); + }); + await runWrangler( + "versions secret put SECRET --name script-name --message 'Deploy a new secret' --x-versions" + ); + + expect(std.out).toMatchInlineSnapshot(` + "🌀 Creating the secret for the Worker \\"script-name\\" + ✨ Success! Created version id with secret SECRET. To deploy this version with secret SECRET to production traffic use the command \\"wrangler versions deploy --x-versions\\"." + `); + expect(std.err).toMatchInlineSnapshot(`""`); + }); +}); diff --git a/packages/wrangler/src/__tests__/versions/secrets/utils.ts b/packages/wrangler/src/__tests__/versions/secrets/utils.ts new file mode 100644 index 000000000000..ff743da91dad --- /dev/null +++ b/packages/wrangler/src/__tests__/versions/secrets/utils.ts @@ -0,0 +1,161 @@ +import { http, HttpResponse } from "msw"; +import { File, FormData } from "undici"; +import { createFetchResult, msw } from "../../helpers/msw"; +import type { WorkerMetadata } from "../../../deployment-bundle/create-worker-upload-form"; +import type { VersionDetails, WorkerVersion } from "../../../versions/secrets"; + +export function mockGetVersions() { + msw.use( + http.get( + `*/accounts/:accountId/workers/scripts/:scriptName/versions`, + async ({ params }) => { + expect(params.accountId).toEqual("some-account-id"); + expect(params.scriptName).toEqual("script-name"); + + return HttpResponse.json( + createFetchResult({ + items: [ + { + id: "ce15c78b-cc43-4f60-b5a9-15ce4f298c2a", + number: 2, + }, + { + id: "ec5ea4f9-fe32-4301-bd73-6a86006ae8d4", + number: 1, + }, + ] as WorkerVersion[], + }) + ); + }, + { once: true } + ) + ); +} + +export function mockGetVersion(versionInfo?: VersionDetails) { + msw.use( + http.get( + `*/accounts/:accountId/workers/scripts/:scriptName/versions/ce15c78b-cc43-4f60-b5a9-15ce4f298c2a`, + async ({ params }) => { + expect(params.accountId).toEqual("some-account-id"); + expect(params.scriptName).toEqual("script-name"); + + return HttpResponse.json( + createFetchResult( + versionInfo ?? { + id: "ce15c78b-cc43-4f60-b5a9-15ce4f298c2a", + metadata: {}, + number: 2, + resources: { + bindings: [ + { type: "secret_text", name: "SECRET", text: "Secret shhh" }, + { + type: "secret_text", + name: "ANOTHER_SECRET", + text: "Another secret shhhh", + }, + { + type: "secret_text", + name: "YET_ANOTHER_SECRET", + text: "Yet another secret shhhhh", + }, + ], + script: { + etag: "etag", + handlers: ["fetch"], + last_deployed_from: "api", + }, + script_runtime: { + usage_model: "standard", + limits: {}, + }, + }, + } + ) + ); + }, + { once: true } + ) + ); +} + +export function mockGetVersionContent() { + msw.use( + http.get( + `*/accounts/:accountId/workers/scripts/:scriptName/content/v2?version=ce15c78b-cc43-4f60-b5a9-15ce4f298c2a`, + async ({ params }) => { + expect(params.accountId).toEqual("some-account-id"); + expect(params.scriptName).toEqual("script-name"); + + const formData = new FormData(); + formData.set( + "index.js", + new File(["export default {}"], "index.js", { + type: "application/javascript+module", + }), + "index.js" + ); + + return HttpResponse.formData(formData, { + headers: { "cf-entrypoint": "index.js" }, + }); + }, + { once: true } + ) + ); +} + +export function mockGetWorkerSettings() { + msw.use( + http.get( + `*/accounts/:accountId/workers/scripts/:scriptName/script-settings`, + async ({ params }) => { + expect(params.accountId).toEqual("some-account-id"); + expect(params.scriptName).toEqual("script-name"); + + return HttpResponse.json( + createFetchResult({ + logpush: false, + tail_consumers: null, + }) + ); + }, + { once: true } + ) + ); +} + +export function mockPostVersion(validate?: (metadata: WorkerMetadata) => void) { + msw.use( + http.post( + `*/accounts/:accountId/workers/scripts/:scriptName/versions`, + async ({ request, params }) => { + expect(params.accountId).toEqual("some-account-id"); + expect(params.scriptName).toEqual("script-name"); + + const formData = await request.formData(); + const metadata = JSON.parse( + formData.get("metadata") as string + ) as WorkerMetadata; + + validate && validate(metadata); + + return HttpResponse.json( + createFetchResult({ + id: "id", + etag: "etag", + deployment_id: "version-id", + }) + ); + }, + { once: true } + ) + ); +} + +export function mockSetupApiCalls() { + mockGetVersions(); + mockGetVersion(); + mockGetVersionContent(); + mockGetWorkerSettings(); +} diff --git a/packages/wrangler/src/__tests__/versions/versions.help.test.ts b/packages/wrangler/src/__tests__/versions/versions.help.test.ts index b43cea3e50bd..4708343014da 100644 --- a/packages/wrangler/src/__tests__/versions/versions.help.test.ts +++ b/packages/wrangler/src/__tests__/versions/versions.help.test.ts @@ -55,23 +55,24 @@ describe("versions --help", () => { await expect(result).resolves.toBeUndefined(); expect(std.out).toMatchInlineSnapshot(` - "wrangler versions - - List, view, upload and deploy Versions of your Worker to Cloudflare [beta] - - Commands: - wrangler versions view View the details of a specific version of your Worker [beta] - wrangler versions list List the 10 most recent Versions of your Worker [beta] - wrangler versions upload Uploads your Worker code and config as a new Version [beta] - wrangler versions deploy [version-specs..] Safely roll out new Versions of your Worker by splitting traffic between multiple Versions [beta] - - Flags: - -j, --experimental-json-config Experimental: Support wrangler.json [boolean] - -c, --config Path to .toml configuration file [string] - -e, --env Environment to use for operations and .env files [string] - -h, --help Show help [boolean] - -v, --version Show version number [boolean]" - `); + "wrangler versions + + List, view, upload and deploy Versions of your Worker to Cloudflare [beta] + + Commands: + wrangler versions view View the details of a specific version of your Worker [beta] + wrangler versions list List the 10 most recent Versions of your Worker [beta] + wrangler versions upload Uploads your Worker code and config as a new Version [beta] + wrangler versions deploy [version-specs..] Safely roll out new Versions of your Worker by splitting traffic between multiple Versions [beta] + wrangler versions secret Generate a secret that can be referenced in a Worker + + Flags: + -j, --experimental-json-config Experimental: Support wrangler.json [boolean] + -c, --config Path to .toml configuration file [string] + -e, --env Environment to use for operations and .env files [string] + -h, --help Show help [boolean] + -v, --version Show version number [boolean]" + `); }); }); @@ -93,23 +94,24 @@ describe("versions subhelp", () => { await setImmediate(); // wait for subhelp expect(std.out).toMatchInlineSnapshot(` - "wrangler versions - - List, view, upload and deploy Versions of your Worker to Cloudflare [beta] - - Commands: - wrangler versions view View the details of a specific version of your Worker [beta] - wrangler versions list List the 10 most recent Versions of your Worker [beta] - wrangler versions upload Uploads your Worker code and config as a new Version [beta] - wrangler versions deploy [version-specs..] Safely roll out new Versions of your Worker by splitting traffic between multiple Versions [beta] - - Flags: - -j, --experimental-json-config Experimental: Support wrangler.json [boolean] - -c, --config Path to .toml configuration file [string] - -e, --env Environment to use for operations and .env files [string] - -h, --help Show help [boolean] - -v, --version Show version number [boolean]" - `); + "wrangler versions + + List, view, upload and deploy Versions of your Worker to Cloudflare [beta] + + Commands: + wrangler versions view View the details of a specific version of your Worker [beta] + wrangler versions list List the 10 most recent Versions of your Worker [beta] + wrangler versions upload Uploads your Worker code and config as a new Version [beta] + wrangler versions deploy [version-specs..] Safely roll out new Versions of your Worker by splitting traffic between multiple Versions [beta] + wrangler versions secret Generate a secret that can be referenced in a Worker + + Flags: + -j, --experimental-json-config Experimental: Support wrangler.json [boolean] + -c, --config Path to .toml configuration file [string] + -e, --env Environment to use for operations and .env files [string] + -h, --help Show help [boolean] + -v, --version Show version number [boolean]" + `); }); test("shows implicit subhelp with --x-versions flag", async () => { @@ -119,23 +121,24 @@ describe("versions subhelp", () => { await setImmediate(); // wait for subhelp expect(std.out).toMatchInlineSnapshot(` - "wrangler versions - - List, view, upload and deploy Versions of your Worker to Cloudflare [beta] - - Commands: - wrangler versions view View the details of a specific version of your Worker [beta] - wrangler versions list List the 10 most recent Versions of your Worker [beta] - wrangler versions upload Uploads your Worker code and config as a new Version [beta] - wrangler versions deploy [version-specs..] Safely roll out new Versions of your Worker by splitting traffic between multiple Versions [beta] - - Flags: - -j, --experimental-json-config Experimental: Support wrangler.json [boolean] - -c, --config Path to .toml configuration file [string] - -e, --env Environment to use for operations and .env files [string] - -h, --help Show help [boolean] - -v, --version Show version number [boolean]" - `); + "wrangler versions + + List, view, upload and deploy Versions of your Worker to Cloudflare [beta] + + Commands: + wrangler versions view View the details of a specific version of your Worker [beta] + wrangler versions list List the 10 most recent Versions of your Worker [beta] + wrangler versions upload Uploads your Worker code and config as a new Version [beta] + wrangler versions deploy [version-specs..] Safely roll out new Versions of your Worker by splitting traffic between multiple Versions [beta] + wrangler versions secret Generate a secret that can be referenced in a Worker + + Flags: + -j, --experimental-json-config Experimental: Support wrangler.json [boolean] + -c, --config Path to .toml configuration file [string] + -e, --env Environment to use for operations and .env files [string] + -h, --help Show help [boolean] + -v, --version Show version number [boolean]" + `); }); test("shows implicit subhelp with --experimental-gradual-rollouts flag", async () => { @@ -145,22 +148,23 @@ describe("versions subhelp", () => { await setImmediate(); // wait for subhelp expect(std.out).toMatchInlineSnapshot(` - "wrangler versions - - List, view, upload and deploy Versions of your Worker to Cloudflare [beta] - - Commands: - wrangler versions view View the details of a specific version of your Worker [beta] - wrangler versions list List the 10 most recent Versions of your Worker [beta] - wrangler versions upload Uploads your Worker code and config as a new Version [beta] - wrangler versions deploy [version-specs..] Safely roll out new Versions of your Worker by splitting traffic between multiple Versions [beta] - - Flags: - -j, --experimental-json-config Experimental: Support wrangler.json [boolean] - -c, --config Path to .toml configuration file [string] - -e, --env Environment to use for operations and .env files [string] - -h, --help Show help [boolean] - -v, --version Show version number [boolean]" - `); + "wrangler versions + + List, view, upload and deploy Versions of your Worker to Cloudflare [beta] + + Commands: + wrangler versions view View the details of a specific version of your Worker [beta] + wrangler versions list List the 10 most recent Versions of your Worker [beta] + wrangler versions upload Uploads your Worker code and config as a new Version [beta] + wrangler versions deploy [version-specs..] Safely roll out new Versions of your Worker by splitting traffic between multiple Versions [beta] + wrangler versions secret Generate a secret that can be referenced in a Worker + + Flags: + -j, --experimental-json-config Experimental: Support wrangler.json [boolean] + -c, --config Path to .toml configuration file [string] + -e, --env Environment to use for operations and .env files [string] + -h, --help Show help [boolean] + -v, --version Show version number [boolean]" + `); }); }); diff --git a/packages/wrangler/src/api/pages/create-worker-bundle-contents.ts b/packages/wrangler/src/api/pages/create-worker-bundle-contents.ts index 8db70fd25f59..1a695e33e5ba 100644 --- a/packages/wrangler/src/api/pages/create-worker-bundle-contents.ts +++ b/packages/wrangler/src/api/pages/create-worker-bundle-contents.ts @@ -86,6 +86,7 @@ function createWorkerBundleFormData( usage_model: undefined, keepVars: undefined, keepSecrets: undefined, + keepBindings: undefined, logpush: undefined, sourceMaps: config?.upload_source_maps ? loadSourceMaps(mainModule, workerBundle.modules, workerBundle) diff --git a/packages/wrangler/src/config/index.ts b/packages/wrangler/src/config/index.ts index 10bd69da2fb6..f4ca007d5c98 100644 --- a/packages/wrangler/src/config/index.ts +++ b/packages/wrangler/src/config/index.ts @@ -41,13 +41,15 @@ export function readConfig( configPath: string | undefined, // Include command specific args as well as the wrangler global flags args: ReadConfigCommandArgs, - requirePagesConfig?: boolean + requirePagesConfig?: boolean, + hideWarnings?: boolean ): Config; export function readConfig( configPath: string | undefined, // Include command specific args as well as the wrangler global flags args: ReadConfigCommandArgs, - requirePagesConfig?: boolean + requirePagesConfig?: boolean, + hideWarnings: boolean = false ): Config { let rawConfig: RawConfig = {}; @@ -112,7 +114,7 @@ export function readConfig( args ); - if (diagnostics.hasWarnings()) { + if (diagnostics.hasWarnings() && !hideWarnings) { logger.warn(diagnostics.renderWarnings()); } if (diagnostics.hasErrors()) { diff --git a/packages/wrangler/src/config/validation.ts b/packages/wrangler/src/config/validation.ts index ed7335ec8969..0f0c86bdd8ca 100644 --- a/packages/wrangler/src/config/validation.ts +++ b/packages/wrangler/src/config/validation.ts @@ -2060,6 +2060,7 @@ const validateUnsafeBinding: ValidatorFn = (diagnostics, field, value) => { if (isRequiredProperty(value, "type", "string")) { const safeBindings = [ "plain_text", + "secret_text", "json", "wasm_module", "data_blob", diff --git a/packages/wrangler/src/deployment-bundle/create-worker-upload-form.ts b/packages/wrangler/src/deployment-bundle/create-worker-upload-form.ts index 435d33c3977c..7dfd279ca25d 100644 --- a/packages/wrangler/src/deployment-bundle/create-worker-upload-form.ts +++ b/packages/wrangler/src/deployment-bundle/create-worker-upload-form.ts @@ -13,31 +13,46 @@ import type { } from "./worker.js"; import type { Json } from "miniflare"; +const moduleTypeMimeType: { [type in CfModuleType]: string | undefined } = { + esm: "application/javascript+module", + commonjs: "application/javascript", + "compiled-wasm": "application/wasm", + buffer: "application/octet-stream", + text: "text/plain", + python: "text/x-python", + "python-requirement": "text/x-python-requirement", + "nodejs-compat-module": undefined, +}; + export function toMimeType(type: CfModuleType): string { - switch (type) { - case "esm": - return "application/javascript+module"; - case "commonjs": - return "application/javascript"; - case "compiled-wasm": - return "application/wasm"; - case "buffer": - return "application/octet-stream"; - case "text": - return "text/plain"; - case "python": - return "text/x-python"; - case "python-requirement": - return "text/x-python-requirement"; - default: - throw new TypeError("Unsupported module: " + type); + const mimeType = moduleTypeMimeType[type]; + if (mimeType === undefined) { + throw new TypeError("Unsupported module: " + type); + } + + return mimeType; +} + +export function fromMimeType(mimeType: string): CfModuleType { + const moduleType = Object.keys(moduleTypeMimeType).find( + (type) => moduleTypeMimeType[type as CfModuleType] === mimeType + ) as CfModuleType | undefined; + if (moduleType === undefined) { + throw new TypeError("Unsupported mime type: " + mimeType); } + + return moduleType; } export type WorkerMetadataBinding = // If you add any new binding types here, also add it to safeBindings // under validateUnsafeBinding in config/validation.ts + + // Inherit is _not_ in safeBindings because it is here for API use only + // wrangler supports this per type today through keep_bindings + | { type: "inherit"; name: string } | { type: "plain_text"; name: string; text: string } + | { type: "secret_text"; name: string; text: string } | { type: "json"; name: string; json: Json } | { type: "wasm_module"; name: string; part: string } | { type: "text_blob"; name: string; part: string } @@ -143,12 +158,14 @@ export function createWorkerUploadForm(worker: CfWorkerInit): FormData { main, sourceMaps, bindings, + rawBindings, migrations, usage_model, compatibility_date, compatibility_flags, keepVars, keepSecrets, + keepBindings, logpush, placement, tail_consumers, @@ -158,7 +175,7 @@ export function createWorkerUploadForm(worker: CfWorkerInit): FormData { let { modules } = worker; - const metadataBindings: WorkerMetadata["bindings"] = []; + const metadataBindings: WorkerMetadataBinding[] = rawBindings ?? []; Object.entries(bindings.vars || {})?.forEach(([key, value]) => { if (typeof value === "string") { @@ -508,6 +525,10 @@ export function createWorkerUploadForm(worker: CfWorkerInit): FormData { keep_bindings ??= []; keep_bindings.push("secret_text", "secret_key"); } + if (keepBindings) { + keep_bindings ??= []; + keep_bindings.push(...keepBindings); + } const metadata: WorkerMetadata = { ...(main.type !== "commonjs" diff --git a/packages/wrangler/src/deployment-bundle/worker.ts b/packages/wrangler/src/deployment-bundle/worker.ts index 18067b040edc..adaba01a3644 100644 --- a/packages/wrangler/src/deployment-bundle/worker.ts +++ b/packages/wrangler/src/deployment-bundle/worker.ts @@ -1,4 +1,8 @@ import type { Route } from "../config/environment"; +import type { + WorkerMetadata, + WorkerMetadataBinding, +} from "./create-worker-upload-form"; import type { Json } from "miniflare"; /** @@ -320,12 +324,20 @@ export interface CfWorkerInit { logfwdr: CfLogfwdr | undefined; unsafe: CfUnsafe | undefined; }; + /** + * The raw bindings - this is basically never provided and it'll be the bindings above + * but if we're just taking from the api and re-putting then this is how we can do that + * without going between the different types + */ + rawBindings?: WorkerMetadataBinding[]; + migrations: CfDurableObjectMigrations | undefined; compatibility_date: string | undefined; compatibility_flags: string[] | undefined; usage_model: "bundled" | "unbound" | undefined; keepVars: boolean | undefined; keepSecrets: boolean | undefined; + keepBindings?: WorkerMetadata["keep_bindings"]; logpush: boolean | undefined; placement: CfPlacement | undefined; tail_consumers: CfTailConsumer[] | undefined; diff --git a/packages/wrangler/src/index.ts b/packages/wrangler/src/index.ts index 8ee07c5d1d76..c06d7b800c08 100644 --- a/packages/wrangler/src/index.ts +++ b/packages/wrangler/src/index.ts @@ -764,7 +764,7 @@ export function createCLIParser(argv: string[]) { "versions", "List, view, upload and deploy Versions of your Worker to Cloudflare [beta]", (yargs) => { - return registerVersionsSubcommands(yargs.command(subHelp)); + return registerVersionsSubcommands(yargs.command(subHelp), subHelp); } ); } diff --git a/packages/wrangler/src/pages/secret/index.ts b/packages/wrangler/src/pages/secret/index.ts index 151b9f10ccb2..7470678b3876 100644 --- a/packages/wrangler/src/pages/secret/index.ts +++ b/packages/wrangler/src/pages/secret/index.ts @@ -12,6 +12,7 @@ import { logger } from "../../logger"; import * as metrics from "../../metrics"; import { parseJSON, readFileSync } from "../../parse"; import { requireAuth } from "../../user"; +import { readFromStdin, trimTrailingWhitespace } from "../../utils/std"; import { PAGES_CONFIG_CACHE_FILENAME } from "../constants"; import { EXIT_CODE_INVALID_PAGES_CONFIG } from "../errors"; import type { Config } from "../../config"; @@ -23,47 +24,6 @@ function isPagesEnv(env: string): env is "production" | "preview" { return ["production", "preview"].includes(env); } -/** - * Remove trailing white space from inputs. - * Matching Wrangler legacy behavior with handling inputs - */ -function trimTrailingWhitespace(str: string) { - return str.trimEnd(); -} - -/** - * Get a promise to the streamed input from stdin. - * - * This function can be used to grab the incoming stream of data from, say, - * piping the output of another process into the wrangler process. - */ -function readFromStdin(): Promise { - return new Promise((resolve, reject) => { - const stdin = process.stdin; - const chunks: string[] = []; - - // When there is data ready to be read, the `readable` event will be triggered. - // In the handler for `readable` we call `read()` over and over until all the available data has been read. - stdin.on("readable", () => { - let chunk; - while (null !== (chunk = stdin.read())) { - chunks.push(chunk); - } - }); - - // When the streamed data is complete the `end` event will be triggered. - // In the handler for `end` we join the chunks together and resolve the promise. - stdin.on("end", () => { - resolve(chunks.join("")); - }); - - // If there is an `error` event then the handler will reject the promise. - stdin.on("error", (err) => { - reject(err); - }); - }); -} - async function pagesProject( env: string | undefined, cliProjectName: string | undefined diff --git a/packages/wrangler/src/secret/index.ts b/packages/wrangler/src/secret/index.ts index 3b440327d88c..4d00e2df0792 100644 --- a/packages/wrangler/src/secret/index.ts +++ b/packages/wrangler/src/secret/index.ts @@ -15,6 +15,7 @@ import { logger } from "../logger"; import * as metrics from "../metrics"; import { APIError, parseJSON, readFileSync } from "../parse"; import { requireAuth } from "../user"; +import { readFromStdin, trimTrailingWhitespace } from "../utils/std"; import type { Config } from "../config"; import type { WorkerMetadataBinding } from "../deployment-bundle/create-worker-upload-form"; import type { @@ -282,11 +283,17 @@ export const secret = (secretYargs: CommonYargsArgv) => { "list", "List all secrets for a Worker", (yargs) => { - return yargs.option("name", { - describe: "Name of the Worker", - type: "string", - requiresArg: true, - }); + return yargs + .option("name", { + describe: "Name of the Worker", + type: "string", + requiresArg: true, + }) + .option("format", { + default: "json", + choices: ["json", "pretty"], + describe: "The format to print the secrets in", + }); }, async (args) => { const config = readConfig(args.config, args); @@ -305,7 +312,17 @@ export const secret = (secretYargs: CommonYargsArgv) => { ? `/accounts/${accountId}/workers/scripts/${scriptName}/secrets` : `/accounts/${accountId}/workers/services/${scriptName}/environments/${args.env}/secrets`; - logger.log(JSON.stringify(await fetchResult(url), null, " ")); + const secrets = + await fetchResult<{ name: string; type: string }[]>(url); + + if (args.pretty) { + for (const workerSecret of secrets) { + logger.log(`Secret Name: ${workerSecret.name}\n`); + } + } else { + logger.log(JSON.stringify(secrets, null, " ")); + } + await metrics.sendMetricsEvent("list encrypted variables", { sendMetrics: config.send_metrics, }); @@ -319,47 +336,6 @@ export const secret = (secretYargs: CommonYargsArgv) => { ); }; -/** - * Remove trailing white space from inputs. - * Matching Wrangler legacy behavior with handling inputs - */ -function trimTrailingWhitespace(str: string) { - return str.trimEnd(); -} - -/** - * Get a promise to the streamed input from stdin. - * - * This function can be used to grab the incoming stream of data from, say, - * piping the output of another process into the wrangler process. - */ -function readFromStdin(): Promise { - return new Promise((resolve, reject) => { - const stdin = process.stdin; - const chunks: string[] = []; - - // When there is data ready to be read, the `readable` event will be triggered. - // In the handler for `readable` we call `read()` over and over until all the available data has been read. - stdin.on("readable", () => { - let chunk; - while (null !== (chunk = stdin.read())) { - chunks.push(chunk); - } - }); - - // When the streamed data is complete the `end` event will be triggered. - // In the handler for `end` we join the chunks together and resolve the promise. - stdin.on("end", () => { - resolve(chunks.join("")); - }); - - // If there is an `error` event then the handler will reject the promise. - stdin.on("error", (err) => { - reject(err); - }); - }); -} - // *** Secret Bulk Section Below *** /** * @description Options for the `secret bulk` command. @@ -526,7 +502,7 @@ export const secretBulkHandler = async (secretBulkArgs: SecretBulkArgs) => { } }; -function validateJSONFileSecrets( +export function validateJSONFileSecrets( content: unknown, jsonFilePath: string ): asserts content is Record { diff --git a/packages/wrangler/src/utils/std.ts b/packages/wrangler/src/utils/std.ts new file mode 100644 index 000000000000..3562602f5268 --- /dev/null +++ b/packages/wrangler/src/utils/std.ts @@ -0,0 +1,40 @@ +/** + * Remove trailing white space from inputs. + * Matching Wrangler legacy behavior with handling inputs + */ +export function trimTrailingWhitespace(str: string) { + return str.trimEnd(); +} + +/** + * Get a promise to the streamed input from stdin. + * + * This function can be used to grab the incoming stream of data from, say, + * piping the output of another process into the wrangler process. + */ +export function readFromStdin(): Promise { + return new Promise((resolve, reject) => { + const stdin = process.stdin; + const chunks: string[] = []; + + // When there is data ready to be read, the `readable` event will be triggered. + // In the handler for `readable` we call `read()` over and over until all the available data has been read. + stdin.on("readable", () => { + let chunk; + while (null !== (chunk = stdin.read())) { + chunks.push(chunk); + } + }); + + // When the streamed data is complete the `end` event will be triggered. + // In the handler for `end` we join the chunks together and resolve the promise. + stdin.on("end", () => { + resolve(chunks.join("")); + }); + + // If there is an `error` event then the handler will reject the promise. + stdin.on("error", (err) => { + reject(err); + }); + }); +} diff --git a/packages/wrangler/src/versions/api.ts b/packages/wrangler/src/versions/api.ts index 22df67e72e3e..f6d600575eba 100644 --- a/packages/wrangler/src/versions/api.ts +++ b/packages/wrangler/src/versions/api.ts @@ -1,4 +1,6 @@ import { fetchResult } from "../cfetch"; +import { confirm } from "../dialogs"; +import { APIError } from "../parse"; import type { TailConsumer } from "../config/environment"; import type { ApiDeployment, @@ -8,6 +10,8 @@ import type { VersionId, } from "./types"; +export const CANNOT_ROLLBACK_WITH_MODIFIED_SECERT_CODE = 10220; + export async function fetchVersion( accountId: string, workerName: string, @@ -104,28 +108,62 @@ export async function createDeployment( accountId: string, workerName: string, versionTraffic: Map, - message: string | undefined + message: string | undefined, + force?: boolean ) { - const res = await fetchResult( - `/accounts/${accountId}/workers/scripts/${workerName}/deployments`, - { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ - strategy: "percentage", - versions: Array.from(versionTraffic).map( - ([version_id, percentage]) => ({ version_id, percentage }) - ), - annotations: { - "workers/message": message, - }, - }), + try { + return await fetchResult( + `/accounts/${accountId}/workers/scripts/${workerName}/deployments${force ? "?force=true" : ""}`, + { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + strategy: "percentage", + versions: Array.from(versionTraffic).map( + ([version_id, percentage]) => ({ version_id, percentage }) + ), + annotations: { + "workers/message": message, + }, + }), + } + ); + } catch (e) { + if ( + e instanceof APIError && + e.code === CANNOT_ROLLBACK_WITH_MODIFIED_SECERT_CODE + ) { + // This is not great but is the best way I could think to handle for now + const errorMsg = e.notes[0].text.replace( + ` [code: ${CANNOT_ROLLBACK_WITH_MODIFIED_SECERT_CODE}]`, + "" + ); + const targetString = "The following secrets have changed:"; + const changedSecrets = errorMsg + .substring(errorMsg.indexOf(targetString) + targetString.length + 1) + .split(", "); + + const confirmed = await confirm( + "The following secrets have changed since the target version was deployed. " + + `Please confirm you wish to continue with the rollback\n` + + changedSecrets.map((secret) => ` * ${secret}`).join("\n") + ); + + if (confirmed) { + return await createDeployment( + accountId, + workerName, + versionTraffic, + message, + true + ); + } else { + throw new Error("Aborting rollback..."); + } + } else { + throw e; } - ); - - // TODO: handle specific errors - - return res; + } } type NonVersionedScriptSettings = { diff --git a/packages/wrangler/src/versions/index.ts b/packages/wrangler/src/versions/index.ts index 3771a7f0d70e..0a9aca2c4fe4 100644 --- a/packages/wrangler/src/versions/index.ts +++ b/packages/wrangler/src/versions/index.ts @@ -13,12 +13,14 @@ import { requireAuth } from "../user"; import { collectKeyValues } from "../utils/collectKeyValues"; import { versionsDeployHandler, versionsDeployOptions } from "./deploy"; import { versionsListHandler, versionsListOptions } from "./list"; +import { registerVersionsSecretsSubcommands } from "./secrets"; import versionsUpload from "./upload"; import { versionsViewHandler, versionsViewOptions } from "./view"; import type { Config } from "../config"; import type { CommonYargsArgv, StrictYargsOptionsToInterface, + SubHelp, } from "../yargs-types"; async function standardPricingWarning(config: Config) { @@ -228,7 +230,8 @@ export async function versionsUploadHandler( } export default function registerVersionsSubcommands( - versionYargs: CommonYargsArgv + versionYargs: CommonYargsArgv, + subHelp: SubHelp ) { versionYargs .command( @@ -254,5 +257,12 @@ export default function registerVersionsSubcommands( "Safely roll out new Versions of your Worker by splitting traffic between multiple Versions [beta]", versionsDeployOptions, versionsDeployHandler + ) + .command( + "secret", + "Generate a secret that can be referenced in a Worker", + (yargs) => { + return registerVersionsSecretsSubcommands(yargs.command(subHelp)); + } ); } diff --git a/packages/wrangler/src/versions/rollback/index.ts b/packages/wrangler/src/versions/rollback/index.ts index 570a8e8c51d5..280b29620e50 100644 --- a/packages/wrangler/src/versions/rollback/index.ts +++ b/packages/wrangler/src/versions/rollback/index.ts @@ -1,5 +1,6 @@ import * as cli from "@cloudflare/cli"; -import { inputPrompt, spinnerWhile } from "@cloudflare/cli/interactive"; +import { spinnerWhile } from "@cloudflare/cli/interactive"; +import { confirm, prompt } from "../../dialogs"; import { UserError } from "../../errors"; import { requireAuth } from "../../user"; import { createDeployment, fetchLatestDeployments, fetchVersion } from "../api"; @@ -45,7 +46,7 @@ export function versionsRollbackOptions(rollbackYargs: CommonYargsArgv) { }) .option("message", { alias: "m", - describe: "The reason for this rollback (optional)", + describe: "The reason for this rollback", type: "string", default: undefined, }) @@ -78,14 +79,12 @@ export async function versionsRollbackHandler(args: VersionsRollbackArgs) { endMessage: "", })); - const message = await inputPrompt({ - type: "text", - label: "Message", - question: - "Please provide a message for this rollback (120 characters max, optional)?", - defaultValue: args.message ?? "Rollback", - acceptDefault: args.yes, - }); + const message = await prompt( + "Please provide an optional message for this rollback (120 characters max)?", + { + defaultValue: args.message ?? "Rollback", + } + ); const version = await fetchVersion(accountId, workerName, versionId); cli.warn( @@ -95,16 +94,11 @@ export async function versionsRollbackHandler(args: VersionsRollbackArgs) { const rollbackTraffic = new Map([[versionId, 100]]); printVersions([version], rollbackTraffic); - const confirm = await inputPrompt({ - type: "confirm", - label: "Rollback", - question: - "Are you sure you want to deploy this Worker Version to 100% of traffic?", - defaultValue: args.yes, // defaultValue: false. if --yes, defaultValue: true - acceptDefault: args.yes, - }); - - if (!confirm) { + const confirmed = await confirm( + "Are you sure you want to deploy this Worker Version to 100% of traffic?", + { defaultValue: true } + ); + if (!confirmed) { cli.cancel("Aborting rollback..."); return; } diff --git a/packages/wrangler/src/versions/secrets/bulk.ts b/packages/wrangler/src/versions/secrets/bulk.ts new file mode 100644 index 000000000000..17bcb4e3b44e --- /dev/null +++ b/packages/wrangler/src/versions/secrets/bulk.ts @@ -0,0 +1,128 @@ +import path from "node:path"; +import readline from "node:readline"; +import { fetchResult } from "../../cfetch"; +import { readConfig } from "../../config"; +import { UserError } from "../../errors"; +import { getLegacyScriptName } from "../../index"; +import { logger } from "../../logger"; +import { parseJSON, readFileSync } from "../../parse"; +import { validateJSONFileSecrets } from "../../secret"; +import { printWranglerBanner } from "../../update-check"; +import { requireAuth } from "../../user"; +import { copyWorkerVersionWithNewSecrets } from "./index"; +import type { + CommonYargsArgv, + StrictYargsOptionsToInterface, +} from "../../yargs-types"; +import type { WorkerVersion } from "./index"; + +export function versionsSecretsPutBulkOptions(yargs: CommonYargsArgv) { + return yargs + .positional("json", { + describe: `The JSON file of key-value pairs to upload, in form {"key": value, ...}`, + type: "string", + }) + .option("name", { + describe: "Name of the Worker", + type: "string", + requiresArg: true, + }) + .option("message", { + describe: "Description of this deployment", + type: "string", + requiresArg: true, + }) + .option("tag", { + describe: "A tag for this version", + type: "string", + requiresArg: true, + }); +} + +export async function versionsSecretPutBulkHandler( + args: StrictYargsOptionsToInterface +) { + await printWranglerBanner(); + const config = readConfig(args.config, args, false, true); + + const scriptName = getLegacyScriptName(args, config); + if (!scriptName) { + throw new UserError( + "Required Worker name missing. Please specify the Worker name in wrangler.toml, or pass it as an argument with `--name `" + ); + } + + const accountId = await requireAuth(config); + + logger.log( + `🌀 Creating the secrets for the Worker "${scriptName}" ${args.env ? `(${args.env})` : ""}` + ); + + let content: Record; + if (args.json) { + const jsonFilePath = path.resolve(args.json); + try { + content = parseJSON>( + readFileSync(jsonFilePath), + jsonFilePath + ); + } catch (e) { + return logger.error( + "Unable to parse JSON file, please ensure the file passed is valid JSON." + ); + } + validateJSONFileSecrets(content, args.json); + } else { + try { + const rl = readline.createInterface({ input: process.stdin }); + let pipedInput = ""; + for await (const line of rl) { + pipedInput += line; + } + content = parseJSON>(pipedInput); + } catch { + return logger.error( + "Unable to parse JSON from the input, please ensure you're passing valid JSON" + ); + } + } + + if (!content) { + return logger.error(`No content found in JSON file or piped input.`); + } + + const secrets = Object.entries(content).map(([key, value]) => ({ + name: key, + value, + })); + + // Grab the latest version + const versions = ( + await fetchResult<{ items: WorkerVersion[] }>( + `/accounts/${accountId}/workers/scripts/${scriptName}/versions` + ) + ).items; + if (versions.length === 0) { + throw new UserError( + "There are currently no uploaded versions of this Worker - please upload a version before uploading a secret." + ); + } + const latestVersion = versions[0]; + + const newVersion = await copyWorkerVersionWithNewSecrets({ + accountId, + scriptName, + versionId: latestVersion.id, + secrets, + versionMessage: args.message, + versionTag: args.tag, + sendMetrics: config.send_metrics, + }); + + for (const secret of secrets) { + logger.log(`✨ Successfully created secret for key: ${secret.name}`); + } + logger.log( + `✨ Success! Created version ${newVersion.id} with ${secrets.length} secrets. To deploy this version to production traffic use the command "wrangler versions deploy --x-versions".` + ); +} diff --git a/packages/wrangler/src/versions/secrets/delete.ts b/packages/wrangler/src/versions/secrets/delete.ts new file mode 100644 index 000000000000..e100c14ae379 --- /dev/null +++ b/packages/wrangler/src/versions/secrets/delete.ts @@ -0,0 +1,118 @@ +import { fetchResult } from "../../cfetch"; +import { readConfig } from "../../config"; +import { confirm } from "../../dialogs"; +import { UserError } from "../../errors"; +import { getLegacyScriptName, isLegacyEnv } from "../../index"; +import { logger } from "../../logger"; +import { printWranglerBanner } from "../../update-check"; +import { requireAuth } from "../../user"; +import { copyWorkerVersionWithNewSecrets } from "./index"; +import type { + CommonYargsArgv, + StrictYargsOptionsToInterface, +} from "../../yargs-types"; +import type { VersionDetails, WorkerVersion } from "./index"; + +export function versionsSecretsDeleteOptions(yargs: CommonYargsArgv) { + return yargs + .positional("key", { + describe: "The variable name to be accessible in the Worker", + type: "string", + }) + .option("name", { + describe: "Name of the Worker", + type: "string", + requiresArg: true, + }) + .option("message", { + describe: "Description of this deployment", + type: "string", + requiresArg: true, + }) + .option("tag", { + describe: "A tag for this version", + type: "string", + requiresArg: true, + }); +} + +export async function versionsSecretDeleteHandler( + args: StrictYargsOptionsToInterface +) { + await printWranglerBanner(); + const config = readConfig(args.config, args, false, true); + + const scriptName = getLegacyScriptName(args, config); + if (!scriptName) { + throw new UserError( + "Required Worker name missing. Please specify the Worker name in wrangler.toml, or pass it as an argument with `--name `" + ); + } + + if (args.key === undefined) { + throw new UserError( + "Secret name is required. Please specify the name of your secret." + ); + } + + const accountId = await requireAuth(config); + + if ( + await confirm( + `Are you sure you want to permanently delete the secret ${ + args.key + } on the Worker ${scriptName}${ + args.env && !isLegacyEnv(config) ? ` (${args.env})` : "" + }?` + ) + ) { + logger.log( + `🌀 Deleting the secret ${args.key} on the Worker ${scriptName}${ + args.env && !isLegacyEnv(config) ? ` (${args.env})` : "" + }` + ); + + // Grab the latest version + const versions = ( + await fetchResult<{ items: WorkerVersion[] }>( + `/accounts/${accountId}/workers/scripts/${scriptName}/versions` + ) + ).items; + if (versions.length === 0) { + throw new UserError( + "There are currently no uploaded versions of this Worker - please upload a version before uploading a secret." + ); + } + const latestVersion = versions[0]; + + const versionInfo = await fetchResult( + `/accounts/${accountId}/workers/scripts/${scriptName}/versions/${latestVersion.id}` + ); + + // Go through all + const newSecrets = versionInfo.resources.bindings + .filter( + (binding) => binding.type === "secret_text" && binding.name !== args.key + ) + .map((binding) => ({ + name: binding.name, + value: "", + inherit: true, + })); + + const newVersion = await copyWorkerVersionWithNewSecrets({ + accountId, + scriptName, + versionId: latestVersion.id, + secrets: newSecrets, + versionMessage: args.message, + versionTag: args.tag, + sendMetrics: config.send_metrics, + overrideAllSecrets: true, + }); + + logger.log( + `✨ Success! Created version ${newVersion.id} with deleted secret ${args.key}. To deploy this version without the secret ${args.key} to production traffic use the command "wrangler versions deploy --x-versions".` + ); + } +} diff --git a/packages/wrangler/src/versions/secrets/index.ts b/packages/wrangler/src/versions/secrets/index.ts new file mode 100644 index 000000000000..3561744e0ceb --- /dev/null +++ b/packages/wrangler/src/versions/secrets/index.ts @@ -0,0 +1,332 @@ +import { fetchResult } from "../../cfetch"; +import { performApiFetch } from "../../cfetch/internal"; +import { + createWorkerUploadForm, + fromMimeType, +} from "../../deployment-bundle/create-worker-upload-form"; +import { FatalError, UserError } from "../../errors"; +import { getMetricsUsageHeaders } from "../../metrics"; +import { + versionsSecretPutBulkHandler, + versionsSecretsPutBulkOptions, +} from "./bulk"; +import { + versionsSecretDeleteHandler, + versionsSecretsDeleteOptions, +} from "./delete"; +import { versionsSecretListHandler, versionsSecretsListOptions } from "./list"; +import { versionsSecretPutHandler, versionsSecretsPutOptions } from "./put"; +import type { + WorkerMetadata as CfWorkerMetadata, + WorkerMetadataBinding, +} from "../../deployment-bundle/create-worker-upload-form"; +import type { + CfModule, + CfTailConsumer, + CfUserLimits, + CfWorkerInit, + CfWorkerSourceMap, +} from "../../deployment-bundle/worker"; +import type { CommonYargsArgv } from "../../yargs-types"; +import type { File, SpecIterableIterator } from "undici"; + +export function registerVersionsSecretsSubcommands(yargs: CommonYargsArgv) { + return yargs + .command( + "put ", + "Create or update a secret variable for a Worker", + versionsSecretsPutOptions, + versionsSecretPutHandler + ) + .command( + "bulk [json]", + "Create or update a secret variable for a Worker", + versionsSecretsPutBulkOptions, + versionsSecretPutBulkHandler + ) + .command( + "delete ", + "Delete a secret variable from a Worker", + versionsSecretsDeleteOptions, + versionsSecretDeleteHandler + ) + .command( + "list", + "List the secrets currently deployed", + versionsSecretsListOptions, + versionsSecretListHandler + ); +} + +// Shared code +export interface WorkerVersion { + id: string; + metadata: WorkerMetadata; + number: number; +} + +export interface WorkerMetadata { + author_email: string; + author_id: string; + created_on: string; + modified_on: string; + source: string; +} + +interface Annotations { + "workers/message"?: string; + "workers/tag"?: string; + "workers/triggered_by"?: string; +} + +export interface VersionDetails { + id: string; + metadata: WorkerMetadata; + annotations?: Annotations; + number: number; + resources: { + bindings: WorkerMetadataBinding[]; + script: { + etag: string; + handlers: string[]; + placement_mode?: "smart"; + last_deployed_from: string; + }; + script_runtime: { + compatibility_date?: string; + compatibility_flags?: string[]; + usage_model: "bundled" | "unbound" | "standard"; + limits: CfUserLimits; + }; + }; +} + +interface ScriptSettings { + logpush: boolean; + tail_consumers: CfTailConsumer[] | null; +} + +interface CopyLatestWorkerVersionArgs { + accountId: string; + scriptName: string; + versionId: string; + secrets: { name: string; value: string; inherit?: boolean }[]; + versionMessage?: string; + versionTag?: string; + sendMetrics?: boolean; + overrideAllSecrets?: boolean; // Used for delete - this will make sure we do not inherit any +} + +// TODO: This is a naive implementation, replace later +export async function copyWorkerVersionWithNewSecrets({ + accountId, + scriptName, + versionId, + secrets, + versionMessage, + versionTag, + sendMetrics, + overrideAllSecrets, +}: CopyLatestWorkerVersionArgs) { + // Grab the specific version info + const versionInfo = await fetchResult( + `/accounts/${accountId}/workers/scripts/${scriptName}/versions/${versionId}` + ); + + // Naive implementation ahead, don't worry too much about it -- we will replace it + const { mainModule, modules, sourceMaps } = await parseModules( + accountId, + scriptName, + versionId + ); + + // Grab the script settings + const scriptSettings = await fetchResult( + `/accounts/${accountId}/workers/scripts/${scriptName}/script-settings` + ); + + // Filter out secrets because we're gonna inherit them + const bindings = versionInfo.resources.bindings.filter( + (binding) => binding.type !== "secret_text" + ); + + // We cannot upload a DO with a namespace_id so remove it + for (const binding of bindings) { + if (binding.type === "durable_object_namespace") { + // @ts-expect-error - it doesn't exist within wrangler but does in the API + delete binding.namespace_id; + } + } + + // Add the new secrets + for (const secret of secrets) { + if (secret.inherit) { + bindings.push({ + type: "inherit", + name: secret.name, + }); + } else { + bindings.push({ + type: "secret_text", + name: secret.name, + text: secret.value, + }); + } + } + + // We don't ever want to remove secret_key + const keepBindings: CfWorkerMetadata["keep_bindings"] = ["secret_key"]; + // If we aren't overriding all secrets then inherit them + if (!overrideAllSecrets) { + keepBindings.push("secret_text"); + } + + const worker: CfWorkerInit = { + name: scriptName, + main: mainModule, + bindings: {} as CfWorkerInit["bindings"], // handled in rawBindings + rawBindings: bindings, + modules, + sourceMaps: sourceMaps, + migrations: undefined, + compatibility_date: versionInfo.resources.script_runtime.compatibility_date, + compatibility_flags: + versionInfo.resources.script_runtime.compatibility_flags, + usage_model: versionInfo.resources.script_runtime + .usage_model as CfWorkerInit["usage_model"], + keepVars: false, // we're re-uploading everything + keepSecrets: false, // handled in keepBindings + keepBindings, + logpush: scriptSettings.logpush, + placement: + versionInfo.resources.script.placement_mode === "smart" + ? { mode: "smart" } + : undefined, + tail_consumers: scriptSettings.tail_consumers ?? undefined, + limits: versionInfo.resources.script_runtime.limits, + annotations: { + "workers/message": versionMessage, + "workers/tag": versionTag, + }, + }; + + const body = createWorkerUploadForm(worker); + const result = await fetchResult<{ + available_on_subdomain: boolean; + id: string | null; + etag: string | null; + deployment_id: string | null; + }>( + `/accounts/${accountId}/workers/scripts/${scriptName}/versions`, + { + method: "POST", + body, + headers: await getMetricsUsageHeaders(sendMetrics), + }, + new URLSearchParams({ + include_subdomain_availability: "true", + // pass excludeScript so the whole body of the + // script doesn't get included in the response + excludeScript: "true", + }) + ); + + return result; +} + +async function parseModules( + accountId: string, + scriptName: string, + versionId: string +): Promise<{ + mainModule: CfModule; + modules: CfModule[]; + sourceMaps: CfWorkerSourceMap[]; +}> { + // Pull the Worker content - https://developers.cloudflare.com/api/operations/worker-script-get-content + const contentRes = await performApiFetch( + `/accounts/${accountId}/workers/scripts/${scriptName}/content/v2?version=${versionId}` + ); + if ( + contentRes.headers.get("content-type")?.startsWith("multipart/form-data") + ) { + const formData = await contentRes.formData(); + + // Workers Sites is not supported + if (formData.get("__STATIC_CONTENT_MANIFEST") !== null) { + throw new UserError( + "Workers Sites is not supported for `versions secret put` today." + ); + } + + // Load the main module and any additionals + const entrypoint = contentRes.headers.get("cf-entrypoint"); + if (entrypoint === null) { + throw new FatalError("Got modules without cf-entrypoint header"); + } + + const entrypointPart = formData.get(entrypoint) as File | null; + if (entrypointPart === null) { + throw new FatalError("Could not find entrypoint in form-data"); + } + + const mainModule: CfModule = { + name: entrypointPart.name, + filePath: "", + content: await entrypointPart.text(), + type: fromMimeType(entrypointPart.type), + }; + + // Load all modules that are not the entrypoint or sourcemaps + const modules = await Promise.all( + Array.from(formData.entries() as SpecIterableIterator<[string, File]>) + .filter( + ([name, file]) => + name !== entrypoint && file.type !== "application/source-map" + ) + .map( + async ([name, file]) => + ({ + name, + filePath: "", + content: await file.text(), + type: fromMimeType(file.type), + }) as CfModule + ) + ); + + // Load sourcemaps + const sourceMaps = await Promise.all( + Array.from(formData.entries() as SpecIterableIterator<[string, File]>) + .filter(([_, file]) => file.type === "application/source-map") + .map( + async ([name, file]) => + ({ + name, + content: await file.text(), + }) as CfWorkerSourceMap + ) + ); + + return { mainModule, modules, sourceMaps }; + } else { + const contentType = contentRes.headers.get("content-type"); + if (contentType === null) { + throw new FatalError( + "No content-type header was provided for non-module Worker content" + ); + } + + // good old Service Worker with no additional modules + const content = await contentRes.text(); + + const mainModule: CfModule = { + name: "index.js", + filePath: "", + content, + type: fromMimeType(contentType), + }; + + return { mainModule, modules: [], sourceMaps: [] }; + } +} diff --git a/packages/wrangler/src/versions/secrets/list.ts b/packages/wrangler/src/versions/secrets/list.ts new file mode 100644 index 000000000000..e002123a6f2f --- /dev/null +++ b/packages/wrangler/src/versions/secrets/list.ts @@ -0,0 +1,94 @@ +import { fetchResult } from "../../cfetch"; +import { readConfig } from "../../config"; +import { UserError } from "../../errors"; +import { getLegacyScriptName } from "../../index"; +import { logger } from "../../logger"; +import { printWranglerBanner } from "../../update-check"; +import { requireAuth } from "../../user"; +import { fetchDeploymentVersions, fetchLatestDeployment } from "../api"; +import type { VersionDetails } from "."; +import type { + CommonYargsArgv, + StrictYargsOptionsToInterface, +} from "../../yargs-types"; +import type { ApiVersion, VersionCache } from "../types"; + +export function versionsSecretsListOptions(yargs: CommonYargsArgv) { + return yargs + .option("name", { + describe: "Name of the Worker", + type: "string", + requiresArg: true, + }) + .option("latest-version", { + describe: "Only show the latest version", + type: "boolean", + default: false, + }); +} + +export async function versionsSecretListHandler( + args: StrictYargsOptionsToInterface +) { + await printWranglerBanner(); + const config = readConfig(args.config, args, false, true); + + const scriptName = getLegacyScriptName(args, config); + if (!scriptName) { + throw new UserError( + "Required Worker name missing. Please specify the Worker name in wrangler.toml, or pass it as an argument with `--name `" + ); + } + + const accountId = await requireAuth(config); + const versionCache: VersionCache = new Map(); + + let versions: ApiVersion[] = []; + let rollout: Map = new Map(); + if (args.latestVersion) { + // Grab the latest version + const mostRecentVersions = ( + await fetchResult<{ items: ApiVersion[] }>( + `/accounts/${accountId}/workers/scripts/${scriptName}/versions` + ) + ).items; + if (mostRecentVersions.length === 0) { + throw new UserError( + "There are currently no uploaded versions of this Worker - please upload a version." + ); + } + const latestVersion = mostRecentVersions[0]; + versions = [latestVersion]; + + // Check if the version is in the latest deployment + const latestDeployment = await fetchLatestDeployment(accountId, scriptName); + const deploymentVersion = latestDeployment?.versions.find( + (ver) => ver.version_id === latestVersion.id + ); + + rollout.set(latestVersion.id, deploymentVersion?.percentage ?? 0); + } else { + const latestDeployment = await fetchLatestDeployment(accountId, scriptName); + [versions, rollout] = await fetchDeploymentVersions( + accountId, + scriptName, + latestDeployment, + versionCache + ); + } + + for (const version of versions) { + logger.log( + `-- Version ${version.id} (${rollout.get(version.id)}%) secrets --` + ); + + const secrets = (version as VersionDetails).resources.bindings.filter( + (binding) => binding.type === "secret_text" + ); + for (const secret of secrets) { + logger.log(`Secret Name: ${secret.name}`); + } + + logger.log(); + } +} diff --git a/packages/wrangler/src/versions/secrets/put.ts b/packages/wrangler/src/versions/secrets/put.ts new file mode 100644 index 000000000000..367a178fc706 --- /dev/null +++ b/packages/wrangler/src/versions/secrets/put.ts @@ -0,0 +1,98 @@ +import { fetchResult } from "../../cfetch"; +import { readConfig } from "../../config"; +import { prompt } from "../../dialogs"; +import { UserError } from "../../errors"; +import { getLegacyScriptName } from "../../index"; +import { logger } from "../../logger"; +import { printWranglerBanner } from "../../update-check"; +import { requireAuth } from "../../user"; +import { readFromStdin, trimTrailingWhitespace } from "../../utils/std"; +import { copyWorkerVersionWithNewSecrets } from "./index"; +import type { + CommonYargsArgv, + StrictYargsOptionsToInterface, +} from "../../yargs-types"; +import type { WorkerVersion } from "./index"; + +export function versionsSecretsPutOptions(yargs: CommonYargsArgv) { + return yargs + .positional("key", { + describe: "The variable name to be accessible in the Worker", + type: "string", + }) + .option("name", { + describe: "Name of the Worker", + type: "string", + requiresArg: true, + }) + .option("message", { + describe: "Description of this deployment", + type: "string", + requiresArg: true, + }) + .option("tag", { + describe: "A tag for this version", + type: "string", + requiresArg: true, + }); +} + +export async function versionsSecretPutHandler( + args: StrictYargsOptionsToInterface +) { + await printWranglerBanner(); + const config = readConfig(args.config, args, false, true); + + const scriptName = getLegacyScriptName(args, config); + if (!scriptName) { + throw new UserError( + "Required Worker name missing. Please specify the Worker name in wrangler.toml, or pass it as an argument with `--name `" + ); + } + + if (args.key === undefined) { + throw new UserError( + "Secret name is required. Please specify the name of your secret." + ); + } + + const accountId = await requireAuth(config); + + const isInteractive = process.stdin.isTTY; + const secretValue = trimTrailingWhitespace( + isInteractive + ? await prompt("Enter a secret value:", { isSecret: true }) + : await readFromStdin() + ); + + logger.log( + `🌀 Creating the secret for the Worker "${scriptName}" ${args.env ? `(${args.env})` : ""}` + ); + + // Grab the latest version + const versions = ( + await fetchResult<{ items: WorkerVersion[] }>( + `/accounts/${accountId}/workers/scripts/${scriptName}/versions` + ) + ).items; + if (versions.length === 0) { + throw new UserError( + "There are currently no uploaded versions of this Worker. Please upload a version before uploading a secret." + ); + } + const latestVersion = versions[0]; + + const newVersion = await copyWorkerVersionWithNewSecrets({ + accountId, + scriptName, + versionId: latestVersion.id, + secrets: [{ name: args.key, value: secretValue }], + versionMessage: args.message, + versionTag: args.tag, + sendMetrics: config.send_metrics, + }); + + logger.log( + `✨ Success! Created version ${newVersion.id} with secret ${args.key}. To deploy this version with secret ${args.key} to production traffic use the command "wrangler versions deploy --x-versions".` + ); +} diff --git a/packages/wrangler/src/versions/types.d.ts b/packages/wrangler/src/versions/types.ts similarity index 54% rename from packages/wrangler/src/versions/types.d.ts rename to packages/wrangler/src/versions/types.ts index 0a1411fcf3f0..b1c167efc0bb 100644 --- a/packages/wrangler/src/versions/types.d.ts +++ b/packages/wrangler/src/versions/types.ts @@ -1,3 +1,6 @@ +import type { WorkerMetadataBinding } from "../deployment-bundle/create-worker-upload-form"; +import type { CfUserLimits } from "../deployment-bundle/worker"; + export type Percentage = number; export type UUID = string; export type VersionId = UUID; @@ -29,7 +32,21 @@ export type ApiVersion = { "workers/message"?: string; "workers/tag"?: string; }; - // other properties not typed as not used + resources: { + bindings: WorkerMetadataBinding[]; + script: { + etag: string; + handlers: string[]; + placement_mode?: "smart"; + last_deployed_from: string; + }; + script_runtime: { + compatibility_date?: string; + compatibility_flags?: string[]; + usage_model: "bundled" | "unbound" | "standard"; + limits: CfUserLimits; + }; + }; }; -type VersionCache = Map; +export type VersionCache = Map;