@wtfalch/foundry 0.51.5 → 0.52.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.
- package/dist/plan/diff.d.ts +4 -0
- package/dist/plan/diff.js +28 -7
- package/dist/plan/executor.d.ts +6 -0
- package/dist/plan/executor.js +41 -3
- package/dist/plan/plan-apply.d.ts +4 -0
- package/dist/plan/plan-apply.js +3 -0
- package/dist/plan/signin-db-backup.d.ts +7 -1
- package/dist/plan/signin-db-backup.js +9 -10
- package/dist/plan/signin-db-tls.d.ts +30 -3
- package/dist/plan/signin-db-tls.js +44 -15
- package/dist/plan/signin-db.d.ts +26 -4
- package/dist/plan/signin-db.js +24 -21
- package/dist/plan/worker-app-database.d.ts +20 -0
- package/dist/plan/worker-app-database.js +20 -2
- package/dist/plan/worker-app-db.d.ts +78 -0
- package/dist/plan/worker-app-db.js +154 -0
- package/package.json +1 -1
package/dist/plan/diff.d.ts
CHANGED
|
@@ -95,6 +95,10 @@ export interface PlanOrgOptions {
|
|
|
95
95
|
customersDirectory?: boolean;
|
|
96
96
|
/** C3: the executor can mint Boule's Archon credential (`FoundryStepExecutorPorts.mintBouleArchonCredential`). */
|
|
97
97
|
bouleArchonCredential?: boolean;
|
|
98
|
+
/** The executor can store a Worker app database's CA and certificate (`FoundryStepExecutor.workerAppDbKeysPuttable`). Absent: true. */
|
|
99
|
+
workerAppDbKeysPuttable?: boolean;
|
|
100
|
+
/** The host supplies the backup bucket a Worker app database dumps into (`FoundryStepExecutor.workerAppDbBackupConfigured`). Absent: true. */
|
|
101
|
+
workerAppDbBackupConfigured?: boolean;
|
|
98
102
|
/** The executor can store the sign-in database's CA and certificate (`FoundryStepExecutor.ownSigninDbKeysPuttable`). Absent: true. */
|
|
99
103
|
ownSigninDbKeysPuttable?: boolean;
|
|
100
104
|
/** The host supplies the backup bucket the sign-in database dumps into (`FoundryStepExecutor.ownSigninDbBackupConfigured`). Absent: true. */
|
package/dist/plan/diff.js
CHANGED
|
@@ -37,6 +37,7 @@ import { planRemoteDatabase } from './remote-database.js';
|
|
|
37
37
|
import { ZITADEL_ORG_DOMAIN_BLOCKED_SCOPE_KEY, ZITADEL_ORG_DOMAIN_SCOPE_KEY, ZITADEL_ORG_REGISTRATION_CLOSED_SCOPE_KEY, signInDomainBlockedText, signInOrgDomain, } from './sign-in-org.js';
|
|
38
38
|
import { SIGN_IN_BASE_PATH, SIGN_IN_IMAGE_TAG_SUFFIX, SIGN_IN_IN_APP_SCOPE_KEY, SIGN_IN_LOGIN_APP_SCOPE_KEY, SIGN_IN_LOGIN_INTERNAL_NAME, SIGN_IN_RATE_LIMIT_SCOPE_KEY, SIGN_IN_TRUSTED_SCOPE_KEY, inAppSignIn, inAppStatePairs, inAppTargets, } from './sign-in.js';
|
|
39
39
|
import { withStepSystem } from './step-system.js';
|
|
40
|
+
import { WORKER_APP_DB_BLOCKED_KEY, WORKER_APP_DB_LEGACY_NOTE_KEY, WORKER_APP_DB_SCOPE_KEYS, planWorkerAppDb, } from './worker-app-db.js';
|
|
40
41
|
import { brokerKeyDelivered, planWorkerApps } from './worker-app.js';
|
|
41
42
|
import { planWorkers } from './worker.js';
|
|
42
43
|
/** The `Plan` (#19's public contract) a `PlanDetail` carries. */
|
|
@@ -465,6 +466,16 @@ export function computePlanDetail(org, live, recordedIds = {}, agoraHost = NO_AG
|
|
|
465
466
|
const database = planDatabase(org, byKey, claimed, recordedIds);
|
|
466
467
|
if (database)
|
|
467
468
|
plannedSteps.push(database);
|
|
469
|
+
// A Worker app's own TLS database, also before the Worker app that connects to it.
|
|
470
|
+
const workerAppDb = planWorkerAppDb({
|
|
471
|
+
org,
|
|
472
|
+
recordedIds,
|
|
473
|
+
now: (options.now ?? Date.now)(),
|
|
474
|
+
keysPuttable: options.workerAppDbKeysPuttable ?? true,
|
|
475
|
+
backupConfigured: options.workerAppDbBackupConfigured ?? true,
|
|
476
|
+
});
|
|
477
|
+
if (workerAppDb.step)
|
|
478
|
+
plannedSteps.push(workerAppDb.step);
|
|
468
479
|
const authIssuer = 'error' in agoraHost ? null : agoraHost.issuer;
|
|
469
480
|
const app = planApps(org, byKey, claimed, recordedIds, plannedSteps, authIssuer, platformReadiness);
|
|
470
481
|
plannedSteps.push(...app.steps);
|
|
@@ -659,8 +670,9 @@ export function computePlanDetail(org, live, recordedIds = {}, agoraHost = NO_AG
|
|
|
659
670
|
...(options.workerAppDatabase
|
|
660
671
|
? {
|
|
661
672
|
database: {
|
|
662
|
-
databaseReady: Boolean(recordedIds[
|
|
663
|
-
|
|
673
|
+
databaseReady: Boolean(recordedIds[WORKER_APP_DB_SCOPE_KEYS.app] &&
|
|
674
|
+
recordedIds[WORKER_APP_DB_SCOPE_KEYS.tls]) ||
|
|
675
|
+
plannedSteps.some((s) => s.action.system === 'coolify' && s.action.kind === 'worker-app-db'),
|
|
664
676
|
tunnelReady: Boolean(recordedIds[`cloudflare:tunnel:${org.id}`]) ||
|
|
665
677
|
plannedSteps.some((s) => s.action.system === 'cloudflare' && s.action.kind === 'tunnel'),
|
|
666
678
|
accessReady: edgeNeeded(platformReadiness, ['access', 'tunnels', 'dns', 'zones']) === null,
|
|
@@ -777,6 +789,12 @@ export function computePlanDetail(org, live, recordedIds = {}, agoraHost = NO_AG
|
|
|
777
789
|
unmanaged.push(...entryAiKeys.blocked);
|
|
778
790
|
unmanaged.push(...edgeWorkers.blocked);
|
|
779
791
|
unmanaged.push(...workerApps.blocked);
|
|
792
|
+
if (workerAppDb.blocked) {
|
|
793
|
+
unmanaged.push({ description: workerAppDb.blocked, key: WORKER_APP_DB_BLOCKED_KEY });
|
|
794
|
+
}
|
|
795
|
+
if (workerAppDb.note) {
|
|
796
|
+
unmanaged.push({ description: workerAppDb.note, key: WORKER_APP_DB_LEGACY_NOTE_KEY });
|
|
797
|
+
}
|
|
780
798
|
unmanaged.push(...mcp.blocked);
|
|
781
799
|
unmanaged.push(...customers.blocked);
|
|
782
800
|
unmanaged.push(...entryEdgeKeys.blocked);
|
|
@@ -3635,13 +3653,16 @@ function planDatabase(org, byKey, claimed, recordedIds) {
|
|
|
3635
3653
|
// #444: only a company whose app database is still its own Coolify database
|
|
3636
3654
|
// plans one. A new company, and one that has moved, has it on its Postgres
|
|
3637
3655
|
// server: recorded, or provisioned by a step earlier in this plan.
|
|
3638
|
-
// A Worker app's database is
|
|
3639
|
-
//
|
|
3640
|
-
|
|
3656
|
+
// A Worker app's database is foundry's own TLS Postgres (`worker-app-db.ts`), never this one:
|
|
3657
|
+
// an org whose entries use a database only through Worker apps plans no Coolify database, and
|
|
3658
|
+
// a recorded one is left as it is.
|
|
3659
|
+
if (org.hosting.entries.some((entry) => entry.workerApp?.database) &&
|
|
3660
|
+
!org.hosting.entries.some((entry) => entry.database)) {
|
|
3661
|
+
return null;
|
|
3662
|
+
}
|
|
3641
3663
|
const appHome = ORG_SERVICE_SCHEMAS.find((home) => home.schema === 'app');
|
|
3642
3664
|
const legacy = appHome?.legacyKey(org);
|
|
3643
|
-
if (!
|
|
3644
|
-
(!appHome || !legacy || !recordedIds[legacy] || serviceOnServer(org, appHome, recordedIds))) {
|
|
3665
|
+
if (!appHome || !legacy || !recordedIds[legacy] || serviceOnServer(org, appHome, recordedIds)) {
|
|
3645
3666
|
return null;
|
|
3646
3667
|
}
|
|
3647
3668
|
return planHostingToggle('coolify', 'database', org.hosting.entries.some(entryHasDatabase), org, byKey, claimed, () => `provision a database for ${org.slug}'s app`, () => `park ${org.slug}'s database (data is retained, not deleted -- #7 corrections 1)`);
|
package/dist/plan/executor.d.ts
CHANGED
|
@@ -2021,6 +2021,10 @@ export declare class FoundryStepExecutor implements StepExecutor, StepDestroyer
|
|
|
2021
2021
|
bouleArchonCredentialAvailable(): boolean;
|
|
2022
2022
|
/** Whether the held keys can store a new company's sign-in database CA and certificate (#746). */
|
|
2023
2023
|
ownSigninDbKeysPuttable(): boolean;
|
|
2024
|
+
/** Whether the held keys can store a Worker app database's CA and certificate. */
|
|
2025
|
+
workerAppDbKeysPuttable(): boolean;
|
|
2026
|
+
/** Whether the host supplies the backup bucket a Worker app database dumps into (the files host's bucket and key ports). */
|
|
2027
|
+
workerAppDbBackupConfigured(): boolean;
|
|
2024
2028
|
/** Whether the host supplies the backup bucket the sign-in database dumps into (#746). */
|
|
2025
2029
|
ownSigninDbBackupConfigured(): boolean;
|
|
2026
2030
|
/** What the executor's own files dependencies supply, for the planner's blocked lines. */
|
|
@@ -3318,6 +3322,8 @@ export declare class FoundryStepExecutor implements StepExecutor, StepDestroyer
|
|
|
3318
3322
|
* platform's password, and every secret the step reads registered for redaction.
|
|
3319
3323
|
*/
|
|
3320
3324
|
private ownAuthDeps;
|
|
3325
|
+
/** `plan/worker-app-db.ts`: the org's Worker app database as a TLS Postgres, and its server certificate's renewal. */
|
|
3326
|
+
private workerAppDb;
|
|
3321
3327
|
/** `plan/signin-db-move.ts`: copies a company's sign-in data from its Coolify database to the TLS database. */
|
|
3322
3328
|
private ownAuthDbMove;
|
|
3323
3329
|
private ownAuth;
|
package/dist/plan/executor.js
CHANGED
|
@@ -61,6 +61,7 @@ import { ZITADEL_ORG_DOMAIN_SCOPE_KEY, ZITADEL_ORG_REGISTRATION_CLOSED_SCOPE_KEY
|
|
|
61
61
|
import { SIGN_IN_BASE_PATH, SIGN_IN_ENV_LIVE_SCOPE_PREFIX, SIGN_IN_IN_APP_SCOPE_KEY, SIGN_IN_LOGIN_APP_SCOPE_KEY, SIGN_IN_RATE_LIMIT_DESCRIPTION, SIGN_IN_RATE_LIMIT_EXPRESSION, SIGN_IN_RATE_LIMIT_SCOPE_KEY, SIGN_IN_TRUSTED_SCOPE_KEY, SIGN_IN_UPSTREAM_ENV, inAppBindingFor, inAppSignIn, inAppStateHosts, inAppStatePairs, inAppTargets, signInEnvLiveHosts, } from './sign-in.js';
|
|
62
62
|
import { moveSigninDatabase } from './signin-db-move.js';
|
|
63
63
|
import { stageGateKey, stageHostname, } from './stage-guards.js';
|
|
64
|
+
import { WORKER_APP_DB_SCOPE_KEYS, provisionWorkerAppDb, workerAppDbCaPem, workerAppDbOwnerUrl, } from './worker-app-db.js';
|
|
64
65
|
import { WORKER_APP_CALL_TIMEOUT_MS, applyWorkerApp, heldBrokerKey, teardownWorkerApp, withCallTimeout, } from './worker-app.js';
|
|
65
66
|
import { entryWorkerAppliedScopeKey, entryWorkerExclusionsScopeKey, entryWorkerRetiredScopeKey, entryWorkerRouteIdScopeKey, entryWorkerRoutePatternScopeKey, entryWorkerRoutePendingScopeKey, entryWorkerRouteZoneScopeKey, entryWorkerUploadedScopeKey, } from './worker.js';
|
|
66
67
|
/**
|
|
@@ -1012,6 +1013,14 @@ export class FoundryStepExecutor {
|
|
|
1012
1013
|
ownSigninDbKeysPuttable() {
|
|
1013
1014
|
return Boolean(this.ports.ownServices?.provision.auth.heldKeys?.put);
|
|
1014
1015
|
}
|
|
1016
|
+
/** Whether the held keys can store a Worker app database's CA and certificate. */
|
|
1017
|
+
workerAppDbKeysPuttable() {
|
|
1018
|
+
return typeof this.ports.heldKeys?.put === 'function';
|
|
1019
|
+
}
|
|
1020
|
+
/** Whether the host supplies the backup bucket a Worker app database dumps into (the files host's bucket and key ports). */
|
|
1021
|
+
workerAppDbBackupConfigured() {
|
|
1022
|
+
return this.ownSigninDbBackupConfigured();
|
|
1023
|
+
}
|
|
1015
1024
|
/** Whether the host supplies the backup bucket the sign-in database dumps into (#746). */
|
|
1016
1025
|
ownSigninDbBackupConfigured() {
|
|
1017
1026
|
const files = this.ports.ownServices?.provision.files;
|
|
@@ -1196,7 +1205,7 @@ export class FoundryStepExecutor {
|
|
|
1196
1205
|
if (!workers) {
|
|
1197
1206
|
throw new Error(`cloudflare:worker-app for ${org.slug}: this executor was built without a WorkerAppPort`);
|
|
1198
1207
|
}
|
|
1199
|
-
return applyWorkerApp(this.workerAppDeps(workers), org, action, context);
|
|
1208
|
+
return applyWorkerApp(this.workerAppDeps(workers, org, () => context.recordedIds()), org, action, context);
|
|
1200
1209
|
}
|
|
1201
1210
|
case 'files:organisation':
|
|
1202
1211
|
return this.filesOrganisation(action, org, context);
|
|
@@ -1244,6 +1253,8 @@ export class FoundryStepExecutor {
|
|
|
1244
1253
|
return this.ownAuth(action, org, context);
|
|
1245
1254
|
case 'coolify:own-auth-db-move':
|
|
1246
1255
|
return this.ownAuthDbMove(org, context);
|
|
1256
|
+
case 'coolify:worker-app-db':
|
|
1257
|
+
return this.workerAppDb(org, context);
|
|
1247
1258
|
case 'coolify:own-jobs':
|
|
1248
1259
|
return this.ownJobs(action, org, context);
|
|
1249
1260
|
case 'coolify:own-mail':
|
|
@@ -1487,7 +1498,7 @@ export class FoundryStepExecutor {
|
|
|
1487
1498
|
* "Declared changes apply" nothing is marked done until the
|
|
1488
1499
|
* live state actually matches it.
|
|
1489
1500
|
*/
|
|
1490
|
-
workerAppDeps(workers) {
|
|
1501
|
+
workerAppDeps(workers, org, recorded) {
|
|
1491
1502
|
return {
|
|
1492
1503
|
workers,
|
|
1493
1504
|
...(this.ports.workerAppKeys ? { keys: this.ports.workerAppKeys } : {}),
|
|
@@ -1499,7 +1510,14 @@ export class FoundryStepExecutor {
|
|
|
1499
1510
|
access: this.accessPort(),
|
|
1500
1511
|
dns: this.dnsPort(),
|
|
1501
1512
|
zones: this.zonePort(),
|
|
1513
|
+
// The foundry-made TLS database when it is the recorded one; else the legacy Coolify database.
|
|
1514
|
+
ca: () => workerAppDbCaPem(this.ports.heldKeys, org.id),
|
|
1502
1515
|
ownerUrl: async (databaseId) => {
|
|
1516
|
+
if (databaseId === recorded()[WORKER_APP_DB_SCOPE_KEYS.app]) {
|
|
1517
|
+
const url = await workerAppDbOwnerUrl(this.ports.coolify, org, databaseId);
|
|
1518
|
+
this.handledSecrets.add(url);
|
|
1519
|
+
return url;
|
|
1520
|
+
}
|
|
1503
1521
|
const db = await getDatabaseStarted(this.ports.coolify, databaseId);
|
|
1504
1522
|
if (!db.internal_db_url) {
|
|
1505
1523
|
throw new Error('the database reports no internal connection URL yet');
|
|
@@ -7194,6 +7212,26 @@ export class FoundryStepExecutor {
|
|
|
7194
7212
|
},
|
|
7195
7213
|
};
|
|
7196
7214
|
}
|
|
7215
|
+
/** `plan/worker-app-db.ts`: the org's Worker app database as a TLS Postgres, and its server certificate's renewal. */
|
|
7216
|
+
async workerAppDb(org, context) {
|
|
7217
|
+
const files = this.ports.ownServices?.provision.files;
|
|
7218
|
+
await provisionWorkerAppDb(scopeFromOrg(context, {}), {
|
|
7219
|
+
coolify: this.placedForCompany(this.ports.coolify, org, context),
|
|
7220
|
+
heldKeys: this.ports.heldKeys,
|
|
7221
|
+
signinDbBackup: files?.r2Buckets && files.r2Keys
|
|
7222
|
+
? { r2Buckets: files.r2Buckets, r2Keys: files.r2Keys }
|
|
7223
|
+
: undefined,
|
|
7224
|
+
runtime: {
|
|
7225
|
+
...(this.ports.runtime?.sleep ? { sleep: this.ports.runtime.sleep } : {}),
|
|
7226
|
+
...(this.ports.runtime?.now ? { now: this.ports.runtime.now } : {}),
|
|
7227
|
+
onSecret: (value) => void this.handledSecrets.add(value),
|
|
7228
|
+
},
|
|
7229
|
+
}, org, {
|
|
7230
|
+
project: { ensure: () => this.ensureCoolifyProject(org, context) },
|
|
7231
|
+
label: `${org.slug}:worker-app-db`,
|
|
7232
|
+
});
|
|
7233
|
+
return {};
|
|
7234
|
+
}
|
|
7197
7235
|
/** `plan/signin-db-move.ts`: copies a company's sign-in data from its Coolify database to the TLS database. */
|
|
7198
7236
|
async ownAuthDbMove(org, context) {
|
|
7199
7237
|
const own = this.ports.ownServices;
|
|
@@ -9287,7 +9325,7 @@ export class FoundryStepExecutor {
|
|
|
9287
9325
|
}
|
|
9288
9326
|
const entryKey = target.key.slice(org.id.length + 1);
|
|
9289
9327
|
const at = `cloudflare:worker-app '${target.key}' for ${org.slug}`;
|
|
9290
|
-
await teardownWorkerApp(this.workerAppDeps(workers), entryKey, target.id || undefined, recordedIds, (label, task) => withCallTimeout(WORKER_APP_CALL_TIMEOUT_MS, `${at}: ${label}`, task), org.id);
|
|
9328
|
+
await teardownWorkerApp(this.workerAppDeps(workers, org, () => recordedIds), entryKey, target.id || undefined, recordedIds, (label, task) => withCallTimeout(WORKER_APP_CALL_TIMEOUT_MS, `${at}: ${label}`, task), org.id);
|
|
9291
9329
|
return;
|
|
9292
9330
|
}
|
|
9293
9331
|
case 'edge:key': {
|
|
@@ -226,6 +226,10 @@ export interface StepExecutor {
|
|
|
226
226
|
* copy is not recorded. Optional: an executor without it runs no release.
|
|
227
227
|
*/
|
|
228
228
|
releaseHost?(org: DeclaredOrg, subject: BlueGreenSubject, context: ReleaseContext, renew: () => Promise<void>): ReleaseHost | null;
|
|
229
|
+
/** Whether the executor's held keys can store a Worker app database's CA and certificate. Absent: true. */
|
|
230
|
+
workerAppDbKeysPuttable?(): boolean;
|
|
231
|
+
/** Whether the host supplies the backup bucket a Worker app database dumps into. Absent: true. */
|
|
232
|
+
workerAppDbBackupConfigured?(): boolean;
|
|
229
233
|
/** Whether the executor's held keys can store the sign-in database's CA and certificate (#746). Absent: true. */
|
|
230
234
|
ownSigninDbKeysPuttable?(): boolean;
|
|
231
235
|
/** Whether the host supplies the backup bucket the sign-in database dumps into (#746). Absent: true. */
|
package/dist/plan/plan-apply.js
CHANGED
|
@@ -1359,6 +1359,8 @@ export class FoundryPlanApply {
|
|
|
1359
1359
|
}),
|
|
1360
1360
|
}
|
|
1361
1361
|
: {}),
|
|
1362
|
+
workerAppDbKeysPuttable: this.executor.workerAppDbKeysPuttable?.() ?? true,
|
|
1363
|
+
workerAppDbBackupConfigured: this.executor.workerAppDbBackupConfigured?.() ?? true,
|
|
1362
1364
|
ownSigninDbKeysPuttable: this.executor.ownSigninDbKeysPuttable?.() ?? true,
|
|
1363
1365
|
ownSigninDbBackupConfigured: this.executor.ownSigninDbBackupConfigured?.() ?? true,
|
|
1364
1366
|
...(this.backupGate
|
|
@@ -2205,6 +2207,7 @@ const STEP_FIELDS = {
|
|
|
2205
2207
|
'coolify:chat': 'chat.usesDomain',
|
|
2206
2208
|
'coolify:own-auth': 'services.own',
|
|
2207
2209
|
'coolify:own-auth-db-move': 'services.own',
|
|
2210
|
+
'coolify:worker-app-db': 'hosting.entries.workerApp.database',
|
|
2208
2211
|
'coolify:own-files': 'services.own',
|
|
2209
2212
|
'coolify:own-files-r2': 'services.own',
|
|
2210
2213
|
'coolify:own-jobs': 'services.own',
|
|
@@ -61,6 +61,11 @@ export declare function ensureSigninDbBackupTarget(port: SigninDbBackupPort, inp
|
|
|
61
61
|
};
|
|
62
62
|
record(key: string, id: string): Promise<void>;
|
|
63
63
|
onSecret?(value: string): void;
|
|
64
|
+
/** Where the key's ids are recorded; the sign-in database's by default. */
|
|
65
|
+
keys?: {
|
|
66
|
+
backupKey: string;
|
|
67
|
+
backupKeyUndelivered: string;
|
|
68
|
+
};
|
|
64
69
|
}): Promise<{
|
|
65
70
|
target: SigninDbBackupTarget;
|
|
66
71
|
tokenId: string;
|
|
@@ -77,11 +82,12 @@ export declare const SIGNIN_DB_BACKUP_ENV: {
|
|
|
77
82
|
readonly script: "BACKUP_S3_SCRIPT";
|
|
78
83
|
};
|
|
79
84
|
/** Where in the bucket a company's dumps go: `signin-db/<slug>/signin-db-<UTC time>.dump`. */
|
|
80
|
-
export declare function signinDbBackupPrefix(slug: string): string;
|
|
85
|
+
export declare function signinDbBackupPrefix(slug: string, base?: string): string;
|
|
81
86
|
/** The env entries the backup command needs, for the database app, the script included. */
|
|
82
87
|
export declare function signinDbBackupEnvs(target: SigninDbBackupTarget, slug: string, db: {
|
|
83
88
|
user: string;
|
|
84
89
|
database: string;
|
|
90
|
+
prefixBase?: string;
|
|
85
91
|
}): Array<{
|
|
86
92
|
key: string;
|
|
87
93
|
value: string;
|
|
@@ -54,9 +54,11 @@ const usable = (value) => typeof value === 'string' && value.length > 0 && value
|
|
|
54
54
|
* move stops anything. Errors name the label only.
|
|
55
55
|
*/
|
|
56
56
|
export async function ensureSigninDbBackupTarget(port, input) {
|
|
57
|
+
const keyScope = input.keys?.backupKey ?? SIGNIN_DB_BACKUP_KEY_SCOPE_KEY;
|
|
58
|
+
const undeliveredScope = input.keys?.backupKeyUndelivered ?? SIGNIN_DB_BACKUP_KEY_UNDELIVERED_SCOPE_KEY;
|
|
57
59
|
const { label, recorded } = input;
|
|
58
60
|
const bucket = await portCall(port.r2Buckets.ensureBucket(input.bucketName), label, `the sign-in backup bucket ${input.bucketName}`);
|
|
59
|
-
const kept = recorded[
|
|
61
|
+
const kept = recorded[keyScope];
|
|
60
62
|
if (kept && input.held.keyId && input.held.secret) {
|
|
61
63
|
input.onSecret?.(input.held.secret);
|
|
62
64
|
input.onSecret?.(input.held.keyId);
|
|
@@ -72,11 +74,8 @@ export async function ensureSigninDbBackupTarget(port, input) {
|
|
|
72
74
|
};
|
|
73
75
|
}
|
|
74
76
|
for (const [scopeKey, tokenId] of [
|
|
75
|
-
[
|
|
76
|
-
|
|
77
|
-
recorded[SIGNIN_DB_BACKUP_KEY_UNDELIVERED_SCOPE_KEY],
|
|
78
|
-
],
|
|
79
|
-
[SIGNIN_DB_BACKUP_KEY_SCOPE_KEY, kept],
|
|
77
|
+
[undeliveredScope, recorded[undeliveredScope]],
|
|
78
|
+
[keyScope, kept],
|
|
80
79
|
]) {
|
|
81
80
|
if (!tokenId)
|
|
82
81
|
continue;
|
|
@@ -90,7 +89,7 @@ export async function ensureSigninDbBackupTarget(port, input) {
|
|
|
90
89
|
input.onSecret?.(key.accessKeyId);
|
|
91
90
|
// A token id we can read is recorded even when the rest of the answer is unusable, so it is deleted next run.
|
|
92
91
|
if (usable(key?.tokenId))
|
|
93
|
-
await input.record(
|
|
92
|
+
await input.record(undeliveredScope, key.tokenId);
|
|
94
93
|
if (!usable(key?.accessKeyId) || !usable(key?.secretAccessKey) || !usable(key?.tokenId)) {
|
|
95
94
|
throw new Error(`${label}: the sign-in backup key mint answered with an unusable key`);
|
|
96
95
|
}
|
|
@@ -116,8 +115,8 @@ export const SIGNIN_DB_BACKUP_ENV = {
|
|
|
116
115
|
script: 'BACKUP_S3_SCRIPT',
|
|
117
116
|
};
|
|
118
117
|
/** Where in the bucket a company's dumps go: `signin-db/<slug>/signin-db-<UTC time>.dump`. */
|
|
119
|
-
export function signinDbBackupPrefix(slug) {
|
|
120
|
-
return
|
|
118
|
+
export function signinDbBackupPrefix(slug, base = 'signin-db') {
|
|
119
|
+
return `${base}/${slug}`;
|
|
121
120
|
}
|
|
122
121
|
/** The env entries the backup command needs, for the database app, the script included. */
|
|
123
122
|
export function signinDbBackupEnvs(target, slug, db) {
|
|
@@ -127,7 +126,7 @@ export function signinDbBackupEnvs(target, slug, db) {
|
|
|
127
126
|
{ key: SIGNIN_DB_BACKUP_ENV.region, value: target.region ?? 'auto' },
|
|
128
127
|
{ key: SIGNIN_DB_BACKUP_ENV.keyId, value: target.accessKeyId },
|
|
129
128
|
{ key: SIGNIN_DB_BACKUP_ENV.secret, value: target.secretAccessKey },
|
|
130
|
-
{ key: SIGNIN_DB_BACKUP_ENV.prefix, value: signinDbBackupPrefix(slug) },
|
|
129
|
+
{ key: SIGNIN_DB_BACKUP_ENV.prefix, value: signinDbBackupPrefix(slug, db.prefixBase) },
|
|
131
130
|
{
|
|
132
131
|
key: SIGNIN_DB_BACKUP_ENV.script,
|
|
133
132
|
value: Buffer.from(signinDbBackupScript(db.user, db.database), 'utf8').toString('base64'),
|
|
@@ -10,6 +10,22 @@
|
|
|
10
10
|
*/
|
|
11
11
|
/** The platform's one CA, held as JSON `{ cert, key }` (PEM strings). */
|
|
12
12
|
export declare const SIGNIN_DB_CA_REF = "signin-db:platform:ca";
|
|
13
|
+
/**
|
|
14
|
+
* The recorded ids of one TLS database: the app, the marker that it is in use, and the server
|
|
15
|
+
* certificate's expiry (milliseconds since the epoch, so the planner can renew it; not a secret).
|
|
16
|
+
*/
|
|
17
|
+
export interface TlsDbScopeKeys {
|
|
18
|
+
app: string;
|
|
19
|
+
tls: string;
|
|
20
|
+
certNotAfter: string;
|
|
21
|
+
/** The id of the database's backup key (kept) and of one minted and not yet delivered. */
|
|
22
|
+
backupKey: string;
|
|
23
|
+
backupKeyUndelivered: string;
|
|
24
|
+
/** The id of the nightly dump's schedule. */
|
|
25
|
+
backupTask: string;
|
|
26
|
+
}
|
|
27
|
+
/** The scope keys of a TLS database named by `prefix`: `<prefix>_app`, `<prefix>_tls`, `<prefix>_cert_not_after`. */
|
|
28
|
+
export declare function tlsDbScopeKeys(prefix: string): TlsDbScopeKeys;
|
|
13
29
|
/** The recorded id of the server certificate's expiry (milliseconds since the epoch), so the planner can renew it. Not a secret. */
|
|
14
30
|
export declare const SIGNIN_DB_CERT_NOT_AFTER_SCOPE_KEY = "signin_db_cert_not_after";
|
|
15
31
|
/** Recorded id of the database app. */
|
|
@@ -19,6 +35,8 @@ export declare const SIGNIN_DB_APP_SCOPE_KEY = "signin_db_app";
|
|
|
19
35
|
* provisions this database and never the Coolify one.
|
|
20
36
|
*/
|
|
21
37
|
export declare const SIGNIN_DB_TLS_SCOPE_KEY = "signin_db_tls";
|
|
38
|
+
/** The sign-in database's scope keys. */
|
|
39
|
+
export declare const SIGNIN_DB_SCOPE_KEYS: TlsDbScopeKeys;
|
|
22
40
|
/**
|
|
23
41
|
* Recorded while ZITADEL has been started on the TLS database and not yet seen healthy there: the
|
|
24
42
|
* planner then plans the sign-in step, which starts ZITADEL again and clears it (#746).
|
|
@@ -26,6 +44,8 @@ export declare const SIGNIN_DB_TLS_SCOPE_KEY = "signin_db_tls";
|
|
|
26
44
|
export declare const SIGNIN_DB_ZITADEL_PENDING_SCOPE_KEY = "signin_db_zitadel_pending";
|
|
27
45
|
/** A server certificate with less than this left is issued again. */
|
|
28
46
|
export declare const SIGNIN_DB_CERT_RENEW_WINDOW_MS: number;
|
|
47
|
+
/** The CA's subject unless the caller names another. */
|
|
48
|
+
export declare const SIGNIN_DB_CA_COMMON_NAME = "foundry sign-in database CA";
|
|
29
49
|
export interface SigninDbKeys {
|
|
30
50
|
resolve(ref: string): Promise<string | null | undefined>;
|
|
31
51
|
put?(ref: string, value: string): Promise<void>;
|
|
@@ -47,14 +67,19 @@ export interface ServerCert extends PemPair {
|
|
|
47
67
|
}
|
|
48
68
|
/**
|
|
49
69
|
* The platform's CA: the held one, else a new one stored under
|
|
50
|
-
* {@link SIGNIN_DB_CA_REF}. A held value that cannot be read, or whose
|
|
70
|
+
* {@link SIGNIN_DB_CA_REF} (or `ca.ref`). A held value that cannot be read, or whose
|
|
51
71
|
* certificate does not match its key, is an error and is never replaced: a
|
|
52
72
|
* company's database may already trust it. The value read back after a store
|
|
53
73
|
* is the one used, so two applies at once end with the same CA.
|
|
54
74
|
*/
|
|
55
75
|
export declare function ensureSigninDbCa(keys: SigninDbKeys, runtime?: SigninDbTlsRuntime,
|
|
56
76
|
/** A company already has a database or certificate made under the CA: a lost CA is then an error, never made again. */
|
|
57
|
-
inUse?: boolean
|
|
77
|
+
inUse?: boolean,
|
|
78
|
+
/** The held key of the CA and its subject; the sign-in database's by default. */
|
|
79
|
+
ca?: {
|
|
80
|
+
ref?: string;
|
|
81
|
+
commonName?: string;
|
|
82
|
+
}): Promise<PemPair>;
|
|
58
83
|
/**
|
|
59
84
|
* The server certificate held under `ref` for `host`, issued again when it is
|
|
60
85
|
* missing, unreadable, for another host, signed by another CA, or has less
|
|
@@ -66,4 +91,6 @@ export declare function ensureSigninDbServerCert(keys: SigninDbKeys, ca: PemPair
|
|
|
66
91
|
* Whether the recorded server certificate expires within the renew window.
|
|
67
92
|
* Nothing recorded (a company not on the TLS database) is never due.
|
|
68
93
|
*/
|
|
69
|
-
export declare function signinDbCertDue(recordedIds: Readonly<Record<string, string>>, now: number
|
|
94
|
+
export declare function signinDbCertDue(recordedIds: Readonly<Record<string, string>>, now: number,
|
|
95
|
+
/** The scope keys of the database; the sign-in database's by default. */
|
|
96
|
+
scopeKeys?: TlsDbScopeKeys): boolean;
|
|
@@ -11,6 +11,17 @@ import { X509Certificate, createHash, createPrivateKey, createPublicKey, generat
|
|
|
11
11
|
*/
|
|
12
12
|
/** The platform's one CA, held as JSON `{ cert, key }` (PEM strings). */
|
|
13
13
|
export const SIGNIN_DB_CA_REF = 'signin-db:platform:ca';
|
|
14
|
+
/** The scope keys of a TLS database named by `prefix`: `<prefix>_app`, `<prefix>_tls`, `<prefix>_cert_not_after`. */
|
|
15
|
+
export function tlsDbScopeKeys(prefix) {
|
|
16
|
+
return {
|
|
17
|
+
app: `${prefix}_app`,
|
|
18
|
+
tls: `${prefix}_tls`,
|
|
19
|
+
certNotAfter: `${prefix}_cert_not_after`,
|
|
20
|
+
backupKey: `${prefix}_backup_key`,
|
|
21
|
+
backupKeyUndelivered: `${prefix}_backup_key_undelivered`,
|
|
22
|
+
backupTask: `${prefix}_backup_task`,
|
|
23
|
+
};
|
|
24
|
+
}
|
|
14
25
|
/** The recorded id of the server certificate's expiry (milliseconds since the epoch), so the planner can renew it. Not a secret. */
|
|
15
26
|
export const SIGNIN_DB_CERT_NOT_AFTER_SCOPE_KEY = 'signin_db_cert_not_after';
|
|
16
27
|
/** Recorded id of the database app. */
|
|
@@ -20,6 +31,16 @@ export const SIGNIN_DB_APP_SCOPE_KEY = 'signin_db_app';
|
|
|
20
31
|
* provisions this database and never the Coolify one.
|
|
21
32
|
*/
|
|
22
33
|
export const SIGNIN_DB_TLS_SCOPE_KEY = 'signin_db_tls';
|
|
34
|
+
/** The sign-in database's scope keys. */
|
|
35
|
+
export const SIGNIN_DB_SCOPE_KEYS = {
|
|
36
|
+
app: SIGNIN_DB_APP_SCOPE_KEY,
|
|
37
|
+
tls: SIGNIN_DB_TLS_SCOPE_KEY,
|
|
38
|
+
certNotAfter: SIGNIN_DB_CERT_NOT_AFTER_SCOPE_KEY,
|
|
39
|
+
// The same strings as `signin-db-backup.ts` exports (that file imports this one); a test pins them.
|
|
40
|
+
backupKey: 'signin_db_backup_key',
|
|
41
|
+
backupKeyUndelivered: 'signin_db_backup_key_undelivered',
|
|
42
|
+
backupTask: 'signin_db_backup_task',
|
|
43
|
+
};
|
|
23
44
|
/**
|
|
24
45
|
* Recorded while ZITADEL has been started on the TLS database and not yet seen healthy there: the
|
|
25
46
|
* planner then plans the sign-in step, which starts ZITADEL again and clears it (#746).
|
|
@@ -29,8 +50,9 @@ export const SIGNIN_DB_ZITADEL_PENDING_SCOPE_KEY = 'signin_db_zitadel_pending';
|
|
|
29
50
|
export const SIGNIN_DB_CERT_RENEW_WINDOW_MS = 30 * 24 * 3_600_000;
|
|
30
51
|
const SERVER_CERT_VALIDITY_MS = 365 * 24 * 3_600_000;
|
|
31
52
|
const CA_VALIDITY_MS = 10 * 365 * 24 * 3_600_000;
|
|
32
|
-
/** The CA's subject. */
|
|
33
|
-
const
|
|
53
|
+
/** The CA's subject unless the caller names another. */
|
|
54
|
+
export const SIGNIN_DB_CA_COMMON_NAME = 'foundry sign-in database CA';
|
|
55
|
+
const CA_COMMON_NAME = SIGNIN_DB_CA_COMMON_NAME;
|
|
34
56
|
// ---- DER ---------------------------------------------------------------------
|
|
35
57
|
const der = (tag, ...parts) => {
|
|
36
58
|
const body = Buffer.concat(parts);
|
|
@@ -103,11 +125,11 @@ function newKeyPair() {
|
|
|
103
125
|
};
|
|
104
126
|
}
|
|
105
127
|
// ---- the CA ------------------------------------------------------------------
|
|
106
|
-
function newCa(now) {
|
|
128
|
+
function newCa(now, commonName) {
|
|
107
129
|
const { publicKey, privateKey, keyPem } = newKeyPair();
|
|
108
130
|
const certPem = certificate({
|
|
109
|
-
subject:
|
|
110
|
-
issuer:
|
|
131
|
+
subject: commonName,
|
|
132
|
+
issuer: commonName,
|
|
111
133
|
publicKey,
|
|
112
134
|
signingKey: privateKey,
|
|
113
135
|
notBefore: now - 3_600_000,
|
|
@@ -144,15 +166,18 @@ function pairMatches(pair) {
|
|
|
144
166
|
}
|
|
145
167
|
/**
|
|
146
168
|
* The platform's CA: the held one, else a new one stored under
|
|
147
|
-
* {@link SIGNIN_DB_CA_REF}. A held value that cannot be read, or whose
|
|
169
|
+
* {@link SIGNIN_DB_CA_REF} (or `ca.ref`). A held value that cannot be read, or whose
|
|
148
170
|
* certificate does not match its key, is an error and is never replaced: a
|
|
149
171
|
* company's database may already trust it. The value read back after a store
|
|
150
172
|
* is the one used, so two applies at once end with the same CA.
|
|
151
173
|
*/
|
|
152
174
|
export async function ensureSigninDbCa(keys, runtime = {},
|
|
153
175
|
/** A company already has a database or certificate made under the CA: a lost CA is then an error, never made again. */
|
|
154
|
-
inUse = false
|
|
155
|
-
|
|
176
|
+
inUse = false,
|
|
177
|
+
/** The held key of the CA and its subject; the sign-in database's by default. */
|
|
178
|
+
ca = {}) {
|
|
179
|
+
const ref = ca.ref ?? SIGNIN_DB_CA_REF;
|
|
180
|
+
const held = await keys.resolve(ref);
|
|
156
181
|
if (held) {
|
|
157
182
|
const pair = readPair(held);
|
|
158
183
|
if (!pair)
|
|
@@ -164,14 +189,14 @@ inUse = false) {
|
|
|
164
189
|
return pair;
|
|
165
190
|
}
|
|
166
191
|
if (inUse) {
|
|
167
|
-
throw new Error(`the sign-in database CA (${
|
|
192
|
+
throw new Error(`the sign-in database CA (${ref}) is not held, but a company's database already trusts it; a new CA would break every company's sign-in, so none is made. Restore the CA from the keys backup (docs/runbooks/signin-db-tls.md, "A lost CA")`);
|
|
168
193
|
}
|
|
169
194
|
if (!keys.put)
|
|
170
195
|
throw new Error('the host cannot store the sign-in database CA: heldKeys has no put');
|
|
171
|
-
const created = newCa((runtime.now ?? Date.now)());
|
|
196
|
+
const created = newCa((runtime.now ?? Date.now)(), ca.commonName ?? CA_COMMON_NAME);
|
|
172
197
|
runtime.onSecret?.(created.keyPem);
|
|
173
|
-
await keys.put(
|
|
174
|
-
const stored = readPair(await keys.resolve(
|
|
198
|
+
await keys.put(ref, JSON.stringify({ cert: created.certPem, key: created.keyPem }));
|
|
199
|
+
const stored = readPair(await keys.resolve(ref));
|
|
175
200
|
if (!stored)
|
|
176
201
|
throw new Error('the sign-in database CA was stored but cannot be read back');
|
|
177
202
|
runtime.onSecret?.(stored.keyPem);
|
|
@@ -182,9 +207,11 @@ function newServerCert(ca, host, now) {
|
|
|
182
207
|
const { publicKey, keyPem } = newKeyPair();
|
|
183
208
|
const caCert = new X509Certificate(ca.certPem);
|
|
184
209
|
const notAfter = now + SERVER_CERT_VALIDITY_MS;
|
|
210
|
+
// The issuer is the CA's own subject, whatever common name it was made with.
|
|
211
|
+
const issuer = /^CN=(.*)$/m.exec(caCert.subject)?.[1] ?? CA_COMMON_NAME;
|
|
185
212
|
const certPem = certificate({
|
|
186
213
|
subject: host,
|
|
187
|
-
issuer
|
|
214
|
+
issuer,
|
|
188
215
|
publicKey,
|
|
189
216
|
signingKey: createPrivateKey(ca.keyPem),
|
|
190
217
|
notBefore: now - 3_600_000,
|
|
@@ -249,7 +276,9 @@ export async function ensureSigninDbServerCert(keys, ca, ref, host, runtime = {}
|
|
|
249
276
|
* Whether the recorded server certificate expires within the renew window.
|
|
250
277
|
* Nothing recorded (a company not on the TLS database) is never due.
|
|
251
278
|
*/
|
|
252
|
-
export function signinDbCertDue(recordedIds, now
|
|
253
|
-
|
|
279
|
+
export function signinDbCertDue(recordedIds, now,
|
|
280
|
+
/** The scope keys of the database; the sign-in database's by default. */
|
|
281
|
+
scopeKeys = SIGNIN_DB_SCOPE_KEYS) {
|
|
282
|
+
const notAfter = Number(recordedIds[scopeKeys.certNotAfter]);
|
|
254
283
|
return (Number.isFinite(notAfter) && notAfter > 0 && notAfter - now < SIGNIN_DB_CERT_RENEW_WINDOW_MS);
|
|
255
284
|
}
|
package/dist/plan/signin-db.d.ts
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import type { CoolifyPort } from './executor.js';
|
|
2
2
|
import { SIGNIN_DB_BACKUP_TASK_SCOPE_KEY, type SigninDbBackupPort } from './signin-db-backup.js';
|
|
3
|
-
import { SIGNIN_DB_APP_SCOPE_KEY, SIGNIN_DB_TLS_SCOPE_KEY, type SigninDbKeys, type SigninDbTlsRuntime } from './signin-db-tls.js';
|
|
3
|
+
import { SIGNIN_DB_APP_SCOPE_KEY, SIGNIN_DB_TLS_SCOPE_KEY, type SigninDbKeys, type SigninDbTlsRuntime, type TlsDbScopeKeys } from './signin-db-tls.js';
|
|
4
4
|
/**
|
|
5
5
|
* A company's sign-in database as foundry's own image (#746). Coolify 4.4.2
|
|
6
6
|
* cannot turn on a database's TLS and mounts files root-owned, so Postgres
|
|
@@ -42,9 +42,15 @@ export declare function signinDbStartScript(): string[];
|
|
|
42
42
|
* official image); every network connection must be TLS with a password, so a client that skips
|
|
43
43
|
* TLS is refused by the server, not only by the client's own setting.
|
|
44
44
|
*/
|
|
45
|
-
export declare function signinDbDockerfile(
|
|
45
|
+
export declare function signinDbDockerfile(db?: {
|
|
46
|
+
user?: string;
|
|
47
|
+
database?: string;
|
|
48
|
+
}): string;
|
|
46
49
|
/** ZITADEL's DSN: the whole chain is checked, host name included. A secret (it holds the password). */
|
|
47
|
-
export declare function signinDbDsn(host: string, password: string
|
|
50
|
+
export declare function signinDbDsn(host: string, password: string, db?: {
|
|
51
|
+
user?: string;
|
|
52
|
+
database?: string;
|
|
53
|
+
}): string;
|
|
48
54
|
type Coolify = Pick<CoolifyPort, 'findApplicationByName' | 'getApplication' | 'updateApplication' | 'bulkUpsertEnvs' | 'listEnvs' | 'deploy' | 'raw'> & Partial<Pick<CoolifyPort, 'listStorages' | 'createPersistentStorage' | 'createFileStorage'>>;
|
|
49
55
|
export interface SigninDbRuntime extends SigninDbTlsRuntime {
|
|
50
56
|
sleep?: (ms: number) => Promise<void>;
|
|
@@ -97,6 +103,22 @@ export interface ProvisionSigninDbInput {
|
|
|
97
103
|
};
|
|
98
104
|
record(key: string, id: string): Promise<void>;
|
|
99
105
|
}
|
|
106
|
+
/** What differs between TLS databases; every default is the sign-in database's. */
|
|
107
|
+
export interface TlsDbOptions {
|
|
108
|
+
/** The Coolify app name (default `input.name`). */
|
|
109
|
+
appName?: string;
|
|
110
|
+
/** Postgres user (default `zitadel`). */
|
|
111
|
+
user?: string;
|
|
112
|
+
/** Postgres database (default `zitadel`). */
|
|
113
|
+
database?: string;
|
|
114
|
+
/** The held key and subject of the CA. */
|
|
115
|
+
caRef?: string;
|
|
116
|
+
caCommonName?: string;
|
|
117
|
+
/** Recorded-id keys. */
|
|
118
|
+
scopeKeys?: TlsDbScopeKeys;
|
|
119
|
+
/** The folder in the bucket before the slug (default `signin-db`). */
|
|
120
|
+
backupPrefix?: string;
|
|
121
|
+
}
|
|
100
122
|
/**
|
|
101
123
|
* Brings the TLS database up and returns ZITADEL's DSN: the CA and server certificate (held in
|
|
102
124
|
* keys), the app with its data volume and no domain, its env (the password minted once and read
|
|
@@ -104,7 +126,7 @@ export interface ProvisionSigninDbInput {
|
|
|
104
126
|
* database reports healthy. A secret (password, key) goes only to the app's env and to
|
|
105
127
|
* `runtime.onSecret`.
|
|
106
128
|
*/
|
|
107
|
-
export declare function provisionSigninDb(input: ProvisionSigninDbInput): Promise<{
|
|
129
|
+
export declare function provisionSigninDb(input: ProvisionSigninDbInput, options?: TlsDbOptions): Promise<{
|
|
108
130
|
appId: string;
|
|
109
131
|
dsn: string;
|
|
110
132
|
ca: string;
|
package/dist/plan/signin-db.js
CHANGED
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
import { randomBytes } from 'node:crypto';
|
|
2
2
|
import { pinnedRef } from '../bootstrap/versions.js';
|
|
3
3
|
import { envsDiffer, findOrCreatePlatformApp, resolvePlatformServer, } from './platform-providers.js';
|
|
4
|
-
import { SIGNIN_DB_BACKUP_ENV,
|
|
5
|
-
import { SIGNIN_DB_APP_SCOPE_KEY,
|
|
4
|
+
import { SIGNIN_DB_BACKUP_ENV, SIGNIN_DB_BACKUP_TASK_SCOPE_KEY, ensureSigninDbBackupTarget, ensureSigninDbBackupTask, signinDbBackupEnvs, } from './signin-db-backup.js';
|
|
5
|
+
import { SIGNIN_DB_APP_SCOPE_KEY, SIGNIN_DB_SCOPE_KEYS, SIGNIN_DB_TLS_SCOPE_KEY, ensureSigninDbCa, ensureSigninDbServerCert, } from './signin-db-tls.js';
|
|
6
6
|
/**
|
|
7
7
|
* A company's sign-in database as foundry's own image (#746). Coolify 4.4.2
|
|
8
8
|
* cannot turn on a database's TLS and mounts files root-owned, so Postgres
|
|
@@ -84,7 +84,7 @@ export function signinDbStartScript() {
|
|
|
84
84
|
* official image); every network connection must be TLS with a password, so a client that skips
|
|
85
85
|
* TLS is refused by the server, not only by the client's own setting.
|
|
86
86
|
*/
|
|
87
|
-
export function signinDbDockerfile() {
|
|
87
|
+
export function signinDbDockerfile(db = {}) {
|
|
88
88
|
const lines = signinDbStartScript();
|
|
89
89
|
if (lines.some((l) => l.includes("'")))
|
|
90
90
|
throw new Error('signin-db entrypoint holds a quote');
|
|
@@ -95,12 +95,12 @@ export function signinDbDockerfile() {
|
|
|
95
95
|
`RUN printf '%s\\n' \\\n${lines.map((l) => ` '${l}'`).join(' \\\n')} > /usr/local/bin/signin-db-start \\\n && chmod 0755 /usr/local/bin/signin-db-start`,
|
|
96
96
|
`EXPOSE ${SIGNIN_DB_PORT}`,
|
|
97
97
|
'ENTRYPOINT ["/usr/local/bin/signin-db-start"]',
|
|
98
|
-
`HEALTHCHECK --interval=10s --timeout=5s --start-period=60s --retries=5 CMD ["pg_isready", "-U", "${SIGNIN_DB_USER}", "-d", "${SIGNIN_DB_NAME}"]`,
|
|
98
|
+
`HEALTHCHECK --interval=10s --timeout=5s --start-period=60s --retries=5 CMD ["pg_isready", "-U", "${db.user ?? SIGNIN_DB_USER}", "-d", "${db.database ?? SIGNIN_DB_NAME}"]`,
|
|
99
99
|
].join('\n');
|
|
100
100
|
}
|
|
101
101
|
/** ZITADEL's DSN: the whole chain is checked, host name included. A secret (it holds the password). */
|
|
102
|
-
export function signinDbDsn(host, password) {
|
|
103
|
-
return `postgres://${SIGNIN_DB_USER}:${password}@${host}:${SIGNIN_DB_PORT}/${SIGNIN_DB_NAME}?sslmode=verify-full&sslrootcert=${SIGNIN_DB_CA_MOUNT}`;
|
|
102
|
+
export function signinDbDsn(host, password, db = {}) {
|
|
103
|
+
return `postgres://${db.user ?? SIGNIN_DB_USER}:${password}@${host}:${SIGNIN_DB_PORT}/${db.database ?? SIGNIN_DB_NAME}?sslmode=verify-full&sslrootcert=${SIGNIN_DB_CA_MOUNT}`;
|
|
104
104
|
}
|
|
105
105
|
/** The volume and file-mount calls, or an error naming the step: a host that cannot mount cannot run this database. */
|
|
106
106
|
function storageCalls(coolify, label) {
|
|
@@ -168,7 +168,7 @@ export function dockerfileAppCreator(coolify, o) {
|
|
|
168
168
|
* database reports healthy. A secret (password, key) goes only to the app's env and to
|
|
169
169
|
* `runtime.onSecret`.
|
|
170
170
|
*/
|
|
171
|
-
export async function provisionSigninDb(input) {
|
|
171
|
+
export async function provisionSigninDb(input, options = {}) {
|
|
172
172
|
const { runtime, label } = input;
|
|
173
173
|
const coolify = withCallTimeout(input.coolify);
|
|
174
174
|
if (!input.keys?.put) {
|
|
@@ -177,12 +177,13 @@ export async function provisionSigninDb(input) {
|
|
|
177
177
|
if (!input.backup) {
|
|
178
178
|
throw new Error(`${label}: the host supplies no backup bucket for the sign-in database`);
|
|
179
179
|
}
|
|
180
|
-
const
|
|
181
|
-
|
|
182
|
-
|
|
180
|
+
const user = options.user ?? SIGNIN_DB_USER;
|
|
181
|
+
const database = options.database ?? SIGNIN_DB_NAME;
|
|
182
|
+
const scope = options.scopeKeys ?? SIGNIN_DB_SCOPE_KEYS;
|
|
183
|
+
const ca = await ensureSigninDbCa(input.keys, runtime, Boolean(input.recorded[scope.tls] || input.recorded[scope.app] || input.recorded[scope.certNotAfter]), { ref: options.caRef, commonName: options.caCommonName });
|
|
183
184
|
const server = await ensureSigninDbServerCert(input.keys, ca, input.serverCertRef, input.internalHost, runtime);
|
|
184
|
-
const app = await findOrCreatePlatformApp(coolify, input.name, input.environment, () => boundedCall(input.create(signinDbDockerfile()), 'Coolify createDockerfileApplication'));
|
|
185
|
-
await input.record(
|
|
185
|
+
const app = await findOrCreatePlatformApp(coolify, options.appName ?? input.name, input.environment, () => boundedCall(input.create(signinDbDockerfile({ user, database })), 'Coolify createDockerfileApplication'));
|
|
186
|
+
await input.record(scope.app, app.appId);
|
|
186
187
|
// No `ports_mappings` and no `domains`: only the Docker network reaches the port.
|
|
187
188
|
await coolify.updateApplication(app.appId, {
|
|
188
189
|
custom_internal_name: input.internalHost,
|
|
@@ -214,34 +215,36 @@ export async function provisionSigninDb(input) {
|
|
|
214
215
|
},
|
|
215
216
|
record: input.record,
|
|
216
217
|
onSecret: runtime.onSecret,
|
|
218
|
+
keys: scope,
|
|
217
219
|
});
|
|
218
220
|
const b64 = (pem) => Buffer.from(pem, 'utf8').toString('base64');
|
|
219
221
|
runtime.onSecret?.(b64(server.keyPem));
|
|
220
222
|
const envs = [
|
|
221
|
-
{ key: SIGNIN_DB_ENV.user, value:
|
|
223
|
+
{ key: SIGNIN_DB_ENV.user, value: user },
|
|
222
224
|
{ key: SIGNIN_DB_ENV.password, value: password },
|
|
223
|
-
{ key: SIGNIN_DB_ENV.database, value:
|
|
225
|
+
{ key: SIGNIN_DB_ENV.database, value: database },
|
|
224
226
|
{ key: SIGNIN_DB_ENV.cert, value: b64(server.certPem) },
|
|
225
227
|
{ key: SIGNIN_DB_ENV.key, value: b64(server.keyPem) },
|
|
226
228
|
...signinDbBackupEnvs(backup.target, input.slug, {
|
|
227
|
-
user
|
|
228
|
-
database
|
|
229
|
+
user,
|
|
230
|
+
database,
|
|
231
|
+
prefixBase: options.backupPrefix,
|
|
229
232
|
}),
|
|
230
233
|
];
|
|
231
234
|
const changed = await envsDiffer(coolify, app.appId, envs);
|
|
232
235
|
await coolify.bulkUpsertEnvs(app.appId, envs);
|
|
233
236
|
if (backup.fresh) {
|
|
234
|
-
await input.record(
|
|
235
|
-
await input.record(
|
|
237
|
+
await input.record(scope.backupKey, backup.tokenId);
|
|
238
|
+
await input.record(scope.backupKeyUndelivered, '');
|
|
236
239
|
}
|
|
237
240
|
if (app.justCreated || !app.running || changed) {
|
|
238
241
|
await coolify.deploy(app.appId);
|
|
239
242
|
await waitHealthy(coolify, app.appId, runtime, label);
|
|
240
243
|
}
|
|
241
|
-
await input.record(
|
|
244
|
+
await input.record(scope.certNotAfter, String(server.notAfter));
|
|
242
245
|
// The database is not used, and ZITADEL is not stopped for a move, until its nightly dump is scheduled.
|
|
243
|
-
await input.record(
|
|
244
|
-
const dsn = signinDbDsn(input.internalHost, password);
|
|
246
|
+
await input.record(scope.backupTask, await ensureSigninDbBackupTask(coolify, app.appId, label));
|
|
247
|
+
const dsn = signinDbDsn(input.internalHost, password, { user, database });
|
|
245
248
|
runtime.onSecret?.(dsn);
|
|
246
249
|
return { appId: app.appId, dsn, ca: ca.certPem, password };
|
|
247
250
|
}
|
|
@@ -28,6 +28,12 @@ export interface HyperdriveOrigin {
|
|
|
28
28
|
password: string;
|
|
29
29
|
accessClientId: string;
|
|
30
30
|
accessClientSecret: string;
|
|
31
|
+
/** Present when the database's certificate is checked against a CA uploaded to Cloudflare. */
|
|
32
|
+
tls?: {
|
|
33
|
+
sslmode: 'verify-ca';
|
|
34
|
+
caCertificateId: string;
|
|
35
|
+
caPem: string;
|
|
36
|
+
};
|
|
31
37
|
}
|
|
32
38
|
/** One migration of a release's `migrations.json`. */
|
|
33
39
|
export interface DatabaseMigration {
|
|
@@ -50,6 +56,13 @@ export interface WorkerAppDatabasePort {
|
|
|
50
56
|
}>;
|
|
51
57
|
/** A no-op when the config is gone. */
|
|
52
58
|
deleteHyperdrive(name: string): Promise<void>;
|
|
59
|
+
/** Uploads the CA certificate `pem` under `name` unless it is already there; returns its id. */
|
|
60
|
+
ensureCaCertificate(input: {
|
|
61
|
+
name: string;
|
|
62
|
+
pem: string;
|
|
63
|
+
}): Promise<{
|
|
64
|
+
id: string;
|
|
65
|
+
}>;
|
|
53
66
|
/** The text of a release asset of a wtfalch repo, or `null` when the release has no such asset. */
|
|
54
67
|
fetchReleaseAsset(url: string): Promise<string | null>;
|
|
55
68
|
/** The migration ids the database's ledger holds, in the order applied. */
|
|
@@ -109,6 +122,13 @@ export interface WorkerAppDatabaseDeps {
|
|
|
109
122
|
ownerUrl(databaseId: string): Promise<string>;
|
|
110
123
|
/** Registers a value for redaction before it is written anywhere. */
|
|
111
124
|
secret(value: string): void;
|
|
125
|
+
/**
|
|
126
|
+
* The PEM of the CA that signs the app database's certificate. Absent or `null`: the
|
|
127
|
+
* connection carries no TLS settings.
|
|
128
|
+
*/
|
|
129
|
+
ca?: () => Promise<{
|
|
130
|
+
pem: string;
|
|
131
|
+
} | null>;
|
|
112
132
|
heldKeys?: {
|
|
113
133
|
resolve(ref: string): Promise<string | null | undefined>;
|
|
114
134
|
put?(ref: string, value: string): Promise<void>;
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { createHash, randomBytes } from 'node:crypto';
|
|
2
2
|
import { REMOTE_DATABASE_PORT, mergeTcpIngress, recordedHttpIngress, recordedTcpRoutes, remoteDatabaseKeys, } from './remote-database.js';
|
|
3
|
+
import { WORKER_APP_DB_SCOPE_KEYS } from './worker-app-db.js';
|
|
3
4
|
import { WORKER_APP_CALL_TIMEOUT_MS, withCallTimeout, workerAppSecretRef } from './worker-app.js';
|
|
4
5
|
export const WORKER_APP_MIGRATION_TIMEOUT_MS = 300_000;
|
|
5
6
|
export const MIGRATIONS_ASSET_NAME = 'migrations.json';
|
|
@@ -141,7 +142,9 @@ export async function applyWorkerAppDatabase(deps, org, entry, scriptName, want,
|
|
|
141
142
|
throw new Error(`${at}: needs a held-key store that can put`);
|
|
142
143
|
const put = heldKeys.put;
|
|
143
144
|
const keys = remoteDatabaseKeys(entry.key);
|
|
144
|
-
|
|
145
|
+
// The foundry-made TLS database (worker-app-db) when recorded, else the legacy Coolify database.
|
|
146
|
+
const databaseId = context.recordedIds()[WORKER_APP_DB_SCOPE_KEYS.app] ||
|
|
147
|
+
context.recordedIds()[`coolify:database:${org.id}`];
|
|
145
148
|
if (!databaseId)
|
|
146
149
|
throw new Error(`${at}: ${org.slug}'s database is not applied yet`);
|
|
147
150
|
const tunnelId = context.recordedIds()[`cloudflare:tunnel:${org.id}`];
|
|
@@ -208,6 +211,19 @@ export async function applyWorkerAppDatabase(deps, org, entry, scriptName, want,
|
|
|
208
211
|
await call('hold the runtime password', () => put(rtRef, minted));
|
|
209
212
|
}
|
|
210
213
|
deps.secret(runtimePassword);
|
|
214
|
+
// The CA, uploaded before the first connection so both origins carry the same certificate id.
|
|
215
|
+
const ca = deps.ca
|
|
216
|
+
? await call('read the CA certificate', () => deps.ca())
|
|
217
|
+
: null;
|
|
218
|
+
let tls;
|
|
219
|
+
let caName;
|
|
220
|
+
if (ca) {
|
|
221
|
+
caName = `foundry-${org.slug}-appdb-ca-${createHash('sha256').update(ca.pem).digest('hex').slice(0, 12)}`;
|
|
222
|
+
const pem = ca.pem;
|
|
223
|
+
const uploadName = caName;
|
|
224
|
+
const { id: caCertificateId } = await call('upload the CA certificate', () => port.ensureCaCertificate({ name: uploadName, pem }));
|
|
225
|
+
tls = { sslmode: 'verify-ca', caCertificateId, caPem: pem };
|
|
226
|
+
}
|
|
211
227
|
const ownerOrigin = {
|
|
212
228
|
host: hostname,
|
|
213
229
|
database: owner.database,
|
|
@@ -215,6 +231,7 @@ export async function applyWorkerAppDatabase(deps, org, entry, scriptName, want,
|
|
|
215
231
|
password: owner.password,
|
|
216
232
|
accessClientId: access.clientId,
|
|
217
233
|
accessClientSecret: access.clientSecret,
|
|
234
|
+
...(tls ? { tls } : {}),
|
|
218
235
|
};
|
|
219
236
|
// 4. Migrations with the owner credentials. A failure stops the deploy; ids already applied stay.
|
|
220
237
|
const text = await call('fetch the release migrations', () => port.fetchReleaseAsset(migrationsUrl(want.artifactUrl)));
|
|
@@ -259,9 +276,10 @@ export async function applyWorkerAppDatabase(deps, org, entry, scriptName, want,
|
|
|
259
276
|
password: runtimePassword,
|
|
260
277
|
accessClientId: access.clientId,
|
|
261
278
|
accessClientSecret: access.clientSecret,
|
|
279
|
+
...(tls ? { tls } : {}),
|
|
262
280
|
},
|
|
263
281
|
}));
|
|
264
|
-
context.recordScopeId(workerAppHyperdriveScopeKey(entry.key), JSON.stringify({ name, id }));
|
|
282
|
+
context.recordScopeId(workerAppHyperdriveScopeKey(entry.key), JSON.stringify(caName ? { name, id, ca: caName } : { name, id }));
|
|
265
283
|
await context.persistScopeIds();
|
|
266
284
|
return { hyperdriveId: id };
|
|
267
285
|
}
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
import { type DeclaredOrg } from '../org.js';
|
|
2
|
+
import type { DraftStep } from './diff.js';
|
|
3
|
+
import { type ProjectSource, type StepScope } from './own-services.js';
|
|
4
|
+
import type { PlatformAuthDeps } from './platform-providers.js';
|
|
5
|
+
import { type SigninDbKeys } from './signin-db-tls.js';
|
|
6
|
+
import { type SigninDbRuntime } from './signin-db.js';
|
|
7
|
+
/**
|
|
8
|
+
* A Worker app's database as foundry's own TLS Postgres (the sign-in database's image), so that
|
|
9
|
+
* Cloudflare Hyperdrive connects with `verify-ca`. It replaces the Coolify database that a Worker
|
|
10
|
+
* app entry used to force: Coolify 4.4.2 cannot turn on a database's TLS.
|
|
11
|
+
*
|
|
12
|
+
* One app `<slug>-app-db` per org, user `app_owner`, the org's `app` database. The CA is the org's
|
|
13
|
+
* own, held under {@link workerAppDbCaRef}; the server certificate is renewed by planning the step
|
|
14
|
+
* again when it is due.
|
|
15
|
+
*/
|
|
16
|
+
/** Recorded ids: the app, "the step finished", and the server certificate's expiry. */
|
|
17
|
+
export declare const WORKER_APP_DB_SCOPE_KEYS: import("./signin-db-tls.js").TlsDbScopeKeys;
|
|
18
|
+
/** `unmanaged` key of the one blocked line {@link planWorkerAppDb} reports. */
|
|
19
|
+
export declare const WORKER_APP_DB_BLOCKED_KEY = "worker-app-db";
|
|
20
|
+
/** `unmanaged` key of the note that the legacy Coolify database is no longer used. */
|
|
21
|
+
export declare const WORKER_APP_DB_LEGACY_NOTE_KEY = "worker-app-db-legacy";
|
|
22
|
+
export declare const WORKER_APP_DB_USER = "app_owner";
|
|
23
|
+
export declare const WORKER_APP_DB_BACKUP_PREFIX = "worker-app-db";
|
|
24
|
+
export declare const workerAppDbCaRef: (orgId: string) => string;
|
|
25
|
+
export declare const workerAppDbServerRef: (orgId: string) => string;
|
|
26
|
+
export declare const workerAppDbName: (org: Pick<DeclaredOrg, "slug">) => string;
|
|
27
|
+
/** The org's Worker app entries declare a database. */
|
|
28
|
+
export declare const orgHasWorkerAppDatabase: (org: DeclaredOrg) => boolean;
|
|
29
|
+
/** The database's name on the server: the org's `app` home, as the Postgres server would name it. */
|
|
30
|
+
export declare function workerAppDbDatabase(org: DeclaredOrg): string;
|
|
31
|
+
export interface PlanWorkerAppDbOptions {
|
|
32
|
+
org: DeclaredOrg;
|
|
33
|
+
recordedIds: Readonly<Record<string, string>>;
|
|
34
|
+
now: number;
|
|
35
|
+
/** The held keys can store the CA and certificate. Default true. */
|
|
36
|
+
keysPuttable?: boolean;
|
|
37
|
+
/** The host supplies the backup bucket the database dumps into. Default true. */
|
|
38
|
+
backupConfigured?: boolean;
|
|
39
|
+
}
|
|
40
|
+
/**
|
|
41
|
+
* One `coolify:worker-app-db` step per org with a Worker app database: planned until the app and
|
|
42
|
+
* the "finished" marker are recorded, and again when the server certificate is due. A step that
|
|
43
|
+
* cannot succeed yet (no held-key put, no backup bucket) is a blocked line, not a step. The legacy
|
|
44
|
+
* Coolify database is never touched; a recorded one gets a note.
|
|
45
|
+
*/
|
|
46
|
+
export declare function planWorkerAppDb(options: PlanWorkerAppDbOptions): {
|
|
47
|
+
step: DraftStep | null;
|
|
48
|
+
blocked: string | null;
|
|
49
|
+
note: string | null;
|
|
50
|
+
};
|
|
51
|
+
export interface ProvisionWorkerAppDbDeps {
|
|
52
|
+
coolify: PlatformAuthDeps['coolify'];
|
|
53
|
+
heldKeys: SigninDbKeys | undefined;
|
|
54
|
+
signinDbBackup: PlatformAuthDeps['signinDbBackup'];
|
|
55
|
+
runtime?: SigninDbRuntime;
|
|
56
|
+
}
|
|
57
|
+
/**
|
|
58
|
+
* Brings the org's Worker app database up (`provisionSigninDb` with the app database's names) and
|
|
59
|
+
* records the "finished" marker last, so a run that died halfway is planned again. Returns the CA's
|
|
60
|
+
* PEM (public) and the app id.
|
|
61
|
+
*/
|
|
62
|
+
export declare function provisionWorkerAppDb(scope: StepScope, deps: ProvisionWorkerAppDbDeps, org: DeclaredOrg, input: {
|
|
63
|
+
project: ProjectSource;
|
|
64
|
+
label: string;
|
|
65
|
+
}): Promise<{
|
|
66
|
+
appId: string;
|
|
67
|
+
ca: string;
|
|
68
|
+
}>;
|
|
69
|
+
/** The org's CA certificate (public) from the held `{ cert, key }`, or null when none is held or it cannot be read. */
|
|
70
|
+
export declare function workerAppDbCaPem(keys: Pick<SigninDbKeys, 'resolve'> | undefined, orgId: string): Promise<{
|
|
71
|
+
pem: string;
|
|
72
|
+
} | null>;
|
|
73
|
+
/**
|
|
74
|
+
* The owner's address for foundry's own steps (migrations, roles): the app's internal host and the
|
|
75
|
+
* password in its env. A secret. The TLS settings are not part of the address; Hyperdrive gets the
|
|
76
|
+
* CA separately.
|
|
77
|
+
*/
|
|
78
|
+
export declare function workerAppDbOwnerUrl(coolify: Pick<PlatformAuthDeps['coolify'], 'listEnvs'>, org: DeclaredOrg, appId: string): Promise<string>;
|
|
@@ -0,0 +1,154 @@
|
|
|
1
|
+
import { ORG_SERVICE_SCHEMAS, orgServiceDatabase } from '../org.js';
|
|
2
|
+
import { orgTarget } from './own-services.js';
|
|
3
|
+
import { signinDbCertDue, tlsDbScopeKeys } from './signin-db-tls.js';
|
|
4
|
+
import { SIGNIN_DB_ENV, SIGNIN_DB_PORT } from './signin-db.js';
|
|
5
|
+
import { dockerfileAppCreator, provisionSigninDb, withCallTimeout, } from './signin-db.js';
|
|
6
|
+
/**
|
|
7
|
+
* A Worker app's database as foundry's own TLS Postgres (the sign-in database's image), so that
|
|
8
|
+
* Cloudflare Hyperdrive connects with `verify-ca`. It replaces the Coolify database that a Worker
|
|
9
|
+
* app entry used to force: Coolify 4.4.2 cannot turn on a database's TLS.
|
|
10
|
+
*
|
|
11
|
+
* One app `<slug>-app-db` per org, user `app_owner`, the org's `app` database. The CA is the org's
|
|
12
|
+
* own, held under {@link workerAppDbCaRef}; the server certificate is renewed by planning the step
|
|
13
|
+
* again when it is due.
|
|
14
|
+
*/
|
|
15
|
+
/** Recorded ids: the app, "the step finished", and the server certificate's expiry. */
|
|
16
|
+
export const WORKER_APP_DB_SCOPE_KEYS = tlsDbScopeKeys('workerapp_db');
|
|
17
|
+
/** `unmanaged` key of the one blocked line {@link planWorkerAppDb} reports. */
|
|
18
|
+
export const WORKER_APP_DB_BLOCKED_KEY = 'worker-app-db';
|
|
19
|
+
/** `unmanaged` key of the note that the legacy Coolify database is no longer used. */
|
|
20
|
+
export const WORKER_APP_DB_LEGACY_NOTE_KEY = 'worker-app-db-legacy';
|
|
21
|
+
export const WORKER_APP_DB_USER = 'app_owner';
|
|
22
|
+
export const WORKER_APP_DB_BACKUP_PREFIX = 'worker-app-db';
|
|
23
|
+
export const workerAppDbCaRef = (orgId) => `worker-app-db:${orgId}:ca`;
|
|
24
|
+
export const workerAppDbServerRef = (orgId) => `worker-app-db:${orgId}:server`;
|
|
25
|
+
export const workerAppDbName = (org) => `${org.slug}-app-db`;
|
|
26
|
+
/** The org's Worker app entries declare a database. */
|
|
27
|
+
export const orgHasWorkerAppDatabase = (org) => org.hosting.entries.some((entry) => Boolean(entry.workerApp?.database));
|
|
28
|
+
/** The database's name on the server: the org's `app` home, as the Postgres server would name it. */
|
|
29
|
+
export function workerAppDbDatabase(org) {
|
|
30
|
+
const home = ORG_SERVICE_SCHEMAS.find((h) => h.schema === 'app');
|
|
31
|
+
if (!home)
|
|
32
|
+
throw new Error('the org service schemas have no app home');
|
|
33
|
+
return orgServiceDatabase(org, home).database;
|
|
34
|
+
}
|
|
35
|
+
/**
|
|
36
|
+
* One `coolify:worker-app-db` step per org with a Worker app database: planned until the app and
|
|
37
|
+
* the "finished" marker are recorded, and again when the server certificate is due. A step that
|
|
38
|
+
* cannot succeed yet (no held-key put, no backup bucket) is a blocked line, not a step. The legacy
|
|
39
|
+
* Coolify database is never touched; a recorded one gets a note.
|
|
40
|
+
*/
|
|
41
|
+
export function planWorkerAppDb(options) {
|
|
42
|
+
const { org, recordedIds } = options;
|
|
43
|
+
if (!orgHasWorkerAppDatabase(org))
|
|
44
|
+
return { step: null, blocked: null, note: null };
|
|
45
|
+
const note = recordedIds[`coolify:database:${org.id}`]
|
|
46
|
+
? `${org.slug}'s Coolify database is no longer used by its Worker apps; it is left as it is`
|
|
47
|
+
: null;
|
|
48
|
+
const keys = WORKER_APP_DB_SCOPE_KEYS;
|
|
49
|
+
const done = Boolean(recordedIds[keys.app] && recordedIds[keys.tls]);
|
|
50
|
+
if (done && !signinDbCertDue(recordedIds, options.now, keys)) {
|
|
51
|
+
return { step: null, blocked: null, note };
|
|
52
|
+
}
|
|
53
|
+
if (options.keysPuttable === false) {
|
|
54
|
+
return {
|
|
55
|
+
step: null,
|
|
56
|
+
note,
|
|
57
|
+
blocked: `${org.slug}'s Worker app database is blocked, not planned: the host cannot store the database certificate (held keys have no put)`,
|
|
58
|
+
};
|
|
59
|
+
}
|
|
60
|
+
if (options.backupConfigured === false) {
|
|
61
|
+
return {
|
|
62
|
+
step: null,
|
|
63
|
+
note,
|
|
64
|
+
blocked: `${org.slug}'s Worker app database is blocked, not planned: the host supplies no backup bucket for the database`,
|
|
65
|
+
};
|
|
66
|
+
}
|
|
67
|
+
return {
|
|
68
|
+
blocked: null,
|
|
69
|
+
note,
|
|
70
|
+
step: {
|
|
71
|
+
action: { system: 'coolify', kind: 'worker-app-db', key: org.id, desired: {} },
|
|
72
|
+
step: {
|
|
73
|
+
description: done
|
|
74
|
+
? `renew the server certificate of ${org.slug}'s Worker app database`
|
|
75
|
+
: `provision ${org.slug}'s Worker app database as a TLS Postgres`,
|
|
76
|
+
destroys: false,
|
|
77
|
+
migrates: false,
|
|
78
|
+
},
|
|
79
|
+
},
|
|
80
|
+
};
|
|
81
|
+
}
|
|
82
|
+
/**
|
|
83
|
+
* Brings the org's Worker app database up (`provisionSigninDb` with the app database's names) and
|
|
84
|
+
* records the "finished" marker last, so a run that died halfway is planned again. Returns the CA's
|
|
85
|
+
* PEM (public) and the app id.
|
|
86
|
+
*/
|
|
87
|
+
export async function provisionWorkerAppDb(scope, deps, org, input) {
|
|
88
|
+
const coolify = withCallTimeout(deps.coolify);
|
|
89
|
+
const runtime = deps.runtime ?? {};
|
|
90
|
+
const name = workerAppDbName(org);
|
|
91
|
+
const keys = WORKER_APP_DB_SCOPE_KEYS;
|
|
92
|
+
const record = async (key, id) => {
|
|
93
|
+
scope.recordScopeId(key, id);
|
|
94
|
+
await scope.persist();
|
|
95
|
+
};
|
|
96
|
+
const projectUuid = await input.project.ensure(coolify, scope);
|
|
97
|
+
const { environment } = await coolify.findOrCreateEnvironment(projectUuid, 'production');
|
|
98
|
+
const db = await provisionSigninDb({
|
|
99
|
+
coolify,
|
|
100
|
+
keys: deps.heldKeys,
|
|
101
|
+
runtime,
|
|
102
|
+
name,
|
|
103
|
+
internalHost: name,
|
|
104
|
+
volumeName: `${name}-data`,
|
|
105
|
+
serverCertRef: workerAppDbServerRef(org.id),
|
|
106
|
+
slug: org.slug,
|
|
107
|
+
backup: deps.signinDbBackup,
|
|
108
|
+
backupBucket: orgTarget(org).names.signinBackupBucket,
|
|
109
|
+
recorded: scope.recordedIds(),
|
|
110
|
+
label: input.label,
|
|
111
|
+
environment,
|
|
112
|
+
record,
|
|
113
|
+
create: dockerfileAppCreator(coolify, {
|
|
114
|
+
projectUuid,
|
|
115
|
+
environment,
|
|
116
|
+
name,
|
|
117
|
+
description: `${org.slug}'s Worker app database with TLS`,
|
|
118
|
+
}),
|
|
119
|
+
}, {
|
|
120
|
+
appName: name,
|
|
121
|
+
user: WORKER_APP_DB_USER,
|
|
122
|
+
database: workerAppDbDatabase(org),
|
|
123
|
+
caRef: workerAppDbCaRef(org.id),
|
|
124
|
+
caCommonName: `foundry ${org.slug} app database CA`,
|
|
125
|
+
scopeKeys: keys,
|
|
126
|
+
backupPrefix: WORKER_APP_DB_BACKUP_PREFIX,
|
|
127
|
+
});
|
|
128
|
+
await record(keys.tls, db.appId);
|
|
129
|
+
return { appId: db.appId, ca: db.ca };
|
|
130
|
+
}
|
|
131
|
+
/** The org's CA certificate (public) from the held `{ cert, key }`, or null when none is held or it cannot be read. */
|
|
132
|
+
export async function workerAppDbCaPem(keys, orgId) {
|
|
133
|
+
const held = await keys?.resolve(workerAppDbCaRef(orgId));
|
|
134
|
+
if (!held)
|
|
135
|
+
return null;
|
|
136
|
+
try {
|
|
137
|
+
const cert = JSON.parse(held).cert;
|
|
138
|
+
return typeof cert === 'string' && cert ? { pem: cert } : null;
|
|
139
|
+
}
|
|
140
|
+
catch {
|
|
141
|
+
return null;
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
/**
|
|
145
|
+
* The owner's address for foundry's own steps (migrations, roles): the app's internal host and the
|
|
146
|
+
* password in its env. A secret. The TLS settings are not part of the address; Hyperdrive gets the
|
|
147
|
+
* CA separately.
|
|
148
|
+
*/
|
|
149
|
+
export async function workerAppDbOwnerUrl(coolify, org, appId) {
|
|
150
|
+
const password = (await coolify.listEnvs(appId)).find((e) => e.key === SIGNIN_DB_ENV.password && !e.is_preview && e.value)?.value;
|
|
151
|
+
if (!password)
|
|
152
|
+
throw new Error('the database app holds no password yet');
|
|
153
|
+
return `postgres://${WORKER_APP_DB_USER}:${encodeURIComponent(password)}@${workerAppDbName(org)}:${SIGNIN_DB_PORT}/${workerAppDbDatabase(org)}`;
|
|
154
|
+
}
|