Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,3 +7,4 @@
- Added web app support for Crashlytics MCP tools and prompts.
- Added support for forwarding custom HTTP headers (`Mcp-Param-*`) to remote MCP tools when defined in tool parameter input schemas (`x-mcp-header`), per [SEP-2243](https://modelcontextprotocol.io/seps/2243-http-standardization).
- Improved function parameter prompting clarity for multi-codebase deploys (#10897)
- Fixed a bug where deploying functions from a codebase with no extensions still required the Cloud Billing API to be enabled. (#7584)
78 changes: 78 additions & 0 deletions src/deploy/extensions/deploy.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,78 @@
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" };

Check warning on line 13 in src/deploy/extensions/deploy.spec.ts

View workflow job for this annotation

GitHub Actions / lint (24)

Unexpected any. Specify a different type
Comment on lines +1 to +13

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

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.

Suggested change
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
  1. Never use any or unknown as an escape hatch. Define proper interfaces/types or use type guards. (link)

const instance = (instanceId: string) => ({ instanceId, params: {}, systemParams: {} }) as any;

Check warning on line 14 in src/deploy/extensions/deploy.spec.ts

View workflow job for this annotation

GitHub Actions / lint (24)

Unexpected any. Specify a different type

Check warning on line 14 in src/deploy/extensions/deploy.spec.ts

View workflow job for this annotation

GitHub Actions / lint (24)

Unsafe return of an `any` typed value

Check warning on line 14 in src/deploy/extensions/deploy.spec.ts

View workflow job for this annotation

GitHub Actions / lint (24)

Missing return type on function

beforeEach(() => {
checkBillingEnabledStub = sinon.stub(cloudbilling, "checkBillingEnabled").resolves(true);
bulkCheckProductsProvisionedStub = sinon
.stub(provisioningHelper, "bulkCheckProductsProvisioned")
.resolves();
});

afterEach(() => {
sinon.restore();
});

it("should do nothing when there is nothing to deploy", async () => {
// A functions deploy for a codebase that declares no extensions reaches this
// stage with an empty payload, and must not require the Cloud Billing API.
await deploy({} as Context, options, {} as Payload);

Check warning on line 30 in src/deploy/extensions/deploy.spec.ts

View workflow job for this annotation

GitHub Actions / lint (24)

Unsafe argument of type `any` assigned to a parameter of type `Options`

expect(checkBillingEnabledStub.called).to.be.false;
expect(bulkCheckProductsProvisionedStub.called).to.be.false;
});

it("should do nothing when the payload only has empty instance lists", async () => {
const payload: Payload = {
instancesToCreate: [],
instancesToUpdate: [],
instancesToConfigure: [],
instancesToDelete: [],
};

await deploy({} as Context, options, payload);

Check warning on line 44 in src/deploy/extensions/deploy.spec.ts

View workflow job for this annotation

GitHub Actions / lint (24)

Unsafe argument of type `any` assigned to a parameter of type `Options`

expect(checkBillingEnabledStub.called).to.be.false;
expect(bulkCheckProductsProvisionedStub.called).to.be.false;
});

it("should deploy normally when there is an instance to create", async () => {
const payload: Payload = { instancesToCreate: [instance("new-instance")] };

await deploy({} as Context, options, payload);

Check warning on line 53 in src/deploy/extensions/deploy.spec.ts

View workflow job for this annotation

GitHub Actions / lint (24)

Unsafe argument of type `any` assigned to a parameter of type `Options`

expect(checkBillingEnabledStub.calledWith("test-project")).to.be.true;
// Asserted so that widening the empty-payload guard above cannot silently turn
// a real deploy into a no-op.
expect(bulkCheckProductsProvisionedStub.called).to.be.true;
});

it("should not skip a delete-only deploy", async () => {
const payload: Payload = { instancesToDelete: [instance("doomed")] };

await deploy({} as Context, options, payload);

Check warning on line 64 in src/deploy/extensions/deploy.spec.ts

View workflow job for this annotation

GitHub Actions / lint (24)

Unsafe argument of type `any` assigned to a parameter of type `Options`

expect(checkBillingEnabledStub.called).to.be.true;
expect(bulkCheckProductsProvisionedStub.called).to.be.true;
});

it("should deploy normally when only configuring an instance", async () => {
const payload: Payload = { instancesToConfigure: [instance("existing")] };

await deploy({} as Context, options, payload);

Check warning on line 73 in src/deploy/extensions/deploy.spec.ts

View workflow job for this annotation

GitHub Actions / lint (24)

Unsafe argument of type `any` assigned to a parameter of type `Options`

expect(checkBillingEnabledStub.called).to.be.true;
expect(bulkCheckProductsProvisionedStub.called).to.be.true;
});
});
30 changes: 24 additions & 6 deletions src/deploy/extensions/deploy.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,15 +16,33 @@ import { checkBilling } from "./validate";
* @param payload The deploy payload
*/
export async function deploy(context: Context, options: Options, payload: Payload): Promise<void> {
const instancesToCreate = payload.instancesToCreate ?? [];
const instancesToUpdate = payload.instancesToUpdate ?? [];
const instancesToConfigure = payload.instancesToConfigure ?? [];
const instancesToDelete = payload.instancesToDelete ?? [];

// Nothing to do. A functions deploy reaches this stage even when the codebase
// declares no extensions, since every codebase reports an extensions record and
// it is empty in that case. Without this guard such a deploy would go on to
// require the Cloud Billing API below.
if (
!instancesToCreate.length &&
!instancesToUpdate.length &&
!instancesToConfigure.length &&
!instancesToDelete.length
) {
return;
}

const projectId = needProjectId(options);
// First, check that billing is enabled
await checkBilling(projectId, options.nonInteractive);

// Then, check that required products are provisioned.
await bulkCheckProductsProvisioned(projectId, [
...(payload.instancesToCreate ?? []),
...(payload.instancesToUpdate ?? []),
...(payload.instancesToConfigure ?? []),
...instancesToCreate,
...instancesToUpdate,
...instancesToConfigure,
]);

if (context.have) {
Expand All @@ -43,17 +61,17 @@ export async function deploy(context: Context, options: Options, payload: Payloa
// Validate all creates, updates and configures.
// Skip validating local extensions, since doing so requires us to create a new source.
// No need to validate deletes.
for (const create of payload.instancesToCreate?.filter((i) => !!i.ref) ?? []) {
for (const create of instancesToCreate.filter((i) => !!i.ref)) {
const task = tasks.createExtensionInstanceTask(projectId, create, /* validateOnly=*/ true);
void validationQueue.run(task);
}

for (const update of payload.instancesToUpdate?.filter((i) => !!i.ref) ?? []) {
for (const update of instancesToUpdate.filter((i) => !!i.ref)) {
const task = tasks.updateExtensionInstanceTask(projectId, update, /* validateOnly=*/ true);
void validationQueue.run(task);
}

for (const configure of payload.instancesToConfigure?.filter((i) => !!i.ref) ?? []) {
for (const configure of instancesToConfigure.filter((i) => !!i.ref)) {
const task = tasks.configureExtensionInstanceTask(
projectId,
configure,
Expand Down
69 changes: 69 additions & 0 deletions src/deploy/extensions/prepare.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -41,7 +41,7 @@

const context: Context = {};
const payload: Payload = {};
const options: any = {

Check warning on line 44 in src/deploy/extensions/prepare.spec.ts

View workflow job for this annotation

GitHub Actions / lint (24)

Unexpected any. Specify a different type
config: {
src: { functions: { source: "functions" } },
},
Expand All @@ -52,6 +52,36 @@
await expect(prepareDynamicExtensions(context, options, payload, builds)).to.not.be.rejected;
});

it("should report no plan if the extensions API is down", async () => {
haveDynamicStub.rejects(new Error("Extensions API is having an outage"));

const options: any = {
config: {
src: { functions: { source: "functions" } },
},
};

const prepared = await prepareDynamicExtensions({}, options, {}, {});

expect(prepared).to.be.false;
});

it("should report no plan when nothing is defined and nothing exists", async () => {
// functions/prepare uses this to tell "no extensions" apart from a real plan,
// so that the deploy and release stages are skipped entirely.
const payload: Payload = {};
const options: any = {
config: {
src: { functions: { source: "functions" } },
},
};

const prepared = await prepareDynamicExtensions({}, options, payload, {});

expect(prepared).to.be.false;
expect(payload).to.deep.equal({});
});

it("should proceed normally if extensions API is healthy", async () => {
haveDynamicStub.resolves([
{
Expand Down Expand Up @@ -90,5 +120,44 @@
v2apistub.restore();
tosStub.restore();
});

it("should report a plan when an existing instance needs deleting", async () => {
haveDynamicStub.resolves([
{
instanceId: "test-extension",
ref: { publisherId: "test", extensionId: "test", version: "0.1.0" },
params: {},
systemParams: {},
labels: { codebase: "default" },
},
]);

const payload: Payload = {};
const options: any = {
config: {
get: () => [],
src: { functions: { source: "functions" } },
},
rc: { getEtags: () => [] },
dryRun: true,
};

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);

const prepared = await prepareDynamicExtensions({}, options, payload, {});

expect(prepared).to.be.true;
expect(payload.instancesToDelete).to.have.length(1);

wantDynamicStub.restore();
v2apistub.restore();
tosStub.restore();
});
});
});
18 changes: 7 additions & 11 deletions src/deploy/extensions/prepare.ts
Original file line number Diff line number Diff line change
Expand Up @@ -161,13 +161,15 @@ async function prepareHelper(
* @param options The prepare options
* @param payload The prepare payload
* @param builds firebase functions builds
* @return Whether a deployment plan was prepared. False means the context and
* payload were left untouched, so there is nothing for the caller to deploy.
*/
export async function prepareDynamicExtensions(
context: Context,
options: DeployOptions,
payload: Payload,
builds: Record<string, Build>,
): Promise<void> {
): Promise<boolean> {
const functionsConfig = normalizeAndValidate(options.config.src.functions);
const filters = getEndpointFilters(options, functionsConfig);
const extensions = extractExtensionsFromBuilds(builds, filters);
Expand All @@ -189,12 +191,12 @@ export async function prepareDynamicExtensions(
"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;
return false;
}

if (Object.keys(extensions).length === 0 && haveExtensions.length === 0) {
// Nothing defined, and nothing to delete
return;
return false;
}

const dynamicWant = await planner.wantDynamic({
Expand All @@ -203,14 +205,8 @@ export async function prepareDynamicExtensions(
extensions,
});

return prepareHelper(
context,
options,
payload,
dynamicWant,
haveExtensions,
true /* isDynamic */,
);
await prepareHelper(context, options, payload, dynamicWant, haveExtensions, true /* isDynamic */);
return true;
}

/**
Expand Down
10 changes: 7 additions & 3 deletions src/deploy/functions/prepare.ts
Original file line number Diff line number Diff line change
Expand Up @@ -278,9 +278,13 @@ export async function prepare(
if (Object.values(wantBuilds).some((b) => b.extensions)) {
const extContext: ExtContext = {};
const extPayload: ExtPayload = {};
await prepareDynamicExtensions(extContext, options, extPayload, wantBuilds);
context.extensions = extContext;
payload.extensions = extPayload;
// Every codebase reports an extensions record, empty when it declares none, so
// reaching here does not mean there is anything to deploy. Only hand the
// extensions stages a plan when one was actually prepared.
if (await prepareDynamicExtensions(extContext, options, extPayload, wantBuilds)) {
context.extensions = extContext;
payload.extensions = extPayload;
}
}

// == Phase 2. Resolve build to backend.
Expand Down
Loading