Skip to content
3 changes: 2 additions & 1 deletion CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
- Fixed a bug when deploying firestore indexes failed due to broken index comparison logic (#8859)
- Added prefix support for multi-instance Cloud Functions extension parameters. (#8911)
- Fixed a bug when `firebase deploy --only dataconnect` doesn't include GQL in nested folders (#8981)
- Make it possible to init a dataconnect project in non interactive mode (#8993)
- Added 2 new MCP tools for crashlytics `get_sample_crash_for_issue` and `get_issue_details` (#8995)
- Use Gemini to generate schema and seed_data.gql in `firebase init dataconnect` (#8988)
- Fixed a bug when `firebase deploy --only dataconnect` didn't include GQL files in nested folders (#8981)
- Changed `firebase deploy` create Cloud SQL instances asynchronously (#9004)
9 changes: 9 additions & 0 deletions src/commands/dataconnect-sql-grant.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import { grantRoleToUserInSchema } from "../dataconnect/schemaMigration";
import { requireAuth } from "../requireAuth";
import { FirebaseError } from "../error";
import { fdcSqlRoleMap } from "../gcp/cloudsql/permissionsSetup";
import { iamUserIsCSQLAdmin } from "../gcp/cloudsql/cloudsqladmin";

const allowedRoles = Object.keys(fdcSqlRoleMap);

Expand Down Expand Up @@ -38,6 +39,14 @@ export const command = new Command("dataconnect:sql:grant [serviceId]")
throw new FirebaseError(`Role should be one of ${allowedRoles.join(" | ")}.`);
}

// Make sure current user can perform this action.
const userIsCSQLAdmin = await iamUserIsCSQLAdmin(options);
if (!userIsCSQLAdmin) {
throw new FirebaseError(
`Only users with 'roles/cloudsql.admin' can grant SQL roles. If you do not have this role, ask your database administrator to run this command or manually grant ${role} to ${email}`,
);
}

const projectId = needProjectId(options);
await ensureApis(projectId);
const serviceInfo = await pickService(projectId, options.config, serviceId);
Expand Down
8 changes: 3 additions & 5 deletions src/commands/dataconnect-sql-setup.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,8 +9,7 @@ import { ensureApis } from "../dataconnect/ensureApis";
import { setupSQLPermissions, getSchemaMetadata } from "../gcp/cloudsql/permissionsSetup";
import { DEFAULT_SCHEMA } from "../gcp/cloudsql/permissions";
import { getIdentifiers, ensureServiceIsConnectedToCloudSql } from "../dataconnect/schemaMigration";
import { getIAMUser } from "../gcp/cloudsql/connect";
import * as cloudSqlAdminClient from "../gcp/cloudsql/cloudsqladmin";
import { setupIAMUsers } from "../gcp/cloudsql/connect";

export const command = new Command("dataconnect:sql:setup [serviceId]")
.description("set up your CloudSQL database")
Expand Down Expand Up @@ -41,9 +40,8 @@ export const command = new Command("dataconnect:sql:setup [serviceId]")
/* linkIfNotConnected=*/ true,
);

// Create an IAM user for the current identity.
const { user, mode } = await getIAMUser(options);
await cloudSqlAdminClient.createUser(projectId, instanceId, mode, user);
// Setup the IAM user for the current identity.
await setupIAMUsers(instanceId, options);

const schemaInfo = await getSchemaMetadata(instanceId, databaseId, DEFAULT_SCHEMA, options);
await setupSQLPermissions(instanceId, databaseId, schemaInfo, options);
Expand Down
3 changes: 2 additions & 1 deletion src/dataconnect/client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,14 +6,14 @@
const DATACONNECT_API_VERSION = "v1";
const PAGE_SIZE_MAX = 100;

const dataconnectClient = () =>

Check warning on line 9 in src/dataconnect/client.ts

View workflow job for this annotation

GitHub Actions / lint (20)

Missing return type on function
new Client({
urlPrefix: dataconnectOrigin(),
apiVersion: DATACONNECT_API_VERSION,
auth: true,
});

export async function listLocations(projectId: string): Promise<string[]> {

Check warning on line 16 in src/dataconnect/client.ts

View workflow job for this annotation

GitHub Actions / lint (20)

Missing JSDoc comment
const res = await dataconnectClient().get<{
locations: {
name: string;
Expand All @@ -30,14 +30,14 @@
return res.body;
}

export async function listAllServices(projectId: string): Promise<types.Service[]> {

Check warning on line 33 in src/dataconnect/client.ts

View workflow job for this annotation

GitHub Actions / lint (20)

Missing JSDoc comment
const res = await dataconnectClient().get<{ services: types.Service[] }>(
`/projects/${projectId}/locations/-/services`,
);
return res.body.services ?? [];
}

export async function createService(

Check warning on line 40 in src/dataconnect/client.ts

View workflow job for this annotation

GitHub Actions / lint (20)

Missing JSDoc comment
projectId: string,
locationId: string,
serviceId: string,
Expand All @@ -60,15 +60,15 @@
operationResourceName: op.body.name,
});
return pollRes;
} catch (err: any) {

Check warning on line 63 in src/dataconnect/client.ts

View workflow job for this annotation

GitHub Actions / lint (20)

Unexpected any. Specify a different type
if (err.status !== 409) {

Check warning on line 64 in src/dataconnect/client.ts

View workflow job for this annotation

GitHub Actions / lint (20)

Unsafe member access .status on an `any` value
throw err;
}
return undefined; // Service already exists
}
}

export async function deleteService(serviceName: string): Promise<types.Service> {

Check warning on line 71 in src/dataconnect/client.ts

View workflow job for this annotation

GitHub Actions / lint (20)

Missing JSDoc comment
// Note that we need to force delete in order to delete child resources too.
const op = await dataconnectClient().delete<types.Service>(serviceName, {
queryParams: { force: "true" },
Expand All @@ -83,14 +83,14 @@

/** Schema methods */

export async function getSchema(serviceName: string): Promise<types.Schema | undefined> {

Check warning on line 86 in src/dataconnect/client.ts

View workflow job for this annotation

GitHub Actions / lint (20)

Missing JSDoc comment
try {
const res = await dataconnectClient().get<types.Schema>(
`${serviceName}/schemas/${types.SCHEMA_ID}`,
);
return res.body;
} catch (err: any) {

Check warning on line 92 in src/dataconnect/client.ts

View workflow job for this annotation

GitHub Actions / lint (20)

Unexpected any. Specify a different type
if (err.status !== 404) {

Check warning on line 93 in src/dataconnect/client.ts

View workflow job for this annotation

GitHub Actions / lint (20)

Unsafe member access .status on an `any` value
throw err;
}
return undefined;
Expand Down Expand Up @@ -125,14 +125,15 @@
export async function upsertSchema(
schema: types.Schema,
validateOnly: boolean = false,
async: boolean = false,
): Promise<types.Schema | undefined> {
const op = await dataconnectClient().patch<types.Schema, types.Schema>(`${schema.name}`, schema, {
queryParams: {
allowMissing: "true",
validateOnly: validateOnly ? "true" : "false",
},
});
if (validateOnly) {
if (validateOnly || async) {
return;
}
return operationPoller.pollOperation<types.Schema>({
Expand Down
44 changes: 0 additions & 44 deletions src/dataconnect/freeTrial.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,6 @@
import * as clc from "colorette";

import { queryTimeSeries, CmQuery } from "../gcp/cloudmonitoring";
import { listInstances } from "../gcp/cloudsql/cloudsqladmin";
import * as utils from "../utils";

export function freeTrialTermsLink(): string {
Expand Down Expand Up @@ -40,49 +39,6 @@ export async function checkFreeTrialInstanceUsed(projectId: string): Promise<boo
return used;
}

export async function getFreeTrialInstanceId(projectId: string): Promise<string | undefined> {
const instances = await listInstances(projectId);
return instances.find((i) => i.settings.userLabels?.["firebase-data-connect"] === "ft")?.name;
}

export async function isFreeTrialError(err: any, projectId: string): Promise<boolean> {
// checkFreeTrialInstanceUsed is also called to ensure the request didn't fail due to an unrelated quota issue.
return err.message.includes("Quota Exhausted") && (await checkFreeTrialInstanceUsed(projectId))
? true
: false;
}

export function printFreeTrialUnavailable(
projectId: string,
configYamlPath: string,
instanceId?: string,
): void {
if (!instanceId) {
utils.logLabeledError(
"dataconnect",
"The CloudSQL free trial has already been used on this project.",
);
utils.logLabeledError(
"dataconnect",
`You may create or use a paid CloudSQL instance by visiting https://console.cloud.google.com/sql/instances`,
);
return;
}
utils.logLabeledError(
"dataconnect",
`Project '${projectId} already has a CloudSQL instance '${instanceId}' on the Firebase Data Connect no-cost trial.`,
);
const reuseHint =
`To use a different database in the same instance, ${clc.bold(`change the ${clc.blue("instanceId")} to "${instanceId}"`)} and update ${clc.blue("location")} in ` +
`${clc.green(configYamlPath)}.`;

utils.logLabeledError("dataconnect", reuseHint);
utils.logLabeledError(
"dataconnect",
`Alternatively, you may create a new (paid) CloudSQL instance at https://console.cloud.google.com/sql/instances`,
);
}

export function upgradeInstructions(projectId: string): string {
return `To provision a CloudSQL Postgres instance on the Firebase Data Connect no-cost trial:

Expand Down
201 changes: 111 additions & 90 deletions src/dataconnect/provisionCloudSql.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,139 +4,160 @@ import { grantRolesToCloudSqlServiceAccount } from "./checkIam";
import { Instance } from "../gcp/cloudsql/types";
import { promiseWithSpinner } from "../utils";
import { logger } from "../logger";
import { freeTrialTermsLink, checkFreeTrialInstanceUsed } from "./freeTrial";

const GOOGLE_ML_INTEGRATION_ROLE = "roles/aiplatform.user";

import { freeTrialTermsLink, checkFreeTrialInstanceUsed } from "./freeTrial";
/** Sets up a Cloud SQL instance, database and its permissions. */
export async function setupCloudSql(args: {
projectId: string;
location: string;
instanceId: string;
databaseId: string;
requireGoogleMlIntegration: boolean;
dryRun?: boolean;
}): Promise<void> {
await upsertInstance({ ...args });
const { projectId, instanceId, requireGoogleMlIntegration, dryRun } = args;
if (requireGoogleMlIntegration && !dryRun) {
await grantRolesToCloudSqlServiceAccount(projectId, instanceId, [GOOGLE_ML_INTEGRATION_ROLE]);
}
}

export async function provisionCloudSql(args: {
async function upsertInstance(args: {
projectId: string;
location: string;
instanceId: string;
databaseId: string;
enableGoogleMlIntegration: boolean;
waitForCreation: boolean;
silent?: boolean;
requireGoogleMlIntegration: boolean;
dryRun?: boolean;
}): Promise<string> {
let connectionName = ""; // Not used yet, will be used for schema migration
const {
projectId,
location,
instanceId,
databaseId,
enableGoogleMlIntegration,
waitForCreation,
silent,
dryRun,
} = args;
}): Promise<void> {
const { projectId, instanceId, requireGoogleMlIntegration, dryRun } = args;
try {
const existingInstance = await cloudSqlAdminClient.getInstance(projectId, instanceId);
silent ||
utils.logLabeledBullet("dataconnect", `Found existing Cloud SQL instance ${instanceId}.`);
connectionName = existingInstance?.connectionName || "";
const why = getUpdateReason(existingInstance, enableGoogleMlIntegration);
utils.logLabeledBullet("dataconnect", `Found existing Cloud SQL instance ${instanceId}.`);
const why = getUpdateReason(existingInstance, requireGoogleMlIntegration);
if (why) {
const cta = dryRun
? `It will be updated on your next deploy.`
: `Updating instance. This may take a few minutes...`;
silent ||
if (dryRun) {
utils.logLabeledBullet(
"dataconnect",
`Instance ${instanceId} settings not compatible with Firebase Data Connect. ` + cta + why,
`Cloud SQL instance ${instanceId} settings not compatible with Firebase Data Connect. ` +
`It will be updated on your next deploy.` +
why,
);
} else {
utils.logLabeledBullet(
"dataconnect",
`Cloud SQL instance ${instanceId} settings not compatible with Firebase Data Connect. ` +
why,
);
if (!dryRun) {
await promiseWithSpinner(
() =>
cloudSqlAdminClient.updateInstanceForDataConnect(
existingInstance,
enableGoogleMlIntegration,
requireGoogleMlIntegration,
),
"Updating your instance...",
"Updating your Cloud SQL instance...",
);
silent || utils.logLabeledBullet("dataconnect", "Instance updated");
}
}
await upsertDatabase({ ...args });
} catch (err: any) {
// We only should catch NOT FOUND errors
if (err.status !== 404) {
throw err;
}
const cta = dryRun ? "It will be created on your next deploy" : "Creating it now.";
const freeTrialUsed = await checkFreeTrialInstanceUsed(projectId);
silent ||
utils.logLabeledBullet(
"dataconnect",
`CloudSQL instance '${instanceId}' not found.` + cta + freeTrialUsed
? ""
: `\nThis instance is provided under the terms of the Data Connect no-cost trial ${freeTrialTermsLink()}` +
dryRun
? `\nMonitor the progress at ${cloudSqlAdminClient.instanceConsoleLink(projectId, instanceId)}`
: "",
);
// Cloud SQL instance is not found, start its creation.
await createInstance({ ...args });
}
}

if (!dryRun) {
const newInstance = await promiseWithSpinner(
() =>
cloudSqlAdminClient.createInstance({
projectId,
location,
instanceId,
enableGoogleMlIntegration,
waitForCreation,
freeTrial: !freeTrialUsed,
}),
"Creating your instance...",
);
if (newInstance) {
silent || utils.logLabeledBullet("dataconnect", "Instance created");
connectionName = newInstance?.connectionName || "";
} else {
silent ||
utils.logLabeledBullet(
"dataconnect",
"Cloud SQL instance creation started. While it is being set up, your data will be saved in a temporary database. When it is ready, your data will be migrated.",
);
return connectionName;
}
}
async function createInstance(args: {
projectId: string;
location: string;
instanceId: string;
requireGoogleMlIntegration: boolean;
dryRun?: boolean;
}): Promise<void> {
const { projectId, location, instanceId, requireGoogleMlIntegration, dryRun } = args;
const freeTrialUsed = await checkFreeTrialInstanceUsed(projectId);
if (dryRun) {
utils.logLabeledBullet(
"dataconnect",
`Cloud SQL Instance ${instanceId} not found. It will be created on your next deploy.`,
);
} else {
await cloudSqlAdminClient.createInstance({
projectId,
location,
instanceId,
enableGoogleMlIntegration: requireGoogleMlIntegration,
freeTrial: !freeTrialUsed,
});
utils.logLabeledBullet(
"dataconnect",
cloudSQLBeingCreated(projectId, instanceId, !freeTrialUsed),
);
}
}

/**
* Returns a message indicating that a Cloud SQL instance is being created.
*/
export function cloudSQLBeingCreated(
projectId: string,
instanceId: string,
includeFreeTrialToS?: boolean,
): string {
return (
`Cloud SQL Instance ${instanceId} is being created.` +
(includeFreeTrialToS
? `\nThis instance is provided under the terms of the Data Connect no-cost trial ${freeTrialTermsLink()}`
: "") +
`
Meanwhile, your data are saved in a temporary database and will be migrated once complete. Monitor its progress at

${cloudSqlAdminClient.instanceConsoleLink(projectId, instanceId)}
`
);
}

async function upsertDatabase(args: {
projectId: string;
instanceId: string;
databaseId: string;
dryRun?: boolean;
}): Promise<void> {
const { projectId, instanceId, databaseId, dryRun } = args;
try {
await cloudSqlAdminClient.getDatabase(projectId, instanceId, databaseId);
silent || utils.logLabeledBullet("dataconnect", `Found existing database ${databaseId}.`);
utils.logLabeledBullet("dataconnect", `Found existing Postgres Database ${databaseId}.`);
} catch (err: any) {
if (err.status === 404) {
if (dryRun) {
silent ||
utils.logLabeledBullet(
"dataconnect",
`Postgres database ${databaseId} not found. It will be created on your next deploy.`,
);
} else {
await cloudSqlAdminClient.createDatabase(projectId, instanceId, databaseId);
silent || utils.logLabeledBullet("dataconnect", `Postgres database ${databaseId} created.`);
}
} else {
if (err.status !== 404) {
// Skip it if the database is not accessible.
// Possible that the CSQL instance is in the middle of something.
logger.debug(`Unexpected error from CloudSQL: ${err}`);
silent || utils.logLabeledWarning("dataconnect", `Database ${databaseId} is not accessible.`);
logger.debug(`Unexpected error from Cloud SQL: ${err}`);
utils.logLabeledWarning("dataconnect", `Postgres Database ${databaseId} is not accessible.`);
return;
}
if (dryRun) {
utils.logLabeledBullet(
"dataconnect",
`Postgres Database ${databaseId} not found. It will be created on your next deploy.`,
);
} else {
await cloudSqlAdminClient.createDatabase(projectId, instanceId, databaseId);
utils.logLabeledBullet("dataconnect", `Postgres Database ${databaseId} created.`);
}
}
if (enableGoogleMlIntegration && !dryRun) {
await grantRolesToCloudSqlServiceAccount(projectId, instanceId, [GOOGLE_ML_INTEGRATION_ROLE]);
}
return connectionName;
}

/**
* Validate that existing CloudSQL instances have the necessary settings.
* Validate that existing Cloud SQL instances have the necessary settings.
*/
export function getUpdateReason(instance: Instance, requireGoogleMlIntegration: boolean): string {
let reason = "";
const settings = instance.settings;
// CloudSQL instances must have public IP enabled to be used with Firebase Data Connect.
// Cloud SQL instances must have public IP enabled to be used with Firebase Data Connect.
if (!settings.ipConfiguration?.ipv4Enabled) {
reason += "\n - to enable public IP.";
}
Expand All @@ -154,7 +175,7 @@ export function getUpdateReason(instance: Instance, requireGoogleMlIntegration:
}
}

// CloudSQL instances must have IAM authentication enabled to be used with Firebase Data Connect.
// Cloud SQL instances must have IAM authentication enabled to be used with Firebase Data Connect.
const isIamEnabled =
settings.databaseFlags?.some(
(f) => f.name === "cloudsql.iam_authentication" && f.value === "on",
Expand Down
Loading
Loading