mirror of
https://github.com/whyour/qinglong.git
synced 2026-09-21 18:08:20 +08:00
feat(ql3): establish 3.0 incubation baseline
This commit is contained in:
@@ -0,0 +1,145 @@
|
||||
import { X509Certificate } from 'node:crypto';
|
||||
import { closeSync, fstatSync, openSync, readSync, constants } from 'node:fs';
|
||||
import { isAbsolute } from 'node:path';
|
||||
|
||||
export const POSTGRES_CA_MAX_FILE_BYTES = 256 * 1024;
|
||||
export const POSTGRES_CA_MAX_CERTIFICATES = 16;
|
||||
|
||||
export const POSTGRES_CA_FILE_ERROR_CODES = Object.freeze([
|
||||
'QL3_POSTGRES_CA_INVALID_PATH',
|
||||
'QL3_POSTGRES_CA_UNAVAILABLE',
|
||||
'QL3_POSTGRES_CA_NOT_REGULAR',
|
||||
'QL3_POSTGRES_CA_INSECURE_PERMISSIONS',
|
||||
'QL3_POSTGRES_CA_INVALID_SIZE',
|
||||
'QL3_POSTGRES_CA_CHANGED_DURING_READ',
|
||||
'QL3_POSTGRES_CA_INVALID_ENCODING',
|
||||
'QL3_POSTGRES_CA_INVALID_PEM',
|
||||
'QL3_POSTGRES_CA_TOO_MANY_CERTIFICATES',
|
||||
'QL3_POSTGRES_CA_NOT_CA',
|
||||
'QL3_POSTGRES_CA_DUPLICATE_CERTIFICATE',
|
||||
] as const);
|
||||
|
||||
export type PostgresCertificateAuthorityFileErrorCode =
|
||||
(typeof POSTGRES_CA_FILE_ERROR_CODES)[number];
|
||||
|
||||
export class PostgresCertificateAuthorityFileError extends TypeError {
|
||||
constructor(readonly code: PostgresCertificateAuthorityFileErrorCode) {
|
||||
super('PostgreSQL certificate authority file is invalid');
|
||||
this.name = 'PostgresCertificateAuthorityFileError';
|
||||
}
|
||||
}
|
||||
|
||||
export interface PostgresCertificateAuthorityFileInspection {
|
||||
readonly bundle: string;
|
||||
readonly fingerprints256: readonly string[];
|
||||
}
|
||||
|
||||
function fail(code: PostgresCertificateAuthorityFileErrorCode): never {
|
||||
throw new PostgresCertificateAuthorityFileError(code);
|
||||
}
|
||||
|
||||
function readBoundedRegularFile(filePath: string): Buffer {
|
||||
if (
|
||||
typeof filePath !== 'string' ||
|
||||
filePath.length < 1 ||
|
||||
filePath.length > 4096 ||
|
||||
/[\0\r\n]/.test(filePath) ||
|
||||
!isAbsolute(filePath)
|
||||
) {
|
||||
fail('QL3_POSTGRES_CA_INVALID_PATH');
|
||||
}
|
||||
|
||||
let descriptor: number;
|
||||
try {
|
||||
descriptor = openSync(filePath, constants.O_RDONLY);
|
||||
} catch {
|
||||
fail('QL3_POSTGRES_CA_UNAVAILABLE');
|
||||
}
|
||||
try {
|
||||
const stat = fstatSync(descriptor);
|
||||
if (!stat.isFile()) fail('QL3_POSTGRES_CA_NOT_REGULAR');
|
||||
if ((stat.mode & 0o022) !== 0) {
|
||||
fail('QL3_POSTGRES_CA_INSECURE_PERMISSIONS');
|
||||
}
|
||||
if (
|
||||
!Number.isSafeInteger(stat.size) ||
|
||||
stat.size < 1 ||
|
||||
stat.size > POSTGRES_CA_MAX_FILE_BYTES
|
||||
) {
|
||||
fail('QL3_POSTGRES_CA_INVALID_SIZE');
|
||||
}
|
||||
|
||||
const bytes = Buffer.alloc(stat.size + 1);
|
||||
let offset = 0;
|
||||
while (offset < bytes.byteLength) {
|
||||
const count = readSync(
|
||||
descriptor,
|
||||
bytes,
|
||||
offset,
|
||||
bytes.byteLength - offset,
|
||||
null,
|
||||
);
|
||||
if (count === 0) break;
|
||||
offset += count;
|
||||
}
|
||||
if (offset !== stat.size) {
|
||||
fail('QL3_POSTGRES_CA_CHANGED_DURING_READ');
|
||||
}
|
||||
return bytes.subarray(0, offset);
|
||||
} finally {
|
||||
closeSync(descriptor);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Loads one immutable PostgreSQL trust bundle. Kubernetes projected Secret
|
||||
* symlinks are accepted because the opened target is validated with fstat.
|
||||
*/
|
||||
export function inspectPostgresCertificateAuthorityFile(
|
||||
filePath: string,
|
||||
): PostgresCertificateAuthorityFileInspection {
|
||||
const bytes = readBoundedRegularFile(filePath);
|
||||
let source: string;
|
||||
try {
|
||||
source = new TextDecoder('utf-8', { fatal: true }).decode(bytes);
|
||||
} catch {
|
||||
fail('QL3_POSTGRES_CA_INVALID_ENCODING');
|
||||
} finally {
|
||||
bytes.fill(0);
|
||||
}
|
||||
|
||||
const pattern =
|
||||
/-----BEGIN CERTIFICATE-----[\s\S]*?-----END CERTIFICATE-----/g;
|
||||
const blocks = source.match(pattern) ?? [];
|
||||
if (blocks.length < 1 || source.replace(pattern, '').trim().length !== 0) {
|
||||
fail('QL3_POSTGRES_CA_INVALID_PEM');
|
||||
}
|
||||
if (blocks.length > POSTGRES_CA_MAX_CERTIFICATES) {
|
||||
fail('QL3_POSTGRES_CA_TOO_MANY_CERTIFICATES');
|
||||
}
|
||||
|
||||
const fingerprints = new Set<string>();
|
||||
const normalized: string[] = [];
|
||||
for (const block of blocks) {
|
||||
let certificate: X509Certificate;
|
||||
try {
|
||||
certificate = new X509Certificate(block);
|
||||
} catch {
|
||||
fail('QL3_POSTGRES_CA_INVALID_PEM');
|
||||
}
|
||||
if (!certificate.ca) fail('QL3_POSTGRES_CA_NOT_CA');
|
||||
if (fingerprints.has(certificate.fingerprint256)) {
|
||||
fail('QL3_POSTGRES_CA_DUPLICATE_CERTIFICATE');
|
||||
}
|
||||
fingerprints.add(certificate.fingerprint256);
|
||||
normalized.push(certificate.toString().trimEnd());
|
||||
}
|
||||
return Object.freeze({
|
||||
bundle: `${normalized.join('\n')}\n`,
|
||||
fingerprints256: Object.freeze([...fingerprints].sort()),
|
||||
});
|
||||
}
|
||||
|
||||
export function loadPostgresCertificateAuthorityFile(filePath: string): string {
|
||||
return inspectPostgresCertificateAuthorityFile(filePath).bundle;
|
||||
}
|
||||
@@ -0,0 +1,142 @@
|
||||
import type { PostgresConnectionOptions } from './pool';
|
||||
|
||||
export type PostgresConnectionEnvironment = Readonly<
|
||||
Record<string, string | undefined>
|
||||
>;
|
||||
|
||||
export interface PostgresConnectionEnvironmentKeys {
|
||||
readonly connectionString: string;
|
||||
readonly host: string;
|
||||
readonly port: string;
|
||||
readonly database: string;
|
||||
readonly user: string;
|
||||
readonly password: string;
|
||||
}
|
||||
|
||||
export class PostgresConnectionEnvironmentError extends TypeError {
|
||||
readonly code = 'QL3_POSTGRES_CONNECTION_ENVIRONMENT_INVALID';
|
||||
|
||||
constructor(message: string) {
|
||||
super(`PostgreSQL connection environment is invalid: ${message}`);
|
||||
this.name = 'PostgresConnectionEnvironmentError';
|
||||
}
|
||||
}
|
||||
|
||||
function present(value: string | undefined): boolean {
|
||||
return value !== undefined && value !== '';
|
||||
}
|
||||
|
||||
function bounded(
|
||||
environment: PostgresConnectionEnvironment,
|
||||
name: string,
|
||||
maximumLength: number,
|
||||
): string | undefined {
|
||||
const value = environment[name];
|
||||
if (!present(value)) return undefined;
|
||||
if (value!.length > maximumLength || /[\0\r\n]/.test(value!)) {
|
||||
throw new PostgresConnectionEnvironmentError(`${name} is invalid`);
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
function assertConnectionString(value: string, name: string): void {
|
||||
let url: URL;
|
||||
try {
|
||||
url = new URL(value);
|
||||
} catch {
|
||||
throw new PostgresConnectionEnvironmentError(`${name} must be a valid URL`);
|
||||
}
|
||||
if (url.protocol !== 'postgres:' && url.protocol !== 'postgresql:') {
|
||||
throw new PostgresConnectionEnvironmentError(
|
||||
`${name} must use postgres or postgresql`,
|
||||
);
|
||||
}
|
||||
for (const parameter of url.searchParams.keys()) {
|
||||
if (parameter.toLowerCase().startsWith('ssl')) {
|
||||
throw new PostgresConnectionEnvironmentError(
|
||||
'PostgreSQL TLS query parameters are forbidden',
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function port(value: string | undefined, name: string): number {
|
||||
if (value === undefined) return 5432;
|
||||
if (!/^\d+$/.test(value)) {
|
||||
throw new PostgresConnectionEnvironmentError(`${name} must be an integer`);
|
||||
}
|
||||
const parsed = Number(value);
|
||||
if (!Number.isSafeInteger(parsed) || parsed < 1 || parsed > 65_535) {
|
||||
throw new PostgresConnectionEnvironmentError(
|
||||
`${name} must be between 1 and 65535`,
|
||||
);
|
||||
}
|
||||
return parsed;
|
||||
}
|
||||
|
||||
function identifier(value: string | undefined, name: string): string {
|
||||
if (value === undefined || !/^[A-Za-z_][A-Za-z0-9_$]{0,62}$/.test(value)) {
|
||||
throw new PostgresConnectionEnvironmentError(
|
||||
`${name} must be a safe PostgreSQL identifier`,
|
||||
);
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
/**
|
||||
* Loads either one legacy connection URL or an exact discrete credential set.
|
||||
*
|
||||
* Discrete values avoid duplicating Kubernetes Secret passwords inside a DSN
|
||||
* and let an operator-managed DatabaseRole Secret remain the sole password
|
||||
* authority. The two forms are deliberately mutually exclusive.
|
||||
*/
|
||||
export function loadPostgresConnectionEnvironment(
|
||||
environment: PostgresConnectionEnvironment,
|
||||
keys: PostgresConnectionEnvironmentKeys,
|
||||
): PostgresConnectionOptions {
|
||||
if (
|
||||
!environment ||
|
||||
typeof environment !== 'object' ||
|
||||
Array.isArray(environment)
|
||||
) {
|
||||
throw new PostgresConnectionEnvironmentError(
|
||||
'environment must be an object',
|
||||
);
|
||||
}
|
||||
const connectionString = bounded(environment, keys.connectionString, 4096);
|
||||
const host = bounded(environment, keys.host, 253);
|
||||
const portValue = bounded(environment, keys.port, 5);
|
||||
const database = bounded(environment, keys.database, 63);
|
||||
const user = bounded(environment, keys.user, 63);
|
||||
const password = bounded(environment, keys.password, 1024);
|
||||
const discreteValues = [host, portValue, database, user, password];
|
||||
const hasDiscreteValue = discreteValues.some((value) => value !== undefined);
|
||||
|
||||
if (connectionString !== undefined) {
|
||||
if (hasDiscreteValue) {
|
||||
throw new PostgresConnectionEnvironmentError(
|
||||
`${keys.connectionString} cannot be combined with discrete PostgreSQL connection keys`,
|
||||
);
|
||||
}
|
||||
assertConnectionString(connectionString, keys.connectionString);
|
||||
return Object.freeze({ connectionString });
|
||||
}
|
||||
|
||||
if (
|
||||
host === undefined ||
|
||||
database === undefined ||
|
||||
user === undefined ||
|
||||
password === undefined
|
||||
) {
|
||||
throw new PostgresConnectionEnvironmentError(
|
||||
`use ${keys.connectionString} or provide ${keys.host}, ${keys.database}, ${keys.user}, and ${keys.password}`,
|
||||
);
|
||||
}
|
||||
return Object.freeze({
|
||||
host,
|
||||
port: port(portValue, keys.port),
|
||||
database: identifier(database, keys.database),
|
||||
user: identifier(user, keys.user),
|
||||
password,
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,548 @@
|
||||
import {
|
||||
Pool,
|
||||
type PoolClient,
|
||||
type PoolConfig,
|
||||
type QueryResultRow,
|
||||
} from 'pg';
|
||||
import { isIP } from 'node:net';
|
||||
import type {
|
||||
PostgresClient,
|
||||
PostgresDatabaseResource,
|
||||
PostgresPool,
|
||||
PostgresQueryResult,
|
||||
} from '@qinglong/runtime-core';
|
||||
|
||||
const DEFAULT_RUNTIME_APPLICATION_NAME = 'qinglong-cluster-runtime';
|
||||
const DEFAULT_ADMIN_APPLICATION_NAME = 'qinglong-cluster-admin';
|
||||
const DEFAULT_AI_CREDENTIAL_TESTER_APPLICATION_NAME =
|
||||
'qinglong-ai-credential-tester';
|
||||
const DEFAULT_AUTOMATION_MANAGER_APPLICATION_NAME =
|
||||
'qinglong-automation-manager';
|
||||
const DEFAULT_APPROVAL_MANAGER_APPLICATION_NAME = 'qinglong-approval-manager';
|
||||
const DEFAULT_PACKAGE_MANAGER_APPLICATION_NAME = 'qinglong-package-manager';
|
||||
const DEFAULT_PACKAGE_EXECUTOR_APPLICATION_NAME = 'qinglong-package-executor';
|
||||
const DEFAULT_WORKER_CREDENTIAL_MANAGER_APPLICATION_NAME =
|
||||
'qinglong-worker-credential-manager';
|
||||
const DEFAULT_WORKER_CREDENTIAL_EXECUTOR_APPLICATION_NAME =
|
||||
'qinglong-worker-credential-executor';
|
||||
const DEFAULT_WORKER_INGRESS_APPLICATION_NAME = 'qinglong-worker-ingress';
|
||||
const DEFAULT_MIGRATION_APPLICATION_NAME = 'qinglong-cluster-migration';
|
||||
const DEFAULT_RUNTIME_POOL_SIZE = 8;
|
||||
const DEFAULT_ADMIN_POOL_SIZE = 2;
|
||||
const DEFAULT_WORKER_INGRESS_POOL_SIZE = 4;
|
||||
const DEFAULT_MIGRATION_POOL_SIZE = 1;
|
||||
const MAX_RUNTIME_POOL_SIZE = 64;
|
||||
const MAX_ADMIN_POOL_SIZE = 4;
|
||||
const MAX_WORKER_INGRESS_POOL_SIZE = 16;
|
||||
const MAX_MIGRATION_POOL_SIZE = 4;
|
||||
const DEFAULT_CONNECTION_TIMEOUT_MS = 5_000;
|
||||
const DEFAULT_IDLE_TIMEOUT_MS = 30_000;
|
||||
const DEFAULT_MAX_LIFETIME_SECONDS = 300;
|
||||
const DEFAULT_RUNTIME_STATEMENT_TIMEOUT_MS = 5_000;
|
||||
const DEFAULT_MIGRATION_STATEMENT_TIMEOUT_MS = 15_000;
|
||||
const DEFAULT_RUNTIME_LOCK_TIMEOUT_MS = 1_000;
|
||||
const DEFAULT_MIGRATION_LOCK_TIMEOUT_MS = 5_000;
|
||||
const DEFAULT_RUNTIME_IDLE_TRANSACTION_TIMEOUT_MS = 10_000;
|
||||
const DEFAULT_MIGRATION_IDLE_TRANSACTION_TIMEOUT_MS = 15_000;
|
||||
const FIXED_SEARCH_PATH = '-c search_path=ql3,pg_catalog';
|
||||
|
||||
export const POSTGRES_AVAILABILITY_SQLSTATE_CLASSES = Object.freeze([
|
||||
'08',
|
||||
] as const);
|
||||
|
||||
export const POSTGRES_AVAILABILITY_SQLSTATES = Object.freeze([
|
||||
'08000',
|
||||
'08001',
|
||||
'08003',
|
||||
'08004',
|
||||
'08006',
|
||||
'08007',
|
||||
'08P01',
|
||||
'25006',
|
||||
'57P01',
|
||||
'57P02',
|
||||
'57P03',
|
||||
'57P04',
|
||||
] as const);
|
||||
|
||||
export const POSTGRES_AVAILABILITY_SYSTEM_ERROR_CODES = Object.freeze([
|
||||
'EAI_AGAIN',
|
||||
'ECONNREFUSED',
|
||||
'ECONNRESET',
|
||||
'EHOSTUNREACH',
|
||||
'ENETUNREACH',
|
||||
'ENOTFOUND',
|
||||
'EPIPE',
|
||||
'ETIMEDOUT',
|
||||
] as const);
|
||||
|
||||
const POSTGRES_AVAILABILITY_CODES = new Set<string>([
|
||||
...POSTGRES_AVAILABILITY_SQLSTATES,
|
||||
...POSTGRES_AVAILABILITY_SYSTEM_ERROR_CODES,
|
||||
]);
|
||||
|
||||
export type PostgresDatabaseRole =
|
||||
| 'runtime'
|
||||
| 'ai-maintenance'
|
||||
| 'ai-credential-manager'
|
||||
| 'ai-credential-tester'
|
||||
| 'admin'
|
||||
| 'automation-manager'
|
||||
| 'approval-manager'
|
||||
| 'package-manager'
|
||||
| 'package-executor'
|
||||
| 'worker-credential-manager'
|
||||
| 'worker-credential-executor'
|
||||
| 'worker-ingress'
|
||||
| 'migration';
|
||||
|
||||
export type PostgresTlsOptions =
|
||||
| Readonly<{
|
||||
mode: 'verify-full';
|
||||
ca?: string;
|
||||
servername: string;
|
||||
}>
|
||||
| Readonly<{
|
||||
mode: 'disable';
|
||||
}>;
|
||||
|
||||
export interface PostgresConnectionOptions {
|
||||
readonly connectionString?: string;
|
||||
readonly host?: string;
|
||||
readonly port?: number;
|
||||
readonly database?: string;
|
||||
readonly user?: string;
|
||||
readonly password?: string | (() => string | Promise<string>);
|
||||
readonly tls?: PostgresTlsOptions;
|
||||
}
|
||||
|
||||
export interface PostgresPoolOptions {
|
||||
readonly applicationName?: string;
|
||||
readonly maxConnections?: number;
|
||||
readonly connectionTimeoutMs?: number;
|
||||
readonly idleTimeoutMs?: number;
|
||||
readonly maxLifetimeSeconds?: number;
|
||||
readonly keepAliveInitialDelayMs?: number;
|
||||
}
|
||||
|
||||
export interface OpenPostgresDatabaseOptions {
|
||||
readonly role: PostgresDatabaseRole;
|
||||
readonly connection: PostgresConnectionOptions;
|
||||
readonly pool?: PostgresPoolOptions;
|
||||
readonly onPoolError: (error: Error) => void;
|
||||
}
|
||||
|
||||
export function isPostgresTlsDnsServername(value: unknown): value is string {
|
||||
if (
|
||||
typeof value !== 'string' ||
|
||||
value.length < 3 ||
|
||||
value.length > 253 ||
|
||||
isIP(value) !== 0
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
const labels = value.split('.');
|
||||
return (
|
||||
labels.length >= 2 &&
|
||||
labels.every((label) =>
|
||||
/^[A-Za-z0-9](?:[A-Za-z0-9-]{0,61}[A-Za-z0-9])?$/.test(label),
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
export type QingLongPostgresQueryResult<
|
||||
TRow extends Record<string, unknown> = Record<string, unknown>,
|
||||
> = PostgresQueryResult<TRow>;
|
||||
|
||||
export type QingLongPostgresClient = PostgresClient;
|
||||
|
||||
export type QingLongPostgresPool = PostgresPool;
|
||||
|
||||
export type QingLongPostgresDatabaseResource = PostgresDatabaseResource;
|
||||
|
||||
export function isPostgresAvailabilityError(error: unknown): error is Error {
|
||||
if (!(error instanceof Error)) return false;
|
||||
const code = (error as Error & { readonly code?: unknown }).code;
|
||||
if (typeof code !== 'string') return false;
|
||||
return (
|
||||
POSTGRES_AVAILABILITY_CODES.has(code) ||
|
||||
(code.length === 5 &&
|
||||
POSTGRES_AVAILABILITY_SQLSTATE_CLASSES.some((prefix) =>
|
||||
code.startsWith(prefix),
|
||||
))
|
||||
);
|
||||
}
|
||||
|
||||
function reportPostgresAvailabilityError(
|
||||
error: unknown,
|
||||
listener: (error: Error) => void,
|
||||
force = false,
|
||||
): void {
|
||||
if (!(error instanceof Error)) return;
|
||||
if (!force && !isPostgresAvailabilityError(error)) return;
|
||||
try {
|
||||
listener(error);
|
||||
} catch {
|
||||
// Availability reporting must never replace the original query/Pool error.
|
||||
}
|
||||
}
|
||||
|
||||
function requireIntegerInRange(
|
||||
name: string,
|
||||
value: number,
|
||||
minimum: number,
|
||||
maximum: number,
|
||||
): number {
|
||||
if (!Number.isInteger(value) || value < minimum || value > maximum) {
|
||||
throw new RangeError(
|
||||
`${name} must be an integer between ${minimum} and ${maximum}`,
|
||||
);
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
function validateApplicationName(value: string): string {
|
||||
if (!/^[A-Za-z0-9][A-Za-z0-9._:-]{0,62}$/.test(value)) {
|
||||
throw new TypeError(
|
||||
'PostgreSQL applicationName must contain 1-63 safe identifier characters',
|
||||
);
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
function assertNoConnectionStringTlsOverrides(connectionString: string): void {
|
||||
let parsed: URL;
|
||||
try {
|
||||
parsed = new URL(connectionString);
|
||||
} catch {
|
||||
throw new TypeError('PostgreSQL connectionString must be a valid URL');
|
||||
}
|
||||
if (parsed.protocol !== 'postgres:' && parsed.protocol !== 'postgresql:') {
|
||||
throw new TypeError(
|
||||
'PostgreSQL connectionString must use postgres or postgresql',
|
||||
);
|
||||
}
|
||||
for (const key of parsed.searchParams.keys()) {
|
||||
if (key.toLowerCase().startsWith('ssl')) {
|
||||
throw new TypeError(
|
||||
'PostgreSQL TLS settings must use the explicit tls option',
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function tlsConfig(tls: PostgresTlsOptions | undefined): PoolConfig['ssl'] {
|
||||
if (tls?.mode === 'disable') return false;
|
||||
if (tls !== undefined && !isPostgresTlsDnsServername(tls.servername)) {
|
||||
throw new TypeError(
|
||||
'PostgreSQL verify-full TLS requires an explicit DNS servername',
|
||||
);
|
||||
}
|
||||
return {
|
||||
rejectUnauthorized: true,
|
||||
...(tls?.ca === undefined ? {} : { ca: tls.ca }),
|
||||
...(tls === undefined ? {} : { servername: tls.servername }),
|
||||
};
|
||||
}
|
||||
|
||||
function buildPoolConfig(options: OpenPostgresDatabaseOptions): PoolConfig {
|
||||
if (
|
||||
![
|
||||
'runtime',
|
||||
'ai-maintenance',
|
||||
'ai-credential-manager',
|
||||
'ai-credential-tester',
|
||||
'admin',
|
||||
'automation-manager',
|
||||
'approval-manager',
|
||||
'package-manager',
|
||||
'package-executor',
|
||||
'worker-credential-manager',
|
||||
'worker-credential-executor',
|
||||
'worker-ingress',
|
||||
'migration',
|
||||
].includes(options.role)
|
||||
) {
|
||||
throw new TypeError('PostgreSQL database role is invalid');
|
||||
}
|
||||
const connection = options.connection;
|
||||
const hasConnectionString = connection.connectionString !== undefined;
|
||||
const hasDiscreteAddress =
|
||||
connection.host !== undefined ||
|
||||
connection.port !== undefined ||
|
||||
connection.database !== undefined ||
|
||||
connection.user !== undefined;
|
||||
if (hasConnectionString && hasDiscreteAddress) {
|
||||
throw new TypeError(
|
||||
'PostgreSQL connectionString cannot be combined with host, port, database, or user',
|
||||
);
|
||||
}
|
||||
if (hasConnectionString) {
|
||||
assertNoConnectionStringTlsOverrides(connection.connectionString!);
|
||||
} else if (
|
||||
connection.host === undefined ||
|
||||
connection.database === undefined ||
|
||||
connection.user === undefined
|
||||
) {
|
||||
throw new TypeError(
|
||||
'PostgreSQL connection requires connectionString or host, database, and user',
|
||||
);
|
||||
}
|
||||
|
||||
const isRuntime = options.role === 'runtime';
|
||||
const isAiMaintenance = options.role === 'ai-maintenance';
|
||||
const isAiCredentialManager = options.role === 'ai-credential-manager';
|
||||
const isAiCredentialTester = options.role === 'ai-credential-tester';
|
||||
const isAdmin = options.role === 'admin';
|
||||
const isAutomationManager = options.role === 'automation-manager';
|
||||
const isApprovalManager = options.role === 'approval-manager';
|
||||
const isPackageManager = options.role === 'package-manager';
|
||||
const isPackageExecutor = options.role === 'package-executor';
|
||||
const isWorkerCredentialManager =
|
||||
options.role === 'worker-credential-manager';
|
||||
const isWorkerCredentialExecutor =
|
||||
options.role === 'worker-credential-executor';
|
||||
const isShortLivedAuthority =
|
||||
isAiMaintenance ||
|
||||
isAiCredentialManager ||
|
||||
isAiCredentialTester ||
|
||||
isAdmin ||
|
||||
isAutomationManager ||
|
||||
isApprovalManager ||
|
||||
isPackageManager ||
|
||||
isPackageExecutor ||
|
||||
isWorkerCredentialManager ||
|
||||
isWorkerCredentialExecutor;
|
||||
const isWorkerIngress = options.role === 'worker-ingress';
|
||||
const maximumPoolSize = isRuntime
|
||||
? MAX_RUNTIME_POOL_SIZE
|
||||
: isShortLivedAuthority
|
||||
? MAX_ADMIN_POOL_SIZE
|
||||
: isWorkerIngress
|
||||
? MAX_WORKER_INGRESS_POOL_SIZE
|
||||
: MAX_MIGRATION_POOL_SIZE;
|
||||
const maxConnections = requireIntegerInRange(
|
||||
'PostgreSQL maxConnections',
|
||||
options.pool?.maxConnections ??
|
||||
(isRuntime
|
||||
? DEFAULT_RUNTIME_POOL_SIZE
|
||||
: isShortLivedAuthority
|
||||
? DEFAULT_ADMIN_POOL_SIZE
|
||||
: isWorkerIngress
|
||||
? DEFAULT_WORKER_INGRESS_POOL_SIZE
|
||||
: DEFAULT_MIGRATION_POOL_SIZE),
|
||||
1,
|
||||
maximumPoolSize,
|
||||
);
|
||||
const connectionTimeoutMs = requireIntegerInRange(
|
||||
'PostgreSQL connectionTimeoutMs',
|
||||
options.pool?.connectionTimeoutMs ?? DEFAULT_CONNECTION_TIMEOUT_MS,
|
||||
100,
|
||||
60_000,
|
||||
);
|
||||
const idleTimeoutMs = requireIntegerInRange(
|
||||
'PostgreSQL idleTimeoutMs',
|
||||
options.pool?.idleTimeoutMs ?? DEFAULT_IDLE_TIMEOUT_MS,
|
||||
1_000,
|
||||
300_000,
|
||||
);
|
||||
const maxLifetimeSeconds = requireIntegerInRange(
|
||||
'PostgreSQL maxLifetimeSeconds',
|
||||
options.pool?.maxLifetimeSeconds ?? DEFAULT_MAX_LIFETIME_SECONDS,
|
||||
30,
|
||||
3_600,
|
||||
);
|
||||
const keepAliveInitialDelayMs = requireIntegerInRange(
|
||||
'PostgreSQL keepAliveInitialDelayMs',
|
||||
options.pool?.keepAliveInitialDelayMs ?? 1_000,
|
||||
0,
|
||||
60_000,
|
||||
);
|
||||
const applicationName = validateApplicationName(
|
||||
options.pool?.applicationName ??
|
||||
(isRuntime
|
||||
? DEFAULT_RUNTIME_APPLICATION_NAME
|
||||
: isAdmin
|
||||
? DEFAULT_ADMIN_APPLICATION_NAME
|
||||
: isAiCredentialTester
|
||||
? DEFAULT_AI_CREDENTIAL_TESTER_APPLICATION_NAME
|
||||
: isAutomationManager
|
||||
? DEFAULT_AUTOMATION_MANAGER_APPLICATION_NAME
|
||||
: isApprovalManager
|
||||
? DEFAULT_APPROVAL_MANAGER_APPLICATION_NAME
|
||||
: isPackageManager
|
||||
? DEFAULT_PACKAGE_MANAGER_APPLICATION_NAME
|
||||
: isPackageExecutor
|
||||
? DEFAULT_PACKAGE_EXECUTOR_APPLICATION_NAME
|
||||
: isWorkerCredentialManager
|
||||
? DEFAULT_WORKER_CREDENTIAL_MANAGER_APPLICATION_NAME
|
||||
: isWorkerCredentialExecutor
|
||||
? DEFAULT_WORKER_CREDENTIAL_EXECUTOR_APPLICATION_NAME
|
||||
: isWorkerIngress
|
||||
? DEFAULT_WORKER_INGRESS_APPLICATION_NAME
|
||||
: DEFAULT_MIGRATION_APPLICATION_NAME),
|
||||
);
|
||||
|
||||
return {
|
||||
...(connection.connectionString === undefined
|
||||
? {
|
||||
host: connection.host,
|
||||
...(connection.port === undefined
|
||||
? {}
|
||||
: {
|
||||
port: requireIntegerInRange(
|
||||
'PostgreSQL port',
|
||||
connection.port,
|
||||
1,
|
||||
65_535,
|
||||
),
|
||||
}),
|
||||
database: connection.database,
|
||||
user: connection.user,
|
||||
}
|
||||
: { connectionString: connection.connectionString }),
|
||||
...(connection.password === undefined
|
||||
? {}
|
||||
: { password: connection.password }),
|
||||
ssl: tlsConfig(connection.tls),
|
||||
application_name: applicationName,
|
||||
options: FIXED_SEARCH_PATH,
|
||||
max: maxConnections,
|
||||
min: 0,
|
||||
connectionTimeoutMillis: connectionTimeoutMs,
|
||||
idleTimeoutMillis: idleTimeoutMs,
|
||||
maxLifetimeSeconds,
|
||||
keepAlive: true,
|
||||
keepAliveInitialDelayMillis: keepAliveInitialDelayMs,
|
||||
allowExitOnIdle: false,
|
||||
statement_timeout:
|
||||
isRuntime || isWorkerIngress
|
||||
? DEFAULT_RUNTIME_STATEMENT_TIMEOUT_MS
|
||||
: DEFAULT_MIGRATION_STATEMENT_TIMEOUT_MS,
|
||||
query_timeout:
|
||||
isRuntime || isWorkerIngress
|
||||
? DEFAULT_RUNTIME_STATEMENT_TIMEOUT_MS + 1_000
|
||||
: DEFAULT_MIGRATION_STATEMENT_TIMEOUT_MS + 1_000,
|
||||
lock_timeout:
|
||||
isRuntime || isWorkerIngress
|
||||
? DEFAULT_RUNTIME_LOCK_TIMEOUT_MS
|
||||
: DEFAULT_MIGRATION_LOCK_TIMEOUT_MS,
|
||||
idle_in_transaction_session_timeout:
|
||||
isRuntime || isWorkerIngress
|
||||
? DEFAULT_RUNTIME_IDLE_TRANSACTION_TIMEOUT_MS
|
||||
: DEFAULT_MIGRATION_IDLE_TRANSACTION_TIMEOUT_MS,
|
||||
};
|
||||
}
|
||||
|
||||
async function query<TRow extends Record<string, unknown>>(
|
||||
queryable: Pick<Pool | PoolClient, 'query'>,
|
||||
text: string,
|
||||
values?: readonly unknown[],
|
||||
onAvailabilityError: (error: Error) => void = () => {},
|
||||
): Promise<PostgresQueryResult<TRow>> {
|
||||
try {
|
||||
const result = await queryable.query<TRow & QueryResultRow>(
|
||||
text,
|
||||
values === undefined ? undefined : [...values],
|
||||
);
|
||||
return { rows: result.rows, rowCount: result.rowCount };
|
||||
} catch (error) {
|
||||
reportPostgresAvailabilityError(error, onAvailabilityError);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
class PgClientBinding implements PostgresClient {
|
||||
private readonly onClientError: (error: Error) => void;
|
||||
|
||||
constructor(
|
||||
private readonly client: PoolClient,
|
||||
private readonly onAvailabilityError: (error: Error) => void,
|
||||
) {
|
||||
this.onClientError = (error) =>
|
||||
reportPostgresAvailabilityError(error, this.onAvailabilityError, true);
|
||||
this.client.on('error', this.onClientError);
|
||||
}
|
||||
|
||||
query<TRow extends Record<string, unknown> = Record<string, unknown>>(
|
||||
text: string,
|
||||
values?: readonly unknown[],
|
||||
): Promise<PostgresQueryResult<TRow>> {
|
||||
return query<TRow>(this.client, text, values, this.onAvailabilityError);
|
||||
}
|
||||
|
||||
release(): void {
|
||||
this.client.removeListener('error', this.onClientError);
|
||||
this.client.release();
|
||||
}
|
||||
}
|
||||
|
||||
export class PgPoolBinding implements PostgresPool {
|
||||
constructor(
|
||||
private readonly driverPool: Pool,
|
||||
private readonly onAvailabilityError: (error: Error) => void = () => {},
|
||||
) {}
|
||||
|
||||
query<TRow extends Record<string, unknown> = Record<string, unknown>>(
|
||||
text: string,
|
||||
values?: readonly unknown[],
|
||||
): Promise<PostgresQueryResult<TRow>> {
|
||||
return query<TRow>(this.driverPool, text, values, this.onAvailabilityError);
|
||||
}
|
||||
|
||||
async connect(): Promise<PostgresClient> {
|
||||
try {
|
||||
return new PgClientBinding(
|
||||
await this.driverPool.connect(),
|
||||
this.onAvailabilityError,
|
||||
);
|
||||
} catch (error) {
|
||||
reportPostgresAvailabilityError(error, this.onAvailabilityError);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
class PgDatabaseResource implements PostgresDatabaseResource {
|
||||
readonly pool: PostgresPool;
|
||||
private closePromise: Promise<void> | undefined;
|
||||
|
||||
constructor(
|
||||
private readonly driverPool: Pool,
|
||||
onAvailabilityError: (error: Error) => void,
|
||||
) {
|
||||
this.pool = new PgPoolBinding(driverPool, onAvailabilityError);
|
||||
}
|
||||
|
||||
close(): Promise<void> {
|
||||
this.closePromise ??= this.driverPool.end();
|
||||
return this.closePromise;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Builds a lazy database opener for the core cluster bootstrap. Constructing
|
||||
* the opener, and invoking it, do not acquire a PostgreSQL connection; the
|
||||
* first readiness or repository query triggers pg.Pool connection creation.
|
||||
*/
|
||||
export function createPostgresDatabaseOpener(
|
||||
options: OpenPostgresDatabaseOptions,
|
||||
): () => Promise<PostgresDatabaseResource> {
|
||||
const config = buildPoolConfig(options);
|
||||
return async () => {
|
||||
const driverPool = new Pool(config);
|
||||
driverPool.on('connect', (client) => {
|
||||
// Keep one listener for the physical connection lifetime. pg swaps its
|
||||
// own idle listener while a client is checked out, and a failover can
|
||||
// otherwise emit a second socket error after a checkout binding has
|
||||
// released but before Pool teardown has completed.
|
||||
client.on('error', (error) =>
|
||||
reportPostgresAvailabilityError(error, options.onPoolError, true),
|
||||
);
|
||||
});
|
||||
driverPool.on('error', (error) =>
|
||||
reportPostgresAvailabilityError(error, options.onPoolError, true),
|
||||
);
|
||||
return new PgDatabaseResource(driverPool, options.onPoolError);
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user