diff --git a/.changeset/secrets-file-versions-upload.md b/.changeset/secrets-file-versions-upload.md new file mode 100644 index 000000000000..33ddb0b4f1bd --- /dev/null +++ b/.changeset/secrets-file-versions-upload.md @@ -0,0 +1,16 @@ +--- +"wrangler": minor +--- + +feat: add `--secrets-file` parameter to `wrangler deploy` and `wrangler versions upload` + +You can now upload secrets alongside your Worker code in a single operation using the `--secrets-file` parameter on both `wrangler deploy` and `wrangler versions upload`. The file format matches what's used by `wrangler versions secret bulk`, supporting both JSON and .env formats. + +Example usage: + +```bash +wrangler deploy --secrets-file .env.production +wrangler versions upload --secrets-file secrets.json +``` + +Secrets not included in the file will be inherited from the previous version, matching the behavior of `wrangler versions secret bulk`. diff --git a/packages/wrangler/src/__tests__/deploy.test.ts b/packages/wrangler/src/__tests__/deploy.test.ts index b6acce165e2e..b0de6e5366ae 100644 --- a/packages/wrangler/src/__tests__/deploy.test.ts +++ b/packages/wrangler/src/__tests__/deploy.test.ts @@ -15893,3 +15893,180 @@ declare module "vitest" { interface Assertion extends CustomMatchers {} interface AsymmetricMatchersContaining extends CustomMatchers {} } + +describe("deploy --secrets-file", () => { + const std = mockConsoleMethods(); + runInTempDir(); + mockAccountId(); + mockApiToken(); + + const workerName = "test-worker"; + + beforeEach(() => { + mockLastDeploymentRequest(); + mockDeploymentsListRequest(); + writeWranglerConfig({ + name: workerName, + main: "./index.js", + }); + writeWorkerSource(); + }); + + it("should upload secrets from a JSON file alongside the worker", async () => { + const secretsFile = "secrets.json"; + fs.writeFileSync( + secretsFile, + JSON.stringify({ + SECRET1: "value1", + SECRET2: "value2", + }) + ); + + mockServiceScriptData({ + scriptName: workerName, + script: { id: workerName }, + }); + mockSubDomainRequest(); + mockUploadWorkerRequest({ + expectedBindings: [ + { + type: "secret_text", + name: "SECRET1", + text: "value1", + }, + { + type: "secret_text", + name: "SECRET2", + text: "value2", + }, + ], + expectedCompatibilityDate: "2022-01-12", + expectedMainModule: "index.js", + keepSecrets: true, + }); + + await runWrangler(`deploy --secrets-file ${secretsFile}`); + + expect(std.out).toMatchInlineSnapshot(` + "Total Upload: xx KiB / gzip: xx KiB + Your worker has access to the following bindings: + - Secrets: + - SECRET1: (hidden) + - SECRET2: (hidden) + Uploaded test-worker (TIMINGS) + Deployed test-worker triggers (TIMINGS) + https://test-worker.test-sub-domain.workers.dev + Current Version ID: Galaxy-Class" + `); + }); + + it("should upload secrets from a .env file alongside the worker", async () => { + const secretsFile = ".env.production"; + fs.writeFileSync( + secretsFile, + `SECRET1=value1 +SECRET2=value2 +# Comment line +SECRET3=value3` + ); + + mockServiceScriptData({ + scriptName: workerName, + script: { id: workerName }, + }); + mockSubDomainRequest(); + mockUploadWorkerRequest({ + expectedBindings: expect.arrayContaining([ + { + type: "secret_text", + name: "SECRET1", + text: "value1", + }, + { + type: "secret_text", + name: "SECRET2", + text: "value2", + }, + { + type: "secret_text", + name: "SECRET3", + text: "value3", + }, + ]), + expectedCompatibilityDate: "2022-01-12", + expectedMainModule: "index.js", + keepSecrets: true, + }); + + await runWrangler(`deploy --secrets-file ${secretsFile}`); + + expect(std.out).toMatchInlineSnapshot(` + "Total Upload: xx KiB / gzip: xx KiB + Your worker has access to the following bindings: + - Secrets: + - SECRET1: (hidden) + - SECRET2: (hidden) + - SECRET3: (hidden) + Uploaded test-worker (TIMINGS) + Deployed test-worker triggers (TIMINGS) + https://test-worker.test-sub-domain.workers.dev + Current Version ID: Galaxy-Class" + `); + }); + + it("should set keepSecrets to inherit non-provided secrets when providing secrets file", async () => { + const secretsFile = "secrets.json"; + fs.writeFileSync( + secretsFile, + JSON.stringify({ + MY_SECRET: "secret_value", + }) + ); + + mockServiceScriptData({ + scriptName: workerName, + script: { id: workerName }, + }); + mockSubDomainRequest(); + mockUploadWorkerRequest({ + expectedBindings: [ + { + type: "secret_text", + name: "MY_SECRET", + text: "secret_value", + }, + ], + expectedCompatibilityDate: "2022-01-12", + expectedMainModule: "index.js", + keepSecrets: true, + }); + + await runWrangler(`deploy --secrets-file ${secretsFile}`); + + expect(std.out).toMatchInlineSnapshot(` + "Total Upload: xx KiB / gzip: xx KiB + Your worker has access to the following bindings: + - Secrets: + - MY_SECRET: (hidden) + Uploaded test-worker (TIMINGS) + Deployed test-worker triggers (TIMINGS) + https://test-worker.test-sub-domain.workers.dev + Current Version ID: Galaxy-Class" + `); + }); + + it("should fail when secrets file does not exist", async () => { + await expect( + runWrangler("deploy --secrets-file non-existent-file.json") + ).rejects.toThrowError(); + }); + + it("should fail when secrets file contains invalid JSON", async () => { + const secretsFile = "invalid.json"; + fs.writeFileSync(secretsFile, "{ invalid json }"); + + await expect( + runWrangler(`deploy --secrets-file ${secretsFile}`) + ).rejects.toThrowError(); + }); +}); diff --git a/packages/wrangler/src/__tests__/versions/upload.test.ts b/packages/wrangler/src/__tests__/versions/upload.test.ts new file mode 100644 index 000000000000..42386885cd95 --- /dev/null +++ b/packages/wrangler/src/__tests__/versions/upload.test.ts @@ -0,0 +1,255 @@ +import { writeFileSync } from "node:fs"; +import { http, HttpResponse } from "msw"; +import { beforeEach, describe, expect, it } from "vitest"; +import { mockAccountId, mockApiToken } from "../helpers/mock-account-id"; +import { mockConsoleMethods } from "../helpers/mock-console"; +import { createFetchResult, msw } from "../helpers/msw"; +import { runInTempDir } from "../helpers/run-in-tmp"; +import { runWrangler } from "../helpers/run-wrangler"; +import { writeWorkerSource } from "../helpers/write-worker-source"; +import { writeWranglerConfig } from "../helpers/write-wrangler-config"; + +describe("versions upload --secrets-file", () => { + const std = mockConsoleMethods(); + runInTempDir(); + mockAccountId(); + mockApiToken(); + + const workerName = "test-worker"; + + function mockGetScript() { + msw.use( + http.get( + `*/accounts/:accountId/workers/services/:scriptName`, + ({ params }) => { + expect(params.scriptName).toEqual(workerName); + + return HttpResponse.json( + createFetchResult({ + default_environment: { + script: { + last_deployed_from: "wrangler", + }, + }, + }) + ); + }, + { once: true } + ) + ); + } + + beforeEach(() => { + writeWranglerConfig({ + name: workerName, + main: "./index.js", + }); + writeWorkerSource(); + }); + + it("should upload secrets from a JSON file alongside the worker version", async () => { + mockGetScript(); + const secretsFile = "secrets.json"; + writeFileSync( + secretsFile, + JSON.stringify({ + SECRET1: "value1", + SECRET2: "value2", + }) + ); + + msw.use( + http.post( + "*/accounts/:accountId/workers/scripts/:scriptName/versions", + async ({ request, params }) => { + expect(params.accountId).toEqual("some-account-id"); + expect(params.scriptName).toEqual(workerName); + + const formData = await request.formData(); + const metadata = JSON.parse(formData.get("metadata") as string); + + expect(metadata.bindings).toEqual([ + { + type: "secret_text", + name: "SECRET1", + text: "value1", + }, + { + type: "secret_text", + name: "SECRET2", + text: "value2", + }, + ]); + + expect(metadata.keep_bindings).toEqual(["secret_text", "secret_key"]); + + return HttpResponse.json( + createFetchResult({ + id: "version-id-123", + startup_time_ms: 100, + metadata: { + has_preview: false, + }, + }) + ); + }, + { once: true } + ) + ); + + await runWrangler( + `versions upload --name ${workerName} --secrets-file ${secretsFile}` + ); + + expect(std.out).toContain("Worker Startup Time:"); + }); + + it("should upload secrets from a .env file alongside the worker version", async () => { + mockGetScript(); + const secretsFile = ".env.production"; + writeFileSync( + secretsFile, + `SECRET1=value1 +SECRET2=value2 +# Comment line +SECRET3=value3` + ); + + msw.use( + http.post( + "*/accounts/:accountId/workers/scripts/:scriptName/versions", + async ({ request, params }) => { + expect(params.accountId).toEqual("some-account-id"); + expect(params.scriptName).toEqual(workerName); + + const formData = await request.formData(); + const metadata = JSON.parse(formData.get("metadata") as string); + + expect(metadata.bindings).toEqual( + expect.arrayContaining([ + { + type: "secret_text", + name: "SECRET1", + text: "value1", + }, + { + type: "secret_text", + name: "SECRET2", + text: "value2", + }, + { + type: "secret_text", + name: "SECRET3", + text: "value3", + }, + ]) + ); + + expect(metadata.keep_bindings).toEqual(["secret_text", "secret_key"]); + + return HttpResponse.json( + createFetchResult({ + id: "version-id-123", + startup_time_ms: 100, + metadata: { + has_preview: false, + }, + }) + ); + }, + { once: true } + ) + ); + + await runWrangler( + `versions upload --name ${workerName} --secrets-file ${secretsFile}` + ); + + expect(std.out).toContain("Worker Startup Time:"); + }); + + it("should set keep_bindings to inherit non-provided secrets when providing secrets file", async () => { + mockGetScript(); + const secretsFile = "secrets.json"; + writeFileSync( + secretsFile, + JSON.stringify({ + MY_SECRET: "secret_value", + }) + ); + + msw.use( + http.post( + "*/accounts/:accountId/workers/scripts/:scriptName/versions", + async ({ request }) => { + const formData = await request.formData(); + const metadata = JSON.parse(formData.get("metadata") as string); + + expect(metadata.keep_bindings).toEqual(["secret_text", "secret_key"]); + + return HttpResponse.json( + createFetchResult({ + id: "version-id-123", + startup_time_ms: 100, + metadata: { + has_preview: false, + }, + }) + ); + }, + { once: true } + ) + ); + + await runWrangler( + `versions upload --name ${workerName} --secrets-file ${secretsFile}` + ); + }); + + it("should inherit secrets when not providing secrets file", async () => { + mockGetScript(); + msw.use( + http.post( + "*/accounts/:accountId/workers/scripts/:scriptName/versions", + async ({ request }) => { + const formData = await request.formData(); + const metadata = JSON.parse(formData.get("metadata") as string); + + expect(metadata.keep_bindings).toEqual(["secret_text", "secret_key"]); + + return HttpResponse.json( + createFetchResult({ + id: "version-id-123", + startup_time_ms: 100, + metadata: { + has_preview: false, + }, + }) + ); + }, + { once: true } + ) + ); + + await runWrangler(`versions upload --name ${workerName}`); + }); + + it("should fail when secrets file does not exist", async () => { + await expect( + runWrangler( + `versions upload --name ${workerName} --secrets-file non-existent-file.json` + ) + ).rejects.toThrowError(); + }); + + it("should fail when secrets file contains invalid JSON", async () => { + const secretsFile = "invalid.json"; + writeFileSync(secretsFile, "{ invalid json }"); + + await expect( + runWrangler( + `versions upload --name ${workerName} --secrets-file ${secretsFile}` + ) + ).rejects.toThrowError(); + }); +}); diff --git a/packages/wrangler/src/deploy/deploy.ts b/packages/wrangler/src/deploy/deploy.ts index 3048a55d5ec3..015f1f525021 100644 --- a/packages/wrangler/src/deploy/deploy.ts +++ b/packages/wrangler/src/deploy/deploy.ts @@ -46,6 +46,7 @@ import { putConsumer, putConsumerById, } from "../queues/client"; +import { parseBulkInputToObject } from "../secret"; import { syncWorkersSite } from "../sites"; import { getSourceMappedString, @@ -72,6 +73,7 @@ import type { ZoneIdRoute, ZoneNameRoute, } from "../config/environment"; +import type { WorkerMetadataBinding } from "../deployment-bundle/create-worker-upload-form"; import type { Entry } from "../deployment-bundle/entry"; import type { CfModule, @@ -122,6 +124,7 @@ type Props = { metafile: string | boolean | undefined; containersRollout: "immediate" | "gradual" | undefined; strict: boolean | undefined; + secretsFile: string | undefined; }; export type RouteObject = ZoneIdRoute | ZoneNameRoute | CustomDomainRoute; @@ -730,6 +733,20 @@ See https://developers.cloudflare.com/workers/platform/compatibility-dates for m }, }); + let rawBindings: WorkerMetadataBinding[] | undefined; + if (props.secretsFile) { + const secretsContent = await parseBulkInputToObject(props.secretsFile); + if (secretsContent) { + rawBindings = Object.entries(secretsContent).map( + ([secretName, secretValue]) => ({ + type: "secret_text", + name: secretName, + text: secretValue, + }) + ); + } + } + if (workersSitesAssets.manifest) { modules.push({ name: "__STATIC_CONTENT_MANIFEST", @@ -756,6 +773,7 @@ See https://developers.cloudflare.com/workers/platform/compatibility-dates for m name: scriptName, main, bindings, + rawBindings, migrations, modules, containers: config.containers ?? undefined, @@ -765,7 +783,7 @@ See https://developers.cloudflare.com/workers/platform/compatibility-dates for m compatibility_date: compatibilityDate, compatibility_flags: compatibilityFlags, keepVars, - keepSecrets: keepVars, // keepVars implies keepSecrets + keepSecrets: keepVars || !!props.secretsFile, logpush: props.logpush !== undefined ? props.logpush : config.logpush, placement, tail_consumers: config.tail_consumers, diff --git a/packages/wrangler/src/deploy/index.ts b/packages/wrangler/src/deploy/index.ts index ef6b0a6fcf66..f319513ad27b 100644 --- a/packages/wrangler/src/deploy/index.ts +++ b/packages/wrangler/src/deploy/index.ts @@ -243,6 +243,12 @@ export const deployCommand = createCommand({ type: "boolean", default: false, }, + "secrets-file": { + describe: + "Path to a file containing secrets to upload with the deployment (JSON or .env format)", + type: "string", + requiresArg: true, + }, }, behaviour: { useConfigRedirectIfAvailable: true, @@ -389,6 +395,7 @@ export const deployCommand = createCommand({ experimentalAutoCreate: args.experimentalAutoCreate, containersRollout: args.containersRollout, strict: args.strict, + secretsFile: args.secretsFile, }); writeOutput({ diff --git a/packages/wrangler/src/versions/upload.ts b/packages/wrangler/src/versions/upload.ts index 85a2df2db3ed..95573b17a0ad 100644 --- a/packages/wrangler/src/versions/upload.ts +++ b/packages/wrangler/src/versions/upload.ts @@ -50,6 +50,7 @@ import { ParseError } from "../parse"; import { getWranglerTmpDir } from "../paths"; import { ensureQueuesExistByConfig } from "../queues/client"; import { getWorkersDevSubdomain } from "../routes"; +import { parseBulkInputToObject } from "../secret"; import { getSourceMappedString, maybeRetrieveFileSourceMap, @@ -66,6 +67,7 @@ import { retryOnAPIFailure } from "../utils/retry"; import { patchNonVersionedScriptSettings } from "./api"; import type { AssetsOptions } from "../assets"; import type { Config } from "../config"; +import type { WorkerMetadataBinding } from "../deployment-bundle/create-worker-upload-form"; import type { Entry } from "../deployment-bundle/entry"; import type { CfPlacement, CfWorkerInit } from "../deployment-bundle/worker"; import type { RetrieveSourceMapFunction } from "../sourcemap"; @@ -102,6 +104,7 @@ type Props = { tag: string | undefined; message: string | undefined; previewAlias: string | undefined; + secretsFile: string | undefined; }; export const versionsUploadCommand = createCommand({ @@ -264,6 +267,12 @@ export const versionsUploadCommand = createCommand({ hidden: true, alias: "x-auto-create", }, + "secrets-file": { + describe: + "Path to a file containing secrets to upload with the version (JSON or .env format)", + type: "string", + requiresArg: true, + }, }, behaviour: { useConfigRedirectIfAvailable: true, @@ -390,6 +399,7 @@ export const versionsUploadCommand = createCommand({ previewAlias: previewAlias, experimentalAutoCreate: args.experimentalAutoCreate, outFile: args.outfile, + secretsFile: args.secretsFile, }); writeOutput({ @@ -677,6 +687,20 @@ See https://developers.cloudflare.com/workers/platform/compatibility-dates for m vars: { ...config.vars, ...props.vars }, }); + let rawBindings: WorkerMetadataBinding[] | undefined; + if (props.secretsFile) { + const secretsContent = await parseBulkInputToObject(props.secretsFile); + if (secretsContent) { + rawBindings = Object.entries(secretsContent).map( + ([secretName, secretValue]) => ({ + type: "secret_text", + name: secretName, + text: secretValue, + }) + ); + } + } + // The upload API only accepts an empty string or no specified placement for the "off" mode. const placement: CfPlacement | undefined = config.placement?.mode === "smart" @@ -694,6 +718,7 @@ See https://developers.cloudflare.com/workers/platform/compatibility-dates for m name: scriptName, main, bindings, + rawBindings, migrations, modules, sourceMaps: uploadSourceMaps @@ -702,7 +727,9 @@ See https://developers.cloudflare.com/workers/platform/compatibility-dates for m compatibility_date: compatibilityDate, compatibility_flags: compatibilityFlags, keepVars: props.keepVars ?? false, - keepSecrets: true, // until wrangler.toml specifies secret bindings, we need to inherit from the previous Worker Version + // we never delete secret bindings when uploading, even if we are setting secrets from a file + // so inherit all unchanged secrets from the previous Worker Version + keepSecrets: true, placement, tail_consumers: config.tail_consumers, limits: config.limits,