firebase-tools
Version:
Command-Line Interface for Firebase
518 lines (514 loc) • 25.2 kB
JavaScript
;
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");
const provisionCloudSql_1 = require("./provisionCloudSql");
const requireAuth_1 = require("../requireAuth");
async function setupSchemaIfNecessary(instanceId, databaseId, options) {
try {
await (0, connect_1.setupIAMUsers)(instanceId, options);
const schemaInfo = await (0, permissionsSetup_1.getSchemaMetadata)(instanceId, databaseId, permissions_1.DEFAULT_SCHEMA, options);
switch (schemaInfo.setupStatus) {
case permissionsSetup_1.SchemaSetupStatus.BrownField:
case permissionsSetup_1.SchemaSetupStatus.GreenField:
logger_1.logger.debug(`Cloud SQL Database ${instanceId}:${databaseId} is already set up in ${schemaInfo.setupStatus}`);
return schemaInfo.setupStatus;
case permissionsSetup_1.SchemaSetupStatus.NotSetup:
case permissionsSetup_1.SchemaSetupStatus.NotFound:
(0, utils_1.logLabeledBullet)("dataconnect", "Setting up Cloud SQL Database SQL permissions...");
return await (0, permissionsSetup_1.setupSQLPermissions)(instanceId, databaseId, schemaInfo, options, true);
default:
throw new error_1.FirebaseError(`Unexpected schema setup status: ${schemaInfo.setupStatus}`);
}
}
catch (err) {
throw new error_1.FirebaseError(`Cannot setup Postgres SQL permissions of Cloud SQL database ${instanceId}:${databaseId}\n${err}`);
}
}
async function diffSchema(options, schema, schemaValidation) {
let validationMode = schemaValidation !== null && schemaValidation !== void 0 ? schemaValidation : "STRICT";
setSchemaValidationMode(schema, validationMode);
displayStartSchemaDiff(validationMode);
const { serviceName, instanceName, databaseId, instanceId } = getIdentifiers(schema);
await ensureServiceIsConnectedToCloudSql(serviceName, instanceName, databaseId, false);
let incompatible = undefined;
try {
await (0, client_1.upsertSchema)(schema, true);
displayNoSchemaDiff(instanceId, databaseId, validationMode);
}
catch (err) {
if ((err === null || err === void 0 ? void 0 : err.status) !== 400) {
throw err;
}
incompatible = errors.getIncompatibleSchemaError(err);
const invalidConnectors = errors.getInvalidConnectors(err);
if (!incompatible && !invalidConnectors.length) {
const gqlErrs = errors.getGQLErrors(err);
if (gqlErrs) {
throw new error_1.FirebaseError(`There are errors in your schema files:\n${gqlErrs}`);
}
throw err;
}
if (invalidConnectors.length) {
displayInvalidConnectors(invalidConnectors);
}
}
if (!incompatible) {
return [];
}
if (schemaValidation) {
displaySchemaChanges(incompatible, validationMode);
return incompatible.diffs;
}
const strictIncompatible = incompatible;
let compatibleIncompatible = undefined;
validationMode = "COMPATIBLE";
setSchemaValidationMode(schema, validationMode);
try {
displayStartSchemaDiff(validationMode);
await (0, client_1.upsertSchema)(schema, true);
displayNoSchemaDiff(instanceId, databaseId, validationMode);
}
catch (err) {
if ((err === null || err === void 0 ? void 0 : err.status) !== 400) {
throw err;
}
compatibleIncompatible = errors.getIncompatibleSchemaError(err);
}
if (!compatibleIncompatible) {
displaySchemaChanges(strictIncompatible, "STRICT");
}
else if (diffsEqual(strictIncompatible.diffs, compatibleIncompatible.diffs)) {
displaySchemaChanges(strictIncompatible, "STRICT");
}
else {
displaySchemaChanges(compatibleIncompatible, "COMPATIBLE");
displaySchemaChanges(strictIncompatible, "STRICT_AFTER_COMPATIBLE");
}
return incompatible.diffs;
}
exports.diffSchema = diffSchema;
async function migrateSchema(args) {
var _a;
const { options, schema, validateOnly, schemaValidation, stats } = args;
let validationMode = schemaValidation !== null && schemaValidation !== void 0 ? schemaValidation : "COMPATIBLE";
setSchemaValidationMode(schema, validationMode);
displayStartSchemaDiff(validationMode);
const projectId = (0, projectUtils_1.needProjectId)(options);
const { serviceName, instanceId, instanceName, databaseId } = getIdentifiers(schema);
await ensureServiceIsConnectedToCloudSql(serviceName, instanceName, databaseId, true);
const existingInstance = await cloudSqlAdminClient.getInstance(projectId, instanceId);
if (existingInstance.state === "PENDING_CREATE") {
if (stats) {
stats.numSchemaSkippedDueToPendingCreate++;
}
const postgresql = (_a = schema.datasources.find((d) => d.postgresql)) === null || _a === void 0 ? void 0 : _a.postgresql;
if (!postgresql) {
throw new error_1.FirebaseError(`Cannot find Postgres datasource in the schema to deploy: ${serviceName}/schemas/${types_1.MAIN_SCHEMA_ID}.\nIts datasources: ${JSON.stringify(schema.datasources)}`);
}
postgresql.schemaValidation = "NONE";
postgresql.schemaMigration = undefined;
await (0, client_1.upsertSchema)(schema, validateOnly);
postgresql.schemaValidation = undefined;
postgresql.schemaMigration = "MIGRATE_COMPATIBLE";
await (0, client_1.upsertSchema)(schema, validateOnly, true);
(0, utils_1.logLabeledWarning)("dataconnect", `Skip SQL schema migration because Cloud SQL is still being created`);
return [];
}
await setupSchemaIfNecessary(instanceId, databaseId, options);
let diffs = [];
try {
await (0, client_1.upsertSchema)(schema, validateOnly);
displayNoSchemaDiff(instanceId, databaseId, validationMode);
}
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) {
const gqlErrs = errors.getGQLErrors(err);
if (gqlErrs) {
throw new error_1.FirebaseError(`There are errors in your schema files:\n${gqlErrs}`);
}
throw err;
}
if (stats) {
if (incompatible) {
stats.numSchemaSqlDiffs += incompatible.diffs.length;
}
if (invalidConnectors.length) {
stats.numSchemaInvalidConnectors += invalidConnectors.length;
}
}
const migrationMode = await promptForSchemaMigration(options, instanceId, 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);
if (!incompatible) {
throw err;
}
if (stats && incompatible) {
stats.numSchemaSqlDiffs += incompatible.diffs.length;
}
const migrationMode = await promptForSchemaMigration(options, instanceId, 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 { serviceName, instanceId, instanceName, databaseId } = getIdentifiers(schema);
await ensureServiceIsConnectedToCloudSql(serviceName, instanceName, databaseId, false);
const schemaSetupStatus = await setupSchemaIfNecessary(instanceId, databaseId, options);
if (schemaSetupStatus !== permissionsSetup_1.SchemaSetupStatus.GreenField && role === "owner") {
throw new error_1.FirebaseError(`Owner rule isn't available in ${schemaSetupStatus} databases. If you would like Data Connect to manage and own your database schema, run 'firebase dataconnect:sql:setup'`);
}
await (0, permissionsSetup_1.grantRoleTo)(options, instanceId, databaseId, role, email);
}
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, _c;
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("Data Connect schema must have a postgres datasource with a database name.");
}
const instanceName = (_c = (_b = postgresDatasource === null || postgresDatasource === void 0 ? void 0 : postgresDatasource.postgresql) === null || _b === void 0 ? void 0 : _b.cloudSql) === null || _c === void 0 ? void 0 : _c.instance;
if (!instanceName) {
throw new error_1.FirebaseError("Data Connect schema must have a postgres datasource with a CloudSQL instance.");
}
const instanceId = instanceName.split("/").pop();
const serviceName = schema.name.replace(`/schemas/${types_1.MAIN_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;
const commandsToExecute = incompatibleSchemaError.diffs.filter((d) => {
switch (choice) {
case "all":
return true;
case "safe":
return !d.destructive;
case "none":
return false;
}
});
if (commandsToExecute.length) {
const commandsToExecuteBySuperUser = commandsToExecute.filter(requireSuperUser);
const commandsToExecuteByOwner = commandsToExecute.filter((sql) => !requireSuperUser(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
${diffsToString(commandsToExecuteBySuperUser)}`);
}
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))) {
if (!userIsCSQLAdmin) {
throw new error_1.FirebaseError(`Command aborted. Only users granted firebaseowner SQL role can run migrations.`);
}
const account = (await (0, requireAuth_1.requireAuth)(options));
(0, utils_1.logLabeledBullet)("dataconnect", `Granting firebaseowner role to myself ${account}...`);
await (0, permissionsSetup_1.grantRoleTo)(options, instanceId, databaseId, "owner", account);
}
if (commandsToExecuteBySuperUser.length) {
(0, utils_1.logLabeledBullet)("dataconnect", `Executing admin SQL commands as superuser...`);
await (0, connect_1.executeSqlCmdsAsSuperUser)(options, instanceId, databaseId, commandsToExecuteBySuperUser.map((d) => d.sql), false);
}
if (commandsToExecuteByOwner.length) {
await (0, connect_1.executeSqlCmdsAsIamUser)(options, instanceId, databaseId, [`SET ROLE "${(0, permissions_1.firebaseowner)(databaseId)}"`, ...commandsToExecuteByOwner.map((d) => d.sql)], false);
return incompatibleSchemaError.diffs;
}
}
return [];
}
async function promptForSchemaMigration(options, instanceId, databaseId, err, validateOnly, validationMode) {
if (!err) {
return "none";
}
const defaultChoice = validationMode === "STRICT_AFTER_COMPATIBLE" ? "none" : "all";
displaySchemaChanges(err, validationMode);
if (!options.nonInteractive) {
if (validateOnly && options.force) {
return defaultChoice;
}
let choices = [
{ name: "Execute all", value: "all" },
];
if (err.destructive) {
choices = [{ name: `Execute all ${clc.red("(including destructive)")}`, value: "all" }];
if (err.diffs.some((d) => !d.destructive)) {
choices.push({ name: "Execute safe only", value: "safe" });
}
}
if (validationMode === "STRICT_AFTER_COMPATIBLE") {
choices.push({ name: "Skip them", value: "none" });
}
else {
choices.push({ name: "Abort", value: "abort" });
}
const ans = await (0, prompt_1.select)({
message: `Do you want to execute these SQL against ${instanceId}:${databaseId}?`,
choices: choices,
default: defaultChoice,
});
if (ans === "abort") {
throw new error_1.FirebaseError("Command aborted.");
}
return ans;
}
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 defaultChoice;
}
else if (!err.destructive) {
return defaultChoice;
}
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: ${clc.bold(connectorIds)}.`);
(0, utils_1.logLabeledWarning)("dataconnect", `This is a ${clc.red("breaking")} change and may break existing apps.`);
}
async function ensureServiceIsConnectedToCloudSql(serviceName, instanceName, databaseId, linkIfNotConnected) {
var _a, _b, _c, _d;
let currentSchema = await (0, client_1.getSchema)(serviceName);
let postgresql = (_b = (_a = currentSchema === null || currentSchema === void 0 ? void 0 : currentSchema.datasources) === null || _a === void 0 ? void 0 : _a.find((d) => d.postgresql)) === null || _b === void 0 ? void 0 : _b.postgresql;
if ((currentSchema === null || currentSchema === void 0 ? void 0 : currentSchema.reconciling) &&
(postgresql === null || postgresql === void 0 ? void 0 : postgresql.ephemeral) &&
((_c = postgresql === null || postgresql === void 0 ? void 0 : postgresql.cloudSql) === null || _c === void 0 ? void 0 : _c.instance) &&
(postgresql === null || postgresql === void 0 ? void 0 : postgresql.schemaValidation) === "NONE") {
const [, , , , , serviceId] = serviceName.split("/");
const [, projectId, , , , instanceId] = postgresql.cloudSql.instance.split("/");
throw new error_1.FirebaseError(`While checking the service ${serviceId}, ` + (0, provisionCloudSql_1.cloudSQLBeingCreated)(projectId, instanceId));
}
if (!currentSchema || !postgresql) {
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.MAIN_SCHEMA_ID}`,
source: {
files: [],
},
datasources: [
{
postgresql: { ephemeral: true },
},
],
};
}
if (!postgresql) {
postgresql = currentSchema.datasources[0].postgresql;
}
let alreadyConnected = !postgresql.ephemeral || false;
if (((_d = postgresql.cloudSql) === null || _d === void 0 ? void 0 : _d.instance) && postgresql.cloudSql.instance !== instanceName) {
alreadyConnected = false;
(0, utils_1.logLabeledWarning)("dataconnect", `Switching connected Cloud SQL instance\n From ${postgresql.cloudSql.instance}\n To ${instanceName}`);
}
if (postgresql.database && postgresql.database !== databaseId) {
alreadyConnected = false;
(0, utils_1.logLabeledWarning)("dataconnect", `Switching connected Postgres database from ${postgresql.database} to ${databaseId}`);
}
if (alreadyConnected) {
return;
}
try {
postgresql.schemaValidation = "STRICT";
postgresql.database = databaseId;
postgresql.cloudSql = { instance: instanceName };
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(`Failed to ensure service is connected to Cloud SQL: ${err.message}`);
}
}
exports.ensureServiceIsConnectedToCloudSql = ensureServiceIsConnectedToCloudSql;
function displayStartSchemaDiff(validationMode) {
switch (validationMode) {
case "COMPATIBLE":
(0, utils_1.logLabeledBullet)("dataconnect", `Generating SQL schema migrations to be compatible...`);
break;
case "STRICT":
(0, utils_1.logLabeledBullet)("dataconnect", `Generating SQL schema migrations to match exactly...`);
break;
}
}
function displayNoSchemaDiff(instanceId, databaseId, validationMode) {
switch (validationMode) {
case "COMPATIBLE":
(0, utils_1.logLabeledSuccess)("dataconnect", `Database schema of ${instanceId}:${databaseId} is compatible with Data Connect Schema.`);
break;
case "STRICT":
(0, utils_1.logLabeledSuccess)("dataconnect", `Database schema of ${instanceId}:${databaseId} matches Data Connect Schema exactly.`);
break;
}
}
function displaySchemaChanges(error, validationMode) {
switch (error.violationType) {
case "INCOMPATIBLE_SCHEMA":
{
switch (validationMode) {
case "COMPATIBLE":
(0, utils_1.logLabeledWarning)("dataconnect", `PostgreSQL schema is incompatible with the Data Connect Schema.
Those SQL statements will migrate it to be compatible:
${diffsToString(error.diffs)}
`);
break;
case "STRICT_AFTER_COMPATIBLE":
(0, utils_1.logLabeledBullet)("dataconnect", `PostgreSQL schema contains unused SQL objects not part of the Data Connect Schema.
Those SQL statements will migrate it to match exactly:
${diffsToString(error.diffs)}
`);
break;
case "STRICT":
(0, utils_1.logLabeledWarning)("dataconnect", `PostgreSQL schema does not match the Data Connect Schema.
Those SQL statements will migrate it to match exactly:
${diffsToString(error.diffs)}
`);
break;
}
}
break;
case "INACCESSIBLE_SCHEMA":
{
(0, utils_1.logLabeledWarning)("dataconnect", `Cannot access CloudSQL database to validate schema.
Here is the complete expected SQL schema:
${diffsToString(error.diffs)}
`);
(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 requireSuperUser(diff) {
return diff.sql.startsWith("CREATE EXTENSION") || diff.sql.startsWith("CREATE SCHEMA");
}
function diffsToString(diffs) {
return diffs.map(diffToString).join("\n\n");
}
function diffToString(diff) {
return `\/** ${diff.destructive ? clc.red("Destructive: ") : ""}${diff.description}*\/\n${(0, sql_formatter_1.format)(diff.sql, { language: "postgresql" })}`;
}