import { ensureError, getBasicDbConfigDefinitions, getValidatedConfig, isNullOrWhitespace, pick, } from '@axinom/mosaic-service-common'; import chalk from 'chalk'; import { ExecException, exec } from 'child_process'; import { existsSync, mkdirSync, statSync } from 'fs'; import { basename, dirname, isAbsolute, join, normalize, relative } from 'path'; import { exitCode, getTimestamp } from '../../common'; import { PgDumpOptions } from './pg-dump-options'; /** * Type needed for findCompose functions */ interface ComposePathResult { dirName: string; fileName: string; } /** * Convert absolute path to ComposePathResult * dirName would be relative to the current working directory */ const absolutePathToRelativeDirNameAndFileName = ( absPath: string, ): ComposePathResult => { return { dirName: dirname(relative(process.cwd(), absPath)), fileName: basename(absPath), }; }; /** * Calculates a path to `docker-compose.yaml` file based on initial path. * For example, if `scripts/infra` is provided as initial path, it will look for `scripts/infra/docker-compose.yml` in current folder, then in folder one hierarchy level up, traversing directories towards root. * Returns a value like `../../../scripts/infra` or throws an error. * Eliminates the need to calculate how many `..` you must have in the path to docker compose file which is passed as a cli parameter. */ const findComposePath = (initialPath: string): ComposePathResult => { const supportedComposeFiles = ['docker-compose.yml', 'docker-compose.yaml']; let startPath = process.cwd(); let lastPath = ''; while (lastPath !== startPath) { for (const file of supportedComposeFiles) { const lookupPath = join(startPath, initialPath, file); if (existsSync(lookupPath)) { return absolutePathToRelativeDirNameAndFileName(lookupPath); } } // Preserve currently checked path for while condition comparison lastPath = startPath; // Remove last segment from path, move to directory closer to root startPath = normalize(join(startPath, '..')); } throw Error( `'${initialPath}/docker-compose.y(a)ml' file not found. Please make sure that correct composeFolderPath parameter is provided.`, ); }; /** * checks if the given path to file exists. and returns the directory path and file name of the file */ const findComposeFilePath = (initialPath: string): ComposePathResult => { const isAbs = isAbsolute(initialPath); const lookupPath = isAbs ? initialPath : join(process.cwd(), initialPath); try { const fileStats = statSync(lookupPath); if (fileStats.isFile()) { return absolutePathToRelativeDirNameAndFileName(lookupPath); } else { throw Error( `'${initialPath}' is not a file. Please make sure that correct composeFilePath parameter is provided.`, ); } } catch (error) { throw Error( `'${initialPath}' file not found. Please make sure that correct composeFilePath parameter is provided.`, ); } }; /** * Loads and validates all required environment variables that are used to create a shadow database connection string. * For this to work, CLI script must be called with pre-loaded variables. e.g. * - using dotenv-cli script would look like this: `dotenv -- mosaic pg-dump -c scripts/infra` * - using env-cmd script could look like this: `env-cmd -f .env env-cmd -f ../../../.env mosaic pg-dump -c scripts/infra` */ const getDumpDbConnectionString = (connectionString?: string): string => { if (!isNullOrWhitespace(connectionString)) { return connectionString; } try { const configDefinitions = pick( getBasicDbConfigDefinitions(), 'pgHost', 'pgPort', 'dbName', 'dbOwner', 'dbOwnerPassword', 'dbOwnerConnectionString', 'dbShadowConnectionString', 'pgUserSuffix', ); const config = getValidatedConfig(configDefinitions); return config.dbShadowConnectionString; } catch (e) { const error = ensureError(e); throw new Error( `${error.message}\n\nSpecified environment variables must be pre-loaded, or a database connection string can be specified explicitly using 'connectionString' cli parameter.`, ); } }; /** * Creates a dump directory (if not existing already), where resulting sql dump file will be created/updated. * Returns absolute path to dump file, including filename. */ const ensureDumpDirectoryExist = (targetPath: string): string => { const dbSchemaExportPath = join(process.cwd(), targetPath); const dirPath = dirname(dbSchemaExportPath); if (!existsSync(dirPath)) { mkdirSync(dirPath, { recursive: true }); } return dbSchemaExportPath; }; export const generate = (options: PgDumpOptions): void => { try { console.log(getTimestamp(), `Starting pg-dump command...`); let composeDirPath = ''; let composeFileName = 'docker-compose.yml'; if (options.composeFilePath) { ({ dirName: composeDirPath, fileName: composeFileName } = findComposeFilePath(normalize(options.composeFilePath))); } else { ({ dirName: composeDirPath, fileName: composeFileName } = findComposePath( normalize(options.composeFolderPath), )); } const connectionString = getDumpDbConnectionString( options.connectionString, ); const dumpPath = ensureDumpDirectoryExist(options.dumpPath); const excludeSchemas = isNullOrWhitespace(options.excludeSchemas) ? [] : options.excludeSchemas .split(',') .map((schemaName) => `--exclude-schema=${schemaName}`); const dumpOptions = [ '--no-sync', '--schema-only', '--no-owner', ...excludeSchemas, connectionString, ].join(' '); exec( `cd "${composeDirPath}" && docker compose -f "${composeFileName}" exec -T postgres pg_dump ${dumpOptions} > "${dumpPath}"`, (_error: ExecException | null, stdout: string, stderr: string) => { if (stdout) { console.log(getTimestamp(), 'pg_dump info:', stdout); } if (stderr) { console.log( getTimestamp(), chalk.red('Command failed:'), stderr.trim(), ); } else { console.log( getTimestamp(), chalk.green('Success:'), `Database schema dump created at '${chalk.blueBright( options.dumpPath, )}'`, ); } }, ); } catch (e) { const error = ensureError(e); console.log(getTimestamp(), chalk.red('Command failed:'), error.message); process.exit(exitCode); } };