@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.
- package/dist/apply.d.ts +16 -0
- package/dist/index.d.ts +4 -3
- package/dist/index.js +1 -1
- package/dist/migrations/0030_hosting_entry_hostnames.sql +24 -0
- package/dist/migrations/0031_adoptions.sql +37 -0
- package/dist/org.d.ts +49 -4
- package/dist/org.js +134 -1
- package/dist/plan/adopt.d.ts +112 -0
- package/dist/plan/adopt.js +252 -0
- package/dist/plan/adoption-vault.d.ts +9 -0
- package/dist/plan/adoption-vault.js +1 -0
- package/dist/plan/agora.d.ts +7 -0
- package/dist/plan/agora.js +9 -0
- package/dist/plan/broker-entries.d.ts +108 -0
- package/dist/plan/broker-entries.js +214 -0
- package/dist/plan/cloudflared-connector.d.ts +10 -2
- package/dist/plan/cloudflared-connector.js +24 -16
- package/dist/plan/diff.d.ts +14 -0
- package/dist/plan/diff.js +488 -47
- package/dist/plan/executor.d.ts +141 -3
- package/dist/plan/executor.js +1100 -149
- package/dist/plan/hostnames.d.ts +24 -0
- package/dist/plan/hostnames.js +56 -0
- package/dist/plan/index.d.ts +2 -0
- package/dist/plan/index.js +1 -0
- 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 +63 -3
- package/dist/plan/plan-apply.js +163 -5
- package/dist/plan/platform-diff.d.ts +66 -13
- package/dist/plan/platform-diff.js +117 -15
- package/dist/plan/platform-providers.d.ts +95 -2
- package/dist/plan/platform-providers.js +393 -27
- package/dist/plan/providers-readers.d.ts +8 -1
- package/dist/plan/providers-readers.js +58 -4
- package/dist/plan.d.ts +6 -0
- package/dist/store/postgres.d.ts +6 -1
- package/dist/store/postgres.js +80 -5
- package/dist/store/schema.d.ts +162 -0
- package/dist/store/schema.js +19 -1
- package/dist/store.d.ts +37 -0
- 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
|
-
*
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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 [
|
|
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
|
package/dist/store/postgres.d.ts
CHANGED
|
@@ -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;
|