embedded-postgres
Version:
A package to run an embedded Postgresql database right from NodeJS
355 lines (354 loc) • 15.7 kB
JavaScript
var __awaiter = (this && this.__awaiter) || function (thisArg, _arguments, P, generator) {
function adopt(value) { return value instanceof P ? value : new P(function (resolve) { resolve(value); }); }
return new (P || (P = Promise))(function (resolve, reject) {
function fulfilled(value) { try { step(generator.next(value)); } catch (e) { reject(e); } }
function rejected(value) { try { step(generator["throw"](value)); } catch (e) { reject(e); } }
function step(result) { result.done ? resolve(result.value) : adopt(result.value).then(fulfilled, rejected); }
step((generator = generator.apply(thisArg, _arguments || [])).next());
});
};
import path from 'path';
import crypto from 'crypto';
import fs from 'fs/promises';
import { platform, tmpdir, userInfo } from 'os';
import { spawn, exec } from 'child_process';
import pg from 'pg';
import AsyncExitHook from 'async-exit-hook';
import getBinaries from './binary.js';
const bin = getBinaries();
const { Client } = pg;
/**
* We have to specify the LC_MESSAGES locale because we rely on inspecting the
* output of the `initdb` command to see if Postgres is ready. As we're looking
* for a particular string, we need to force that string into the right locale.
* @see https://github.com/leinelissen/embedded-postgres/issues/15
*/
const LC_MESSAGES_LOCALE = 'en_US.UTF-8';
// The default configuration options for the class
const defaults = {
databaseDir: path.join(process.cwd(), 'data', 'db'),
port: 5432,
user: 'postgres',
password: 'password',
authMethod: 'password',
persistent: true,
initdbFlags: [],
postgresFlags: [],
createPostgresUser: false,
onLog: console.log,
onError: console.error,
};
/**
* This will track instances of all current initialised clusters. We need this
* because we want to be able to shutdown any clusters when the script is exited.
*/
const instances = new Set();
/**
* This class creates an instance from which a single Postgres cluster is
* managed. Note that many clusters may be created, but they will need seperate
* data directories in order to be properly lifecycle managed.
*/
class EmbeddedPostgres {
constructor(options = {}) {
// Options were previously specified in snake_case rather than
// camelCase. We still want to accept the old style of options.
const legacyOptions = {};
if (options.database_dir) {
legacyOptions.databaseDir = options.database_dir;
}
if (options.auth_method) {
legacyOptions.authMethod = options.auth_method;
}
// Assign default options to options object
this.options = Object.assign({}, defaults, legacyOptions, options);
instances.add(this);
this.isRootUser = userInfo().uid === 0;
}
/**
* This function needs to be called whenever a Postgres cluster first needs
* to be created. It will populate the data directory with the right
* settings. If your Postgres cluster is already initialised, you don't need
* to call this function again.
*/
initialise() {
return __awaiter(this, void 0, void 0, function* () {
const { postgres, initdb } = yield bin;
// GUARD: Check that a postgres user is available
yield this.checkForRootUser();
// Optionally retrieve the uid and gid
let permissionIds = yield this.getUidAndGid()
.catch(() => ({}));
// GUARD: Check if we need to create users
if (this.options.createPostgresUser
&& !('uid' in permissionIds)
&& !('gid' in permissionIds)) {
try {
// Create the group and user
yield execAsync('groupadd postgres');
yield execAsync('useradd -g postgres postgres');
// Re-treieve the permission ids now the user exists
permissionIds = yield this.getUidAndGid();
}
catch (err) {
this.options.onError(err);
throw new Error('Failed to create and initialize a new user on this system.');
}
}
// GUARD: Ensure that the data directory is owned by the created user
if (this.options.createPostgresUser) {
if (!('uid' in permissionIds)) {
throw new Error('Failed to retrieve the uid for the newly created user.');
}
// Create the data directory and have the user own it, so we
// don't get any permission errors
yield fs.mkdir(this.options.databaseDir, { recursive: true });
yield fs.chown(this.options.databaseDir, permissionIds.uid, permissionIds.gid);
}
// Create a file on disk that contains the password in plaintext
const randomId = crypto.randomBytes(6).readUIntLE(0, 6).toString(36);
const passwordFile = path.resolve(tmpdir(), `pg-password-${randomId}`);
yield fs.writeFile(passwordFile, this.options.password + '\n');
// Greedily make the file executable, in case it is not
yield fs.chmod(postgres, '755');
yield fs.chmod(initdb, '755');
// Initialize the database
yield new Promise((resolve, reject) => {
var _a;
const process = spawn(initdb, [
`--pgdata=${this.options.databaseDir}`,
`--auth=${this.options.authMethod}`,
`--username=${this.options.user}`,
`--pwfile=${passwordFile}`,
`--lc-messages=${LC_MESSAGES_LOCALE}`,
...this.options.initdbFlags,
], Object.assign(Object.assign({}, permissionIds), { env: { LC_MESSAGES: LC_MESSAGES_LOCALE } }));
// Connect to stderr, as that is where the messages get sent
(_a = process.stdout) === null || _a === void 0 ? void 0 : _a.on('data', (chunk) => {
// Parse the data as a string and log it
const message = chunk.toString('utf-8');
this.options.onLog(message);
});
process.on('exit', (code) => {
if (code === 0) {
resolve();
}
else {
reject(`Postgres init script exited with code ${code}. Please check the logs for extra info. The data directory might already exist.`);
}
});
});
// Clean up the file
yield fs.unlink(passwordFile);
});
}
/**
* Start the Postgres cluster with the given configuration. The cluster is
* started as a seperate process, unmanaged by NodeJS. It is automatically
* shut down when the script exits.
*/
start() {
return __awaiter(this, void 0, void 0, function* () {
const { postgres } = yield bin;
// Optionally retrieve the uid and gid
const permissionIds = yield this.getUidAndGid()
.catch(() => {
throw new Error('Postgres cannot run as a root user. embedded-postgres could not find a postgres user to run as instead. Consider using the `createPostgresUser` option.');
});
// Greedily make the file executable, in case it is not
yield fs.chmod(postgres, '755');
yield new Promise((resolve, reject) => {
var _a;
// Spawn a postgres server
this.process = spawn(postgres, [
'-D',
this.options.databaseDir,
'-p',
this.options.port.toString(),
...this.options.postgresFlags,
], Object.assign(Object.assign({}, permissionIds), { env: { LC_MESSAGES: LC_MESSAGES_LOCALE } }));
// Connect to stderr, as that is where the messages get sent
(_a = this.process.stderr) === null || _a === void 0 ? void 0 : _a.on('data', (chunk) => {
// Parse the data as a string and log it
const message = chunk.toString('utf-8');
this.options.onLog(message);
// GUARD: Check for the right message to determine server start
if (message.includes('database system is ready to accept connections')) {
resolve();
}
});
// In case the process exits early, the promise is rejected.
this.process.on('close', () => {
reject();
});
});
});
}
/**
* Stop an already started cluster with the given configuration.
* NOTE: If you have `persisent` set to false, this method WILL DELETE your
* database files. You will need to call `.initialise()` again after executing
* this method.
*/
stop() {
return __awaiter(this, void 0, void 0, function* () {
// GUARD: If no database is running, immdiately return the function.
if (!this.process) {
return;
}
// Kill the existing postgres process
yield new Promise((resolve) => {
var _a, _b, _c;
// Register a handler for when the process finally exists
(_a = this.process) === null || _a === void 0 ? void 0 : _a.on('exit', resolve);
// GUARD: Check if we're on Windows, since Windows doesn't support SIGINT
if (platform() === 'win32') {
// GUARD: Double check the pid is there to keep TypeScript happy
if (!((_b = this.process) === null || _b === void 0 ? void 0 : _b.pid)) {
throw new Error('Could not find process PID');
}
// Actually kill the process using the Windows taskkill command
spawn('taskkill', ['/pid', this.process.pid.toString(), '/f', '/t']);
}
else {
// If on a sane OS, simply kill using SIGINT
(_c = this.process) === null || _c === void 0 ? void 0 : _c.kill('SIGINT');
}
});
// Clean up process
this.process = undefined;
// GUARD: Additional work if database is not persistent
if (this.options.persistent === false) {
// Delete the data directory
yield fs.rm(this.options.databaseDir, { recursive: true, force: true });
}
});
}
/**
* Create a node-postgres client using the existing cluster configuration.
*
* @param database The database that the postgres client should connect to
* @param host The host that should be pre-filled in the connection options
* @returns Client
*/
getPgClient(database = 'postgres', host = 'localhost') {
// Create client
const client = new Client({
user: this.options.user,
password: this.options.password,
port: this.options.port,
host,
database,
});
// Log errors rather than throwing them so that embedded-postgres has
// enough time to actually shutdown.
client.on('error', this.options.onError);
return client;
}
/**
* Create a database with a given name on the cluster
*/
createDatabase(name) {
return __awaiter(this, void 0, void 0, function* () {
// GUARD: Cluster must be running for performing database operations
if (!this.process) {
throw new Error('Your cluster must be running before you can create a database');
}
// Get client and execute CREATE DATABASE query
const client = this.getPgClient();
yield client.connect();
yield client.query(`CREATE DATABASE ${client.escapeIdentifier(name)}`);
// Clean up client
yield client.end();
});
}
/**
* Drop a database with a given name on the cluster
*/
dropDatabase(name) {
return __awaiter(this, void 0, void 0, function* () {
// GUARD: Cluster must be running for performing database operations
if (!this.process) {
throw new Error('Your cluster must be running before you can create a database');
}
// Get client and execute DROP DATABASE query
const client = this.getPgClient();
yield client.connect();
yield client.query(`DROP DATABASE ${client.escapeIdentifier(name)}`);
// Clean up client
yield client.end();
});
}
/**
* Warn the user in case they're trying to run this library as a root user
*/
checkForRootUser() {
return __awaiter(this, void 0, void 0, function* () {
// GUARD: Ensure that the user isn't root
if (!this.isRootUser) {
return;
}
// Attempt to retrieve the uid and gid for the postgres user. This check
// will throw and error when the postgres user doesn't exist
try {
yield this.getUidAndGid();
}
catch (err) {
// GUARD: No user exists, but check that a postgres user should be created
if (!this.options.createPostgresUser) {
throw new Error('You are running this script as root. Postgres does not support running as root. If you wish to continue, configure embedded-postgres to create a Postgres user by setting the `createPostgresUser` option to true.');
}
}
});
}
/**
* Retrieve the uid and gid for a particular user
*/
getUidAndGid(name = 'postgres') {
return __awaiter(this, void 0, void 0, function* () {
if (!this.isRootUser) {
return {};
}
const [uid, gid] = yield Promise.all([
execAsync(`id -u ${name}`).then(Number.parseInt),
execAsync(`id -g ${name}`).then(Number.parseInt),
]);
return { uid, gid };
});
}
}
/**
* A promisified version of the exec API that either throws on errors or returns
* the string results from the executed command.
*/
function execAsync(command) {
return __awaiter(this, void 0, void 0, function* () {
return new Promise((resolve, reject) => {
exec(command, (error, stdout) => {
if (error) {
reject(error);
}
else {
resolve(stdout);
}
});
});
});
}
/**
* This script should be called when a Node script is exited, so that we can
* nicely shutdown all potentially started clusters, and we don't end up with
* zombie processes.
*/
function gracefulShutdown(done) {
return __awaiter(this, void 0, void 0, function* () {
// Loop through all instances, stop them, and await the response
yield Promise.all([...instances].map((instance) => {
return instance.stop();
}));
// Let NodeJS know we're done
done();
});
}
// Register graceful shutdown function
AsyncExitHook(gracefulShutdown);
export default EmbeddedPostgres;