Skip to content
Merged
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
3 changes: 3 additions & 0 deletions src/commands/ext-install.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ import { Command } from "../command";
import { FirebaseError } from "../error";
import * as getProjectId from "../getProjectId";
import * as extensionsApi from "../extensions/extensionsApi";
import * as provisioningHelper from "../extensions/provisioningHelper";
import { displayWarningPrompts } from "../extensions/warnings";
import * as paramHelper from "../extensions/paramHelper";
import {
Expand Down Expand Up @@ -57,6 +58,8 @@ async function installExtension(options: InstallExtensionOptions): Promise<void>
"Installing your extension instance. This usually takes 3 to 5 minutes..."
);
try {
await provisioningHelper.checkProductsProvisioned(projectId, spec);
Copy link
Contributor

Choose a reason for hiding this comment

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

Do we need this on ext:update as well? Its a corner case, but I could imagine it being relevant if a new version added a feature that required a new service to be provisioned

Copy link
Collaborator Author

Choose a reason for hiding this comment

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

Good point. Added.


if (spec.billingRequired) {
const enabled = await checkBillingEnabled(projectId);
if (!enabled) {
Expand Down
4 changes: 4 additions & 0 deletions src/commands/ext-update.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ import { displayNode10UpdateBillingNotice } from "../extensions/billingMigration
import { enableBilling } from "../extensions/checkProjectBilling";
import { checkBillingEnabled } from "../gcp/cloudbilling";
import * as extensionsApi from "../extensions/extensionsApi";
import * as provisioningHelper from "../extensions/provisioningHelper";
import {
ensureExtensionsApiEnabled,
logPrefix,
Expand Down Expand Up @@ -232,6 +233,9 @@ export default new Command("ext:update <extensionInstanceId> [updateSource]")
newSourceOrigin === SourceOrigin.OFFICIAL_EXTENSION ||
newSourceOrigin === SourceOrigin.OFFICIAL_EXTENSION_VERSION;
await displayChanges(existingSpec, newSpec, isOfficial);

await provisioningHelper.checkProductsProvisioned(projectId, newSpec);

if (newSpec.billingRequired) {
const enabled = await checkBillingEnabled(projectId);
if (!enabled) {
Expand Down
116 changes: 116 additions & 0 deletions src/extensions/provisioningHelper.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,116 @@
import * as marked from "marked";

import * as extensionsApi from "./extensionsApi";
import * as api from "../api";
import { FirebaseError } from "../error";

/** Product for which provisioning can be (or is) deferred */
export enum DeferredProduct {
STORAGE,
AUTH,
}

/**
* Checks whether products used by the extension require provisioning.
*
* @param spec extension spec
*/
export async function checkProductsProvisioned(
projectId: string,
spec: extensionsApi.ExtensionSpec
): Promise<void> {
const usedProducts = getUsedProducts(spec);
const needProvisioning = [] as DeferredProduct[];
let isStorageProvisionedPromise;
Copy link
Contributor

@joehan joehan Jun 17, 2021

Choose a reason for hiding this comment

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

Optional: I feel like we can simplify this chunk a bit:

if (usedProducts.includes(DeferredProduct.STORAGE) {
  storageProvisioned = await isStorageProvisioned(projectId);
 if (!storageProvisioned) {
  needProvisioning.push(DeferredProduct.STORAGE);
  }
}
// Repeat for AUTH

IMO, this is a little more readable since we don't need to pass around promises quite as much

Copy link
Collaborator Author

Choose a reason for hiding this comment

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

This is actually on purpose. I want storage and auth checks to be running in parallel (if both are used), to speed up the check.

Copy link
Contributor

Choose a reason for hiding this comment

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

That seems like a good enough reason to me!

let isAuthProvisionedPromise;
if (usedProducts.includes(DeferredProduct.STORAGE)) {
isStorageProvisionedPromise = isStorageProvisioned(projectId);
}
if (usedProducts.includes(DeferredProduct.AUTH)) {
isAuthProvisionedPromise = isAuthProvisioned(projectId);
}

if (isStorageProvisionedPromise && !(await isStorageProvisionedPromise)) {
needProvisioning.push(DeferredProduct.STORAGE);
}
if (isAuthProvisionedPromise && !(await isAuthProvisionedPromise)) {
needProvisioning.push(DeferredProduct.AUTH);
}

if (needProvisioning.length > 0) {
let errorMessage =
"Some services used by this extension have not been set up on your " +
"Firebase project. To ensure this extension works as intended, you must enable these " +
"services by following the provided links, then retry installing the extension\n\n";
if (needProvisioning.includes(DeferredProduct.STORAGE)) {
errorMessage +=
" - Firebase Storage: store and retrieve user-generated files like images, audio, and " +
"video without server-side code.\n";
errorMessage += ` https://console.firebase.google.com/project/${projectId}/storage`;
errorMessage += "\n";
}
if (needProvisioning.includes(DeferredProduct.AUTH)) {
errorMessage +=
" - Firebase Authentication: authenticate and manage users from a variety of providers " +
"without server-side code.\n";
errorMessage += ` https://console.firebase.google.com/project/${projectId}/authentication/users`;
}
throw new FirebaseError(marked(errorMessage), { exit: 2 });
}
}

/**
* From the spec determines which products are used by the extension and
* returns the list.
*/
export function getUsedProducts(spec: extensionsApi.ExtensionSpec): DeferredProduct[] {
const usedProducts: DeferredProduct[] = [];
const usedApis = spec.apis?.map((api) => api.apiName);
const usedRoles = spec.roles?.map((r) => r.role.split(".")[0]);
const usedTriggers = spec.resources.map((r) => getTriggerType(r.propertiesYaml));
if (
usedApis?.includes("storage-component.googleapis.com") ||
usedRoles?.includes("storage") ||
usedTriggers.find((t) => t?.startsWith("google.storage."))
) {
usedProducts.push(DeferredProduct.STORAGE);
}
if (
usedApis?.includes("identitytoolkit.googleapis.com") ||
usedRoles?.includes("firebaseauth") ||
usedTriggers.find((t) => t?.startsWith("providers/firebase.auth/"))
) {
usedProducts.push(DeferredProduct.AUTH);
}
return usedProducts;
}

/**
* Parses out trigger eventType from the propertiesYaml.
*/
function getTriggerType(propertiesYaml: string | undefined) {
return propertiesYaml?.match(/eventType:\ ([\S]+)/)?.[1];
}

async function isStorageProvisioned(projectId: string): Promise<boolean> {
const resp = await api.request("GET", `/v1beta/projects/${projectId}/buckets`, {
auth: true,
origin: api.firebaseStorageOrigin,
});
return !!resp.body?.buckets?.find((bucket: any) => {
const bucketResourceName = bucket.name;
// Bucket resource name looks like: projects/PROJECT_NUMBER/buckets/BUCKET_NAME
// and we just need the BUCKET_NAME part.
const bucketResourceNameTokens = bucketResourceName.split("/");
const pattern = "^" + projectId + "(.[[a-z0-9]+)*.appspot.com$";
return new RegExp(pattern).test(bucketResourceNameTokens[bucketResourceNameTokens.length - 1]);
});
}

async function isAuthProvisioned(projectId: string): Promise<boolean> {
const resp = await api.request("GET", `/v1/projects/${projectId}/products`, {
auth: true,
origin: api.firedataOrigin,
});
return !!resp.body?.activation?.map((a: any) => a.service).includes("FIREBASE_AUTH");
}
207 changes: 207 additions & 0 deletions src/test/extensions/provisioningHelper.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,207 @@
import * as nock from "nock";
import { expect } from "chai";

import * as api from "../../api";
import * as provisioningHelper from "../../extensions/provisioningHelper";
import * as extensionsApi from "../../extensions/extensionsApi";
import { FirebaseError } from "../../error";

const TEST_INSTANCES_RESPONSE = {};
const PROJECT_ID = "test-project";
const SPEC_WITH_STORAGE_AND_AUTH = {
apis: [
{
apiName: "storage-component.googleapis.com",
},
{
apiName: "identitytoolkit.googleapis.com",
},
] as extensionsApi.Api[],
resources: [] as extensionsApi.Resource[],
} as extensionsApi.ExtensionSpec;

const FIREDATA_AUTH_ACTIVATED_RESPONSE = {
activation: [
{
service: "FIREBASE_AUTH",
},
],
};

const FIREBASE_STORAGE_DEFAULT_BUCKET_LINKED_RESPONSE = {
buckets: [
{
name: `projects/12345/bucket/${PROJECT_ID}.appspot.com`,
},
],
};

describe("provisioningHelper", () => {
afterEach(() => {
nock.cleanAll();
});

describe("getUsedProducts", () => {
let testSpec: extensionsApi.ExtensionSpec;

beforeEach(() => {
testSpec = {
apis: [
{
apiName: "unrelated.googleapis.com",
},
] as extensionsApi.Api[],
roles: [
{
role: "unrelated.role",
},
] as extensionsApi.Role[],
resources: [
{
propertiesYaml:
"availableMemoryMb: 1024\neventTrigger:\n eventType: providers/unrelates.service/eventTypes/something.do\n resource: projects/_/buckets/${param:IMG_BUCKET}\nlocation: ${param:LOCATION}\nruntime: nodejs10\n",
},
] as extensionsApi.Resource[],
} as extensionsApi.ExtensionSpec;
});

it("returns empty array when nothing is used", () => {
expect(provisioningHelper.getUsedProducts(testSpec)).to.be.empty;
});

it("returns STORAGE when Storage API is used", () => {
testSpec.apis?.push({
apiName: "storage-component.googleapis.com",
reason: "whatever",
});
expect(provisioningHelper.getUsedProducts(testSpec)).to.be.deep.eq([
provisioningHelper.DeferredProduct.STORAGE,
]);
});

it("returns STORAGE when Storage Role is used", () => {
testSpec.roles?.push({
role: "storage.object.admin",
reason: "whatever",
});
expect(provisioningHelper.getUsedProducts(testSpec)).to.be.deep.eq([
provisioningHelper.DeferredProduct.STORAGE,
]);
});

it("returns STORAGE when Storage trigger is used", () => {
testSpec.resources?.push({
propertiesYaml:
"availableMemoryMb: 1024\neventTrigger:\n eventType: google.storage.object.finalize\n resource: projects/_/buckets/${param:IMG_BUCKET}\nlocation: ${param:LOCATION}\nruntime: nodejs10\n",
} as extensionsApi.Resource);
expect(provisioningHelper.getUsedProducts(testSpec)).to.be.deep.eq([
provisioningHelper.DeferredProduct.STORAGE,
]);
});

it("returns AUTH when Authentication API is used", () => {
testSpec.apis?.push({
apiName: "identitytoolkit.googleapis.com",
reason: "whatever",
});
expect(provisioningHelper.getUsedProducts(testSpec)).to.be.deep.eq([
provisioningHelper.DeferredProduct.AUTH,
]);
});

it("returns AUTH when Authentication Role is used", () => {
testSpec.roles?.push({
role: "firebaseauth.user.admin",
reason: "whatever",
});
expect(provisioningHelper.getUsedProducts(testSpec)).to.be.deep.eq([
provisioningHelper.DeferredProduct.AUTH,
]);
});

it("returns AUTH when Auth trigger is used", () => {
testSpec.resources?.push({
propertiesYaml:
"availableMemoryMb: 1024\neventTrigger:\n eventType: providers/firebase.auth/eventTypes/user.create\n resource: projects/_/buckets/${param:IMG_BUCKET}\nlocation: ${param:LOCATION}\nruntime: nodejs10\n",
} as extensionsApi.Resource);
expect(provisioningHelper.getUsedProducts(testSpec)).to.be.deep.eq([
provisioningHelper.DeferredProduct.AUTH,
]);
});
});

describe("checkProductsProvisioned", () => {
it("passes provisioning check status when nothing is used", async () => {
await expect(
provisioningHelper.checkProductsProvisioned(PROJECT_ID, {
resources: [] as extensionsApi.Resource[],
} as extensionsApi.ExtensionSpec)
).to.be.fulfilled;
});

it("passes provisioning check when all is provisioned", async () => {
nock(api.firedataOrigin)
.get(`/v1/projects/${PROJECT_ID}/products`)
.reply(200, FIREDATA_AUTH_ACTIVATED_RESPONSE);
nock(api.firebaseStorageOrigin)
.get(`/v1beta/projects/${PROJECT_ID}/buckets`)
.reply(200, FIREBASE_STORAGE_DEFAULT_BUCKET_LINKED_RESPONSE);

await expect(
provisioningHelper.checkProductsProvisioned(PROJECT_ID, SPEC_WITH_STORAGE_AND_AUTH)
).to.be.fulfilled;

expect(nock.isDone()).to.be.true;
});

it("fails provisioning check storage when default bucket is not linked", async () => {
nock(api.firedataOrigin)
.get(`/v1/projects/${PROJECT_ID}/products`)
.reply(200, FIREDATA_AUTH_ACTIVATED_RESPONSE);
nock(api.firebaseStorageOrigin)
.get(`/v1beta/projects/${PROJECT_ID}/buckets`)
.reply(200, {
buckets: [
{
name: `projects/12345/bucket/some-other-bucket`,
},
],
});

await expect(
provisioningHelper.checkProductsProvisioned(PROJECT_ID, SPEC_WITH_STORAGE_AND_AUTH)
).to.be.rejectedWith(FirebaseError, "Firebase Storage: store and retrieve user-generated");

expect(nock.isDone()).to.be.true;
});

it("fails provisioning check storage when no firebase storage buckets", async () => {
nock(api.firedataOrigin)
.get(`/v1/projects/${PROJECT_ID}/products`)
.reply(200, FIREDATA_AUTH_ACTIVATED_RESPONSE);
nock(api.firebaseStorageOrigin).get(`/v1beta/projects/${PROJECT_ID}/buckets`).reply(200, {});

await expect(
provisioningHelper.checkProductsProvisioned(PROJECT_ID, SPEC_WITH_STORAGE_AND_AUTH)
).to.be.rejectedWith(FirebaseError, "Firebase Storage: store and retrieve user-generated");

expect(nock.isDone()).to.be.true;
});

it("fails provisioning check storage when no auth is not provisioned", async () => {
nock(api.firedataOrigin).get(`/v1/projects/${PROJECT_ID}/products`).reply(200, {});
nock(api.firebaseStorageOrigin)
.get(`/v1beta/projects/${PROJECT_ID}/buckets`)
.reply(200, FIREBASE_STORAGE_DEFAULT_BUCKET_LINKED_RESPONSE);

await expect(
provisioningHelper.checkProductsProvisioned(PROJECT_ID, SPEC_WITH_STORAGE_AND_AUTH)
).to.be.rejectedWith(
FirebaseError,
"Firebase Authentication: authenticate and manage users from"
);

expect(nock.isDone()).to.be.true;
});
});
});