PSA/server/setup/create_database.js
Hermes 284313f908
Some checks are pending
Bidi Control Character Guard / bidi-control-guard (push) Waiting to run
Circular Dependency Check / Check for new circular dependencies (push) Waiting to run
Citus Migration Smoke / Combined migrations on single-node Citus (push) Waiting to run
E2E Fresh Install Tests / fresh-install-e2e (push) Waiting to run
ext-v2 guardrails / Run ext-v2 guard and ESLint (push) Waiting to run
Integration Tests / Check for relevant changes (push) Waiting to run
Integration Tests / ${{ (github.event_name == 'schedule' || github.event.inputs.suite == 'full') && 'Full integration suite' || 'Tier-1 integration subset' }} (push) Blocked by required conditions
Mobile checks / Mobile lint + typecheck (push) Waiting to run
Mobile checks / Mobile unit tests (push) Waiting to run
Mobile checks / Mobile dependency audit (report) (push) Waiting to run
Mobile checks / Mobile reproducibility checks (push) Waiting to run
Secrets guard (env backups) / Ensure no tracked env backup files (push) Waiting to run
Temporal Readiness / fast-readiness (push) Waiting to run
Temporal Readiness / docker-parity (push) Waiting to run
TypeScript Type Check / Nx affected typecheck (push) Waiting to run
Unit Tests / Skipped-test budget (push) Waiting to run
Unit Tests / Nx affected unit tests (push) Waiting to run
Unit Tests / Server unit coverage (informational) (push) Waiting to run
Validate Tenant Management Schema / Check for relevant changes (push) Waiting to run
Validate Tenant Management Schema / Validate Tenant Management Schema (push) Blocked by required conditions
EE Workflows Build Guard / ee-workflows-build-guard (push) Waiting to run
Initial import of AlgaPSA codebase from PSA server
Excluded: .git, node_modules, secrets/, compose.env, assemblyscript tgz

Source: /opt/alga-psa on psa.joliet.tech
2026-06-22 16:12:17 -05:00

385 lines
15 KiB
JavaScript

import pg from 'pg';
import dotenv from 'dotenv';
import { fileURLToPath } from 'url';
import { dirname } from 'path';
import fs from 'fs';
import path from 'path';
import crypto from 'crypto';
// Use a direct path to the shared secret provider implementation to avoid relying on package exports
// during CI/setup where the shared package may not be built yet.
// Do NOT statically import shared code here. This script runs before the monorepo is built in CI.
// We'll attempt dynamic imports, but always fall back to env vars or Docker secrets files.
// Enable long stack traces for async operations
Error.stackTraceLimit = 50;
process.env.NODE_OPTIONS = '--async-stack-traces';
// Calculate secrets directory path once at module load
const DOCKER_SECRETS_PATH = '/run/secrets';
const LOCAL_SECRETS_PATH = '../secrets';
const SECRETS_PATH = fs.existsSync(DOCKER_SECRETS_PATH) ? DOCKER_SECRETS_PATH : LOCAL_SECRETS_PATH;
async function getSecret(secretName, envVar, defaultValue = '') {
// 1) Try dynamic import of shared secret provider (package path first, then monorepo path)
try {
let spiFactory = null;
try {
const mod = await import('@alga-psa/core');
spiFactory = mod?.getSecretProviderInstance;
} catch {}
if (!spiFactory) {
try {
const mod2 = await import('../../shared/core/secretProvider.js');
spiFactory = mod2?.getSecretProviderInstance;
} catch {}
}
if (typeof spiFactory === 'function') {
const secretProvider = await spiFactory();
const secret = await secretProvider.getAppSecret(secretName);
if (secret) {
console.log(`Successfully read secret '${secretName}' from secret provider`);
return secret;
}
}
} catch (error) {
console.warn(`Failed to read secret '${secretName}' from secret provider:`, error?.message || String(error));
}
// 2) Try Docker/Kubernetes secrets files
try {
const candidates = [
path.join(SECRETS_PATH, secretName),
path.join(SECRETS_PATH, envVar),
];
for (const p of candidates) {
if (fs.existsSync(p)) {
const v = fs.readFileSync(p, 'utf8').trim();
if (v) {
console.warn(`Using value from secrets file '${p}' for '${secretName}'`);
return v;
}
}
}
} catch {}
// 3) Fallback to environment variable
if (process.env[envVar]) {
console.warn(`Using ${envVar} environment variable instead of secret provider`);
return process.env[envVar] || defaultValue;
}
console.warn(`Neither secret provider, secrets file, nor ${envVar} environment variable found, using default value`);
return defaultValue;
}
const __filename = fileURLToPath(import.meta.url);
const __dirname = dirname(__filename);
const { Client } = pg;
dotenv.config();
const MAX_RETRIES = 5;
const INITIAL_RETRY_DELAY = 1000; // 1 second
/**
* Configuration validation
* @throws {Error} If required environment variables are missing
*/
function validateConfig() {
const required = [
'DB_HOST',
'DB_PORT',
'DB_NAME_SERVER',
'DB_USER_SERVER',
'APP_ENV'
];
const missing = required.filter(key => !process.env[key]);
if (missing.length > 0) {
throw new Error(`Missing required environment variables: ${missing.join(', ')}`);
}
}
/**
* Attempts to set up the hocuspocus database
* This is a non-fatal operation - if it fails, we log the error but continue
*/
async function setupHocuspocusDatabase(client, postgresPassword) {
// Default to 'hocuspocus' if environment variables are not set
process.env.DB_NAME_HOCUSPOCUS = process.env.DB_NAME_HOCUSPOCUS || 'hocuspocus';
process.env.DB_USER_HOCUSPOCUS = process.env.DB_USER_HOCUSPOCUS || 'hocuspocus_user';
// Get hocuspocus password from secrets or env var
const hocuspocusPassword = await getSecret('db_password_hocuspocus', 'DB_PASSWORD_HOCUSPOCUS', postgresPassword);
try {
// Check if database exists
const dbCheckResult = await client.query(
"SELECT 1 FROM pg_database WHERE datname = $1",
[process.env.DB_NAME_HOCUSPOCUS]
);
if (dbCheckResult.rows.length > 0) {
console.log(`Database ${process.env.DB_NAME_HOCUSPOCUS} already exists`);
} else {
await client.query(`CREATE DATABASE ${process.env.DB_NAME_HOCUSPOCUS}`);
console.log(`Database ${process.env.DB_NAME_HOCUSPOCUS} created successfully`);
}
// Connect to the hocuspocus database
const hocuspocusClient = new Client({
host: process.env.DB_HOST,
port: process.env.DB_PORT,
user: 'postgres',
password: postgresPassword,
database: process.env.DB_NAME_HOCUSPOCUS
});
await hocuspocusClient.connect();
// Check if hocuspocus user exists
const userCheckResult = await hocuspocusClient.query(
"SELECT 1 FROM pg_roles WHERE rolname = $1",
[process.env.DB_USER_HOCUSPOCUS]
);
if (userCheckResult.rows.length > 0) {
console.log(`User ${process.env.DB_USER_HOCUSPOCUS} already exists`);
// Update password for existing user to keep Postgres in sync with secret store
await hocuspocusClient.query(`ALTER USER ${process.env.DB_USER_HOCUSPOCUS} WITH PASSWORD '${hocuspocusPassword}'`);
console.log(`Updated password for user ${process.env.DB_USER_HOCUSPOCUS}`);
} else {
await hocuspocusClient.query(`CREATE USER ${process.env.DB_USER_HOCUSPOCUS} WITH PASSWORD '${hocuspocusPassword}'`);
console.log(`User ${process.env.DB_USER_HOCUSPOCUS} created successfully`);
}
// Grant necessary permissions
await hocuspocusClient.query(`GRANT ALL PRIVILEGES ON DATABASE ${process.env.DB_NAME_HOCUSPOCUS} TO ${process.env.DB_USER_HOCUSPOCUS}`);
await hocuspocusClient.query(`GRANT ALL PRIVILEGES ON SCHEMA public TO ${process.env.DB_USER_HOCUSPOCUS}`);
await hocuspocusClient.end();
console.log('Hocuspocus database setup completed successfully');
} catch (error) {
console.warn('Warning: Hocuspocus database setup failed:', error.message);
console.log('Continuing with main application setup...');
}
}
async function ensureLoginRole(client, username, password) {
const roleResult = await client.query(
"SELECT 1 FROM pg_roles WHERE rolname = $1",
[username]
);
if (roleResult.rows.length > 0) {
console.log(`User ${username} already exists`);
await client.query(`ALTER USER ${username} WITH PASSWORD '${password}'`);
console.log(`Updated password for user ${username}`);
return;
}
await client.query(`CREATE USER ${username} WITH PASSWORD '${password}'`);
console.log(`User ${username} created successfully`);
}
async function grantServerDatabasePrivileges(client, username) {
await client.query(`GRANT CONNECT ON DATABASE ${process.env.DB_NAME_SERVER} TO ${username}`);
await client.query(`GRANT USAGE ON SCHEMA public TO ${username}`);
await client.query(`GRANT SELECT, INSERT, UPDATE, DELETE ON ALL TABLES IN SCHEMA public TO ${username}`);
await client.query(`ALTER DEFAULT PRIVILEGES IN SCHEMA public GRANT SELECT, INSERT, UPDATE, DELETE ON TABLES TO ${username}`);
await client.query(`GRANT USAGE ON ALL SEQUENCES IN SCHEMA public TO ${username}`);
await client.query(`ALTER DEFAULT PRIVILEGES IN SCHEMA public GRANT USAGE ON SEQUENCES TO ${username}`);
}
/**
* Creates database and users with appropriate permissions and RLS policies
* @param {number} retryCount - Number of retry attempts
* @returns {Promise<void>}
*/
async function createDatabase(retryCount = 0) {
try {
validateConfig();
} catch (error) {
console.error('Configuration validation failed:', error.message);
process.exit(1);
}
// Read passwords from secret files
const postgresPassword = await getSecret('postgres_password', 'DB_PASSWORD_ADMIN');
if (!postgresPassword) {
console.error('Error: No postgres password available');
process.exit(1);
}
const serverPassword = await getSecret('db_password_server', 'DB_PASSWORD_SERVER');
if (!serverPassword) {
console.error('Error: No server password available');
process.exit(1);
}
const pgbouncerUser = process.env.DB_USER_PGBOUNCER || '';
const pgbouncerPassword = pgbouncerUser
? await getSecret('db_password_pgbouncer', 'DB_PASSWORD_PGBOUNCER', serverPassword)
: '';
const client = new Client({
host: process.env.DB_HOST,
port: process.env.DB_PORT,
user: 'postgres',
password: postgresPassword,
database: 'postgres' // Connect to default postgres database
});
try {
console.log(`Attempting to connect to PostgreSQL at ${process.env.DB_HOST}:${process.env.DB_PORT} with user 'postgres'`);
await client.connect();
console.log('Connected to PostgreSQL server');
// Ensure connection succeeds but leave password_encryption untouched.
// Try to set up hocuspocus database (non-fatal if it fails)
await setupHocuspocusDatabase(client, postgresPassword);
// Check if database exists
const dbCheckResult = await client.query(
"SELECT 1 FROM pg_database WHERE datname = $1",
[process.env.DB_NAME_SERVER]
);
if (dbCheckResult.rows.length > 0) {
console.log(`Database ${process.env.DB_NAME_SERVER} already exists`);
} else {
await client.query(`CREATE DATABASE ${process.env.DB_NAME_SERVER}`);
console.log(`Database ${process.env.DB_NAME_SERVER} created successfully`);
}
// Close connection to postgres database
await client.end();
// Connect to the newly created database
const dbClient = new Client({
host: process.env.DB_HOST,
port: process.env.DB_PORT,
user: 'postgres',
password: postgresPassword,
database: process.env.DB_NAME_SERVER
});
await dbClient.connect();
// Note: The postgres superuser password is already set via POSTGRES_PASSWORD_FILE during container initialization.
// We do NOT re-set it here to avoid authentication issues with concurrent connections.
let skipDbSetup = false;
try {
const tenantsCheck = await dbClient.query("SELECT 1 FROM information_schema.tables WHERE table_name = 'tenants'");
if (tenantsCheck.rows.length > 0) {
console.log('Database appears to be already configured (tenants table exists). Skipping setup.');
skipDbSetup = true;
}
} catch (error) {
console.log('Tenants table not found, proceeding with database setup...');
}
if (!skipDbSetup) {
try {
await dbClient.query(`ALTER USER ${process.env.DB_USER_SERVER} WITH PASSWORD 'placeholder'`);
if (pgbouncerUser && pgbouncerUser !== process.env.DB_USER_SERVER) {
await dbClient.query(`ALTER USER ${pgbouncerUser} WITH PASSWORD 'placeholder'`);
}
} catch (error) {
if (error?.code === '42704') {
console.log(`User ${process.env.DB_USER_SERVER} does not exist yet, skipping placeholder password reset.`);
} else {
throw error;
}
}
}
// Set Citus mode to sequential for DDL operations if Citus is available
try {
await dbClient.query(`SET LOCAL citus.multi_shard_modify_mode TO 'sequential';`);
console.log('Citus sequential mode enabled');
} catch (error) {
// Ignore error if Citus is not installed
console.log('Citus not detected, continuing with standard PostgreSQL');
}
// Create extensions (including vector for pgvector support)
await dbClient.query(`
CREATE EXTENSION IF NOT EXISTS "vector";
`);
console.log('Database extensions created successfully');
await ensureLoginRole(dbClient, process.env.DB_USER_SERVER, serverPassword);
if (pgbouncerUser && pgbouncerPassword && pgbouncerUser !== process.env.DB_USER_SERVER) {
await ensureLoginRole(dbClient, pgbouncerUser, pgbouncerPassword);
}
// Configure database
await dbClient.query(`ALTER DATABASE ${process.env.DB_NAME_SERVER} SET app.environment = '${process.env.APP_ENV}'`);
// Ensure postgres user has necessary permissions
await dbClient.query(`GRANT ALL PRIVILEGES ON DATABASE ${process.env.DB_NAME_SERVER} TO postgres`);
await dbClient.query(`GRANT ALL PRIVILEGES ON SCHEMA public TO postgres`);
await dbClient.query(`ALTER USER postgres WITH CREATEDB CREATEROLE`);
// Grant CREATE permission on public schema to postgres user
await dbClient.query(`ALTER SCHEMA public OWNER TO postgres`);
await dbClient.query(`GRANT CREATE ON SCHEMA public TO postgres`);
// Set up RLS and permissions
console.log('Setting up Row Level Security...');
await grantServerDatabasePrivileges(dbClient, process.env.DB_USER_SERVER);
if (pgbouncerUser && pgbouncerUser !== process.env.DB_USER_SERVER) {
await grantServerDatabasePrivileges(dbClient, pgbouncerUser);
}
console.log('Database setup completed successfully');
await dbClient.end();
} catch (error) {
console.error('Error during database setup:', error);
// Log additional debugging information for authentication errors
const emsg = String(error?.message || '');
if (error.code === '08P01' || /SCRAM|SASL|authentication|wrong password type/i.test(emsg)) {
console.error('=== AUTHENTICATION ERROR DEBUG INFO ===');
console.error(`Database Host: ${process.env.DB_HOST}`);
console.error(`Database Port: ${process.env.DB_PORT}`);
console.error(`Username: postgres`);
console.error(`Password source: ${postgresPassword ? 'Available' : 'Missing'}`);
console.error(`Password length: ${postgresPassword ? postgresPassword.length : 0} characters`);
console.error(`Secrets path used: ${SECRETS_PATH}`);
console.error(`Environment variables check:`);
console.error(` - DB_PASSWORD_ADMIN: ${process.env.DB_PASSWORD_ADMIN ? 'Set' : 'Not set'}`);
console.error(` - DB_HOST: ${process.env.DB_HOST || 'Not set'}`);
console.error(` - DB_PORT: ${process.env.DB_PORT || 'Not set'}`);
console.error('Tip: For appliance installs, keep alga-core on direct Postgres and use a separate PgBouncer role for pooled services.');
console.error('If direct app auth fails, ensure DB_PASSWORD_SERVER matches the direct runtime role password and DB_PASSWORD_PGBOUNCER is only used by PgBouncer-backed services.');
console.error('For setup, prefer direct Postgres by setting DB_HOST_ADMIN=db and DB_PORT_ADMIN=5432.');
console.error('See docs/setup_guide.md → Troubleshooting → Postgres authentication loop.');
console.error('=======================================');
}
if (retryCount < MAX_RETRIES) {
const delay = INITIAL_RETRY_DELAY * Math.pow(2, retryCount);
console.log(`Retrying in ${delay / 1000} seconds... (Attempt ${retryCount + 1}/${MAX_RETRIES})`);
await new Promise(resolve => setTimeout(resolve, delay));
return createDatabase(retryCount + 1);
}
console.error(`Max retries (${MAX_RETRIES}) reached. Database setup failed.`);
process.exit(1);
}
}
// Execute database setup
createDatabase().catch(error => {
console.error('Unhandled error during database setup:', error);
process.exit(1);
});