@hyperfixation/cli 0.1.0 → 0.1.2

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.
Files changed (75) hide show
  1. package/dist/app.d.ts +15 -2
  2. package/dist/app.js +4 -2
  3. package/dist/backup-source.d.ts +47 -0
  4. package/dist/backup-source.js +107 -0
  5. package/dist/bootstrap.d.ts +2 -0
  6. package/dist/bootstrap.js +1 -1
  7. package/dist/checklist.d.ts +25 -0
  8. package/dist/checklist.js +32 -0
  9. package/dist/cli.d.ts +2 -2
  10. package/dist/cli.js +95 -2
  11. package/dist/cloud-steps/backup.d.ts +17 -0
  12. package/dist/cloud-steps/backup.js +40 -0
  13. package/dist/cloud-steps/context.d.ts +120 -0
  14. package/dist/cloud-steps/context.js +88 -0
  15. package/dist/cloud-steps/coolify.d.ts +84 -0
  16. package/dist/cloud-steps/coolify.js +316 -0
  17. package/dist/cloud-steps/database.d.ts +12 -0
  18. package/dist/cloud-steps/database.js +25 -0
  19. package/dist/cloud-steps/deploy.d.ts +18 -0
  20. package/dist/cloud-steps/deploy.js +110 -0
  21. package/dist/cloud-steps/dns.d.ts +11 -0
  22. package/dist/cloud-steps/dns.js +53 -0
  23. package/dist/cloud-steps/index.d.ts +21 -0
  24. package/dist/cloud-steps/index.js +30 -0
  25. package/dist/cloud-steps/install.d.ts +12 -0
  26. package/dist/cloud-steps/install.js +53 -0
  27. package/dist/cloud-steps/langfuse.d.ts +17 -0
  28. package/dist/cloud-steps/langfuse.js +71 -0
  29. package/dist/cloud-steps/repo.d.ts +20 -0
  30. package/dist/cloud-steps/repo.js +198 -0
  31. package/dist/cloud-steps/sentry.d.ts +13 -0
  32. package/dist/cloud-steps/sentry.js +55 -0
  33. package/dist/cloud-steps/template.d.ts +22 -0
  34. package/dist/cloud-steps/template.js +68 -0
  35. package/dist/config.d.ts +65 -0
  36. package/dist/config.js +192 -0
  37. package/dist/database.d.ts +95 -0
  38. package/dist/database.js +226 -0
  39. package/dist/doctor.d.ts +72 -0
  40. package/dist/doctor.js +368 -0
  41. package/dist/index.d.ts +6 -1
  42. package/dist/index.js +5 -0
  43. package/dist/migrate.d.ts +11 -0
  44. package/dist/migrate.js +26 -2
  45. package/dist/new-cloud.d.ts +135 -0
  46. package/dist/new-cloud.js +219 -0
  47. package/dist/new.d.ts +2 -0
  48. package/dist/new.js +2 -1
  49. package/dist/providers/cloudflare.d.ts +49 -0
  50. package/dist/providers/cloudflare.js +27 -0
  51. package/dist/providers/coolify.d.ts +148 -0
  52. package/dist/providers/coolify.js +87 -0
  53. package/dist/providers/github.d.ts +117 -0
  54. package/dist/providers/github.js +98 -0
  55. package/dist/providers/http.d.ts +41 -0
  56. package/dist/providers/http.js +56 -0
  57. package/dist/providers/langfuse.d.ts +41 -0
  58. package/dist/providers/langfuse.js +29 -0
  59. package/dist/providers/sentry.d.ts +31 -0
  60. package/dist/providers/sentry.js +27 -0
  61. package/dist/provision-database.d.ts +42 -0
  62. package/dist/provision-database.js +107 -0
  63. package/dist/restore-check.d.ts +91 -0
  64. package/dist/restore-check.js +262 -0
  65. package/dist/runner.d.ts +72 -0
  66. package/dist/runner.js +221 -0
  67. package/dist/secret-file.d.ts +30 -0
  68. package/dist/secret-file.js +69 -0
  69. package/dist/state.d.ts +124 -0
  70. package/dist/state.js +217 -0
  71. package/dist/status-token.d.ts +2 -0
  72. package/dist/status-token.js +1 -1
  73. package/dist/template-source.d.ts +23 -0
  74. package/dist/template-source.js +23 -0
  75. package/package.json +10 -7
@@ -0,0 +1,107 @@
1
+ import { randomBytes } from "node:crypto";
2
+ import { quoteIdent } from "@hyperfixation/db";
3
+ import { provisionRoles, roleNames } from "@hyperfixation/db/migrator";
4
+ import { openDatabaseUrl } from "./database.js";
5
+ import { deriveNames } from "./names.js";
6
+ /** Created in the app's database before its first migration; both are `hf_*` table columns. */
7
+ export const REQUIRED_EXTENSIONS = ["vector", "pg_trgm"];
8
+ export class ProvisionDatabaseError extends Error {
9
+ constructor(message) {
10
+ super(message);
11
+ this.name = "ProvisionDatabaseError";
12
+ }
13
+ }
14
+ /**
15
+ * The app's database, its extensions and its three roles, resumable and safe to rerun.
16
+ *
17
+ * Passwords live only in the state cache, and the order here is what keeps that honest: every
18
+ * password is written to the file **after** the cluster has accepted it, never before. A crash
19
+ * in between therefore leaves a state file that lags the database rather than one that leads
20
+ * it, and the next run — which still sees the step unrecorded, and still has no password to
21
+ * reuse — generates a fresh one and `ALTER`s again. Converging costs one more rotation; the
22
+ * other order would leave a file whose passwords nothing can log in with, and those files are
23
+ * the only copy there is.
24
+ */
25
+ export async function provisionDatabase(target, options) {
26
+ // `deriveNames` is `assertAppName`'s rule with hyphens allowed in the typed name only, so a
27
+ // name carrying a quote, a semicolon or a `$(` is refused here rather than quoted downstream.
28
+ const names = deriveNames(options.app);
29
+ const roles = roleNames(names.appName);
30
+ const { state } = options;
31
+ const stored = state.state.database ?? {};
32
+ if (state.isDone("database") &&
33
+ stored.migratorPassword !== undefined &&
34
+ stored.applicationPassword !== undefined) {
35
+ return {
36
+ databaseName: names.databaseName,
37
+ roles,
38
+ createdDatabase: false,
39
+ alreadyDone: true,
40
+ rotated: false,
41
+ };
42
+ }
43
+ const db = typeof target === "string" ? openDatabaseUrl(target) : target;
44
+ const adminUrl = db.adminUrl();
45
+ if (adminUrl === undefined) {
46
+ throw new ProvisionDatabaseError(`roles cannot be provisioned over the ${db.kind} transport: provisionRoles() is a pg ` +
47
+ "client and needs an address. Name the Postgres container — HF_DB_CONTAINER, or " +
48
+ "HF_COOLIFY_POSTGRES_UUID — so the tunnel can discover one.");
49
+ }
50
+ try {
51
+ const createdDatabase = await createDatabaseIfAbsent(db, names.databaseName);
52
+ for (const extension of REQUIRED_EXTENSIONS) {
53
+ await db.query(`CREATE EXTENSION IF NOT EXISTS ${quoteIdent(extension)}`, {
54
+ database: names.databaseName,
55
+ });
56
+ }
57
+ const wanted = options.readonlyRole !== false;
58
+ // A cold run is one with no password to reuse; it is a *rotation* only when the roles are
59
+ // already there, which is the case that strands a deployed app on its old credentials.
60
+ const regenerating = stored.migratorPassword === undefined || stored.applicationPassword === undefined;
61
+ const rotated = regenerating && (await anyRoleExists(db, [roles.migrator, roles.application]));
62
+ const provisioned = await provisionRoles(adminUrl, {
63
+ appName: names.appName,
64
+ databaseName: names.databaseName,
65
+ migratorPassword: stored.migratorPassword,
66
+ applicationPassword: stored.applicationPassword,
67
+ readonlyPassword: wanted ? (stored.readonlyPassword ?? generatePassword()) : undefined,
68
+ });
69
+ await state.patch({
70
+ database: {
71
+ migratorPassword: provisioned.migratorPassword,
72
+ applicationPassword: provisioned.applicationPassword,
73
+ ...(provisioned.readonlyPassword === undefined
74
+ ? {}
75
+ : { readonlyPassword: provisioned.readonlyPassword }),
76
+ },
77
+ });
78
+ await state.markDone("database");
79
+ return { databaseName: names.databaseName, roles, createdDatabase, alreadyDone: false, rotated };
80
+ }
81
+ finally {
82
+ if (typeof target === "string")
83
+ await db.close();
84
+ }
85
+ }
86
+ async function createDatabaseIfAbsent(db, databaseName) {
87
+ const { rows } = await db.query(`SELECT 1 FROM pg_database WHERE datname = ${quoteLiteral(databaseName)}`);
88
+ if (rows.length > 0)
89
+ return false;
90
+ await db.query(`CREATE DATABASE ${quoteIdent(databaseName)}`);
91
+ return true;
92
+ }
93
+ async function anyRoleExists(db, roles) {
94
+ const list = roles.map(quoteLiteral).join(", ");
95
+ const { rows } = await db.query(`SELECT 1 FROM pg_roles WHERE rolname IN (${list})`);
96
+ return rows.length > 0;
97
+ }
98
+ /**
99
+ * The `_ro` password. `provisionRoles` generates the other two itself, but creates the
100
+ * read-only role only when it is handed one.
101
+ */
102
+ function generatePassword() {
103
+ return randomBytes(24).toString("base64url");
104
+ }
105
+ function quoteLiteral(value) {
106
+ return `'${value.replaceAll("'", "''")}'`;
107
+ }
@@ -0,0 +1,91 @@
1
+ import { type BackupDump, type BackupSource } from "./backup-source.js";
2
+ import { type Database } from "./database.js";
3
+ import { type Runner } from "./runner.js";
4
+ import { type AppStateStore } from "./state.js";
5
+ /** Appended to `hf_<app>` for the database the dump is restored into and then dropped. */
6
+ export declare const SCRATCH_SUFFIX = "_restore_check";
7
+ /** Older than this and the dump gets a warning line; it never changes the exit code. */
8
+ export declare const STALE_DUMP_HOURS = 36;
9
+ export type RestoreVerdict = "ok" | "mismatch" | "live only" | "restored only";
10
+ export interface RestoreCheckRow {
11
+ table: string;
12
+ /** Absent when the table is not in the live database. */
13
+ live?: number;
14
+ /** Absent when the table is not in the restored dump. */
15
+ restored?: number;
16
+ verdict: RestoreVerdict;
17
+ }
18
+ export interface RestoreCheckResult {
19
+ databaseName: string;
20
+ /** Created and dropped by this call; never left behind. */
21
+ scratchDatabase: string;
22
+ dump: BackupDump;
23
+ dumpAgeHours: number;
24
+ /** The dump is older than `STALE_DUMP_HOURS`. Informational. */
25
+ dumpStale: boolean;
26
+ /** One row per table, `hf_*` or carrying `normalized_name`, sorted by name. */
27
+ rows: readonly RestoreCheckRow[];
28
+ /** Every row's verdict is `ok`. The command's exit code is `ok ? 0 : 1`. */
29
+ ok: boolean;
30
+ }
31
+ export declare class RestoreCheckError extends Error {
32
+ constructor(message: string);
33
+ }
34
+ export interface RestoreCheckOptions {
35
+ /** The name `hf new` was given; `hf_<app>` and the migrator role derive from it. */
36
+ app: string;
37
+ state: AppStateStore;
38
+ source: BackupSource;
39
+ /** Where `pg_restore` runs: the box, or this machine in a test. */
40
+ runner: Runner;
41
+ /** How the scratch database is created and both sides are counted. */
42
+ database: Database | string;
43
+ /**
44
+ * The cluster's admin URL **as the runner sees it**, for `pg_restore`.
45
+ *
46
+ * Not the same address as `database`: the laptop reaches the cluster through an `ssh -L`
47
+ * forward onto a local port, and a `pg_restore` running on the far side of that forward has to
48
+ * dial the box's own loopback. Defaults to `database`'s URL, which is what a test wants when
49
+ * both sides are the same machine.
50
+ */
51
+ restoreAdminUrl?: string;
52
+ /** The `pg_restore` binary on the runner. */
53
+ pgRestorePath?: string;
54
+ now?: Date;
55
+ }
56
+ /**
57
+ * Restores the newest dump of `hf_<app>` beside the live database and compares row counts.
58
+ *
59
+ * The scratch database is dropped in a `finally`: it holds a full copy of the app's data, so
60
+ * leaving one behind on a failure would double the disk the app uses until someone noticed.
61
+ *
62
+ * `lastRestoreCheckAt` is written only by a run that counted both sides and found them equal.
63
+ * `hf doctor` warns on a stale timestamp, so a check that died halfway — or one that found a
64
+ * mismatch — has to leave the warning standing until a check actually passes.
65
+ */
66
+ export declare function restoreCheck(options: RestoreCheckOptions): Promise<RestoreCheckResult>;
67
+ /** The argv `restoreCheck` runs — the array the test asserts against. */
68
+ export declare function pgRestoreArgv(options: {
69
+ url: string;
70
+ role: string;
71
+ file: string;
72
+ pgRestorePath?: string;
73
+ }): string[];
74
+ /** The table `hf restore-check` prints, and the two lines around it. */
75
+ export declare function formatRestoreCheck(result: RestoreCheckResult): string[];
76
+ export interface RestoreCheckAppOptions {
77
+ app: string;
78
+ /** Where the dumps are; defaults to Coolify's backup directory on the box. */
79
+ backupDir?: string;
80
+ /** Read the dump from Hetzner object storage instead. Not implemented; see `backup-source`. */
81
+ fromS3?: boolean;
82
+ env?: NodeJS.ProcessEnv;
83
+ }
84
+ /**
85
+ * `hf restore-check <name>`: the operator config, an `ssh` runner onto the box, and the check.
86
+ *
87
+ * The cluster admin password comes from libpq's own `PGPASSWORD` until E3 records Coolify's — the
88
+ * operator config has no key for it, and inventing one before the box has been looked at is
89
+ * exactly what risk 3 warns against.
90
+ */
91
+ export declare function restoreCheckApp(options: RestoreCheckAppOptions): Promise<RestoreCheckResult>;
@@ -0,0 +1,262 @@
1
+ import { quoteIdent } from "@hyperfixation/db";
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";
5
+ import { deriveNames } from "./names.js";
6
+ import { REQUIRED_EXTENSIONS } from "./provision-database.js";
7
+ import { createSshRunner } from "./runner.js";
8
+ import { openAppState } from "./state.js";
9
+ /** Appended to `hf_<app>` for the database the dump is restored into and then dropped. */
10
+ export const SCRATCH_SUFFIX = "_restore_check";
11
+ /** Postgres truncates an identifier past this, which would collide with the live database. */
12
+ const MAX_IDENTIFIER_BYTES = 63;
13
+ /** Older than this and the dump gets a warning line; it never changes the exit code. */
14
+ export const STALE_DUMP_HOURS = 36;
15
+ const CLUSTER_ADMIN_DATABASE = "postgres";
16
+ export class RestoreCheckError extends Error {
17
+ constructor(message) {
18
+ super(message);
19
+ this.name = "RestoreCheckError";
20
+ }
21
+ }
22
+ /**
23
+ * Restores the newest dump of `hf_<app>` beside the live database and compares row counts.
24
+ *
25
+ * The scratch database is dropped in a `finally`: it holds a full copy of the app's data, so
26
+ * leaving one behind on a failure would double the disk the app uses until someone noticed.
27
+ *
28
+ * `lastRestoreCheckAt` is written only by a run that counted both sides and found them equal.
29
+ * `hf doctor` warns on a stale timestamp, so a check that died halfway — or one that found a
30
+ * mismatch — has to leave the warning standing until a check actually passes.
31
+ */
32
+ export async function restoreCheck(options) {
33
+ // Refuses a name carrying a quote, a semicolon or a glob before it reaches SQL, `find` or
34
+ // `pg_restore`'s argv.
35
+ const names = deriveNames(options.app);
36
+ const scratchDatabase = `${names.databaseName}${SCRATCH_SUFFIX}`;
37
+ if (Buffer.byteLength(scratchDatabase) > MAX_IDENTIFIER_BYTES) {
38
+ throw new RestoreCheckError(`${scratchDatabase} is longer than Postgres's ${String(MAX_IDENTIFIER_BYTES)}-byte ` +
39
+ `identifier limit, and a truncated name would collide with another database`);
40
+ }
41
+ const dump = await options.source.newest(names.databaseName);
42
+ if (dump === undefined) {
43
+ throw new RestoreCheckError(`no ${names.databaseName} dump in the ${options.source.kind} backup source: nothing to check`);
44
+ }
45
+ const db = typeof options.database === "string" ? openDatabaseUrl(options.database) : options.database;
46
+ const clusterUrl = db.adminUrl();
47
+ 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.");
51
+ }
52
+ const restoreTarget = urlOnto(options.restoreAdminUrl ?? clusterUrl, scratchDatabase);
53
+ const now = options.now ?? new Date();
54
+ const dumpAgeHours = (now.getTime() - dump.takenAt.getTime()) / 3_600_000;
55
+ try {
56
+ // A scratch database left by a killed run is the one thing in the way of this one.
57
+ await db.query(`DROP DATABASE IF EXISTS ${quoteIdent(scratchDatabase)} WITH (FORCE)`);
58
+ await db.query(`CREATE DATABASE ${quoteIdent(scratchDatabase)}`);
59
+ try {
60
+ // Its own connection, so that it can be closed before the drop: `WITH (FORCE)` terminates
61
+ // the backends it finds, and a `pg` client whose backend was killed under it raises an
62
+ // error event nothing is listening for.
63
+ const scratch = openDatabaseUrl(urlOnto(clusterUrl, scratchDatabase));
64
+ let rows;
65
+ try {
66
+ for (const extension of REQUIRED_EXTENSIONS) {
67
+ await scratch.query(`CREATE EXTENSION IF NOT EXISTS ${quoteIdent(extension)}`);
68
+ }
69
+ // What `provisionRoles` grants the migrator in the live database. Without them the
70
+ // restore runs as a role that may not create anything: the extensions have to be created
71
+ // by the admin (pgvector needs a superuser) but everything the dump carries is the
72
+ // migrator's, which is the ownership the live database has.
73
+ await db.query(`GRANT CONNECT, CREATE ON DATABASE ${quoteIdent(scratchDatabase)} ` +
74
+ `TO ${quoteIdent(names.migratorRole)}`);
75
+ await scratch.query(`GRANT CREATE, USAGE ON SCHEMA public TO ${quoteIdent(names.migratorRole)}`);
76
+ await runRestore(options, restoreTarget, names.migratorRole, dump.path);
77
+ rows = compare(await countTables(db, names.databaseName), await countTables(scratch));
78
+ }
79
+ finally {
80
+ await scratch.close();
81
+ }
82
+ const ok = rows.every((row) => row.verdict === "ok");
83
+ if (ok)
84
+ await options.state.patch({ lastRestoreCheckAt: now.toISOString() });
85
+ return {
86
+ databaseName: names.databaseName,
87
+ scratchDatabase,
88
+ dump,
89
+ dumpAgeHours,
90
+ dumpStale: dumpAgeHours > STALE_DUMP_HOURS,
91
+ rows,
92
+ ok,
93
+ };
94
+ }
95
+ finally {
96
+ await db.query(`DROP DATABASE IF EXISTS ${quoteIdent(scratchDatabase)} WITH (FORCE)`);
97
+ }
98
+ }
99
+ finally {
100
+ if (typeof options.database === "string")
101
+ await db.close();
102
+ }
103
+ }
104
+ /** The argv `restoreCheck` runs — the array the test asserts against. */
105
+ export function pgRestoreArgv(options) {
106
+ return [
107
+ options.pgRestorePath ?? "pg_restore",
108
+ "--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
+ "--no-comments",
113
+ `--role=${options.role}`,
114
+ "--dbname",
115
+ options.url,
116
+ options.file,
117
+ ];
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
+ }
127
+ }
128
+ /**
129
+ * Row counts for every table the app's data lives in: `hf_*`, plus every table carrying
130
+ * `normalized_name`, which is how `@hyperfixation/db` spells a record table the app declared.
131
+ */
132
+ async function countTables(db, database) {
133
+ const { rows: tables } = await db.query(`SELECT c.relname
134
+ FROM pg_class c
135
+ JOIN pg_namespace n ON n.oid = c.relnamespace
136
+ WHERE n.nspname = 'public'
137
+ AND c.relkind IN ('r', 'p')
138
+ AND (c.relname LIKE 'hf\\_%'
139
+ OR EXISTS (SELECT 1
140
+ FROM pg_attribute a
141
+ WHERE a.attrelid = c.oid
142
+ AND a.attname = 'normalized_name'
143
+ AND a.attnum > 0
144
+ AND NOT a.attisdropped))
145
+ ORDER BY c.relname`, { database });
146
+ const names = tables.map((row) => row[0]).filter((name) => name !== undefined);
147
+ const counts = new Map();
148
+ if (names.length === 0)
149
+ return counts;
150
+ const { rows } = await db.query(names
151
+ .map((name) => `SELECT ${quoteLiteral(name)} AS relname, count(*) AS rows FROM public.${quoteIdent(name)}`)
152
+ .join(" UNION ALL "), { database });
153
+ for (const [name, count] of rows) {
154
+ if (name !== undefined && count !== undefined)
155
+ counts.set(name, Number(count));
156
+ }
157
+ return counts;
158
+ }
159
+ /**
160
+ * One row per table either side has.
161
+ *
162
+ * A table on one side only is its own verdict rather than a crash or a zero: an app migration
163
+ * between the backup and the check is the ordinary reason for it, and reading it as a count of
164
+ * zero would make an added table look like lost data.
165
+ */
166
+ function compare(live, restored) {
167
+ const tables = [...new Set([...live.keys(), ...restored.keys()])].sort();
168
+ return tables.map((table) => {
169
+ const liveCount = live.get(table);
170
+ const restoredCount = restored.get(table);
171
+ const verdict = liveCount === undefined
172
+ ? "restored only"
173
+ : restoredCount === undefined
174
+ ? "live only"
175
+ : liveCount === restoredCount
176
+ ? "ok"
177
+ : "mismatch";
178
+ return {
179
+ table,
180
+ ...(liveCount === undefined ? {} : { live: liveCount }),
181
+ ...(restoredCount === undefined ? {} : { restored: restoredCount }),
182
+ verdict,
183
+ };
184
+ });
185
+ }
186
+ /** The table `hf restore-check` prints, and the two lines around it. */
187
+ export function formatRestoreCheck(result) {
188
+ const age = `${result.dumpAgeHours.toFixed(1)} h old`;
189
+ const lines = [`${result.databaseName}: ${result.dump.path}, ${age}`];
190
+ if (result.dumpStale) {
191
+ lines.push(`WARNING: the dump is ${age} — over ${String(STALE_DUMP_HOURS)} h`);
192
+ }
193
+ const header = ["table", "live", "restored", "verdict"];
194
+ const cells = result.rows.map((row) => [
195
+ row.table,
196
+ row.live === undefined ? "—" : String(row.live),
197
+ row.restored === undefined ? "—" : String(row.restored),
198
+ row.verdict,
199
+ ]);
200
+ const widths = header.map((name, column) => Math.max(name.length, ...cells.map((row) => row[column]?.length ?? 0)));
201
+ const line = (row) => row.map((cell, column) => cell.padEnd(widths[column] ?? 0)).join(" ").trimEnd();
202
+ lines.push(line(header), ...cells.map(line));
203
+ const mismatched = result.rows.filter((row) => row.verdict !== "ok").length;
204
+ lines.push(result.ok
205
+ ? `${String(result.rows.length)} table(s) matched`
206
+ : `${String(mismatched)} of ${String(result.rows.length)} table(s) did not match`);
207
+ return lines;
208
+ }
209
+ /**
210
+ * `hf restore-check <name>`: the operator config, an `ssh` runner onto the box, and the check.
211
+ *
212
+ * The cluster admin password comes from libpq's own `PGPASSWORD` until E3 records Coolify's — the
213
+ * operator config has no key for it, and inventing one before the box has been looked at is
214
+ * exactly what risk 3 warns against.
215
+ */
216
+ export async function restoreCheckApp(options) {
217
+ const env = options.env ?? process.env;
218
+ const config = await loadOperatorConfig({ env });
219
+ const { HF_SSH_HOST } = requireOperatorConfig(config, ["HF_SSH_HOST"], { env });
220
+ const runner = createSshRunner({ host: HF_SSH_HOST });
221
+ const admin = { user: pgAdminUser(config), password: env.PGPASSWORD };
222
+ const db = await openDatabase(runner, { admin, containers: postgresContainers(config) });
223
+ try {
224
+ return await restoreCheck({
225
+ app: options.app,
226
+ state: await openAppState(options.app, { env }),
227
+ source: options.fromS3 === true
228
+ ? createS3BackupSource()
229
+ : createLocalDirectoryBackupSource({ runner, directory: options.backupDir }),
230
+ runner,
231
+ database: db,
232
+ restoreAdminUrl: boxAdminUrl(admin, db.boxAddress),
233
+ });
234
+ }
235
+ finally {
236
+ await db.close();
237
+ }
238
+ }
239
+ /**
240
+ * The cluster as the box itself sees it, where `pg_restore` runs.
241
+ *
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.
245
+ */
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();
254
+ }
255
+ function urlOnto(connectionString, database) {
256
+ const url = new URL(connectionString);
257
+ url.pathname = `/${encodeURIComponent(database)}`;
258
+ return url.toString();
259
+ }
260
+ function quoteLiteral(value) {
261
+ return `'${value.replaceAll("'", "''")}'`;
262
+ }
@@ -0,0 +1,72 @@
1
+ export interface ExecOptions {
2
+ /** Written to the command's stdin and then closed. SQL goes here, never into argv. */
3
+ input?: string;
4
+ }
5
+ export interface ExecResult {
6
+ /** `null` when the command was killed by a signal or by `timeoutMs`. */
7
+ code: number | null;
8
+ stdout: string;
9
+ stderr: string;
10
+ }
11
+ export interface Tunnel {
12
+ /** On 127.0.0.1, already accepting connections. */
13
+ localPort: number;
14
+ close(): Promise<void>;
15
+ }
16
+ /**
17
+ * Somewhere commands run: the Coolify box over `ssh`, or this machine in a test.
18
+ *
19
+ * `exec` takes an argument array, never a string. Everything it is asked to run carries an app
20
+ * name, a container name or a role name that came from a flag or an API, and the one shape that
21
+ * cannot be talked into a second command is a vector the shell never sees as one token.
22
+ */
23
+ export interface Runner {
24
+ exec(command: readonly string[], options?: ExecOptions): Promise<ExecResult>;
25
+ /**
26
+ * Forwards a local port to `<remoteHost>:<remotePort>` as the far side sees it.
27
+ *
28
+ * `remoteHost` defaults to the far side's own loopback; it is an address on a network the far
29
+ * side can route to, which is how a container that publishes nothing is still reachable.
30
+ */
31
+ tunnel(remotePort: number, remoteHost?: string): Promise<Tunnel>;
32
+ }
33
+ export declare class RunnerError extends Error {
34
+ constructor(message: string, options?: {
35
+ cause?: unknown;
36
+ });
37
+ }
38
+ /** The argv `exec` runs, `ssh` excluded — the array the test asserts against. */
39
+ export declare function sshExecArgv(host: string, command: readonly string[]): string[];
40
+ export declare function sshTunnelArgv(host: string, localPort: number, remotePort: number, remoteHost?: string): string[];
41
+ /** Where a forward lands when the caller names no host: the far side's own loopback. */
42
+ export declare const TUNNEL_LOOPBACK = "127.0.0.1";
43
+ /** Single-quotes one word for a POSIX remote shell. */
44
+ export declare function shellQuote(command: readonly string[]): string;
45
+ export interface SshRunnerOptions {
46
+ /** `HF_SSH_HOST`. */
47
+ host: string;
48
+ /** The `ssh` binary; overridden only by tests that assert the argv. */
49
+ sshPath?: string;
50
+ /** How long to wait for a forwarded port to accept a connection. */
51
+ tunnelReadyTimeoutMs?: number;
52
+ }
53
+ export declare const DEFAULT_TUNNEL_READY_TIMEOUT_MS = 10000;
54
+ /** A `Runner` that reaches the box over `ssh`. */
55
+ export declare function createSshRunner(options: SshRunnerOptions): Runner;
56
+ export interface LocalRunnerOptions {
57
+ /** What `tunnel()` reports; the local Postgres a test already has. */
58
+ tunnelPort?: number;
59
+ }
60
+ export interface LocalRunner extends Runner {
61
+ /** Every argv `exec` was asked for, in order. */
62
+ readonly commands: readonly (readonly string[])[];
63
+ /** Every remote port `tunnel` was asked for, in order. */
64
+ readonly tunnels: readonly number[];
65
+ }
66
+ /**
67
+ * A `Runner` that runs on this machine and records what it was asked to run.
68
+ *
69
+ * `tunnel()` forwards nothing — it names a port the test already has — so provisioning can be
70
+ * exercised end to end against the test cluster without an `ssh` anywhere in the suite.
71
+ */
72
+ export declare function createLocalRunner(options?: LocalRunnerOptions): LocalRunner;