diff --git a/Dockerfile b/Dockerfile index f461c37..772fcee 100644 --- a/Dockerfile +++ b/Dockerfile @@ -48,6 +48,7 @@ WORKDIR /app COPY --from=builder /app/dist ./dist COPY --from=builder /app/node_modules ./node_modules COPY --from=builder /app/package*.json ./ +COPY --from=builder /app/prisma.config.ts ./prisma.config.ts COPY --from=builder /app/prisma ./prisma # Copy entrypoint scripts and make them executable diff --git a/docker-entrypoint.sh b/docker-entrypoint.sh index 25b32c6..635357b 100644 --- a/docker-entrypoint.sh +++ b/docker-entrypoint.sh @@ -39,6 +39,29 @@ log_warn() { echo -e "${YELLOW}[ENTRYPOINT] WARN: $1${NC}" } +normalize_quoted_env_value() { + local value="${1-}" + + # Trim leading/trailing whitespace first. + value="${value#"${value%%[![:space:]]*}"}" + value="${value%"${value##*[![:space:]]}"}" + + if [ -z "$value" ]; then + echo "" + return + fi + + local first_char="${value:0:1}" + local last_char="${value: -1}" + if { [ "$first_char" = '"' ] && [ "$last_char" = '"' ]; } || { [ "$first_char" = "'" ] && [ "$last_char" = "'" ]; }; then + value="${value:1:${#value}-2}" + value="${value#"${value%%[![:space:]]*}"}" + value="${value%"${value##*[![:space:]]}"}" + fi + + echo "$value" +} + normalize_exit_code() { local exit_code="${1:-1}" @@ -368,6 +391,15 @@ monitor_processes() { # Main execution log_info "Starting LyttleNGINX container..." + +if [ -n "${DATABASE_URL:-}" ]; then + NORMALIZED_DATABASE_URL="$(normalize_quoted_env_value "$DATABASE_URL")" + if [ "$NORMALIZED_DATABASE_URL" != "$DATABASE_URL" ]; then + log_warn "DATABASE_URL contained wrapping quotes; normalizing it for Prisma and application startup" + fi + export DATABASE_URL="$NORMALIZED_DATABASE_URL" +fi + log_info "Hostname: $(hostname)" log_info "Instance: ${HOSTNAME:-unknown}" log_info "Node version: $(node --version)" diff --git a/prisma.config.ts b/prisma.config.ts index d86931f..2762e41 100644 --- a/prisma.config.ts +++ b/prisma.config.ts @@ -1,14 +1,39 @@ +// @ts-nocheck import { defineConfig } from 'prisma/config'; +import * as process from 'node:process'; const DATABASE_URL_FALLBACK = 'postgresql://postgres:postgres@127.0.0.1:5432/lyttle_nginx?schema=public'; +function normalizePossiblyQuotedEnvValue(value: string | undefined): string | undefined { + if (typeof value !== 'string') { + return undefined; + } + + const trimmed = value.trim(); + if (!trimmed) { + return undefined; + } + + if ( + ((trimmed.startsWith('"') && trimmed.endsWith('"')) || + (trimmed.startsWith("'") && trimmed.endsWith("'"))) && + trimmed.length >= 2 + ) { + return trimmed.slice(1, -1).trim() || undefined; + } + + return trimmed; +} + export default defineConfig({ schema: 'prisma/schema.prisma', migrations: { path: 'prisma/migrations', }, datasource: { - url: process.env['DATABASE_URL'] || DATABASE_URL_FALLBACK, + url: + normalizePossiblyQuotedEnvValue(process.env['DATABASE_URL']) || + DATABASE_URL_FALLBACK, }, }); diff --git a/src/prisma/prisma.service.ts b/src/prisma/prisma.service.ts index 210e4d6..dc2e716 100644 --- a/src/prisma/prisma.service.ts +++ b/src/prisma/prisma.service.ts @@ -6,6 +6,29 @@ import { } from '@nestjs/common'; import { PrismaClient } from '@prisma/client'; +function normalizePossiblyQuotedEnvValue( + value: string | undefined, +): string | undefined { + if (typeof value !== 'string') { + return undefined; + } + + const trimmed = value.trim(); + if (!trimmed) { + return undefined; + } + + if ( + ((trimmed.startsWith('"') && trimmed.endsWith('"')) || + (trimmed.startsWith("'") && trimmed.endsWith("'"))) && + trimmed.length >= 2 + ) { + return trimmed.slice(1, -1).trim() || undefined; + } + + return trimmed; +} + @Injectable() export class PrismaService extends PrismaClient @@ -16,7 +39,7 @@ export class PrismaService constructor() { // Configure connection pool to prevent connection leaks // Parse DATABASE_URL and add connection pool parameters if not present - const databaseUrl = process.env.DATABASE_URL; + const databaseUrl = normalizePossiblyQuotedEnvValue(process.env.DATABASE_URL); if (databaseUrl) { const url = new URL(databaseUrl); const configuredPoolSize = Number.parseInt( diff --git a/test/test-harness.config.js b/test/test-harness.config.js index 313e6b4..c2d018b 100644 --- a/test/test-harness.config.js +++ b/test/test-harness.config.js @@ -10,6 +10,7 @@ const UNIT_TEST_FILES = [ 'test/unit/structured-logging.test.js', 'test/unit/metrics-and-alerting.test.js', 'test/unit/auth-service-unit.test.js', + 'test/unit/env-normalization.test.js', 'test/unit/release-gating.test.js', ]; diff --git a/test/unit/env-normalization.test.js b/test/unit/env-normalization.test.js new file mode 100644 index 0000000..cb2c1a4 --- /dev/null +++ b/test/unit/env-normalization.test.js @@ -0,0 +1,70 @@ +const { describe, it } = require('node:test'); +const assert = require('node:assert/strict'); +const fs = require('node:fs/promises'); +const path = require('node:path'); + +const repoRoot = path.resolve(__dirname, '../..'); + +function normalizePossiblyQuotedEnvValue(value) { + if (typeof value !== 'string') { + return undefined; + } + + const trimmed = value.trim(); + if (!trimmed) { + return undefined; + } + + if ( + ((trimmed.startsWith('"') && trimmed.endsWith('"')) || + (trimmed.startsWith("'") && trimmed.endsWith("'"))) && + trimmed.length >= 2 + ) { + return trimmed.slice(1, -1).trim() || undefined; + } + + return trimmed; +} + +describe('environment value normalization', () => { + it('strips matching wrapping quotes and surrounding whitespace', () => { + assert.equal( + normalizePossiblyQuotedEnvValue(' "postgresql://user:pass@db:5432/app?schema=public" '), + 'postgresql://user:pass@db:5432/app?schema=public', + ); + assert.equal( + normalizePossiblyQuotedEnvValue(" 'postgresql://user:pass@db:5432/app?schema=public' "), + 'postgresql://user:pass@db:5432/app?schema=public', + ); + }); + + it('preserves already clean values and collapses empty input to undefined', () => { + assert.equal( + normalizePossiblyQuotedEnvValue('postgresql://user:pass@db:5432/app?schema=public'), + 'postgresql://user:pass@db:5432/app?schema=public', + ); + assert.equal(normalizePossiblyQuotedEnvValue(' '), undefined); + assert.equal(normalizePossiblyQuotedEnvValue(undefined), undefined); + }); + + it('keeps Prisma and entrypoint wired to normalize DATABASE_URL before use', async () => { + const prismaConfig = await fs.readFile( + path.join(repoRoot, 'prisma.config.ts'), + 'utf8', + ); + const prismaService = await fs.readFile( + path.join(repoRoot, 'src/prisma/prisma.service.ts'), + 'utf8', + ); + const entrypoint = await fs.readFile( + path.join(repoRoot, 'docker-entrypoint.sh'), + 'utf8', + ); + + assert.match(prismaConfig, /normalizePossiblyQuotedEnvValue/); + assert.match(prismaService, /normalizePossiblyQuotedEnvValue/); + assert.match(entrypoint, /normalize_quoted_env_value/); + assert.match(entrypoint, /DATABASE_URL contained wrapping quotes; normalizing it/); + }); +}); + diff --git a/test/unit/release-gating.test.js b/test/unit/release-gating.test.js index a5e285e..644a495 100644 --- a/test/unit/release-gating.test.js +++ b/test/unit/release-gating.test.js @@ -81,6 +81,7 @@ describe('CI/CD release gating', () => { assert.match(dockerfile, /RUN npm run verify:ci/); assert.match(dockerfile, /RUN npm prune --omit=dev/); assert.match(dockerfile, /COPY \. \.\nRUN npm run verify:ci/); + assert.match(dockerfile, /COPY --from=builder \/app\/prisma\.config\.ts \.\/prisma\.config\.ts/); assert.match(dockerfile, /apt-get update && \\\n\s+apt-get dist-upgrade -y && \\\n\s+apt-get install -y --no-install-recommends/); }); });