Skip to content

Commit 4f1882a

Browse files
committed
Merge branch 'master' into readme
* master: ✨ Wait for docker-postgres to "truly" start and accept connections (#1)
2 parents debc218 + 6f5013e commit 4f1882a

File tree

8 files changed

+647
-56
lines changed

8 files changed

+647
-56
lines changed

.eslintignore

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,5 @@
11
lib/
22
coverage/
3+
example/
4+
35
jest.config.js

.travis.yml

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,18 +1,23 @@
1-
git:
2-
depth: 1
1+
sudo: required
2+
services:
3+
- docker
4+
35
os:
46
- linux
57
language: node_js
68
node_js:
79
- '8'
810
- '10'
11+
912
notifications:
1013
email:
1114
on_success: never
15+
1216
cache:
1317
yarn: true
1418
directories:
1519
- node_modules
20+
1621
script:
1722
- yarn ci
1823
- yarn global add codecov

README.md

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,17 @@ Using the Docker API (via `dockerode` or similar) will only tell you if the cont
2727

2828
`docker-await-postgres` will read the server logs and long poll until the _postgres_ server is trulry ready. So that tests only run when
2929
the server is trurly ready to accept connections.
30+
Start `postgres` docker container and wait until it is truly ready.
31+
32+
This module is based on on [`ava-fixture-docker-db`](https://github.com/cdaringe/ava-fixture-docker-db).
33+
However, it is
34+
35+
- (test) runner agnostic
36+
- waits until `postgres` executed all SQL scripts and restarted
37+
38+
## Why
39+
40+
See https://github.com/docker-library/postgres/issues/146
3041

3142
## Usage
3243

example/index.js

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,30 @@
1+
#!/usr/bin/env node
2+
const { Client } = require('pg');
3+
const { startPostgresContainer } = require('../lib');
4+
5+
(async () => {
6+
const config = {
7+
user: 'admin',
8+
password: '12345',
9+
database: 'database',
10+
image: 'postgres',
11+
};
12+
13+
const { stop, port } = await startPostgresContainer(config);
14+
console.log(`Postgres running on port ${port} ...`);
15+
16+
const client = new Client({
17+
host: 'localhost',
18+
port,
19+
...config,
20+
});
21+
22+
await client.connect();
23+
console.log(`Client connected to Database.`);
24+
25+
const { rows } = await client.query('SELECT NOW()');
26+
console.log(`Server time is: ${rows[0].now}`);
27+
28+
await client.end();
29+
await stop();
30+
})();

package.json

Lines changed: 16 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -24,10 +24,21 @@
2424
"pre-commit": "pretty-quick --staged"
2525
}
2626
},
27-
"dependencies": {},
27+
"dependencies": {
28+
"dockerode": "2.5.8",
29+
"execa": "1.0.0",
30+
"get-port": "4.1.0",
31+
"p-retry": "3.0.1",
32+
"pg": "7.8.1"
33+
},
2834
"devDependencies": {
29-
"@types/jest": "24.0.6",
30-
"@typescript-eslint/eslint-plugin": "1.4.0",
35+
"@types/dockerode": "2.5.12",
36+
"@types/execa": "0.9.0",
37+
"@types/get-port": "4.0.1",
38+
"@types/jest": "24.0.9",
39+
"@types/p-retry": "3.0.0",
40+
"@types/pg": "7.4.13",
41+
"@typescript-eslint/eslint-plugin": "1.4.2",
3142
"conventional-changelog-cli": "2.0.12",
3243
"conventional-changelog-emojis": "3.0.1",
3344
"eslint": "5.14.1",
@@ -41,10 +52,11 @@
4152
"prettier": "1.16.4",
4253
"pretty-quick": "1.10.0",
4354
"ts-jest": "24.0.0",
44-
"typescript": "3.3.3"
55+
"typescript": "3.3.3333"
4556
},
4657
"scripts": {
4758
"start": "tsc",
59+
"example": "tsc && node example",
4860
"test": "jest --config jest.config.js",
4961
"clean": "rm -rf lib coverage",
5062
"typecheck": "tsc --noEmit",

src/index.test.ts

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,35 @@
1+
import { Client } from 'pg';
2+
import { startPostgresContainer } from '.';
3+
4+
jest.setTimeout(1000 * 60 * 5); // 5 Min timeout, so the image cann be pulled.
5+
6+
test('wait until postgres is ready', async () => {
7+
const config = {
8+
user: 'admin',
9+
password: '12345',
10+
database: 'database',
11+
image: 'postgres',
12+
};
13+
const { stop, port } = await startPostgresContainer(config);
14+
15+
const client = new Client({
16+
host: 'localhost',
17+
port,
18+
...config,
19+
});
20+
await client.connect();
21+
const { rows } = await client.query('SELECT NOW()');
22+
await client.end();
23+
24+
expect(rows[0].now).toEqual(expect.any(Date));
25+
26+
await stop();
27+
28+
// Should fail, since container was stopped.
29+
const c = new Client({
30+
host: 'localhost',
31+
port,
32+
...config,
33+
});
34+
await expect(c.connect()).rejects.toThrow(/ECONNREFUSED/);
35+
});

src/index.ts

Lines changed: 199 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,199 @@
1+
import Docker, { Container } from 'dockerode';
2+
import execa from 'execa';
3+
import getPort from 'get-port';
4+
import { ClientConfig, Client } from 'pg';
5+
import retry from 'p-retry';
6+
import { PassThrough } from 'stream';
7+
8+
const docker = new Docker();
9+
10+
/**
11+
* Ensure that the image is available on the machine.
12+
* Will pull the image if it doesn't exist yet.
13+
*
14+
* @param name image name including version
15+
*/
16+
const ensureImage = async (name: string): Promise<void> => {
17+
try {
18+
const image = await docker.getImage(name);
19+
await image.inspect();
20+
} catch {
21+
try {
22+
// `dockerode`'s pull method doesn't work ... fallback to CLI
23+
await execa('docker', ['pull', name]);
24+
} catch (e) {
25+
throw new Error(`Image "${name}" can not be pulled.\n\n${e.message}`);
26+
}
27+
}
28+
};
29+
30+
/**
31+
* Meh ...
32+
*/
33+
export const THE_MAGIC_WORD =
34+
'PostgreSQL init process complete; ready for start up.';
35+
36+
/**
37+
* Wait until `postgres` was initialized.
38+
* The docker container will execute SQL script files first.
39+
* Afterwards the `postgres` server is rebootet.
40+
*
41+
* @param container
42+
*/
43+
const isInitialized = async (
44+
container: Container,
45+
waitForMessage: string
46+
): Promise<void> =>
47+
new Promise((resolve, reject) => {
48+
const logger = new PassThrough();
49+
50+
logger.on('data', (chunk: Buffer | string) => {
51+
const line = chunk.toString('utf8').trim();
52+
if (line.includes(waitForMessage)) {
53+
resolve();
54+
}
55+
});
56+
57+
logger.on('error', err => reject(err));
58+
logger.on('end', () => resolve());
59+
60+
container.logs(
61+
{
62+
follow: true,
63+
stdout: true,
64+
stderr: true,
65+
},
66+
(err, stream) => {
67+
if (err) {
68+
return reject(err);
69+
}
70+
71+
if (!stream) {
72+
return reject(new Error('No stream to read available!'));
73+
}
74+
75+
stream.pipe(logger);
76+
}
77+
);
78+
});
79+
80+
/**
81+
* Ping a `postgres` server (10 times max) until it accepts connections.
82+
*
83+
* @param config client configuration to reach the `postgres` server
84+
*/
85+
const isReady = async (config: ClientConfig): Promise<void> =>
86+
retry(async () => {
87+
const client = new Client(config);
88+
89+
await client.connect();
90+
await client.query('SELECT NOW()');
91+
await client.end();
92+
});
93+
94+
/**
95+
* Kill and remove a docker container.
96+
*
97+
* @param container the container to kill
98+
*/
99+
const kill = async (container: Container): Promise<void> => {
100+
try {
101+
await container.kill();
102+
} finally {
103+
try {
104+
await container.remove({ force: true });
105+
} catch (err) {
106+
// If 404, we probably used the --rm flag on container launch. it's all good.
107+
if (err.statusCode !== 404 && err.statusCode !== 409) {
108+
// eslint-disable-next-line no-unsafe-finally
109+
throw err;
110+
}
111+
}
112+
}
113+
};
114+
115+
/**
116+
* Configuration for `postgres` container.
117+
*/
118+
export type Config = {
119+
/**
120+
* Image name of the container.
121+
*/
122+
image?: string;
123+
124+
/**
125+
* Database user.
126+
*/
127+
user: string;
128+
129+
/**
130+
* Password for the database user.
131+
*/
132+
password: string;
133+
134+
/**
135+
* Database name.
136+
*/
137+
database: string;
138+
139+
/**
140+
* Sub-string that represents the successul initialization of the `postgres` server.
141+
* Not to confused with "ready". This only means that the server read all scripts
142+
* files and created tables etc.
143+
*/
144+
theMagicWord?: string;
145+
};
146+
147+
export type Result = {
148+
/**
149+
* Port on which `postgres` is running.
150+
*/
151+
port: number;
152+
153+
/**
154+
* Stop `postgres` container.
155+
*/
156+
stop: () => Promise<void>;
157+
};
158+
159+
/**
160+
* Start a `postgres` container and wait until it is ready
161+
* to process queries.
162+
*
163+
* @param config
164+
* @returns object with `port` number and a `stop` method
165+
*/
166+
export const startPostgresContainer = async (
167+
config: Config
168+
): Promise<Result> => {
169+
const port = await getPort();
170+
const image = config.image || 'postgres:latest';
171+
172+
await ensureImage(image);
173+
174+
const container = await docker.createContainer({
175+
Image: image,
176+
ExposedPorts: {
177+
'5432/tcp': {},
178+
},
179+
HostConfig: {
180+
AutoRemove: true,
181+
PortBindings: { '5432/tcp': [{ HostPort: String(port) }] },
182+
},
183+
Env: [
184+
`POSTGRES_USER=${config.user}`,
185+
`POSTGRES_PASSWORD=${config.password}`,
186+
`POSTGRES_DB=${config.database}`,
187+
],
188+
});
189+
190+
await container.start();
191+
192+
await isInitialized(container, config.theMagicWord || THE_MAGIC_WORD);
193+
await isReady({ ...config, host: 'localhost', port });
194+
195+
return {
196+
port,
197+
stop: async () => kill(container),
198+
};
199+
};

0 commit comments

Comments
 (0)