From 0f467a31172e6f7de2ee0fd8b81344f0816f1e13 Mon Sep 17 00:00:00 2001 From: Izaak Gough Date: Tue, 4 Aug 2026 11:27:36 +0100 Subject: [PATCH 1/4] fix: v2 to v1 downgrade failing with CPU error --- src/deploy/functions/prepare.spec.ts | 44 ++++++++++++++++++++++++++++ src/deploy/functions/prepare.ts | 31 ++++++++++++-------- 2 files changed, 63 insertions(+), 12 deletions(-) diff --git a/src/deploy/functions/prepare.spec.ts b/src/deploy/functions/prepare.spec.ts index 9e67266b430..1df484e8496 100644 --- a/src/deploy/functions/prepare.spec.ts +++ b/src/deploy/functions/prepare.spec.ts @@ -782,6 +782,50 @@ describe("prepare", () => { expect(want.timeoutSeconds).to.equal(120); }); + it("does not inherit cpu/memory/timeout when the platform changes", () => { + // Downgrading an existing gcfv2 function to gcfv1 (e.g. redeploying a name + // as a v1 blocking auth trigger). cpu is a gcfv2-only concept, so inheriting + // it onto the v1 endpoint would fail CPU validation and mask the real + // "cannot be downgraded" error. See issue #5461. + const have: backend.Endpoint = { + ...ENDPOINT_BASE, + platform: "gcfv2", + httpsTrigger: {}, + cpu: 1, + availableMemoryMb: 512, + timeoutSeconds: 120, + }; + const want: backend.Endpoint = { + ...ENDPOINT_BASE, + platform: "gcfv1", + httpsTrigger: {}, + }; + + prepare.inferDetailsFromExisting(backend.of(want), backend.of(have), /* usedDotEnv= */ false); + + expect(want.cpu).to.be.undefined; + expect(want.availableMemoryMb).to.be.undefined; + expect(want.timeoutSeconds).to.be.undefined; + }); + + it("inherits cpu when the platform is unchanged", () => { + const have: backend.Endpoint = { + ...ENDPOINT_BASE, + platform: "gcfv2", + httpsTrigger: {}, + cpu: 2, + }; + const want: backend.Endpoint = { + ...ENDPOINT_BASE, + platform: "gcfv2", + httpsTrigger: {}, + }; + + prepare.inferDetailsFromExisting(backend.of(want), backend.of(have), /* usedDotEnv= */ false); + + expect(want.cpu).to.equal(2); + }); + it("downgrades concurrency if necessary (explicit)", () => { const have: backend.Endpoint = { ...ENDPOINT_BASE, diff --git a/src/deploy/functions/prepare.ts b/src/deploy/functions/prepare.ts index 28cc97ce0f3..63e747db262 100644 --- a/src/deploy/functions/prepare.ts +++ b/src/deploy/functions/prepare.ts @@ -601,20 +601,27 @@ export function inferDetailsFromExisting( }; } - // If the instance size is set out of bounds or was previously set and is now - // unset we still need to remember it so that the min instance price estimator - // is accurate. If, on the other hand, we have a null value for availableMemoryMb - // we need to keep that null (meaning "use defaults"). - if (typeof wantE.availableMemoryMb === "undefined" && haveE.availableMemoryMb) { - wantE.availableMemoryMb = haveE.availableMemoryMb; - } + // Only inherit infrastructure fields when the platform is unchanged. When a + // function is being switched between generations (e.g. downgraded from gcfv2 + // to gcfv1), fields like cpu are meaningless on the target platform and would + // fail validation (e.g. "Cannot set CPU on ... because they are GCF gen 1"), + // masking the more accurate downgrade error raised later in the release plan. + if (wantE.platform === haveE.platform) { + // If the instance size is set out of bounds or was previously set and is now + // unset we still need to remember it so that the min instance price estimator + // is accurate. If, on the other hand, we have a null value for availableMemoryMb + // we need to keep that null (meaning "use defaults"). + if (typeof wantE.availableMemoryMb === "undefined" && haveE.availableMemoryMb) { + wantE.availableMemoryMb = haveE.availableMemoryMb; + } - if (typeof wantE.cpu === "undefined" && haveE.cpu) { - wantE.cpu = haveE.cpu; - } + if (typeof wantE.cpu === "undefined" && haveE.cpu) { + wantE.cpu = haveE.cpu; + } - if (typeof wantE.timeoutSeconds === "undefined" && haveE.timeoutSeconds) { - wantE.timeoutSeconds = haveE.timeoutSeconds; + if (typeof wantE.timeoutSeconds === "undefined" && haveE.timeoutSeconds) { + wantE.timeoutSeconds = haveE.timeoutSeconds; + } } // N.B. concurrency has different defaults based on CPU. If the customer From e167c47ec4431a218976457155428e47c6e64e86 Mon Sep 17 00:00:00 2001 From: Izaak Gough Date: Thu, 13 Aug 2026 14:41:56 +0100 Subject: [PATCH 2/4] fix: narrow the platform-change guard to cpu and fail fast on downgrade Memory and timeout are valid on both generations, and the platform-equality guard also skipped them for gcfv2 vs run, which is a legal update. Gate only cpu, and reject a gcfv2-to-gcfv1 downgrade in validation so the error appears before the source is uploaded rather than in the release plan. --- src/deploy/functions/prepare.spec.ts | 17 +++++++------- src/deploy/functions/prepare.ts | 34 ++++++++++++--------------- src/deploy/functions/validate.spec.ts | 15 ++++++++++++ src/deploy/functions/validate.ts | 21 +++++++++++++++++ 4 files changed, 59 insertions(+), 28 deletions(-) diff --git a/src/deploy/functions/prepare.spec.ts b/src/deploy/functions/prepare.spec.ts index 3800a152d2e..d0437c592a7 100644 --- a/src/deploy/functions/prepare.spec.ts +++ b/src/deploy/functions/prepare.spec.ts @@ -886,11 +886,10 @@ describe("prepare", () => { expect(want.timeoutSeconds).to.equal(120); }); - it("does not inherit cpu/memory/timeout when the platform changes", () => { - // Downgrading an existing gcfv2 function to gcfv1 (e.g. redeploying a name - // as a v1 blocking auth trigger). cpu is a gcfv2-only concept, so inheriting - // it onto the v1 endpoint would fail CPU validation and mask the real - // "cannot be downgraded" error. See issue #5461. + it("does not inherit cpu onto a gcfv1 endpoint", () => { + // Redeploying an existing gcfv2 function as gcfv1 (e.g. as a v1 blocking auth + // trigger). Inheriting cpu here fails CPU validation and masks the real + // "cannot be downgraded" error. Memory and timeout exist on both generations. const have: backend.Endpoint = { ...ENDPOINT_BASE, platform: "gcfv2", @@ -908,14 +907,14 @@ describe("prepare", () => { prepare.inferDetailsFromExisting(backend.of(want), backend.of(have), /* usedDotEnv= */ false); expect(want.cpu).to.be.undefined; - expect(want.availableMemoryMb).to.be.undefined; - expect(want.timeoutSeconds).to.be.undefined; + expect(want.availableMemoryMb).to.equal(512); + expect(want.timeoutSeconds).to.equal(120); }); - it("inherits cpu when the platform is unchanged", () => { + it("inherits cpu across gcfv2 and run platforms", () => { const have: backend.Endpoint = { ...ENDPOINT_BASE, - platform: "gcfv2", + platform: "run", httpsTrigger: {}, cpu: 2, }; diff --git a/src/deploy/functions/prepare.ts b/src/deploy/functions/prepare.ts index 171e310ce10..ac8242cbbd8 100644 --- a/src/deploy/functions/prepare.ts +++ b/src/deploy/functions/prepare.ts @@ -612,27 +612,23 @@ export function inferDetailsFromExisting( }; } - // Only inherit infrastructure fields when the platform is unchanged. When a - // function is being switched between generations (e.g. downgraded from gcfv2 - // to gcfv1), fields like cpu are meaningless on the target platform and would - // fail validation (e.g. "Cannot set CPU on ... because they are GCF gen 1"), - // masking the more accurate downgrade error raised later in the release plan. - if (wantE.platform === haveE.platform) { - // If the instance size is set out of bounds or was previously set and is now - // unset we still need to remember it so that the min instance price estimator - // is accurate. If, on the other hand, we have a null value for availableMemoryMb - // we need to keep that null (meaning "use defaults"). - if (typeof wantE.availableMemoryMb === "undefined" && haveE.availableMemoryMb) { - wantE.availableMemoryMb = haveE.availableMemoryMb; - } + // If the instance size is set out of bounds or was previously set and is now + // unset we still need to remember it so that the min instance price estimator + // is accurate. If, on the other hand, we have a null value for availableMemoryMb + // we need to keep that null (meaning "use defaults"). + if (typeof wantE.availableMemoryMb === "undefined" && haveE.availableMemoryMb) { + wantE.availableMemoryMb = haveE.availableMemoryMb; + } - if (typeof wantE.cpu === "undefined" && haveE.cpu) { - wantE.cpu = haveE.cpu; - } + // cpu does not exist on gcfv1. Inheriting it from an existing gcfv2 function onto + // a gcfv1 endpoint fails CPU validation and masks the accurate "cannot be + // downgraded" error. + if (typeof wantE.cpu === "undefined" && haveE.cpu && wantE.platform !== "gcfv1") { + wantE.cpu = haveE.cpu; + } - if (typeof wantE.timeoutSeconds === "undefined" && haveE.timeoutSeconds) { - wantE.timeoutSeconds = haveE.timeoutSeconds; - } + if (typeof wantE.timeoutSeconds === "undefined" && haveE.timeoutSeconds) { + wantE.timeoutSeconds = haveE.timeoutSeconds; } // N.B. concurrency has different defaults based on CPU. If the customer diff --git a/src/deploy/functions/validate.spec.ts b/src/deploy/functions/validate.spec.ts index 165317a106b..ccca51ec1a7 100644 --- a/src/deploy/functions/validate.spec.ts +++ b/src/deploy/functions/validate.spec.ts @@ -125,6 +125,21 @@ describe("validate", () => { httpsTrigger: {}, }; + it("rejects downgrading an existing gcfv2 function to gcfv1", () => { + const want = backend.of({ ...ENDPOINT_BASE, platform: "gcfv1" }); + const have = backend.of({ ...ENDPOINT_BASE, platform: "gcfv2", cpu: 1 }); + + expect(() => validate.endpointsAreValid(want, have)).to.throw( + /cannot be downgraded from GCFv2 to GCFv1/, + ); + }); + + it("allows a gcfv1 function that does not exist yet", () => { + const want = backend.of({ ...ENDPOINT_BASE, platform: "gcfv1" }); + + expect(() => validate.endpointsAreValid(want, backend.empty())).to.not.throw(); + }); + it("disallows concurrency for GCF gen 1", () => { const ep: backend.Endpoint = { ...ENDPOINT_BASE, diff --git a/src/deploy/functions/validate.ts b/src/deploy/functions/validate.ts index 14efb258f4a..6d19ac8b157 100644 --- a/src/deploy/functions/validate.ts +++ b/src/deploy/functions/validate.ts @@ -89,6 +89,9 @@ export function endpointsAreValid( existingBackend?: backend.Backend, ): void { validateLifecycleHooks(wantBackend, existingBackend); + if (existingBackend) { + noGenerationDowngrades(wantBackend, existingBackend); + } const endpoints = backend.allEndpoints(wantBackend); functionIdsAreValid(endpoints); validateTimeoutConfig(endpoints); @@ -132,6 +135,24 @@ export function endpointsAreValid( cpuConfigIsValid(endpoints); } +/** + * Rejects a gcfv2 function being redeployed as gcfv1. The release planner enforces this + * too, but only after the source has been packaged and uploaded. + */ +function noGenerationDowngrades( + wantBackend: backend.Backend, + existingBackend: backend.Backend, +): void { + for (const want of backend.allEndpoints(wantBackend)) { + const have = existingBackend.endpoints[want.region]?.[want.id]; + if (have && want.platform === "gcfv1" && have.platform === "gcfv2") { + throw new FirebaseError( + `[${getFunctionLabel(want)}] Functions cannot be downgraded from GCFv2 to GCFv1`, + ); + } + } +} + /** * Validate that endpoints have valid CPU configuration. * Enforces https://cloud.google.com/run/docs/configuring/cpu. From 8f824ec85b87b3b464cc84cb9d3013ff6a4b4799 Mon Sep 17 00:00:00 2001 From: Izaak Gough Date: Thu, 13 Aug 2026 14:52:34 +0100 Subject: [PATCH 3/4] fix: also reject redeploying a Cloud Run service as gcfv1 Skipping cpu inheritance for gcfv1 removed the only thing that stopped a deploy from trying to update an existing `run` endpoint into a gcfv1 function, which then failed with an opaque API error mid-deploy. Share one predicate between validation and the release planner so the two checks cannot drift, and cover both platforms. --- src/deploy/functions/functionsDeployHelper.ts | 17 ++++++++ src/deploy/functions/prepare.spec.ts | 39 ++++++++++++------- src/deploy/functions/release/planner.ts | 8 ++-- src/deploy/functions/validate.spec.ts | 9 +++++ src/deploy/functions/validate.ts | 22 ++++++----- 5 files changed, 67 insertions(+), 28 deletions(-) diff --git a/src/deploy/functions/functionsDeployHelper.ts b/src/deploy/functions/functionsDeployHelper.ts index 77a8ee0fce4..83e5bca1516 100644 --- a/src/deploy/functions/functionsDeployHelper.ts +++ b/src/deploy/functions/functionsDeployHelper.ts @@ -167,6 +167,23 @@ export function getFunctionLabel(fn: backend.TargetIds & { codebase?: string }): return id; } +/** + * Explains why a gcfv1 endpoint may not take over a name that already exists as something + * else, or undefined if the update is legal. A gcfv1 function is a different resource to a + * gcfv2 function or a Cloud Run service, so the CLI cannot update one into the other. + * Shared so that prepare-time validation and the release planner cannot drift apart. + */ +export function generationDowngradeMessage( + want: backend.Endpoint, + have: backend.Endpoint, +): string | undefined { + if (want.platform !== "gcfv1" || have.platform === "gcfv1") { + return undefined; + } + const from = have.platform === "gcfv2" ? "GCFv2" : "Cloud Run"; + return `[${getFunctionLabel(want)}] Functions cannot be downgraded from ${from} to GCFv1`; +} + /** * Returns list of codebases specified in firebase.json filtered by --only filters if present. */ diff --git a/src/deploy/functions/prepare.spec.ts b/src/deploy/functions/prepare.spec.ts index d0437c592a7..8e193f00fbd 100644 --- a/src/deploy/functions/prepare.spec.ts +++ b/src/deploy/functions/prepare.spec.ts @@ -911,23 +911,32 @@ describe("prepare", () => { expect(want.timeoutSeconds).to.equal(120); }); - it("inherits cpu across gcfv2 and run platforms", () => { - const have: backend.Endpoint = { - ...ENDPOINT_BASE, - platform: "run", - httpsTrigger: {}, - cpu: 2, - }; - const want: backend.Endpoint = { - ...ENDPOINT_BASE, - platform: "gcfv2", - httpsTrigger: {}, - }; + for (const [havePlatform, wantPlatform] of [ + ["run", "gcfv2"], + ["gcfv2", "run"], + ] as const) { + it(`inherits cpu from ${havePlatform} onto ${wantPlatform}`, () => { + const have: backend.Endpoint = { + ...ENDPOINT_BASE, + platform: havePlatform, + httpsTrigger: {}, + cpu: 2, + }; + const want: backend.Endpoint = { + ...ENDPOINT_BASE, + platform: wantPlatform, + httpsTrigger: {}, + }; - prepare.inferDetailsFromExisting(backend.of(want), backend.of(have), /* usedDotEnv= */ false); + prepare.inferDetailsFromExisting( + backend.of(want), + backend.of(have), + /* usedDotEnv= */ false, + ); - expect(want.cpu).to.equal(2); - }); + expect(want.cpu).to.equal(2); + }); + } it("downgrades concurrency if necessary (explicit)", () => { const have: backend.Endpoint = { diff --git a/src/deploy/functions/release/planner.ts b/src/deploy/functions/release/planner.ts index c4c202e0f29..8182df9470b 100644 --- a/src/deploy/functions/release/planner.ts +++ b/src/deploy/functions/release/planner.ts @@ -1,6 +1,7 @@ import { EndpointFilter, endpointMatchesAnyFilter, + generationDowngradeMessage, getFunctionLabel, } from "../functionsDeployHelper"; import { isFirebaseManaged } from "../../../deploymentTool"; @@ -380,10 +381,9 @@ export function checkForIllegalUpdate(want: backend.Endpoint, have: backend.Endp )}] Changing from ${haveType} function to ${wantType} function is not allowed. Please delete your function and create a new one instead.`, ); } - if (want.platform === "gcfv1" && have.platform === "gcfv2") { - throw new FirebaseError( - `[${getFunctionLabel(want)}] Functions cannot be downgraded from GCFv2 to GCFv1`, - ); + const downgrade = generationDowngradeMessage(want, have); + if (downgrade) { + throw new FirebaseError(downgrade); } // We need to call from module exports so tests can stub this behavior, but that diff --git a/src/deploy/functions/validate.spec.ts b/src/deploy/functions/validate.spec.ts index ccca51ec1a7..5ad425a36c8 100644 --- a/src/deploy/functions/validate.spec.ts +++ b/src/deploy/functions/validate.spec.ts @@ -134,6 +134,15 @@ describe("validate", () => { ); }); + it("rejects redeploying an existing Cloud Run service as gcfv1", () => { + const want = backend.of({ ...ENDPOINT_BASE, platform: "gcfv1" }); + const have = backend.of({ ...ENDPOINT_BASE, platform: "run", cpu: 1 }); + + expect(() => validate.endpointsAreValid(want, have)).to.throw( + /cannot be downgraded from Cloud Run to GCFv1/, + ); + }); + it("allows a gcfv1 function that does not exist yet", () => { const want = backend.of({ ...ENDPOINT_BASE, platform: "gcfv1" }); diff --git a/src/deploy/functions/validate.ts b/src/deploy/functions/validate.ts index 6d19ac8b157..f93d495c582 100644 --- a/src/deploy/functions/validate.ts +++ b/src/deploy/functions/validate.ts @@ -4,7 +4,12 @@ import * as clc from "colorette"; import { FirebaseError } from "../../error"; import { getSecretVersion, SecretVersion } from "../../gcp/secretManager"; import { logger } from "../../logger"; -import { EndpointFilter, endpointMatchesFilter, getFunctionLabel } from "./functionsDeployHelper"; +import { + EndpointFilter, + endpointMatchesFilter, + generationDowngradeMessage, + getFunctionLabel, +} from "./functionsDeployHelper"; import { serviceForEndpoint } from "./services"; import * as fsutils from "../../fsutils"; import * as backend from "./backend"; @@ -89,11 +94,11 @@ export function endpointsAreValid( existingBackend?: backend.Backend, ): void { validateLifecycleHooks(wantBackend, existingBackend); + const endpoints = backend.allEndpoints(wantBackend); + functionIdsAreValid(endpoints); if (existingBackend) { noGenerationDowngrades(wantBackend, existingBackend); } - const endpoints = backend.allEndpoints(wantBackend); - functionIdsAreValid(endpoints); validateTimeoutConfig(endpoints); for (const ep of endpoints) { validateScheduledTimeout(ep); @@ -136,8 +141,8 @@ export function endpointsAreValid( } /** - * Rejects a gcfv2 function being redeployed as gcfv1. The release planner enforces this - * too, but only after the source has been packaged and uploaded. + * Rejects an existing gcfv2 function or Cloud Run service being redeployed as gcfv1. The + * release planner enforces this too, but only after the source has been uploaded. */ function noGenerationDowngrades( wantBackend: backend.Backend, @@ -145,10 +150,9 @@ function noGenerationDowngrades( ): void { for (const want of backend.allEndpoints(wantBackend)) { const have = existingBackend.endpoints[want.region]?.[want.id]; - if (have && want.platform === "gcfv1" && have.platform === "gcfv2") { - throw new FirebaseError( - `[${getFunctionLabel(want)}] Functions cannot be downgraded from GCFv2 to GCFv1`, - ); + const msg = have && generationDowngradeMessage(want, have); + if (msg) { + throw new FirebaseError(msg); } } } From 04e9d5420b91400a8ebad2e1c3b5793c60500f2c Mon Sep 17 00:00:00 2001 From: Izaak Gough Date: Thu, 20 Aug 2026 16:51:02 +0100 Subject: [PATCH 4/4] test: pin the downgrade check ahead of the timeout check inferDetailsFromExisting copies timeoutSeconds regardless of platform, so a downgraded endpoint reaches endpointsAreValid carrying a timeout that is legal on gcfv2 and not on gcfv1. Nothing failed if the downgrade check moved below validateTimeoutConfig, which would report the 540s limit instead of the real reason. --- src/deploy/functions/validate.spec.ts | 13 +++++++++++++ 1 file changed, 13 insertions(+) diff --git a/src/deploy/functions/validate.spec.ts b/src/deploy/functions/validate.spec.ts index 5ad425a36c8..dd5cdf76563 100644 --- a/src/deploy/functions/validate.spec.ts +++ b/src/deploy/functions/validate.spec.ts @@ -143,6 +143,19 @@ describe("validate", () => { ); }); + it("reports the downgrade rather than the inherited timeout", () => { + // inferDetailsFromExisting copies timeoutSeconds regardless of platform, so want + // reaches here carrying a timeout that is legal on gcfv2 and not on gcfv1. The + // downgrade check has to run before validateTimeoutConfig or that 540s limit is + // reported instead of the real reason. + const want = backend.of({ ...ENDPOINT_BASE, platform: "gcfv1", timeoutSeconds: 3600 }); + const have = backend.of({ ...ENDPOINT_BASE, platform: "gcfv2", timeoutSeconds: 3600 }); + + expect(() => validate.endpointsAreValid(want, have)).to.throw( + /cannot be downgraded from GCFv2 to GCFv1/, + ); + }); + it("allows a gcfv1 function that does not exist yet", () => { const want = backend.of({ ...ENDPOINT_BASE, platform: "gcfv1" });