fix(extensions): skip extensions deploy when there is nothing to deploy - #10925
fix(extensions): skip extensions deploy when there is nothing to deploy#10925IzaakGough wants to merge 3 commits into
Conversation
A functions deploy for a codebase that declares no extensions still ran the extensions deploy stage, whose first action is a Cloud Billing API check. The functions SDK always emits an extensions record in the manifest, empty when there are none, so `build.extensions` was truthy and functions/prepare handed the extensions stages a payload even though prepareDynamicExtensions had returned early without building a plan. Only pass the extensions context and payload on when a plan was actually made, and guard extensions deploy the way release already does. Billing is now only checked when an instance is created, updated or configured, since deleting one does not require the Blaze plan. Fixes #7584
There was a problem hiding this comment.
Code Review
This pull request resolves an issue where deploying functions from a codebase with no extensions incorrectly required the Cloud Billing API to be enabled. It updates the deployment flow to skip billing checks when there are no extensions to create, update, or configure, and adds comprehensive unit tests. The review feedback suggests two key improvements: first, refining the payload check in prepare.ts to explicitly check the lengths of the instance lists rather than using Object.keys(), which can be misleadingly truthy; second, replacing the use of any with the proper Options type in the test suite to align with the repository's TypeScript style guidelines.
| // prepareDynamicExtensions returns without touching the payload when there is | ||
| // nothing to deploy and nothing to delete. Only hand the extensions stages a | ||
| // plan when it actually made one, otherwise they run against an empty payload. | ||
| if (Object.keys(extPayload).length) { |
There was a problem hiding this comment.
Instead of checking Object.keys(extPayload).length, which can be truthy (e.g., 4) even when all the deployment lists are empty arrays (e.g., []), we should explicitly check if there is at least one instance to create, configure, update, or delete. This prevents passing an empty payload to subsequent stages.
if (
extPayload.instancesToCreate?.length ||
extPayload.instancesToConfigure?.length ||
extPayload.instancesToUpdate?.length ||
extPayload.instancesToDelete?.length
) {| import { expect } from "chai"; | ||
| import * as sinon from "sinon"; | ||
|
|
||
| import { deploy } from "./deploy"; | ||
| import { Context, Payload } from "./args"; | ||
| import * as cloudbilling from "../../gcp/cloudbilling"; | ||
| import * as provisioningHelper from "../../extensions/provisioningHelper"; | ||
|
|
||
| describe("Extensions deploy", () => { | ||
| let checkBillingEnabledStub: sinon.SinonStub; | ||
| let bulkCheckProductsProvisionedStub: sinon.SinonStub; | ||
|
|
||
| const options: any = { nonInteractive: true, project: "test-project" }; |
There was a problem hiding this comment.
Avoid using any as an escape hatch for the options variable, as it violates the repository style guide (TypeScript section, line 37). Instead, import Options from ../../options and cast the mock object as Options.
| import { expect } from "chai"; | |
| import * as sinon from "sinon"; | |
| import { deploy } from "./deploy"; | |
| import { Context, Payload } from "./args"; | |
| import * as cloudbilling from "../../gcp/cloudbilling"; | |
| import * as provisioningHelper from "../../extensions/provisioningHelper"; | |
| describe("Extensions deploy", () => { | |
| let checkBillingEnabledStub: sinon.SinonStub; | |
| let bulkCheckProductsProvisionedStub: sinon.SinonStub; | |
| const options: any = { nonInteractive: true, project: "test-project" }; | |
| import { expect } from "chai"; | |
| import * as sinon from "sinon"; | |
| import { deploy } from "./deploy"; | |
| import { Context, Payload } from "./args"; | |
| import * as cloudbilling from "../../gcp/cloudbilling"; | |
| import * as provisioningHelper from "../../extensions/provisioningHelper"; | |
| import { Options } from "../../options"; | |
| describe("Extensions deploy", () => { | |
| let checkBillingEnabledStub: sinon.SinonStub; | |
| let bulkCheckProductsProvisionedStub: sinon.SinonStub; | |
| const options = { nonInteractive: true, project: "test-project" } as Options; |
References
- Never use
anyorunknownas an escape hatch. Define proper interfaces/types or use type guards. (link)
Make the "was a plan prepared" signal explicit rather than inferring it from whether prepareDynamicExtensions happened to populate the payload, so the two modules no longer share an implicit invariant. Drop the delete-only billing exemption. It was not needed to fix the reported problem and was the only behaviour change affecting a real extensions deploy. Correct the comment in extensions/deploy: release guards on the payload fields being absent, not on them being empty, so the two guards are not equivalent. Strengthen the tests. Deploys that should proceed now assert that they reached bulkCheckProductsProvisioned, so widening the empty-payload guard cannot turn a real deploy into a silent no-op, and prepareDynamicExtensions is covered on both the no-plan and the delete-only-plan paths.
Fixes #7584.
Problem
firebase deploy --only functionson a codebase with no extensions still runs the extensions deploy stage, and the first thing that stage does is check the Cloud Billing API. Under Workload Identity or a service account that fails with a 403, since ADC carries no quota project and Cloud Billing usually is not enabled on the target project.The chain:
extensionsrecord in the manifest,{}when there are none.v1alpha1.tsdoesif (manifest.extensions), so an empty record still setsbuild.extensions = {}, which is truthy.functions/prepare.tstherefore callsprepareDynamicExtensions, which returns early when nothing is declared and nothing exists remotely, butprepare.tsassignscontext.extensionsandpayload.extensionsanyway.functions/deploy.tsgates onpayload.extensions && context.extensions, and{} && {}is truthy, so extensions deploy runs against an empty plan and callscheckBilling.extensions/release.tsalready guards against an empty payload.extensions/deploy.tsdoes not, which is what lets this through.Fix
functions/prepare.ts: only pass the extensions context and payload on whenprepareDynamicExtensionsreports that it built a plan. It now returns a boolean rather than leaving the caller to infer this from the payload contents.extensions/deploy.ts: return early when there is nothing to create, update, configure or delete, before the billing check.releasehas a similar guard, though it tests for the payload fields being absent rather than empty.The Extensions API check and
firebaseextensions.instances.liststay on the deploy path, since those are what detect extensions deleted from code, and they already fail soft. Worth noting that the Billing API is not needed for that detection: nothing in the prepare or list path calls Cloud Billing, so the empty-payload deploy stage was the only thing requiring it.Testing
New
extensions/deploy.spec.tscovers empty payload, all-empty lists, create, delete-only and configure. The two no-op cases fail without this change. Cases that should proceed assert they reachedbulkCheckProductsProvisioned, so widening the guard cannot silently turn a real deploy into a no-op. Added a case toextensions/prepare.spec.tspinning the early-return contract thatfunctions/preparenow depends on. Existing suites fordeploy/extensionsanddeploy/functionspass (104 tests),tsc --noEmitand eslint are clean.