@astralarium/repo-postgres 0.0.0-stage → 0.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +38 -2
- package/container/init/.containerignore +2 -0
- package/container/init/Containerfile +26 -0
- package/container/init/entrypoint.d/init-db.sh +20 -0
- package/container/snapshot/run-snapshot.sh +23 -0
- package/container/util/.containerignore +2 -0
- package/container/util/Containerfile +7 -0
- package/container/util/entrypoint.d/dump-db.sh +19 -0
- package/container/util/entrypoint.d/upgrade-db.sh +103 -0
- package/dist/bin/stage-schema.d.ts +2 -0
- package/dist/bin/stage-schema.js +20 -0
- package/dist/src/provision/connection.d.ts +11 -0
- package/dist/src/provision/connection.js +33 -0
- package/dist/src/provision/container.d.ts +87 -0
- package/dist/src/provision/container.js +244 -0
- package/dist/src/provision/data.d.ts +8 -0
- package/dist/src/provision/data.js +47 -0
- package/dist/src/provision/disposable.d.ts +49 -0
- package/dist/src/provision/disposable.js +188 -0
- package/dist/src/provision/image.d.ts +63 -0
- package/dist/src/provision/image.js +159 -0
- package/dist/src/provision/index.d.ts +17 -0
- package/dist/src/provision/index.js +205 -0
- package/dist/src/provision/options.d.ts +23 -0
- package/dist/src/provision/options.js +1 -0
- package/dist/src/provision/schema.d.ts +11 -0
- package/dist/src/provision/schema.js +134 -0
- package/dist/src/provision/test-database.d.ts +29 -0
- package/dist/src/provision/test-database.js +130 -0
- package/dist/src/queue.d.ts +86 -0
- package/dist/src/queue.js +201 -0
- package/dist/src/schema/index.d.ts +3 -0
- package/dist/src/schema/index.js +3 -0
- package/dist/src/schema/module.d.ts +59 -0
- package/dist/src/schema/module.js +122 -0
- package/dist/src/schema/modules.d.ts +24 -0
- package/dist/src/schema/modules.js +71 -0
- package/dist/src/sql.d.ts +83 -0
- package/dist/src/sql.js +146 -0
- package/dist/testkit/index.d.ts +14 -0
- package/dist/testkit/index.js +32 -0
- package/dist/testkit/locks.d.ts +27 -0
- package/dist/testkit/locks.js +57 -0
- package/dist/testkit/schema.d.ts +14 -0
- package/dist/testkit/schema.js +89 -0
- package/package.json +63 -3
- package/scripts/build-images.sh +52 -0
- package/scripts/dump-db.sh +32 -0
- package/sql/app/schema.sql +8 -0
- package/sql/bootstrap.sql +27 -0
- package/sql/database-grants.sql +3 -0
- package/sql/drop-schemas.sql +11 -0
- package/sql/extensions/cron/schema.sql +23 -0
- package/sql/extensions/partman/before-reset.sql +38 -0
- package/sql/extensions/partman/schema.sql +8 -0
- package/sql/extensions/trgm/schema.sql +1 -0
- package/sql/reset.sql +22 -0
- package/sql/roles.sql +10 -0
- package/sql/slug/schema.sql +25 -0
- package/sql/taskclaim/jobs/schema.sql +19 -0
- package/sql/taskclaim/schema.sql +41 -0
- package/sql/taskclaim/scrub.sql +3 -0
|
@@ -0,0 +1,244 @@
|
|
|
1
|
+
/** Postgres containers: runtime detection, inspection, startup, readiness. */
|
|
2
|
+
import { execFileSync, spawnSync } from "node:child_process";
|
|
3
|
+
import { createHash } from "node:crypto";
|
|
4
|
+
import { output, READY_TIMEOUT_MS, succeeds, waitFor, } from "@astralarium/repo-dev";
|
|
5
|
+
import { ExpectedError } from "@astralarium/repo-telemetry";
|
|
6
|
+
/** Image label carrying the `schemaHash()` it was built from. */
|
|
7
|
+
export const SCHEMA_LABEL = "db.schema";
|
|
8
|
+
/** Container and volume label naming the checkout that owns it. */
|
|
9
|
+
export const OWNER_LABEL = "dev.owner";
|
|
10
|
+
/** Container label carrying the sha256 of its Postgres server arguments. */
|
|
11
|
+
const ARGS_LABEL = "db.args";
|
|
12
|
+
/** File the init script writes the image's schema hash to, inside the data volume. */
|
|
13
|
+
const SCHEMA_STAMP = "/var/lib/postgresql/db-schema";
|
|
14
|
+
/** `CONTAINER_RUNTIME`, else the first of podman, docker that answers `info`. */
|
|
15
|
+
export function detectRuntime() {
|
|
16
|
+
const override = process.env.CONTAINER_RUNTIME;
|
|
17
|
+
if (override)
|
|
18
|
+
return override;
|
|
19
|
+
for (const bin of ["podman", "docker"]) {
|
|
20
|
+
if (succeeds(bin, ["info"]))
|
|
21
|
+
return bin;
|
|
22
|
+
}
|
|
23
|
+
throw new ExpectedError("neither podman nor docker is running");
|
|
24
|
+
}
|
|
25
|
+
/** Schema hash the container's data was initialised from; null while
|
|
26
|
+
* init has yet to complete. */
|
|
27
|
+
function schemaStamp(runtime, container) {
|
|
28
|
+
return output(runtime, ["exec", container, "cat", SCHEMA_STAMP]);
|
|
29
|
+
}
|
|
30
|
+
/** The container's data was initialised from a schema other than its image's. */
|
|
31
|
+
export class StaleSchemaError extends Error {
|
|
32
|
+
constructor(runtime, container, stamp) {
|
|
33
|
+
super(stamp === null
|
|
34
|
+
? `${container} has no schema stamp: its first boot is incomplete (see ${runtime} logs ${container}).`
|
|
35
|
+
: `${container} data was initialised from another schema.`);
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
/** Host port published for Postgres; the container must be running. */
|
|
39
|
+
function publishedPort(runtime, container) {
|
|
40
|
+
const port = Number(execFileSync(runtime, [
|
|
41
|
+
"container",
|
|
42
|
+
"inspect",
|
|
43
|
+
"-f",
|
|
44
|
+
'{{(index (index .NetworkSettings.Ports "5432/tcp") 0).HostPort}}',
|
|
45
|
+
container,
|
|
46
|
+
], { encoding: "utf8" }).trim());
|
|
47
|
+
if (!Number.isInteger(port) || port < 1 || port > 65535)
|
|
48
|
+
throw new Error(`${container} has no published Postgres port: ${port}`);
|
|
49
|
+
return port;
|
|
50
|
+
}
|
|
51
|
+
/** One `container inspect`; null for an absent `name`. */
|
|
52
|
+
export function inspectContainer(runtime, name) {
|
|
53
|
+
const state = output(runtime, [
|
|
54
|
+
"container",
|
|
55
|
+
"inspect",
|
|
56
|
+
"-f",
|
|
57
|
+
"{{json .}}",
|
|
58
|
+
name,
|
|
59
|
+
]);
|
|
60
|
+
if (state === null)
|
|
61
|
+
return null;
|
|
62
|
+
const inspected = JSON.parse(state);
|
|
63
|
+
const image = inspected.Image;
|
|
64
|
+
return {
|
|
65
|
+
running: inspected.State.Running,
|
|
66
|
+
image: image.startsWith("sha256:") ? image : `sha256:${image}`,
|
|
67
|
+
schema: inspected.Config.Labels[SCHEMA_LABEL],
|
|
68
|
+
owner: inspected.Config.Labels[OWNER_LABEL],
|
|
69
|
+
args: inspected.Config.Labels[ARGS_LABEL],
|
|
70
|
+
bindings: inspected.HostConfig.PortBindings["5432/tcp"] ?? [],
|
|
71
|
+
dataMount: inspected.Mounts.find((mount) => mount.Destination === "/var/lib/postgresql"),
|
|
72
|
+
};
|
|
73
|
+
}
|
|
74
|
+
/** Persistent data volume of dev instance `name`. */
|
|
75
|
+
export function volumeName(name) {
|
|
76
|
+
return `${name}-data`;
|
|
77
|
+
}
|
|
78
|
+
/** A named volume's `OWNER_LABEL`; null for an absent `name`. */
|
|
79
|
+
function inspectVolume(runtime, name) {
|
|
80
|
+
const labels = output(runtime, [
|
|
81
|
+
"volume",
|
|
82
|
+
"inspect",
|
|
83
|
+
"-f",
|
|
84
|
+
"{{json .Labels}}",
|
|
85
|
+
name,
|
|
86
|
+
]);
|
|
87
|
+
if (labels === null)
|
|
88
|
+
return null;
|
|
89
|
+
const parsed = JSON.parse(labels || "null");
|
|
90
|
+
return { owner: parsed?.[OWNER_LABEL] };
|
|
91
|
+
}
|
|
92
|
+
/** The existing container and data volume of dev instance `name`. */
|
|
93
|
+
export function holders(runtime, name) {
|
|
94
|
+
const found = [];
|
|
95
|
+
const container = inspectContainer(runtime, name);
|
|
96
|
+
if (container)
|
|
97
|
+
found.push({ kind: "container", name, owner: container.owner });
|
|
98
|
+
const volume = inspectVolume(runtime, volumeName(name));
|
|
99
|
+
if (volume)
|
|
100
|
+
found.push({ kind: "volume", name: volumeName(name), owner: volume.owner });
|
|
101
|
+
return found;
|
|
102
|
+
}
|
|
103
|
+
/** Container `name` and its data volume are each absent or labeled as
|
|
104
|
+
* owned by `owner`, so a volume surviving its container stays with the
|
|
105
|
+
* checkout that made it. */
|
|
106
|
+
export function claimable(runtime, name, owner) {
|
|
107
|
+
return holders(runtime, name).every((holder) => holder.owner === owner);
|
|
108
|
+
}
|
|
109
|
+
/** Creates `volume` labeled for `owner` when absent; one labeled
|
|
110
|
+
* otherwise throws, keeping each checkout on its own data. */
|
|
111
|
+
function ensureVolume(runtime, volume, owner) {
|
|
112
|
+
const current = inspectVolume(runtime, volume);
|
|
113
|
+
if (current === null) {
|
|
114
|
+
execFileSync(runtime, [
|
|
115
|
+
"volume",
|
|
116
|
+
"create",
|
|
117
|
+
...(owner ? ["--label", `${OWNER_LABEL}=${owner}`] : []),
|
|
118
|
+
volume,
|
|
119
|
+
], { stdio: ["ignore", "ignore", "inherit"] });
|
|
120
|
+
return;
|
|
121
|
+
}
|
|
122
|
+
if (current.owner !== owner)
|
|
123
|
+
throw new ExpectedError(`data volume ${volume} is ${current.owner === undefined ? "unlabeled" : `labeled for ${current.owner}`}; rerun to allocate another instance, or free the name with: ${runtime} volume rm ${volume}`);
|
|
124
|
+
}
|
|
125
|
+
/** `rm -f`; a missing container is fine, any other failure throws. */
|
|
126
|
+
export function removeContainer(runtime, name) {
|
|
127
|
+
if (inspectContainer(runtime, name) === null)
|
|
128
|
+
return;
|
|
129
|
+
execFileSync(runtime, ["rm", "-f", "-v", name], {
|
|
130
|
+
stdio: ["ignore", "ignore", "inherit"],
|
|
131
|
+
});
|
|
132
|
+
}
|
|
133
|
+
/** `volume rm -f`; a missing volume is fine, any other failure throws. */
|
|
134
|
+
export function removeVolume(runtime, name) {
|
|
135
|
+
if (inspectVolume(runtime, name) === null)
|
|
136
|
+
return;
|
|
137
|
+
execFileSync(runtime, ["volume", "rm", "-f", name], {
|
|
138
|
+
stdio: ["ignore", "ignore", "inherit"],
|
|
139
|
+
});
|
|
140
|
+
}
|
|
141
|
+
/**
|
|
142
|
+
* Reuses a container of exactly `image`, `owner`, server arguments and
|
|
143
|
+
* settings, else recreates it (named volumes survive). Resolves once
|
|
144
|
+
* Postgres accepts connections and the data carries `hash`, with the
|
|
145
|
+
* published host port. `resources` stops a persistent container this call
|
|
146
|
+
* started; one already running is left alone. The caller removes an
|
|
147
|
+
* ephemeral one (`volume` unset).
|
|
148
|
+
*/
|
|
149
|
+
export async function startPostgresContainer(runtime, resources, { name, image, hash, port, volume, owner, serverArgs = [], labels = {}, }) {
|
|
150
|
+
const args = [
|
|
151
|
+
...serverArgs,
|
|
152
|
+
...(volume
|
|
153
|
+
? []
|
|
154
|
+
: [
|
|
155
|
+
"-c",
|
|
156
|
+
"fsync=off",
|
|
157
|
+
"-c",
|
|
158
|
+
"synchronous_commit=off",
|
|
159
|
+
"-c",
|
|
160
|
+
"full_page_writes=off",
|
|
161
|
+
]),
|
|
162
|
+
];
|
|
163
|
+
const argsHash = createHash("sha256")
|
|
164
|
+
.update(JSON.stringify(args))
|
|
165
|
+
.digest("hex");
|
|
166
|
+
const current = inspectContainer(runtime, name);
|
|
167
|
+
const reuse = current !== null &&
|
|
168
|
+
current.schema === hash &&
|
|
169
|
+
current.image === image &&
|
|
170
|
+
current.owner === owner &&
|
|
171
|
+
current.args === argsHash &&
|
|
172
|
+
current.bindings.length === 1 &&
|
|
173
|
+
current.bindings[0]?.HostIp === "127.0.0.1" &&
|
|
174
|
+
(port === undefined || current.bindings[0].HostPort === String(port)) &&
|
|
175
|
+
(volume
|
|
176
|
+
? current.dataMount?.Type === "volume" &&
|
|
177
|
+
current.dataMount.Name === volume
|
|
178
|
+
: current.dataMount === undefined);
|
|
179
|
+
if (!reuse && volume)
|
|
180
|
+
ensureVolume(runtime, volume, owner);
|
|
181
|
+
if (current && !reuse)
|
|
182
|
+
removeContainer(runtime, name);
|
|
183
|
+
if (!reuse) {
|
|
184
|
+
execFileSync(runtime, [
|
|
185
|
+
"create",
|
|
186
|
+
"--name",
|
|
187
|
+
name,
|
|
188
|
+
"--env",
|
|
189
|
+
"POSTGRES_HOST_AUTH_METHOD=trust",
|
|
190
|
+
"--env",
|
|
191
|
+
"PGUSER=postgres",
|
|
192
|
+
...(owner ? ["--label", `${OWNER_LABEL}=${owner}`] : []),
|
|
193
|
+
"--label",
|
|
194
|
+
`${ARGS_LABEL}=${argsHash}`,
|
|
195
|
+
...Object.entries(labels).flatMap(([key, value]) => [
|
|
196
|
+
"--label",
|
|
197
|
+
`${key}=${value}`,
|
|
198
|
+
]),
|
|
199
|
+
"--publish",
|
|
200
|
+
`127.0.0.1:${port ?? ""}:5432`,
|
|
201
|
+
...(volume ? ["--volume", `${volume}:/var/lib/postgresql`] : []),
|
|
202
|
+
image,
|
|
203
|
+
...args,
|
|
204
|
+
], { stdio: ["ignore", "ignore", "inherit"] });
|
|
205
|
+
}
|
|
206
|
+
if (!(reuse && current.running)) {
|
|
207
|
+
if (volume)
|
|
208
|
+
resources.own({
|
|
209
|
+
dispose: () => {
|
|
210
|
+
execFileSync(runtime, ["stop", name], { stdio: "ignore" });
|
|
211
|
+
},
|
|
212
|
+
});
|
|
213
|
+
execFileSync(runtime, ["start", name], { stdio: "ignore" });
|
|
214
|
+
}
|
|
215
|
+
try {
|
|
216
|
+
await waitForPostgres(runtime, name, resources.signal);
|
|
217
|
+
}
|
|
218
|
+
catch (error) {
|
|
219
|
+
spawnSync(runtime, ["logs", "--tail", "50", name], { stdio: "inherit" });
|
|
220
|
+
throw error;
|
|
221
|
+
}
|
|
222
|
+
const stamp = schemaStamp(runtime, name);
|
|
223
|
+
if (stamp !== hash)
|
|
224
|
+
throw new StaleSchemaError(runtime, name, stamp);
|
|
225
|
+
return { port: publishedPort(runtime, name) };
|
|
226
|
+
}
|
|
227
|
+
/** Resolves once `pg_isready` succeeds in `container`: init SQL has been applied. */
|
|
228
|
+
function waitForPostgres(runtime, container, signal) {
|
|
229
|
+
return waitFor(() => {
|
|
230
|
+
if (succeeds(runtime, [
|
|
231
|
+
"exec",
|
|
232
|
+
container,
|
|
233
|
+
"pg_isready",
|
|
234
|
+
"-h",
|
|
235
|
+
"127.0.0.1",
|
|
236
|
+
"-U",
|
|
237
|
+
"postgres",
|
|
238
|
+
]))
|
|
239
|
+
return true;
|
|
240
|
+
if (!inspectContainer(runtime, container)?.running)
|
|
241
|
+
throw new ExpectedError(`${container} exited during startup. Inspect: ${runtime} logs ${container}`);
|
|
242
|
+
return false;
|
|
243
|
+
}, `${container} not ready after ${READY_TIMEOUT_MS}ms. Inspect: ${runtime} logs ${container}`, READY_TIMEOUT_MS, signal);
|
|
244
|
+
}
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
/** `data`'s files in load order: each file as given, each directory's
|
|
2
|
+
* `*.sql` in name order. */
|
|
3
|
+
export declare function dataFiles(data: readonly string[]): string[];
|
|
4
|
+
/** sha256 over the data files' names and contents, in load order; the
|
|
5
|
+
* same across checkouts. */
|
|
6
|
+
export declare function dataDigest(data: readonly string[]): string;
|
|
7
|
+
/** Runs the data files in load order, each as one multi-statement script. */
|
|
8
|
+
export declare function loadData(connectionString: string, data: readonly string[]): Promise<void>;
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
/** The app's data SQL: loaded into `db_template` after the schema, its
|
|
2
|
+
* digest recorded in the test image manifest. */
|
|
3
|
+
import { createHash } from "node:crypto";
|
|
4
|
+
import { readdirSync, readFileSync, statSync } from "node:fs";
|
|
5
|
+
import { basename, join } from "node:path";
|
|
6
|
+
import { ExpectedError } from "@astralarium/repo-telemetry";
|
|
7
|
+
import { withSql } from "../sql.js";
|
|
8
|
+
/** `data`'s files in load order: each file as given, each directory's
|
|
9
|
+
* `*.sql` in name order. */
|
|
10
|
+
export function dataFiles(data) {
|
|
11
|
+
return data.flatMap((path) => {
|
|
12
|
+
let directory;
|
|
13
|
+
try {
|
|
14
|
+
directory = statSync(path).isDirectory();
|
|
15
|
+
}
|
|
16
|
+
catch (cause) {
|
|
17
|
+
throw new ExpectedError(`data path ${path} is missing`, { cause });
|
|
18
|
+
}
|
|
19
|
+
return directory
|
|
20
|
+
? readdirSync(path)
|
|
21
|
+
.filter((file) => file.endsWith(".sql"))
|
|
22
|
+
.sort()
|
|
23
|
+
.map((file) => join(path, file))
|
|
24
|
+
: [path];
|
|
25
|
+
});
|
|
26
|
+
}
|
|
27
|
+
/** sha256 over the data files' names and contents, in load order; the
|
|
28
|
+
* same across checkouts. */
|
|
29
|
+
export function dataDigest(data) {
|
|
30
|
+
const hash = createHash("sha256");
|
|
31
|
+
for (const file of dataFiles(data)) {
|
|
32
|
+
hash.update(`${basename(file)}\0`);
|
|
33
|
+
hash.update(readFileSync(file));
|
|
34
|
+
hash.update("\0");
|
|
35
|
+
}
|
|
36
|
+
return hash.digest("hex");
|
|
37
|
+
}
|
|
38
|
+
/** Runs the data files in load order, each as one multi-statement script. */
|
|
39
|
+
export async function loadData(connectionString, data) {
|
|
40
|
+
const files = dataFiles(data);
|
|
41
|
+
if (files.length === 0)
|
|
42
|
+
return;
|
|
43
|
+
await withSql(connectionString, async (sql) => {
|
|
44
|
+
for (const file of files)
|
|
45
|
+
await sql.file(file);
|
|
46
|
+
});
|
|
47
|
+
}
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
/** Throws an `ExpectedError` unless `path` is an existing file. */
|
|
2
|
+
export declare function requireFile(path: string): void;
|
|
3
|
+
/** One foreground disposable server. */
|
|
4
|
+
export interface DisposableServer {
|
|
5
|
+
/** Container name. */
|
|
6
|
+
name: string;
|
|
7
|
+
/** Immutable image ID. */
|
|
8
|
+
image: string;
|
|
9
|
+
/** Published loopback port. */
|
|
10
|
+
port: number;
|
|
11
|
+
/** Server arguments appended to the image command. */
|
|
12
|
+
serverArgs: readonly string[];
|
|
13
|
+
/** Full dump the initdb hook loads into `db_template`. */
|
|
14
|
+
snapshot?: {
|
|
15
|
+
dump: string;
|
|
16
|
+
scrub: boolean;
|
|
17
|
+
};
|
|
18
|
+
/** Stops the server: SIGINT, Postgres' fast shutdown. */
|
|
19
|
+
signal: AbortSignal;
|
|
20
|
+
}
|
|
21
|
+
/** `run --rm` in the foreground; logs the connection once Postgres
|
|
22
|
+
* listens on the published port, resolves once the container is gone. */
|
|
23
|
+
export declare function runDisposableServer(runtime: string, { name, image, port, serverArgs, snapshot, signal }: DisposableServer): Promise<void>;
|
|
24
|
+
/** `--mount` arguments placing each migrate file at
|
|
25
|
+
* `/app/migrate.d/<basename>`; each must be an existing `.sql` file with a
|
|
26
|
+
* unique basename. */
|
|
27
|
+
export declare function migrationMounts(files: readonly string[]): string[];
|
|
28
|
+
/** Throws an `ExpectedError` for a `DB_DUMP_EXCLUDE` pattern lacking a
|
|
29
|
+
* schema. */
|
|
30
|
+
export declare function checkDumpExclude(patterns?: string): void;
|
|
31
|
+
/** The upgrade dump's containers. */
|
|
32
|
+
export interface UpgradeDump {
|
|
33
|
+
/** Name of the throwaway instance and its private network. */
|
|
34
|
+
name: string;
|
|
35
|
+
/** Immutable db image ID. */
|
|
36
|
+
image: string;
|
|
37
|
+
/** Util image tag. */
|
|
38
|
+
util: string;
|
|
39
|
+
/** `migrationMounts` of the migrate files. */
|
|
40
|
+
mounts: readonly string[];
|
|
41
|
+
/** Stops the dump; its containers go. */
|
|
42
|
+
signal: AbortSignal;
|
|
43
|
+
}
|
|
44
|
+
/**
|
|
45
|
+
* The util image loads the `PG*` source into a throwaway instance of
|
|
46
|
+
* `image` on a private network and prints that instance's full dump on
|
|
47
|
+
* stdout. Leftovers of an earlier run go first; everything goes on exit.
|
|
48
|
+
*/
|
|
49
|
+
export declare function runUpgradeDump(runtime: string, { name, image, util, mounts, signal }: UpgradeDump): Promise<void>;
|
|
@@ -0,0 +1,188 @@
|
|
|
1
|
+
/** Disposable instances of the schema image: foreground servers and the
|
|
2
|
+
* upgrade dump. */
|
|
3
|
+
import { execFileSync, spawn, } from "node:child_process";
|
|
4
|
+
import { once } from "node:events";
|
|
5
|
+
import { chmodSync, copyFileSync, mkdtempSync, rmSync, statSync, } from "node:fs";
|
|
6
|
+
import { tmpdir } from "node:os";
|
|
7
|
+
import { basename, extname, join } from "node:path";
|
|
8
|
+
import { succeeds, waitFor } from "@astralarium/repo-dev";
|
|
9
|
+
import { packageRoot } from "@astralarium/repo-package-files";
|
|
10
|
+
import { ExpectedError, log } from "@astralarium/repo-telemetry";
|
|
11
|
+
import { adminConnectionString } from "./connection.js";
|
|
12
|
+
/** initdb hook loading `/app/data.sql`, relative to the package root. */
|
|
13
|
+
const SNAPSHOT_HOOK = "container/snapshot/run-snapshot.sh";
|
|
14
|
+
/** Container environment: trust auth, `postgres` as the default client. */
|
|
15
|
+
const TRUST_ENV = [
|
|
16
|
+
"--env",
|
|
17
|
+
"POSTGRES_HOST_AUTH_METHOD=trust",
|
|
18
|
+
"--env",
|
|
19
|
+
"PGUSER=postgres",
|
|
20
|
+
];
|
|
21
|
+
/** Throws an `ExpectedError` unless `path` is an existing file. */
|
|
22
|
+
export function requireFile(path) {
|
|
23
|
+
let file = false;
|
|
24
|
+
try {
|
|
25
|
+
file = statSync(path).isFile();
|
|
26
|
+
}
|
|
27
|
+
catch {
|
|
28
|
+
// Missing: reported below.
|
|
29
|
+
}
|
|
30
|
+
if (!file)
|
|
31
|
+
throw new ExpectedError(`${path} is not a file`);
|
|
32
|
+
}
|
|
33
|
+
/** Exit code of `child`, or its signal; rejects when it fails to spawn. */
|
|
34
|
+
async function exit(child) {
|
|
35
|
+
const [code, signal] = (await once(child, "exit"));
|
|
36
|
+
return code === null ? String(signal) : String(code);
|
|
37
|
+
}
|
|
38
|
+
/** `run --rm` in the foreground; logs the connection once Postgres
|
|
39
|
+
* listens on the published port, resolves once the container is gone. */
|
|
40
|
+
export async function runDisposableServer(runtime, { name, image, port, serverArgs, snapshot, signal }) {
|
|
41
|
+
// The entrypoint sources non-executable hooks; packed packages drop mode bits.
|
|
42
|
+
const hooks = snapshot ? mkdtempSync(join(tmpdir(), "db-snapshot-")) : "";
|
|
43
|
+
try {
|
|
44
|
+
const snapshotArgs = [];
|
|
45
|
+
if (snapshot) {
|
|
46
|
+
const hook = join(hooks, basename(SNAPSHOT_HOOK));
|
|
47
|
+
copyFileSync(join(packageRoot(import.meta.filename), SNAPSHOT_HOOK), hook);
|
|
48
|
+
chmodSync(hook, 0o755);
|
|
49
|
+
snapshotArgs.push("--env", `SNAPSHOT_SCRUB=${snapshot.scrub ? 1 : 0}`, "--mount", `type=bind,source=${snapshot.dump},target=/app/data.sql,readonly`, "--mount", `type=bind,source=${hook},target=/docker-entrypoint-initdb.d/${basename(hook)},readonly`);
|
|
50
|
+
}
|
|
51
|
+
signal.throwIfAborted();
|
|
52
|
+
const server = spawn(runtime, [
|
|
53
|
+
"run",
|
|
54
|
+
"--rm",
|
|
55
|
+
"--name",
|
|
56
|
+
name,
|
|
57
|
+
...TRUST_ENV,
|
|
58
|
+
"--publish",
|
|
59
|
+
`127.0.0.1:${port}:5432`,
|
|
60
|
+
...snapshotArgs,
|
|
61
|
+
image,
|
|
62
|
+
...serverArgs,
|
|
63
|
+
], { stdio: ["ignore", "inherit", "inherit"] });
|
|
64
|
+
const exited = exit(server);
|
|
65
|
+
const gone = new AbortController();
|
|
66
|
+
exited.then(() => gone.abort(), () => gone.abort());
|
|
67
|
+
const stop = () => server.kill("SIGINT");
|
|
68
|
+
signal.addEventListener("abort", stop, { once: true });
|
|
69
|
+
try {
|
|
70
|
+
try {
|
|
71
|
+
// initdb, a snapshot load included, listens on the socket alone.
|
|
72
|
+
await waitFor(() => succeeds(runtime, [
|
|
73
|
+
"exec",
|
|
74
|
+
name,
|
|
75
|
+
"pg_isready",
|
|
76
|
+
"-h",
|
|
77
|
+
"127.0.0.1",
|
|
78
|
+
"-U",
|
|
79
|
+
"postgres",
|
|
80
|
+
]), `${name} never became ready`, Number.POSITIVE_INFINITY, AbortSignal.any([signal, gone.signal]));
|
|
81
|
+
log.info("dev.db_disposable_ready", "{name}: ready on 127.0.0.1:{port}, removed on exit: {url}", { name, port, url: adminConnectionString(port, "db_template") });
|
|
82
|
+
}
|
|
83
|
+
catch (error) {
|
|
84
|
+
if (!signal.aborted && !gone.signal.aborted)
|
|
85
|
+
throw error;
|
|
86
|
+
}
|
|
87
|
+
const status = await exited;
|
|
88
|
+
if (!signal.aborted && status !== "0")
|
|
89
|
+
throw new ExpectedError(`${name} exited with ${status}`);
|
|
90
|
+
}
|
|
91
|
+
finally {
|
|
92
|
+
signal.removeEventListener("abort", stop);
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
finally {
|
|
96
|
+
if (hooks)
|
|
97
|
+
rmSync(hooks, { recursive: true, force: true });
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
/** `--mount` arguments placing each migrate file at
|
|
101
|
+
* `/app/migrate.d/<basename>`; each must be an existing `.sql` file with a
|
|
102
|
+
* unique basename. */
|
|
103
|
+
export function migrationMounts(files) {
|
|
104
|
+
const targets = new Set();
|
|
105
|
+
return files.flatMap((file) => {
|
|
106
|
+
requireFile(file);
|
|
107
|
+
if (extname(file) !== ".sql")
|
|
108
|
+
throw new ExpectedError(`${file} lacks the .sql extension`);
|
|
109
|
+
const target = `/app/migrate.d/${basename(file)}`;
|
|
110
|
+
if (targets.has(target))
|
|
111
|
+
throw new ExpectedError(`two migrate files share the basename of ${file}`);
|
|
112
|
+
targets.add(target);
|
|
113
|
+
return ["--mount", `type=bind,source=${file},target=${target},readonly`];
|
|
114
|
+
});
|
|
115
|
+
}
|
|
116
|
+
/** Throws an `ExpectedError` for a `DB_DUMP_EXCLUDE` pattern lacking a
|
|
117
|
+
* schema. */
|
|
118
|
+
export function checkDumpExclude(patterns = "") {
|
|
119
|
+
for (const pattern of patterns.split(/\s+/))
|
|
120
|
+
if (pattern !== "" && !pattern.includes("."))
|
|
121
|
+
throw new ExpectedError(`DB_DUMP_EXCLUDE pattern ${pattern} lacks a schema`);
|
|
122
|
+
}
|
|
123
|
+
/**
|
|
124
|
+
* The util image loads the `PG*` source into a throwaway instance of
|
|
125
|
+
* `image` on a private network and prints that instance's full dump on
|
|
126
|
+
* stdout. Leftovers of an earlier run go first; everything goes on exit.
|
|
127
|
+
*/
|
|
128
|
+
export async function runUpgradeDump(runtime, { name, image, util, mounts, signal }) {
|
|
129
|
+
const utilName = `${name}-util`;
|
|
130
|
+
const cleanup = () => {
|
|
131
|
+
succeeds(runtime, ["rm", "-f", utilName]);
|
|
132
|
+
succeeds(runtime, ["rm", "-f", name]);
|
|
133
|
+
succeeds(runtime, ["network", "rm", name]);
|
|
134
|
+
};
|
|
135
|
+
cleanup();
|
|
136
|
+
try {
|
|
137
|
+
const quiet = {
|
|
138
|
+
stdio: ["ignore", "ignore", "inherit"],
|
|
139
|
+
};
|
|
140
|
+
execFileSync(runtime, ["network", "create", name], quiet);
|
|
141
|
+
execFileSync(runtime, [
|
|
142
|
+
"run",
|
|
143
|
+
"--rm",
|
|
144
|
+
"--detach",
|
|
145
|
+
"--name",
|
|
146
|
+
name,
|
|
147
|
+
...TRUST_ENV,
|
|
148
|
+
"--network",
|
|
149
|
+
name,
|
|
150
|
+
"--expose",
|
|
151
|
+
"5432",
|
|
152
|
+
image,
|
|
153
|
+
], quiet);
|
|
154
|
+
signal.throwIfAborted();
|
|
155
|
+
const dump = spawn(runtime, [
|
|
156
|
+
"run",
|
|
157
|
+
"--rm",
|
|
158
|
+
"--interactive",
|
|
159
|
+
"--name",
|
|
160
|
+
utilName,
|
|
161
|
+
"--env",
|
|
162
|
+
`CONTAINER_PGHOST=${name}`,
|
|
163
|
+
...["PGHOST", "PGPORT", "PGUSER", "PGPASSWORD", "PGDATABASE"].flatMap((key) => ["--env", key]),
|
|
164
|
+
"--env",
|
|
165
|
+
"DB_DUMP_EXCLUDE",
|
|
166
|
+
"--network",
|
|
167
|
+
name,
|
|
168
|
+
...mounts,
|
|
169
|
+
util,
|
|
170
|
+
"/entrypoint.d/upgrade-db.sh",
|
|
171
|
+
], { stdio: "inherit" });
|
|
172
|
+
const stop = () => dump.kill("SIGINT");
|
|
173
|
+
signal.addEventListener("abort", stop, { once: true });
|
|
174
|
+
try {
|
|
175
|
+
const status = await exit(dump);
|
|
176
|
+
if (signal.aborted)
|
|
177
|
+
throw new ExpectedError(`${utilName} interrupted; the dump is partial`);
|
|
178
|
+
if (status !== "0")
|
|
179
|
+
throw new ExpectedError(`${utilName} exited with ${status}`);
|
|
180
|
+
}
|
|
181
|
+
finally {
|
|
182
|
+
signal.removeEventListener("abort", stop);
|
|
183
|
+
}
|
|
184
|
+
}
|
|
185
|
+
finally {
|
|
186
|
+
cleanup();
|
|
187
|
+
}
|
|
188
|
+
}
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
import type { PostgresOptions } from "./options.ts";
|
|
2
|
+
/**
|
|
3
|
+
* What the package contributes to the image: `name@version` once
|
|
4
|
+
* installed from a registry; while linked from a workspace (files change
|
|
5
|
+
* without a version bump), the Git-visible `container/init` and `sql`
|
|
6
|
+
* sources and the build script.
|
|
7
|
+
*/
|
|
8
|
+
export declare function packageFingerprint(dir?: string): string;
|
|
9
|
+
/**
|
|
10
|
+
* sha256 of what the image bakes: the staged schema (`schemaDigest`), the
|
|
11
|
+
* package fingerprint and the modules' extensions.
|
|
12
|
+
*/
|
|
13
|
+
export declare function schemaHash(options: PostgresOptions): string;
|
|
14
|
+
/** What `buildImages` builds and how. */
|
|
15
|
+
interface BuildImagesOptions {
|
|
16
|
+
/** Images to build; defaults to `all`. */
|
|
17
|
+
target?: "db" | "util" | "all";
|
|
18
|
+
/** Image base name. */
|
|
19
|
+
name: string;
|
|
20
|
+
/** File receiving the built db image's ID. */
|
|
21
|
+
imageIdFile?: string;
|
|
22
|
+
/** Pulls the base image first. */
|
|
23
|
+
refreshBase?: boolean;
|
|
24
|
+
}
|
|
25
|
+
/**
|
|
26
|
+
* Builds database images with `build-images.sh` over a staged copy of the
|
|
27
|
+
* schema; base-image refresh is explicit.
|
|
28
|
+
*/
|
|
29
|
+
export declare function buildImages(options: PostgresOptions, runtime: string, hash: string, { target, name, imageIdFile, refreshBase, }: BuildImagesOptions): void;
|
|
30
|
+
/**
|
|
31
|
+
* Reuses the schema-tagged local image or builds it; returns its immutable
|
|
32
|
+
* ID. A reused image is retagged `<name>:latest`, as a build tags it.
|
|
33
|
+
*/
|
|
34
|
+
export declare function ensureImage(options: PostgresOptions, runtime: string, hash: string, name: string): string;
|
|
35
|
+
/** Builds `<name>-util:latest` from the package's util sources
|
|
36
|
+
* (layer-cached) and returns the tag. */
|
|
37
|
+
export declare function ensureUtilImage(runtime: string, name: string): string;
|
|
38
|
+
/** Database image and host runtime captured by the image prerequisite. */
|
|
39
|
+
export interface TestImage {
|
|
40
|
+
/** `PostgresOptions.name` the image was built for. */
|
|
41
|
+
name: string;
|
|
42
|
+
/** Container runtime the image was built with. */
|
|
43
|
+
runtime: string;
|
|
44
|
+
/** Immutable image ID (`sha256:…`). */
|
|
45
|
+
image: string;
|
|
46
|
+
/** `schemaHash` the image was built from. */
|
|
47
|
+
hash: string;
|
|
48
|
+
/** `dataDigest` of `PostgresOptions.data`, which loads after the image's
|
|
49
|
+
* schema. */
|
|
50
|
+
data: string;
|
|
51
|
+
/** Node version and platform captured by the prerequisite. */
|
|
52
|
+
host: string;
|
|
53
|
+
}
|
|
54
|
+
/** Selects the schema image, tagged `name`, and records its immutable ID,
|
|
55
|
+
* runtime and data digest. */
|
|
56
|
+
export declare function prepareTestImage(options: PostgresOptions, name?: string): TestImage;
|
|
57
|
+
/** Publishes the manifest of the image tagged `envDbName` at `file`,
|
|
58
|
+
* replacing it whole. */
|
|
59
|
+
export declare function writeTestImage(options: PostgresOptions, file: URL): void;
|
|
60
|
+
/** Reads a prepared database-image manifest; a malformed one is an
|
|
61
|
+
* `ExpectedError`. */
|
|
62
|
+
export declare function readTestImage(path: string): TestImage;
|
|
63
|
+
export {};
|