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
Excluded: .git, node_modules, secrets/, compose.env, assemblyscript tgz Source: /opt/alga-psa on psa.joliet.tech
385 lines
15 KiB
JavaScript
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);
|
|
});
|