@wtfalch/foundry 0.8.0 → 0.10.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/declare.js +1 -5
- package/dist/index.d.ts +3 -3
- package/dist/index.js +2 -2
- package/dist/migrations/0029_hosting_entries.sql +109 -0
- package/dist/migrations/0030_hosting_entry_hostnames.sql +24 -0
- package/dist/org.d.ts +87 -29
- package/dist/org.js +180 -27
- package/dist/plan/agora.d.ts +7 -0
- package/dist/plan/agora.js +9 -0
- package/dist/plan/broker-entries.d.ts +104 -0
- package/dist/plan/broker-entries.js +214 -0
- package/dist/plan/diff.d.ts +2 -0
- package/dist/plan/diff.js +274 -50
- package/dist/plan/executor.d.ts +89 -29
- package/dist/plan/executor.js +495 -233
- package/dist/plan/hostnames.d.ts +24 -0
- package/dist/plan/hostnames.js +56 -0
- package/dist/plan/index.d.ts +3 -1
- package/dist/plan/index.js +2 -1
- package/dist/plan/locked-hostnames.d.ts +8 -0
- package/dist/plan/locked-hostnames.js +26 -0
- package/dist/plan/login-host.d.ts +1 -1
- package/dist/plan/login-host.js +5 -2
- package/dist/plan/plan-apply.d.ts +16 -0
- package/dist/plan/plan-apply.js +48 -11
- package/dist/plan/platform-diff.d.ts +69 -10
- package/dist/plan/platform-diff.js +133 -14
- package/dist/plan/platform-providers.d.ts +102 -2
- package/dist/plan/platform-providers.js +486 -40
- package/dist/plan/zitadel-system-api.d.ts +64 -0
- package/dist/plan/zitadel-system-api.js +125 -0
- package/dist/platform.d.ts +1 -1
- package/dist/store/postgres.js +12 -12
- package/dist/store/schema.d.ts +20 -1
- package/dist/store/schema.js +7 -1
- package/package.json +1 -1
|
@@ -4,8 +4,9 @@ import { VERSIONS, floatingTagBlocked } from '../bootstrap/versions.js';
|
|
|
4
4
|
import { redactSecrets } from '../calls-sfu-deploy-support.js';
|
|
5
5
|
import { ensureCloudflaredConnector } from './cloudflared-connector.js';
|
|
6
6
|
import { ACCESS_POLICY_NAME, isNotFoundError, isRunningOrStarting } from './executor.js';
|
|
7
|
-
import { JOBS_SCHEDULER_KEY, PLATFORM_AI_APPLIED_GITREF_SCOPE_KEY, PLATFORM_AI_APPLIED_HOSTNAME_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_ARCHON_APPLIED_BROKER_SCOPE_KEY, PLATFORM_ARCHON_APPLIED_GIT_REF_SCOPE_KEY, PLATFORM_ARCHON_APPLIED_HOSTNAME_SCOPE_KEY, PLATFORM_ARCHON_CONNECTOR_APP_SCOPE_KEY, PLATFORM_ARCHON_GATE_CLOSED_SCOPE_KEY, PLATFORM_ARCHON_KEY, PLATFORM_ARCHON_MASTER_CLOUDFLARE_TOKEN_REF, PLATFORM_ARCHON_MASTER_COOLIFY_TOKEN_REF, PLATFORM_ARCHON_MASTER_COOLIFY_URL_REF, 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_KEY, PLATFORM_AUTH_LOGIN_CLIENT_UNDELIVERED_SCOPE_KEY, PLATFORM_AUTH_LOGIN_CLIENT_USER_SCOPE_KEY, PLATFORM_AUTH_ZITADEL_APPLIED_IMAGE_TAG_SCOPE_KEY, PLATFORM_BACKUP_DATABASE_SCOPE_KEYS, PLATFORM_BACKUP_SERVICE_KEYS, 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_INTEGRATIONS_APPLIED_GITREF_SCOPE_KEY, PLATFORM_INTEGRATIONS_APPLIED_HOSTNAME_SCOPE_KEY, PLATFORM_INTEGRATIONS_KEY, PLATFORM_INTEGRATIONS_KEY_SET_SCOPE_KEY, PLATFORM_INTEGRATIONS_KEY_UNDELIVERED_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_INTERNAL_URL_SCOPE_KEY, PLATFORM_MAIL_KEY, parseBrokerDeployRecord, parseLoginClientRecord, platformMailMxHost, } from './platform-diff.js';
|
|
7
|
+
import { JOBS_SCHEDULER_KEY, PLATFORM_AI_APPLIED_GITREF_SCOPE_KEY, PLATFORM_AI_APPLIED_HOSTNAME_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_ARCHON_APPLIED_BROKER_SCOPE_KEY, PLATFORM_ARCHON_APPLIED_GIT_REF_SCOPE_KEY, PLATFORM_ARCHON_APPLIED_HOSTNAME_SCOPE_KEY, PLATFORM_ARCHON_CONNECTOR_APP_SCOPE_KEY, PLATFORM_ARCHON_GATE_CLOSED_SCOPE_KEY, PLATFORM_ARCHON_KEY, PLATFORM_ARCHON_MASTER_CLOUDFLARE_TOKEN_REF, PLATFORM_ARCHON_MASTER_COOLIFY_TOKEN_REF, PLATFORM_ARCHON_MASTER_COOLIFY_URL_REF, 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_ZITADEL_APPLIED_IMAGE_TAG_SCOPE_KEY, PLATFORM_BACKUP_DATABASE_SCOPE_KEYS, PLATFORM_BACKUP_SERVICE_KEYS, 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_INTEGRATIONS_APPLIED_GITREF_SCOPE_KEY, PLATFORM_INTEGRATIONS_APPLIED_HOSTNAME_SCOPE_KEY, PLATFORM_INTEGRATIONS_KEY, PLATFORM_INTEGRATIONS_KEY_SET_SCOPE_KEY, PLATFORM_INTEGRATIONS_KEY_UNDELIVERED_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_INTERNAL_URL_SCOPE_KEY, PLATFORM_MAIL_KEY, parseBrokerDeployRecord, parseLoginClientRecord, platformMailMxHost, } from './platform-diff.js';
|
|
8
8
|
import { applyCallsSfuPlatformStep } from './platform-sfu.js';
|
|
9
|
+
import { CreateInstanceError, createZitadelInstance, generateSystemApiKeypair, instanceWithoutBearerMessage, systemApiUsersEnv, } from './zitadel-system-api.js';
|
|
9
10
|
/** The key `runPlatformSteps` records the apex zone's id under (`system:kind:key`). */
|
|
10
11
|
function apexZoneKey(apex) {
|
|
11
12
|
return `cloudflare:zone:${apex}`;
|
|
@@ -247,6 +248,18 @@ export class FoundryPlatformStepExecutor {
|
|
|
247
248
|
if (loginClientDrifted)
|
|
248
249
|
result.auth = true;
|
|
249
250
|
}
|
|
251
|
+
// #257 fix round 2: only once a platform database actually exists to
|
|
252
|
+
// back up -- the same "nothing to report yet" gate `blockedSteps`'s own
|
|
253
|
+
// `backupExecutorMissing` check follows above, so a platform that has
|
|
254
|
+
// not applied any service yet never pays for a live Coolify read here.
|
|
255
|
+
if (this.backup &&
|
|
256
|
+
PLATFORM_BACKUP_SERVICE_KEYS.some((service) => Boolean(recordedIds[PLATFORM_BACKUP_DATABASE_SCOPE_KEYS[service]]))) {
|
|
257
|
+
const backupStorage = await platformBackupStorageDrifted(this.backup.coolify, this.backup.r2, recordedIds);
|
|
258
|
+
if (backupStorage.drifted)
|
|
259
|
+
result.backupStorage = true;
|
|
260
|
+
if (backupStorage.blocked)
|
|
261
|
+
result.backupStorageBlocked = backupStorage.blocked;
|
|
262
|
+
}
|
|
250
263
|
return result;
|
|
251
264
|
}
|
|
252
265
|
async execute(action, context) {
|
|
@@ -302,6 +315,12 @@ export class FoundryPlatformStepExecutor {
|
|
|
302
315
|
}
|
|
303
316
|
return provisionPlatformBackup(action, context, this.backup);
|
|
304
317
|
}
|
|
318
|
+
if (action.system === 'coolify' && action.kind === 'backup-storage') {
|
|
319
|
+
if (!this.backup) {
|
|
320
|
+
throw new Error('platform:backup-storage needs FoundryPlatformStepExecutor to be constructed with its backup dependencies (#257)');
|
|
321
|
+
}
|
|
322
|
+
return provisionPlatformBackupStorage(context, this.backup);
|
|
323
|
+
}
|
|
305
324
|
if (action.system === 'sfu' && action.kind === 'livekit') {
|
|
306
325
|
return this.executeSfu();
|
|
307
326
|
}
|
|
@@ -495,7 +514,7 @@ const AUTH_BROKER_PORT = '3000';
|
|
|
495
514
|
* falls through to the login app's own ingress rule below (login v2 answers
|
|
496
515
|
* everything else, including the root).
|
|
497
516
|
*/
|
|
498
|
-
const AUTH_ZITADEL_PATH_PATTERN = '^/(oidc|oauth|saml|management|admin|system|auth|assets|ui/console|\\.well-known)(/|$)';
|
|
517
|
+
const AUTH_ZITADEL_PATH_PATTERN = '^/(oidc|oauth|saml|management|admin|system|auth|assets|debug/healthz|ui/console|\\.well-known)(/|$)';
|
|
499
518
|
/**
|
|
500
519
|
* `@wtfalch/auth`'s own `brokerUrl` default, `<issuer>/api`
|
|
501
520
|
* (`packages/auth/src/config.ts`) -- `deploy/proxy/Caddyfile`'s own
|
|
@@ -515,6 +534,14 @@ const PLATFORM_AUTH_ADMIN_USER_NAME = 'foundry-admin';
|
|
|
515
534
|
/** #216 (building on #209/#215): the narrower `IAM_LOGIN_CLIENT` machine user for the login app and the broker. */
|
|
516
535
|
const PLATFORM_AUTH_LOGIN_CLIENT_USER_NAME = 'foundry-login-client';
|
|
517
536
|
const PLATFORM_AUTH_ORG_NAME = 'foundry';
|
|
537
|
+
/**
|
|
538
|
+
* #283: the ref the new instance's IAM_OWNER machine token (a PAT) is
|
|
539
|
+
* delivered under, once. A host reads it back to build `zitadelForInstance`
|
|
540
|
+
* (see {@link provisionPlatformAuth}'s doc comment). Distinct from the admin
|
|
541
|
+
* key delivered under `zitadel:platform:admin`.
|
|
542
|
+
*/
|
|
543
|
+
export const PLATFORM_AUTH_INSTANCE_BEARER_REF = 'zitadel:platform:instanceBearer';
|
|
544
|
+
const PLATFORM_AUTH_BEARER_USER_NAME = 'foundry-instance-owner';
|
|
518
545
|
/**
|
|
519
546
|
* Retries `action` up to `attempts` times, sleeping increasingly between
|
|
520
547
|
* tries -- the same shape the admin machine user's own delivery retry (just
|
|
@@ -758,8 +785,24 @@ async function liveLoginClientTokenHash(coolify, appId) {
|
|
|
758
785
|
* first), so it will not start (`parseApps` throws on an empty list) until
|
|
759
786
|
* then either. Safe to run again: the next apply that registers an app
|
|
760
787
|
* redeploys the broker with a non-empty list.
|
|
761
|
-
* - Whoever supplies `deps.zitadel` (or `zitadelForInstance`) must hold a
|
|
762
|
-
* bearer token for the new instance, which foundry cannot read from it
|
|
788
|
+
* - ~~Whoever supplies `deps.zitadel` (or `zitadelForInstance`) must hold a
|
|
789
|
+
* bearer token for the new instance, which foundry cannot read from it.~~
|
|
790
|
+
* Closed by #283: see "The instance bearer" below.
|
|
791
|
+
*
|
|
792
|
+
* The instance bearer (#283): the container creates no instance of its own
|
|
793
|
+
* (`ZITADEL_FIRSTINSTANCE_SKIP`). foundry generates an RSA keypair, puts the
|
|
794
|
+
* public half in `ZITADEL_SYSTEMAPIUSERS` (a system API user with
|
|
795
|
+
* SYSTEM_OWNER only), and once the container answers calls ZITADEL's system
|
|
796
|
+
* API `CreateInstance` with a machine owner (IAM_OWNER of the new instance)
|
|
797
|
+
* signed by the private half, which lives in memory for that run only. The
|
|
798
|
+
* owner's PAT comes back once and is handed to
|
|
799
|
+
* {@link PlatformAuthDeps.deliverSecret} under
|
|
800
|
+
* {@link PLATFORM_AUTH_INSTANCE_BEARER_REF}, before anything else uses it. A
|
|
801
|
+
* host builds `zitadelForInstance` as `(url) => createZitadelAdminMinter({
|
|
802
|
+
* url, token: <the secret delivered under that ref> })` plus its own org
|
|
803
|
+
* calls; `zitadelForInstance` is first called after the delivery. No human
|
|
804
|
+
* admin is created: a person who needs console access is added by the host
|
|
805
|
+
* through that bearer.
|
|
763
806
|
*
|
|
764
807
|
* #197: a `cloudflared` connector for this tunnel is run
|
|
765
808
|
* ({@link ensureCloudflaredConnector}), so `hostname` answers.
|
|
@@ -899,7 +942,19 @@ async function provisionPlatformAuth(context, deps) {
|
|
|
899
942
|
// same rule `executor.ts`'s `ensureAuthCookieSecret` follows for a value
|
|
900
943
|
// this package mints itself rather than receiving from a provider.
|
|
901
944
|
const masterkey = await ensureMasterkey(deps.coolify, zitadelAppId, databaseInUse);
|
|
902
|
-
|
|
945
|
+
// #283: the instance is created once, with the system API, by a platform
|
|
946
|
+
// whose admin machine user is not recorded yet (one that already has it was
|
|
947
|
+
// built from a first instance and keeps it). The keypair is regenerated on
|
|
948
|
+
// any run that still has to create it, so the private half never needs to
|
|
949
|
+
// be held anywhere between runs.
|
|
950
|
+
const lostInstance = recorded[PLATFORM_AUTH_INSTANCE_UNDELIVERED_SCOPE_KEY];
|
|
951
|
+
if (lostInstance &&
|
|
952
|
+
!recorded[PLATFORM_AUTH_INSTANCE_SCOPE_KEY] &&
|
|
953
|
+
!recorded[PLATFORM_AUTH_ADMIN_USER_SCOPE_KEY]) {
|
|
954
|
+
throw new Error(instanceWithoutBearerMessage(hostname, `instance ${lostInstance}`));
|
|
955
|
+
}
|
|
956
|
+
const needsInstance = !recorded[PLATFORM_AUTH_ADMIN_USER_SCOPE_KEY] && !recorded[PLATFORM_AUTH_INSTANCE_SCOPE_KEY];
|
|
957
|
+
const systemKeys = needsInstance ? generateSystemApiKeypair() : undefined;
|
|
903
958
|
const zitadelEnvs = [
|
|
904
959
|
{ key: 'ZITADEL_PORT', value: AUTH_ZITADEL_PORT },
|
|
905
960
|
{ key: 'ZITADEL_EXTERNALDOMAIN', value: hostname },
|
|
@@ -907,8 +962,7 @@ async function provisionPlatformAuth(context, deps) {
|
|
|
907
962
|
{ key: 'ZITADEL_EXTERNALSECURE', value: 'true' },
|
|
908
963
|
{ key: 'ZITADEL_TLS_ENABLED', value: 'false' },
|
|
909
964
|
{ key: 'ZITADEL_DATABASE_POSTGRES_DSN', value: db.internal_db_url },
|
|
910
|
-
{ key: '
|
|
911
|
-
{ key: 'ZITADEL_FIRSTINSTANCE_ORG_HUMAN_PASSWORDCHANGEREQUIRED', value: 'false' },
|
|
965
|
+
{ key: 'ZITADEL_FIRSTINSTANCE_SKIP', value: 'true' },
|
|
912
966
|
{ key: 'ZITADEL_DEFAULTINSTANCE_FEATURES_LOGINV2_REQUIRED', value: 'true' },
|
|
913
967
|
{ key: 'ZITADEL_DEFAULTINSTANCE_FEATURES_LOGINV2_BASEURI', value: `https://${hostname}/` },
|
|
914
968
|
{ key: 'ZITADEL_OIDC_DEFAULTLOGINURLV2', value: `https://${hostname}/login?authRequest=` },
|
|
@@ -921,8 +975,12 @@ async function provisionPlatformAuth(context, deps) {
|
|
|
921
975
|
];
|
|
922
976
|
if (masterkey)
|
|
923
977
|
zitadelEnvs.push({ key: 'ZITADEL_MASTERKEY', value: masterkey });
|
|
924
|
-
if (
|
|
925
|
-
zitadelEnvs.push({
|
|
978
|
+
if (systemKeys) {
|
|
979
|
+
zitadelEnvs.push({
|
|
980
|
+
key: 'ZITADEL_SYSTEMAPIUSERS',
|
|
981
|
+
value: systemApiUsersEnv(systemKeys.publicKey),
|
|
982
|
+
});
|
|
983
|
+
}
|
|
926
984
|
const loginEnvs = [
|
|
927
985
|
{
|
|
928
986
|
key: 'ZITADEL_API_URL',
|
|
@@ -1051,6 +1109,50 @@ async function provisionPlatformAuth(context, deps) {
|
|
|
1051
1109
|
// a caller that reaches the step anyway.
|
|
1052
1110
|
throw new Error(`platform:auth: the admin key of ZITADEL machine user ${lost} was minted but never delivered; delete that user and the recorded key ${PLATFORM_AUTH_ADMIN_UNDELIVERED_SCOPE_KEY} to mint a new one`);
|
|
1053
1111
|
}
|
|
1112
|
+
if (systemKeys) {
|
|
1113
|
+
// ZITADEL answers /debug/healthz without an instance; the openid
|
|
1114
|
+
// document below only exists once the instance does.
|
|
1115
|
+
await waitForInstance(`https://${hostname}/debug/healthz`, runtime, sleep);
|
|
1116
|
+
// Sent exactly once. Anything that may have reached ZITADEL leaves a
|
|
1117
|
+
// marker first, so the next run refuses instead of creating again.
|
|
1118
|
+
let created;
|
|
1119
|
+
try {
|
|
1120
|
+
created = await createZitadelInstance({
|
|
1121
|
+
customDomain: hostname,
|
|
1122
|
+
privateKey: systemKeys.privateKey,
|
|
1123
|
+
instanceName: PLATFORM_AUTH_ORG_NAME,
|
|
1124
|
+
firstOrgName: PLATFORM_AUTH_ORG_NAME,
|
|
1125
|
+
owner: { userName: PLATFORM_AUTH_BEARER_USER_NAME, name: 'Foundry (instance owner)' },
|
|
1126
|
+
}, { fetch: runtime.fetch, now: runtime.now ?? Date.now });
|
|
1127
|
+
}
|
|
1128
|
+
catch (error) {
|
|
1129
|
+
if (error instanceof CreateInstanceError && error.kind !== 'rejected') {
|
|
1130
|
+
await record(PLATFORM_AUTH_INSTANCE_UNDELIVERED_SCOPE_KEY, 'unknown');
|
|
1131
|
+
}
|
|
1132
|
+
throw error;
|
|
1133
|
+
}
|
|
1134
|
+
const { instanceId, pat } = created;
|
|
1135
|
+
// Recorded before the PAT leaves this function, like the admin key below.
|
|
1136
|
+
await record(PLATFORM_AUTH_INSTANCE_UNDELIVERED_SCOPE_KEY, instanceId);
|
|
1137
|
+
const attempts = runtime.deliverAttempts ?? 3;
|
|
1138
|
+
for (let attempt = 1;; attempt += 1) {
|
|
1139
|
+
try {
|
|
1140
|
+
await deps.deliverSecret(PLATFORM_AUTH_INSTANCE_BEARER_REF, pat);
|
|
1141
|
+
break;
|
|
1142
|
+
}
|
|
1143
|
+
catch (error) {
|
|
1144
|
+
if (attempt >= attempts) {
|
|
1145
|
+
const reason = redactSecrets(error instanceof Error ? error.message : String(error), [
|
|
1146
|
+
pat,
|
|
1147
|
+
]);
|
|
1148
|
+
throw new Error(`platform:auth: the owner token of ZITADEL instance ${instanceId} was created but could not be delivered after ${attempts} tries (${reason}); ${instanceWithoutBearerMessage(hostname, `instance ${instanceId}`)}`);
|
|
1149
|
+
}
|
|
1150
|
+
await sleep(1000 * attempt);
|
|
1151
|
+
}
|
|
1152
|
+
}
|
|
1153
|
+
// The marker stays recorded but is ignored once this is set.
|
|
1154
|
+
await record(PLATFORM_AUTH_INSTANCE_SCOPE_KEY, instanceId);
|
|
1155
|
+
}
|
|
1054
1156
|
// Minting needs a running instance behind the tunnel: the first apply
|
|
1055
1157
|
// gets here right after the deploy.
|
|
1056
1158
|
await waitForInstance(`https://${hostname}/.well-known/openid-configuration`, runtime, sleep);
|
|
@@ -2360,7 +2462,7 @@ function isCanonicalOrigin(value) {
|
|
|
2360
2462
|
* malformed entry never reaches a property read on a non-object and throws a
|
|
2361
2463
|
* `TypeError` instead of this named error.
|
|
2362
2464
|
*/
|
|
2363
|
-
function validateBrokerAppEntry(entry, brokerAppId, index) {
|
|
2465
|
+
export function validateBrokerAppEntry(entry, brokerAppId, index) {
|
|
2364
2466
|
const label = `platform:archon: broker app ${brokerAppId}'s AUTH_APPS[${index}]`;
|
|
2365
2467
|
if (typeof entry !== 'object' || entry === null || Array.isArray(entry)) {
|
|
2366
2468
|
throw new Error(`${label} is not an object`);
|
|
@@ -3480,7 +3582,7 @@ async function findOrCreatePlatformApp(coolify, name, environment, create) {
|
|
|
3480
3582
|
* `CoolifyPort.listEnvs` doc comment already warns a live value does not
|
|
3481
3583
|
* always match its declared `string` type.
|
|
3482
3584
|
*/
|
|
3483
|
-
async function missingBrokerInputs(coolify, brokerAppId) {
|
|
3585
|
+
export async function missingBrokerInputs(coolify, brokerAppId) {
|
|
3484
3586
|
const envs = await coolify.listEnvs(brokerAppId);
|
|
3485
3587
|
const value = (key) => envs.find((e) => e.key === key && !e.is_preview && e.value != null && e.value !== '')?.value;
|
|
3486
3588
|
const has = (key) => value(key) !== undefined;
|
|
@@ -3560,7 +3662,7 @@ function hashAuthApps(raw) {
|
|
|
3560
3662
|
* left unchanged), so neither caller needs a second read to agree with
|
|
3561
3663
|
* itself about what is now true of the app.
|
|
3562
3664
|
*/
|
|
3563
|
-
function brokerDeployNeeded(missingInputs, brokerAppId, authAppsRaw, recordedDeploy) {
|
|
3665
|
+
export function brokerDeployNeeded(missingInputs, brokerAppId, authAppsRaw, recordedDeploy) {
|
|
3564
3666
|
if (missingInputs.length > 0)
|
|
3565
3667
|
return false;
|
|
3566
3668
|
if (recordedDeploy === null)
|
|
@@ -3575,7 +3677,7 @@ function brokerDeployNeeded(missingInputs, brokerAppId, authAppsRaw, recordedDep
|
|
|
3575
3677
|
* caller's own `record()`), replacing the plain broker-id string round 2-4
|
|
3576
3678
|
* wrote there. Paired with {@link brokerDeployNeeded}, which reads it back.
|
|
3577
3679
|
*/
|
|
3578
|
-
function brokerDeployRecordValue(brokerAppId, authAppsRaw) {
|
|
3680
|
+
export function brokerDeployRecordValue(brokerAppId, authAppsRaw) {
|
|
3579
3681
|
const record = { brokerAppId, authAppsHash: hashAuthApps(authAppsRaw) };
|
|
3580
3682
|
return JSON.stringify(record);
|
|
3581
3683
|
}
|
|
@@ -3642,10 +3744,6 @@ async function ensureMasterkey(coolify, appId, databaseInUse) {
|
|
|
3642
3744
|
}
|
|
3643
3745
|
return randomBytes(24).toString('base64');
|
|
3644
3746
|
}
|
|
3645
|
-
/** `docs/deploy.md`: "ZITADEL_ADMIN_PASSWORD: 8+, upper, lower, digit, symbol". */
|
|
3646
|
-
function generateAdminPassword() {
|
|
3647
|
-
return `${randomBytes(18).toString('base64')}Aa1!`;
|
|
3648
|
-
}
|
|
3649
3747
|
/**
|
|
3650
3748
|
* #171: how the host signs a files management request with the key this step
|
|
3651
3749
|
* delivers under {@link FILES_MANAGEMENT_KEY_REF} (files'
|
|
@@ -4388,17 +4486,384 @@ async function waitForIntegrationsMigration(coolify, appId, runtime, sleep) {
|
|
|
4388
4486
|
const PLATFORM_BACKUP_S3_STORAGE_NAME = 'platform-backups';
|
|
4389
4487
|
/** The uuid `provisionPlatformBackup` records once {@link PLATFORM_BACKUP_S3_STORAGE_NAME} is registered on Coolify, so a later apply never re-registers it. */
|
|
4390
4488
|
const PLATFORM_BACKUP_S3_STORAGE_SCOPE_KEY = 'coolify_platform_backup_s3_storage';
|
|
4489
|
+
/**
|
|
4490
|
+
* #257 fix round: a sha256 of the exact `endpoint|bucket|key|secret` the
|
|
4491
|
+
* storage was last pushed with -- never the values themselves, the same
|
|
4492
|
+
* "hash only, never the secret" rule {@link hashAuthApps} already follows
|
|
4493
|
+
* in this file -- so a rotation is detected without ever writing a
|
|
4494
|
+
* credential to applied state.
|
|
4495
|
+
*/
|
|
4496
|
+
const PLATFORM_BACKUP_S3_STORAGE_FINGERPRINT_SCOPE_KEY = 'coolify_platform_backup_s3_storage_fingerprint';
|
|
4497
|
+
/**
|
|
4498
|
+
* #257 fix round 6, point 1: the uuid a freshly created
|
|
4499
|
+
* {@link PLATFORM_BACKUP_S3_STORAGE_NAME} is recorded under the moment
|
|
4500
|
+
* `createS3Storage` returns, persisted at once (the same "one mint" shape
|
|
4501
|
+
* CLAUDE.md's platform-steps rules require of any id a step cannot safely
|
|
4502
|
+
* re-mint), and cleared back to the empty string only once
|
|
4503
|
+
* {@link PLATFORM_BACKUP_S3_STORAGE_SCOPE_KEY} itself is updated to match --
|
|
4504
|
+
* the same "empty string means nothing pending" convention
|
|
4505
|
+
* `LOGIN_HOST_PENDING_REMOVAL_SCOPE_KEY` (`login-host.ts`) already uses.
|
|
4506
|
+
* Without this, a `repointPlatformBackupSchedules` failure partway through
|
|
4507
|
+
* the service list left the new storage created but unrecorded; the next
|
|
4508
|
+
* apply, finding the old recorded uuid still dead, created *another* one,
|
|
4509
|
+
* stranding the first holding this run's R2 credentials with nothing ever
|
|
4510
|
+
* pointed at it. `ensurePlatformBackupStorage` checks this before creating,
|
|
4511
|
+
* so a retry reuses the same storage instead.
|
|
4512
|
+
*/
|
|
4513
|
+
const PLATFORM_BACKUP_S3_STORAGE_PENDING_SCOPE_KEY = 'coolify_platform_backup_s3_storage_pending';
|
|
4391
4514
|
/** Coolify's own name for "once a day" -- `createDatabaseBackup`'s `frequency`. */
|
|
4392
4515
|
const PLATFORM_BACKUP_DEFAULT_FREQUENCY = 'daily';
|
|
4516
|
+
/**
|
|
4517
|
+
* Trims a trailing slash and lowercases the host, so a Coolify-normalised
|
|
4518
|
+
* endpoint (or one a person typed with a trailing slash) is never read as a
|
|
4519
|
+
* rotation. #257 fix round, point 3: the prior strict string compare treated
|
|
4520
|
+
* `https://R2.example.com/` and `https://r2.example.com` as different
|
|
4521
|
+
* buckets. Falls back to a plain trim-and-lowercase for a value `URL` cannot
|
|
4522
|
+
* parse, rather than throwing on a malformed endpoint here.
|
|
4523
|
+
*/
|
|
4524
|
+
function normalizePlatformBackupEndpoint(endpoint) {
|
|
4525
|
+
const trimmed = endpoint.trim().replace(/\/+$/, '');
|
|
4526
|
+
try {
|
|
4527
|
+
const url = new URL(trimmed);
|
|
4528
|
+
return `${url.protocol}//${url.host.toLowerCase()}${url.pathname.replace(/\/+$/, '')}${url.search}`;
|
|
4529
|
+
}
|
|
4530
|
+
catch {
|
|
4531
|
+
return trimmed.toLowerCase();
|
|
4532
|
+
}
|
|
4533
|
+
}
|
|
4534
|
+
/**
|
|
4535
|
+
* #257 fix round, point 1: sha256 of the R2 config this step would push to
|
|
4536
|
+
* Coolify, normalised the same way the live/desired endpoint compare is, so
|
|
4537
|
+
* a merely differently-formatted endpoint never reads as a rotated
|
|
4538
|
+
* credential. Compared against the fingerprint recorded on the last apply
|
|
4539
|
+
* that actually pushed this config -- never the key or secret themselves.
|
|
4540
|
+
* #257 fix round 3, point 4: `region` is in the hash too -- a region-only
|
|
4541
|
+
* change (R2 is `auto` today, but a host could point this at a different
|
|
4542
|
+
* S3-compatible provider later) must push an update the same as a changed
|
|
4543
|
+
* bucket or endpoint would, not go unnoticed because only the other four
|
|
4544
|
+
* fields were compared.
|
|
4545
|
+
*/
|
|
4546
|
+
function platformBackupStorageFingerprint(r2) {
|
|
4547
|
+
return createHash('sha256')
|
|
4548
|
+
.update([
|
|
4549
|
+
normalizePlatformBackupEndpoint(r2.endpoint),
|
|
4550
|
+
r2.bucket,
|
|
4551
|
+
r2.region ?? 'auto',
|
|
4552
|
+
r2.accessKeyId,
|
|
4553
|
+
r2.secretAccessKey,
|
|
4554
|
+
].join('|'))
|
|
4555
|
+
.digest('hex');
|
|
4556
|
+
}
|
|
4557
|
+
/**
|
|
4558
|
+
* #257 fix round, point 1 and 2: pushes the configured R2 bucket/endpoint/
|
|
4559
|
+
* key/secret onto an existing Coolify S3 storage via `PATCH /s3-storages/
|
|
4560
|
+
* {uuid}` (Coolify's own update-by-uuid route; `CoolifyPort` has no typed
|
|
4561
|
+
* method for it, so this goes through `raw` the way `coolifyApp`'s
|
|
4562
|
+
* stop/start calls already do for a route the port has not wrapped). The
|
|
4563
|
+
* secret is sent to Coolify and nowhere else -- it never reaches `raw`'s
|
|
4564
|
+
* own error path below, a log, or applied state.
|
|
4565
|
+
*/
|
|
4566
|
+
async function updatePlatformBackupS3Storage(coolify, storageUuid, r2) {
|
|
4567
|
+
await coolify.raw('PATCH', `/s3-storages/${storageUuid}`, {
|
|
4568
|
+
endpoint: r2.endpoint,
|
|
4569
|
+
bucket: r2.bucket,
|
|
4570
|
+
region: r2.region ?? 'auto',
|
|
4571
|
+
key: r2.accessKeyId,
|
|
4572
|
+
secret: r2.secretAccessKey,
|
|
4573
|
+
});
|
|
4574
|
+
}
|
|
4575
|
+
/**
|
|
4576
|
+
* #257 fix round 3, point 3, revised in fix round 4 (points 1 and 2): when
|
|
4577
|
+
* {@link ensurePlatformBackupStorage} moves the shared storage to a new uuid
|
|
4578
|
+
* -- a brand new registration (the recorded one was deleted by hand) or an
|
|
4579
|
+
* existing one adopted under a different uuid by name -- every service's
|
|
4580
|
+
* already-scheduled `save_s3` backup still carries the old uuid in
|
|
4581
|
+
* `s3_storage_uuid`. Coolify dumps a database on schedule regardless, but the
|
|
4582
|
+
* S3 copy step fails every night pointed at a storage that is gone or no
|
|
4583
|
+
* longer the one configured. This repoints each live schedule to the new
|
|
4584
|
+
* storage, the same "own live-vs-recorded compare per service" pattern
|
|
4585
|
+
* {@link ProvidersPlatformCoolifyReader.listResources} already uses to find a
|
|
4586
|
+
* service's own schedule in the first place.
|
|
4587
|
+
*
|
|
4588
|
+
* Fix round 4, point 2: a schedule is repointed only when it currently points
|
|
4589
|
+
* at `oldStorageUuid` -- the caller's own recorded uuid, never "whatever the
|
|
4590
|
+
* first enabled `save_s3` schedule happens to be." A hand-made schedule
|
|
4591
|
+
* pointed at an unrelated storage is left alone.
|
|
4592
|
+
*
|
|
4593
|
+
* Fix round 4, point 1: this throws on the first failure -- a list read or a
|
|
4594
|
+
* PATCH -- rather than swallowing it per service and logging. Both call
|
|
4595
|
+
* sites in {@link ensurePlatformBackupStorage} record the new uuid and
|
|
4596
|
+
* fingerprint only once this resolves, so a repoint failure here leaves the
|
|
4597
|
+
* old uuid recorded and the next plan sees the storage as still drifted (the
|
|
4598
|
+
* newly created entry is found again, by uuid or by name, and every schedule
|
|
4599
|
+
* still pointed at the old uuid is retried) rather than silently leaving a
|
|
4600
|
+
* schedule on a storage that no longer backs it up. A service with no
|
|
4601
|
+
* recorded database is skipped: there is nothing to repoint.
|
|
4602
|
+
*
|
|
4603
|
+
* Fix round 5, point 3: every `save_s3` schedule on the database is
|
|
4604
|
+
* checked, not just the first one Coolify happens to list -- a database
|
|
4605
|
+
* could carry more than one. And a schedule whose `s3_storage_uuid` is
|
|
4606
|
+
* missing is an unknown state, not a silent "doesn't match": a prior round
|
|
4607
|
+
* read a missing field the same as "points elsewhere" and skipped it, which
|
|
4608
|
+
* would leave a schedule nobody can tell is stale. Throwing here fails this
|
|
4609
|
+
* repoint the same way a list read or PATCH failure already does, which is
|
|
4610
|
+
* what keeps the storage reachable as drifted (see {@link
|
|
4611
|
+
* platformBackupStorageDrifted}) rather than quietly wrong.
|
|
4612
|
+
*
|
|
4613
|
+
* Fix round 6, point 3: before throwing on a missing `s3_storage_uuid`, this
|
|
4614
|
+
* reads the schedule's own detail route, `GET /databases/{uuid}/backups/
|
|
4615
|
+
* {backup_uuid}` -- the same resource `CoolifyPort.listDatabaseBackups`'s own
|
|
4616
|
+
* doc comment already flags as unconfirmed on the *list* response, and the
|
|
4617
|
+
* read half of the PATCH this file already sends to the identical path. Not
|
|
4618
|
+
* independently confirmed against a live Coolify instance; if the detail
|
|
4619
|
+
* route also omits the field, this still throws, now naming both endpoints
|
|
4620
|
+
* checked.
|
|
4621
|
+
*
|
|
4622
|
+
* Fix round 7, point 3: `backup.enabled` is no longer part of the skip
|
|
4623
|
+
* check -- a disabled `save_s3` schedule is repointed exactly like an
|
|
4624
|
+
* enabled one. Left on a dead storage, it breaks the day someone re-enables
|
|
4625
|
+
* it, and "disabled" says nothing about whether it still carries the right
|
|
4626
|
+
* `s3_storage_uuid`.
|
|
4627
|
+
*/
|
|
4628
|
+
async function repointPlatformBackupSchedules(coolify, recorded, oldStorageUuid, newStorageUuid) {
|
|
4629
|
+
for (const service of PLATFORM_BACKUP_SERVICE_KEYS) {
|
|
4630
|
+
const databaseId = recorded[PLATFORM_BACKUP_DATABASE_SCOPE_KEYS[service]];
|
|
4631
|
+
if (!databaseId)
|
|
4632
|
+
continue;
|
|
4633
|
+
const backups = await coolify.listDatabaseBackups(databaseId);
|
|
4634
|
+
for (const backup of backups) {
|
|
4635
|
+
// #257 fix round 7, point 3: a disabled schedule is repointed too --
|
|
4636
|
+
// left on a dead storage, it breaks the day someone re-enables it, and
|
|
4637
|
+
// nothing about "disabled" means "will never run again."
|
|
4638
|
+
if (!backup.save_s3)
|
|
4639
|
+
continue;
|
|
4640
|
+
let s3StorageUuid = backup.s3_storage_uuid;
|
|
4641
|
+
if (s3StorageUuid === undefined) {
|
|
4642
|
+
const detail = (await coolify.raw('GET', `/databases/${databaseId}/backups/${backup.uuid}`));
|
|
4643
|
+
s3StorageUuid = detail?.s3_storage_uuid;
|
|
4644
|
+
}
|
|
4645
|
+
if (s3StorageUuid === undefined) {
|
|
4646
|
+
throw new Error(`platform:backup-storage: ${service}'s backup ${backup.uuid} is enabled with save_s3 but has no s3_storage_uuid on either the list or the detail endpoint -- cannot tell whether it needs repointing`);
|
|
4647
|
+
}
|
|
4648
|
+
if (s3StorageUuid !== oldStorageUuid)
|
|
4649
|
+
continue;
|
|
4650
|
+
await coolify.raw('PATCH', `/databases/${databaseId}/backups/${backup.uuid}`, {
|
|
4651
|
+
s3_storage_uuid: newStorageUuid,
|
|
4652
|
+
});
|
|
4653
|
+
}
|
|
4654
|
+
}
|
|
4655
|
+
}
|
|
4656
|
+
/**
|
|
4657
|
+
* #257 fix round: self-heals the shared R2 storage entry rather than
|
|
4658
|
+
* trusting a recorded uuid forever, the same "stale id never blocks
|
|
4659
|
+
* planning for good" rule {@link recordedZone} already follows elsewhere in
|
|
4660
|
+
* this file. A recorded uuid Coolify no longer has (deleted by hand, or
|
|
4661
|
+
* never recorded at all) falls through to a create, exactly as a
|
|
4662
|
+
* first-ever apply does. When a uuid *was* recorded before and a new one had
|
|
4663
|
+
* to be created, every service's own schedule is repointed from the old
|
|
4664
|
+
* recorded uuid to the new one before either is recorded (fix round 4, point
|
|
4665
|
+
* 1: `repointPlatformBackupSchedules` now throws on any failure, so a failed
|
|
4666
|
+
* repoint leaves the old uuid recorded and the drift reachable again next
|
|
4667
|
+
* plan -- see {@link platformBackupStorageDrifted} -- rather than recording a
|
|
4668
|
+
* move that left a schedule behind). Once the uuid is settled, the entry's
|
|
4669
|
+
* fingerprint decides whether anything needs pushing: unchanged, nothing
|
|
4670
|
+
* happens; changed (a rotated key, secret, bucket or endpoint alike),
|
|
4671
|
+
* `updatePlatformBackupS3Storage` pushes the current config onto it rather
|
|
4672
|
+
* than refusing or duplicating it -- `listS3Storages` never returns a
|
|
4673
|
+
* storage's secret, so the fingerprint, not a field-by-field compare, is
|
|
4674
|
+
* what catches a key/secret rotation that left the bucket and endpoint
|
|
4675
|
+
* untouched.
|
|
4676
|
+
*
|
|
4677
|
+
* #257 fix round 5, point 2: earlier rounds also looked up a storage by
|
|
4678
|
+
* *name* (`PLATFORM_BACKUP_S3_STORAGE_NAME`) when the recorded uuid wasn't
|
|
4679
|
+
* live, and pushed this run's R2 credentials onto whatever it found. That
|
|
4680
|
+
* adopts and overwrites any storage that merely happens to share the name --
|
|
4681
|
+
* not necessarily one this package ever created -- which is indistinguishable
|
|
4682
|
+
* here from handing a stranger's bucket our key and secret. So this function
|
|
4683
|
+
* only ever mutates a storage whose uuid is the one already recorded: when
|
|
4684
|
+
* that uuid isn't live, it always creates a fresh one rather than taking an
|
|
4685
|
+
* existing same-named entry over. (`platformBackupStorageDrifted`'s own
|
|
4686
|
+
* by-name lookup is unaffected -- it only ever reads, to decide whether a
|
|
4687
|
+
* plan is needed, never writes.)
|
|
4688
|
+
*
|
|
4689
|
+
* #257 fix round 6, point 1: when the recorded uuid isn't live, a pending
|
|
4690
|
+
* uuid recorded under {@link PLATFORM_BACKUP_S3_STORAGE_PENDING_SCOPE_KEY} is
|
|
4691
|
+
* reused if it is still live, rather than creating a second storage. Before
|
|
4692
|
+
* this, a `repointPlatformBackupSchedules` failure left the just-created
|
|
4693
|
+
* storage unrecorded anywhere, so the next apply's "recorded uuid isn't
|
|
4694
|
+
* live" check always created another one -- one orphan per failed retry,
|
|
4695
|
+
* each holding a live copy of the R2 secret pointed at by nothing. The
|
|
4696
|
+
* pending uuid is persisted the moment it exists (create, or an existing
|
|
4697
|
+
* pending storage adopted unchanged) and cleared only once every schedule's
|
|
4698
|
+
* repoint has actually succeeded and the main key has caught up to it.
|
|
4699
|
+
*
|
|
4700
|
+
* #257 fix round 7, point 1: a reused pending storage is re-pushed with the
|
|
4701
|
+
* current `r2` config before anything repoints onto it. Without this, a
|
|
4702
|
+
* pending storage created on a failed attempt keeps whatever credential it
|
|
4703
|
+
* was given then -- if the R2 secret rotates before the retry, the retry
|
|
4704
|
+
* reuses the pending storage unchanged, records a fingerprint that matches
|
|
4705
|
+
* the *new* secret, and Coolify is left holding the old one. Nightly
|
|
4706
|
+
* backups then fail on auth with drift reporting clean.
|
|
4707
|
+
*
|
|
4708
|
+
* #257 fix round 7, point 2: once the recorded uuid is found live, any
|
|
4709
|
+
* leftover pending uuid is cleared even though nothing else needed fixing
|
|
4710
|
+
* this apply. `platformBackupStorageDrifted` treats a non-empty pending key
|
|
4711
|
+
* as "self-heal incomplete" on its own, with no Coolify call -- so a stale
|
|
4712
|
+
* pending uuid from an attempt that is no longer relevant (the recorded
|
|
4713
|
+
* storage came back, or was restored by hand) would otherwise plan this
|
|
4714
|
+
* step forever.
|
|
4715
|
+
*/
|
|
4716
|
+
async function ensurePlatformBackupStorage(coolify, r2, recorded, context) {
|
|
4717
|
+
const fingerprint = platformBackupStorageFingerprint(r2);
|
|
4718
|
+
const recordedUuid = recorded[PLATFORM_BACKUP_S3_STORAGE_SCOPE_KEY];
|
|
4719
|
+
const pendingUuid = recorded[PLATFORM_BACKUP_S3_STORAGE_PENDING_SCOPE_KEY];
|
|
4720
|
+
const recordedFingerprint = recorded[PLATFORM_BACKUP_S3_STORAGE_FINGERPRINT_SCOPE_KEY];
|
|
4721
|
+
// Persists at once, so a crash between this write and the repoint below
|
|
4722
|
+
// can never strand a created-but-unrecorded storage (this function's own
|
|
4723
|
+
// doc comment, fix round 6 point 1).
|
|
4724
|
+
const recordPending = async (id) => {
|
|
4725
|
+
context.recordScopeId(PLATFORM_BACKUP_S3_STORAGE_PENDING_SCOPE_KEY, id);
|
|
4726
|
+
await context.persist?.();
|
|
4727
|
+
};
|
|
4728
|
+
const storages = await coolify.listS3Storages();
|
|
4729
|
+
const live = recordedUuid ? storages.find((storage) => storage.uuid === recordedUuid) : undefined;
|
|
4730
|
+
if (!live) {
|
|
4731
|
+
const pendingLive = pendingUuid
|
|
4732
|
+
? storages.find((storage) => storage.uuid === pendingUuid)
|
|
4733
|
+
: undefined;
|
|
4734
|
+
const target = pendingLive ??
|
|
4735
|
+
(await coolify.createS3Storage({
|
|
4736
|
+
name: PLATFORM_BACKUP_S3_STORAGE_NAME,
|
|
4737
|
+
endpoint: r2.endpoint,
|
|
4738
|
+
bucket: r2.bucket,
|
|
4739
|
+
region: r2.region ?? 'auto',
|
|
4740
|
+
key: r2.accessKeyId,
|
|
4741
|
+
secret: r2.secretAccessKey,
|
|
4742
|
+
is_usable: true,
|
|
4743
|
+
}));
|
|
4744
|
+
if (!pendingLive) {
|
|
4745
|
+
await recordPending(target.uuid);
|
|
4746
|
+
}
|
|
4747
|
+
else {
|
|
4748
|
+
// #257 fix round 7, point 1: a pending storage can be carrying a
|
|
4749
|
+
// credential this run already left behind -- it was created (or
|
|
4750
|
+
// adopted) on an earlier, failed attempt, and the R2 secret may have
|
|
4751
|
+
// rotated since. Reusing it without pushing the current config would
|
|
4752
|
+
// record a fingerprint that matches `r2` while Coolify still holds
|
|
4753
|
+
// whatever was configured when the pending storage was first made.
|
|
4754
|
+
await updatePlatformBackupS3Storage(coolify, pendingLive.uuid, r2);
|
|
4755
|
+
}
|
|
4756
|
+
if (recordedUuid && recordedUuid !== target.uuid) {
|
|
4757
|
+
await repointPlatformBackupSchedules(coolify, recorded, recordedUuid, target.uuid);
|
|
4758
|
+
}
|
|
4759
|
+
context.recordScopeId(PLATFORM_BACKUP_S3_STORAGE_SCOPE_KEY, target.uuid);
|
|
4760
|
+
context.recordScopeId(PLATFORM_BACKUP_S3_STORAGE_FINGERPRINT_SCOPE_KEY, fingerprint);
|
|
4761
|
+
context.recordScopeId(PLATFORM_BACKUP_S3_STORAGE_PENDING_SCOPE_KEY, '');
|
|
4762
|
+
return target.uuid;
|
|
4763
|
+
}
|
|
4764
|
+
if (recordedFingerprint !== fingerprint) {
|
|
4765
|
+
await updatePlatformBackupS3Storage(coolify, live.uuid, r2);
|
|
4766
|
+
context.recordScopeId(PLATFORM_BACKUP_S3_STORAGE_FINGERPRINT_SCOPE_KEY, fingerprint);
|
|
4767
|
+
}
|
|
4768
|
+
// #257 fix round 7, point 2: the recorded storage is live, so the
|
|
4769
|
+
// self-heal this run needed (if any) is done. A pending uuid left over
|
|
4770
|
+
// from an earlier, now-irrelevant attempt (the recorded uuid came back
|
|
4771
|
+
// live again, or was fixed by hand) must not keep reporting drift forever
|
|
4772
|
+
// -- `platformBackupStorageDrifted` treats any non-empty pending key as
|
|
4773
|
+
// "incomplete self-heal" with no Coolify call, so a stale one would plan
|
|
4774
|
+
// this step on every apply from here on.
|
|
4775
|
+
if (pendingUuid) {
|
|
4776
|
+
context.recordScopeId(PLATFORM_BACKUP_S3_STORAGE_PENDING_SCOPE_KEY, '');
|
|
4777
|
+
}
|
|
4778
|
+
return live.uuid;
|
|
4779
|
+
}
|
|
4780
|
+
/**
|
|
4781
|
+
* #257 fix round 2: a read-only echo of {@link ensurePlatformBackupStorage}'s
|
|
4782
|
+
* own live-vs-recorded compare, for {@link FoundryPlatformStepExecutor.driftedSteps}
|
|
4783
|
+
* to call every plan -- never pushes anything to Coolify, just answers
|
|
4784
|
+
* whether the next apply would. `planBackups` (`platform-diff.ts`) needs
|
|
4785
|
+
* this because once every service's own `coolify:backup:<service>` step is
|
|
4786
|
+
* live, none of them is replanned again (the same "no park branch, claimed
|
|
4787
|
+
* for good" rule every other provision-only platform step here follows), so
|
|
4788
|
+
* nothing would otherwise ever call {@link ensurePlatformBackupStorage}
|
|
4789
|
+
* again to notice a rotated credential. True whenever the recorded uuid is
|
|
4790
|
+
* missing from Coolify's live list (it would re-register) or a live entry's
|
|
4791
|
+
* fingerprint no longer matches the desired one (it would push an update).
|
|
4792
|
+
*
|
|
4793
|
+
* #257 fix round 3, point 2, revised in fix round 4, point 4: Coolify being
|
|
4794
|
+
* unreachable here must not fail the whole platform plan -- the same "a plan
|
|
4795
|
+
* must not fail outright over a transient read" reason
|
|
4796
|
+
* {@link authLoginClientDrifted} already follows -- but a `console.error` a
|
|
4797
|
+
* person never sees hid exactly the kind of persistent failure (credentials
|
|
4798
|
+
* revoked, DNS broken, R2 endpoint unreachable) this check exists to catch.
|
|
4799
|
+
* So a failure here reads as "not drifted" (never plans a step doomed to
|
|
4800
|
+
* fail the same read again) *and* carries a `blocked` reason the caller
|
|
4801
|
+
* surfaces in the plan's own unmanaged list, the same "blocked, not failing,
|
|
4802
|
+
* not silent" shape every other blocked platform step in this file already
|
|
4803
|
+
* has.
|
|
4804
|
+
*
|
|
4805
|
+
* #257 fix round 5, point 1: also drifted when a live entry is found (by
|
|
4806
|
+
* uuid or by the shared name) under a *different* uuid than the one
|
|
4807
|
+
* recorded. This is what actually catches a repoint that failed partway:
|
|
4808
|
+
* {@link ensurePlatformBackupStorage} only records the new uuid once every
|
|
4809
|
+
* schedule's repoint has succeeded, so a failed repoint leaves the old,
|
|
4810
|
+
* now-dead uuid recorded. Before this fix, if the live entry's config
|
|
4811
|
+
* already matched (nothing rotated -- the storage was simply recreated
|
|
4812
|
+
* under a new uuid, found again here by name), the fingerprint compare
|
|
4813
|
+
* alone said "not drifted" and the stranded repoint was never retried.
|
|
4814
|
+
*
|
|
4815
|
+
* #257 fix round 6, point 1: also drifted whenever
|
|
4816
|
+
* {@link PLATFORM_BACKUP_S3_STORAGE_PENDING_SCOPE_KEY} is non-empty, with no
|
|
4817
|
+
* Coolify call needed to know it. A pending uuid means
|
|
4818
|
+
* {@link ensurePlatformBackupStorage} created or adopted a storage but has
|
|
4819
|
+
* not yet finished repointing every schedule onto it, so the self-heal is
|
|
4820
|
+
* incomplete regardless of what the fingerprint compare below would say.
|
|
4821
|
+
*/
|
|
4822
|
+
async function platformBackupStorageDrifted(coolify, r2, recorded) {
|
|
4823
|
+
const fingerprint = platformBackupStorageFingerprint(r2);
|
|
4824
|
+
const recordedUuid = recorded[PLATFORM_BACKUP_S3_STORAGE_SCOPE_KEY];
|
|
4825
|
+
const recordedFingerprint = recorded[PLATFORM_BACKUP_S3_STORAGE_FINGERPRINT_SCOPE_KEY];
|
|
4826
|
+
if (recorded[PLATFORM_BACKUP_S3_STORAGE_PENDING_SCOPE_KEY])
|
|
4827
|
+
return { drifted: true };
|
|
4828
|
+
try {
|
|
4829
|
+
const storages = await coolify.listS3Storages();
|
|
4830
|
+
const live = (recordedUuid && storages.find((storage) => storage.uuid === recordedUuid)) ||
|
|
4831
|
+
storages.find((storage) => storage.name === PLATFORM_BACKUP_S3_STORAGE_NAME);
|
|
4832
|
+
if (!live)
|
|
4833
|
+
return { drifted: true };
|
|
4834
|
+
if (recordedUuid && live.uuid !== recordedUuid)
|
|
4835
|
+
return { drifted: true };
|
|
4836
|
+
return { drifted: recordedFingerprint !== fingerprint };
|
|
4837
|
+
}
|
|
4838
|
+
catch (e) {
|
|
4839
|
+
return {
|
|
4840
|
+
drifted: false,
|
|
4841
|
+
blocked: `platform:backup-storage is blocked: could not check the R2 backup storage's liveness: ${e instanceof Error ? e.message : String(e)}`,
|
|
4842
|
+
};
|
|
4843
|
+
}
|
|
4844
|
+
}
|
|
4845
|
+
/**
|
|
4846
|
+
* #257 fix round 2: the one step `planBackups` plans, independent of every
|
|
4847
|
+
* per-service `coolify:backup:<service>` step's own claimed/live status,
|
|
4848
|
+
* purely to keep {@link ensurePlatformBackupStorage}'s self-heal reachable
|
|
4849
|
+
* once every service's own step has gone quiet. Calling it again once a
|
|
4850
|
+
* per-service step has already self-healed this same apply is a safe no-op:
|
|
4851
|
+
* {@link ensurePlatformBackupStorage}'s own fingerprint compare is what
|
|
4852
|
+
* decides whether anything is pushed, not which step called it.
|
|
4853
|
+
*/
|
|
4854
|
+
async function provisionPlatformBackupStorage(context, deps) {
|
|
4855
|
+
const { coolify, r2 } = deps;
|
|
4856
|
+
const recorded = context.recordedIds();
|
|
4857
|
+
await ensurePlatformBackupStorage(coolify, r2, recorded, context);
|
|
4858
|
+
return undefined;
|
|
4859
|
+
}
|
|
4393
4860
|
/**
|
|
4394
4861
|
* #257: schedules a nightly R2 backup of one platform database --
|
|
4395
4862
|
* `planBackups` (`platform-diff.ts`) plans this step once per service, each
|
|
4396
4863
|
* carrying that service's own database id as `action.desired.databaseId`
|
|
4397
4864
|
* (recorded by that service's own `coolify:app` step; never minted here).
|
|
4398
4865
|
* The R2 bucket is registered as a Coolify S3 storage once, by name, and
|
|
4399
|
-
*
|
|
4400
|
-
* every service's own backup step -- and a rerun of this one -- reuses it
|
|
4401
|
-
* rather than registering a second storage entry under the same name.
|
|
4866
|
+
* self-healed on every apply by {@link ensurePlatformBackupStorage} --
|
|
4402
4867
|
* `createDatabaseBackup` itself is the idempotency boundary for the
|
|
4403
4868
|
* schedule: an existing enabled, `save_s3` schedule is left alone rather
|
|
4404
4869
|
* than replaced, since Coolify has no "upsert" for this call and a second
|
|
@@ -4408,26 +4873,7 @@ async function provisionPlatformBackup(action, context, deps) {
|
|
|
4408
4873
|
const { coolify, r2 } = deps;
|
|
4409
4874
|
const databaseId = action.desired.databaseId;
|
|
4410
4875
|
const recorded = context.recordedIds();
|
|
4411
|
-
|
|
4412
|
-
if (!storageUuid) {
|
|
4413
|
-
const found = (await coolify.listS3Storages()).find((storage) => storage.name === PLATFORM_BACKUP_S3_STORAGE_NAME);
|
|
4414
|
-
if (found) {
|
|
4415
|
-
storageUuid = found.uuid;
|
|
4416
|
-
}
|
|
4417
|
-
else {
|
|
4418
|
-
const created = await coolify.createS3Storage({
|
|
4419
|
-
name: PLATFORM_BACKUP_S3_STORAGE_NAME,
|
|
4420
|
-
endpoint: r2.endpoint,
|
|
4421
|
-
bucket: r2.bucket,
|
|
4422
|
-
region: r2.region ?? 'auto',
|
|
4423
|
-
key: r2.accessKeyId,
|
|
4424
|
-
secret: r2.secretAccessKey,
|
|
4425
|
-
is_usable: true,
|
|
4426
|
-
});
|
|
4427
|
-
storageUuid = created.uuid;
|
|
4428
|
-
}
|
|
4429
|
-
context.recordScopeId(PLATFORM_BACKUP_S3_STORAGE_SCOPE_KEY, storageUuid);
|
|
4430
|
-
}
|
|
4876
|
+
const storageUuid = await ensurePlatformBackupStorage(coolify, r2, recorded, context);
|
|
4431
4877
|
const existing = (await coolify.listDatabaseBackups(databaseId)).find((backup) => backup.enabled && backup.save_s3);
|
|
4432
4878
|
if (!existing) {
|
|
4433
4879
|
await coolify.createDatabaseBackup(databaseId, {
|