pg-create-db
Version:
Database creation tool for PostgreSQL database server
353 lines (352 loc) • 16.8 kB
JavaScript
"use strict";
Object.defineProperty(exports, "__esModule", { value: true });
exports.CreateDatabaseService = void 0;
const connection_string_1 = require("connection-string");
const log4js_1 = require("log4js");
const get_log_level_1 = require("../utils/get-log-level");
const replace_env_1 = require("../utils/replace-env");
class CreateDatabaseService {
constructor(dryRun) {
this.dryRun = dryRun;
this.logger = (0, log4js_1.getLogger)('create-database');
this.logger.level = (0, get_log_level_1.getLogLevel)();
this.logger.info(`dryRun: ${dryRun}`);
}
loadPackages() {
if (!this.Client) {
// eslint-disable-next-line @typescript-eslint/no-var-requires
this.Client = require('pg').Client;
}
if (!this.pgp) {
// eslint-disable-next-line @typescript-eslint/no-var-requires
this.pgp = require('pg-promise');
}
}
async createDatabase({ rootDatabaseUrl, appDatabaseUrl, forceChangeUsername, forceChangePassword, dropAppDatabase, extensions, }) {
rootDatabaseUrl = (0, replace_env_1.replaceEnv)(rootDatabaseUrl);
appDatabaseUrl = (0, replace_env_1.replaceEnv)(appDatabaseUrl);
this.loadPackages();
await this.checkSuperuserName(rootDatabaseUrl);
if (dropAppDatabase) {
await this.dropAppDatabaseHandler(rootDatabaseUrl, appDatabaseUrl);
}
if (forceChangeUsername) {
await this.forceChangeUsername(rootDatabaseUrl, appDatabaseUrl);
}
await this.createAppDatabaseHandler(rootDatabaseUrl, appDatabaseUrl, extensions, forceChangePassword);
await this.closeRootDbConnection();
}
async dropAppDatabaseHandler(rootDatabaseUrl, appDatabaseUrl) {
this.logger.info('Start drop database...');
const rootDatabase = this.parseDatabaseUrl(rootDatabaseUrl);
const appDatabase = this.parseDatabaseUrl(appDatabaseUrl);
this.logger.debug('Root database:', rootDatabase.DATABASE);
this.logger.debug('App database:', appDatabase.DATABASE);
const db = this.getRootDbConnection({
username: rootDatabase.USERNAME,
password: rootDatabase.PASSWORD,
port: rootDatabase.PORT,
host: (rootDatabase.HOST || '').split(':')[0],
database: rootDatabase.DATABASE,
});
if (appDatabase.USERNAME !== rootDatabase.USERNAME) {
try {
if (this.dryRun) {
this.logger.info(`DROP DATABASE "${appDatabase.DATABASE}"`);
}
else {
this.logger.debug(`DROP DATABASE "${appDatabase.DATABASE}"`);
await db.none(`DROP DATABASE "${appDatabase.DATABASE}"`, []);
}
// eslint-disable-next-line @typescript-eslint/no-explicit-any
}
catch (err) {
if (!String(err).includes('already exists')) {
this.logger.error(err, err.stack);
throw err;
}
}
}
else {
this.logger.error(`Application database user and root database user must be different`);
}
this.logger.info('End of drop database...');
}
async createAppDatabaseHandler(rootDatabaseUrl, appDatabaseUrl, extensions, forceChangePassword) {
this.logger.info('Start create database...');
const rootDatabase = this.parseDatabaseUrl(rootDatabaseUrl);
const appDatabase = this.parseDatabaseUrl(appDatabaseUrl);
this.logger.debug('Root database:', rootDatabase.DATABASE);
this.logger.debug('App database:', appDatabase.DATABASE);
const db = this.getRootDbConnection({
username: rootDatabase.USERNAME,
password: rootDatabase.PASSWORD,
port: rootDatabase.PORT,
host: (rootDatabase.HOST || '').split(':')[0],
database: rootDatabase.DATABASE,
});
try {
if (appDatabase.USERNAME !== rootDatabase.USERNAME) {
try {
if (this.dryRun) {
this.logger.info(`CREATE DATABASE ${appDatabase.DATABASE}`);
}
else {
this.logger.debug(`CREATE DATABASE ${appDatabase.DATABASE}`);
await db.none('CREATE DATABASE $1:name', [appDatabase.DATABASE]);
}
// eslint-disable-next-line @typescript-eslint/no-explicit-any
}
catch (err) {
if (!String(err).includes('already exists')) {
this.logger.error(err, err.stack);
throw err;
}
else {
if (forceChangePassword) {
await this.forceChangePassword(rootDatabaseUrl, appDatabaseUrl);
}
}
}
await this.applyPermissionsHandler(rootDatabaseUrl, appDatabaseUrl);
const updateDatabaseQuery = extensions
.map((extension) => `CREATE EXTENSION IF NOT EXISTS "${extension}"`)
.filter(Boolean)
.map((sql) => `${sql};` ||
`DO $$ BEGIN
${sql};
EXCEPTION
WHEN others THEN null;
END $$;`);
const pgAppConfig = {
user: appDatabase.USERNAME,
host: (rootDatabase.HOST || '').split(':')[0],
password: appDatabase.PASSWORD,
port: rootDatabase.PORT,
database: appDatabase.DATABASE,
idleTimeoutMillis: 30000,
};
const appClient = new this.Client(pgAppConfig);
await appClient.connect();
for (const query of updateDatabaseQuery) {
try {
if (this.dryRun) {
this.logger.info(query);
}
else {
this.logger.debug(query);
await appClient.query(query);
}
// eslint-disable-next-line @typescript-eslint/no-explicit-any
}
catch (err) {
if (!String(err).includes('already exists')) {
this.logger.debug(query);
this.logger.error(err, err.stack);
throw err;
}
}
}
await appClient.end();
}
else {
this.logger.error(`Application database user and root database user must be different`);
}
// eslint-disable-next-line @typescript-eslint/no-explicit-any
}
catch (err) {
if (!String(err).includes('already exists')) {
this.logger.error(err, err.stack);
throw err;
}
}
this.logger.info('End of create database...');
}
async forceChangePassword(rootDatabaseUrl, appDatabaseUrl) {
const rootDatabase = this.parseDatabaseUrl(rootDatabaseUrl);
const appDatabase = this.parseDatabaseUrl(appDatabaseUrl);
const db = this.getRootDbConnection({
username: rootDatabase.USERNAME,
password: rootDatabase.PASSWORD,
port: rootDatabase.PORT,
host: (rootDatabase.HOST || '').split(':')[0],
database: rootDatabase.DATABASE,
});
this.logger.debug(`ALTERING PASSWORD OF ${appDatabase.USERNAME} to '********'`);
await db.none(`ALTER USER $1:name WITH PASSWORD '${appDatabase.PASSWORD}'`, [appDatabase.USERNAME]);
}
async checkSuperuserName(rootDatabaseUrl) {
const rootDatabase = this.parseDatabaseUrl(rootDatabaseUrl);
if (rootDatabase.USERNAME !== 'postgres') {
throw Error(`The username for the root database must always be "postgres", otherwise the user will not receive "super" rights, current username "${rootDatabase.USERNAME}"`);
}
}
async forceChangeUsername(rootDatabaseUrl, appDatabaseUrl) {
const rootDatabase = this.parseDatabaseUrl(rootDatabaseUrl);
const appDatabase = this.parseDatabaseUrl(appDatabaseUrl);
this.logger.info('Start updating username...');
const db = this.getRootDbConnection({
username: rootDatabase.USERNAME,
password: rootDatabase.PASSWORD,
port: rootDatabase.PORT,
host: (rootDatabase.HOST || '').split(':')[0],
database: rootDatabase.DATABASE,
});
try {
// Get a list of users
const users = await db.any(`
select d.datname, (select string_agg(u.usename, ',' order by u.usename)
from pg_user u where has_database_privilege(u.usename, d.datname, 'CREATE'))
as allowed_users from pg_database d order by d.datname`);
const curDb = users.find(({ datname }) => datname === appDatabase.DATABASE);
if (!curDb) {
throw new Error(`"${appDatabase.DATABASE}" does not exist! force change username was not applied`);
}
const nonRootUsers = curDb.allowed_users.split(',').filter((user) => user !== rootDatabase.USERNAME);
// Verify that there is only one non-root user
if (nonRootUsers.length === 1) {
if (nonRootUsers[0] === appDatabase.USERNAME) {
return;
}
if (this.dryRun) {
this.logger.info(`ALTER USER '${nonRootUsers[0]}':name RENAME TO '${appDatabase.USERNAME}':name`);
this.logger.info(`ALTER USER '${appDatabase.USERNAME}':name WITH PASSWORD '********'`);
}
else {
await db.none(`ALTER USER $1:name RENAME TO $2:name`, [nonRootUsers[0], appDatabase.USERNAME]);
await db.none(`ALTER USER $1:name WITH PASSWORD '${appDatabase.PASSWORD}'`, [appDatabase.USERNAME]);
this.logger.info('Username have been updated...');
}
}
else {
this.logger.error('There are multiple non-root users in the database: ', nonRootUsers);
throw new Error('Cannot update credentials: multiple non-root users exist');
}
}
catch (error) {
if (!String(error).includes('does not exist')) {
throw error;
}
}
}
getRootDbConnection(rootDatabase) {
if (!this.rootDatabaseConnection) {
this.rootDatabaseConnection = this.pgp({})({
user: rootDatabase.username,
password: rootDatabase.password,
port: rootDatabase.port,
host: (rootDatabase.host || '').split(':')[0],
database: rootDatabase.database,
});
}
return this.rootDatabaseConnection;
}
async closeRootDbConnection() {
await this.rootDatabaseConnection.$pool.end();
this.rootDatabaseConnection = null;
}
async applyPermissionsHandler(rootDatabaseUrl, appDatabaseUrl) {
this.logger.info('Start apply permissions...');
this.logger.debug('Root database url:', rootDatabaseUrl);
this.logger.debug('App database url:', appDatabaseUrl);
const rootDatabase = this.parseDatabaseUrl(rootDatabaseUrl);
const appDatabase = this.parseDatabaseUrl(appDatabaseUrl);
const createDatabaseQuery = [
appDatabase.USERNAME !== rootDatabase.USERNAME &&
`CREATE USER "${appDatabase.USERNAME}" WITH PASSWORD '${appDatabase.PASSWORD}'`,
appDatabase.USERNAME !== rootDatabase.USERNAME &&
`grant connect on database "${appDatabase.DATABASE}" to "${appDatabase.USERNAME}"`,
appDatabase.USERNAME !== rootDatabase.USERNAME && `grant all on schema public to "${appDatabase.USERNAME}"`,
appDatabase.USERNAME !== rootDatabase.USERNAME &&
`GRANT SELECT, INSERT, UPDATE, DELETE ON ALL TABLES IN SCHEMA public TO "${appDatabase.USERNAME}"`,
`GRANT ALL PRIVILEGES ON ALL TABLES IN SCHEMA public TO "${appDatabase.USERNAME}"`,
`GRANT ALL PRIVILEGES ON ALL SEQUENCES IN SCHEMA public TO "${appDatabase.USERNAME}"`,
`GRANT ALL PRIVILEGES ON ALL FUNCTIONS IN SCHEMA public TO "${appDatabase.USERNAME}"`,
`GRANT delete, insert, references, select, trigger, truncate, update ON ALL TABLES IN SCHEMA public TO "${appDatabase.USERNAME}"`,
`GRANT select, update, usage ON ALL SEQUENCES IN SCHEMA public TO "${appDatabase.USERNAME}"`,
`GRANT EXECUTE ON ALL FUNCTIONS IN SCHEMA public TO "${appDatabase.USERNAME}"`,
`ALTER DEFAULT PRIVILEGES IN SCHEMA public GRANT ALL ON TABLES TO "${appDatabase.USERNAME}"`,
`ALTER DEFAULT PRIVILEGES IN SCHEMA public GRANT ALL ON SEQUENCES TO "${appDatabase.USERNAME}"`,
`ALTER DEFAULT PRIVILEGES IN SCHEMA public GRANT ALL ON FUNCTIONS TO "${appDatabase.USERNAME}"`,
`ALTER DEFAULT PRIVILEGES IN SCHEMA public GRANT delete, insert, references, select, trigger, truncate, update ON TABLES TO "${appDatabase.USERNAME}"`,
`ALTER DEFAULT PRIVILEGES IN SCHEMA public GRANT select, update, usage ON SEQUENCES TO "${appDatabase.USERNAME}"`,
`ALTER DEFAULT PRIVILEGES IN SCHEMA public GRANT EXECUTE ON FUNCTIONS TO "${appDatabase.USERNAME}"`,
`GRANT ALL PRIVILEGES ON DATABASE "${appDatabase.DATABASE}" TO "${appDatabase.USERNAME}"`,
appDatabase.USERNAME !== rootDatabase.USERNAME &&
`GRANT ALL PRIVILEGES ON DATABASE "${appDatabase.DATABASE}" TO ${rootDatabase.USERNAME}`,
appDatabase.USERNAME !== rootDatabase.USERNAME &&
`ALTER USER "${appDatabase.USERNAME}" WITH LOGIN CREATEROLE NOCREATEDB NOSUPERUSER INHERIT`,
appDatabase.USERNAME !== rootDatabase.USERNAME &&
`DO $$ BEGIN
DECLARE
t record;
BEGIN
FOR t IN
(select 'GRANT ALL ON TABLE ' || schemaname || '."' || tablename || '" to "${appDatabase.USERNAME}"' query
from pg_tables
where schemaname in ('public')
order by schemaname, tablename)
LOOP
EXECUTE format('%s', t.query);
END LOOP;
END;
END $$`,
]
.filter(Boolean)
.map((sql) => `${sql};` /* ||
`DO $$ BEGIN
${sql};
EXCEPTION
WHEN others THEN null;
END $$;`*/);
const pgConfig = {
user: rootDatabase.USERNAME,
host: (rootDatabase.HOST || '').split(':')[0],
password: rootDatabase.PASSWORD,
port: rootDatabase.PORT,
database: appDatabase.DATABASE,
idleTimeoutMillis: 30000,
};
const client = new this.Client(pgConfig);
await client.connect();
for (const query of createDatabaseQuery) {
try {
if (this.dryRun) {
this.logger.info(query.replace(appDatabase.PASSWORD || '', '********'));
}
else {
this.logger.debug(query.replace(appDatabase.PASSWORD || '', '********'));
await client.query(query);
}
// eslint-disable-next-line @typescript-eslint/no-explicit-any
}
catch (err) {
if (!String(err).includes('already exists')) {
this.logger.debug(query);
this.logger.error(err, err.stack);
throw err;
}
}
}
await client.end();
this.logger.info('End of apply permissions...');
}
parseDatabaseUrl(databaseUrl) {
try {
const cs = new connection_string_1.ConnectionString(databaseUrl);
const USERNAME = cs.user;
const PASSWORD = cs.password;
const PORT = cs.port;
const HOST = cs.hosts && cs.hosts[0].toString();
const DATABASE = cs.path && cs.path[0];
const SCHEMA = cs.params && cs.params['schema'];
const SCHEMAS = cs.params && cs.params['schemas'];
return { USERNAME, PASSWORD, HOST, DATABASE, SCHEMA, SCHEMAS, PORT };
}
catch (err) {
this.logger.debug({ databaseUrl });
throw err;
}
}
}
exports.CreateDatabaseService = CreateDatabaseService;