diff --git a/src/config.ts b/src/config.ts index 9c23d2d84ae..75fedee9bbc 100644 --- a/src/config.ts +++ b/src/config.ts @@ -229,7 +229,7 @@ export class Config { fs.removeSync(this.path(p)); } - askWriteProjectFile(p: string, content: any, force?: boolean) { + askWriteProjectFile(p: string, content: any, force?: boolean, confirmByDefault?: boolean) { const writeTo = this.path(p); let next; if (typeof content !== "string") { @@ -243,7 +243,7 @@ export class Config { next = promptOnce({ type: "confirm", message: "File " + clc.underline(p) + " already exists. Overwrite?", - default: false, + default: !!confirmByDefault, }); } else { next = Promise.resolve(true); diff --git a/src/dataconnect/freeTrial.ts b/src/dataconnect/freeTrial.ts index 7925beeecc1..c4d3841defb 100644 --- a/src/dataconnect/freeTrial.ts +++ b/src/dataconnect/freeTrial.ts @@ -65,3 +65,11 @@ export function printFreeTrialUnavailable( `Alternatively, you may create a new (paid) CloudSQL instance at https://console.cloud.google.com/sql/instances`, ); } + +export function upgradeInstructions(projectId: string): string { + return `If you'd like to provision a CloudSQL Postgres instance on the Firebase Data Connect no-cost trial: +1. Please upgrade to the pay-as-you-go (Blaze) billing plan. Visit the following page: + https://console.firebase.google.com/project/${projectId}/usage/details +2. Run ${clc.bold("firebase init dataconnect")} again to configure the Cloud SQL instance. +3. Run ${clc.bold("firebase deploy --only dataconnect")} to deploy your Data Connect service.`; +} diff --git a/src/deploy/dataconnect/prepare.ts b/src/deploy/dataconnect/prepare.ts index 637fb29026e..42e357b2e99 100644 --- a/src/deploy/dataconnect/prepare.ts +++ b/src/deploy/dataconnect/prepare.ts @@ -12,11 +12,13 @@ import { ensureApis } from "../../dataconnect/ensureApis"; import { requireTosAcceptance } from "../../requireTosAcceptance"; import { DATA_CONNECT_TOS_ID } from "../../gcp/firedata"; import { provisionCloudSql } 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 { join } from "node:path"; +import { upgradeInstructions } from "../../dataconnect/freeTrial"; /** * Prepares for a Firebase DataConnect deployment by loading schemas and connectors from file. @@ -25,6 +27,9 @@ import { join } from "node:path"; */ export default async function (context: any, options: DeployOptions): Promise { const projectId = needProjectId(options); + if (!(await checkBillingEnabled(projectId))) { + throw new FirebaseError(upgradeInstructions(projectId)); + } await ensureApis(projectId); await requireTosAcceptance(DATA_CONNECT_TOS_ID)(options); const serviceCfgs = readFirebaseJson(options.config); diff --git a/src/init/features/dataconnect/index.ts b/src/init/features/dataconnect/index.ts index d88d3a9024c..c7369cb677c 100644 --- a/src/init/features/dataconnect/index.ts +++ b/src/init/features/dataconnect/index.ts @@ -5,7 +5,7 @@ import { confirm, promptOnce } from "../../../prompt"; import { Config } from "../../../config"; import { Setup } from "../.."; import { provisionCloudSql } from "../../../dataconnect/provisionCloudSql"; -import { checkFreeTrialInstanceUsed } from "../../../dataconnect/freeTrial"; +import { checkFreeTrialInstanceUsed, upgradeInstructions } from "../../../dataconnect/freeTrial"; import * as cloudsql from "../../../gcp/cloudsql/cloudsqladmin"; import { ensureApis, ensureSparkApis } from "../../../dataconnect/ensureApis"; import * as experiments from "../../../experiments"; @@ -19,7 +19,7 @@ import { Schema, Service, File, Platform } from "../../../dataconnect/types"; import { parseCloudSQLInstanceName, parseServiceName } from "../../../dataconnect/names"; import { logger } from "../../../logger"; import { readTemplateSync } from "../../../templates"; -import { logBullet, logSuccess } from "../../../utils"; +import { logBullet } from "../../../utils"; import { checkBillingEnabled } from "../../../gcp/cloudbilling"; import * as sdk from "./sdk"; import { getPlatformFromFolder } from "../../../dataconnect/fileUtils"; @@ -74,7 +74,11 @@ const defaultSchema = { path: "schema.gql", content: SCHEMA_TEMPLATE }; // doSetup is split into 2 phases - ask questions and then actuate files and API calls based on those answers. export async function doSetup(setup: Setup, config: Config): Promise { - const info = await askQuestions(setup); + const isBillingEnabled = setup.projectId ? await checkBillingEnabled(setup.projectId) : false; + if (setup.projectId) { + isBillingEnabled ? await ensureApis(setup.projectId) : await ensureSparkApis(setup.projectId); + } + const info = await askQuestions(setup, isBillingEnabled); await actuate(setup, config, info); const cwdPlatformGuess = await getPlatformFromFolder(process.cwd()); @@ -82,14 +86,17 @@ export async function doSetup(setup: Setup, config: Config): Promise { await sdk.doSetup(setup, config); } else { logBullet( - `If you'd like to add the generated SDK to your app your later, run ${clc.bold("firebase init dataconnect:sdk")}`, + `If you'd like to add the generated SDK to your app your, run ${clc.bold("firebase init dataconnect:sdk")}`, ); } + if (setup.projectId && !isBillingEnabled) { + logBullet(upgradeInstructions(setup.projectId)); + } } // askQuestions prompts the user about the Data Connect service they want to init. Any prompting // logic should live here, and _no_ actuation logic should live here. -async function askQuestions(setup: Setup): Promise { +async function askQuestions(setup: Setup, isBillingEnabled: boolean): Promise { let info: RequiredInfo = { serviceId: "", locationId: "", @@ -101,11 +108,8 @@ async function askQuestions(setup: Setup): Promise { schemaGql: [defaultSchema], shouldProvisionCSQL: false, }; - const isBillingEnabled = setup.projectId ? await checkBillingEnabled(setup.projectId) : false; - if (setup.projectId) { - isBillingEnabled ? await ensureApis(setup.projectId) : await ensureSparkApis(setup.projectId); - } - info = await checkExistingInstances(setup, info, isBillingEnabled); + // Query backend and pick up any existing services quickly. + info = await promptForExistingServices(setup, info, isBillingEnabled); const requiredConfigUnset = info.serviceId === "" || @@ -123,8 +127,7 @@ async function askQuestions(setup: Setup): Promise { : false; if (shouldConfigureBackend) { info = await promptForService(info); - info = await promptForCloudSQLInstance(setup, info); - info = await promptForDatabase(info); + info = await promptForCloudSQL(setup, info); info.shouldProvisionCSQL = !!( setup.projectId && @@ -136,16 +139,10 @@ async function askQuestions(setup: Setup): Promise { })) ); } else { - if (requiredConfigUnset) { - logBullet( - `Setting placeholder values in dataconnect.yaml. You can edit these before you deploy to specify different IDs or regions.`, - ); - } - info.serviceId = info.serviceId !== "" ? info.serviceId : basename(process.cwd()); - info.cloudSqlInstanceId = - info.cloudSqlInstanceId !== "" ? info.cloudSqlInstanceId : `${info.serviceId || "app"}-fdc`; - info.locationId = info.locationId !== "" ? info.locationId : `us-central1`; - info.cloudSqlDatabase = info.cloudSqlDatabase !== "" ? info.cloudSqlDatabase : `fdcdb`; + info.serviceId = info.serviceId || basename(process.cwd()); + info.cloudSqlInstanceId = info.cloudSqlInstanceId || `${info.serviceId || "app"}-fdc`; + info.locationId = info.locationId || `us-central1`; + info.cloudSqlDatabase = info.cloudSqlDatabase || `fdcdb`; } return info; } @@ -176,12 +173,16 @@ async function writeFiles(config: Config, info: RequiredInfo) { }); config.set("dataconnect", { source: dir }); - await config.askWriteProjectFile(join(dir, "dataconnect.yaml"), subbedDataconnectYaml); + await config.askWriteProjectFile( + join(dir, "dataconnect.yaml"), + subbedDataconnectYaml, + false, + // Default to override dataconnect.yaml + // Sole purpose of `firebase init dataconnect` is to update `dataconnect.yaml`. + true, + ); if (info.schemaGql.length) { - logSuccess( - "The service you chose already has GQL files deployed. We'll use those instead of the default templates.", - ); for (const f of info.schemaGql) { await config.askWriteProjectFile(join(dir, "schema", f.path), f.content); } @@ -244,7 +245,7 @@ function subConnectorYamlValues(replacementValues: { connectorId: string }): str return replaced; } -async function checkExistingInstances( +async function promptForExistingServices( setup: Setup, info: RequiredInfo, isBillingEnabled: boolean, @@ -314,13 +315,14 @@ async function checkExistingInstances( }); } } - } else { - info = await promptForService(info); } } + return info; +} +async function promptForCloudSQL(setup: Setup, info: RequiredInfo): Promise { // Check for existing Cloud SQL instances, if we didn't already set one. - if (info.cloudSqlInstanceId === "") { + if (info.cloudSqlInstanceId === "" && setup.projectId) { const instances = await cloudsql.listInstances(setup.projectId); let choices = instances.map((i) => { let display = `${i.name} (${i.region})`; @@ -343,14 +345,31 @@ async function checkExistingInstances( if (info.cloudSqlInstanceId !== "") { // Infer location if a CloudSQL instance is chosen. info.locationId = choices.find((c) => c.value === info.cloudSqlInstanceId)!.location; - } else { - info = await promptForCloudSQLInstance(setup, info); } } } - // Check for existing Cloud SQL databases, if we didn't already set one. - if (info.cloudSqlDatabase === "" && info.cloudSqlInstanceId !== "") { + // No existing instance found or choose to create new instance. + if (info.cloudSqlInstanceId === "") { + info.isNewInstance = true; + info.cloudSqlInstanceId = await promptOnce({ + message: `What ID would you like to use for your new CloudSQL instance?`, + type: "input", + default: `${info.serviceId || "app"}-fdc`, + }); + } + if (info.locationId === "") { + const choices = await locationChoices(setup); + info.locationId = await promptOnce({ + message: "What location would like to use?", + type: "list", + choices, + }); + } + + // Look for existing databases within the picked instance. + // Best effort since the picked `info.cloudSqlInstanceId` may not exists or is still being provisioned. + if (info.cloudSqlDatabase === "" && setup.projectId) { try { const dbs = await cloudsql.listDatabases(setup.projectId, info.cloudSqlInstanceId); const choices = dbs.map((d) => { @@ -363,9 +382,6 @@ async function checkExistingInstances( type: "list", choices, }); - if (info.cloudSqlDatabase === "") { - info = await promptForDatabase(info); - } } } catch (err) { // Show existing databases in a list is optional, ignore any errors from ListDatabases. @@ -373,6 +389,17 @@ async function checkExistingInstances( logger.debug(`[dataconnect] Cannot list databases during init: ${err}`); } } + + // No existing database found or cannot access the instance. + // Prompt for a name. + if (info.cloudSqlDatabase === "") { + info.isNewDatabase = true; + info.cloudSqlDatabase = await promptOnce({ + message: `What ID would you like to use for your new database in ${info.cloudSqlInstanceId}?`, + type: "input", + default: `fdcdb`, + }); + } return info; } @@ -387,26 +414,6 @@ async function promptForService(info: RequiredInfo): Promise { return info; } -async function promptForCloudSQLInstance(setup: Setup, info: RequiredInfo): Promise { - if (info.cloudSqlInstanceId === "") { - info.isNewInstance = true; - info.cloudSqlInstanceId = await promptOnce({ - message: `What ID would you like to use for your new CloudSQL instance?`, - type: "input", - default: `${info.serviceId || "app"}-fdc`, - }); - } - if (info.locationId === "") { - const choices = await locationChoices(setup); - info.locationId = await promptOnce({ - message: "What location would like to use?", - type: "list", - choices, - }); - } - return info; -} - async function locationChoices(setup: Setup) { if (setup.projectId) { const locations = await listLocations(setup.projectId); @@ -427,15 +434,3 @@ async function locationChoices(setup: Setup) { ]; } } - -async function promptForDatabase(info: RequiredInfo): Promise { - if (info.cloudSqlDatabase === "") { - info.isNewDatabase = true; - info.cloudSqlDatabase = await promptOnce({ - message: `What ID would you like to use for your new database in ${info.cloudSqlInstanceId}?`, - type: "input", - default: `fdcdb`, - }); - } - return info; -}