Skip to content

Commit 86d9311

Browse files
authored
Firebase Functions can handle an Extensions outage (#9986)
* Firebase Functions can handle an Extensions outage * Add tests for getting the list of active extensions succeeding or failing * Update message
1 parent 107f7dc commit 86d9311

2 files changed

Lines changed: 111 additions & 6 deletions

File tree

Lines changed: 94 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,94 @@
1+
import { expect } from "chai";
2+
import * as sinon from "sinon";
3+
4+
import { prepareDynamicExtensions } from "./prepare";
5+
import * as planner from "./planner";
6+
import * as projectUtils from "../../projectUtils";
7+
import * as extensionsHelper from "../../extensions/extensionsHelper";
8+
import * as requirePermissions from "../../requirePermissions";
9+
import { Context, Payload } from "./args";
10+
import * as v2FunctionHelper from "./v2FunctionHelper";
11+
import * as tos from "../../extensions/tos";
12+
13+
describe("Extensions prepare", () => {
14+
describe("prepareDynamicExtensions", () => {
15+
let haveDynamicStub: sinon.SinonStub;
16+
let ensureExtensionsApiEnabledStub: sinon.SinonStub;
17+
let requirePermissionsStub: sinon.SinonStub;
18+
let needProjectIdStub: sinon.SinonStub;
19+
let needProjectNumberStub: sinon.SinonStub;
20+
21+
beforeEach(() => {
22+
haveDynamicStub = sinon.stub(planner, "haveDynamic").resolves([]);
23+
ensureExtensionsApiEnabledStub = sinon
24+
.stub(extensionsHelper, "ensureExtensionsApiEnabled")
25+
.resolves();
26+
requirePermissionsStub = sinon.stub(requirePermissions, "requirePermissions").resolves();
27+
needProjectIdStub = sinon.stub(projectUtils, "needProjectId").returns("test-project");
28+
needProjectNumberStub = sinon.stub(projectUtils, "needProjectNumber").resolves("123456");
29+
});
30+
31+
afterEach(() => {
32+
haveDynamicStub.restore();
33+
ensureExtensionsApiEnabledStub.restore();
34+
requirePermissionsStub.restore();
35+
needProjectIdStub.restore();
36+
needProjectNumberStub.restore();
37+
});
38+
39+
it("should swallow errors and exit cleanly if the extensions API is down", async () => {
40+
haveDynamicStub.rejects(new Error("Extensions API is having an outage"));
41+
42+
const context: Context = {};
43+
const payload: Payload = {};
44+
const options: any = {
45+
config: {
46+
src: { functions: { source: "functions" } },
47+
},
48+
};
49+
const builds = {};
50+
51+
// This should not throw.
52+
await expect(prepareDynamicExtensions(context, options, payload, builds)).to.not.be.rejected;
53+
});
54+
55+
it("should proceed normally if extensions API is healthy", async () => {
56+
haveDynamicStub.resolves([
57+
{
58+
instanceId: "test-extension",
59+
ref: { publisherId: "test", extensionId: "test", version: "0.1.0" },
60+
params: {},
61+
systemParams: {},
62+
labels: { codebase: "default" },
63+
},
64+
]);
65+
66+
const context: Context = {};
67+
const payload: Payload = {};
68+
const options: any = {
69+
config: {
70+
get: () => [],
71+
src: { functions: { source: "functions" } },
72+
},
73+
rc: { getEtags: () => [] },
74+
dryRun: true,
75+
};
76+
const builds = {};
77+
78+
const wantDynamicStub: sinon.SinonStub = sinon.stub(planner, "wantDynamic").resolves([]);
79+
const v2apistub: sinon.SinonStub = sinon
80+
.stub(v2FunctionHelper, "ensureNecessaryV2ApisAndRoles")
81+
.resolves();
82+
const tosStub: sinon.SinonStub = sinon
83+
.stub(tos, "getAppDeveloperTOSStatus")
84+
.resolves({ lastAcceptedVersion: "1.0.0" } as any);
85+
86+
// Expect successful completion
87+
await expect(prepareDynamicExtensions(context, options, payload, builds)).to.not.be.rejected;
88+
89+
wantDynamicStub.restore();
90+
v2apistub.restore();
91+
tosStub.restore();
92+
});
93+
});
94+
});

src/deploy/extensions/prepare.ts

Lines changed: 17 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,7 @@ import { Build } from "../functions/build";
2222
import { getEndpointFilters } from "../functions/functionsDeployHelper";
2323
import { normalizeAndValidate } from "../../functions/projectConfig";
2424
import { DeployOptions } from "..";
25+
import { logLabeledError } from "../../utils";
2526

2627
const matchesInstanceId = (dep: planner.InstanceSpec) => (test: planner.InstanceSpec) => {
2728
return dep.instanceId === test.instanceId;
@@ -173,13 +174,23 @@ export async function prepareDynamicExtensions(
173174
const projectId = needProjectId(options);
174175
const projectNumber = await needProjectNumber(options);
175176

176-
await ensureExtensionsApiEnabled(options);
177-
await requirePermissions(options, ["firebaseextensions.instances.list"]);
177+
let haveExtensions: planner.DeploymentInstanceSpec[] = [];
178+
try {
179+
await ensureExtensionsApiEnabled(options);
180+
await requirePermissions(options, ["firebaseextensions.instances.list"]);
178181

179-
let haveExtensions = await planner.haveDynamic(projectId);
180-
haveExtensions = haveExtensions.filter((e) =>
181-
extensionMatchesAnyFilter(e.labels?.codebase, e.instanceId, filters),
182-
);
182+
haveExtensions = await planner.haveDynamic(projectId);
183+
haveExtensions = haveExtensions.filter((e) =>
184+
extensionMatchesAnyFilter(e.labels?.codebase, e.instanceId, filters),
185+
);
186+
} catch (err) {
187+
logLabeledError(
188+
"extensions",
189+
"Failed to fetch the list of extensions. Assuming for now that there are no existing extensions. " +
190+
"If you are trying to install an extension through Firebase Functions this may fail later.",
191+
);
192+
return;
193+
}
183194

184195
if (Object.keys(extensions).length === 0 && haveExtensions.length === 0) {
185196
// Nothing defined, and nothing to delete

0 commit comments

Comments
 (0)