diff --git a/packages/cli/index.ts b/packages/cli/index.ts index 35be0d3dfcfa..09e6829f334d 100644 --- a/packages/cli/index.ts +++ b/packages/cli/index.ts @@ -163,15 +163,16 @@ export const warn = ( shape = shapes.corners.bl, // current default for backcompat -- TODO: change default to true once all callees have been updated multiline = false, + newlineBefore = true, } = {} ) => { logRaw( format(msg, { firstLinePrefix: gray(shape) + space() + status.warning, linePrefix: gray(shapes.bar), - newlineBefore: true, formatLine: (line) => dim(line), // for backcompat but it's not ideal for this to be "dim" multiline, + newlineBefore, }) ); }; diff --git a/packages/wrangler/src/__tests__/deploy.test.ts b/packages/wrangler/src/__tests__/deploy.test.ts index c57fb1291ae7..a1112e0c1eed 100644 --- a/packages/wrangler/src/__tests__/deploy.test.ts +++ b/packages/wrangler/src/__tests__/deploy.test.ts @@ -43,6 +43,7 @@ import { mswSuccessOauthHandlers, mswSuccessUserHandlers, } from "./helpers/msw"; +import { mswListNewDeploymentsLatestFull } from "./helpers/msw/handlers/versions"; import { runInTempDir } from "./helpers/run-in-tmp"; import { runWrangler } from "./helpers/run-wrangler"; import { writeWorkerSource } from "./helpers/write-worker-source"; @@ -78,6 +79,7 @@ describe("deploy", () => { setIsTTY(true); mockLastDeploymentRequest(); mockDeploymentsListRequest(); + msw.use(...mswListNewDeploymentsLatestFull); logger.loggerLevel = "log"; }); diff --git a/packages/wrangler/src/__tests__/deprecated-usage-model.test.ts b/packages/wrangler/src/__tests__/deprecated-usage-model.test.ts index aa25d0273057..3e5ef2472277 100644 --- a/packages/wrangler/src/__tests__/deprecated-usage-model.test.ts +++ b/packages/wrangler/src/__tests__/deprecated-usage-model.test.ts @@ -4,6 +4,7 @@ import { mockConsoleMethods } from "./helpers/mock-console"; import { mockUploadWorkerRequest } from "./helpers/mock-upload-worker"; import { mockSubDomainRequest } from "./helpers/mock-workers-subdomain"; import { msw, mswSuccessDeploymentScriptMetadata } from "./helpers/msw"; +import { mswListNewDeploymentsLatestFull } from "./helpers/msw/handlers/versions"; import { runInTempDir } from "./helpers/run-in-tmp"; import { runWrangler } from "./helpers/run-wrangler"; import { writeWorkerSource } from "./helpers/write-worker-source"; @@ -26,7 +27,10 @@ describe("deprecated-usage-model", () => { }); it("should warn user about ignored usage model if usage_model specified", async () => { - msw.use(...mswSuccessDeploymentScriptMetadata); + msw.use( + ...mswSuccessDeploymentScriptMetadata, + ...mswListNewDeploymentsLatestFull + ); writeWranglerToml({ usage_model: "bundled" }); writeWorkerSource(); mockSubDomainRequest(); @@ -41,7 +45,10 @@ describe("deprecated-usage-model", () => { `); }); it("should not warn user about ignored usage model if usage_model not specified", async () => { - msw.use(...mswSuccessDeploymentScriptMetadata); + msw.use( + ...mswSuccessDeploymentScriptMetadata, + ...mswListNewDeploymentsLatestFull + ); writeWranglerToml(); writeWorkerSource(); mockSubDomainRequest(); diff --git a/packages/wrangler/src/__tests__/helpers/msw/handlers/versions.ts b/packages/wrangler/src/__tests__/helpers/msw/handlers/versions.ts index ecd7bc3c1312..8dea63eaa3fc 100644 --- a/packages/wrangler/src/__tests__/helpers/msw/handlers/versions.ts +++ b/packages/wrangler/src/__tests__/helpers/msw/handlers/versions.ts @@ -1,5 +1,102 @@ import { http, HttpResponse } from "msw"; import { createFetchResult } from "../index"; +import type { ApiDeployment, ApiVersion } from "../../../../versions/types"; + +export const mswListNewDeploymentsLatestFull = [ + http.get( + "*/accounts/:accountId/workers/scripts/:scriptName/deployments", + ({ params }) => { + return HttpResponse.json( + createFetchResult({ + deployments: [ + { + id: `deployment:${params["scriptName"]}`, + source: "api", + strategy: "percentage", + author_email: "author@example.com", + created_on: "2021-01-01T00:00:00.000000Z", + versions: [ + { + version_id: `${params["scriptName"]}:version:0`, + percentage: 100.0, + }, + ], + }, + ] as Array, + }) + ); + }, + { once: true } + ), + http.get( + "*/accounts/:accountId/workers/scripts/:scriptName/versions/:version", + ({ params }) => { + return HttpResponse.json( + createFetchResult({ + id: params["version"], + number: 1, + metadata: { + created_on: "2021-01-01T00:00:00.000000Z", + modified_on: "2021-01-01T00:00:00.000000Z", + source: "api", + author_email: "author@example.com", + }, + } as ApiVersion) + ); + }, + { once: false } + ), +]; + +export const mswListNewDeploymentsLatestFiftyFifty = [ + http.get( + "*/accounts/:accountId/workers/scripts/:scriptName/deployments", + ({ params }) => { + return HttpResponse.json( + createFetchResult({ + deployments: [ + { + id: `deployment:${params["scriptName"]}`, + source: "api", + strategy: "percentage", + author_email: "author@example.com", + created_on: "2021-01-01T00:00:00.000000Z", + versions: [ + { + version_id: `${params["scriptName"]}:version:0`, + percentage: 50.0, + }, + { + version_id: `${params["scriptName"]}:version:1`, + percentage: 50.0, + }, + ], + }, + ] as Array, + }) + ); + }, + { once: true } + ), + http.get( + "*/accounts/:accountId/workers/scripts/:scriptName/versions/:version", + ({ params }) => { + return HttpResponse.json( + createFetchResult({ + id: params["version"], + number: 1, + metadata: { + created_on: "2021-01-01T00:00:00.000000Z", + modified_on: "2021-01-01T00:00:00.000000Z", + source: "api", + author_email: "author@example.com", + }, + } as ApiVersion) + ); + }, + { once: false } + ), +]; export const mswListNewDeployments = http.get( "*/accounts/:accountId/workers/scripts/:workerName/deployments", diff --git a/packages/wrangler/src/__tests__/versions/versions.deploy.test.ts b/packages/wrangler/src/__tests__/versions/versions.deploy.test.ts index 1891bc04f1de..0c7e31569b74 100644 --- a/packages/wrangler/src/__tests__/versions/versions.deploy.test.ts +++ b/packages/wrangler/src/__tests__/versions/versions.deploy.test.ts @@ -11,6 +11,8 @@ import { collectCLIOutput } from "../helpers/collect-cli-output"; import { mockAccountId, mockApiToken } from "../helpers/mock-account-id"; import { mockConsoleMethods } from "../helpers/mock-console"; import { useMockIsTTY } from "../helpers/mock-istty"; +import { mockUploadWorkerRequest } from "../helpers/mock-upload-worker"; +import { mockSubDomainRequest } from "../helpers/mock-workers-subdomain"; import { msw, mswGetVersion, @@ -18,9 +20,12 @@ import { mswListVersions, mswPatchNonVersionedScriptSettings, mswPostNewDeployment, + mswSuccessDeploymentScriptMetadata, } from "../helpers/msw"; +import { mswListNewDeploymentsLatestFiftyFifty } from "../helpers/msw/handlers/versions"; import { runInTempDir } from "../helpers/run-in-tmp"; import { runWrangler } from "../helpers/run-wrangler"; +import { writeWorkerSource } from "../helpers/write-worker-source"; import writeWranglerToml from "../helpers/write-wrangler-toml"; import type { VersionsDeployArgs } from "../../versions/deploy"; @@ -43,6 +48,42 @@ describe("versions deploy", () => { ); }); + describe("legacy deploy", () => { + test("should warn user when worker has deployment with multiple versions", async () => { + msw.use( + ...mswSuccessDeploymentScriptMetadata, + ...mswListNewDeploymentsLatestFiftyFifty + ); + writeWranglerToml(); + writeWorkerSource(); + mockSubDomainRequest(); + mockUploadWorkerRequest(); + + await runWrangler("deploy ./index"); + + expect(normalizeOutput(std.out)).toMatchInlineSnapshot(` + "╭ WARNING Your last deployment has multiple versions. To progress that deployment use \\"wrangler versions deploy\\" instead. + │ + ├ Your last deployment has 2 version(s): + │ + │ (50%) test-name:version:0 + │ Created: TIMESTAMP + │ Tag: - + │ Message: - + │ + │ (50%) test-name:version:1 + │ Created: TIMESTAMP + │ Tag: - + │ Message: - + │ + ├ \\"wrangler deploy\\" will upload a new version and deploy it globally immediately. + Are you sure you want to continue? + │ yes + │" + `); + }); + }); + describe("without wrangler.toml", () => { test("succeeds with --name arg", async () => { const result = runWrangler( diff --git a/packages/wrangler/src/deploy/deploy.ts b/packages/wrangler/src/deploy/deploy.ts index aec7003c676e..f50c149e9922 100644 --- a/packages/wrangler/src/deploy/deploy.ts +++ b/packages/wrangler/src/deploy/deploy.ts @@ -2,6 +2,7 @@ import assert from "node:assert"; import { mkdirSync, readFileSync, writeFileSync } from "node:fs"; import path from "node:path"; import { URLSearchParams } from "node:url"; +import { cancel } from "@cloudflare/cli"; import { fetchListResult, fetchResult } from "../cfetch"; import { printBindings } from "../config"; import { bundleWorker } from "../deployment-bundle/bundle"; @@ -45,6 +46,7 @@ import { } from "../sourcemap"; import triggersDeploy from "../triggers/deploy"; import { logVersionIdChange } from "../utils/deployment-id-version-id-change"; +import { confirmLatestDeploymentOverwrite } from "../versions/deploy"; import { getZoneForRoute } from "../zones"; import type { Config } from "../config"; import type { @@ -421,7 +423,8 @@ See https://developers.cloudflare.com/workers/platform/compatibility-dates for m const envName = props.env ?? "production"; const start = Date.now(); - const notProd = Boolean(!props.legacyEnv && props.env); + const prod = Boolean(props.legacyEnv || !props.env); + const notProd = !prod; const workerName = notProd ? `${scriptName} (${envName})` : scriptName; const workerUrl = props.dispatchNamespace ? `/accounts/${accountId}/workers/dispatch/namespaces/${props.dispatchNamespace}/scripts/${scriptName}` @@ -433,6 +436,14 @@ See https://developers.cloudflare.com/workers/platform/compatibility-dates for m const { format } = props.entry; + if (!props.dispatchNamespace && prod && accountId && scriptName) { + const yes = await confirmLatestDeploymentOverwrite(accountId, scriptName); + if (!yes) { + cancel("Aborting deploy..."); + return; + } + } + if ( !props.isWorkersSite && Boolean(props.assetPaths) && @@ -460,6 +471,7 @@ See https://developers.cloudflare.com/workers/platform/compatibility-dates for m "You cannot configure [data_blobs] with an ES module worker. Instead, import the file directly in your code, and optionally configure `[rules]` in your wrangler.toml" ); } + try { if (props.noBundle) { // if we're not building, let's just copy the entry to the destination directory diff --git a/packages/wrangler/src/versions/api.ts b/packages/wrangler/src/versions/api.ts index 0af0a8b0edbc..22df67e72e3e 100644 --- a/packages/wrangler/src/versions/api.ts +++ b/packages/wrangler/src/versions/api.ts @@ -60,23 +60,20 @@ export async function fetchLatestDeployment( return deployments.at(0); } -export async function fetchLatestDeploymentVersions( +export async function fetchDeploymentVersions( accountId: string, workerName: string, + deployment: ApiDeployment | undefined, versionCache: VersionCache ): Promise<[ApiVersion[], Map]> { - const latestDeployment = await fetchLatestDeployment(accountId, workerName); - - if (!latestDeployment) { + if (!deployment) { return [[], new Map()]; } const versionTraffic = new Map( - latestDeployment.versions.map(({ version_id: versionId, percentage }) => [ - versionId, - percentage, - ]) + deployment.versions.map((v) => [v.version_id, v.percentage]) ); + const versions = await fetchVersions( accountId, workerName, diff --git a/packages/wrangler/src/versions/deploy.ts b/packages/wrangler/src/versions/deploy.ts index 372c4a402c66..5dd27a3a29f7 100644 --- a/packages/wrangler/src/versions/deploy.ts +++ b/packages/wrangler/src/versions/deploy.ts @@ -10,14 +10,18 @@ import { } from "@cloudflare/cli/interactive"; import { findWranglerToml, readConfig } from "../config"; import { UserError } from "../errors"; +import { CI } from "../is-ci"; +import isInteractive from "../is-interactive"; import * as metrics from "../metrics"; +import { APIError } from "../parse"; import { printWranglerBanner } from "../update-check"; import { requireAuth } from "../user"; import formatLabelledValues from "../utils/render-labelled-values"; import { createDeployment, fetchDeployableVersions, - fetchLatestDeploymentVersions, + fetchDeploymentVersions, + fetchLatestDeployment, fetchVersions, patchNonVersionedScriptSettings, } from "./api"; @@ -26,7 +30,13 @@ import type { CommonYargsArgv, StrictYargsOptionsToInterface, } from "../yargs-types"; -import type { ApiVersion, Percentage, VersionCache, VersionId } from "./types"; +import type { + ApiDeployment, + ApiVersion, + Percentage, + VersionCache, + VersionId, +} from "./types"; const EPSILON = 0.001; // used to avoid floating-point errors. Comparions to a value +/- EPSILON will mean "roughly equals the value". const BLANK_INPUT = "-"; // To be used where optional user-input is displayed and the value is nullish @@ -205,22 +215,86 @@ function getConfig( return config; } +/** + * Prompts the user for confirmation when overwriting the latest deployment, given that it's split. + */ +export async function confirmLatestDeploymentOverwrite( + accountId: string, + scriptName: string +) { + try { + const latest = await fetchLatestDeployment(accountId, scriptName); + if (latest && latest.versions.length >= 2) { + const versionCache: VersionCache = new Map(); + + // Print message and confirmation. + + cli.warn( + `Your last deployment has multiple versions. To progress that deployment use "wrangler versions deploy" instead.`, + { shape: cli.shapes.corners.tl, newlineBefore: false } + ); + cli.newline(); + await printDeployment( + accountId, + scriptName, + latest, + "last", + versionCache + ); + + return inputPrompt({ + type: "confirm", + question: `"wrangler deploy" will upload a new version and deploy it globally immediately.\nAre you sure you want to continue?`, + label: "", + defaultValue: !isInteractive() || CI.isCI(), // defaults to true in CI for back-compat + acceptDefault: !isInteractive() || CI.isCI(), + }); + } + } catch (e) { + const isNotFound = e instanceof APIError && e.code == 10007; + if (!isNotFound) { + throw e; + } + } + return true; +} + export async function printLatestDeployment( accountId: string, workerName: string, versionCache: VersionCache ) { - const [versions, traffic] = await spinnerWhile({ + const latestDeployment = await spinnerWhile({ startMessage: "Fetching latest deployment", async promise() { - return fetchLatestDeploymentVersions(accountId, workerName, versionCache); + return fetchLatestDeployment(accountId, workerName); }, }); + await printDeployment( + accountId, + workerName, + latestDeployment, + "current", + versionCache + ); +} +export async function printDeployment( + accountId: string, + workerName: string, + deployment: ApiDeployment | undefined, + adjective: "current" | "last", + versionCache: VersionCache +) { + const [versions, traffic] = await fetchDeploymentVersions( + accountId, + workerName, + deployment, + versionCache + ); cli.logRaw( - `${leftT} Your current deployment has ${versions.length} version(s):` + `${leftT} Your ${adjective} deployment has ${versions.length} version(s):` ); - printVersions(versions, traffic); } @@ -228,20 +302,25 @@ export function printVersions( versions: ApiVersion[], traffic: Map ) { - for (const version of versions) { - const trafficString = brandColor(`(${traffic.get(version.id)}%)`); - const versionIdString = white(version.id); + cli.newline(); + cli.log(formatVersions(versions, traffic)); + cli.newline(); +} - cli.log( - gray(` -${trafficString} ${versionIdString} +export function formatVersions( + versions: ApiVersion[], + traffic: Map +) { + return versions + .map((version) => { + const trafficString = brandColor(`(${traffic.get(version.id)}%)`); + const versionIdString = white(version.id); + return gray(`${trafficString} ${versionIdString} Created: ${version.metadata.created_on} Tag: ${version.annotations?.["workers/tag"] ?? BLANK_INPUT} - Message: ${version.annotations?.["workers/message"] ?? BLANK_INPUT}`) - ); - } - - cli.newline(); + Message: ${version.annotations?.["workers/message"] ?? BLANK_INPUT}`); + }) + .join("\n\n"); } /**