@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.
@@ -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
- const adminPassword = await ensureGeneratedEnv(deps.coolify, zitadelAppId, 'ZITADEL_ADMIN_PASSWORD', generateAdminPassword);
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: 'ZITADEL_FIRSTINSTANCE_ORG_HUMAN_USERNAME', value: 'zitadel-admin' },
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 (adminPassword)
925
- zitadelEnvs.push({ key: 'ZITADEL_ADMIN_PASSWORD', value: adminPassword });
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
- * its uuid recorded under {@link PLATFORM_BACKUP_S3_STORAGE_SCOPE_KEY} so
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
- let storageUuid = recorded[PLATFORM_BACKUP_S3_STORAGE_SCOPE_KEY];
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, {