@wtfalch/foundry 0.48.0 → 0.50.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.
Files changed (38) hide show
  1. package/dist/bootstrap/declare.js +1 -0
  2. package/dist/index.d.ts +8 -3
  3. package/dist/index.js +5 -2
  4. package/dist/migrations/0060_hosting_repos.sql +7 -0
  5. package/dist/org.d.ts +9 -1
  6. package/dist/org.js +27 -0
  7. package/dist/plan/archon-blue-green.js +1 -0
  8. package/dist/plan/blue-green-apply.d.ts +9 -0
  9. package/dist/plan/blue-green-apply.js +86 -2
  10. package/dist/plan/blue-green.d.ts +8 -0
  11. package/dist/plan/blue-green.js +18 -1
  12. package/dist/plan/call-timeout.d.ts +6 -0
  13. package/dist/plan/call-timeout.js +27 -0
  14. package/dist/plan/diff.d.ts +2 -0
  15. package/dist/plan/diff.js +1 -0
  16. package/dist/plan/executor.d.ts +2 -0
  17. package/dist/plan/executor.js +25 -1
  18. package/dist/plan/github-release-hook.d.ts +81 -0
  19. package/dist/plan/github-release-hook.js +159 -0
  20. package/dist/plan/index.d.ts +7 -1
  21. package/dist/plan/index.js +4 -1
  22. package/dist/plan/live.d.ts +1 -1
  23. package/dist/plan/plan-apply.d.ts +2 -0
  24. package/dist/plan/plan-apply.js +8 -3
  25. package/dist/plan/platform-diff.d.ts +59 -0
  26. package/dist/plan/platform-diff.js +98 -1
  27. package/dist/plan/platform-providers.d.ts +10 -0
  28. package/dist/plan/platform-providers.js +71 -1
  29. package/dist/plan/stage-copy-port.d.ts +62 -0
  30. package/dist/plan/stage-copy-port.js +208 -0
  31. package/dist/plan/stage-copy.d.ts +140 -0
  32. package/dist/plan/stage-copy.js +538 -0
  33. package/dist/plan/stage-sink.d.ts +37 -0
  34. package/dist/plan/stage-sink.js +33 -0
  35. package/dist/store/postgres.js +13 -2
  36. package/dist/store/schema.d.ts +19 -0
  37. package/dist/store/schema.js +2 -0
  38. package/package.json +1 -1
@@ -0,0 +1,538 @@
1
+ import { randomBytes } from 'node:crypto';
2
+ import { runtimeDatabaseUrl } from './blue-green-apply.js';
3
+ import { bgKey, stageKindOf } from './blue-green.js';
4
+ import { withTimeout } from './call-timeout.js';
5
+ import { dumpNow } from './database-dump.js';
6
+ import { waitForDeploymentFinished } from './deploy-health.js';
7
+ import { getDatabaseStarted } from './executor.js';
8
+ import { stageGuardsReady } from './stage-guards.js';
9
+ import { assertStageIsolated, envClass } from './stage-isolation.js';
10
+ import { STAGE_SINK_APPLIED_SCOPE_KEY, stageMailEnv } from './stage-sink.js';
11
+ /**
12
+ * Table names whose rows the exact copy leaves out (SQL structure is still restored). `*` matches any run of
13
+ * characters. Sessions and tokens are dropped so a copied sign-in or API token is useless on stage; the keystore
14
+ * and held-key tables hold ciphertext that stage must never be able to open (stage gets no production key).
15
+ */
16
+ export const STAGE_COPY_EXCLUDED_TABLES = [
17
+ 'keystore_*',
18
+ 'keys_held_*',
19
+ '*session*',
20
+ '*token*',
21
+ ];
22
+ /** Whether `table` is on {@link STAGE_COPY_EXCLUDED_TABLES}. Case-insensitive, like Postgres folds names. */
23
+ export function tableExcluded(table, patterns = STAGE_COPY_EXCLUDED_TABLES) {
24
+ const name = table.toLowerCase();
25
+ return patterns.some((pattern) => {
26
+ const parts = pattern.toLowerCase().split('*');
27
+ return new RegExp(`^${parts.map((p) => p.replace(/[.+?^${}()|[\]\\]/g, '\\$&')).join('.*')}$`).test(name);
28
+ });
29
+ }
30
+ /** Thrown before anything is touched. Fixed text and names only. */
31
+ export class StageCopyRefusedError extends Error {
32
+ reason;
33
+ missing;
34
+ constructor(reason, missing = []) {
35
+ super(`stage copy refused: ${reason}${missing.length > 0 ? ` (${missing.join(', ')})` : ''}`);
36
+ this.reason = reason;
37
+ this.missing = missing;
38
+ this.name = 'StageCopyRefusedError';
39
+ }
40
+ }
41
+ /** Thrown once a run started; carries what happened so far. */
42
+ export class StageCopyFailedError extends Error {
43
+ record;
44
+ constructor(message, record) {
45
+ super(`stage copy failed: ${message}`);
46
+ this.record = record;
47
+ this.name = 'StageCopyFailedError';
48
+ }
49
+ }
50
+ const DEFAULTS = {
51
+ callMs: 30_000,
52
+ restoreMs: 2_640_000,
53
+ deployMs: 960_000,
54
+ roleMs: 120_000,
55
+ databaseStartMs: 180_000,
56
+ appRunningMs: 120_000,
57
+ appPollMs: 2_000,
58
+ };
59
+ const COPY_PREFIX = (base) => `${base}-stage-db-copy-`;
60
+ const pgNameOf = (base) => `${base}-stage-db`.replace(/-/g, '_');
61
+ const OWNER_ENV = 'DATABASE_URL_OWNER';
62
+ const RUNTIME_ENV = 'DATABASE_URL';
63
+ function requireText(name, value) {
64
+ if (typeof value !== 'string' ||
65
+ value.length === 0 ||
66
+ [...value].some((c) => c.charCodeAt(0) < 32)) {
67
+ throw new TypeError(`stage copy: ${name} must be a non-empty single-line string`);
68
+ }
69
+ return value;
70
+ }
71
+ function positive(name, value) {
72
+ if (!(value > 0))
73
+ throw new TypeError(`stage copy: ${name} must be positive`);
74
+ return value;
75
+ }
76
+ function settings(timeouts) {
77
+ const t = timeouts ?? {};
78
+ return {
79
+ callMs: positive('callMs', t.callMs ?? DEFAULTS.callMs),
80
+ dumpMs: t.dumpMs === undefined ? undefined : positive('dumpMs', t.dumpMs),
81
+ restoreMs: positive('restoreMs', t.restoreMs ?? DEFAULTS.restoreMs),
82
+ deployMs: positive('deployMs', t.deployMs ?? DEFAULTS.deployMs),
83
+ roleMs: positive('roleMs', t.roleMs ?? DEFAULTS.roleMs),
84
+ databaseStartMs: positive('databaseStartMs', t.databaseStartMs ?? DEFAULTS.databaseStartMs),
85
+ appRunningMs: positive('appRunningMs', t.appRunningMs ?? DEFAULTS.appRunningMs),
86
+ appPollMs: positive('appPollMs', t.appPollMs ?? DEFAULTS.appPollMs),
87
+ };
88
+ }
89
+ class Run {
90
+ requestedBy;
91
+ now;
92
+ steps = [];
93
+ dumpExecutionId;
94
+ liveDatabaseId;
95
+ constructor(requestedBy, now, liveDatabaseId) {
96
+ this.requestedBy = requestedBy;
97
+ this.now = now;
98
+ this.liveDatabaseId = liveDatabaseId;
99
+ }
100
+ ok(step) {
101
+ this.steps.push({ step, outcome: 'ok' });
102
+ }
103
+ fail(step, detail) {
104
+ this.steps.push({ step, outcome: 'failed', detail });
105
+ return new StageCopyFailedError(detail, this.record());
106
+ }
107
+ record() {
108
+ return {
109
+ requestedBy: this.requestedBy,
110
+ at: new Date(this.now()).toISOString(),
111
+ liveDatabaseId: this.liveDatabaseId,
112
+ ...(this.dumpExecutionId ? { dumpExecutionId: this.dumpExecutionId } : {}),
113
+ steps: [...this.steps],
114
+ };
115
+ }
116
+ }
117
+ async function readStageUrls(base, cfg, appId) {
118
+ const envs = await withTimeout('stage copy: read the stage env', cfg.callMs, () => base.coolify.listEnvs(appId));
119
+ const get = (key) => envs.find((e) => e.key === key && !e.is_preview)?.value;
120
+ const owner = get(OWNER_ENV);
121
+ if (!owner)
122
+ throw new Error('the stage app has no owner database URL');
123
+ return { owner, runtime: get(RUNTIME_ENV) };
124
+ }
125
+ /** Waits for `running:healthy`. Called only after a new deployment finished, never on an old status. */
126
+ async function waitHealthy(base, cfg, appId) {
127
+ const deadline = Date.now() + cfg.appRunningMs;
128
+ for (;;) {
129
+ const { status } = await withTimeout('stage copy: read the stage app', cfg.callMs, () => base.coolify.getApplication(appId));
130
+ if (status === 'running:healthy')
131
+ return true;
132
+ if (Date.now() >= deadline)
133
+ return false;
134
+ await new Promise((resolve) => setTimeout(resolve, cfg.appPollMs));
135
+ }
136
+ }
137
+ async function stopApp(base, cfg, appId) {
138
+ await withTimeout('stage copy: stop the stage app', cfg.callMs, () => base.coolify.raw('POST', `/applications/${appId}/stop`));
139
+ }
140
+ /** Starts the stage app as a new deployment and waits for Coolify's record of it to finish. */
141
+ async function deployApp(base, cfg, appId) {
142
+ await withTimeout('stage copy: deploy the stage app', cfg.deployMs, () => waitForDeploymentFinished(base.coolify, appId, {}, 'stage copy', 'the stage app stays as it was'));
143
+ }
144
+ async function pushUrls(base, cfg, appId, urls) {
145
+ const envs = [{ key: OWNER_ENV, value: urls.owner }];
146
+ if (urls.runtime)
147
+ envs.push({ key: RUNTIME_ENV, value: urls.runtime });
148
+ await withTimeout('stage copy: write the stage env', cfg.callMs, async () => {
149
+ await base.coolify.bulkUpsertEnvs(appId, envs.map((e) => ({ ...e, literal: true })));
150
+ if (urls.runtime)
151
+ return;
152
+ // The database the app goes back to had no runtime URL: the forward one must not stay.
153
+ for (const row of await base.coolify.listEnvs(appId)) {
154
+ if (row.key === RUNTIME_ENV && row.uuid)
155
+ await base.coolify.deleteEnv(appId, row.uuid);
156
+ }
157
+ });
158
+ }
159
+ /**
160
+ * Points the stage app at `to`: stop, write the two URLs, deploy anew, wait until it is healthy. When any part fails the app
161
+ * is pointed back at `from` the same way. `true`: stage runs on `to`. `false`: it was put back on `from` (or
162
+ * that could not be confirmed either; `recovered` says which).
163
+ */
164
+ async function switchStage(base, cfg, appId, from, to) {
165
+ const attempt = async (urls) => {
166
+ await stopApp(base, cfg, appId);
167
+ await pushUrls(base, cfg, appId, urls);
168
+ await deployApp(base, cfg, appId);
169
+ return waitHealthy(base, cfg, appId);
170
+ };
171
+ try {
172
+ if (await attempt(to))
173
+ return { switched: true, recovered: true };
174
+ }
175
+ catch (error) {
176
+ logFailure('pointing stage at the new database', error);
177
+ }
178
+ try {
179
+ return { switched: false, recovered: await attempt(from) };
180
+ }
181
+ catch (error) {
182
+ logFailure('pointing stage back at its database', error);
183
+ return { switched: false, recovered: false };
184
+ }
185
+ }
186
+ /** Fixed text and the error name; the cause can echo a connection URL. */
187
+ function logFailure(what, error) {
188
+ console.error(`stage copy: ${what} failed (${error instanceof Error ? error.name : 'unknown'})`);
189
+ }
190
+ const dbIdentity = (url) => {
191
+ try {
192
+ const u = new URL(url);
193
+ return `${u.hostname.toLowerCase()}:${u.port || '5432'}${u.pathname}`;
194
+ }
195
+ catch {
196
+ return null;
197
+ }
198
+ };
199
+ /** Whether stage's live owner URL points at this Coolify database. A database that cannot be read counts as live. */
200
+ async function runsOn(base, cfg, uuid, liveOwnerUrl) {
201
+ try {
202
+ const { internal_db_url: url } = await withTimeout('stage copy: read a database', cfg.callMs, () => base.coolify.getDatabase(uuid));
203
+ const live = dbIdentity(liveOwnerUrl);
204
+ return url === undefined || live === null || dbIdentity(url) === live;
205
+ }
206
+ catch (error) {
207
+ logFailure('reading a database', error);
208
+ return true;
209
+ }
210
+ }
211
+ async function deleteQuietly(base, cfg, uuid) {
212
+ try {
213
+ await withTimeout('stage copy: delete a database', cfg.callMs, () => base.coolify.deleteDatabase(uuid));
214
+ }
215
+ catch (error) {
216
+ // The next copy deletes every database with the copy prefix that stage does not run on.
217
+ logFailure('deleting a database', error);
218
+ }
219
+ }
220
+ function checkInput(base) {
221
+ requireText('requestedBy', base.requestedBy);
222
+ requireText('subject.id', base.subject.id);
223
+ requireText('subject.base', base.subject.base);
224
+ }
225
+ /** The ids stage runs on, or the refusal. */
226
+ function stageIds(base) {
227
+ const recorded = base.recorded();
228
+ const { subject } = base;
229
+ if (!subject.database)
230
+ throw new StageCopyRefusedError('no-stage-database');
231
+ const databaseId = recorded[bgKey.stageDatabase(subject.id)];
232
+ if (!databaseId)
233
+ throw new StageCopyRefusedError('no-stage-database');
234
+ const appKeyPrefix = `bg.${subject.id}.app.`;
235
+ const appId = Object.entries(recorded).find(([key, value]) => key.startsWith(appKeyPrefix) && key.endsWith('.stage') && value)?.[1];
236
+ if (!appId)
237
+ throw new StageCopyRefusedError('no-stage-app');
238
+ return { appId, databaseId };
239
+ }
240
+ /**
241
+ * Copies production's database into stage, exact except for {@link STAGE_COPY_EXCLUDED_TABLES}, on request. It
242
+ * refuses until the stage guards and the sink are in place, dumps production through Coolify (it never holds a
243
+ * production URL), restores into a new database beside stage's and points stage at it. Stage's database is
244
+ * recorded only once stage runs on the new one.
245
+ *
246
+ * What a failure leaves behind, by point: refusal or dump failure: nothing changed. New database made, restore
247
+ * or role failed: the new database is deleted, stage untouched. Switch failed: stage is pointed back at its own
248
+ * database and the new one deleted; if putting it back also failed the run says so (`recovered: false`) and the
249
+ * new database is left for the next run's sweep. Recording: the three records are written in memory and
250
+ * persisted once, so a crash leaves either all or none.
251
+ */
252
+ export async function stageCopy(input) {
253
+ checkInput(input);
254
+ const cfg = settings(input.timeouts);
255
+ const { subject, coolify } = input;
256
+ const id = subject.id;
257
+ const recorded = input.recorded();
258
+ const guards = stageGuardsReady(recorded, id);
259
+ if (!guards.ready)
260
+ throw new StageCopyRefusedError('guards', guards.missing);
261
+ if (!recorded[STAGE_SINK_APPLIED_SCOPE_KEY] || !recorded[bgKey.stageSink(id)]) {
262
+ throw new StageCopyRefusedError('sink');
263
+ }
264
+ const production = input.production;
265
+ if (!production)
266
+ throw new StageCopyRefusedError('no-production-database');
267
+ for (const [name, value] of Object.entries(production))
268
+ requireText(`production.${name}`, value);
269
+ const { appId, databaseId: stageDbId } = stageIds(input);
270
+ if (production.databaseUuid === stageDbId) {
271
+ throw new StageCopyRefusedError('no-production-database');
272
+ }
273
+ if (!input.projectUuid || !input.serverUuid)
274
+ throw new StageCopyRefusedError('no-coolify-project');
275
+ const stageEnv = await withTimeout('stage copy: read the stage env', cfg.callMs, () => coolify.listEnvs(appId));
276
+ const wanted = stageMailEnv(input.org);
277
+ const have = new Map(stageEnv.filter((e) => !e.is_preview).map((e) => [e.key, e.value]));
278
+ if (wanted.some((e) => have.get(e.key) !== e.value)) {
279
+ throw new StageCopyRefusedError('stage-sink-env');
280
+ }
281
+ // Whatever was added to stage by hand (a mail token, an SMTP setting) blocks the copy; names only.
282
+ const violations = assertStageIsolated({
283
+ kind: stageKindOf(subject),
284
+ prod: { env: [], resources: {} },
285
+ stage: {
286
+ env: [...have].map(([name, value]) => ({ name, class: envClass(name), value })),
287
+ resources: {},
288
+ },
289
+ sinkMail: Object.fromEntries(wanted.map((e) => [e.key, e.value])),
290
+ });
291
+ if (violations.length > 0)
292
+ throw new StageCopyRefusedError('stage-isolation', violations);
293
+ // The database stage really runs on, whatever the records say: the sweep below never deletes it.
294
+ const liveOwnerUrl = have.get(OWNER_ENV);
295
+ if (!liveOwnerUrl)
296
+ throw new StageCopyRefusedError('no-stage-database');
297
+ const run = new Run(input.requestedBy, input.now ?? Date.now, stageDbId);
298
+ const pgName = pgNameOf(subject.base);
299
+ const runtimeRole = `${pgName}_rt`;
300
+ let dump;
301
+ try {
302
+ dump = await dumpNow(coolify, {
303
+ ...production,
304
+ ...(cfg.dumpMs !== undefined ? { timeoutMs: cfg.dumpMs } : {}),
305
+ });
306
+ }
307
+ catch (error) {
308
+ logFailure('dumping production', error);
309
+ throw run.fail('dump', 'the production dump failed');
310
+ }
311
+ run.dumpExecutionId = dump.executionId;
312
+ run.ok('dump');
313
+ // Databases an earlier run left (the kept one, a failed copy): stage never runs on them.
314
+ const prefix = COPY_PREFIX(subject.base);
315
+ const previousKept = recorded[bgKey.stageKept(id)];
316
+ const listed = await withTimeout('stage copy: list databases', cfg.callMs, () => coolify.listDatabases());
317
+ for (const database of listed) {
318
+ if (database.uuid === stageDbId)
319
+ continue;
320
+ if (database.name.startsWith(prefix) || database.uuid === previousKept) {
321
+ if (await runsOn(input, cfg, database.uuid, liveOwnerUrl))
322
+ continue;
323
+ await deleteQuietly(input, cfg, database.uuid);
324
+ }
325
+ }
326
+ if (previousKept) {
327
+ input.record(bgKey.stageKept(id), '');
328
+ await input.persist();
329
+ }
330
+ run.ok('drop-older');
331
+ const { environment } = await withTimeout('stage copy: find the environment', cfg.callMs, () => coolify.findOrCreateEnvironment(input.projectUuid, 'production'));
332
+ let created;
333
+ try {
334
+ created = await withTimeout('stage copy: create the database', cfg.callMs, () => coolify.createPostgresDatabase({
335
+ project_uuid: input.projectUuid,
336
+ server_uuid: input.serverUuid,
337
+ environment_name: environment.name,
338
+ environment_uuid: environment.uuid,
339
+ name: `${prefix}${run.now().toString(36)}`,
340
+ postgres_db: pgName,
341
+ postgres_user: pgName,
342
+ }));
343
+ }
344
+ catch (error) {
345
+ logFailure('creating the database', error);
346
+ throw run.fail('create', 'the new database could not be made');
347
+ }
348
+ const newDbId = created.uuid;
349
+ let ownerUrl = created.internal_db_url;
350
+ input.secret?.(ownerUrl);
351
+ const discard = async (step, detail) => {
352
+ await deleteQuietly(input, cfg, newDbId);
353
+ throw run.fail(step, detail);
354
+ };
355
+ try {
356
+ const started = await withTimeout('stage copy: start the database', cfg.databaseStartMs, () => getDatabaseStarted(coolify, newDbId));
357
+ if (started.internal_db_url) {
358
+ ownerUrl = started.internal_db_url;
359
+ input.secret?.(ownerUrl);
360
+ }
361
+ }
362
+ catch (error) {
363
+ logFailure('starting the new database', error);
364
+ return discard('start-database', 'the new database did not start');
365
+ }
366
+ const abort = new AbortController();
367
+ let restoring = Promise.resolve();
368
+ let restored;
369
+ try {
370
+ restored = await withTimeout('stage copy: restore', cfg.restoreMs, () => {
371
+ const running = input.restore.runRestore({
372
+ location: dump.location,
373
+ targetDatabaseUrl: ownerUrl,
374
+ ownerRole: pgName,
375
+ excludeTableData: STAGE_COPY_EXCLUDED_TABLES,
376
+ signal: abort.signal,
377
+ });
378
+ restoring = running.catch(() => undefined);
379
+ return running;
380
+ });
381
+ }
382
+ catch (error) {
383
+ logFailure('restoring', error);
384
+ // Stop pg_restore and let it finish before the database goes: it must not write into a deleted one.
385
+ abort.abort();
386
+ const stopped = await withTimeout('stage copy: stop the restore', cfg.callMs, () => restoring.then(() => true)).catch(() => false);
387
+ if (!stopped)
388
+ throw run.fail('restore', 'the restore failed and could not be stopped');
389
+ return discard('restore', 'the restore failed');
390
+ }
391
+ if (restored.exitCode !== 0)
392
+ return discard('restore', 'the restore failed');
393
+ run.ok('restore');
394
+ const password = randomBytes(24).toString('base64url');
395
+ input.secret?.(password);
396
+ const runtimeUrl = runtimeDatabaseUrl(ownerUrl, runtimeRole, password);
397
+ if (!runtimeUrl)
398
+ return discard('runtime-role', 'the new database URL could not be read');
399
+ input.secret?.(runtimeUrl);
400
+ try {
401
+ const roled = await withTimeout('stage copy: runtime role', cfg.roleMs, () => input.restore.ensureRuntimeRole({
402
+ targetDatabaseUrl: ownerUrl,
403
+ runtimeRole,
404
+ runtimePassword: password,
405
+ }));
406
+ if (roled.exitCode !== 0)
407
+ return discard('runtime-role', 'the runtime role failed');
408
+ }
409
+ catch (error) {
410
+ logFailure('setting the runtime role', error);
411
+ return discard('runtime-role', 'the runtime role failed');
412
+ }
413
+ run.ok('runtime-role');
414
+ let from;
415
+ try {
416
+ from = await readStageUrls(input, cfg, appId);
417
+ }
418
+ catch (error) {
419
+ logFailure('reading the stage env', error);
420
+ return discard('switch', 'the stage env could not be read');
421
+ }
422
+ for (const value of [from.owner, from.runtime])
423
+ if (value)
424
+ input.secret?.(value);
425
+ const result = await switchStage(input, cfg, appId, from, {
426
+ owner: ownerUrl,
427
+ runtime: runtimeUrl,
428
+ });
429
+ if (!result.switched) {
430
+ if (result.recovered)
431
+ await deleteQuietly(input, cfg, newDbId);
432
+ throw run.fail('switch', result.recovered
433
+ ? 'stage did not run on the new database; it runs on its own database again'
434
+ : 'stage did not run on the new database, and putting it back could not be confirmed');
435
+ }
436
+ run.ok('switch');
437
+ input.record(bgKey.stageKept(id), stageDbId);
438
+ input.record(bgKey.stageDatabase(id), newDbId);
439
+ input.record(bgKey.stageCopy(id), `${new Date(run.now()).toISOString()}|${dump.executionId}|ok`);
440
+ run.liveDatabaseId = newDbId;
441
+ try {
442
+ await input.persist();
443
+ }
444
+ catch (error) {
445
+ // Stage runs on the new database but the records did not land. The next sweep reads stage's live
446
+ // database from its env, so it keeps this one.
447
+ logFailure('recording the new database', error);
448
+ throw run.fail('record', 'stage runs on the new database but recording it failed');
449
+ }
450
+ run.ok('record');
451
+ return run.record();
452
+ }
453
+ /**
454
+ * Puts the database stage ran on before the last copy back, and deletes the one the copy made. It mints a new
455
+ * runtime password for the kept database (the old one is gone from stage's env). A failure leaves stage on the
456
+ * database it ran on, switched back the same way a failed copy does, and records nothing.
457
+ */
458
+ export async function undoStageCopy(input) {
459
+ checkInput(input);
460
+ const cfg = settings(input.timeouts);
461
+ const { subject, coolify } = input;
462
+ const id = subject.id;
463
+ const { appId, databaseId: currentDbId } = stageIds(input);
464
+ const keptId = input.recorded()[bgKey.stageKept(id)];
465
+ if (!keptId || keptId === currentDbId)
466
+ throw new StageCopyRefusedError('no-kept-database');
467
+ const run = new Run(input.requestedBy, input.now ?? Date.now, currentDbId);
468
+ const pgName = pgNameOf(subject.base);
469
+ const runtimeRole = `${pgName}_rt`;
470
+ let ownerUrl;
471
+ try {
472
+ const kept = await withTimeout('stage copy: start the kept database', cfg.databaseStartMs, () => getDatabaseStarted(coolify, keptId));
473
+ if (!kept.internal_db_url)
474
+ throw new Error('no URL');
475
+ ownerUrl = kept.internal_db_url;
476
+ }
477
+ catch (error) {
478
+ logFailure('reading the kept database', error);
479
+ throw new StageCopyRefusedError('no-kept-database');
480
+ }
481
+ input.secret?.(ownerUrl);
482
+ const password = randomBytes(24).toString('base64url');
483
+ input.secret?.(password);
484
+ const runtimeUrl = runtimeDatabaseUrl(ownerUrl, runtimeRole, password);
485
+ if (!runtimeUrl)
486
+ throw run.fail('runtime-role', 'the kept database URL could not be read');
487
+ input.secret?.(runtimeUrl);
488
+ try {
489
+ const roled = await withTimeout('stage copy: runtime role', cfg.roleMs, () => input.restore.ensureRuntimeRole({
490
+ targetDatabaseUrl: ownerUrl,
491
+ runtimeRole,
492
+ runtimePassword: password,
493
+ }));
494
+ if (roled.exitCode !== 0)
495
+ throw run.fail('runtime-role', 'the runtime role failed');
496
+ }
497
+ catch (error) {
498
+ if (error instanceof StageCopyFailedError)
499
+ throw error;
500
+ logFailure('setting the runtime role', error);
501
+ throw run.fail('runtime-role', 'the runtime role failed');
502
+ }
503
+ run.ok('runtime-role');
504
+ let from;
505
+ try {
506
+ from = await readStageUrls(input, cfg, appId);
507
+ }
508
+ catch (error) {
509
+ logFailure('reading the stage env', error);
510
+ throw run.fail('switch', 'the stage env could not be read');
511
+ }
512
+ for (const value of [from.owner, from.runtime])
513
+ if (value)
514
+ input.secret?.(value);
515
+ const result = await switchStage(input, cfg, appId, from, {
516
+ owner: ownerUrl,
517
+ runtime: runtimeUrl,
518
+ });
519
+ if (!result.switched) {
520
+ throw run.fail('switch', result.recovered
521
+ ? 'stage did not run on the kept database; it runs on its own database again'
522
+ : 'stage did not run on the kept database, and putting it back could not be confirmed');
523
+ }
524
+ run.ok('switch');
525
+ input.record(bgKey.stageDatabase(id), keptId);
526
+ input.record(bgKey.stageKept(id), '');
527
+ run.liveDatabaseId = keptId;
528
+ try {
529
+ await input.persist();
530
+ }
531
+ catch (error) {
532
+ logFailure('recording the kept database', error);
533
+ throw run.fail('record', 'stage runs on the kept database but recording it failed');
534
+ }
535
+ await deleteQuietly(input, cfg, currentDbId);
536
+ run.ok('record');
537
+ return run.record();
538
+ }
@@ -0,0 +1,37 @@
1
+ import type { DeclaredOrg } from '../org.js';
2
+ /**
3
+ * Mailpit v1.31.4, pinned by digest. `@wtfalch/foundry-local` runs the same image (`packages/local/src/sink.ts`
4
+ * imports {@link STAGE_SINK_IMAGE_REF}), so both use the one pin. Coolify wants the repository and the tag
5
+ * apart, and a tag needs a real name before `@`.
6
+ */
7
+ export declare const STAGE_SINK_REPOSITORY = "axllent/mailpit";
8
+ export declare const STAGE_SINK_TAG = "v1.31.4";
9
+ export declare const STAGE_SINK_DIGEST = "sha256:b68349e3a014b90c5610bfb26b2ae36f3892d7b8cf25ee140c6c71c98d2fcf48";
10
+ /** `<repository>@<digest>`, for a direct Docker pull. */
11
+ export declare const STAGE_SINK_IMAGE_REF = "axllent/mailpit@sha256:b68349e3a014b90c5610bfb26b2ae36f3892d7b8cf25ee140c6c71c98d2fcf48";
12
+ /** `<tag>@<digest>`, for Coolify's `docker_registry_image_tag`. */
13
+ export declare const STAGE_SINK_IMAGE_TAG = "v1.31.4@sha256:b68349e3a014b90c5610bfb26b2ae36f3892d7b8cf25ee140c6c71c98d2fcf48";
14
+ export declare const STAGE_SINK_SMTP_PORT = 1025;
15
+ export declare const STAGE_SINK_HTTP_PORT = 8025;
16
+ /** Flat org scope id: `'1'` once the sink app is deployed. Recorded after the deploy, never before. */
17
+ export declare const STAGE_SINK_APPLIED_SCOPE_KEY = "stage_sink_applied";
18
+ /** Flat org scope id of the sink's Coolify app: one sink per company, shared by every stage. */
19
+ export declare const STAGE_SINK_APP_SCOPE_KEY = "stage_sink_app";
20
+ /**
21
+ * Part of the structure hash of a primary server. Raise it when the sink wiring changes, so an applied
22
+ * structure runs once more.
23
+ */
24
+ export declare const STAGE_SINK_VERSION = 1;
25
+ type SinkOrg = Pick<DeclaredOrg, 'slug'>;
26
+ export declare const stageSinkAppName: (org: SinkOrg) => string;
27
+ /** The sink's HTTP address on the company's Docker network. */
28
+ export declare const stageSinkInternalUrl: (org: SinkOrg) => string;
29
+ /**
30
+ * The only mail env stage holds. `@wtfalch/mailer` 0.6.0 posts every message to `MAIL_SINK_URL` when it is set,
31
+ * so stage holds no mail token and no Lettermint setting.
32
+ */
33
+ export declare function stageMailEnv(org: SinkOrg): Array<{
34
+ key: string;
35
+ value: string;
36
+ }>;
37
+ export {};
@@ -0,0 +1,33 @@
1
+ /**
2
+ * Mailpit v1.31.4, pinned by digest. `@wtfalch/foundry-local` runs the same image (`packages/local/src/sink.ts`
3
+ * imports {@link STAGE_SINK_IMAGE_REF}), so both use the one pin. Coolify wants the repository and the tag
4
+ * apart, and a tag needs a real name before `@`.
5
+ */
6
+ export const STAGE_SINK_REPOSITORY = 'axllent/mailpit';
7
+ export const STAGE_SINK_TAG = 'v1.31.4';
8
+ export const STAGE_SINK_DIGEST = 'sha256:b68349e3a014b90c5610bfb26b2ae36f3892d7b8cf25ee140c6c71c98d2fcf48';
9
+ /** `<repository>@<digest>`, for a direct Docker pull. */
10
+ export const STAGE_SINK_IMAGE_REF = `${STAGE_SINK_REPOSITORY}@${STAGE_SINK_DIGEST}`;
11
+ /** `<tag>@<digest>`, for Coolify's `docker_registry_image_tag`. */
12
+ export const STAGE_SINK_IMAGE_TAG = `${STAGE_SINK_TAG}@${STAGE_SINK_DIGEST}`;
13
+ export const STAGE_SINK_SMTP_PORT = 1025;
14
+ export const STAGE_SINK_HTTP_PORT = 8025;
15
+ /** Flat org scope id: `'1'` once the sink app is deployed. Recorded after the deploy, never before. */
16
+ export const STAGE_SINK_APPLIED_SCOPE_KEY = 'stage_sink_applied';
17
+ /** Flat org scope id of the sink's Coolify app: one sink per company, shared by every stage. */
18
+ export const STAGE_SINK_APP_SCOPE_KEY = 'stage_sink_app';
19
+ /**
20
+ * Part of the structure hash of a primary server. Raise it when the sink wiring changes, so an applied
21
+ * structure runs once more.
22
+ */
23
+ export const STAGE_SINK_VERSION = 1;
24
+ export const stageSinkAppName = (org) => `${org.slug}-stage-sink`;
25
+ /** The sink's HTTP address on the company's Docker network. */
26
+ export const stageSinkInternalUrl = (org) => `http://${stageSinkAppName(org)}:${STAGE_SINK_HTTP_PORT}`;
27
+ /**
28
+ * The only mail env stage holds. `@wtfalch/mailer` 0.6.0 posts every message to `MAIL_SINK_URL` when it is set,
29
+ * so stage holds no mail token and no Lettermint setting.
30
+ */
31
+ export function stageMailEnv(org) {
32
+ return [{ key: 'MAIL_SINK_URL', value: stageSinkInternalUrl(org) }];
33
+ }
@@ -1,5 +1,5 @@
1
1
  import { and, asc, desc, eq, inArray, isNull, ne, sql } from 'drizzle-orm';
2
- import { APP_ENTRY_KEY, DEFAULT_MAIL_APPS, DEFAULT_NO_REPLY_LOCAL_PART, refuseBuiltinBlueGreen, refuseCustomers, refuseHosting, validateBlueGreen, validateBuyerIdentity, validateCalls, validateDomain, validateDomainClaim, validateMail, validateOwners, validateServices, validateSignIn, validateSite, validateVersionPin, } from '../org.js';
2
+ import { APP_ENTRY_KEY, DEFAULT_MAIL_APPS, DEFAULT_NO_REPLY_LOCAL_PART, normalizeHostingRepos, refuseBuiltinBlueGreen, refuseCustomers, refuseHosting, validateBlueGreen, validateBuyerIdentity, validateCalls, validateDomain, validateDomainClaim, validateMail, validateOwners, validateServices, validateSignIn, validateSite, validateVersionPin, } from '../org.js';
3
3
  import { hostingNames } from '../plan/locked-hostnames.js';
4
4
  import { findNameClashes, nameClashMessage } from '../plan/org-names.js';
5
5
  import { validatePlatform } from '../platform.js';
@@ -176,6 +176,7 @@ function rowToOrg(row) {
176
176
  issuerApps: row.hostingIssuerApps,
177
177
  entries: row.hostingEntries.map((entry) => storedEntryToEntry(entry, row.hostingUsesDomain)),
178
178
  gateLimit: row.hostingGateLimit,
179
+ repos: row.hostingRepos,
179
180
  },
180
181
  sso: { enabled: row.ssoEnabled },
181
182
  guests: { enabled: row.guestsEnabled },
@@ -241,6 +242,7 @@ function orgToRow(org) {
241
242
  hostingIssuerApps: org.hosting.issuerApps,
242
243
  hostingEntries: org.hosting.entries,
243
244
  hostingGateLimit: org.hosting.gateLimit,
245
+ hostingRepos: normalizeHostingRepos(org.hosting.repos),
244
246
  ssoEnabled: org.sso.enabled,
245
247
  guestsEnabled: org.guests.enabled,
246
248
  soloEnabled: org.solo.enabled,
@@ -569,7 +571,16 @@ export function createPostgresDeclaredOrgStore(db) {
569
571
  // just be re-litigating a write this update isn't making.
570
572
  if (patch.slug !== undefined && patch.slug !== current.slug)
571
573
  requireValidSlug(patch.slug);
572
- const next = { ...current, ...patch, id: current.id, version: current.version };
574
+ const merged = {
575
+ ...current,
576
+ ...patch,
577
+ id: current.id,
578
+ version: current.version,
579
+ };
580
+ const next = {
581
+ ...merged,
582
+ hosting: { ...merged.hosting, repos: normalizeHostingRepos(merged.hosting.repos) },
583
+ };
573
584
  requireValidDomain(next.domain);
574
585
  requireValidLimits(next);
575
586
  requireValidBuyer(next);
@@ -692,6 +692,25 @@ export declare const declaredOrgs: import("drizzle-orm/pg-core").PgTableWithColu
692
692
  identity: undefined;
693
693
  generated: undefined;
694
694
  }, {}, {}>;
695
+ hostingRepos: import("drizzle-orm/pg-core").PgColumn<{
696
+ name: "hosting_repos";
697
+ tableName: "foundry_declared_orgs";
698
+ dataType: "json";
699
+ columnType: "PgJsonb";
700
+ data: string[];
701
+ driverParam: unknown;
702
+ notNull: true;
703
+ hasDefault: true;
704
+ isPrimaryKey: false;
705
+ isAutoincrement: false;
706
+ hasRuntimeDefault: false;
707
+ enumValues: undefined;
708
+ baseColumn: never;
709
+ identity: undefined;
710
+ generated: undefined;
711
+ }, {}, {
712
+ $type: string[];
713
+ }>;
695
714
  ssoEnabled: import("drizzle-orm/pg-core").PgColumn<{
696
715
  name: "sso_enabled";
697
716
  tableName: "foundry_declared_orgs";