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
2 changes: 1 addition & 1 deletion src/dataconnect/freeTrial.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,14 +3,14 @@
import { queryTimeSeries, CmQuery } from "../gcp/cloudmonitoring";
import * as utils from "../utils";

export function freeTrialTermsLink(): string {

Check warning on line 6 in src/dataconnect/freeTrial.ts

View workflow job for this annotation

GitHub Actions / lint (20)

Missing JSDoc comment
return "https://firebase.google.com/pricing";
}

const FREE_TRIAL_METRIC = "sqladmin.googleapis.com/fdc_lifetime_free_trial_per_project";

// Checks whether there is already a free trial instance on a project.
export async function checkFreeTrialInstanceUsed(projectId: string): Promise<boolean> {

Check warning on line 13 in src/dataconnect/freeTrial.ts

View workflow job for this annotation

GitHub Actions / lint (20)

Missing JSDoc comment
const past7d = new Date();
past7d.setDate(past7d.getDate() - 7);
const query: CmQuery = {
Expand All @@ -24,7 +24,7 @@
if (ts.length) {
used = ts[0].points.some((p) => p.value.int64Value);
}
} catch (err: any) {

Check warning on line 27 in src/dataconnect/freeTrial.ts

View workflow job for this annotation

GitHub Actions / lint (20)

Unexpected any. Specify a different type
// If the metric doesn't exist, free trial is not used.
used = false;
}
Expand All @@ -39,8 +39,8 @@
return used;
}

export function upgradeInstructions(projectId: string): string {

Check warning on line 42 in src/dataconnect/freeTrial.ts

View workflow job for this annotation

GitHub Actions / lint (20)

Missing JSDoc comment
return `To provision a CloudSQL Postgres instance on the Firebase Data Connect no-cost trial:
return `To provision a paid CloudSQL Postgres instance:

1. Please upgrade to the pay-as-you-go (Blaze) billing plan. Visit the following page:

Expand Down
12 changes: 0 additions & 12 deletions src/deploy/dataconnect/prepare.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@
import * as build from "../../dataconnect/build";
import * as ensureApis from "../../dataconnect/ensureApis";
import * as requireTosAcceptance from "../../requireTosAcceptance";
import * as cloudbilling from "../../gcp/cloudbilling";

Check failure on line 12 in src/deploy/dataconnect/prepare.spec.ts

View workflow job for this annotation

GitHub Actions / unit (22)

'cloudbilling' is defined but never used

Check failure on line 12 in src/deploy/dataconnect/prepare.spec.ts

View workflow job for this annotation

GitHub Actions / unit (20)

'cloudbilling' is defined but never used

Check failure on line 12 in src/deploy/dataconnect/prepare.spec.ts

View workflow job for this annotation

GitHub Actions / lint (20)

'cloudbilling' is defined but never used

Check failure on line 12 in src/deploy/dataconnect/prepare.spec.ts

View workflow job for this annotation

GitHub Actions / unit (20)

'cloudbilling' is defined but never used
import * as schemaMigration from "../../dataconnect/schemaMigration";
import * as provisionCloudSql from "../../dataconnect/provisionCloudSql";
import { FirebaseError } from "../../error";
Expand All @@ -18,7 +18,6 @@
let sandbox: sinon.SinonSandbox;
let loadAllStub: sinon.SinonStub;
let buildStub: sinon.SinonStub;
let checkBillingEnabledStub: sinon.SinonStub;
let getResourceFiltersStub: sinon.SinonStub;
let diffSchemaStub: sinon.SinonStub;
let setupCloudSqlStub: sinon.SinonStub;
Expand All @@ -26,8 +25,7 @@
beforeEach(() => {
sandbox = sinon.createSandbox();
loadAllStub = sandbox.stub(load, "loadAll").resolves([]);
buildStub = sandbox.stub(build, "build").resolves({} as any);

Check warning on line 28 in src/deploy/dataconnect/prepare.spec.ts

View workflow job for this annotation

GitHub Actions / lint (20)

Unexpected any. Specify a different type

Check warning on line 28 in src/deploy/dataconnect/prepare.spec.ts

View workflow job for this annotation

GitHub Actions / lint (20)

Unsafe argument of type `any` assigned to a parameter of type `DeploymentMetadata | undefined`
checkBillingEnabledStub = sandbox.stub(cloudbilling, "checkBillingEnabled").resolves(true);
sandbox.stub(ensureApis, "ensureApis").resolves();
sandbox.stub(requireTosAcceptance, "requireTosAcceptance").returns(() => Promise.resolve());
getResourceFiltersStub = sandbox.stub(filters, "getResourceFilters").returns(undefined);
Expand All @@ -44,8 +42,8 @@

it("should do nothing if there are no services", async () => {
const context = {};
const options = { config: {} } as any;

Check warning on line 45 in src/deploy/dataconnect/prepare.spec.ts

View workflow job for this annotation

GitHub Actions / lint (20)

Unexpected any. Specify a different type

Check warning on line 45 in src/deploy/dataconnect/prepare.spec.ts

View workflow job for this annotation

GitHub Actions / lint (20)

Unsafe assignment of an `any` value
await prepare.default(context, options);

Check warning on line 46 in src/deploy/dataconnect/prepare.spec.ts

View workflow job for this annotation

GitHub Actions / lint (20)

Unsafe argument of type `any` assigned to a parameter of type `DeployOptions`
expect(loadAllStub.calledOnce).to.be.true;
expect(buildStub.notCalled).to.be.true;
expect(context).to.deep.equal({
Expand All @@ -56,19 +54,9 @@
});
});

it("should throw an error if billing is not enabled", async () => {
checkBillingEnabledStub.resolves(false);
const context = {};
const options = { config: {} } as any;
await expect(prepare.default(context, options)).to.be.rejectedWith(
FirebaseError,
"To provision a CloudSQL Postgres instance on the Firebase Data Connect no-cost trial",
);
});

it("should build services", async () => {
const serviceInfos = [{ sourceDirectory: "a" }, { sourceDirectory: "b" }];
loadAllStub.resolves(serviceInfos as any);

Check warning on line 59 in src/deploy/dataconnect/prepare.spec.ts

View workflow job for this annotation

GitHub Actions / lint (20)

Unexpected any. Specify a different type
const context = {};
const options = { config: {} } as any;
await prepare.default(context, options);
Expand Down
5 changes: 0 additions & 5 deletions src/deploy/dataconnect/prepare.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,12 +11,10 @@ import { ensureApis } from "../../dataconnect/ensureApis";
import { requireTosAcceptance } from "../../requireTosAcceptance";
import { DATA_CONNECT_TOS_ID } from "../../gcp/firedata";
import { setupCloudSql } from "../../dataconnect/provisionCloudSql";
import { checkBillingEnabled } from "../../gcp/cloudbilling";
import { parseServiceName } from "../../dataconnect/names";
import { FirebaseError } from "../../error";
import { requiresVector } from "../../dataconnect/types";
import { diffSchema } from "../../dataconnect/schemaMigration";
import { upgradeInstructions } from "../../dataconnect/freeTrial";

/**
* Prepares for a Firebase DataConnect deployment by loading schemas and connectors from file.
Expand All @@ -25,9 +23,6 @@ import { upgradeInstructions } from "../../dataconnect/freeTrial";
*/
export default async function (context: any, options: DeployOptions): Promise<void> {
const projectId = needProjectId(options);
if (!(await checkBillingEnabled(projectId))) {
throw new FirebaseError(upgradeInstructions(projectId));
}
await ensureApis(projectId);
await requireTosAcceptance(DATA_CONNECT_TOS_ID)(options);
const filters = getResourceFilters(options);
Expand Down
96 changes: 73 additions & 23 deletions src/gcp/cloudsql/cloudsqladmin.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ import * as sinon from "sinon";

import * as sqladmin from "../../gcp/cloudsql/cloudsqladmin";
import * as iam from "../../gcp/iam";
import { cloudSQLAdminOrigin } from "../../api";
import { cloudbillingOrigin, cloudSQLAdminOrigin } from "../../api";
import { Options } from "../../options";
import * as operationPoller from "../../operation-poller";
import { Config } from "../../config";
Expand Down Expand Up @@ -78,27 +78,6 @@ describe("cloudsqladmin", () => {
expect(result).to.deep.equal(instances);
expect(nock.isDone()).to.be.true;
});

it("should handle allowlist error", async () => {
nock(cloudSQLAdminOrigin())
.post(`/${API_VERSION}/projects/${PROJECT_ID}/instances`)
.reply(400, {
error: {
message: "Not allowed to set system label: firebase-data-connect",
},
});

await expect(
sqladmin.createInstance({
projectId: PROJECT_ID,
location: "us-central",
instanceId: INSTANCE_ID,
enableGoogleMlIntegration: false,
freeTrial: false,
}),
).to.be.rejectedWith("Cloud SQL free trial instances are not yet available in us-central");
expect(nock.isDone()).to.be.true;
});
});

describe("getInstance", () => {
Expand Down Expand Up @@ -151,10 +130,21 @@ describe("cloudsqladmin", () => {
});

describe("createInstance", () => {
it("should create an instance", async () => {
beforeEach(() => {
sandbox = sinon.createSandbox();
});

afterEach(() => {
sandbox.restore();
nock.cleanAll();
});
it("should create an paid instance when billing is enabled", async () => {
nock(cloudSQLAdminOrigin())
.post(`/${API_VERSION}/projects/${PROJECT_ID}/instances`)
.reply(200, {});
nock(cloudbillingOrigin()).get(`/v1/projects/${PROJECT_ID}/billingInfo`).reply(200, {
billingEnabled: true,
});

await sqladmin.createInstance({
projectId: PROJECT_ID,
Expand All @@ -166,6 +156,66 @@ describe("cloudsqladmin", () => {

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

it("should create a free instance.", async () => {
nock(cloudSQLAdminOrigin())
.post(`/${API_VERSION}/projects/${PROJECT_ID}/instances`)
.reply(200, {});

await sqladmin.createInstance({
projectId: PROJECT_ID,
location: "us-central",
instanceId: INSTANCE_ID,
enableGoogleMlIntegration: false,
freeTrial: true,
});

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

it("should error when trying to create a paid instance when billing is disabled", async () => {
nock(cloudbillingOrigin()).get(`/v1/projects/${PROJECT_ID}/billingInfo`).reply(200, {
billingEnabled: false,
});

await expect(
sqladmin.createInstance({
projectId: PROJECT_ID,
location: "us-central",
instanceId: INSTANCE_ID,
enableGoogleMlIntegration: false,
freeTrial: false,
}),
).to.be.rejectedWith(
"The Cloud SQL free trial instance has already been used for this project",
);

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

it("should handle allowlist error", async () => {
nock(cloudSQLAdminOrigin())
.post(`/${API_VERSION}/projects/${PROJECT_ID}/instances`)
.reply(400, {
error: {
message: "Not allowed to set system label: firebase-data-connect",
},
});
nock(cloudbillingOrigin()).get(`/v1/projects/${PROJECT_ID}/billingInfo`).reply(200, {
billingEnabled: true,
});

await expect(
sqladmin.createInstance({
projectId: PROJECT_ID,
location: "us-central",
instanceId: INSTANCE_ID,
enableGoogleMlIntegration: false,
freeTrial: false,
}),
).to.be.rejectedWith("Cloud SQL free trial instances are not yet available in us-central");
expect(nock.isDone()).to.be.true;
});
});

describe("updateInstanceForDataConnect", () => {
Expand Down
8 changes: 8 additions & 0 deletions src/gcp/cloudsql/cloudsqladmin.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,9 @@ import { Options } from "../../options";
import { logger } from "../../logger";
import { testIamPermissions } from "../iam";
import { FirebaseError } from "../../error";
import { checkBillingEnabled } from "../cloudbilling";
import { upgradeInstructions } from "../../dataconnect/freeTrial";

const API_VERSION = "v1";

const client = new Client({
Expand Down Expand Up @@ -70,6 +73,11 @@ export async function createInstance(args: {
if (args.enableGoogleMlIntegration) {
databaseFlags.push({ name: "cloudsql.enable_google_ml_integration", value: "on" });
}
if (!args.freeTrial && !(await checkBillingEnabled(args.projectId))) {
throw new FirebaseError(
`The Cloud SQL free trial instance has already been used for this project. ${upgradeInstructions(args.projectId)}`,
);
}
try {
await client.post<Partial<Instance>, Operation>(`projects/${args.projectId}/instances`, {
name: args.instanceId,
Expand Down
30 changes: 14 additions & 16 deletions src/init/features/dataconnect/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,7 @@ import {
} from "../../../gemini/fdcExperience";
import { configstore } from "../../../configstore";
import { trackGA4 } from "../../../track";
import { FirebaseError } from "../../../error";

// Default GCP region for Data Connect
export const FDC_DEFAULT_REGION = "us-east4";
Expand Down Expand Up @@ -108,7 +109,6 @@ export async function askQuestions(setup: Setup): Promise<void> {
shouldProvisionCSQL: false,
};
if (setup.projectId) {
const hasBilling = await isBillingEnabled(setup);
await ensureApis(setup.projectId);
await promptForExistingServices(setup, info);
if (!info.serviceGql) {
Expand All @@ -126,11 +126,7 @@ export async function askQuestions(setup: Setup): Promise<void> {
await ensureGIFApiTos(setup.projectId);
}
}
if (hasBilling) {
await promptForCloudSQL(setup, info);
} else if (info.appDescription) {
await promptForLocation(setup, info);
}
await promptForCloudSQL(setup, info);
}
setup.featureInfo = setup.featureInfo || {};
setup.featureInfo.dataconnect = info;
Expand Down Expand Up @@ -174,9 +170,6 @@ export async function actuate(setup: Setup, config: Config, options: any): Promi
https://console.firebase.google.com/project/${setup.projectId!}/dataconnect/locations/${info.locationId}/services/${info.serviceId}/schema`,
);
}
if (!setup.isBillingEnabled) {
setup.instructions.push(upgradeInstructions(setup.projectId || "your-firebase-project"));
}
setup.instructions.push(
`Install the Data Connect VS Code Extensions. You can explore Data Connect Query on local pgLite and Cloud SQL Postgres Instance.`,
);
Expand All @@ -196,9 +189,7 @@ async function actuateWithInfo(
}

await ensureApis(projectId, /* silent =*/ true);
const provisionCSQL = info.shouldProvisionCSQL && (await isBillingEnabled(setup));
if (provisionCSQL) {
// Kicks off Cloud SQL provisioning if the project has billing enabled.
if (info.shouldProvisionCSQL) {
await setupCloudSql({
projectId: projectId,
location: info.locationId,
Expand Down Expand Up @@ -253,7 +244,7 @@ async function actuateWithInfo(
projectId,
info,
schemaFiles,
provisionCSQL,
info.shouldProvisionCSQL,
);
await upsertSchema(saveSchemaGql);
if (waitForCloudSQLProvision) {
Expand Down Expand Up @@ -576,6 +567,8 @@ async function chooseExistingService(existing: Service[]): Promise<Service | und
}

async function promptForCloudSQL(setup: Setup, info: RequiredInfo): Promise<void> {
const FREE = "__free__";
const PAID = "__paid__";
if (!setup.projectId) {
return;
}
Expand All @@ -593,20 +586,25 @@ async function promptForCloudSQL(setup: Setup, info: RequiredInfo): Promise<void
choices = choices.filter((c) => info.locationId === "" || info.locationId === c.location);
if (choices.length) {
if (!(await checkFreeTrialInstanceUsed(setup.projectId))) {
choices.push({ name: "Create a new free trial instance", value: "", location: "" });
choices.push({ name: "Create a new free trial instance", value: FREE, location: "" });
} else {
choices.push({ name: "Create a new CloudSQL instance", value: "", location: "" });
choices.push({ name: "Create a new CloudSQL instance", value: PAID, location: "" });
}
info.cloudSqlInstanceId = await select<string>({
message: `Which CloudSQL instance would you like to use?`,
choices,
});
if (info.cloudSqlInstanceId !== "") {
if (![FREE, PAID].includes(info.cloudSqlInstanceId)) {
info.analyticsFlow += "_pick_existing_csql";
// Infer location if a CloudSQL instance is chosen.
info.locationId = choices.find((c) => c.value === info.cloudSqlInstanceId)!.location;
} else {
info.analyticsFlow += "_pick_new_csql";
if (info.cloudSqlInstanceId === PAID && !(await isBillingEnabled(setup))) {
throw new FirebaseError(
`The Cloud SQL free trial instance has already been used for this project. ${upgradeInstructions(setup.projectId)}`,
);
}
info.cloudSqlInstanceId = await input({
message: `What ID would you like to use for your new CloudSQL instance?`,
default: newUniqueId(
Expand Down
Loading