UNPKG

firebase-tools

Version:
453 lines (452 loc) 23.3 kB
"use strict"; Object.defineProperty(exports, "__esModule", { value: true }); exports.ensureServiceIsConnectedToCloudSql = exports.getIdentifiers = exports.grantRoleToUserInSchema = exports.migrateSchema = exports.diffSchema = void 0; const clc = require("colorette"); const sql_formatter_1 = require("sql-formatter"); const types_1 = require("./types"); const client_1 = require("./client"); const connect_1 = require("../gcp/cloudsql/connect"); const projectUtils_1 = require("../projectUtils"); const permissionsSetup_1 = require("../gcp/cloudsql/permissionsSetup"); const permissions_1 = require("../gcp/cloudsql/permissions"); const prompt_1 = require("../prompt"); const logger_1 = require("../logger"); const error_1 = require("../error"); const utils_1 = require("../utils"); const cloudsqladmin_1 = require("../gcp/cloudsql/cloudsqladmin"); const cloudSqlAdminClient = require("../gcp/cloudsql/cloudsqladmin"); const errors = require("./errors"); async function setupSchemaIfNecessary(instanceId, databaseId, options) { await (0, connect_1.setupIAMUsers)(instanceId, databaseId, options); const schemaInfo = await (0, permissionsSetup_1.getSchemaMetadata)(instanceId, databaseId, permissions_1.DEFAULT_SCHEMA, options); if (schemaInfo.setupStatus !== permissionsSetup_1.SchemaSetupStatus.BrownField && schemaInfo.setupStatus !== permissionsSetup_1.SchemaSetupStatus.GreenField) { return await (0, permissionsSetup_1.setupSQLPermissions)(instanceId, databaseId, schemaInfo, options, true); } else { logger_1.logger.debug(`Detected schema "${schemaInfo.name}" is setup in ${schemaInfo.setupStatus} mode. Skipping Setup.`); } return schemaInfo.setupStatus; } async function diffSchema(options, schema, schemaValidation) { (0, utils_1.logLabeledBullet)("dataconnect", `generating required schema changes...`); const { serviceName, instanceName, databaseId, instanceId } = getIdentifiers(schema); await ensureServiceIsConnectedToCloudSql(serviceName, instanceName, databaseId, false); let diffs = []; await setupSchemaIfNecessary(instanceId, databaseId, options); let validationMode = schemaValidation !== null && schemaValidation !== void 0 ? schemaValidation : "COMPATIBLE"; setSchemaValidationMode(schema, validationMode); try { await (0, client_1.upsertSchema)(schema, true); if (validationMode === "STRICT") { (0, utils_1.logLabeledSuccess)("dataconnect", `database schema of ${instanceId}:${databaseId} is up to date.`); } else { (0, utils_1.logLabeledSuccess)("dataconnect", `database schema of ${instanceId}:${databaseId} is compatible.`); } } catch (err) { if ((err === null || err === void 0 ? void 0 : err.status) !== 400) { throw err; } const invalidConnectors = errors.getInvalidConnectors(err); const incompatible = errors.getIncompatibleSchemaError(err); if (!incompatible && !invalidConnectors.length) { throw err; } if (invalidConnectors.length) { displayInvalidConnectors(invalidConnectors); } if (incompatible) { displaySchemaChanges(incompatible, validationMode, instanceName, databaseId); diffs = incompatible.diffs; } } if (!schemaValidation) { validationMode = "STRICT"; setSchemaValidationMode(schema, validationMode); try { (0, utils_1.logLabeledBullet)("dataconnect", `generating schema changes, including optional changes...`); await (0, client_1.upsertSchema)(schema, true); (0, utils_1.logLabeledSuccess)("dataconnect", `no additional optional changes`); } catch (err) { if ((err === null || err === void 0 ? void 0 : err.status) !== 400) { throw err; } const incompatible = errors.getIncompatibleSchemaError(err); if (incompatible) { if (!diffsEqual(diffs, incompatible.diffs)) { if (diffs.length === 0) { displaySchemaChanges(incompatible, "STRICT_AFTER_COMPATIBLE", instanceName, databaseId); } else { displaySchemaChanges(incompatible, validationMode, instanceName, databaseId); } diffs = incompatible.diffs; } else { (0, utils_1.logLabeledSuccess)("dataconnect", `no additional optional changes`); } } } } return diffs; } exports.diffSchema = diffSchema; async function migrateSchema(args) { (0, utils_1.logLabeledBullet)("dataconnect", `generating required schema changes...`); const { options, schema, validateOnly, schemaValidation } = args; const { serviceName, instanceId, instanceName, databaseId } = getIdentifiers(schema); await ensureServiceIsConnectedToCloudSql(serviceName, instanceName, databaseId, true); await (0, connect_1.setupIAMUsers)(instanceId, databaseId, options); let diffs = []; await setupSchemaIfNecessary(instanceId, databaseId, options); let validationMode = schemaValidation !== null && schemaValidation !== void 0 ? schemaValidation : "COMPATIBLE"; setSchemaValidationMode(schema, validationMode); try { await (0, client_1.upsertSchema)(schema, validateOnly); (0, utils_1.logLabeledBullet)("dataconnect", `database schema of ${instanceId}:${databaseId} is up to date.`); } catch (err) { if ((err === null || err === void 0 ? void 0 : err.status) !== 400) { throw err; } const incompatible = errors.getIncompatibleSchemaError(err); const invalidConnectors = errors.getInvalidConnectors(err); if (!incompatible && !invalidConnectors.length) { throw err; } const migrationMode = await promptForSchemaMigration(options, instanceName, databaseId, incompatible, validateOnly, validationMode); const shouldDeleteInvalidConnectors = await promptForInvalidConnectorError(options, serviceName, invalidConnectors, validateOnly); if (incompatible) { diffs = await handleIncompatibleSchemaError({ options, databaseId, instanceId, incompatibleSchemaError: incompatible, choice: migrationMode, }); } if (shouldDeleteInvalidConnectors) { await deleteInvalidConnectors(invalidConnectors); } if (!validateOnly) { await (0, client_1.upsertSchema)(schema, validateOnly); } } if (!schemaValidation) { validationMode = "STRICT"; setSchemaValidationMode(schema, validationMode); try { await (0, client_1.upsertSchema)(schema, validateOnly); } catch (err) { if (err.status !== 400) { throw err; } const incompatible = errors.getIncompatibleSchemaError(err); const invalidConnectors = errors.getInvalidConnectors(err); if (!incompatible && !invalidConnectors.length) { throw err; } const migrationMode = await promptForSchemaMigration(options, instanceName, databaseId, incompatible, validateOnly, "STRICT_AFTER_COMPATIBLE"); if (incompatible) { const maybeDiffs = await handleIncompatibleSchemaError({ options, databaseId, instanceId, incompatibleSchemaError: incompatible, choice: migrationMode, }); diffs = diffs.concat(maybeDiffs); } } } return diffs; } exports.migrateSchema = migrateSchema; async function grantRoleToUserInSchema(options, schema) { const role = options.role; const email = options.email; const { instanceId, databaseId } = getIdentifiers(schema); const projectId = (0, projectUtils_1.needProjectId)(options); const { user, mode } = (0, connect_1.toDatabaseUser)(email); const fdcSqlRole = permissionsSetup_1.fdcSqlRoleMap[role](databaseId); await (0, connect_1.setupIAMUsers)(instanceId, databaseId, options); const userIsCSQLAdmin = await (0, cloudsqladmin_1.iamUserIsCSQLAdmin)(options); if (!userIsCSQLAdmin) { throw new error_1.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 ${fdcSqlRole} to ${user}`); } const schemaSetupStatus = await setupSchemaIfNecessary(instanceId, databaseId, options); if (schemaSetupStatus !== permissionsSetup_1.SchemaSetupStatus.GreenField && fdcSqlRole === (0, permissions_1.firebaseowner)(databaseId, permissions_1.DEFAULT_SCHEMA)) { throw new error_1.FirebaseError(`Owner rule isn't available in brownfield databases. If you would like Data Connect to manage and own your database schema, run 'firebase dataconnect:sql:setup'`); } await cloudSqlAdminClient.createUser(projectId, instanceId, mode, user); await (0, connect_1.executeSqlCmdsAsSuperUser)(options, instanceId, databaseId, [`GRANT "${fdcSqlRole}" TO "${user}"`], false); } exports.grantRoleToUserInSchema = grantRoleToUserInSchema; function diffsEqual(x, y) { if (x.length !== y.length) { return false; } for (let i = 0; i < x.length; i++) { if (x[i].description !== y[i].description || x[i].destructive !== y[i].destructive || x[i].sql !== y[i].sql) { return false; } } return true; } function setSchemaValidationMode(schema, schemaValidation) { const postgresDatasource = schema.datasources.find((d) => d.postgresql); if (postgresDatasource === null || postgresDatasource === void 0 ? void 0 : postgresDatasource.postgresql) { postgresDatasource.postgresql.schemaValidation = schemaValidation; } } function getIdentifiers(schema) { var _a, _b; const postgresDatasource = schema.datasources.find((d) => d.postgresql); const databaseId = (_a = postgresDatasource === null || postgresDatasource === void 0 ? void 0 : postgresDatasource.postgresql) === null || _a === void 0 ? void 0 : _a.database; if (!databaseId) { throw new error_1.FirebaseError("Service does not have a postgres datasource, cannot migrate"); } const instanceName = (_b = postgresDatasource === null || postgresDatasource === void 0 ? void 0 : postgresDatasource.postgresql) === null || _b === void 0 ? void 0 : _b.cloudSql.instance; if (!instanceName) { throw new error_1.FirebaseError("tried to migrate schema but instance name was not provided in dataconnect.yaml"); } const instanceId = instanceName.split("/").pop(); const serviceName = schema.name.replace(`/schemas/${types_1.SCHEMA_ID}`, ""); return { databaseId, instanceId, instanceName, serviceName, }; } exports.getIdentifiers = getIdentifiers; function suggestedCommand(serviceName, invalidConnectorNames) { const serviceId = serviceName.split("/")[5]; const connectorIds = invalidConnectorNames.map((i) => i.split("/")[7]); const onlys = connectorIds.map((c) => `dataconnect:${serviceId}:${c}`).join(","); return `firebase deploy --only ${onlys}`; } async function handleIncompatibleSchemaError(args) { const { incompatibleSchemaError, options, instanceId, databaseId, choice } = args; if (incompatibleSchemaError.destructive && choice === "safe") { throw new error_1.FirebaseError("This schema migration includes potentially destructive changes. If you'd like to execute it anyway, rerun this command with --force"); } const commandsToExecute = incompatibleSchemaError.diffs .filter((d) => { switch (choice) { case "all": return true; case "safe": return !d.destructive; case "none": return false; } }) .map((d) => d.sql); if (commandsToExecute.length) { const commandsToExecuteBySuperUser = commandsToExecute.filter((sql) => sql.startsWith("CREATE EXTENSION") || sql.startsWith("CREATE SCHEMA")); const commandsToExecuteByOwner = commandsToExecute.filter((sql) => !commandsToExecuteBySuperUser.includes(sql)); const userIsCSQLAdmin = await (0, cloudsqladmin_1.iamUserIsCSQLAdmin)(options); if (!userIsCSQLAdmin && commandsToExecuteBySuperUser.length) { throw new error_1.FirebaseError(`Some SQL commands required for this migration require Admin permissions.\n Please ask a user with 'roles/cloudsql.admin' to apply the following commands.\n ${commandsToExecuteBySuperUser.join("\n")}`); } const schemaInfo = await (0, permissionsSetup_1.getSchemaMetadata)(instanceId, databaseId, permissions_1.DEFAULT_SCHEMA, options); if (schemaInfo.setupStatus !== permissionsSetup_1.SchemaSetupStatus.GreenField) { throw new error_1.FirebaseError(`Brownfield database are protected from SQL changes by Data Connect.\n` + `You can use the SQL diff generated by 'firebase dataconnect:sql:diff' to assist you in applying the required changes to your CloudSQL database. Connector deployment will succeed when there is no required diff changes.\n` + `If you would like Data Connect to manage your database schema, run 'firebase dataconnect:sql:setup'`); } if (!(await (0, permissionsSetup_1.checkSQLRoleIsGranted)(options, instanceId, databaseId, (0, permissions_1.firebaseowner)(databaseId), (await (0, connect_1.getIAMUser)(options)).user))) { throw new error_1.FirebaseError(`Command aborted. Only users granted firebaseowner SQL role can run migrations.`); } if (commandsToExecuteBySuperUser.length) { logger_1.logger.info(`The diffs require CloudSQL superuser permissions, attempting to apply changes as superuser.`); await (0, connect_1.executeSqlCmdsAsSuperUser)(options, instanceId, databaseId, commandsToExecuteBySuperUser, false); } if (commandsToExecuteByOwner.length) { await (0, connect_1.executeSqlCmdsAsIamUser)(options, instanceId, databaseId, [`SET ROLE "${(0, permissions_1.firebaseowner)(databaseId)}"`, ...commandsToExecuteByOwner], false); return incompatibleSchemaError.diffs; } } return []; } async function promptForSchemaMigration(options, instanceName, databaseId, err, validateOnly, validationMode) { if (!err) { return "none"; } if (validationMode === "STRICT_AFTER_COMPATIBLE" && (options.nonInteractive || options.force)) { return "none"; } displaySchemaChanges(err, validationMode, instanceName, databaseId); if (!options.nonInteractive) { if (validateOnly && options.force) { return "all"; } const message = validationMode === "STRICT_AFTER_COMPATIBLE" ? `Would you like to execute these optional changes against ${databaseId} in your CloudSQL instance ${instanceName}?` : `Would you like to execute these changes against ${databaseId} in your CloudSQL instance ${instanceName}?`; let executeChangePrompt = "Execute changes"; if (validationMode === "STRICT_AFTER_COMPATIBLE") { executeChangePrompt = "Execute optional changes"; } if (err.destructive) { executeChangePrompt = executeChangePrompt + " (including destructive changes)"; } const choices = [ { name: executeChangePrompt, value: "all" }, { name: "Abort changes", value: "none" }, ]; const defaultValue = validationMode === "STRICT_AFTER_COMPATIBLE" ? "none" : "all"; return await (0, prompt_1.select)({ message, choices, default: defaultValue }); } if (!validateOnly) { throw new error_1.FirebaseError("Command aborted. Your database schema is incompatible with your Data Connect schema. Run `firebase dataconnect:sql:migrate` to migrate your database schema"); } else if (options.force) { return "all"; } else if (!err.destructive) { return "all"; } else { throw new error_1.FirebaseError("Command aborted. This schema migration includes potentially destructive changes. If you'd like to execute it anyway, rerun this command with --force"); } } async function promptForInvalidConnectorError(options, serviceName, invalidConnectors, validateOnly) { if (!invalidConnectors.length) { return false; } displayInvalidConnectors(invalidConnectors); if (validateOnly) { return false; } if (options.force) { return true; } if (!options.nonInteractive && (await (0, prompt_1.confirm)(Object.assign(Object.assign({}, options), { message: `Would you like to delete and recreate these connectors? This will cause ${clc.red(`downtime`)}.` })))) { return true; } const cmd = suggestedCommand(serviceName, invalidConnectors); throw new error_1.FirebaseError(`Command aborted. Try deploying those connectors first with ${clc.bold(cmd)}`); } async function deleteInvalidConnectors(invalidConnectors) { return Promise.all(invalidConnectors.map(client_1.deleteConnector)); } function displayInvalidConnectors(invalidConnectors) { const connectorIds = invalidConnectors.map((i) => i.split("/").pop()).join(", "); (0, utils_1.logLabeledWarning)("dataconnect", `The schema you are deploying is incompatible with the following existing connectors: ${connectorIds}.`); (0, utils_1.logLabeledWarning)("dataconnect", `This is a ${clc.red("breaking")} change and may break existing apps.`); } async function ensureServiceIsConnectedToCloudSql(serviceName, instanceId, databaseId, linkIfNotConnected) { let currentSchema = await (0, client_1.getSchema)(serviceName); if (!currentSchema) { if (!linkIfNotConnected) { (0, utils_1.logLabeledWarning)("dataconnect", `Not yet linked to the Cloud SQL instance.`); return; } (0, utils_1.logLabeledBullet)("dataconnect", `linking the Cloud SQL instance...`); currentSchema = { name: `${serviceName}/schemas/${types_1.SCHEMA_ID}`, source: { files: [], }, datasources: [ { postgresql: { database: databaseId, schemaValidation: "NONE", cloudSql: { instance: instanceId, }, }, }, ], }; } const postgresDatasource = currentSchema.datasources.find((d) => d.postgresql); const postgresql = postgresDatasource === null || postgresDatasource === void 0 ? void 0 : postgresDatasource.postgresql; if ((postgresql === null || postgresql === void 0 ? void 0 : postgresql.cloudSql.instance) !== instanceId) { (0, utils_1.logLabeledWarning)("dataconnect", `Switching connected Cloud SQL instance\nFrom ${postgresql === null || postgresql === void 0 ? void 0 : postgresql.cloudSql.instance}\nTo ${instanceId}`); } if ((postgresql === null || postgresql === void 0 ? void 0 : postgresql.database) !== databaseId) { (0, utils_1.logLabeledWarning)("dataconnect", `Switching connected Postgres database from ${postgresql === null || postgresql === void 0 ? void 0 : postgresql.database} to ${databaseId}`); } if (!postgresql || postgresql.schemaValidation !== "NONE") { return; } postgresql.schemaValidation = "STRICT"; try { await (0, client_1.upsertSchema)(currentSchema, false); } catch (err) { if ((err === null || err === void 0 ? void 0 : err.status) >= 500) { throw err; } logger_1.logger.debug(err); } } exports.ensureServiceIsConnectedToCloudSql = ensureServiceIsConnectedToCloudSql; function displaySchemaChanges(error, validationMode, instanceName, databaseId) { switch (error.violationType) { case "INCOMPATIBLE_SCHEMA": { let message; if (validationMode === "COMPATIBLE") { message = "Your PostgreSQL database " + databaseId + " in your CloudSQL instance " + instanceName + " must be migrated in order to be compatible with your application schema. " + "The following SQL statements will migrate your database schema to be compatible with your new Data Connect schema.\n" + error.diffs.map(toString).join("\n"); } else if (validationMode === "STRICT_AFTER_COMPATIBLE") { message = "Your new application schema is compatible with the schema of your PostgreSQL database " + databaseId + " in your CloudSQL instance " + instanceName + ", but contains unused tables or columns. " + "The following optional SQL statements will migrate your database schema to match your new Data Connect schema.\n" + error.diffs.map(toString).join("\n"); } else { message = "Your PostgreSQL database " + databaseId + " in your CloudSQL instance " + instanceName + " must be migrated in order to match your application schema. " + "The following SQL statements will migrate your database schema to match your new Data Connect schema.\n" + error.diffs.map(toString).join("\n"); } (0, utils_1.logLabeledWarning)("dataconnect", message); } break; case "INACCESSIBLE_SCHEMA": { const message = "Cannot access your CloudSQL database to validate schema. " + "The following SQL statements can setup a new database schema.\n" + error.diffs.map(toString).join("\n"); (0, utils_1.logLabeledWarning)("dataconnect", message); (0, utils_1.logLabeledWarning)("dataconnect", "Some SQL resources may already exist."); } break; default: throw new error_1.FirebaseError(`Unknown schema violation type: ${error.violationType}, IncompatibleSqlSchemaError: ${error}`); } } function toString(diff) { return `\/** ${diff.destructive ? clc.red("Destructive: ") : ""}${diff.description}*\/\n${(0, sql_formatter_1.format)(diff.sql, { language: "postgresql" })}`; }