From 56ff3ac9490a3c70517342dbee10a209425669c3 Mon Sep 17 00:00:00 2001 From: Pascal Breton <179493770+pascal-botpress@users.noreply.github.com> Date: Wed, 26 Aug 2026 15:41:19 -0400 Subject: [PATCH 1/2] chore: get rid of esbuild and pg-format --- cdk-postgresql/lib/database.handler.ts | 15 +++--- cdk-postgresql/lib/postgres.ts | 22 +++++--- cdk-postgresql/lib/provider.ts | 13 ++--- cdk-postgresql/lib/role.handler.ts | 15 ++++-- cdk-postgresql/package.json | 4 +- .../test/lambda.integration.test.ts | 52 +++++++++++++++++++ cdk-postgresql/tsdown.config.ts | 9 +++- package.json | 3 -- pnpm-lock.yaml | 24 +-------- 9 files changed, 101 insertions(+), 56 deletions(-) diff --git a/cdk-postgresql/lib/database.handler.ts b/cdk-postgresql/lib/database.handler.ts index 360a3b0..0c10088 100644 --- a/cdk-postgresql/lib/database.handler.ts +++ b/cdk-postgresql/lib/database.handler.ts @@ -1,4 +1,4 @@ -import format from "pg-format"; +import { escapeIdentifier } from "pg"; import { getConnectedClient, validateConnection, hashCode } from "./util"; import * as postgres from "./postgres"; @@ -117,14 +117,11 @@ export const deleteDatabase = async ( // First, drop all remaining DB connections // Sometimes, DB connections are still alive even though the ECS service has been deleted await client.query( - format( - "SELECT pg_terminate_backend(pg_stat_activity.pid) FROM pg_stat_activity WHERE datname=%L", - name - ) + "SELECT pg_terminate_backend(pg_stat_activity.pid) FROM pg_stat_activity WHERE datname=$1", + [name] ); // Then, drop the DB - await client.query(format("DROP DATABASE %I", name)); - // await client.query(format("REVOKE %I FROM %I", owner, connection.Username)); + await client.query(`DROP DATABASE ${escapeIdentifier(name)}`); await client.end(); }; @@ -136,6 +133,8 @@ export const updateDbOwner = async ( console.log(`Updating DB ${name} owner to ${owner}`); const client = await getConnectedClient(connection); - await client.query(format("ALTER DATABASE %I OWNER TO %I", name, owner)); + await client.query( + `ALTER DATABASE ${escapeIdentifier(name)} OWNER TO ${escapeIdentifier(owner)}` + ); await client.end(); }; diff --git a/cdk-postgresql/lib/postgres.ts b/cdk-postgresql/lib/postgres.ts index 4e5932b..89c41a7 100644 --- a/cdk-postgresql/lib/postgres.ts +++ b/cdk-postgresql/lib/postgres.ts @@ -1,6 +1,5 @@ import { VError } from "verror"; -import { Client, DatabaseError } from "pg"; -import format from "pg-format"; +import { Client, DatabaseError, escapeIdentifier, escapeLiteral } from "pg"; import * as util from "util"; const isDatabaseError = (e: any): e is DatabaseError => { @@ -14,7 +13,9 @@ export const createRole = async (props: { }) => { const { client, name, password } = props; - await client.query(format("CREATE USER %I WITH PASSWORD %L", name, password)); + await client.query( + `CREATE USER ${escapeIdentifier(name)} WITH PASSWORD ${escapeLiteral(password)}` + ); }; export const createDatabase = async (props: { @@ -24,8 +25,15 @@ export const createDatabase = async (props: { }) => { const { client, name, owner } = props; + const grantee = client.user; + if (!grantee) { + throw new VError("the connection has no user to grant the owner role to"); + } + try { - await client.query(format("GRANT %I TO %I", owner, client.user)); + await client.query( + `GRANT ${escapeIdentifier(owner)} TO ${escapeIdentifier(grantee)}` + ); } catch (e) { if (!util.types.isNativeError(e)) { throw e; @@ -34,7 +42,7 @@ export const createDatabase = async (props: { !isDatabaseError(e) || !( e.code === "0LP01" && - e.message === `role "${owner}" is a member of role "${client.user}"` + e.message === `role "${owner}" is a member of role "${grantee}"` ) ) { throw new VError(e, "unexpected error while creating grant"); @@ -43,5 +51,7 @@ export const createDatabase = async (props: { console.warn(e.message); } - return client.query(format("CREATE DATABASE %I WITH OWNER %I", name, owner)); + return client.query( + `CREATE DATABASE ${escapeIdentifier(name)} WITH OWNER ${escapeIdentifier(owner)}` + ); }; diff --git a/cdk-postgresql/lib/provider.ts b/cdk-postgresql/lib/provider.ts index 69ead7e..c7abcd4 100644 --- a/cdk-postgresql/lib/provider.ts +++ b/cdk-postgresql/lib/provider.ts @@ -2,8 +2,7 @@ import * as cdk from "aws-cdk-lib"; import { Construct } from "constructs"; import * as ec2 from "aws-cdk-lib/aws-ec2"; import * as secretsmanager from "aws-cdk-lib/aws-secretsmanager"; -import * as lambda from "aws-cdk-lib/aws-lambda-nodejs"; -import { Runtime } from "aws-cdk-lib/aws-lambda"; +import * as lambda from "aws-cdk-lib/aws-lambda"; import * as logs from "aws-cdk-lib/aws-logs"; import * as iam from "aws-cdk-lib/aws-iam"; import * as cr from "aws-cdk-lib/custom-resources"; @@ -97,12 +96,10 @@ export class Provider extends Construct implements iam.IGrantable { ? new ec2.SecurityGroup(this, "HandlerSecurityGroup", { vpc }) : undefined; const handlerSecurityGroups = handlerSecurityGroup ? [handlerSecurityGroup] : undefined; - const handler = new lambda.NodejsFunction(scope, "handler", { - entry: path.join(__dirname, "..", "dist", "handler.cjs"), - runtime: Runtime.NODEJS_24_X, - bundling: { - nodeModules: ["pg", "pg-format"], - }, + const handler = new lambda.Function(scope, "handler", { + code: lambda.Code.fromAsset(path.join(__dirname, "..", "dist", "lambda")), + handler: "index.handler", + runtime: lambda.Runtime.NODEJS_24_X, logRetention: logs.RetentionDays.ONE_MONTH, timeout: cdk.Duration.minutes(15), vpc, diff --git a/cdk-postgresql/lib/role.handler.ts b/cdk-postgresql/lib/role.handler.ts index 818fb99..0209ead 100644 --- a/cdk-postgresql/lib/role.handler.ts +++ b/cdk-postgresql/lib/role.handler.ts @@ -1,4 +1,4 @@ -import format from "pg-format"; +import { escapeIdentifier, escapeLiteral } from "pg"; import { CloudFormationCustomResourceEvent, @@ -111,7 +111,7 @@ export const deleteRole = async (connection: Connection, name: string) => { console.log("Deleting user", name); const client = await getConnectedClient(connection); - await client.query(format("DROP USER %I", name)); + await client.query(`DROP USER ${escapeIdentifier(name)}`); await client.end(); }; @@ -123,7 +123,9 @@ export const updateRoleName = async ( console.log(`Updating role name from ${oldName} to ${newName}`); const client = await getConnectedClient(connection); - await client.query(format("ALTER ROLE %I RENAME TO %I", oldName, newName)); + await client.query( + `ALTER ROLE ${escapeIdentifier(oldName)} RENAME TO ${escapeIdentifier(newName)}` + ); await client.end(); }; @@ -140,8 +142,13 @@ export const updateRolePassword = async (props: { const { SecretString: password } = await secretsmanager.getSecretValue({ SecretId: passwordArn, }); + if (!password) { + throw new Error("could not decrypt password"); + } - await client.query(format("ALTER USER %I WITH PASSWORD %L", name, password)); + await client.query( + `ALTER USER ${escapeIdentifier(name)} WITH PASSWORD ${escapeLiteral(password)}` + ); await client.end(); }; diff --git a/cdk-postgresql/package.json b/cdk-postgresql/package.json index 4c46b9c..1c801bf 100644 --- a/cdk-postgresql/package.json +++ b/cdk-postgresql/package.json @@ -34,7 +34,7 @@ "check:type": "pnpm exec tsc --noEmit", "test": "pnpm run test:unit && pnpm run test:integration", "test:unit": "pnpm run build && pnpm exec vitest --run --project=unit", - "test:integration": "pnpm exec vitest --run --project=integration", + "test:integration": "pnpm run build && pnpm exec vitest --run --project=integration", "prepublishOnly": "pnpm run build" }, "peerDependencies": { @@ -44,7 +44,6 @@ "dependencies": { "@aws-sdk/client-secrets-manager": "3.1116.0", "pg": "8.23.0", - "pg-format": "1.0.4", "verror": "^1.10.1" }, "devDependencies": { @@ -52,7 +51,6 @@ "@types/node": "22.13.14", "@types/ms": "^2.1.0", "@types/pg": "8.23.0", - "@types/pg-format": "1.0.5", "@types/verror": "^1.10.11", "aws-cdk-lib": "^2.266.0", "constructs": "^10.8.1", diff --git a/cdk-postgresql/test/lambda.integration.test.ts b/cdk-postgresql/test/lambda.integration.test.ts index 9f96391..2d34358 100644 --- a/cdk-postgresql/test/lambda.integration.test.ts +++ b/cdk-postgresql/test/lambda.integration.test.ts @@ -15,6 +15,7 @@ import { createDatabase, createRole } from "../lib/postgres"; import { createSecret, dbExists, getDbOwner, roleExists } from "./helpers"; import { secretsmanager } from "../lib/util"; import { beforeEach, afterEach, describe, test, expect, vi } from "vitest"; +import { createRequire } from "node:module"; const DB_PORT = 5432; const DB_MASTER_USERNAME = "postgres"; @@ -448,3 +449,54 @@ describe("database", () => { await masterClient.end(); }); }); + +// The built asset is what actually gets deployed, and bundling can break it in +// ways the source cannot reproduce, so it gets exercised the way the lambda +// runtime loads it: +describe("built lambda asset", () => { + test("creates a working role", async () => { + // Arrange + const roleName = "assetuser"; + const rolePwd = "assetrolepwd"; + const rolePasswordArn = await createSecret(secretsmanager, rolePwd); + const event: CreateRoleEvent = { + RequestType: "Create", + ServiceToken: "", + ResponseURL: "", + StackId: "", + RequestId: "", + LogicalResourceId: "", + ResourceType: "Custom::Postgresql-Role", + ResourceProperties: { + ServiceToken: "", + Connection: { + Host: pgHost, + Port: pgPort, + Username: DB_MASTER_USERNAME, + Database: DB_DEFAULT_DB, + PasswordArn: masterPasswordArn, + SSLMode: "disable", + }, + Name: roleName, + PasswordArn: rolePasswordArn, + }, + }; + + // Act + const { handler } = createRequire(import.meta.url)("../dist/lambda/index.cjs"); + await handler(event); + + // Assert + const asNewRole = new Client({ + host: pgHost, + port: pgPort, + database: DB_DEFAULT_DB, + user: roleName, + password: rolePwd, + }); + await asNewRole.connect(); + const { rows } = await asNewRole.query("SELECT current_user"); + await asNewRole.end(); + expect(rows[0].current_user).toEqual(roleName); + }); +}); diff --git a/cdk-postgresql/tsdown.config.ts b/cdk-postgresql/tsdown.config.ts index 25053d2..1db7080 100644 --- a/cdk-postgresql/tsdown.config.ts +++ b/cdk-postgresql/tsdown.config.ts @@ -7,11 +7,16 @@ export default defineConfig([ shims: true, dts: true, }, + // The lambda is shipped as a ready-to-deploy asset, so that consumers never + // need esbuild (or Docker) to synthesize a stack. Everything it needs at + // runtime has to be inside the bundle: { - entry: ["./lib/handler.ts"], + entry: { index: "./lib/handler.ts" }, + outDir: "dist/lambda", format: ["cjs"], + platform: "node", + deps: { alwaysBundle: ["pg", "verror", "@aws-sdk/client-secrets-manager"] }, dts: false, - sourcemap: true, clean: false, }, ]); diff --git a/package.json b/package.json index a4e33ca..41cfec6 100644 --- a/package.json +++ b/package.json @@ -1,8 +1,5 @@ { "private": true, - "devDependencies": { - "esbuild": "^0.28.2" - }, "scripts": { "build": "pnpm -r --if-present build", "check": "pnpm -r --if-present check", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index aea8824..5b8d202 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -6,11 +6,7 @@ settings: importers: - .: - devDependencies: - esbuild: - specifier: ^0.28.2 - version: 0.28.2 + .: {} cdk-postgresql: dependencies: @@ -20,9 +16,6 @@ importers: pg: specifier: 8.23.0 version: 8.23.0 - pg-format: - specifier: 1.0.4 - version: 1.0.4 verror: specifier: ^1.10.1 version: 1.10.1 @@ -39,9 +32,6 @@ importers: '@types/pg': specifier: 8.23.0 version: 8.23.0 - '@types/pg-format': - specifier: 1.0.5 - version: 1.0.5 '@types/verror': specifier: ^1.10.11 version: 1.10.11 @@ -584,9 +574,6 @@ packages: '@types/node@22.13.14': resolution: {integrity: sha512-Zs/Ollc1SJ8nKUAgc7ivOEdIBM8JAKgrqqUYi2J997JuKO7/tpQC+WCetQ1sypiKCQWHdvdg9wBNpUPEWZae7w==} - '@types/pg-format@1.0.5': - resolution: {integrity: sha512-i+oEEJEC+1I3XAhgqtVp45Faj8MBbV0Aoq4rHsHD7avgLjyDkaWKObd514g0Q/DOUkdxU0P4CQ0iq2KR4SoJcw==} - '@types/pg@8.23.0': resolution: {integrity: sha512-gPGYzOgqj8wcPJ3GSojYWD1i3wjym3ezWgis/Zk2gY8u29x9tEsP3oaBYFwqsFYmKOm6sawDu3IHNwladQc0ig==} @@ -1597,10 +1584,6 @@ packages: pg-connection-string@2.14.0: resolution: {integrity: sha512-XwWDGcLRGCXAR8F/AM5bG7Q+A3Wm2s6QeEjlOKZLlH3UYcguiqCWKyWXVag5TLTIjR7oOJUY8kcADaZgWPyLeg==} - pg-format@1.0.4: - resolution: {integrity: sha512-YyKEF78pEA6wwTAqOUaHIN/rWpfzzIuMh9KdAhc3rSLQ/7zkRFcCgYBAEGatDstLyZw4g0s9SNICmaTGnBVeyw==} - engines: {node: '>=4.0'} - pg-int8@1.0.1: resolution: {integrity: sha512-WCtabS6t3c8SkpDBUlb1kjOs7l66xsGdKpIPZsg4wR+B3+u9UAum2odSsF9tnvxg80h4ZxLWMy4pRjOsFIqQpw==} engines: {node: '>=4.0.0'} @@ -2651,8 +2634,6 @@ snapshots: dependencies: undici-types: 6.20.0 - '@types/pg-format@1.0.5': {} - '@types/pg@8.23.0': dependencies: '@types/node': 22.13.14 @@ -3137,6 +3118,7 @@ snapshots: '@esbuild/win32-arm64': 0.28.2 '@esbuild/win32-ia32': 0.28.2 '@esbuild/win32-x64': 0.28.2 + optional: true escalade@3.2.0: {} @@ -3552,8 +3534,6 @@ snapshots: pg-connection-string@2.14.0: {} - pg-format@1.0.4: {} - pg-int8@1.0.1: {} pg-pool@3.14.0(pg@8.23.0): From c4bfd6bbec3e37f4c7872f6fb5a49be44a5ab218 Mon Sep 17 00:00:00 2001 From: Pascal Breton <179493770+pascal-botpress@users.noreply.github.com> Date: Wed, 26 Aug 2026 16:04:31 -0400 Subject: [PATCH 2/2] fix the error message --- cdk-postgresql/lib/role.handler.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/cdk-postgresql/lib/role.handler.ts b/cdk-postgresql/lib/role.handler.ts index 0209ead..ec7cac7 100644 --- a/cdk-postgresql/lib/role.handler.ts +++ b/cdk-postgresql/lib/role.handler.ts @@ -143,7 +143,7 @@ export const updateRolePassword = async (props: { SecretId: passwordArn, }); if (!password) { - throw new Error("could not decrypt password"); + throw new Error(`secret ${passwordArn} has no SecretString value`); } await client.query( @@ -165,7 +165,7 @@ export const createRole = async (props: { SecretId: passwordArn, }); if (!password) { - throw new Error("could not decrypt password"); + throw new Error(`secret ${passwordArn} has no SecretString value`); } await postgres.createRole({ client, name, password });