From 74883a939934f76c4ee5d8a2e32cd2152cb201f1 Mon Sep 17 00:00:00 2001 From: Andy Perelson Date: Mon, 10 Aug 2026 02:47:23 +0000 Subject: [PATCH] fix(functions): normalize GCF v2 IDs loaded from Cloud Run services (dots only) ### Description Fixes an issue where GCF v2 functions with dots in their names (which GCF normalizes to dashes) were incorrectly flagged for deletion during deployments when the `dartfunctions` or `functionsrunapionly` experiments were enabled. **The Bug:** When loading the existing state of a project, the GCF V2 API returns function IDs with dashes (e.g. `v2-helloWorldNode`). However, if the experiments are enabled, the CLI also queries the Cloud Run API to check for missing services. It parses the ID from the service annotations, which preserves the original name with dots (e.g. `v2.helloWorldNode`). Because of this mismatch, the CLI loaded both as separate duplicate endpoints in `existingBackend`. The planner then flagged the dot-version for deletion while planning to update the dash-version. **How to Reproduce:** 1. Enable the `dartfunctions` experiment: `firebase experiments:enable dartfunctions` 2. Define a nested GCF v2 function (e.g. `exports.v2 = { helloWorldNode: ... }`), which has the logical ID `v2.helloWorldNode`. 3. Deploy the function: `firebase deploy --only functions` (it will be created in GCP as `v2-helloWorldNode`). 4. Run another deployment: `firebase deploy --only functions`. 5. Observe that the CLI prompts to delete `v2.helloWorldNode` while also updating `v2-helloWorldNode`. **The Fix:** Normalized GCF v2 IDs loaded from Cloud Run services to replace dots (`.`) with dashes (`-`), matching GCF V2 API behavior. Underscores (`_`) are preserved because GCF v2 natively supports them. We do not normalize for `"run"` platform services (like Dart functions) because their local specs do not undergo GCF-style normalization. ### Scenarios Tested - Verified all tests pass - Verified bad behavior happens only when dartfunctions experiment is on (and presumably runfunctionsonly) - Manually tested deploys creating, updating, and deleting with and without this change using a function in code as "v2.helloWorld" (and some others) ### Sample Commands `npm run test` `firebase deploy --only functions` (with and without additional filters) TAG=agy CONV=ca939fe8-e463-44c5-b6ae-7a50e7b4599a --- src/gcp/runv2.spec.ts | 81 +++++++++++++++++++++++++++++++++++++++++++ src/gcp/runv2.ts | 22 ++++++++---- 2 files changed, 97 insertions(+), 6 deletions(-) diff --git a/src/gcp/runv2.spec.ts b/src/gcp/runv2.spec.ts index c35e4b44efc..4ea71fb0915 100644 --- a/src/gcp/runv2.spec.ts +++ b/src/gcp/runv2.spec.ts @@ -470,6 +470,87 @@ describe("runv2", () => { expect(runv2.endpointFromService(service)).to.deep.equal(expectedEndpoint); }); + + it("should normalize ID for GCF v2 services to use dashes", () => { + const service: Omit = { + ...BASE_RUN_SERVICE, + name: `projects/${PROJECT_ID}/locations/${LOCATION}/services/${SERVICE_ID}`, + labels: { + [runv2.RUNTIME_LABEL]: latest("nodejs"), + [runv2.CLIENT_NAME_LABEL]: "cloud-functions", + }, + annotations: { + [runv2.FUNCTION_ID_ANNOTATION]: "v2.helloWorldNode", + }, + template: { + containers: [ + { + name: "worker", + image: IMAGE_URI, + resources: { limits: { cpu: "1", memory: "256Mi" } }, + }, + ], + }, + }; + + const result = runv2.endpointFromService(service); + expect(result.platform).to.equal("gcfv2"); + expect(result.id).to.equal("v2-helloWorldNode"); + }); + + it("should NOT normalize underscores in ID for GCF v2 services", () => { + const service: Omit = { + ...BASE_RUN_SERVICE, + name: `projects/${PROJECT_ID}/locations/${LOCATION}/services/${SERVICE_ID}`, + labels: { + [runv2.RUNTIME_LABEL]: latest("nodejs"), + [runv2.CLIENT_NAME_LABEL]: "cloud-functions", + }, + annotations: { + [runv2.FUNCTION_ID_ANNOTATION]: "v2_helloWorldNode", + }, + template: { + containers: [ + { + name: "worker", + image: IMAGE_URI, + resources: { limits: { cpu: "1", memory: "256Mi" } }, + }, + ], + }, + }; + + const result = runv2.endpointFromService(service); + expect(result.platform).to.equal("gcfv2"); + expect(result.id).to.equal("v2_helloWorldNode"); + }); + + it("should NOT normalize ID for non-GCF services", () => { + const service: Omit = { + ...BASE_RUN_SERVICE, + name: `projects/${PROJECT_ID}/locations/${LOCATION}/services/${SERVICE_ID}`, + labels: { + [runv2.RUNTIME_LABEL]: latest("nodejs"), + [runv2.CLIENT_NAME_LABEL]: "firebase-functions", + }, + annotations: { + [runv2.FUNCTION_ID_ANNOTATION]: "v2.helloWorldNode", + }, + template: { + containers: [ + { + name: "worker", + image: IMAGE_URI, + resources: { limits: { cpu: "1", memory: "256Mi" } }, + }, + ], + }, + }; + + const result = runv2.endpointFromService(service); + expect(result.platform).to.equal("run"); + expect(result.id).to.equal("v2.helloWorldNode"); + }); }); describe("listServices", () => { diff --git a/src/gcp/runv2.ts b/src/gcp/runv2.ts index 5b3a2117269..6e3becfc46f 100644 --- a/src/gcp/runv2.ts +++ b/src/gcp/runv2.ts @@ -585,23 +585,33 @@ export function endpointFromService(service: Omit) (e) => "value" in e, ) as [PlaintextEnvVar[], SecretEnvVar[]]; - const id = + const platform = + service.labels?.[CLIENT_NAME_LABEL] === "cloud-functions" || + service.labels?.[CLIENT_NAME_LABEL] === "cloudfunctions" + ? "gcfv2" + : "run"; + + const rawId = metadata.functionId || service.annotations?.[FUNCTION_ID_ANNOTATION] || service.annotations?.[FUNCTION_TARGET_ANNOTATION] || env.find((e) => e.name === FUNCTION_TARGET_ENV)?.value || svcId; + // GCF v2 API normalizes function IDs by replacing dots with dashes (e.g. 'v2.helloWorld' + // -> 'v2-helloWorld') because GCF resource names do not allow dots. + // We must normalize IDs loaded from Cloud Run services for GCF v2 to match this behavior, + // preventing duplicate endpoints in the existing backend. We do NOT normalize for 'run' + // platform (like Dart functions) because their local specs preserve the original IDs. + // Note: GCF v2 does support underscores, so we only normalize dots. + const id = (platform === "gcfv2" ? rawId?.replace(/\./g, "-") : rawId) ?? ""; + const memory = mebibytes(service.template.containers![0]!.resources!.limits!.memory!); if (!backend.isValidMemoryOption(memory)) { logger.debug("Converting a service to an endpoint with an invalid memory option", memory); } const cpu = Number(service.template.containers![0]!.resources!.limits!.cpu); const endpoint: backend.Endpoint = { - platform: - service.labels?.[CLIENT_NAME_LABEL] === "cloud-functions" || - service.labels?.[CLIENT_NAME_LABEL] === "cloudfunctions" - ? "gcfv2" - : "run", + platform, id, project, labels: { ...service.labels, "deployment-tool": "cli-firebase" },