@wtfalch/foundry 0.45.0 → 0.46.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 (60) hide show
  1. package/dist/index.d.ts +5 -3
  2. package/dist/index.js +3 -2
  3. package/dist/org.d.ts +37 -0
  4. package/dist/org.js +135 -10
  5. package/dist/plan/agora.d.ts +14 -0
  6. package/dist/plan/agora.js +21 -0
  7. package/dist/plan/backup-gate.d.ts +15 -0
  8. package/dist/plan/backup-gate.js +38 -0
  9. package/dist/plan/boule-owners-http.d.ts +12 -0
  10. package/dist/plan/boule-owners-http.js +59 -0
  11. package/dist/plan/boule.d.ts +38 -2
  12. package/dist/plan/boule.js +72 -1
  13. package/dist/plan/company-backups.d.ts +7 -1
  14. package/dist/plan/company-backups.js +42 -2
  15. package/dist/plan/cookie-domain.d.ts +41 -0
  16. package/dist/plan/cookie-domain.js +71 -0
  17. package/dist/plan/desktop-auth.d.ts +58 -0
  18. package/dist/plan/desktop-auth.js +144 -0
  19. package/dist/plan/desktop-callback.d.ts +26 -0
  20. package/dist/plan/desktop-callback.js +59 -0
  21. package/dist/plan/diff.d.ts +13 -4
  22. package/dist/plan/diff.js +272 -84
  23. package/dist/plan/executor.d.ts +136 -6
  24. package/dist/plan/executor.js +725 -98
  25. package/dist/plan/health-gaps.js +5 -0
  26. package/dist/plan/index.d.ts +3 -0
  27. package/dist/plan/index.js +2 -0
  28. package/dist/plan/job-app.d.ts +23 -0
  29. package/dist/plan/job-app.js +41 -0
  30. package/dist/plan/live.d.ts +6 -0
  31. package/dist/plan/live.js +16 -0
  32. package/dist/plan/login-host.d.ts +28 -0
  33. package/dist/plan/login-host.js +36 -0
  34. package/dist/plan/org-names.js +9 -1
  35. package/dist/plan/org-postgres-job.d.ts +22 -0
  36. package/dist/plan/org-postgres-job.js +91 -0
  37. package/dist/plan/org-postgres-migrate.d.ts +115 -0
  38. package/dist/plan/org-postgres-migrate.js +466 -0
  39. package/dist/plan/org-postgres.d.ts +90 -0
  40. package/dist/plan/org-postgres.js +293 -0
  41. package/dist/plan/plan-apply.d.ts +30 -2
  42. package/dist/plan/plan-apply.js +104 -7
  43. package/dist/plan/platform-diff.d.ts +45 -0
  44. package/dist/plan/platform-diff.js +159 -4
  45. package/dist/plan/platform-org.d.ts +2 -0
  46. package/dist/plan/platform-org.js +4 -0
  47. package/dist/plan/platform-providers.d.ts +12 -2
  48. package/dist/plan/platform-providers.js +279 -9
  49. package/dist/plan/post-logout-uris.d.ts +22 -0
  50. package/dist/plan/post-logout-uris.js +43 -0
  51. package/dist/plan/providers-readers.d.ts +10 -1
  52. package/dist/plan/providers-readers.js +41 -1
  53. package/dist/plan/sign-in-bindings.d.ts +4 -0
  54. package/dist/plan/sign-in-bindings.js +10 -0
  55. package/dist/plan/sign-in-org.d.ts +19 -6
  56. package/dist/plan/sign-in-org.js +35 -10
  57. package/dist/plan/sign-in.d.ts +12 -5
  58. package/dist/plan/sign-in.js +12 -5
  59. package/dist/plan/signin-db-move.js +10 -34
  60. package/package.json +5 -5
@@ -0,0 +1,466 @@
1
+ import { pinnedRef } from '../bootstrap/versions.js';
2
+ import { ORG_POSTGRES_SERVER_SCOPE_KEY, ORG_SERVICE_SCHEMAS, orgServiceDatabase, } from '../org.js';
3
+ import { JobFailure, waitForJob } from './job-app.js';
4
+ import { orgPostgresMigratedScopeKey, orgPostgresSecretRef, serviceEndpoint, serviceUrls, } from './org-postgres.js';
5
+ import { boundedCall, waitHealthy, withCallTimeout } from './signin-db.js';
6
+ /** Set only by `confirmOrgPostgresMigration`. Without it no migration step is planned, applied or run. */
7
+ export const ORG_POSTGRES_MIGRATION_CONFIRMED_SCOPE_KEY = 'org_postgres_migration_confirmed';
8
+ /** The copy job's app id while it exists. */
9
+ export const ORG_POSTGRES_MIGRATE_APP_SCOPE_KEY = 'org_postgres_migrate_app';
10
+ /** Thrown before anything is called when the confirmation is not recorded. */
11
+ export class OrgPostgresMigrationNotConfirmedError extends Error {
12
+ constructor() {
13
+ super('org postgres migration refused: no staging confirmation is recorded');
14
+ this.name = 'OrgPostgresMigrationNotConfirmedError';
15
+ }
16
+ }
17
+ /** Thrown by `confirmOrgPostgresMigration` for a confirmation that is not valid; nothing is recorded. */
18
+ export class InvalidMigrationConfirmationError extends Error {
19
+ reason;
20
+ constructor(reason) {
21
+ super(`org postgres migration confirmation refused: ${reason}`);
22
+ this.reason = reason;
23
+ this.name = 'InvalidMigrationConfirmationError';
24
+ }
25
+ }
26
+ /** Why a confirmation is not valid, or null. Pure. */
27
+ export function invalidMigrationConfirmation(c) {
28
+ if (typeof c.stagingCopy !== 'string' || c.stagingCopy.trim().length < 3) {
29
+ return 'the staging copy id needs 3 to 80 characters';
30
+ }
31
+ if (c.stagingCopy.length > 80)
32
+ return 'the staging copy id needs 3 to 80 characters';
33
+ if (typeof c.confirmedBy !== 'string' || c.confirmedBy.trim().length === 0) {
34
+ return 'the confirming person is missing';
35
+ }
36
+ if (typeof c.confirmedAt !== 'string' || Number.isNaN(Date.parse(c.confirmedAt))) {
37
+ return 'the confirmation time is not a time';
38
+ }
39
+ return null;
40
+ }
41
+ /** Pure. The recorded confirmation, or null when absent or not valid. */
42
+ export function parseMigrationConfirmation(raw) {
43
+ if (!raw)
44
+ return null;
45
+ let value;
46
+ try {
47
+ value = JSON.parse(raw);
48
+ }
49
+ catch {
50
+ return null;
51
+ }
52
+ if (typeof value !== 'object' || value === null)
53
+ return null;
54
+ const { stagingCopy, confirmedBy, confirmedAt } = value;
55
+ if (typeof stagingCopy !== 'string' ||
56
+ typeof confirmedBy !== 'string' ||
57
+ typeof confirmedAt !== 'string') {
58
+ return null;
59
+ }
60
+ const confirmation = { stagingCopy, confirmedBy, confirmedAt };
61
+ return invalidMigrationConfirmation(confirmation) === null ? confirmation : null;
62
+ }
63
+ /** The order homes move in; `desktop_auth` has nothing to move. */
64
+ const MIGRATION_ORDER = [
65
+ 'app',
66
+ 'cms',
67
+ 'boule',
68
+ 'chat_service',
69
+ 'calls_service',
70
+ 'chat_synapse',
71
+ 'chat_mas',
72
+ 'calls_synapse',
73
+ 'calls_mas',
74
+ ];
75
+ /** The home a planned `org-postgres-migrate` step names, from its action key `<org.id>:<home>`. */
76
+ export function migrateHomeOf(action) {
77
+ const home = action.desired.home;
78
+ if (typeof home !== 'string' || !MIGRATION_ORDER.some((h) => h === home)) {
79
+ throw new Error('org-postgres-migrate: the plan named no known home');
80
+ }
81
+ return home;
82
+ }
83
+ /**
84
+ * Pure. The migration step for the first home that has a legacy database and is ready, and only once the
85
+ * confirmation is recorded. `legacy` is the homes {@link planOrgPostgres} reports as unmigrated. A home is
86
+ * ready when the server and its database are recorded: provisioning goes first. Unconfirmed: no step, one
87
+ * line. Migrated homes whose old database is still recorded get a line, because the old database is never
88
+ * deleted here.
89
+ */
90
+ export function planOrgPostgresMigration(org, recordedIds, legacy) {
91
+ const steps = [];
92
+ const unmanaged = [];
93
+ for (const home of ORG_SERVICE_SCHEMAS) {
94
+ const key = home.legacyKey(org);
95
+ if (key && recordedIds[key] && recordedIds[orgPostgresMigratedScopeKey(home.schema)]) {
96
+ unmanaged.push({
97
+ description: `${org.slug}'s old ${home.schema} database ${recordedIds[key]} was copied to its Postgres server and is kept; delete it by hand once you no longer need it`,
98
+ key: `org-postgres-legacy-kept:${org.slug}:${home.schema}`,
99
+ });
100
+ }
101
+ }
102
+ if (!recordedIds[ORG_POSTGRES_SERVER_SCOPE_KEY])
103
+ return { steps, unmanaged };
104
+ const ready = MIGRATION_ORDER.flatMap((name) => {
105
+ const home = legacy.find((h) => h.schema === name);
106
+ return home && recordedIds[home.scopeKey] ? [home] : [];
107
+ });
108
+ if (ready.length === 0)
109
+ return { steps, unmanaged };
110
+ if (!parseMigrationConfirmation(recordedIds[ORG_POSTGRES_MIGRATION_CONFIRMED_SCOPE_KEY])) {
111
+ unmanaged.push({
112
+ description: `${org.slug}'s data is still on its own Coolify databases; moving it needs the staging copy confirmed first`,
113
+ key: `org-postgres-migration-unconfirmed:${org.slug}`,
114
+ });
115
+ return { steps, unmanaged };
116
+ }
117
+ const first = ready[0];
118
+ if (first) {
119
+ steps.push({
120
+ action: {
121
+ system: 'coolify',
122
+ kind: 'org-postgres-migrate',
123
+ key: `${org.id}:${first.schema}`,
124
+ desired: { home: first.schema },
125
+ },
126
+ step: {
127
+ description: `copy ${org.slug}'s ${first.schema} database to its Postgres server; the old database is kept`,
128
+ destroys: false,
129
+ migrates: true,
130
+ },
131
+ });
132
+ }
133
+ return { steps, unmanaged };
134
+ }
135
+ export const ORG_POSTGRES_MIGRATE_ENV = {
136
+ oldUrl: 'OLD_DATABASE_URL',
137
+ newUrl: 'NEW_DATABASE_URL',
138
+ newAdminUrl: 'NEW_ADMIN_DATABASE_URL',
139
+ };
140
+ const JOB_DONE_FILE = '/tmp/done';
141
+ const JOB_FAILED_FILE = '/tmp/failed';
142
+ const JOB_DUMP_FILE = '/tmp/dump.fc';
143
+ /** The job app's name in Coolify. */
144
+ export const orgPostgresMigrateJobName = (org) => `${org.slug}-postgres-migrate`;
145
+ /**
146
+ * The job's script. It reads the old database and writes the new one; the old database is never
147
+ * written. A new database that holds tables is a stop, never a drop. It prints counts and fixed
148
+ * messages, never a URL.
149
+ */
150
+ export function orgPostgresMigrateScript(ownerRole) {
151
+ if (!/^[a-z][a-z0-9_]{0,62}$/.test(ownerRole)) {
152
+ throw new Error(`Postgres name is not valid: ${ownerRole}`);
153
+ }
154
+ const old = `"$${ORG_POSTGRES_MIGRATE_ENV.oldUrl}"`;
155
+ const next = `"$${ORG_POSTGRES_MIGRATE_ENV.newUrl}"`;
156
+ const admin = `"$${ORG_POSTGRES_MIGRATE_ENV.newAdminUrl}"`;
157
+ const tables = "select quote_ident(schemaname) || '.' || quote_ident(tablename) from pg_tables where schemaname not in ('pg_catalog', 'information_schema') order by 1";
158
+ const sequences = "select quote_ident(schemaname) || '.' || quote_ident(sequencename) || '=' || coalesce(last_value::text, 'null') from pg_sequences where schemaname not in ('pg_catalog', 'information_schema') order by 1";
159
+ return [
160
+ '#!/bin/sh',
161
+ 'set -eu',
162
+ `trap 'rm -f ${JOB_DUMP_FILE}; [ -f ${JOB_DONE_FILE} ] || touch ${JOB_FAILED_FILE}' EXIT`,
163
+ "trap 'exit 1' HUP INT TERM",
164
+ `[ ! -f ${JOB_FAILED_FILE} ] || exit 1`,
165
+ `rm -f ${JOB_DONE_FILE}`,
166
+ 'fail() { echo "org-postgres-migrate: $1"; exit 1; }',
167
+ `old_major=$(psql ${old} -Atc "show server_version_num" | cut -c1-2)`,
168
+ `new_major=$(psql ${admin} -Atc "show server_version_num" | cut -c1-2)`,
169
+ '[ "$old_major" = "$new_major" ] || fail "the two servers run different Postgres versions"',
170
+ `existing=$(psql ${next} -Atc "select count(*) from pg_tables where schemaname not in ('pg_catalog', 'information_schema')")`,
171
+ '[ "$existing" = "0" ] || fail "the new database is not empty"',
172
+ `pg_dump -Fc --no-owner --no-acl ${old} -f ${JOB_DUMP_FILE} || fail "pg_dump failed"`,
173
+ `pg_restore --no-owner --no-acl --role=${ownerRole} --exit-on-error -d ${next} ${JOB_DUMP_FILE} || fail "the restore failed"`,
174
+ `rm -f ${JOB_DUMP_FILE}`,
175
+ `old_tables=$(psql ${old} -Atc "${tables}")`,
176
+ `new_tables=$(psql ${next} -Atc "${tables}")`,
177
+ '[ "$old_tables" = "$new_tables" ] || fail "the two databases hold different tables"',
178
+ 'checked=0',
179
+ 'set -f',
180
+ 'printf "%s\\n" "$old_tables" > /tmp/tables',
181
+ 'while IFS= read -r t; do',
182
+ ' [ -n "$t" ] || continue',
183
+ ` a=$(psql ${old} -Atc "select count(*) from $t")`,
184
+ ` b=$(psql ${next} -Atc "select count(*) from $t")`,
185
+ ' [ "$a" = "$b" ] || fail "row count differs in $t: old $a, new $b"',
186
+ ' checked=$((checked + 1))',
187
+ 'done < /tmp/tables',
188
+ 'set +f',
189
+ '[ "$checked" -gt 0 ] || fail "the old database has no tables"',
190
+ `old_seq=$(psql ${old} -Atc "${sequences}")`,
191
+ `new_seq=$(psql ${next} -Atc "${sequences}")`,
192
+ '[ "$old_seq" = "$new_seq" ] || fail "the sequences differ"',
193
+ 'echo "org-postgres-migrate: $checked tables copied, row counts and sequences equal"',
194
+ `touch ${JOB_DONE_FILE}`,
195
+ 'exec sleep infinity',
196
+ ];
197
+ }
198
+ /** The job: the same pinned image as the servers, so `pg_dump` matches their major version. */
199
+ export function orgPostgresMigrateDockerfile(ownerRole) {
200
+ const encoded = Buffer.from(`${orgPostgresMigrateScript(ownerRole).join('\n')}\n`, 'utf8').toString('base64');
201
+ return [
202
+ `FROM postgres:${pinnedRef('postgres')}`,
203
+ `RUN echo '${encoded}' | base64 -d > /usr/local/bin/org-postgres-migrate && chmod 0755 /usr/local/bin/org-postgres-migrate`,
204
+ 'ENTRYPOINT ["/usr/local/bin/org-postgres-migrate"]',
205
+ `HEALTHCHECK --interval=10s --timeout=5s --start-period=10s --retries=1 CMD ["test", "-f", "${JOB_DONE_FILE}"]`,
206
+ ].join('\n');
207
+ }
208
+ class MigrateFailure extends Error {
209
+ }
210
+ const defaultSleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
211
+ async function waitStopped(coolify, appId, runtime) {
212
+ const now = runtime.now ?? Date.now;
213
+ const sleep = runtime.sleep ?? defaultSleep;
214
+ const timeout = runtime.signinDbStopTimeoutMs ?? 120_000;
215
+ const interval = runtime.signinDbMovePollMs ?? 10_000;
216
+ const deadline = now() + timeout;
217
+ for (;;) {
218
+ const { status } = await coolify.getApplication(appId);
219
+ if (status?.startsWith('exited') || status?.startsWith('stopped'))
220
+ return;
221
+ if (now() >= deadline) {
222
+ throw new MigrateFailure(`an app did not report stopped within ${Math.round(timeout / 1000)}s (last status: ${status ?? 'unknown'}), so nothing was copied`);
223
+ }
224
+ await sleep(interval);
225
+ }
226
+ }
227
+ /** Runs `work`; a failure comes back as a note for the error message, never thrown. */
228
+ async function attempt(work, failed) {
229
+ try {
230
+ await work();
231
+ return '';
232
+ }
233
+ catch {
234
+ return ` (${failed})`;
235
+ }
236
+ }
237
+ /**
238
+ * Copies one home's data from its legacy Coolify database to the company's Postgres server (#444).
239
+ *
240
+ * 1. The recorded confirmation is checked before anything is called.
241
+ * 2. The consumer apps are stopped, and the step waits until Coolify reports them stopped.
242
+ * 3. A one-shot job app dumps the old database into the new, empty one and compares the tables, the
243
+ * row count of each and the value of each sequence in both.
244
+ * 4. A template app gets the new `DATABASE_URL_OWNER` and `DATABASE_URL` (the runtime password it has now).
245
+ * 5. `org_postgres_migrated.<home>` is recorded and persisted BEFORE a consumer starts on the new database.
246
+ *
247
+ * The old database is never written to or deleted. A failure before step 4 starts the consumers on the
248
+ * old database again. A failure around step 4 reads the live env back and acts on what is really set;
249
+ * when it cannot tell, the consumers stay stopped and the error says so. A rerun that finds the new
250
+ * URL already set records and starts without copying. A matrix stack is left stopped: its config is
251
+ * rendered from the server by the next apply, which starts it.
252
+ */
253
+ export async function migrateOrgPostgresHome(scope, deps, org, homeKey) {
254
+ const label = `${org.slug}'s ${homeKey} database migration`;
255
+ if (!parseMigrationConfirmation(scope.recordedIds()[ORG_POSTGRES_MIGRATION_CONFIRMED_SCOPE_KEY])) {
256
+ throw new OrgPostgresMigrationNotConfirmedError();
257
+ }
258
+ const home = ORG_SERVICE_SCHEMAS.find((h) => h.schema === homeKey);
259
+ if (!home || !MIGRATION_ORDER.some((h) => h === homeKey)) {
260
+ throw new Error(`${label}: not a home that has data to move`);
261
+ }
262
+ const recorded = scope.recordedIds();
263
+ if (recorded[orgPostgresMigratedScopeKey(homeKey)])
264
+ return;
265
+ const legacyId = home.legacyKey(org) ? recorded[home.legacyKey(org)] : undefined;
266
+ const serverId = recorded[ORG_POSTGRES_SERVER_SCOPE_KEY];
267
+ if (!legacyId)
268
+ throw new Error(`${label}: the home has no old database recorded`);
269
+ if (!serverId || !recorded[home.scopeKey]) {
270
+ throw new Error(`${label}: the Postgres server or the home's database is not provisioned yet`);
271
+ }
272
+ const coolify = withCallTimeout(deps.coolify);
273
+ const runtime = deps.runtime ?? {};
274
+ const onSecret = (value) => deps.onSecret?.(value);
275
+ const old = await boundedCall(deps.readLegacyDatabase(legacyId), 'Coolify getDatabase');
276
+ if (!old.internal_db_url || !old.status?.startsWith('running')) {
277
+ throw new Error(`${label}: the old database is not running`);
278
+ }
279
+ onSecret(old.internal_db_url);
280
+ if (URL.canParse(old.internal_db_url)) {
281
+ const pw = decodeURIComponent(new URL(old.internal_db_url).password);
282
+ if (pw)
283
+ onSecret(pw);
284
+ }
285
+ const server = await boundedCall(deps.startServer(serverId), 'Coolify startDatabase');
286
+ const endpoint = server.internal_db_url
287
+ ? serviceEndpoint(org, home, server.internal_db_url)
288
+ : null;
289
+ if (!server.internal_db_url || !endpoint) {
290
+ throw new Error(`${label}: the Postgres server has no internal URL yet`);
291
+ }
292
+ onSecret(server.internal_db_url);
293
+ if (URL.canParse(server.internal_db_url)) {
294
+ const pw = decodeURIComponent(new URL(server.internal_db_url).password);
295
+ if (pw)
296
+ onSecret(pw);
297
+ }
298
+ const ownerPassword = await boundedCall(deps.resolveSecret(orgPostgresSecretRef(org, homeKey, 'owner')), 'resolveSecret');
299
+ if (!ownerPassword)
300
+ throw new Error(`${label}: the owner password is not held yet`);
301
+ onSecret(ownerPassword);
302
+ const newOwnerUrl = serviceUrls(endpoint, ownerPassword, null).owner;
303
+ onSecret(newOwnerUrl);
304
+ const { appIds, template } = await deps.consumers(home);
305
+ const names = orgServiceDatabase(org, home);
306
+ const migratedKey = orgPostgresMigratedScopeKey(homeKey);
307
+ const readEnv = async (appId, key) => (await coolify.listEnvs(appId)).find((e) => e.key === key && !e.is_preview)?.value;
308
+ const primary = appIds[0];
309
+ /** Which database a template app's owner URL names right now. */
310
+ const liveDatabase = async () => {
311
+ if (!template || !primary)
312
+ return 'old';
313
+ try {
314
+ const url = await readEnv(primary, 'DATABASE_URL_OWNER');
315
+ if (url === newOwnerUrl)
316
+ return 'new';
317
+ return url === old.internal_db_url ? 'old' : 'unknown';
318
+ }
319
+ catch {
320
+ return 'unknown';
321
+ }
322
+ };
323
+ const start = async () => {
324
+ for (const id of appIds)
325
+ await coolify.deploy(id);
326
+ if (!template)
327
+ return;
328
+ for (const id of appIds) {
329
+ await waitHealthy(coolify, id, { ...runtime, signinDbTimeoutMs: runtime.readyTimeoutMs ?? 300_000 }, label, 'the app');
330
+ }
331
+ };
332
+ /** The home is recorded as migrated in one write, then the consumers start on the new database. */
333
+ const finishOnNew = async () => {
334
+ scope.recordScopeId(migratedKey, 'done');
335
+ try {
336
+ await scope.persist();
337
+ }
338
+ catch {
339
+ throw new MigrateFailure('the apps are stopped and use the new database; the migration could not be recorded, so they were not started; apply again, which records it and starts them');
340
+ }
341
+ if (!template)
342
+ return;
343
+ try {
344
+ await start();
345
+ }
346
+ catch {
347
+ throw new MigrateFailure('the migration is recorded and the apps use the new database, but they did not report healthy; check their logs and apply again');
348
+ }
349
+ };
350
+ const finished = (error) => {
351
+ throw new Error(`${label}: ${error instanceof MigrateFailure ? error.message : 'it could not be finished'}`);
352
+ };
353
+ const before = await liveDatabase();
354
+ if (before === 'new') {
355
+ await finishOnNew().catch(finished);
356
+ return;
357
+ }
358
+ if (before === 'unknown') {
359
+ throw new Error(`${label}: the app's database URL names neither database, so nothing is copied; set it to the old database in Coolify, then apply again`);
360
+ }
361
+ let envWriteStarted = false;
362
+ let jobAppId;
363
+ try {
364
+ for (const id of appIds)
365
+ await coolify.raw('POST', `/applications/${id}/stop`);
366
+ for (const id of appIds)
367
+ await waitStopped(coolify, id, runtime);
368
+ const projectUuid = await deps.ensureProject();
369
+ const { environment } = await coolify.findOrCreateEnvironment(projectUuid, 'production');
370
+ const jobName = orgPostgresMigrateJobName(org);
371
+ const leftover = await coolify.findApplicationByName(jobName, {
372
+ environmentIds: [environment.id],
373
+ });
374
+ if (leftover)
375
+ await coolify.raw('DELETE', `/applications/${leftover.uuid}`);
376
+ const created = await coolify.createDockerfileApplication({
377
+ project_uuid: projectUuid,
378
+ server_uuid: await deps.serverUuid(),
379
+ environment_name: environment.name,
380
+ environment_uuid: environment.uuid,
381
+ dockerfile: orgPostgresMigrateDockerfile(names.ownerRole),
382
+ name: jobName,
383
+ description: `${org.slug}'s ${homeKey} database copy job`,
384
+ instant_deploy: false,
385
+ autogenerate_domain: false,
386
+ });
387
+ jobAppId = created.uuid;
388
+ scope.recordScopeId(ORG_POSTGRES_MIGRATE_APP_SCOPE_KEY, jobAppId);
389
+ await scope.persist();
390
+ await coolify.updateApplication(jobAppId, { health_check_enabled: false });
391
+ await coolify.bulkUpsertEnvs(jobAppId, [
392
+ { key: ORG_POSTGRES_MIGRATE_ENV.oldUrl, value: old.internal_db_url },
393
+ { key: ORG_POSTGRES_MIGRATE_ENV.newUrl, value: newOwnerUrl },
394
+ { key: ORG_POSTGRES_MIGRATE_ENV.newAdminUrl, value: server.internal_db_url },
395
+ ]);
396
+ await coolify.deploy(jobAppId, true);
397
+ try {
398
+ await waitForJob(coolify, jobAppId, runtime, {
399
+ job: 'the copy job',
400
+ stopped: 'the copy job stopped before the row counts matched',
401
+ });
402
+ }
403
+ catch (error) {
404
+ throw error instanceof JobFailure ? new MigrateFailure(error.message) : error;
405
+ }
406
+ await coolify.raw('POST', `/applications/${jobAppId}/stop`);
407
+ if (template && primary) {
408
+ const liveRuntime = await readEnv(primary, 'DATABASE_URL');
409
+ envWriteStarted = true;
410
+ const envs = [{ key: 'DATABASE_URL_OWNER', value: newOwnerUrl }];
411
+ if (liveRuntime && URL.canParse(liveRuntime)) {
412
+ const live = new URL(liveRuntime);
413
+ const rtPassword = decodeURIComponent(live.password);
414
+ if (rtPassword)
415
+ onSecret(rtPassword);
416
+ const runtimeUrl = serviceUrls({ ...endpoint, runtimeUser: decodeURIComponent(live.username) }, ownerPassword, rtPassword).runtime;
417
+ if (runtimeUrl) {
418
+ onSecret(runtimeUrl);
419
+ envs.push({ key: 'DATABASE_URL', value: runtimeUrl });
420
+ }
421
+ }
422
+ await coolify.bulkUpsertEnvs(primary, envs);
423
+ }
424
+ }
425
+ catch (error) {
426
+ const reason = error instanceof MigrateFailure
427
+ ? error.message
428
+ : 'a Coolify call failed (its message is not repeated)';
429
+ const timedOut = error instanceof Error && error.message.includes('did not answer within');
430
+ const live = await liveDatabase();
431
+ let state;
432
+ let tail = 'the old database is untouched and the new one is left for inspection';
433
+ if (live === 'new') {
434
+ tail = 'the old database is untouched';
435
+ try {
436
+ await finishOnNew();
437
+ state = 'the apps use the NEW database and the migration is recorded';
438
+ }
439
+ catch (finishError) {
440
+ state = `the apps use the NEW database, but ${finishError instanceof MigrateFailure ? finishError.message : 'they could not be started'}`;
441
+ }
442
+ }
443
+ else if (live === 'old' && !(envWriteStarted && timedOut)) {
444
+ state = `the apps were started again on the old database${await attempt(async () => {
445
+ for (const id of appIds)
446
+ await coolify.deploy(id);
447
+ }, 'they could not be restarted, start them by hand')}`;
448
+ }
449
+ else {
450
+ state =
451
+ 'the apps are left stopped: the write of the new database URL did not answer and may still land; do not start them by hand; wait until their database URL reads the same twice a minute apart in Coolify, then apply again';
452
+ }
453
+ const stoppedJob = jobAppId
454
+ ? await attempt(() => coolify.raw('POST', `/applications/${jobAppId}/stop`), 'the copy job could not be stopped')
455
+ : '';
456
+ throw new Error(`${label} failed: ${reason}. ${state}${stoppedJob}; ${tail}`);
457
+ }
458
+ await finishOnNew().catch(finished);
459
+ try {
460
+ await coolify.raw('DELETE', `/applications/${jobAppId}`);
461
+ scope.recordScopeId(ORG_POSTGRES_MIGRATE_APP_SCOPE_KEY, '');
462
+ }
463
+ catch {
464
+ throw new Error(`${label}: the data is migrated, but the copy job ${jobAppId} could not be deleted; delete it in Coolify (it holds the old database URL)`);
465
+ }
466
+ }
@@ -0,0 +1,90 @@
1
+ import { type DeclaredOrg, type OrgServiceSchema } from '../org.js';
2
+ import type { DraftStep } from './diff.js';
3
+ import type { CoolifyPort } from './executor.js';
4
+ import { type JobRuntime } from './job-app.js';
5
+ import type { StepScope } from './own-services.js';
6
+ /** Recorded once a live company's data for `home` is copied to the org's server (#444). */
7
+ export declare const orgPostgresMigratedScopeKey: (home: string) => string;
8
+ /** Whether `home` still has a legacy Coolify database that has not been moved to the server. */
9
+ export declare function legacyUnmigrated(org: DeclaredOrg, home: OrgServiceSchema, recordedIds: Readonly<Record<string, string>>): boolean;
10
+ /**
11
+ * Pure. Where a consumer finds its database, from recorded ids alone: on the
12
+ * server once the home is recorded and either it never had a legacy database
13
+ * or its migration is recorded. A live company stays on its Coolify database
14
+ * until then.
15
+ */
16
+ export declare function serviceOnServer(org: DeclaredOrg, home: OrgServiceSchema, recordedIds: Readonly<Record<string, string>>): boolean;
17
+ /** The parts of a home's connection on the company's server. */
18
+ export interface ServiceDbEndpoint {
19
+ host: string;
20
+ port: string;
21
+ database: string;
22
+ ownerUser: string;
23
+ runtimeUser: string;
24
+ }
25
+ /** Pure. The home's endpoint on the server whose Coolify `internal_db_url` is given; null when that URL has no host. */
26
+ export declare function serviceEndpoint(org: DeclaredOrg, home: OrgServiceSchema, serverInternalUrl: string): ServiceDbEndpoint | null;
27
+ /** Pure. The owner URL, and the runtime URL when a runtime password is given. */
28
+ export declare function serviceUrls(endpoint: ServiceDbEndpoint, ownerPassword: string, runtimePassword: string | null): {
29
+ owner: string;
30
+ runtime: string | null;
31
+ };
32
+ /** The server's name in Coolify. */
33
+ export declare const orgPostgresServerName: (org: Pick<DeclaredOrg, "slug">) => string;
34
+ /** The job app's name in Coolify. */
35
+ export declare const orgPostgresJobName: (org: Pick<DeclaredOrg, "slug">) => string;
36
+ /** The job app's id while it exists. */
37
+ export declare const ORG_POSTGRES_JOB_APP_SCOPE_KEY = "org_postgres_job_app";
38
+ /** An opaque id, set between a password's mint and its delivery. Never the password. */
39
+ export declare const orgPostgresUndeliveredScopeKey: (home: string) => string;
40
+ /** Where the host holds a home's password, once delivered. */
41
+ export declare const orgPostgresSecretRef: (org: Pick<DeclaredOrg, "slug">, home: string, role: "owner" | "runtime") => string;
42
+ /** How many times each password is handed to `deliverSecret`. */
43
+ export declare const ORG_POSTGRES_DELIVERY_ATTEMPTS = 3;
44
+ /**
45
+ * The two steps that make the company's Postgres server and its databases true, from recorded ids
46
+ * alone: the server while it is not recorded, the databases of every enabled home that is not. The
47
+ * databases step needs no precondition beyond the server step before it in the same plan.
48
+ */
49
+ export declare function planOrgPostgresSteps(org: Pick<DeclaredOrg, 'id' | 'slug'>, plan: {
50
+ serverMissing: boolean;
51
+ missingSchemas: readonly OrgServiceSchema[];
52
+ }): DraftStep[];
53
+ type ServerCoolify = Pick<CoolifyPort, 'listDatabases' | 'createPostgresDatabase' | 'findOrCreateEnvironment' | 'findApplicationByName' | 'getApplication' | 'createDockerfileApplication' | 'updateApplication' | 'bulkUpsertEnvs' | 'deploy' | 'raw'>;
54
+ /** Everything the two steps take from the executor. New ones go here, never as positional arguments. */
55
+ export interface OrgPostgresDeps {
56
+ coolify: ServerCoolify;
57
+ /** The company's Coolify project uuid, made when absent. */
58
+ ensureProject(): Promise<string>;
59
+ /** The Coolify server the company's resources live on. */
60
+ serverUuid(): Promise<string>;
61
+ /** Starts the database when it is not running and returns its live record. */
62
+ startDatabase(uuid: string): Promise<{
63
+ uuid: string;
64
+ internal_db_url?: string;
65
+ status?: string;
66
+ }>;
67
+ deliverSecret(ref: string, secret: string): Promise<void>;
68
+ /** Registers a value for redaction. */
69
+ onSecret?(value: string): void;
70
+ /** Test seam: a password. Default: 24 random bytes, base64url. */
71
+ mintPassword?(): string;
72
+ /** Test seam: an opaque id. */
73
+ mintId?(): string;
74
+ runtime?: JobRuntime;
75
+ }
76
+ /** Creates the company's Postgres server once and starts it. Returns the Coolify uuid. */
77
+ export declare function provisionOrgPostgresServer(scope: StepScope, deps: OrgPostgresDeps, org: DeclaredOrg): Promise<{
78
+ resourceId: string;
79
+ }>;
80
+ /**
81
+ * Creates the databases and roles of the homes in `homeKeys` that are not recorded, with one job app
82
+ * for all of them, then hands each owner password to the host and records the home.
83
+ *
84
+ * One mint (CLAUDE.md platform rule 1): each home's password is minted once per attempt, an opaque id
85
+ * is recorded under `org_postgres_undelivered.<home>` and persisted before the job runs, and the home
86
+ * is recorded only after the password is delivered. A run that dies between the two finds the id,
87
+ * and mints again: no consumer has read the old password, and the job sets the new one.
88
+ */
89
+ export declare function provisionOrgPostgresDatabases(scope: StepScope, deps: OrgPostgresDeps, org: DeclaredOrg, homeKeys: readonly string[]): Promise<void>;
90
+ export {};