@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.
@@ -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, DEFAULT_POSTGRES_PORT, } from "./database.js";
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 run over the ${db.kind} transport: pg_restore needs an address. ` +
49
- "Name the Postgres container HF_DB_CONTAINER, or HF_COOLIFY_POSTGRES_UUID so the " +
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, restoreTarget, names.migratorRole, dump.path);
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
- /** The argv `restoreCheck` runs — the array the test asserts against. */
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
- async function runRestore(options, url, role, file) {
120
- const argv = pgRestoreArgv({ url, role, file, pgRestorePath: options.pgRestorePath });
121
- const result = await options.runner.exec(argv);
122
- if (result.code !== 0) {
123
- // `url` carries the cluster's admin password, and so does anything pg_restore echoed of it.
124
- throw new RestoreCheckError(`pg_restore exited ${String(result.code)} restoring ${file}: ` +
125
- redactPasswords(result.stderr.trim()));
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 db = await openDatabase(runner, { admin, containers: postgresContainers(config) });
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
- restoreAdminUrl: boxAdminUrl(admin, db.boxAddress),
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 cluster as the box itself sees it, where `pg_restore` runs.
283
+ * The container `pg_restore` runs inside.
241
284
  *
242
- * `address` is whatever the tunnel settled on: with 5432 unpublished the box's loopback is no more
243
- * a listener for `pg_restore` than for the forward, and the container's address on the docker
244
- * network is what both have to dial.
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 boxAdminUrl(admin, address) {
247
- const url = new URL(`postgresql://${address?.host ?? "127.0.0.1"}`);
248
- url.port = String(address?.port ?? DEFAULT_POSTGRES_PORT);
249
- url.username = encodeURIComponent(admin.user);
250
- if (admin.password !== undefined)
251
- url.password = encodeURIComponent(admin.password);
252
- url.pathname = `/${CLUSTER_ADMIN_DATABASE}`;
253
- return url.toString();
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
- return ["-T", ...SSH_OPTIONS, host, shellQuote(command)];
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) => await spawnCollecting(ssh, sshExecArgv(options.host, 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
- child.stdin?.end(options.input ?? "");
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.4",
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.4",
33
- "@hyperfixation/core": "0.1.4",
34
- "@hyperfixation/db": "0.1.4",
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.4",
40
- "@hyperfixation/testing": "0.1.4",
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",