Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions Dockerfile
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
32 changes: 32 additions & 0 deletions docker-entrypoint.sh
Original file line number Diff line number Diff line change
Expand Up @@ -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}"

Expand Down Expand Up @@ -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)"
Expand Down
27 changes: 26 additions & 1 deletion prisma.config.ts
Original file line number Diff line number Diff line change
@@ -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,
},
});
25 changes: 24 additions & 1 deletion src/prisma/prisma.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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(
Expand Down
1 change: 1 addition & 0 deletions test/test-harness.config.js
Original file line number Diff line number Diff line change
Expand Up @@ -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',
];

Expand Down
70 changes: 70 additions & 0 deletions test/unit/env-normalization.test.js
Original file line number Diff line number Diff line change
@@ -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/);
});
});

1 change: 1 addition & 0 deletions test/unit/release-gating.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -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/);
});
});
Expand Down
Loading