@wtfalch/foundry 0.9.0 → 0.11.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (44) hide show
  1. package/dist/apply.d.ts +16 -0
  2. package/dist/index.d.ts +4 -3
  3. package/dist/index.js +1 -1
  4. package/dist/migrations/0030_hosting_entry_hostnames.sql +24 -0
  5. package/dist/migrations/0031_adoptions.sql +37 -0
  6. package/dist/org.d.ts +49 -4
  7. package/dist/org.js +134 -1
  8. package/dist/plan/adopt.d.ts +112 -0
  9. package/dist/plan/adopt.js +252 -0
  10. package/dist/plan/adoption-vault.d.ts +9 -0
  11. package/dist/plan/adoption-vault.js +1 -0
  12. package/dist/plan/agora.d.ts +7 -0
  13. package/dist/plan/agora.js +9 -0
  14. package/dist/plan/broker-entries.d.ts +108 -0
  15. package/dist/plan/broker-entries.js +214 -0
  16. package/dist/plan/cloudflared-connector.d.ts +10 -2
  17. package/dist/plan/cloudflared-connector.js +24 -16
  18. package/dist/plan/diff.d.ts +14 -0
  19. package/dist/plan/diff.js +488 -47
  20. package/dist/plan/executor.d.ts +141 -3
  21. package/dist/plan/executor.js +1100 -149
  22. package/dist/plan/hostnames.d.ts +24 -0
  23. package/dist/plan/hostnames.js +56 -0
  24. package/dist/plan/index.d.ts +2 -0
  25. package/dist/plan/index.js +1 -0
  26. package/dist/plan/locked-hostnames.d.ts +8 -0
  27. package/dist/plan/locked-hostnames.js +26 -0
  28. package/dist/plan/login-host.d.ts +1 -1
  29. package/dist/plan/login-host.js +5 -2
  30. package/dist/plan/plan-apply.d.ts +63 -3
  31. package/dist/plan/plan-apply.js +163 -5
  32. package/dist/plan/platform-diff.d.ts +66 -13
  33. package/dist/plan/platform-diff.js +117 -15
  34. package/dist/plan/platform-providers.d.ts +95 -2
  35. package/dist/plan/platform-providers.js +393 -27
  36. package/dist/plan/providers-readers.d.ts +8 -1
  37. package/dist/plan/providers-readers.js +58 -4
  38. package/dist/plan.d.ts +6 -0
  39. package/dist/store/postgres.d.ts +6 -1
  40. package/dist/store/postgres.js +80 -5
  41. package/dist/store/schema.d.ts +162 -0
  42. package/dist/store/schema.js +19 -1
  43. package/dist/store.d.ts +37 -0
  44. package/package.json +1 -1
@@ -248,6 +248,18 @@ export class FoundryPlatformStepExecutor {
248
248
  if (loginClientDrifted)
249
249
  result.auth = true;
250
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
+ }
251
263
  return result;
252
264
  }
253
265
  async execute(action, context) {
@@ -303,6 +315,12 @@ export class FoundryPlatformStepExecutor {
303
315
  }
304
316
  return provisionPlatformBackup(action, context, this.backup);
305
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
+ }
306
324
  if (action.system === 'sfu' && action.kind === 'livekit') {
307
325
  return this.executeSfu();
308
326
  }
@@ -2444,7 +2462,7 @@ function isCanonicalOrigin(value) {
2444
2462
  * malformed entry never reaches a property read on a non-object and throws a
2445
2463
  * `TypeError` instead of this named error.
2446
2464
  */
2447
- function validateBrokerAppEntry(entry, brokerAppId, index) {
2465
+ export function validateBrokerAppEntry(entry, brokerAppId, index) {
2448
2466
  const label = `platform:archon: broker app ${brokerAppId}'s AUTH_APPS[${index}]`;
2449
2467
  if (typeof entry !== 'object' || entry === null || Array.isArray(entry)) {
2450
2468
  throw new Error(`${label} is not an object`);
@@ -3564,7 +3582,7 @@ async function findOrCreatePlatformApp(coolify, name, environment, create) {
3564
3582
  * `CoolifyPort.listEnvs` doc comment already warns a live value does not
3565
3583
  * always match its declared `string` type.
3566
3584
  */
3567
- async function missingBrokerInputs(coolify, brokerAppId) {
3585
+ export async function missingBrokerInputs(coolify, brokerAppId) {
3568
3586
  const envs = await coolify.listEnvs(brokerAppId);
3569
3587
  const value = (key) => envs.find((e) => e.key === key && !e.is_preview && e.value != null && e.value !== '')?.value;
3570
3588
  const has = (key) => value(key) !== undefined;
@@ -3644,7 +3662,7 @@ function hashAuthApps(raw) {
3644
3662
  * left unchanged), so neither caller needs a second read to agree with
3645
3663
  * itself about what is now true of the app.
3646
3664
  */
3647
- function brokerDeployNeeded(missingInputs, brokerAppId, authAppsRaw, recordedDeploy) {
3665
+ export function brokerDeployNeeded(missingInputs, brokerAppId, authAppsRaw, recordedDeploy) {
3648
3666
  if (missingInputs.length > 0)
3649
3667
  return false;
3650
3668
  if (recordedDeploy === null)
@@ -3659,7 +3677,7 @@ function brokerDeployNeeded(missingInputs, brokerAppId, authAppsRaw, recordedDep
3659
3677
  * caller's own `record()`), replacing the plain broker-id string round 2-4
3660
3678
  * wrote there. Paired with {@link brokerDeployNeeded}, which reads it back.
3661
3679
  */
3662
- function brokerDeployRecordValue(brokerAppId, authAppsRaw) {
3680
+ export function brokerDeployRecordValue(brokerAppId, authAppsRaw) {
3663
3681
  const record = { brokerAppId, authAppsHash: hashAuthApps(authAppsRaw) };
3664
3682
  return JSON.stringify(record);
3665
3683
  }
@@ -4468,17 +4486,384 @@ async function waitForIntegrationsMigration(coolify, appId, runtime, sleep) {
4468
4486
  const PLATFORM_BACKUP_S3_STORAGE_NAME = 'platform-backups';
4469
4487
  /** The uuid `provisionPlatformBackup` records once {@link PLATFORM_BACKUP_S3_STORAGE_NAME} is registered on Coolify, so a later apply never re-registers it. */
4470
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';
4471
4514
  /** Coolify's own name for "once a day" -- `createDatabaseBackup`'s `frequency`. */
4472
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
+ }
4473
4860
  /**
4474
4861
  * #257: schedules a nightly R2 backup of one platform database --
4475
4862
  * `planBackups` (`platform-diff.ts`) plans this step once per service, each
4476
4863
  * carrying that service's own database id as `action.desired.databaseId`
4477
4864
  * (recorded by that service's own `coolify:app` step; never minted here).
4478
4865
  * The R2 bucket is registered as a Coolify S3 storage once, by name, and
4479
- * its uuid recorded under {@link PLATFORM_BACKUP_S3_STORAGE_SCOPE_KEY} so
4480
- * every service's own backup step -- and a rerun of this one -- reuses it
4481
- * rather than registering a second storage entry under the same name.
4866
+ * self-healed on every apply by {@link ensurePlatformBackupStorage} --
4482
4867
  * `createDatabaseBackup` itself is the idempotency boundary for the
4483
4868
  * schedule: an existing enabled, `save_s3` schedule is left alone rather
4484
4869
  * than replaced, since Coolify has no "upsert" for this call and a second
@@ -4488,26 +4873,7 @@ async function provisionPlatformBackup(action, context, deps) {
4488
4873
  const { coolify, r2 } = deps;
4489
4874
  const databaseId = action.desired.databaseId;
4490
4875
  const recorded = context.recordedIds();
4491
- let storageUuid = recorded[PLATFORM_BACKUP_S3_STORAGE_SCOPE_KEY];
4492
- if (!storageUuid) {
4493
- const found = (await coolify.listS3Storages()).find((storage) => storage.name === PLATFORM_BACKUP_S3_STORAGE_NAME);
4494
- if (found) {
4495
- storageUuid = found.uuid;
4496
- }
4497
- else {
4498
- const created = await coolify.createS3Storage({
4499
- name: PLATFORM_BACKUP_S3_STORAGE_NAME,
4500
- endpoint: r2.endpoint,
4501
- bucket: r2.bucket,
4502
- region: r2.region ?? 'auto',
4503
- key: r2.accessKeyId,
4504
- secret: r2.secretAccessKey,
4505
- is_usable: true,
4506
- });
4507
- storageUuid = created.uuid;
4508
- }
4509
- context.recordScopeId(PLATFORM_BACKUP_S3_STORAGE_SCOPE_KEY, storageUuid);
4510
- }
4876
+ const storageUuid = await ensurePlatformBackupStorage(coolify, r2, recorded, context);
4511
4877
  const existing = (await coolify.listDatabaseBackups(databaseId)).find((backup) => backup.enabled && backup.save_s3);
4512
4878
  if (!existing) {
4513
4879
  await coolify.createDatabaseBackup(databaseId, {
@@ -3,6 +3,7 @@ import type { CoolifyClient } from '@wtfalch/providers/coolify';
3
3
  import type { ZitadelClient } from '@wtfalch/providers/zitadel';
4
4
  import type { OrgId } from '../org.js';
5
5
  import type { DeclaredOrgStore } from '../store.js';
6
+ import type { AdoptedResource } from './adopt.js';
6
7
  import type { CloudflareReader, CmsReader, CoolifyReader, LiveResource, ZitadelReader } from './live.js';
7
8
  /**
8
9
  * Real `CoolifyReader`, `CloudflareReader` and `ZitadelReader` implementations
@@ -92,7 +93,13 @@ export declare class ProvidersCoolifyReader implements CoolifyReader {
92
93
  private readonly client;
93
94
  private readonly resolveScope;
94
95
  private readonly resolveRecordedIds;
95
- constructor(client: CoolifyClient, resolveScope: ScopeResolver, resolveRecordedIds: RecordedIds);
96
+ private readonly options;
97
+ constructor(client: CoolifyClient, resolveScope: ScopeResolver, resolveRecordedIds: RecordedIds, options?: {
98
+ /** #311: what the org adopts by uuid; each is read directly, not found by project. */
99
+ adopted?: (orgId: OrgId) => Promise<AdoptedResource[]>;
100
+ });
101
+ /** #311: each adopted uuid, read by uuid, under the key the planner matches. An unreadable one is absent. */
102
+ private readAdopted;
96
103
  listResources(orgId: OrgId): Promise<LiveResource[]>;
97
104
  }
98
105
  /**
@@ -204,19 +204,73 @@ export class ProvidersCoolifyReader {
204
204
  client;
205
205
  resolveScope;
206
206
  resolveRecordedIds;
207
- constructor(client, resolveScope, resolveRecordedIds) {
207
+ options;
208
+ constructor(client, resolveScope, resolveRecordedIds, options = {}) {
208
209
  this.client = client;
209
210
  this.resolveScope = resolveScope;
210
211
  this.resolveRecordedIds = resolveRecordedIds;
212
+ this.options = options;
213
+ }
214
+ /** #311: each adopted uuid, read by uuid, under the key the planner matches. An unreadable one is absent. */
215
+ async readAdopted(adopted) {
216
+ const read = await Promise.all(adopted.map(async (a) => {
217
+ try {
218
+ if (a.kind === 'database') {
219
+ const { name, status } = await this.client.getDatabase(a.uuid);
220
+ return {
221
+ resource: {
222
+ kind: 'database',
223
+ key: a.uuid,
224
+ config: { name, status },
225
+ },
226
+ recorded: `database:${a.resourceKey.slice('coolify:database:'.length)}`,
227
+ };
228
+ }
229
+ const app = await this.client.getApplication(a.uuid);
230
+ return {
231
+ resource: {
232
+ kind: 'application',
233
+ key: a.uuid,
234
+ config: {
235
+ name: app.name,
236
+ fqdn: app.fqdn,
237
+ status: app.status,
238
+ git_repository: app.git_repository,
239
+ git_branch: app.git_branch,
240
+ is_auto_deploy_enabled: app.is_auto_deploy_enabled,
241
+ environment_id: app.environment_id,
242
+ },
243
+ },
244
+ recorded: `app:${a.resourceKey.slice('coolify:app:'.length)}`,
245
+ };
246
+ }
247
+ catch {
248
+ return null;
249
+ }
250
+ }));
251
+ return read.filter((r) => r !== null);
211
252
  }
212
253
  async listResources(orgId) {
213
254
  const projectUuid = await this.resolveScope(orgId);
214
- if (!projectUuid)
255
+ // The tunnel and its connector are read by `ProvidersCloudflareReader` and the executor (#312).
256
+ const adopted = ((await this.options.adopted?.(orgId)) ?? []).filter((a) => a.kind === 'app' || a.kind === 'database');
257
+ if (!projectUuid && adopted.length === 0)
215
258
  return [];
216
- const [resources, recorded] = await Promise.all([
217
- this.client.listResources(projectUuid),
259
+ const [listed, recordedById, adoptedLive] = await Promise.all([
260
+ projectUuid ? this.client.listResources(projectUuid) : [],
218
261
  this.resolveRecordedIds(orgId),
262
+ this.readAdopted(adopted),
219
263
  ]);
264
+ // The declared uuid wins over a recorded id and over the project listing.
265
+ const uuids = new Set(adoptedLive.map((a) => a.resource.key));
266
+ const resources = [
267
+ ...listed.filter((r) => !uuids.has(r.key)),
268
+ ...adoptedLive.map((a) => a.resource),
269
+ ];
270
+ const recorded = {
271
+ ...recordedById,
272
+ ...Object.fromEntries(adoptedLive.map((a) => [a.resource.key, a.recorded])),
273
+ };
220
274
  const withLogConfig = await Promise.all(toFoundryResources('coolify', resources).map((r) => withLogVolumeConfig(this.client, r, recorded)));
221
275
  const withEnvNames = await Promise.all(withLogConfig.map((r) => withEnvNamesConfig(this.client, r, recorded)));
222
276
  const withDbUrl = await Promise.all(withEnvNames.map((r) => withDatabaseUrlConfig(this.client, r, recorded)));
package/dist/plan.d.ts CHANGED
@@ -9,6 +9,12 @@ import type { OrgId } from './org.js';
9
9
  */
10
10
  export interface PlanStep {
11
11
  description: string;
12
+ /**
13
+ * Lines that say what the step will change, shown beside the description.
14
+ * `coolify:adopt` (#311) gives one per setting, old value beside new, and
15
+ * env vars by name only. Never a secret.
16
+ */
17
+ details?: string[];
12
18
  /**
13
19
  * True when this step deletes or replaces data the org cannot get back
14
20
  * without foundry's own backup: a chat re-key, an issuer move, a mail
@@ -1,5 +1,5 @@
1
1
  import type { PgDatabase, PgQueryResultHKT } from 'drizzle-orm/pg-core';
2
- import type { DeclaredOrgStore, PlatformStore, RequestedChangeStore } from '../store.js';
2
+ import type { AdoptionStore, DeclaredOrgStore, PlatformStore, RequestedChangeStore } from '../store.js';
3
3
  /**
4
4
  * The host's own drizzle handle or a transaction open on it, same shape
5
5
  * `@wtfalch/keys`'s `HeldKeysDb` uses: postgres-js in a real host, PGlite in
@@ -90,3 +90,8 @@ export declare function createPostgresPlatformStore(db: FoundryStoreDb): Platfor
90
90
  * argument makes -- and stores the result as the request's initial `state`.
91
91
  */
92
92
  export declare function createPostgresRequestedChangeStore(db: FoundryStoreDb): RequestedChangeStore;
93
+ /**
94
+ * `AdoptionStore` (`../store.ts`) on Postgres, for #311: the snapshot row of
95
+ * each live resource foundry takes over, beside the stores above.
96
+ */
97
+ export declare function createPostgresAdoptionStore(db: FoundryStoreDb): AdoptionStore;