Skip to content

Commit 0481c58

Browse files
Add SSL support for postgres containers (#1224)
1 parent 746f96e commit 0481c58

11 files changed

Lines changed: 347 additions & 0 deletions

File tree

docs/modules/postgresql.md

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -41,6 +41,15 @@ Choose an image from the [container registry](https://hub.docker.com/_/postgres)
4141
[](../../packages/modules/postgresql/src/postgresql-container.test.ts) inside_block:pgSetUsername
4242
<!--/codeinclude-->
4343

44+
### With SSL
45+
46+
!!! note
47+
`withSSL()` / `withSSLCert()` expect certificate/key files that already exist. They do not generate key material.
48+
49+
<!--codeinclude-->
50+
[](../../packages/modules/postgresql/src/postgresql-container.test.ts) inside_block:pgSslConnect
51+
<!--/codeinclude-->
52+
4453
### Snapshots
4554

4655
!!! warning

packages/modules/postgresql/src/pgvector-container.test.ts

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,11 @@
1+
import path from "node:path";
12
import { Client } from "pg";
23
import { getImage } from "../../../testcontainers/src/utils/test-helper";
34
import { PostgreSqlContainer } from "./postgresql-container";
45

56
const IMAGE = getImage(__dirname);
7+
const SSL_SERVER_CERT = path.resolve(__dirname, "test-certs/server.crt");
8+
const SSL_SERVER_KEY = path.resolve(__dirname, "test-certs/server.key");
69

710
describe("PgvectorContainer", { timeout: 180_000 }, () => {
811
it("should work", async () => {
@@ -41,4 +44,25 @@ describe("PgvectorContainer", { timeout: 180_000 }, () => {
4144

4245
await client.end();
4346
});
47+
48+
it("should connect with SSL", async () => {
49+
await using container = await new PostgreSqlContainer(IMAGE).withSSL(SSL_SERVER_CERT, SSL_SERVER_KEY).start();
50+
51+
const client = new Client({
52+
host: container.getHost(),
53+
port: container.getPort(),
54+
database: container.getDatabase(),
55+
user: container.getUsername(),
56+
password: container.getPassword(),
57+
ssl: {
58+
rejectUnauthorized: false,
59+
},
60+
});
61+
await client.connect();
62+
63+
const result = await client.query("SELECT ssl FROM pg_stat_ssl WHERE pid = pg_backend_pid()");
64+
expect(result.rows[0]).toEqual({ ssl: true });
65+
66+
await client.end();
67+
});
4468
});

packages/modules/postgresql/src/postgis-container.test.ts

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,11 @@
1+
import path from "node:path";
12
import { Client } from "pg";
23
import { getImage } from "../../../testcontainers/src/utils/test-helper";
34
import { PostgreSqlContainer } from "./postgresql-container";
45

56
const IMAGE = getImage(__dirname);
7+
const SSL_SERVER_CERT = path.resolve(__dirname, "test-certs/server.crt");
8+
const SSL_SERVER_KEY = path.resolve(__dirname, "test-certs/server.key");
69

710
describe("PostgisContainer", { timeout: 180_000 }, () => {
811
it("should work", async () => {
@@ -41,4 +44,25 @@ describe("PostgisContainer", { timeout: 180_000 }, () => {
4144

4245
await client.end();
4346
});
47+
48+
it("should connect with SSL", async () => {
49+
await using container = await new PostgreSqlContainer(IMAGE).withSSL(SSL_SERVER_CERT, SSL_SERVER_KEY).start();
50+
51+
const client = new Client({
52+
host: container.getHost(),
53+
port: container.getPort(),
54+
database: container.getDatabase(),
55+
user: container.getUsername(),
56+
password: container.getPassword(),
57+
ssl: {
58+
rejectUnauthorized: false,
59+
},
60+
});
61+
await client.connect();
62+
63+
const result = await client.query("SELECT ssl FROM pg_stat_ssl WHERE pid = pg_backend_pid()");
64+
expect(result.rows[0]).toEqual({ ssl: true });
65+
66+
await client.end();
67+
});
4468
});

packages/modules/postgresql/src/postgresql-container.test.ts

Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,12 @@
1+
import path from "node:path";
12
import { Client } from "pg";
23
import { getImage } from "../../../testcontainers/src/utils/test-helper";
34
import { PostgreSqlContainer } from "./postgresql-container";
45

56
const IMAGE = getImage(__dirname);
7+
const SSL_CA_CERT = path.resolve(__dirname, "test-certs/ca.crt");
8+
const SSL_SERVER_CERT = path.resolve(__dirname, "test-certs/server.crt");
9+
const SSL_SERVER_KEY = path.resolve(__dirname, "test-certs/server.key");
610

711
describe("PostgreSqlContainer", { timeout: 180_000 }, () => {
812
it("should connect and return a query result", async () => {
@@ -110,4 +114,39 @@ describe("PostgreSqlContainer", { timeout: 180_000 }, () => {
110114

111115
await expect(() => container.start()).rejects.toThrow();
112116
});
117+
118+
it("should connect with SSL", async () => {
119+
// pgSslConnect {
120+
await using container = await new PostgreSqlContainer(IMAGE)
121+
.withSSLCert(SSL_CA_CERT, SSL_SERVER_CERT, SSL_SERVER_KEY)
122+
.start();
123+
124+
const client = new Client({
125+
host: container.getHost(),
126+
port: container.getPort(),
127+
database: container.getDatabase(),
128+
user: container.getUsername(),
129+
password: container.getPassword(),
130+
ssl: {
131+
rejectUnauthorized: false,
132+
},
133+
});
134+
await client.connect();
135+
136+
const result = await client.query("SELECT ssl FROM pg_stat_ssl WHERE pid = pg_backend_pid()");
137+
expect(result.rows[0]).toEqual({ ssl: true });
138+
139+
await client.end();
140+
// }
141+
});
142+
143+
it("should validate SSL certificate paths", () => {
144+
const container = new PostgreSqlContainer(IMAGE);
145+
146+
expect(() => container.withSSL("", "server.key")).toThrow("SSL certificate file should not be empty.");
147+
expect(() => container.withSSL("server.crt", "")).toThrow("SSL key file should not be empty.");
148+
expect(() => container.withSSLCert("", "server.crt", "server.key")).toThrow(
149+
"SSL CA certificate file should not be empty."
150+
);
151+
});
113152
});

packages/modules/postgresql/src/postgresql-container.ts

Lines changed: 81 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,41 @@
11
import { AbstractStartedContainer, GenericContainer, StartedTestContainer, Wait } from "testcontainers";
22

33
const POSTGRES_PORT = 5432;
4+
const POSTGRES_SSL_PATH = "/tmp/testcontainers-node/postgres";
5+
const POSTGRES_SSL_CA_CERT_PATH = `${POSTGRES_SSL_PATH}/ca_cert.pem`;
6+
const POSTGRES_SSL_CERT_PATH = `${POSTGRES_SSL_PATH}/server.crt`;
7+
const POSTGRES_SSL_KEY_PATH = `${POSTGRES_SSL_PATH}/server.key`;
8+
const POSTGRES_SSL_ENTRYPOINT_PATH = "/usr/local/bin/docker-entrypoint-ssl.sh";
9+
const POSTGRES_SSL_ENTRYPOINT_CONTENT = `#!/bin/sh
10+
set -eu
11+
12+
puid="$(id -u postgres)"
13+
pgid="$(id -g postgres)"
14+
15+
if [ -z "$puid" ] || [ -z "$pgid" ]; then
16+
echo "Unable to determine postgres uid/gid for SSL key material ownership"
17+
exit 1
18+
fi
19+
20+
for file in "${POSTGRES_SSL_CA_CERT_PATH}" "${POSTGRES_SSL_CERT_PATH}" "${POSTGRES_SSL_KEY_PATH}"; do
21+
if [ -f "$file" ]; then
22+
chown "$puid:$pgid" "$file"
23+
chmod 600 "$file"
24+
fi
25+
done
26+
27+
exec /usr/local/bin/docker-entrypoint.sh "$@"
28+
`;
29+
30+
type PostgreSqlSslConfig = {
31+
caCertPath?: string;
32+
};
433

534
export class PostgreSqlContainer extends GenericContainer {
635
private database = "test";
736
private username = "test";
837
private password = "test";
38+
private sslConfig?: PostgreSqlSslConfig;
939

1040
constructor(image: string) {
1141
super(image);
@@ -29,12 +59,63 @@ export class PostgreSqlContainer extends GenericContainer {
2959
return this;
3060
}
3161

62+
public withSSL(certFile: string, keyFile: string, caCertFile?: string): this {
63+
if (!certFile) throw new Error("SSL certificate file should not be empty.");
64+
if (!keyFile) throw new Error("SSL key file should not be empty.");
65+
if (caCertFile !== undefined && !caCertFile) throw new Error("SSL CA certificate file should not be empty.");
66+
67+
const filesToCopy = [
68+
{ source: certFile, target: POSTGRES_SSL_CERT_PATH, mode: 0o600 },
69+
{ source: keyFile, target: POSTGRES_SSL_KEY_PATH, mode: 0o600 },
70+
];
71+
72+
if (caCertFile) {
73+
filesToCopy.push({ source: caCertFile, target: POSTGRES_SSL_CA_CERT_PATH, mode: 0o600 });
74+
}
75+
76+
this.sslConfig = {
77+
caCertPath: caCertFile ? POSTGRES_SSL_CA_CERT_PATH : undefined,
78+
};
79+
80+
this.withCopyFilesToContainer(filesToCopy)
81+
.withCopyContentToContainer([
82+
{
83+
content: POSTGRES_SSL_ENTRYPOINT_CONTENT,
84+
target: POSTGRES_SSL_ENTRYPOINT_PATH,
85+
mode: 0o700,
86+
},
87+
])
88+
.withEntrypoint(["sh", POSTGRES_SSL_ENTRYPOINT_PATH]);
89+
90+
return this;
91+
}
92+
93+
public withSSLCert(caCertFile: string, certFile: string, keyFile: string): this {
94+
if (!caCertFile) throw new Error("SSL CA certificate file should not be empty.");
95+
return this.withSSL(certFile, keyFile, caCertFile);
96+
}
97+
3298
public override async start(): Promise<StartedPostgreSqlContainer> {
3399
this.withEnvironment({
34100
POSTGRES_DB: this.database,
35101
POSTGRES_USER: this.username,
36102
POSTGRES_PASSWORD: this.password,
37103
});
104+
if (this.sslConfig) {
105+
const command = this.createOpts.Cmd;
106+
if (!command || command[0] === "postgres") {
107+
this.withCommand([
108+
...(command ?? ["postgres"]),
109+
"-c",
110+
"ssl=on",
111+
"-c",
112+
`ssl_cert_file=${POSTGRES_SSL_CERT_PATH}`,
113+
"-c",
114+
`ssl_key_file=${POSTGRES_SSL_KEY_PATH}`,
115+
...(this.sslConfig.caCertPath ? ["-c", `ssl_ca_file=${this.sslConfig.caCertPath}`] : []),
116+
]);
117+
}
118+
}
38119
if (!this.healthCheck) {
39120
this.withHealthCheck({
40121
test: [
Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,22 @@
1+
# Test certificates
2+
3+
This directory contains test-only certificates for PostgreSQL SSL module tests.
4+
5+
To regenerate them:
6+
7+
```bash
8+
cd packages/modules/postgresql/src/test-certs
9+
bash generate-certs.sh
10+
```
11+
12+
Optional: pass validity days (default is `36500`):
13+
14+
```bash
15+
bash generate-certs.sh 3650
16+
```
17+
18+
Generated files:
19+
20+
- `ca.crt`
21+
- `server.crt`
22+
- `server.key`
Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,20 @@
1+
-----BEGIN CERTIFICATE-----
2+
MIIDLTCCAhWgAwIBAgIUWCfr3fMtUOOZLu1/2Xe8pKjlOC0wDQYJKoZIhvcNAQEL
3+
BQAwJTEjMCEGA1UEAwwadGVzdGNvbnRhaW5lcnMtcG9zdGdyZXMtY2EwIBcNMjYw
4+
MjE3MDkzODQzWhgPMjEyNjAxMjQwOTM4NDNaMCUxIzAhBgNVBAMMGnRlc3Rjb250
5+
YWluZXJzLXBvc3RncmVzLWNhMIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKC
6+
AQEAt3lgUG4xAjZ+PcXrd60lNl9OUugjNs4lLM74/X5msS36goT4264MjdDtKYq8
7+
L4iyUMFjp/VMxgor60geQB+A+pT7aJSVOA2ZNwrdMLnFWvziOXTNqIgaUegoXrhb
8+
P3IFbFNlyQiRVT7sZ2YJ4yVZrpvomoXlZ4txxz9WhEGN46txnKTcO2Oj9vgUJwdA
9+
ueUlrEQo6LAMOLFuhPt9+3GNOd+ABKvSOAJTDmhEhXPXQisZHfADgT4nmJwFSuaZ
10+
viFxtnHoxLIru7IRu0S9mIPFRau0CfSufAdpcThv+AQW3qKngNXa2QBVikkD+dJB
11+
OdgjrIpIdNAxTEDPYlgkl4WViQIDAQABo1MwUTAdBgNVHQ4EFgQUUa8kIh73prmS
12+
/tAq3LTcFjhVSTIwHwYDVR0jBBgwFoAUUa8kIh73prmS/tAq3LTcFjhVSTIwDwYD
13+
VR0TAQH/BAUwAwEB/zANBgkqhkiG9w0BAQsFAAOCAQEAdXlDyDvqAgMx/UIAFT60
14+
qKsM2FZf68OYlXsnFE5/Y3BfYx8qtnI+kTOkOvPbMEebfsrWJtI7Qpqy/Ih1Nuis
15+
vgdyy+V41qU2m221MhBh4DCyfZaBJXR8PhFJjXsB0DzDWUj5MoG5ppbf2In1VT2b
16+
prHthUnfzy8TKKZm8APv54Ln8f3Z6OtPsWMfxlXSPN/lZVsEpAuXuDmBfck6cWwl
17+
8OXmYB9ywZv6RqpQvqWEznCnMLJFAe8ELuxo+Jzz/nHfpmYm91QTnsP5KVVghlFF
18+
QDChMXEDRToMjhyk0c87xhLjc0KhUIGeyfFwyDbwMWZOxC0+iifyCTUQHqpTrjE6
19+
aw==
20+
-----END CERTIFICATE-----
Lines changed: 56 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,56 @@
1+
#!/usr/bin/env bash
2+
set -euo pipefail
3+
4+
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
5+
6+
CA_CERT="${SCRIPT_DIR}/ca.crt"
7+
SERVER_CERT="${SCRIPT_DIR}/server.crt"
8+
SERVER_KEY="${SCRIPT_DIR}/server.key"
9+
10+
DAYS="${1:-36500}"
11+
12+
TMP_DIR="$(mktemp -d)"
13+
cleanup() {
14+
rm -rf "${TMP_DIR}"
15+
}
16+
trap cleanup EXIT
17+
18+
cat > "${TMP_DIR}/server.ext" <<EOF
19+
subjectAltName=DNS:localhost,IP:127.0.0.1
20+
extendedKeyUsage=serverAuth
21+
EOF
22+
23+
openssl req \
24+
-x509 \
25+
-newkey rsa:2048 \
26+
-nodes \
27+
-keyout "${TMP_DIR}/ca.key" \
28+
-out "${CA_CERT}" \
29+
-days "${DAYS}" \
30+
-subj "/CN=testcontainers-postgres-ca"
31+
32+
openssl req \
33+
-newkey rsa:2048 \
34+
-nodes \
35+
-keyout "${SERVER_KEY}" \
36+
-out "${TMP_DIR}/server.csr" \
37+
-subj "/CN=localhost"
38+
39+
openssl x509 \
40+
-req \
41+
-in "${TMP_DIR}/server.csr" \
42+
-CA "${CA_CERT}" \
43+
-CAkey "${TMP_DIR}/ca.key" \
44+
-CAcreateserial \
45+
-CAserial "${TMP_DIR}/ca.srl" \
46+
-out "${SERVER_CERT}" \
47+
-days "${DAYS}" \
48+
-sha256 \
49+
-extfile "${TMP_DIR}/server.ext"
50+
51+
chmod 600 "${SERVER_KEY}"
52+
53+
echo "Generated:"
54+
echo " ${CA_CERT}"
55+
echo " ${SERVER_CERT}"
56+
echo " ${SERVER_KEY}"
Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,20 @@
1+
-----BEGIN CERTIFICATE-----
2+
MIIDPDCCAiSgAwIBAgIUeSeLWll7jGddHXy2E+GcFJwa0TowDQYJKoZIhvcNAQEL
3+
BQAwJTEjMCEGA1UEAwwadGVzdGNvbnRhaW5lcnMtcG9zdGdyZXMtY2EwIBcNMjYw
4+
MjE3MDkzODQzWhgPMjEyNjAxMjQwOTM4NDNaMBQxEjAQBgNVBAMMCWxvY2FsaG9z
5+
dDCCASIwDQYJKoZIhvcNAQEBBQADggEPADCCAQoCggEBAKS7T+cI1QDZZ71enQUr
6+
2w9uaVM0niRpAe2SEAX3Re4VFXa3MuW45G3aeiW8phYbNciFVmEUt53dCGZU5Vj5
7+
svXMLRMgxFnk8pIdXt532yjEEOkr4RneFvLGR9fNuaP7lDx3hLEI4CyexmGEyu9I
8+
/S/gzG9fQrCvi8U3zhDYkSEJ/NFVpXRw8dw0kqt+sAPvt2bMWfWYazQEk6hTMyX+
9+
Kw3GDzS+KvCifYNLzZLJZN7YJo/YZ9eeWoh1iBb1zgW3cSu6VQdX0EFHzdWbxWzT
10+
etq5/RXVxZJWq6bGuJQs2/AoT7P63B+EHWOwKjJCEEMApMZu0Y2sTeB/Q5TSpIA+
11+
1IUCAwEAAaNzMHEwGgYDVR0RBBMwEYIJbG9jYWxob3N0hwR/AAABMBMGA1UdJQQM
12+
MAoGCCsGAQUFBwMBMB0GA1UdDgQWBBSIsco32pB4j2Nz+6nHzwFFm9HlpDAfBgNV
13+
HSMEGDAWgBRRryQiHvemuZL+0CrctNwWOFVJMjANBgkqhkiG9w0BAQsFAAOCAQEA
14+
tbqyFfN+Y2tahFflZIS/pcqp0VsDHchzh4sr6iPEcACp5bfNeGcDJAaci5GzbIbX
15+
9lN/1qT827w71/FSCMcIEFM3f6kh7H+16Qqrr8wcuj2DLWNm4Shk+5cAKDd4Sc2S
16+
iWXWOETnVQtabt5PL0JHLkNGGwdo9dAgRIPD0ZA7oUyRXV4bwN6JVup6u5mjDcQ0
17+
WKHNAQWBxJuSjKd7vAgRYTSecn0nMT7RnDBTAjMy2BPehbjUaTNfb/NoJv+vIrxg
18+
cRTP/wG/e+kqH/h0sWyPTvOiURJbDAf8TsAE1Jo7OHgq/MgthpHXWiNMVpu1RhOu
19+
UQEutxKs1AIYFgMYqbDqzA==
20+
-----END CERTIFICATE-----

0 commit comments

Comments
 (0)