@wtfalch/foundry 0.49.0 → 0.50.1

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.
@@ -0,0 +1,208 @@
1
+ import { execFile } from 'node:child_process';
2
+ import { mkdtemp, rm, writeFile } from 'node:fs/promises';
3
+ import { tmpdir } from 'node:os';
4
+ import { join } from 'node:path';
5
+ import { ensureRuntimeRole } from '@wtfalch/db/runtime-role';
6
+ import { runtimeDatabaseUrl } from './blue-green-apply.js';
7
+ import { withTimeout } from './call-timeout.js';
8
+ import { tableExcluded } from './stage-copy.js';
9
+ /** Thrown before anything is run or fetched. Fixed text: never the URL it refused. */
10
+ export class StageRestoreRefusedError extends Error {
11
+ constructor(reason) {
12
+ super(`stage restore refused: ${reason}`);
13
+ this.name = 'StageRestoreRefusedError';
14
+ }
15
+ }
16
+ /** Thrown when a step of the restore failed. Fixed text only: the cause can echo a connection URL. */
17
+ export class StageRestoreError extends Error {
18
+ constructor(step) {
19
+ super(`stage restore: ${step} failed`);
20
+ this.name = 'StageRestoreError';
21
+ }
22
+ }
23
+ /** The default {@link Runner}: `execFile`, stderr dropped, no shell. */
24
+ export const execRunner = (command, args, { env, timeoutMs, signal }) => new Promise((resolve, reject) => {
25
+ execFile(command, [...args], {
26
+ env: { ...env },
27
+ timeout: timeoutMs,
28
+ maxBuffer: 64 * 1024 * 1024,
29
+ encoding: 'utf8',
30
+ ...(signal ? { signal } : {}),
31
+ }, (error, stdout) => {
32
+ if (!error)
33
+ return resolve({ exitCode: 0, stdout });
34
+ const code = error.code;
35
+ if (typeof code === 'number')
36
+ return resolve({ exitCode: code, stdout });
37
+ reject(new StageRestoreError('the program'));
38
+ });
39
+ });
40
+ function parseTarget(url) {
41
+ let parsed;
42
+ try {
43
+ parsed = new URL(url);
44
+ }
45
+ catch {
46
+ throw new StageRestoreRefusedError('the database URL is not a URL');
47
+ }
48
+ if (parsed.protocol !== 'postgres:' && parsed.protocol !== 'postgresql:') {
49
+ throw new StageRestoreRefusedError('the database URL is not a postgres URL');
50
+ }
51
+ const database = decodeURIComponent(parsed.pathname.replace(/^\//, ''));
52
+ if (!parsed.hostname || !database) {
53
+ throw new StageRestoreRefusedError('the database URL names no host or database');
54
+ }
55
+ return {
56
+ host: parsed.hostname.toLowerCase(),
57
+ port: parsed.port || '5432',
58
+ user: decodeURIComponent(parsed.username),
59
+ password: decodeURIComponent(parsed.password),
60
+ database,
61
+ };
62
+ }
63
+ const sameDatabase = (a, b) => a.host === b.host && a.port === b.port && a.database === b.database;
64
+ const IDENTIFIER = /^[a-z_][a-z0-9_]*$/;
65
+ const STAGE_DATABASE = /_stage_db$/;
66
+ const pgEnv = (t) => ({
67
+ PATH: process.env.PATH ?? '',
68
+ PGHOST: t.host,
69
+ PGPORT: t.port,
70
+ PGUSER: t.user,
71
+ PGPASSWORD: t.password,
72
+ PGDATABASE: t.database,
73
+ });
74
+ /** `4213; 0 16405 TABLE DATA public sessions owner` */
75
+ const TABLE_DATA = /^\d+;\s+\d+\s+\d+\s+TABLE DATA\s+\S+\s+(\S+)\s+\S+\s*$/;
76
+ /** Any table data entry, parsed or not. */
77
+ const TABLE_DATA_ENTRY = /^\d+;.*\bTABLE DATA\b/;
78
+ /** The table of contents of a dump without the data of the excluded tables. */
79
+ export function filterToc(toc, patterns) {
80
+ let excluded = 0;
81
+ const kept = toc.split('\n').filter((line) => {
82
+ const table = TABLE_DATA.exec(line)?.[1];
83
+ // A name the parser cannot read (quoted, with a space) could be an excluded table: refuse the restore.
84
+ if ((table === undefined && TABLE_DATA_ENTRY.test(line)) || table?.includes('"')) {
85
+ throw new StageRestoreRefusedError('the dump list has a table entry that cannot be read');
86
+ }
87
+ if (table !== undefined && tableExcluded(table, patterns)) {
88
+ excluded += 1;
89
+ return false;
90
+ }
91
+ return true;
92
+ });
93
+ return { list: kept.join('\n'), excluded };
94
+ }
95
+ /**
96
+ * The concrete {@link StageCopyPort}. It restores a dump into a stage database and sets up that database's runtime
97
+ * role, and refuses any target that is not a stage database or that is production's. Connection settings reach
98
+ * the programs as `PG*` env, never as arguments. Every outside call has a timeout, and an error carries fixed text
99
+ * only.
100
+ */
101
+ export function createStageCopyPort(deps) {
102
+ const run = deps.run ?? execRunner;
103
+ const t = deps.timeouts ?? {};
104
+ const fetchMs = t.fetchMs ?? 600_000;
105
+ const listMs = t.listMs ?? 120_000;
106
+ const restoreMs = t.restoreMs ?? 1_800_000;
107
+ const roleMs = t.roleMs ?? 120_000;
108
+ for (const [name, value] of Object.entries({ fetchMs, listMs, restoreMs, roleMs })) {
109
+ if (!(value > 0))
110
+ throw new TypeError(`stage copy port: ${name} must be positive`);
111
+ }
112
+ /** Parses the target and refuses it unless it is a stage database that production does not answer at. */
113
+ async function checkTarget(url, role) {
114
+ const target = parseTarget(url);
115
+ if (!IDENTIFIER.test(role))
116
+ throw new StageRestoreRefusedError('the role name is not valid');
117
+ if (!STAGE_DATABASE.test(target.database)) {
118
+ throw new StageRestoreRefusedError('the target is not a stage database');
119
+ }
120
+ // The pg client reads host, port and options from the query string: only `sslmode` is allowed.
121
+ for (const key of new URL(url).searchParams.keys()) {
122
+ if (key !== 'sslmode') {
123
+ throw new StageRestoreRefusedError('the database URL has query parameters');
124
+ }
125
+ }
126
+ const production = await withTimeout('stage restore: production URLs', roleMs, deps.productionUrls);
127
+ if (production.length === 0) {
128
+ throw new StageRestoreRefusedError('production database is not known');
129
+ }
130
+ for (const url of production) {
131
+ if (sameDatabase(target, parseTarget(url))) {
132
+ throw new StageRestoreRefusedError('the target is the production database');
133
+ }
134
+ }
135
+ return target;
136
+ }
137
+ return {
138
+ async runRestore({ location, targetDatabaseUrl, ownerRole, excludeTableData, signal }) {
139
+ const target = await checkTarget(targetDatabaseUrl, ownerRole);
140
+ if (target.database !== ownerRole) {
141
+ throw new StageRestoreRefusedError('the owner role is not the stage database owner');
142
+ }
143
+ const fetching = deps.fetchDump(location, signal);
144
+ let dump;
145
+ try {
146
+ dump = await withTimeout('stage restore: fetch the dump', fetchMs, () => fetching);
147
+ }
148
+ catch (error) {
149
+ // The fetch may still finish later; its file goes as soon as it does.
150
+ fetching.then((late) => late.remove().catch(() => undefined), () => undefined);
151
+ throw error;
152
+ }
153
+ let dir;
154
+ try {
155
+ const env = pgEnv(target);
156
+ const toc = await withTimeout('stage restore: list the dump', listMs, () => run('pg_restore', ['--list', dump.path], { env, timeoutMs: listMs, signal }));
157
+ if (toc.exitCode !== 0)
158
+ return { exitCode: toc.exitCode };
159
+ dir = await mkdtemp(join(tmpdir(), 'stage-restore-'));
160
+ const listFile = join(dir, 'toc.list');
161
+ await writeFile(listFile, filterToc(toc.stdout, excludeTableData).list, 'utf8');
162
+ const restored = await withTimeout('stage restore: pg_restore', restoreMs, () => run('pg_restore', [
163
+ '--no-owner',
164
+ '--no-privileges',
165
+ '--exit-on-error',
166
+ `--role=${ownerRole}`,
167
+ '--use-list',
168
+ listFile,
169
+ '--dbname',
170
+ target.database,
171
+ dump.path,
172
+ ], { env, timeoutMs: restoreMs, signal }));
173
+ return { exitCode: restored.exitCode };
174
+ }
175
+ catch (error) {
176
+ if (error instanceof StageRestoreRefusedError)
177
+ throw error;
178
+ console.error(`stage restore: failed (${error instanceof Error ? error.name : 'unknown'})`);
179
+ throw new StageRestoreError('the restore');
180
+ }
181
+ finally {
182
+ await dump.remove().catch(() => {
183
+ console.error('stage restore: removing the dump file failed');
184
+ });
185
+ if (dir)
186
+ await rm(dir, { recursive: true, force: true }).catch(() => undefined);
187
+ }
188
+ },
189
+ async ensureRuntimeRole({ targetDatabaseUrl, runtimeRole, runtimePassword }) {
190
+ const target = await checkTarget(targetDatabaseUrl, runtimeRole);
191
+ const runtimeUrl = runtimeDatabaseUrl(targetDatabaseUrl, runtimeRole, runtimePassword);
192
+ if (!runtimeUrl)
193
+ throw new StageRestoreRefusedError('the database URL is not a postgres URL');
194
+ if (!runtimeRole.endsWith('_rt') || runtimeRole === target.user) {
195
+ throw new StageRestoreRefusedError('the runtime role is not a separate runtime role');
196
+ }
197
+ try {
198
+ const ensure = deps.ensureRole ?? ((options) => ensureRuntimeRole(options));
199
+ await withTimeout('stage restore: runtime role', roleMs, () => ensure({ ownerUrl: targetDatabaseUrl, runtimeUrl, log: () => undefined }));
200
+ return { exitCode: 0 };
201
+ }
202
+ catch (error) {
203
+ console.error(`stage restore: runtime role failed (${error instanceof Error ? error.name : 'unknown'})`);
204
+ return { exitCode: 1 };
205
+ }
206
+ },
207
+ };
208
+ }
@@ -0,0 +1,140 @@
1
+ import { type BlueGreenSubject } from './blue-green.js';
2
+ import { type DatabaseDump } from './database-dump.js';
3
+ import type { CoolifyPort } from './executor.js';
4
+ /**
5
+ * Table names whose rows the exact copy leaves out (SQL structure is still restored). `*` matches any run of
6
+ * characters. Sessions and tokens are dropped so a copied sign-in or API token is useless on stage; the keystore
7
+ * and held-key tables hold ciphertext that stage must never be able to open (stage gets no production key).
8
+ */
9
+ export declare const STAGE_COPY_EXCLUDED_TABLES: readonly string[];
10
+ /** Whether `table` is on {@link STAGE_COPY_EXCLUDED_TABLES}. Case-insensitive, like Postgres folds names. */
11
+ export declare function tableExcluded(table: string, patterns?: readonly string[]): boolean;
12
+ export type StageCopyRefusalReason = 'guards' | 'sink' | 'stage-sink-env' | 'stage-isolation' | 'no-production-database' | 'no-stage-database' | 'no-stage-app' | 'no-coolify-project' | 'no-kept-database';
13
+ /** Thrown before anything is touched. Fixed text and names only. */
14
+ export declare class StageCopyRefusedError extends Error {
15
+ readonly reason: StageCopyRefusalReason;
16
+ readonly missing: readonly string[];
17
+ constructor(reason: StageCopyRefusalReason, missing?: readonly string[]);
18
+ }
19
+ export interface StageCopyStepRecord {
20
+ step: string;
21
+ outcome: 'ok' | 'failed';
22
+ /** Fixed text only. */
23
+ detail?: string;
24
+ }
25
+ /** What a run did. Fixed text, ids and times only: never a URL, a password or a Coolify message. */
26
+ export interface StageCopyRunRecord {
27
+ requestedBy: string;
28
+ at: string;
29
+ /** The Coolify database stage runs on when the call ended. */
30
+ liveDatabaseId: string;
31
+ dumpExecutionId?: string;
32
+ steps: readonly StageCopyStepRecord[];
33
+ }
34
+ /** Thrown once a run started; carries what happened so far. */
35
+ export declare class StageCopyFailedError extends Error {
36
+ readonly record: StageCopyRunRecord;
37
+ constructor(message: string, record: StageCopyRunRecord);
38
+ }
39
+ /** The restore and the runtime role, run by the host (`stage-copy-port.ts` is the concrete one). */
40
+ export interface StageCopyPort {
41
+ /**
42
+ * Restores the dump at `location` into `targetDatabaseUrl` and nothing else, leaving out the data of every
43
+ * table matching `excludeTableData` (see {@link tableExcluded}).
44
+ */
45
+ runRestore(input: {
46
+ location: DatabaseDump['location'];
47
+ targetDatabaseUrl: string;
48
+ ownerRole: string;
49
+ excludeTableData: readonly string[];
50
+ /** Aborted when the caller gives up: the restore must stop writing and remove the dump file. */
51
+ signal?: AbortSignal;
52
+ }): Promise<{
53
+ exitCode: number;
54
+ }>;
55
+ /** Creates or syncs `runtimeRole` with `runtimePassword` on the database `targetDatabaseUrl` names. */
56
+ ensureRuntimeRole(input: {
57
+ targetDatabaseUrl: string;
58
+ runtimeRole: string;
59
+ runtimePassword: string;
60
+ }): Promise<{
61
+ exitCode: number;
62
+ }>;
63
+ }
64
+ export type StageCopyCoolify = Pick<CoolifyPort, 'listDatabases' | 'getDatabase' | 'createPostgresDatabase' | 'deleteDatabase' | 'getApplication' | 'listEnvs' | 'deleteEnv' | 'deployAndWait' | 'bulkUpsertEnvs' | 'findOrCreateEnvironment' | 'listDatabaseBackups' | 'raw'> & Partial<Pick<CoolifyPort, 'listDatabaseBackupExecutions'>>;
65
+ /** Timeouts of every outside call; a run has no call without one. */
66
+ export interface StageCopyTimeouts {
67
+ /** One Coolify call. Default 30 s. */
68
+ callMs?: number;
69
+ /** The dump of production. Default: `dumpNow`'s (10 minutes). */
70
+ dumpMs?: number;
71
+ /**
72
+ * The whole `runRestore` call: the port's fetch (10 minutes), table list (2) and `pg_restore` (30), plus a
73
+ * margin. Default 44 minutes.
74
+ */
75
+ restoreMs?: number;
76
+ /** Creating or syncing the runtime role. Default 2 minutes. */
77
+ roleMs?: number;
78
+ /** Waiting for a new database to start. Default 3 minutes. */
79
+ databaseStartMs?: number;
80
+ /** The stage app's new deployment. Default 16 minutes. */
81
+ deployMs?: number;
82
+ /** Waiting for the stage app to be healthy after its deployment, and the poll between reads. Default 2 minutes, 2 s. */
83
+ appRunningMs?: number;
84
+ appPollMs?: number;
85
+ }
86
+ /** The production database to dump, resolved by the host (it differs by app). `null`: none is recorded. */
87
+ export interface StageCopyProduction {
88
+ /** The database's name in the backup keys, e.g. `app.shop`. Goes into dump errors. */
89
+ databaseKey: string;
90
+ databaseUuid: string;
91
+ /** The uuid of the schedule that saves it to the backup bucket. */
92
+ backupUuid: string;
93
+ storageUuid: string;
94
+ }
95
+ interface StageCopyBase {
96
+ subject: Pick<BlueGreenSubject, 'id' | 'base' | 'database'>;
97
+ recorded: () => Readonly<Record<string, string>>;
98
+ record: (key: string, value: string) => void;
99
+ persist: () => Promise<void>;
100
+ coolify: StageCopyCoolify;
101
+ /** Registers a value for redaction before it is written anywhere. */
102
+ secret?: (value: string) => void;
103
+ /** Who asked: a person or operator id. Goes into the run record. */
104
+ requestedBy: string;
105
+ now?: () => number;
106
+ timeouts?: StageCopyTimeouts;
107
+ }
108
+ export interface StageCopyInput extends StageCopyBase {
109
+ /** The company whose sink the stage mail env must name. */
110
+ org: {
111
+ slug: string;
112
+ };
113
+ production: StageCopyProduction | null;
114
+ restore: StageCopyPort;
115
+ projectUuid: string | undefined;
116
+ serverUuid: string | undefined;
117
+ }
118
+ export interface UndoStageCopyInput extends StageCopyBase {
119
+ restore: Pick<StageCopyPort, 'ensureRuntimeRole'>;
120
+ }
121
+ /**
122
+ * Copies production's database into stage, exact except for {@link STAGE_COPY_EXCLUDED_TABLES}, on request. It
123
+ * refuses until the stage guards and the sink are in place, dumps production through Coolify (it never holds a
124
+ * production URL), restores into a new database beside stage's and points stage at it. Stage's database is
125
+ * recorded only once stage runs on the new one.
126
+ *
127
+ * What a failure leaves behind, by point: refusal or dump failure: nothing changed. New database made, restore
128
+ * or role failed: the new database is deleted, stage untouched. Switch failed: stage is pointed back at its own
129
+ * database and the new one deleted; if putting it back also failed the run says so (`recovered: false`) and the
130
+ * new database is left for the next run's sweep. Recording: the three records are written in memory and
131
+ * persisted once, so a crash leaves either all or none.
132
+ */
133
+ export declare function stageCopy(input: StageCopyInput): Promise<StageCopyRunRecord>;
134
+ /**
135
+ * Puts the database stage ran on before the last copy back, and deletes the one the copy made. It mints a new
136
+ * runtime password for the kept database (the old one is gone from stage's env). A failure leaves stage on the
137
+ * database it ran on, switched back the same way a failed copy does, and records nothing.
138
+ */
139
+ export declare function undoStageCopy(input: UndoStageCopyInput): Promise<StageCopyRunRecord>;
140
+ export {};