Skip to content
Open
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
16 changes: 16 additions & 0 deletions .changeset/secrets-file-versions-upload.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
---
"wrangler": minor
---

feat: add `--secrets-file` parameter to `wrangler versions upload`

You can now upload secrets alongside your Worker code in a single operation using the `--secrets-file` parameter. The file format matches what's used by `wrangler versions secret bulk`, supporting both JSON and .env formats.

Example usage:

```bash
wrangler versions upload --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`.
255 changes: 255 additions & 0 deletions packages/wrangler/src/__tests__/versions/upload.test.ts
Original file line number Diff line number Diff line change
@@ -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_key", "secret_text"]);

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_key", "secret_text"]);

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_key", "secret_text"]);

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();
});
});
32 changes: 30 additions & 2 deletions packages/wrangler/src/versions/upload.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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";
Expand Down Expand Up @@ -102,6 +104,7 @@ type Props = {
tag: string | undefined;
message: string | undefined;
previewAlias: string | undefined;
secretsFile: string | undefined;
};

export const versionsUploadCommand = createCommand({
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -390,6 +399,7 @@ export const versionsUploadCommand = createCommand({
previewAlias: previewAlias,
experimentalAutoCreate: args.experimentalAutoCreate,
outFile: args.outfile,
secretsFile: args.secretsFile,
});

writeOutput({
Expand Down Expand Up @@ -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"
Expand All @@ -694,6 +718,7 @@ See https://developers.cloudflare.com/workers/platform/compatibility-dates for m
name: scriptName,
main,
bindings,
rawBindings,
migrations,
modules,
sourceMaps: uploadSourceMaps
Expand All @@ -702,7 +727,10 @@ 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
keepSecrets: props.secretsFile ? false : true,
keepBindings: props.secretsFile
? ["secret_key", "secret_text"]
: undefined,
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I don't think this change is quite right. They cancel each other out.

When keepSecrets is true (i.e. here secretsFile is falsy) we automatically add secret_key and secret_text to the keep_bindings field in createWorkerUploadForm().

And here if secretsFile is truthy, then we are explicitly adding secret_key and secret_text.

If we are saying that secretsFile is the full source of truth for secrets then we should be setting keepSecrets to false and not setting keepBindings at all.

If we are saying that secretsFiles is additive to the current secrets in the dashboard then keepSecrets should always be true and keepBindings should not be defined.

From looking at how wrangler versions secret bulk works, we should not be treating the secetsFile as a source of truth but only additive. I think we should follow that here too.

So I think this should be:

Suggested change
keepSecrets: props.secretsFile ? false : true,
keepBindings: props.secretsFile
? ["secret_key", "secret_text"]
: undefined,
// 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,
Expand All @@ -722,7 +750,7 @@ See https://developers.cloudflare.com/workers/platform/compatibility-dates for m
run_worker_first: props.assetsOptions.run_worker_first,
}
: undefined,
logpush: undefined, // both logpush and observability are not supported in versions upload
logpush: undefined,
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

no reason to remove this comment

observability: undefined,
};

Expand Down
Loading