@wtfalch/foundry 0.38.0 → 0.39.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/bootstrap/versions.d.ts +3 -2
- package/dist/plan/backup-gate.d.ts +16 -0
- package/dist/plan/backup-gate.js +40 -0
- package/dist/plan/company-backups.js +15 -0
- package/dist/plan/diff.d.ts +4 -0
- package/dist/plan/diff.js +15 -1
- package/dist/plan/executor.d.ts +13 -0
- package/dist/plan/executor.js +49 -12
- package/dist/plan/own-diff.d.ts +26 -0
- package/dist/plan/own-diff.js +59 -2
- package/dist/plan/own-services.d.ts +10 -0
- package/dist/plan/own-services.js +10 -0
- package/dist/plan/plan-apply.d.ts +4 -0
- package/dist/plan/plan-apply.js +23 -1
- package/dist/plan/platform-providers.d.ts +46 -1
- package/dist/plan/platform-providers.js +79 -27
- package/dist/plan/signin-db-backup.d.ts +105 -0
- package/dist/plan/signin-db-backup.js +193 -0
- package/dist/plan/signin-db-move.d.ts +68 -0
- package/dist/plan/signin-db-move.js +338 -0
- package/dist/plan/signin-db-tls.d.ts +69 -0
- package/dist/plan/signin-db-tls.js +255 -0
- package/dist/plan/signin-db.d.ts +114 -0
- package/dist/plan/signin-db.js +255 -0
- package/package.json +1 -1
- package/versions.digests.json +2 -1
- package/versions.json +2 -1
|
@@ -17,6 +17,7 @@ import { type FilesKeysMinted, type ProjectSource, type ServiceNetwork, type Ser
|
|
|
17
17
|
import type { PlatformDriftedSteps, PlatformStepExecutionContext, PlatformStepExecutor } from './plan-apply.js';
|
|
18
18
|
import { type BrokerDeployRecord, PLATFORM_ZITADEL_ORG_SCOPE_KEY } from './platform-diff.js';
|
|
19
19
|
import type { PlatformOwnerInput } from './platform-org.js';
|
|
20
|
+
import type { SigninDbBackupPort } from './signin-db-backup.js';
|
|
20
21
|
export { PLATFORM_ZITADEL_ORG_SCOPE_KEY };
|
|
21
22
|
import { type CallsSfuPlatformDeps } from './platform-sfu.js';
|
|
22
23
|
import { type PlatformUptimeDeps } from './uptime-worker.js';
|
|
@@ -132,7 +133,9 @@ export interface PlatformAuthSmtp {
|
|
|
132
133
|
export interface PlatformAuthDeps {
|
|
133
134
|
/** Overrides the Lettermint SMTP relay ZITADEL is given. Absent, Lettermint exactly as before. */
|
|
134
135
|
smtp?: PlatformAuthSmtp;
|
|
135
|
-
coolify: Pick<CoolifyPort, 'findProjectByName' | 'createProject' | 'findOrCreateEnvironment' | 'listServers' | 'findApplicationByName' | 'getApplication' | 'requireGithubApp' | 'createGithubAppApplication' | 'createDockerImageApplication' | 'createDockerfileApplication' | 'createPostgresDatabase' | 'getDatabase' | 'bulkUpsertEnvs' | 'listEnvs' | 'updateApplication' | 'deploy' | 'raw'
|
|
136
|
+
coolify: Pick<CoolifyPort, 'findProjectByName' | 'createProject' | 'findOrCreateEnvironment' | 'listServers' | 'findApplicationByName' | 'getApplication' | 'requireGithubApp' | 'createGithubAppApplication' | 'createDockerImageApplication' | 'createDockerfileApplication' | 'createPostgresDatabase' | 'getDatabase' | 'bulkUpsertEnvs' | 'listEnvs' | 'updateApplication' | 'deploy' | 'raw'> &
|
|
137
|
+
/** An own org's sign-in database needs a volume and the CA file mount (#746); the platform's does not. */
|
|
138
|
+
Partial<Pick<CoolifyPort, 'listStorages' | 'createPersistentStorage' | 'createFileStorage'>>;
|
|
136
139
|
/** F4.dns: every DNS record call, through edge at its recorded address (see {@link PlatformDnsFactory}). */
|
|
137
140
|
dns: PlatformDnsFactory;
|
|
138
141
|
/** F4.tunnels: every tunnel call, through edge at its recorded address (see {@link PlatformTunnelsFactory}). */
|
|
@@ -172,7 +175,16 @@ export interface PlatformAuthDeps {
|
|
|
172
175
|
*/
|
|
173
176
|
heldKeys?: {
|
|
174
177
|
resolve(ref: string): Promise<string | null>;
|
|
178
|
+
/** An own org's sign-in database CA and certificate are stored through this (#746). Without it a new company's sign-in step is blocked. */
|
|
179
|
+
put?(ref: string, value: string): Promise<void>;
|
|
175
180
|
};
|
|
181
|
+
/**
|
|
182
|
+
* The bucket and storage key ports a company's sign-in database backup uses (#746): its own bucket and
|
|
183
|
+
* a key scoped to it. For an own org the executor fills this from the files host's `r2Buckets` and
|
|
184
|
+
* `r2Keys`; the platform's backup key is never used. Without it a company's sign-in database is not
|
|
185
|
+
* created and an existing one is not moved.
|
|
186
|
+
*/
|
|
187
|
+
signinDbBackup?: SigninDbBackupPort;
|
|
176
188
|
/** Clock, sleep and fetch for the readiness wait and delivery retries; real ones by default. */
|
|
177
189
|
runtime?: PlatformAuthRuntime;
|
|
178
190
|
}
|
|
@@ -190,6 +202,14 @@ export interface PlatformAuthRuntime {
|
|
|
190
202
|
readyRequestTimeoutMs?: number;
|
|
191
203
|
/** Told of every secret the sign-in step reads or mints, so the host can scrub it from logs and audit rows. */
|
|
192
204
|
onSecret?: (value: string) => void;
|
|
205
|
+
/** How long a new or redeployed sign-in database may take to report healthy (default 5 minutes). */
|
|
206
|
+
signinDbTimeoutMs?: number;
|
|
207
|
+
/** How long ZITADEL may take to report stopped before a sign-in database move copies (default 2 minutes). */
|
|
208
|
+
signinDbStopTimeoutMs?: number;
|
|
209
|
+
/** How long the copy job of a sign-in database move may run (default 30 minutes). */
|
|
210
|
+
signinDbMoveTimeoutMs?: number;
|
|
211
|
+
/** Pause between polls of the copy job (default 10 seconds). */
|
|
212
|
+
signinDbMovePollMs?: number;
|
|
193
213
|
}
|
|
194
214
|
/**
|
|
195
215
|
* An own org's scheduler only: the app's uuid, recorded and persisted before
|
|
@@ -458,6 +478,7 @@ export declare const AUTH_LOGIN_PORT = "3000";
|
|
|
458
478
|
* everything else, including the root).
|
|
459
479
|
*/
|
|
460
480
|
export declare const AUTH_ZITADEL_PATH_PATTERN = "^/(oidc|oauth|saml|management|admin|system|auth|assets|debug/healthz|ui/console|\\.well-known|v2|v2beta|zitadel\\.[A-Za-z0-9.]+)(/|$)";
|
|
481
|
+
export declare const PLATFORM_AUTH_DB_SCOPE_KEY = "coolify_platform_auth_database";
|
|
461
482
|
export declare const PLATFORM_AUTH_ZITADEL_SCOPE_KEY = "coolify_platform_auth_zitadel_app";
|
|
462
483
|
export declare const PLATFORM_AUTH_LOGIN_SCOPE_KEY = "coolify_platform_auth_login_app";
|
|
463
484
|
/**
|
|
@@ -1172,6 +1193,25 @@ export declare function createAiManagementKeyMinter(options: {
|
|
|
1172
1193
|
fetchImpl?: typeof fetch;
|
|
1173
1194
|
now?: () => number;
|
|
1174
1195
|
}): AiManagementKeyMinter;
|
|
1196
|
+
export declare function resolvePlatformServer(coolify: Pick<CoolifyPort, 'listServers'>): Promise<{
|
|
1197
|
+
uuid: string;
|
|
1198
|
+
ip?: string;
|
|
1199
|
+
}>;
|
|
1200
|
+
/**
|
|
1201
|
+
* `executor.ts`'s `coolifyCalls`/`findOrCreateCallsApp`, restated here rather
|
|
1202
|
+
* than imported: that method is private to `FoundryStepExecutor`, and this
|
|
1203
|
+
* file holds no instance of that class to call it on. `running` says whether
|
|
1204
|
+
* the app was already running or starting; the caller deploys it when not.
|
|
1205
|
+
*/
|
|
1206
|
+
export declare function findOrCreatePlatformApp(coolify: Pick<CoolifyPort, 'findApplicationByName' | 'getApplication'>, name: string, environment: {
|
|
1207
|
+
id: number;
|
|
1208
|
+
}, create: () => Promise<{
|
|
1209
|
+
uuid: string;
|
|
1210
|
+
}>): Promise<{
|
|
1211
|
+
appId: string;
|
|
1212
|
+
justCreated: boolean;
|
|
1213
|
+
running: boolean;
|
|
1214
|
+
}>;
|
|
1175
1215
|
/**
|
|
1176
1216
|
* #211: names of the envs the platform's sign-in broker (`wtfalch/auth`'s
|
|
1177
1217
|
* `broker/server.ts`) refuses to start without -- `ZITADEL_SERVICE_USER_TOKEN`/
|
|
@@ -1218,6 +1258,11 @@ export declare function brokerDeployNeeded(missingInputs: readonly string[], bro
|
|
|
1218
1258
|
* wrote there. Paired with {@link brokerDeployNeeded}, which reads it back.
|
|
1219
1259
|
*/
|
|
1220
1260
|
export declare function brokerDeployRecordValue(brokerAppId: string, authAppsRaw: string | undefined): string;
|
|
1261
|
+
/** Whether any desired env is missing from the app, or set to another value. */
|
|
1262
|
+
export declare function envsDiffer(coolify: Pick<CoolifyPort, 'listEnvs'>, appId: string, desired: Array<{
|
|
1263
|
+
key: string;
|
|
1264
|
+
value: string;
|
|
1265
|
+
}>): Promise<boolean>;
|
|
1221
1266
|
/**
|
|
1222
1267
|
* #171: everything {@link provisionPlatformFiles} needs beyond
|
|
1223
1268
|
* {@link PlatformStepExecutionContext}, bundled for the same reason as
|
|
@@ -23,6 +23,8 @@ import { recordOwnDnsHostname } from './own-dns.js';
|
|
|
23
23
|
import { platformNetwork, platformTarget, scopeFromPlatform, } from './own-services.js';
|
|
24
24
|
import { AUTH_ZITADEL_RUN_OPTIONS, JOBS_DEPLOY_PENDING_SCOPE_KEY, JOBS_INTERNAL_URL_SCOPE_KEY, JOBS_SCHEDULER_KEY, PLATFORM_ACCESS_PROVIDER_APPLIED_AUTH_DOMAIN_SCOPE_KEY, PLATFORM_ACCESS_PROVIDER_APPLIED_HOSTNAME_SCOPE_KEY, PLATFORM_ACCESS_PROVIDER_UNDELIVERED_SCOPE_KEY, PLATFORM_ACCESS_ZITADEL_APP_SCOPE_KEY, PLATFORM_ACCESS_ZITADEL_PROJECT_SCOPE_KEY, PLATFORM_AI_APPLIED_GITREF_SCOPE_KEY, PLATFORM_AI_APPLIED_HOSTNAME_SCOPE_KEY, PLATFORM_AI_CONNECTOR_APP_SCOPE_KEY, PLATFORM_AI_DEPLOY_PENDING_SCOPE_KEY, PLATFORM_AI_INTERNAL_URL_SCOPE_KEY, PLATFORM_AI_KEY, PLATFORM_AI_MANAGEMENT_KEY_CONFLICT_SCOPE_KEY, PLATFORM_AI_MANAGEMENT_KEY_SCOPE_KEY, PLATFORM_AI_MANAGEMENT_KEY_UNDELIVERED_SCOPE_KEY, PLATFORM_AI_MIGRATE_BLOCKED_SCOPE_KEY, PLATFORM_AI_ORGANISATION_SCOPE_KEY, PLATFORM_AI_SIGNING_KEY_SET_SCOPE_KEY, PLATFORM_AI_SIGNING_KEY_UNDELIVERED_SCOPE_KEY, PLATFORM_AI_TUNNEL_SCOPE_KEY, PLATFORM_ARCHON_ACCESS_PIN_SCOPE_KEY, PLATFORM_ARCHON_APPLIED_AUTH_HOSTNAME_SCOPE_KEY, PLATFORM_ARCHON_APPLIED_AUTH_ORG_SCOPE_KEY, PLATFORM_ARCHON_APPLIED_BROKER_SCOPE_KEY, PLATFORM_ARCHON_APPLIED_CI_ORG_SCOPE_KEY, PLATFORM_ARCHON_APPLIED_FILES_SCOPE_KEY, PLATFORM_ARCHON_APPLIED_FIRST_OWNER_LINK_SCOPE_KEY, PLATFORM_ARCHON_APPLIED_GIT_REF_SCOPE_KEY, PLATFORM_ARCHON_APPLIED_HOSTNAME_SCOPE_KEY, PLATFORM_ARCHON_APPLIED_ROLLING_UPDATE_SCOPE_KEY, PLATFORM_ARCHON_APP_KEY_ORG_SCOPE_KEY, PLATFORM_ARCHON_CONNECTOR_APP_SCOPE_KEY, PLATFORM_ARCHON_COOLIFY_READ_TOKEN_REF, PLATFORM_ARCHON_DEPLOYMENT_SCOPE_KEY, PLATFORM_ARCHON_ENVS_DEPLOYED_SCOPE_KEY, PLATFORM_ARCHON_GATE_CLOSED_SCOPE_KEY, PLATFORM_ARCHON_ID_TOKEN_USERINFO_APPLIED_SCOPE_KEY, PLATFORM_ARCHON_INTEGRATIONS_KEY_SYNCED_SCOPE_KEY, PLATFORM_ARCHON_KEY, PLATFORM_ARCHON_MASTER_CLOUDFLARE_TOKEN_REF, PLATFORM_ARCHON_MASTER_COOLIFY_TOKEN_REF, PLATFORM_ARCHON_MASTER_COOLIFY_URL_REF, PLATFORM_ARCHON_ORG_DOMAIN_BLOCKED_SCOPE_KEY, PLATFORM_ARCHON_ORG_DOMAIN_SCOPE_KEY, PLATFORM_ARCHON_ORG_POLICY_SCOPE_KEY, PLATFORM_ARCHON_ORG_REGISTRATION_CLOSED_SCOPE_KEY, PLATFORM_ARCHON_ORG_SCOPE_KEY, PLATFORM_ARCHON_OWNER_INVITE_ORG_SCOPE_KEY, PLATFORM_ARCHON_OWNER_INVITE_SCOPE_KEY, PLATFORM_ARCHON_RETIRED_CLIENT_SCOPE_KEY, PLATFORM_ARCHON_UPTIME_TOKEN_SYNCED_SCOPE_KEY, PLATFORM_ARCHON_ZITADEL_APP_SCOPE_KEY, PLATFORM_ARCHON_ZITADEL_PROJECT_SCOPE_KEY, PLATFORM_AUTH_ADMIN_UNDELIVERED_SCOPE_KEY, PLATFORM_AUTH_ADMIN_USER_SCOPE_KEY, PLATFORM_AUTH_BROKER_DEPLOYED_SCOPE_KEY, PLATFORM_AUTH_BROKER_SCOPE_KEY, PLATFORM_AUTH_CONNECTOR_APP_SCOPE_KEY, PLATFORM_AUTH_INSTANCE_SCOPE_KEY, PLATFORM_AUTH_INSTANCE_UNDELIVERED_SCOPE_KEY, PLATFORM_AUTH_KEY, PLATFORM_AUTH_LOGIN_CLIENT_UNDELIVERED_SCOPE_KEY, PLATFORM_AUTH_LOGIN_CLIENT_USER_SCOPE_KEY, PLATFORM_AUTH_OWNER_INVITE_ORG_SCOPE_KEY, PLATFORM_AUTH_OWNER_INVITE_SCOPE_KEY, PLATFORM_AUTH_REGISTRATION_CLOSED_SCOPE_KEY, PLATFORM_AUTH_RESERVED_ADMIN_USERNAME_SCOPE_KEY, PLATFORM_AUTH_SMTP_SCOPE_KEY, PLATFORM_AUTH_ZITADEL_APPLIED_IMAGE_TAG_SCOPE_KEY, PLATFORM_AUTH_ZITADEL_RUN_OPTIONS_SCOPE_KEY, PLATFORM_BACKUP_DATABASE_SCOPE_KEYS, PLATFORM_BACKUP_SERVICE_KEYS, PLATFORM_EDGE_APPLIED_GIT_REF_SCOPE_KEY, PLATFORM_EDGE_APPLIED_HOSTNAME_SCOPE_KEY, PLATFORM_EDGE_CLOUDFLARE_TOKEN_REF, PLATFORM_EDGE_INTERNAL_URL_SCOPE_KEY, PLATFORM_EDGE_KEY, PLATFORM_EDGE_KEY_REF, PLATFORM_EDGE_PLATFORM_KEY_SET_SCOPE_KEY, PLATFORM_EDGE_PLATFORM_KEY_UNDELIVERED_SCOPE_KEY, PLATFORM_FILES_APPLIED_GIT_REF_SCOPE_KEY, PLATFORM_FILES_APPLIED_HOSTNAME_SCOPE_KEY, PLATFORM_FILES_INTERNAL_URL_SCOPE_KEY, PLATFORM_FILES_INTROSPECTION_SCOPE_KEY, PLATFORM_FILES_INTROSPECTION_UNDELIVERED_SCOPE_KEY, PLATFORM_FILES_KEY, PLATFORM_FILES_MANAGEMENT_KEY_SCOPE_KEY, PLATFORM_FILES_MANAGEMENT_UNDELIVERED_SCOPE_KEY, PLATFORM_FILES_ZITADEL_PROJECT_SCOPE_KEY, PLATFORM_HC_API_KEY_REF, PLATFORM_HC_APPLIED_GIT_REF_SCOPE_KEY, PLATFORM_HC_APPLIED_HOSTNAME_SCOPE_KEY, PLATFORM_HC_KEY, PLATFORM_HEARTBEATS_APPLIED_SCOPE_KEY, PLATFORM_HEARTBEATS_HOSTED_API_KEY_REF, PLATFORM_INTEGRATIONS_APPLIED_GITREF_SCOPE_KEY, PLATFORM_INTEGRATIONS_APPLIED_HOSTNAME_SCOPE_KEY, PLATFORM_INTEGRATIONS_KEY, PLATFORM_INTEGRATIONS_KEY_ID_SCOPE_KEY, PLATFORM_INTEGRATIONS_KEY_ROTATION_PREVIOUS_SCOPE_KEY, PLATFORM_INTEGRATIONS_KEY_ROTATION_REQUESTED_SCOPE_KEY, PLATFORM_INTEGRATIONS_KEY_ROTATION_UNDELIVERED_SCOPE_KEY, PLATFORM_INTEGRATIONS_KEY_SET_SCOPE_KEY, PLATFORM_INTEGRATIONS_KEY_UNDELIVERED_SCOPE_KEY, PLATFORM_INTEGRATIONS_RUNNER_KEYRING_DEPLOYED_SCOPE_KEY, PLATFORM_MAIL_ADMIN_KEY_SCOPE_KEY, PLATFORM_MAIL_ADMIN_UNDELIVERED_SCOPE_KEY, PLATFORM_MAIL_APPLIED_GIT_REF_SCOPE_KEY, PLATFORM_MAIL_APPLIED_HOSTNAME_SCOPE_KEY, PLATFORM_MAIL_CONNECTOR_APP_SCOPE_KEY, PLATFORM_MAIL_HOST_PORTS_DIRECT, PLATFORM_MAIL_HOST_PORTS_SCOPE_KEY, PLATFORM_MAIL_INTERNAL_URL_SCOPE_KEY, PLATFORM_MAIL_KEY, PLATFORM_MAIL_RELAY_KEY, PLATFORM_MAIL_ROUTE_SCOPE_KEY, PLATFORM_MAIL_ROUTE_WANTED, PLATFORM_MAIL_ZITADEL_PROJECT_SCOPE_KEY, PLATFORM_OWNER_ORG_DOMAIN_BLOCKED_SCOPE_KEY, PLATFORM_OWNER_ORG_DOMAIN_SCOPE_KEY, PLATFORM_OWNER_ORG_SCOPE_KEY, PLATFORM_SFU_APPLIED_SCOPE_KEY, PLATFORM_UPTIME_ACCESS_CLIENT_ID_REF, PLATFORM_UPTIME_ACCESS_CLIENT_SECRET_REF, PLATFORM_UPTIME_ACCESS_TOKEN_ID_SCOPE_KEY, PLATFORM_UPTIME_ACCESS_TOKEN_NAME, PLATFORM_UPTIME_ACCESS_UNDELIVERED_SCOPE_KEY, PLATFORM_UPTIME_CHAT_ROOM_ID_SCOPE_KEY, PLATFORM_UPTIME_HEARTBEAT_CHECK_NAME, PLATFORM_UPTIME_KEY, PLATFORM_UPTIME_REMOVED_SCOPE_KEY, PLATFORM_UPTIME_SCRIPT_NAME, PLATFORM_UPTIME_TOKEN_ID_SCOPE_KEY, PLATFORM_UPTIME_TOKEN_REF, PLATFORM_ZITADEL_ORG_SCOPE_KEY, archonFilesMarker, archonFirstOwnerLink, archonOrgDomain, archonOrgName, desiredHeartbeatCheckNames, heartbeatCheckUrlRef, parseBrokerDeployRecord, parseLoginClientRecord, platformBackupHeartbeatCheckName, platformMailHostPorts, platformMailMxHost, uptimeAccessTokenConflict, uptimeEffectiveVars, uptimeRecorded, uptimeVarsDigest, } from './platform-diff.js';
|
|
25
25
|
import { ensureSignInOrg, inviteOwnerWithLink, signInOrgDomain } from './sign-in-org.js';
|
|
26
|
+
import { SIGNIN_DB_ZITADEL_PENDING_SCOPE_KEY } from './signin-db-tls.js';
|
|
27
|
+
import { SIGNIN_DB_APP_SCOPE_KEY, SIGNIN_DB_TLS_SCOPE_KEY, dockerfileAppCreator, ensureSigninDbCaFile, provisionSigninDb, waitHealthy, withCallTimeout, } from './signin-db.js';
|
|
26
28
|
export { PLATFORM_ZITADEL_ORG_SCOPE_KEY };
|
|
27
29
|
import { applyCallsSfuPlatformStep, callsSfuAppliedFingerprint, } from './platform-sfu.js';
|
|
28
30
|
import { heldUptimeValues, provisionPlatformUptime, } from './uptime-worker.js';
|
|
@@ -1094,7 +1096,7 @@ export const AUTH_ZITADEL_PATH_PATTERN = '^/(oidc|oauth|saml|management|admin|sy
|
|
|
1094
1096
|
*/
|
|
1095
1097
|
const AUTH_BROKER_PATH_PATTERN = '^/api(/|$)';
|
|
1096
1098
|
const PLATFORM_AUTH_PROJECT_SCOPE_KEY = 'coolify_project_platform_auth';
|
|
1097
|
-
const PLATFORM_AUTH_DB_SCOPE_KEY = 'coolify_platform_auth_database';
|
|
1099
|
+
export const PLATFORM_AUTH_DB_SCOPE_KEY = 'coolify_platform_auth_database';
|
|
1098
1100
|
export const PLATFORM_AUTH_ZITADEL_SCOPE_KEY = 'coolify_platform_auth_zitadel_app';
|
|
1099
1101
|
export const PLATFORM_AUTH_LOGIN_SCOPE_KEY = 'coolify_platform_auth_login_app';
|
|
1100
1102
|
const PLATFORM_AUTH_TUNNEL_SCOPE_KEY = 'cloudflare_platform_auth_tunnel';
|
|
@@ -1429,33 +1431,70 @@ export async function provisionAuthStack(scope, deps, target, input) {
|
|
|
1429
1431
|
const projectUuid = await input.project.ensure(deps.coolify, scope);
|
|
1430
1432
|
const { environment } = await deps.coolify.findOrCreateEnvironment(projectUuid, 'production');
|
|
1431
1433
|
// ---- database ----------------------------------------------------------
|
|
1432
|
-
|
|
1434
|
+
// A company's sign-in database is foundry's own TLS image (#746). A company still on the Coolify
|
|
1435
|
+
// database keeps it until its move step has run (`signin-db-move.ts`); the platform's stays on it.
|
|
1436
|
+
const legacyDbId = recorded[PLATFORM_AUTH_DB_SCOPE_KEY];
|
|
1437
|
+
const tlsDatabase = target.kind === 'org' && Boolean(recorded[SIGNIN_DB_TLS_SCOPE_KEY] || !legacyDbId);
|
|
1438
|
+
let dbId = legacyDbId;
|
|
1433
1439
|
// A database ZITADEL has already run against may hold data encrypted with a
|
|
1434
1440
|
// masterkey this run cannot see (see `ensureMasterkey`). A database that
|
|
1435
1441
|
// exists but was never deployed against (a throw between its create and the
|
|
1436
1442
|
// first deploy) is still empty, so a retry may generate the key.
|
|
1437
|
-
const databaseInUse = Boolean(
|
|
1438
|
-
|
|
1439
|
-
|
|
1440
|
-
|
|
1441
|
-
|
|
1442
|
-
|
|
1443
|
-
|
|
1444
|
-
|
|
1445
|
-
|
|
1446
|
-
|
|
1447
|
-
|
|
1443
|
+
const databaseInUse = Boolean((tlsDatabase ? recorded[SIGNIN_DB_APP_SCOPE_KEY] : dbId) &&
|
|
1444
|
+
recorded[PLATFORM_AUTH_ZITADEL_DEPLOYED_SCOPE_KEY]);
|
|
1445
|
+
let databaseUrl;
|
|
1446
|
+
let signinDbCa;
|
|
1447
|
+
if (tlsDatabase) {
|
|
1448
|
+
const provisioned = await provisionSigninDb({
|
|
1449
|
+
coolify: deps.coolify,
|
|
1450
|
+
keys: deps.heldKeys,
|
|
1451
|
+
runtime,
|
|
1452
|
+
name: target.names.signinDb,
|
|
1453
|
+
internalHost: target.internal.signinDb,
|
|
1454
|
+
volumeName: `${target.names.signinDb}-data`,
|
|
1455
|
+
serverCertRef: target.refs.signinDbServer,
|
|
1456
|
+
slug: target.id,
|
|
1457
|
+
backup: deps.signinDbBackup,
|
|
1458
|
+
backupBucket: target.names.signinBackupBucket,
|
|
1459
|
+
recorded,
|
|
1460
|
+
label,
|
|
1461
|
+
environment,
|
|
1462
|
+
record,
|
|
1463
|
+
create: dockerfileAppCreator(deps.coolify, {
|
|
1464
|
+
projectUuid,
|
|
1465
|
+
environment,
|
|
1466
|
+
name: target.names.signinDb,
|
|
1467
|
+
description: `${target.id}'s sign-in database with TLS (#746)`,
|
|
1468
|
+
}),
|
|
1448
1469
|
});
|
|
1449
|
-
|
|
1450
|
-
|
|
1470
|
+
databaseUrl = provisioned.dsn;
|
|
1471
|
+
signinDbCa = provisioned.ca;
|
|
1472
|
+
await record(SIGNIN_DB_TLS_SCOPE_KEY, provisioned.appId);
|
|
1451
1473
|
}
|
|
1452
|
-
|
|
1453
|
-
|
|
1454
|
-
|
|
1455
|
-
|
|
1456
|
-
|
|
1457
|
-
|
|
1458
|
-
|
|
1474
|
+
else {
|
|
1475
|
+
if (!dbId) {
|
|
1476
|
+
const server = await resolvePlatformServer(deps.coolify);
|
|
1477
|
+
const created = await deps.coolify.createPostgresDatabase({
|
|
1478
|
+
project_uuid: projectUuid,
|
|
1479
|
+
server_uuid: server.uuid,
|
|
1480
|
+
environment_name: environment.name,
|
|
1481
|
+
environment_uuid: environment.uuid,
|
|
1482
|
+
name: target.names.authDb,
|
|
1483
|
+
postgres_db: 'zitadel',
|
|
1484
|
+
postgres_user: 'zitadel',
|
|
1485
|
+
});
|
|
1486
|
+
dbId = created.uuid;
|
|
1487
|
+
await record(PLATFORM_AUTH_DB_SCOPE_KEY, dbId);
|
|
1488
|
+
}
|
|
1489
|
+
const db = await getDatabaseStarted(deps.coolify, dbId);
|
|
1490
|
+
if (!db.internal_db_url) {
|
|
1491
|
+
// Static config check before either app is touched (`diff.ts`'s
|
|
1492
|
+
// `coolifyCalls` follows the same rule for its own databases): Coolify
|
|
1493
|
+
// can take a moment to report a freshly created database's
|
|
1494
|
+
// `internal_db_url`, so the next apply retries from a clean state.
|
|
1495
|
+
throw new Error(`coolify:app for ${subject} (auth): the database has no internal_db_url yet -- retry this apply once Coolify reports one`);
|
|
1496
|
+
}
|
|
1497
|
+
databaseUrl = db.internal_db_url;
|
|
1459
1498
|
}
|
|
1460
1499
|
// ---- the ZITADEL app itself ---------------------------------------------
|
|
1461
1500
|
const zitadelApp = await findOrCreatePlatformApp(deps.coolify, target.names.authZitadel, environment, async () => {
|
|
@@ -1473,6 +1512,8 @@ export async function provisionAuthStack(scope, deps, target, input) {
|
|
|
1473
1512
|
});
|
|
1474
1513
|
const zitadelAppId = zitadelApp.appId;
|
|
1475
1514
|
await record(PLATFORM_AUTH_ZITADEL_SCOPE_KEY, zitadelAppId);
|
|
1515
|
+
if (signinDbCa)
|
|
1516
|
+
await ensureSigninDbCaFile(deps.coolify, zitadelAppId, signinDbCa, label);
|
|
1476
1517
|
// Set on every run, not only after a create: a throw right after the
|
|
1477
1518
|
// create must not leave the app unnamed for good, and the tunnel addresses
|
|
1478
1519
|
// the app by this name. (The port cannot read the current name back, and
|
|
@@ -1575,7 +1616,7 @@ export async function provisionAuthStack(scope, deps, target, input) {
|
|
|
1575
1616
|
{ key: 'ZITADEL_EXTERNALPORT', value: '443' },
|
|
1576
1617
|
{ key: 'ZITADEL_EXTERNALSECURE', value: 'true' },
|
|
1577
1618
|
{ key: 'ZITADEL_TLS_ENABLED', value: 'false' },
|
|
1578
|
-
{ key: 'ZITADEL_DATABASE_POSTGRES_DSN', value:
|
|
1619
|
+
{ key: 'ZITADEL_DATABASE_POSTGRES_DSN', value: databaseUrl },
|
|
1579
1620
|
{ key: 'ZITADEL_FIRSTINSTANCE_SKIP', value: 'true' },
|
|
1580
1621
|
{ key: 'ZITADEL_DEFAULTINSTANCE_FEATURES_LOGINV2_REQUIRED', value: 'true' },
|
|
1581
1622
|
// A username is unique per organisation, and the login name is `name@<org domain>`. Without it a
|
|
@@ -1665,13 +1706,24 @@ export async function provisionAuthStack(scope, deps, target, input) {
|
|
|
1665
1706
|
await deps.coolify.bulkUpsertEnvs(brokerAppId, brokerEnvs);
|
|
1666
1707
|
// Deploy an app that was just created, is not running (a throw after the
|
|
1667
1708
|
// create left it never deployed, or it stopped), or whose envs changed.
|
|
1668
|
-
|
|
1709
|
+
// A move that started ZITADEL on the TLS database and never saw it healthy there (#746): start it
|
|
1710
|
+
// again and wait, so sign-in is not left down by a plan that finds nothing to do.
|
|
1711
|
+
const zitadelUnconfirmed = Boolean(recorded[SIGNIN_DB_ZITADEL_PENDING_SCOPE_KEY]);
|
|
1712
|
+
if (zitadelApp.justCreated ||
|
|
1713
|
+
!zitadelApp.running ||
|
|
1714
|
+
zitadelEnvsChanged ||
|
|
1715
|
+
zitadelRunOptionsNew ||
|
|
1716
|
+
zitadelUnconfirmed) {
|
|
1669
1717
|
await deps.coolify.deploy(zitadelAppId);
|
|
1670
1718
|
await record(PLATFORM_AUTH_ZITADEL_DEPLOYED_SCOPE_KEY, zitadelAppId);
|
|
1671
1719
|
// Only now: the options are live once the deploy went out. A throw between
|
|
1672
1720
|
// the PATCH and here leaves the marker unset, so the next run deploys.
|
|
1673
1721
|
await record(PLATFORM_AUTH_ZITADEL_RUN_OPTIONS_SCOPE_KEY, AUTH_ZITADEL_RUN_OPTIONS);
|
|
1674
1722
|
}
|
|
1723
|
+
if (zitadelUnconfirmed) {
|
|
1724
|
+
await waitHealthy(withCallTimeout(deps.coolify), zitadelAppId, runtime, label, 'ZITADEL');
|
|
1725
|
+
await record(SIGNIN_DB_ZITADEL_PENDING_SCOPE_KEY, '');
|
|
1726
|
+
}
|
|
1675
1727
|
if (loginApp.justCreated || !loginApp.running || loginEnvsChanged) {
|
|
1676
1728
|
await deps.coolify.deploy(loginAppId);
|
|
1677
1729
|
}
|
|
@@ -6229,7 +6281,7 @@ async function waitForInstance(url, runtime, sleep, label = 'platform:auth') {
|
|
|
6229
6281
|
await sleep(interval);
|
|
6230
6282
|
}
|
|
6231
6283
|
}
|
|
6232
|
-
async function resolvePlatformServer(coolify) {
|
|
6284
|
+
export async function resolvePlatformServer(coolify) {
|
|
6233
6285
|
const servers = await coolify.listServers();
|
|
6234
6286
|
const server = servers.find((s) => s.is_usable && s.is_reachable) ?? servers[0];
|
|
6235
6287
|
if (!server)
|
|
@@ -6242,7 +6294,7 @@ async function resolvePlatformServer(coolify) {
|
|
|
6242
6294
|
* file holds no instance of that class to call it on. `running` says whether
|
|
6243
6295
|
* the app was already running or starting; the caller deploys it when not.
|
|
6244
6296
|
*/
|
|
6245
|
-
async function findOrCreatePlatformApp(coolify, name, environment, create) {
|
|
6297
|
+
export async function findOrCreatePlatformApp(coolify, name, environment, create) {
|
|
6246
6298
|
const existing = await coolify.findApplicationByName(name, {
|
|
6247
6299
|
environmentIds: [environment.id],
|
|
6248
6300
|
});
|
|
@@ -6397,7 +6449,7 @@ export function brokerDeployRecordValue(brokerAppId, authAppsRaw) {
|
|
|
6397
6449
|
return JSON.stringify(record);
|
|
6398
6450
|
}
|
|
6399
6451
|
/** Whether any desired env is missing from the app, or set to another value. */
|
|
6400
|
-
async function envsDiffer(coolify, appId, desired) {
|
|
6452
|
+
export async function envsDiffer(coolify, appId, desired) {
|
|
6401
6453
|
const live = new Map((await coolify.listEnvs(appId)).filter((e) => !e.is_preview).map((e) => [e.key, e.value]));
|
|
6402
6454
|
return desired.some((e) => live.get(e.key) !== e.value);
|
|
6403
6455
|
}
|
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
import type { CoolifyPort } from './executor.js';
|
|
2
|
+
import type { OwnFilesR2Keys } from './own-services.js';
|
|
3
|
+
/**
|
|
4
|
+
* The nightly backup of a company's sign-in database (#746). Coolify's database backups cover Coolify
|
|
5
|
+
* databases only, and the TLS database is an application, so the schedule is a Coolify scheduled task of
|
|
6
|
+
* that application (`POST /applications/{uuid}/scheduled-tasks`, read from Coolify 4.4.2's
|
|
7
|
+
* `ScheduledTasksController`). Coolify runs the task's command with `docker exec` in the database's
|
|
8
|
+
* container at the task's frequency. The command dumps with `pg_dump` and puts the file in the company's
|
|
9
|
+
* own backup bucket, with a storage key that reaches that bucket alone (minted through the same host
|
|
10
|
+
* ports as the company's files bucket key, `ensureSigninDbBackupTarget`). The platform's backup key never
|
|
11
|
+
* reaches a company's container. It keeps nothing: Coolify's default of keeping every dump applies.
|
|
12
|
+
*/
|
|
13
|
+
/** Recorded id of the scheduled task (set only after Coolify lists it back as enabled). */
|
|
14
|
+
export declare const SIGNIN_DB_BACKUP_TASK_SCOPE_KEY = "signin_db_backup_task";
|
|
15
|
+
export declare const SIGNIN_DB_BACKUP_TASK_NAME = "signin-db-backup";
|
|
16
|
+
/** Coolify's own name for "once a day", the frequency the platform's database backups use. */
|
|
17
|
+
export declare const SIGNIN_DB_BACKUP_FREQUENCY = "daily";
|
|
18
|
+
/** How long Coolify lets one run take, in seconds. */
|
|
19
|
+
export declare const SIGNIN_DB_BACKUP_TIMEOUT_SECONDS = 3600;
|
|
20
|
+
/** The company's own backup bucket and the storage key scoped to it. The key is a secret. */
|
|
21
|
+
export interface SigninDbBackupTarget {
|
|
22
|
+
endpoint: string;
|
|
23
|
+
bucket: string;
|
|
24
|
+
/** R2 takes `auto`. */
|
|
25
|
+
region?: string;
|
|
26
|
+
accessKeyId: string;
|
|
27
|
+
secretAccessKey: string;
|
|
28
|
+
}
|
|
29
|
+
/** The host's bucket and storage key ports, the same ones the company's files bucket uses. */
|
|
30
|
+
export interface SigninDbBackupPort {
|
|
31
|
+
r2Buckets: {
|
|
32
|
+
ensureBucket(name: string): Promise<{
|
|
33
|
+
bucket: string;
|
|
34
|
+
endpoint: string;
|
|
35
|
+
}>;
|
|
36
|
+
};
|
|
37
|
+
r2Keys: OwnFilesR2Keys;
|
|
38
|
+
}
|
|
39
|
+
/** The token id of the company's sign-in backup key, recorded once it is in the database app's env. */
|
|
40
|
+
export declare const SIGNIN_DB_BACKUP_KEY_SCOPE_KEY = "signin_db_backup_key";
|
|
41
|
+
/** The token id minted and not yet in the env: deleted before any new mint. */
|
|
42
|
+
export declare const SIGNIN_DB_BACKUP_KEY_UNDELIVERED_SCOPE_KEY = "signin_db_backup_key_undelivered";
|
|
43
|
+
/** How long one call to the bucket or key port may take. */
|
|
44
|
+
export declare const SIGNIN_DB_BACKUP_PORT_TIMEOUT_MS = 30000;
|
|
45
|
+
/**
|
|
46
|
+
* The company's own bucket and a storage key that names that bucket alone. A key already in the
|
|
47
|
+
* database app's env whose token id is recorded is kept. Otherwise any earlier token of this company
|
|
48
|
+
* (recorded, or minted and never confirmed) is deleted first, so there are never two live tokens, then
|
|
49
|
+
* one is minted, registered as a secret and recorded as undelivered; the caller writes it to the env and
|
|
50
|
+
* then records it as delivered. A failed bucket or mint throws before the database is deployed or the
|
|
51
|
+
* move stops anything. Errors name the label only.
|
|
52
|
+
*/
|
|
53
|
+
export declare function ensureSigninDbBackupTarget(port: SigninDbBackupPort, input: {
|
|
54
|
+
bucketName: string;
|
|
55
|
+
label: string;
|
|
56
|
+
recorded: Readonly<Record<string, string>>;
|
|
57
|
+
/** What the database app's env holds now. */
|
|
58
|
+
held: {
|
|
59
|
+
keyId?: string;
|
|
60
|
+
secret?: string;
|
|
61
|
+
};
|
|
62
|
+
record(key: string, id: string): Promise<void>;
|
|
63
|
+
onSecret?(value: string): void;
|
|
64
|
+
}): Promise<{
|
|
65
|
+
target: SigninDbBackupTarget;
|
|
66
|
+
tokenId: string;
|
|
67
|
+
fresh: boolean;
|
|
68
|
+
}>;
|
|
69
|
+
/** The env of the database app that the backup command reads. The command itself holds no value. */
|
|
70
|
+
export declare const SIGNIN_DB_BACKUP_ENV: {
|
|
71
|
+
readonly endpoint: "BACKUP_S3_ENDPOINT";
|
|
72
|
+
readonly bucket: "BACKUP_S3_BUCKET";
|
|
73
|
+
readonly region: "BACKUP_S3_REGION";
|
|
74
|
+
readonly keyId: "BACKUP_S3_KEY_ID";
|
|
75
|
+
readonly secret: "BACKUP_S3_SECRET";
|
|
76
|
+
readonly prefix: "BACKUP_S3_PREFIX";
|
|
77
|
+
};
|
|
78
|
+
/** Where in the bucket a company's dumps go: `signin-db/<slug>/signin-db-<UTC time>.dump`. */
|
|
79
|
+
export declare function signinDbBackupPrefix(slug: string): string;
|
|
80
|
+
/** The env entries the backup command needs, for the database app. */
|
|
81
|
+
export declare function signinDbBackupEnvs(target: SigninDbBackupTarget, slug: string): Array<{
|
|
82
|
+
key: string;
|
|
83
|
+
value: string;
|
|
84
|
+
}>;
|
|
85
|
+
/**
|
|
86
|
+
* The command Coolify runs in the container. A custom-format dump goes to a temporary file (an upload
|
|
87
|
+
* needs a length), is signed with SigV4 and put with curl, and the file is removed on every exit. The
|
|
88
|
+
* credentials reach curl on its standard input as a config, never in an argument. Every failure ends the
|
|
89
|
+
* run non-zero, and curl's error text names the HTTP status only. No single quote: Coolify wraps the
|
|
90
|
+
* command in single quotes.
|
|
91
|
+
*/
|
|
92
|
+
export declare function signinDbBackupCommand(user: string, database: string): string;
|
|
93
|
+
/**
|
|
94
|
+
* Makes sure the database app has its backup task, and returns its uuid. The task is found by name
|
|
95
|
+
* before it is created, and updated to the full wanted body every time (Coolify hides the stored
|
|
96
|
+
* command from a token without the sensitive-read ability, so it cannot be compared). The task is then
|
|
97
|
+
* read back and must be listed enabled with the wanted frequency and timeout, or this throws: a
|
|
98
|
+
* schedule that cannot be seen is not a backup. The caller records the uuid. Errors name the label only.
|
|
99
|
+
*/
|
|
100
|
+
export declare function ensureSigninDbBackupTask(coolify: Pick<CoolifyPort, 'raw'>, appId: string, label: string, db: {
|
|
101
|
+
user: string;
|
|
102
|
+
database: string;
|
|
103
|
+
}): Promise<string>;
|
|
104
|
+
/** A company on the TLS database whose nightly dump is not scheduled yet: the sign-in step plans again to schedule it. */
|
|
105
|
+
export declare function signinDbBackupMissing(recordedIds: Readonly<Record<string, string>>): boolean;
|
|
@@ -0,0 +1,193 @@
|
|
|
1
|
+
import { SIGNIN_DB_TLS_SCOPE_KEY } from './signin-db-tls.js';
|
|
2
|
+
/**
|
|
3
|
+
* The nightly backup of a company's sign-in database (#746). Coolify's database backups cover Coolify
|
|
4
|
+
* databases only, and the TLS database is an application, so the schedule is a Coolify scheduled task of
|
|
5
|
+
* that application (`POST /applications/{uuid}/scheduled-tasks`, read from Coolify 4.4.2's
|
|
6
|
+
* `ScheduledTasksController`). Coolify runs the task's command with `docker exec` in the database's
|
|
7
|
+
* container at the task's frequency. The command dumps with `pg_dump` and puts the file in the company's
|
|
8
|
+
* own backup bucket, with a storage key that reaches that bucket alone (minted through the same host
|
|
9
|
+
* ports as the company's files bucket key, `ensureSigninDbBackupTarget`). The platform's backup key never
|
|
10
|
+
* reaches a company's container. It keeps nothing: Coolify's default of keeping every dump applies.
|
|
11
|
+
*/
|
|
12
|
+
/** Recorded id of the scheduled task (set only after Coolify lists it back as enabled). */
|
|
13
|
+
export const SIGNIN_DB_BACKUP_TASK_SCOPE_KEY = 'signin_db_backup_task';
|
|
14
|
+
export const SIGNIN_DB_BACKUP_TASK_NAME = 'signin-db-backup';
|
|
15
|
+
/** Coolify's own name for "once a day", the frequency the platform's database backups use. */
|
|
16
|
+
export const SIGNIN_DB_BACKUP_FREQUENCY = 'daily';
|
|
17
|
+
/** How long Coolify lets one run take, in seconds. */
|
|
18
|
+
export const SIGNIN_DB_BACKUP_TIMEOUT_SECONDS = 3600;
|
|
19
|
+
/** How long the upload may take, in seconds: the dump has to leave time inside the task's own limit. */
|
|
20
|
+
const UPLOAD_MAX_SECONDS = 1800;
|
|
21
|
+
/** The token id of the company's sign-in backup key, recorded once it is in the database app's env. */
|
|
22
|
+
export const SIGNIN_DB_BACKUP_KEY_SCOPE_KEY = 'signin_db_backup_key';
|
|
23
|
+
/** The token id minted and not yet in the env: deleted before any new mint. */
|
|
24
|
+
export const SIGNIN_DB_BACKUP_KEY_UNDELIVERED_SCOPE_KEY = 'signin_db_backup_key_undelivered';
|
|
25
|
+
/** How long one call to the bucket or key port may take. */
|
|
26
|
+
export const SIGNIN_DB_BACKUP_PORT_TIMEOUT_MS = 30_000;
|
|
27
|
+
async function portCall(work, label, what) {
|
|
28
|
+
let timer;
|
|
29
|
+
try {
|
|
30
|
+
return await Promise.race([
|
|
31
|
+
work,
|
|
32
|
+
new Promise((_, reject) => {
|
|
33
|
+
timer = setTimeout(() => reject(new Error('timeout')), SIGNIN_DB_BACKUP_PORT_TIMEOUT_MS);
|
|
34
|
+
}),
|
|
35
|
+
]);
|
|
36
|
+
}
|
|
37
|
+
catch (error) {
|
|
38
|
+
// The cause is dropped: a port's message is not ours to repeat, and may hold the secret.
|
|
39
|
+
throw new Error(`${label}: ${what} ${error instanceof Error && error.message === 'timeout'
|
|
40
|
+
? `did not answer within ${SIGNIN_DB_BACKUP_PORT_TIMEOUT_MS / 1000} seconds`
|
|
41
|
+
: 'failed'}`);
|
|
42
|
+
}
|
|
43
|
+
finally {
|
|
44
|
+
clearTimeout(timer);
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
const usable = (value) => typeof value === 'string' && value.length > 0 && value.length <= 200;
|
|
48
|
+
/**
|
|
49
|
+
* The company's own bucket and a storage key that names that bucket alone. A key already in the
|
|
50
|
+
* database app's env whose token id is recorded is kept. Otherwise any earlier token of this company
|
|
51
|
+
* (recorded, or minted and never confirmed) is deleted first, so there are never two live tokens, then
|
|
52
|
+
* one is minted, registered as a secret and recorded as undelivered; the caller writes it to the env and
|
|
53
|
+
* then records it as delivered. A failed bucket or mint throws before the database is deployed or the
|
|
54
|
+
* move stops anything. Errors name the label only.
|
|
55
|
+
*/
|
|
56
|
+
export async function ensureSigninDbBackupTarget(port, input) {
|
|
57
|
+
const { label, recorded } = input;
|
|
58
|
+
const bucket = await portCall(port.r2Buckets.ensureBucket(input.bucketName), label, `the sign-in backup bucket ${input.bucketName}`);
|
|
59
|
+
const kept = recorded[SIGNIN_DB_BACKUP_KEY_SCOPE_KEY];
|
|
60
|
+
if (kept && input.held.keyId && input.held.secret) {
|
|
61
|
+
input.onSecret?.(input.held.secret);
|
|
62
|
+
input.onSecret?.(input.held.keyId);
|
|
63
|
+
return {
|
|
64
|
+
target: {
|
|
65
|
+
endpoint: bucket.endpoint,
|
|
66
|
+
bucket: bucket.bucket,
|
|
67
|
+
accessKeyId: input.held.keyId,
|
|
68
|
+
secretAccessKey: input.held.secret,
|
|
69
|
+
},
|
|
70
|
+
tokenId: kept,
|
|
71
|
+
fresh: false,
|
|
72
|
+
};
|
|
73
|
+
}
|
|
74
|
+
for (const [scopeKey, tokenId] of [
|
|
75
|
+
[
|
|
76
|
+
SIGNIN_DB_BACKUP_KEY_UNDELIVERED_SCOPE_KEY,
|
|
77
|
+
recorded[SIGNIN_DB_BACKUP_KEY_UNDELIVERED_SCOPE_KEY],
|
|
78
|
+
],
|
|
79
|
+
[SIGNIN_DB_BACKUP_KEY_SCOPE_KEY, kept],
|
|
80
|
+
]) {
|
|
81
|
+
if (!tokenId)
|
|
82
|
+
continue;
|
|
83
|
+
await portCall(port.r2Keys.deleteBucketKey(tokenId), label, `deleting the earlier sign-in backup key ${tokenId}, so no new one was made:`);
|
|
84
|
+
await input.record(scopeKey, '');
|
|
85
|
+
}
|
|
86
|
+
const key = await portCall(port.r2Keys.mintBucketKey(bucket.bucket), label, 'the sign-in backup key mint');
|
|
87
|
+
if (usable(key?.secretAccessKey))
|
|
88
|
+
input.onSecret?.(key.secretAccessKey);
|
|
89
|
+
if (usable(key?.accessKeyId))
|
|
90
|
+
input.onSecret?.(key.accessKeyId);
|
|
91
|
+
// A token id we can read is recorded even when the rest of the answer is unusable, so it is deleted next run.
|
|
92
|
+
if (usable(key?.tokenId))
|
|
93
|
+
await input.record(SIGNIN_DB_BACKUP_KEY_UNDELIVERED_SCOPE_KEY, key.tokenId);
|
|
94
|
+
if (!usable(key?.accessKeyId) || !usable(key?.secretAccessKey) || !usable(key?.tokenId)) {
|
|
95
|
+
throw new Error(`${label}: the sign-in backup key mint answered with an unusable key`);
|
|
96
|
+
}
|
|
97
|
+
return {
|
|
98
|
+
target: {
|
|
99
|
+
endpoint: bucket.endpoint,
|
|
100
|
+
bucket: bucket.bucket,
|
|
101
|
+
accessKeyId: key.accessKeyId,
|
|
102
|
+
secretAccessKey: key.secretAccessKey,
|
|
103
|
+
},
|
|
104
|
+
tokenId: key.tokenId,
|
|
105
|
+
fresh: true,
|
|
106
|
+
};
|
|
107
|
+
}
|
|
108
|
+
/** The env of the database app that the backup command reads. The command itself holds no value. */
|
|
109
|
+
export const SIGNIN_DB_BACKUP_ENV = {
|
|
110
|
+
endpoint: 'BACKUP_S3_ENDPOINT',
|
|
111
|
+
bucket: 'BACKUP_S3_BUCKET',
|
|
112
|
+
region: 'BACKUP_S3_REGION',
|
|
113
|
+
keyId: 'BACKUP_S3_KEY_ID',
|
|
114
|
+
secret: 'BACKUP_S3_SECRET',
|
|
115
|
+
prefix: 'BACKUP_S3_PREFIX',
|
|
116
|
+
};
|
|
117
|
+
/** Where in the bucket a company's dumps go: `signin-db/<slug>/signin-db-<UTC time>.dump`. */
|
|
118
|
+
export function signinDbBackupPrefix(slug) {
|
|
119
|
+
return `signin-db/${slug}`;
|
|
120
|
+
}
|
|
121
|
+
/** The env entries the backup command needs, for the database app. */
|
|
122
|
+
export function signinDbBackupEnvs(target, slug) {
|
|
123
|
+
return [
|
|
124
|
+
{ key: SIGNIN_DB_BACKUP_ENV.endpoint, value: target.endpoint.replace(/\/+$/, '') },
|
|
125
|
+
{ key: SIGNIN_DB_BACKUP_ENV.bucket, value: target.bucket },
|
|
126
|
+
{ key: SIGNIN_DB_BACKUP_ENV.region, value: target.region ?? 'auto' },
|
|
127
|
+
{ key: SIGNIN_DB_BACKUP_ENV.keyId, value: target.accessKeyId },
|
|
128
|
+
{ key: SIGNIN_DB_BACKUP_ENV.secret, value: target.secretAccessKey },
|
|
129
|
+
{ key: SIGNIN_DB_BACKUP_ENV.prefix, value: signinDbBackupPrefix(slug) },
|
|
130
|
+
];
|
|
131
|
+
}
|
|
132
|
+
/**
|
|
133
|
+
* The command Coolify runs in the container. A custom-format dump goes to a temporary file (an upload
|
|
134
|
+
* needs a length), is signed with SigV4 and put with curl, and the file is removed on every exit. The
|
|
135
|
+
* credentials reach curl on its standard input as a config, never in an argument. Every failure ends the
|
|
136
|
+
* run non-zero, and curl's error text names the HTTP status only. No single quote: Coolify wraps the
|
|
137
|
+
* command in single quotes.
|
|
138
|
+
*/
|
|
139
|
+
export function signinDbBackupCommand(user, database) {
|
|
140
|
+
const e = SIGNIN_DB_BACKUP_ENV;
|
|
141
|
+
return [
|
|
142
|
+
'set -eu',
|
|
143
|
+
'f=/tmp/signin-db-$(date -u +%Y%m%dT%H%M%SZ).dump',
|
|
144
|
+
'trap "rm -f $f" EXIT',
|
|
145
|
+
`pg_dump -U ${user} -d ${database} -Fc -f "$f"`,
|
|
146
|
+
`printf "user = \\"%s:%s\\"\\n" "$${e.keyId}" "$${e.secret}" | curl -sS --fail --max-time ${UPLOAD_MAX_SECONDS} --aws-sigv4 "aws:amz:$${e.region}:s3" -K - -T "$f" "$${e.endpoint}/$${e.bucket}/$${e.prefix}/\${f##*/}"`,
|
|
147
|
+
].join('; ');
|
|
148
|
+
}
|
|
149
|
+
function rows(body) {
|
|
150
|
+
if (!Array.isArray(body))
|
|
151
|
+
throw new Error('scheduled tasks list has an unexpected shape');
|
|
152
|
+
return body.map((row) => (row && typeof row === 'object' ? row : {}));
|
|
153
|
+
}
|
|
154
|
+
/**
|
|
155
|
+
* Makes sure the database app has its backup task, and returns its uuid. The task is found by name
|
|
156
|
+
* before it is created, and updated to the full wanted body every time (Coolify hides the stored
|
|
157
|
+
* command from a token without the sensitive-read ability, so it cannot be compared). The task is then
|
|
158
|
+
* read back and must be listed enabled with the wanted frequency and timeout, or this throws: a
|
|
159
|
+
* schedule that cannot be seen is not a backup. The caller records the uuid. Errors name the label only.
|
|
160
|
+
*/
|
|
161
|
+
export async function ensureSigninDbBackupTask(coolify, appId, label, db) {
|
|
162
|
+
const base = `/applications/${appId}/scheduled-tasks`;
|
|
163
|
+
const body = {
|
|
164
|
+
name: SIGNIN_DB_BACKUP_TASK_NAME,
|
|
165
|
+
command: signinDbBackupCommand(db.user, db.database),
|
|
166
|
+
frequency: SIGNIN_DB_BACKUP_FREQUENCY,
|
|
167
|
+
timeout: SIGNIN_DB_BACKUP_TIMEOUT_SECONDS,
|
|
168
|
+
enabled: true,
|
|
169
|
+
};
|
|
170
|
+
const found = rows(await coolify.raw('GET', base)).find((t) => t.name === SIGNIN_DB_BACKUP_TASK_NAME && typeof t.uuid === 'string');
|
|
171
|
+
let uuid = found?.uuid;
|
|
172
|
+
if (found) {
|
|
173
|
+
await coolify.raw('PATCH', `${base}/${found.uuid}`, body);
|
|
174
|
+
}
|
|
175
|
+
else {
|
|
176
|
+
uuid = (await coolify.raw('POST', base, body))?.uuid;
|
|
177
|
+
}
|
|
178
|
+
if (typeof uuid !== 'string' || !uuid) {
|
|
179
|
+
throw new Error(`${label}: Coolify did not return the sign-in database's backup schedule`);
|
|
180
|
+
}
|
|
181
|
+
const listed = rows(await coolify.raw('GET', base)).find((t) => t.uuid === uuid);
|
|
182
|
+
if (!listed ||
|
|
183
|
+
listed.enabled !== true ||
|
|
184
|
+
listed.frequency !== SIGNIN_DB_BACKUP_FREQUENCY ||
|
|
185
|
+
listed.timeout !== SIGNIN_DB_BACKUP_TIMEOUT_SECONDS) {
|
|
186
|
+
throw new Error(`${label}: the sign-in database's backup schedule is not listed as enabled in Coolify; nothing was moved`);
|
|
187
|
+
}
|
|
188
|
+
return uuid;
|
|
189
|
+
}
|
|
190
|
+
/** A company on the TLS database whose nightly dump is not scheduled yet: the sign-in step plans again to schedule it. */
|
|
191
|
+
export function signinDbBackupMissing(recordedIds) {
|
|
192
|
+
return (Boolean(recordedIds[SIGNIN_DB_TLS_SCOPE_KEY]) && !recordedIds[SIGNIN_DB_BACKUP_TASK_SCOPE_KEY]);
|
|
193
|
+
}
|