From 8cb3e4da8a8d6c9e54953766015d95fadab1939b Mon Sep 17 00:00:00 2001 From: Thomas Bouldin Date: Thu, 26 Feb 2026 17:06:35 -0800 Subject: [PATCH 1/5] Firebase Functions can handle an Extensions outage --- src/deploy/extensions/prepare.ts | 21 +++++++++++++++------ 1 file changed, 15 insertions(+), 6 deletions(-) diff --git a/src/deploy/extensions/prepare.ts b/src/deploy/extensions/prepare.ts index 61474b7eecf..2802cb34a23 100644 --- a/src/deploy/extensions/prepare.ts +++ b/src/deploy/extensions/prepare.ts @@ -21,6 +21,7 @@ import { import { Build } from "../functions/build"; import { getEndpointFilters } from "../functions/functionsDeployHelper"; import { DeployOptions } from ".."; +import { logLabeledError } from "../../utils"; const matchesInstanceId = (dep: planner.InstanceSpec) => (test: planner.InstanceSpec) => { return dep.instanceId === test.instanceId; @@ -171,13 +172,21 @@ export async function prepareDynamicExtensions( const projectId = needProjectId(options); const projectNumber = await needProjectNumber(options); - await ensureExtensionsApiEnabled(options); - await requirePermissions(options, ["firebaseextensions.instances.list"]); + let haveExtensions: planner.DeploymentInstanceSpec[] = []; + try { + await ensureExtensionsApiEnabled(options); + await requirePermissions(options, ["firebaseextensions.instances.list"]); - let haveExtensions = await planner.haveDynamic(projectId); - haveExtensions = haveExtensions.filter((e) => - extensionMatchesAnyFilter(e.labels?.codebase, e.instanceId, filters), - ); + haveExtensions = await planner.haveDynamic(projectId); + haveExtensions = haveExtensions.filter((e) => + extensionMatchesAnyFilter(e.labels?.codebase, e.instanceId, filters), + ); + } catch (err) { + logLabeledError("extensions", + "Firebase Extensions is having an outage. Skipping extensions from functions codebase.", + ); + return; + } if (Object.keys(extensions).length === 0 && haveExtensions.length === 0) { // Nothing defined, and nothing to delete From 81c5cb76efddf1f1a977658a401272eb0fb17284 Mon Sep 17 00:00:00 2001 From: Thomas Bouldin Date: Thu, 26 Feb 2026 17:14:39 -0800 Subject: [PATCH 2/5] Add tests for getting the list of active extensions succeeding or failing --- src/deploy/extensions/prepare.spec.ts | 72 +++++++++++++++++++++++++++ 1 file changed, 72 insertions(+) create mode 100644 src/deploy/extensions/prepare.spec.ts diff --git a/src/deploy/extensions/prepare.spec.ts b/src/deploy/extensions/prepare.spec.ts new file mode 100644 index 00000000000..3868593d8d9 --- /dev/null +++ b/src/deploy/extensions/prepare.spec.ts @@ -0,0 +1,72 @@ +import { expect } from "chai"; +import * as sinon from "sinon"; + +import { prepareDynamicExtensions } from "./prepare"; +import * as planner from "./planner"; +import * as projectUtils from "../../projectUtils"; +import * as extensionsHelper from "../../extensions/extensionsHelper"; +import * as requirePermissions from "../../requirePermissions"; +import { Context, Payload } from "./args"; + +describe("Extensions prepare", () => { + describe("prepareDynamicExtensions", () => { + let haveDynamicStub: sinon.SinonStub; + let ensureExtensionsApiEnabledStub: sinon.SinonStub; + let requirePermissionsStub: sinon.SinonStub; + let needProjectIdStub: sinon.SinonStub; + let needProjectNumberStub: sinon.SinonStub; + + beforeEach(() => { + haveDynamicStub = sinon.stub(planner, "haveDynamic").resolves([]); + ensureExtensionsApiEnabledStub = sinon.stub(extensionsHelper, "ensureExtensionsApiEnabled").resolves(); + requirePermissionsStub = sinon.stub(requirePermissions, "requirePermissions").resolves(); + needProjectIdStub = sinon.stub(projectUtils, "needProjectId").returns("test-project"); + needProjectNumberStub = sinon.stub(projectUtils, "needProjectNumber").resolves("123456"); + }); + + afterEach(() => { + haveDynamicStub.restore(); + ensureExtensionsApiEnabledStub.restore(); + requirePermissionsStub.restore(); + needProjectIdStub.restore(); + needProjectNumberStub.restore(); + }); + + it("should swallow errors and exit cleanly if the extensions API is down", async () => { + haveDynamicStub.rejects(new Error("Extensions API is having an outage")); + + const context: Context = {}; + const payload: Payload = {}; + const options: any = {}; + const builds = {}; + + // This should not throw. + await expect(prepareDynamicExtensions(context, options, payload, builds)).to.not.be.rejected; + }); + + it("should proceed normally if extensions API is healthy", async () => { + haveDynamicStub.resolves([{ + instanceId: "test-extension", + ref: { publisherId: "test", extensionId: "test", version: "0.1.0" }, + params: {}, + systemParams: {}, + labels: { codebase: "default" } + }]); + + const context: Context = {}; + const payload: Payload = {}; + const options: any = { + config: { get: () => [] }, + rc: { getEtags: () => [] }, + }; + const builds = {}; + + const wantDynamicStub: sinon.SinonStub = sinon.stub(planner, "wantDynamic").resolves([]); + + // Expect successful completion + await expect(prepareDynamicExtensions(context, options, payload, builds)).to.not.be.rejected; + + wantDynamicStub.restore(); + }); + }); +}); From 9a9dd4d08844ad62507429d62aec5b3a43c6844f Mon Sep 17 00:00:00 2001 From: Thomas Bouldin Date: Thu, 26 Feb 2026 17:27:56 -0800 Subject: [PATCH 3/5] Update message --- src/deploy/extensions/prepare.spec.ts | 111 ++++++++++++++------------ src/deploy/extensions/prepare.ts | 6 +- 2 files changed, 66 insertions(+), 51 deletions(-) diff --git a/src/deploy/extensions/prepare.spec.ts b/src/deploy/extensions/prepare.spec.ts index 3868593d8d9..803088810dc 100644 --- a/src/deploy/extensions/prepare.spec.ts +++ b/src/deploy/extensions/prepare.spec.ts @@ -9,64 +9,77 @@ import * as requirePermissions from "../../requirePermissions"; import { Context, Payload } from "./args"; describe("Extensions prepare", () => { - describe("prepareDynamicExtensions", () => { - let haveDynamicStub: sinon.SinonStub; - let ensureExtensionsApiEnabledStub: sinon.SinonStub; - let requirePermissionsStub: sinon.SinonStub; - let needProjectIdStub: sinon.SinonStub; - let needProjectNumberStub: sinon.SinonStub; + describe("prepareDynamicExtensions", () => { + let haveDynamicStub: sinon.SinonStub; + let ensureExtensionsApiEnabledStub: sinon.SinonStub; + let requirePermissionsStub: sinon.SinonStub; + let needProjectIdStub: sinon.SinonStub; + let needProjectNumberStub: sinon.SinonStub; - beforeEach(() => { - haveDynamicStub = sinon.stub(planner, "haveDynamic").resolves([]); - ensureExtensionsApiEnabledStub = sinon.stub(extensionsHelper, "ensureExtensionsApiEnabled").resolves(); - requirePermissionsStub = sinon.stub(requirePermissions, "requirePermissions").resolves(); - needProjectIdStub = sinon.stub(projectUtils, "needProjectId").returns("test-project"); - needProjectNumberStub = sinon.stub(projectUtils, "needProjectNumber").resolves("123456"); - }); + beforeEach(() => { + haveDynamicStub = sinon.stub(planner, "haveDynamic").resolves([]); + ensureExtensionsApiEnabledStub = sinon + .stub(extensionsHelper, "ensureExtensionsApiEnabled") + .resolves(); + requirePermissionsStub = sinon.stub(requirePermissions, "requirePermissions").resolves(); + needProjectIdStub = sinon.stub(projectUtils, "needProjectId").returns("test-project"); + needProjectNumberStub = sinon.stub(projectUtils, "needProjectNumber").resolves("123456"); + }); - afterEach(() => { - haveDynamicStub.restore(); - ensureExtensionsApiEnabledStub.restore(); - requirePermissionsStub.restore(); - needProjectIdStub.restore(); - needProjectNumberStub.restore(); - }); + afterEach(() => { + haveDynamicStub.restore(); + ensureExtensionsApiEnabledStub.restore(); + requirePermissionsStub.restore(); + needProjectIdStub.restore(); + needProjectNumberStub.restore(); + }); - it("should swallow errors and exit cleanly if the extensions API is down", async () => { - haveDynamicStub.rejects(new Error("Extensions API is having an outage")); + it("should swallow errors and exit cleanly if the extensions API is down", async () => { + haveDynamicStub.rejects(new Error("Extensions API is having an outage")); - const context: Context = {}; - const payload: Payload = {}; - const options: any = {}; - const builds = {}; + const context: Context = {}; + const payload: Payload = {}; + const options: any = {}; + const builds = {}; - // This should not throw. - await expect(prepareDynamicExtensions(context, options, payload, builds)).to.not.be.rejected; - }); + // This should not throw. + await expect(prepareDynamicExtensions(context, options, payload, builds)).to.not.be.rejected; + }); - it("should proceed normally if extensions API is healthy", async () => { - haveDynamicStub.resolves([{ - instanceId: "test-extension", - ref: { publisherId: "test", extensionId: "test", version: "0.1.0" }, - params: {}, - systemParams: {}, - labels: { codebase: "default" } - }]); + it("should proceed normally if extensions API is healthy", async () => { + haveDynamicStub.resolves([ + { + instanceId: "test-extension", + ref: { publisherId: "test", extensionId: "test", version: "0.1.0" }, + params: {}, + systemParams: {}, + labels: { codebase: "default" }, + }, + ]); - const context: Context = {}; - const payload: Payload = {}; - const options: any = { - config: { get: () => [] }, - rc: { getEtags: () => [] }, - }; - const builds = {}; + const context: Context = {}; + const payload: Payload = {}; + const options: any = { + config: { get: () => [] }, + rc: { getEtags: () => [] }, + dryRun: true, + }; + const builds = {}; - const wantDynamicStub: sinon.SinonStub = sinon.stub(planner, "wantDynamic").resolves([]); + const wantDynamicStub: sinon.SinonStub = sinon.stub(planner, "wantDynamic").resolves([]); + const v2apistub: sinon.SinonStub = sinon + .stub(v2FunctionHelper, "ensureNecessaryV2ApisAndRoles") + .resolves(); + const tosStub: sinon.SinonStub = sinon + .stub(tos, "getAppDeveloperTOSStatus") + .resolves({ lastAcceptedVersion: "1.0.0" } as any); - // Expect successful completion - await expect(prepareDynamicExtensions(context, options, payload, builds)).to.not.be.rejected; + // Expect successful completion + await expect(prepareDynamicExtensions(context, options, payload, builds)).to.not.be.rejected; - wantDynamicStub.restore(); - }); + wantDynamicStub.restore(); + v2apistub.restore(); + tosStub.restore(); }); + }); }); diff --git a/src/deploy/extensions/prepare.ts b/src/deploy/extensions/prepare.ts index 2802cb34a23..6fa221d0426 100644 --- a/src/deploy/extensions/prepare.ts +++ b/src/deploy/extensions/prepare.ts @@ -182,8 +182,10 @@ export async function prepareDynamicExtensions( extensionMatchesAnyFilter(e.labels?.codebase, e.instanceId, filters), ); } catch (err) { - logLabeledError("extensions", - "Firebase Extensions is having an outage. Skipping extensions from functions codebase.", + logLabeledError( + "extensions", + "Failed to fetch the list of extensions. Assuming for now that there are no existing extensions. " + + "If you are trying to install an extension through Firebase Functions this may fail later.", ); return; } From 4405ce4b9707f969f1d19d7207014e104e41c985 Mon Sep 17 00:00:00 2001 From: Thomas Bouldin Date: Fri, 27 Feb 2026 10:02:23 -0800 Subject: [PATCH 4/5] Fix missing test imports --- src/deploy/extensions/prepare.spec.ts | 2 ++ 1 file changed, 2 insertions(+) diff --git a/src/deploy/extensions/prepare.spec.ts b/src/deploy/extensions/prepare.spec.ts index 803088810dc..45fefd6033b 100644 --- a/src/deploy/extensions/prepare.spec.ts +++ b/src/deploy/extensions/prepare.spec.ts @@ -7,6 +7,8 @@ import * as projectUtils from "../../projectUtils"; import * as extensionsHelper from "../../extensions/extensionsHelper"; import * as requirePermissions from "../../requirePermissions"; import { Context, Payload } from "./args"; +import * as v2FunctionHelper from "./v2FunctionHelper"; +import * as tos from "../../extensions/tos"; describe("Extensions prepare", () => { describe("prepareDynamicExtensions", () => { From 1ca6b5bab9a3eaf87f1e9f3c7b57997991b0f335 Mon Sep 17 00:00:00 2001 From: Thomas Bouldin Date: Fri, 27 Feb 2026 12:02:35 -0800 Subject: [PATCH 5/5] Fix config in unit tests --- src/deploy/extensions/prepare.spec.ts | 11 +++++++++-- 1 file changed, 9 insertions(+), 2 deletions(-) diff --git a/src/deploy/extensions/prepare.spec.ts b/src/deploy/extensions/prepare.spec.ts index 45fefd6033b..f8f6e8dc2a3 100644 --- a/src/deploy/extensions/prepare.spec.ts +++ b/src/deploy/extensions/prepare.spec.ts @@ -41,7 +41,11 @@ describe("Extensions prepare", () => { const context: Context = {}; const payload: Payload = {}; - const options: any = {}; + const options: any = { + config: { + src: { functions: { source: "functions" } }, + }, + }; const builds = {}; // This should not throw. @@ -62,7 +66,10 @@ describe("Extensions prepare", () => { const context: Context = {}; const payload: Payload = {}; const options: any = { - config: { get: () => [] }, + config: { + get: () => [], + src: { functions: { source: "functions" } }, + }, rc: { getEtags: () => [] }, dryRun: true, };