@hyperfixation/cli 0.1.4 → 0.1.6
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/dist/cli.d.ts +2 -2
- package/dist/cli.js +25 -1
- package/dist/cloud-steps/backup.d.ts +12 -10
- package/dist/cloud-steps/backup.js +92 -16
- package/dist/cloud-steps/coolify.d.ts +6 -3
- package/dist/cloud-steps/coolify.js +10 -3
- package/dist/cloud-steps/deploy.d.ts +38 -1
- package/dist/cloud-steps/deploy.js +95 -31
- package/dist/config.d.ts +1 -1
- package/dist/config.js +5 -0
- package/dist/database.d.ts +20 -0
- package/dist/database.js +9 -10
- package/dist/deploy-app.d.ts +32 -0
- package/dist/deploy-app.js +81 -0
- package/dist/doctor.js +10 -3
- package/dist/index.d.ts +2 -2
- package/dist/index.js +2 -2
- package/dist/new-cloud.d.ts +4 -3
- package/dist/new-cloud.js +5 -3
- package/dist/providers/coolify.d.ts +56 -0
- package/dist/providers/coolify.js +38 -0
- package/dist/restore-check.d.ts +33 -8
- package/dist/restore-check.js +78 -34
- package/dist/runner.d.ts +9 -1
- package/dist/runner.js +26 -5
- package/package.json +6 -6
package/dist/restore-check.js
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { quoteIdent } from "@hyperfixation/db";
|
|
2
2
|
import { createLocalDirectoryBackupSource, createS3BackupSource, } from "./backup-source.js";
|
|
3
|
-
import { loadOperatorConfig, pgAdminUser, postgresContainers, requireOperatorConfig, } from "./config.js";
|
|
4
|
-
import { openDatabase, openDatabaseUrl, redactPasswords,
|
|
3
|
+
import { loadOperatorConfig, pgAdminUser, postgresContainers, requireOperatorConfig, DEFAULT_PG_ADMIN_USER, } from "./config.js";
|
|
4
|
+
import { findPostgresContainer, openDatabase, openDatabaseUrl, redactPasswords, } from "./database.js";
|
|
5
5
|
import { deriveNames } from "./names.js";
|
|
6
6
|
import { REQUIRED_EXTENSIONS } from "./provision-database.js";
|
|
7
7
|
import { createSshRunner } from "./runner.js";
|
|
@@ -12,7 +12,6 @@ export const SCRATCH_SUFFIX = "_restore_check";
|
|
|
12
12
|
const MAX_IDENTIFIER_BYTES = 63;
|
|
13
13
|
/** Older than this and the dump gets a warning line; it never changes the exit code. */
|
|
14
14
|
export const STALE_DUMP_HOURS = 36;
|
|
15
|
-
const CLUSTER_ADMIN_DATABASE = "postgres";
|
|
16
15
|
export class RestoreCheckError extends Error {
|
|
17
16
|
constructor(message) {
|
|
18
17
|
super(message);
|
|
@@ -45,11 +44,10 @@ export async function restoreCheck(options) {
|
|
|
45
44
|
const db = typeof options.database === "string" ? openDatabaseUrl(options.database) : options.database;
|
|
46
45
|
const clusterUrl = db.adminUrl();
|
|
47
46
|
if (clusterUrl === undefined) {
|
|
48
|
-
throw new RestoreCheckError(`a restore cannot be
|
|
49
|
-
"
|
|
50
|
-
"tunnel can discover one.");
|
|
47
|
+
throw new RestoreCheckError(`a restore cannot be checked over the ${db.kind} transport: counting the restored side ` +
|
|
48
|
+
"needs an address a client library can dial. Name the Postgres container — " +
|
|
49
|
+
"HF_DB_CONTAINER, or HF_COOLIFY_POSTGRES_UUID — so the tunnel can discover one.");
|
|
51
50
|
}
|
|
52
|
-
const restoreTarget = urlOnto(options.restoreAdminUrl ?? clusterUrl, scratchDatabase);
|
|
53
51
|
const now = options.now ?? new Date();
|
|
54
52
|
const dumpAgeHours = (now.getTime() - dump.takenAt.getTime()) / 3_600_000;
|
|
55
53
|
try {
|
|
@@ -73,7 +71,7 @@ export async function restoreCheck(options) {
|
|
|
73
71
|
await db.query(`GRANT CONNECT, CREATE ON DATABASE ${quoteIdent(scratchDatabase)} ` +
|
|
74
72
|
`TO ${quoteIdent(names.migratorRole)}`);
|
|
75
73
|
await scratch.query(`GRANT CREATE, USAGE ON SCHEMA public TO ${quoteIdent(names.migratorRole)}`);
|
|
76
|
-
await runRestore(options,
|
|
74
|
+
await runRestore(options, scratchDatabase, names.migratorRole, dump.path);
|
|
77
75
|
rows = compare(await countTables(db, names.databaseName), await countTables(scratch));
|
|
78
76
|
}
|
|
79
77
|
finally {
|
|
@@ -101,14 +99,16 @@ export async function restoreCheck(options) {
|
|
|
101
99
|
await db.close();
|
|
102
100
|
}
|
|
103
101
|
}
|
|
104
|
-
/**
|
|
102
|
+
/**
|
|
103
|
+
* The argv `restoreCheck` runs — the array the test asserts against.
|
|
104
|
+
*
|
|
105
|
+
* @deprecated The box host has no `pg_restore`; the restore runs inside the Postgres container.
|
|
106
|
+
* Use `pgRestoreInContainerArgv`. Removed in 0.2.0.
|
|
107
|
+
*/
|
|
105
108
|
export function pgRestoreArgv(options) {
|
|
106
109
|
return [
|
|
107
110
|
options.pgRestorePath ?? "pg_restore",
|
|
108
111
|
"--no-owner",
|
|
109
|
-
// Every object comes out owned by the migrator, as in the live database. `--no-comments`
|
|
110
|
-
// because a dump's `COMMENT ON EXTENSION` belongs to the admin that created the extension,
|
|
111
|
-
// and a comment nobody may set is not a restore failure.
|
|
112
112
|
"--no-comments",
|
|
113
113
|
`--role=${options.role}`,
|
|
114
114
|
"--dbname",
|
|
@@ -116,15 +116,56 @@ export function pgRestoreArgv(options) {
|
|
|
116
116
|
options.file,
|
|
117
117
|
];
|
|
118
118
|
}
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
119
|
+
/**
|
|
120
|
+
* The argv `restoreCheck` runs — the array the test asserts against.
|
|
121
|
+
*
|
|
122
|
+
* No `--dbname` URL: inside the container the server is on the local socket, so the admin user
|
|
123
|
+
* and the database name are all it takes and no password crosses an argv. The dump arrives on
|
|
124
|
+
* stdin, which is why there is no file argument either.
|
|
125
|
+
*/
|
|
126
|
+
export function pgRestoreInContainerArgv(options) {
|
|
127
|
+
return [
|
|
128
|
+
"docker",
|
|
129
|
+
"exec",
|
|
130
|
+
"-i",
|
|
131
|
+
options.container,
|
|
132
|
+
options.pgRestorePath ?? "pg_restore",
|
|
133
|
+
"--no-owner",
|
|
134
|
+
// Every object comes out owned by the migrator, as in the live database. `--no-comments`
|
|
135
|
+
// because a dump's `COMMENT ON EXTENSION` belongs to the admin that created the extension,
|
|
136
|
+
// and a comment nobody may set is not a restore failure.
|
|
137
|
+
"--no-comments",
|
|
138
|
+
`--role=${options.role}`,
|
|
139
|
+
"-U",
|
|
140
|
+
options.adminUser,
|
|
141
|
+
"-d",
|
|
142
|
+
options.database,
|
|
143
|
+
];
|
|
144
|
+
}
|
|
145
|
+
async function runRestore(options, database, role, file) {
|
|
146
|
+
const { container } = options;
|
|
147
|
+
const argv = pgRestoreInContainerArgv({
|
|
148
|
+
container,
|
|
149
|
+
adminUser: options.adminUser ?? DEFAULT_PG_ADMIN_USER,
|
|
150
|
+
database,
|
|
151
|
+
role,
|
|
152
|
+
pgRestorePath: options.pgRestorePath,
|
|
153
|
+
});
|
|
154
|
+
const result = await options.runner.exec(argv, { inputFile: file });
|
|
155
|
+
if (result.code === 0)
|
|
156
|
+
return;
|
|
157
|
+
// Anything `pg_restore` echoed of a connection string carries the cluster's admin password.
|
|
158
|
+
const stderr = redactPasswords(result.stderr.trim());
|
|
159
|
+
throw new RestoreCheckError(`pg_restore exited ${String(result.code)} restoring ${file} in ${container}: ${stderr}` +
|
|
160
|
+
(result.code === NOT_FOUND_EXIT
|
|
161
|
+
? ` — exit ${String(NOT_FOUND_EXIT)} means "command not found". The box host has no ` +
|
|
162
|
+
"Postgres client tools; only the container does. Check that docker is on the box's " +
|
|
163
|
+
`PATH and that ${container} is the Postgres container (HF_DB_CONTAINER, or ` +
|
|
164
|
+
"HF_COOLIFY_POSTGRES_UUID)."
|
|
165
|
+
: ""));
|
|
127
166
|
}
|
|
167
|
+
/** A shell's, and `docker exec`'s, "command not found". */
|
|
168
|
+
const NOT_FOUND_EXIT = 127;
|
|
128
169
|
/**
|
|
129
170
|
* Row counts for every table the app's data lives in: `hf_*`, plus every table carrying
|
|
130
171
|
* `normalized_name`, which is how `@hyperfixation/db` spells a record table the app declared.
|
|
@@ -219,7 +260,8 @@ export async function restoreCheckApp(options) {
|
|
|
219
260
|
const { HF_SSH_HOST } = requireOperatorConfig(config, ["HF_SSH_HOST"], { env });
|
|
220
261
|
const runner = createSshRunner({ host: HF_SSH_HOST });
|
|
221
262
|
const admin = { user: pgAdminUser(config), password: env.PGPASSWORD };
|
|
222
|
-
const
|
|
263
|
+
const containers = postgresContainers(config);
|
|
264
|
+
const db = await openDatabase(runner, { admin, containers });
|
|
223
265
|
try {
|
|
224
266
|
return await restoreCheck({
|
|
225
267
|
app: options.app,
|
|
@@ -229,7 +271,8 @@ export async function restoreCheckApp(options) {
|
|
|
229
271
|
: createLocalDirectoryBackupSource({ runner, directory: options.backupDir }),
|
|
230
272
|
runner,
|
|
231
273
|
database: db,
|
|
232
|
-
|
|
274
|
+
container: await restoreContainer(runner, db, containers),
|
|
275
|
+
adminUser: admin.user,
|
|
233
276
|
});
|
|
234
277
|
}
|
|
235
278
|
finally {
|
|
@@ -237,20 +280,21 @@ export async function restoreCheckApp(options) {
|
|
|
237
280
|
}
|
|
238
281
|
}
|
|
239
282
|
/**
|
|
240
|
-
* The
|
|
283
|
+
* The container `pg_restore` runs inside.
|
|
241
284
|
*
|
|
242
|
-
* `
|
|
243
|
-
* a
|
|
244
|
-
*
|
|
285
|
+
* `openDatabase` already knows it whenever the box's loopback had no listener, which is the box
|
|
286
|
+
* Coolify builds; a box that does publish 5432 answers on the loopback and never looks, so the
|
|
287
|
+
* discovery runs here instead of leaving the check with nowhere to restore.
|
|
245
288
|
*/
|
|
246
|
-
function
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
289
|
+
async function restoreContainer(runner, db, containers) {
|
|
290
|
+
if (db.container !== undefined)
|
|
291
|
+
return db.container;
|
|
292
|
+
if (containers.length === 0) {
|
|
293
|
+
throw new RestoreCheckError("pg_restore has to run inside the Postgres container — the box host has no Postgres " +
|
|
294
|
+
"client tools — and no container was named. Set HF_DB_CONTAINER, or " +
|
|
295
|
+
"HF_COOLIFY_POSTGRES_UUID, in the operator config.");
|
|
296
|
+
}
|
|
297
|
+
return (await findPostgresContainer(runner, containers)).container;
|
|
254
298
|
}
|
|
255
299
|
function urlOnto(connectionString, database) {
|
|
256
300
|
const url = new URL(connectionString);
|
package/dist/runner.d.ts
CHANGED
|
@@ -1,6 +1,14 @@
|
|
|
1
1
|
export interface ExecOptions {
|
|
2
2
|
/** Written to the command's stdin and then closed. SQL goes here, never into argv. */
|
|
3
3
|
input?: string;
|
|
4
|
+
/**
|
|
5
|
+
* A file **on the far side**, streamed into the command's stdin instead of `input`.
|
|
6
|
+
*
|
|
7
|
+
* How a dump on the box reaches a `pg_restore` that runs inside the Postgres container: the
|
|
8
|
+
* file is the host's and the process is the container's, so nothing but stdin spans the two.
|
|
9
|
+
* Takes precedence over `input`.
|
|
10
|
+
*/
|
|
11
|
+
inputFile?: string;
|
|
4
12
|
}
|
|
5
13
|
export interface ExecResult {
|
|
6
14
|
/** `null` when the command was killed by a signal or by `timeoutMs`. */
|
|
@@ -36,7 +44,7 @@ export declare class RunnerError extends Error {
|
|
|
36
44
|
});
|
|
37
45
|
}
|
|
38
46
|
/** The argv `exec` runs, `ssh` excluded — the array the test asserts against. */
|
|
39
|
-
export declare function sshExecArgv(host: string, command: readonly string[]): string[];
|
|
47
|
+
export declare function sshExecArgv(host: string, command: readonly string[], options?: ExecOptions): string[];
|
|
40
48
|
export declare function sshTunnelArgv(host: string, localPort: number, remotePort: number, remoteHost?: string): string[];
|
|
41
49
|
/** Where a forward lands when the caller names no host: the far side's own loopback. */
|
|
42
50
|
export declare const TUNNEL_LOOPBACK = "127.0.0.1";
|
package/dist/runner.js
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { spawn } from "node:child_process";
|
|
2
|
+
import { createReadStream } from "node:fs";
|
|
2
3
|
import { createServer, connect as connectTcp } from "node:net";
|
|
3
4
|
export class RunnerError extends Error {
|
|
4
5
|
constructor(message, options) {
|
|
@@ -31,11 +32,16 @@ const SSH_OPTIONS = [
|
|
|
31
32
|
"ControlPath=none",
|
|
32
33
|
];
|
|
33
34
|
/** The argv `exec` runs, `ssh` excluded — the array the test asserts against. */
|
|
34
|
-
export function sshExecArgv(host, command) {
|
|
35
|
+
export function sshExecArgv(host, command, options = {}) {
|
|
35
36
|
assertHost(host);
|
|
36
37
|
// `ssh` hands the remote end one string and the login shell splits it, so the array has to be
|
|
37
|
-
// re-quoted for that shell; nothing else in this file ever builds a shell word.
|
|
38
|
-
|
|
38
|
+
// re-quoted for that shell; nothing else in this file ever builds a shell word. `inputFile`
|
|
39
|
+
// becomes that shell's own `<` redirection, which is the only way a far-side file reaches the
|
|
40
|
+
// stdin of a far-side command without being pulled across the link first.
|
|
41
|
+
const remote = options.inputFile === undefined
|
|
42
|
+
? shellQuote(command)
|
|
43
|
+
: `${shellQuote(command)} < ${shellQuote([options.inputFile])}`;
|
|
44
|
+
return ["-T", ...SSH_OPTIONS, host, remote];
|
|
39
45
|
}
|
|
40
46
|
export function sshTunnelArgv(host, localPort, remotePort, remoteHost = TUNNEL_LOOPBACK) {
|
|
41
47
|
assertHost(host);
|
|
@@ -61,7 +67,13 @@ export function createSshRunner(options) {
|
|
|
61
67
|
const ssh = options.sshPath ?? "ssh";
|
|
62
68
|
assertHost(options.host);
|
|
63
69
|
return {
|
|
64
|
-
exec: async (command, execOptions) =>
|
|
70
|
+
exec: async (command, execOptions) =>
|
|
71
|
+
// The redirection is the remote shell's, so `inputFile` is spent building the remote word
|
|
72
|
+
// and must not also be opened on this machine.
|
|
73
|
+
await spawnCollecting(ssh, sshExecArgv(options.host, command, execOptions), {
|
|
74
|
+
...execOptions,
|
|
75
|
+
inputFile: undefined,
|
|
76
|
+
}),
|
|
65
77
|
tunnel: async (remotePort, remoteHost = TUNNEL_LOOPBACK) => {
|
|
66
78
|
const localPort = await freeLocalPort();
|
|
67
79
|
const child = spawn(ssh, sshTunnelArgv(options.host, localPort, remotePort, remoteHost), {
|
|
@@ -134,19 +146,28 @@ async function spawnCollecting(bin, args, options = {}) {
|
|
|
134
146
|
child.stderr?.on("data", (chunk) => {
|
|
135
147
|
stderr += chunk.toString("utf8");
|
|
136
148
|
});
|
|
149
|
+
const file = options.inputFile === undefined ? undefined : createReadStream(options.inputFile);
|
|
137
150
|
const closed = new Promise((resolve, reject) => {
|
|
138
151
|
// A child that exits before reading its stdin (`true`, a refused ssh) makes the write fail with
|
|
139
152
|
// EPIPE; the exit code already says what happened. Any other stdin error is a real failure.
|
|
140
153
|
child.stdin?.on("error", (error) => {
|
|
154
|
+
file?.destroy();
|
|
141
155
|
if (error.code === "EPIPE")
|
|
142
156
|
return;
|
|
143
157
|
child.kill();
|
|
144
158
|
reject(error);
|
|
145
159
|
});
|
|
160
|
+
file?.once("error", (cause) => {
|
|
161
|
+
child.kill();
|
|
162
|
+
reject(new RunnerError(`could not read ${String(options.inputFile)} into stdin`, { cause }));
|
|
163
|
+
});
|
|
146
164
|
child.once("error", reject);
|
|
147
165
|
child.once("close", (exitCode) => resolve(exitCode));
|
|
148
166
|
});
|
|
149
|
-
|
|
167
|
+
if (file === undefined)
|
|
168
|
+
child.stdin?.end(options.input ?? "");
|
|
169
|
+
else if (child.stdin !== null)
|
|
170
|
+
file.pipe(child.stdin);
|
|
150
171
|
const code = await closed;
|
|
151
172
|
return { code, stdout, stderr };
|
|
152
173
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@hyperfixation/cli",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.6",
|
|
4
4
|
"license": "MIT",
|
|
5
5
|
"description": "The hf binary and its Turborepo generator templates",
|
|
6
6
|
"repository": {
|
|
@@ -29,15 +29,15 @@
|
|
|
29
29
|
"!dist/test-support/**"
|
|
30
30
|
],
|
|
31
31
|
"dependencies": {
|
|
32
|
-
"@hyperfixation/auth": "0.1.
|
|
33
|
-
"@hyperfixation/core": "0.1.
|
|
34
|
-
"@hyperfixation/db": "0.1.
|
|
32
|
+
"@hyperfixation/auth": "0.1.6",
|
|
33
|
+
"@hyperfixation/core": "0.1.6",
|
|
34
|
+
"@hyperfixation/db": "0.1.6",
|
|
35
35
|
"giget": "3.3.1",
|
|
36
36
|
"pg": "^8.23.0"
|
|
37
37
|
},
|
|
38
38
|
"devDependencies": {
|
|
39
|
-
"@hyperfixation/eslint-config": "0.1.
|
|
40
|
-
"@hyperfixation/testing": "0.1.
|
|
39
|
+
"@hyperfixation/eslint-config": "0.1.6",
|
|
40
|
+
"@hyperfixation/testing": "0.1.6",
|
|
41
41
|
"@microsoft/api-extractor": "^7.59.1",
|
|
42
42
|
"@types/pg": "^8.23.1",
|
|
43
43
|
"eslint": "^10.10.0",
|