@wtfalch/foundry 0.7.1 → 0.8.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 (43) hide show
  1. package/dist/bootstrap/bootstrap.d.ts +12 -8
  2. package/dist/bootstrap/bootstrap.js +4 -12
  3. package/dist/bootstrap/versions.d.ts +8 -0
  4. package/dist/bootstrap/versions.js +31 -0
  5. package/dist/calls-sfu-deploy-support.d.ts +1 -1
  6. package/dist/calls-sfu-deploy-support.js +1 -1
  7. package/dist/calls-sfu-ssh-adapter.d.ts +1 -1
  8. package/dist/calls-sfu-ssh-adapter.js +1 -1
  9. package/dist/calls-sfu.d.ts +5 -4
  10. package/dist/calls-sfu.js +5 -4
  11. package/dist/index.d.ts +1 -2
  12. package/dist/index.js +4 -1
  13. package/dist/migrations/0024_audit_events.sql +1 -1
  14. package/dist/migrations/0025_audit_display.sql +1 -1
  15. package/dist/migrations/0026_audit_erase_email_guard.sql +1 -1
  16. package/dist/migrations/0027_audit_chain.sql +1 -1
  17. package/dist/migrations/0028_audit_rls.sql +2 -2
  18. package/dist/migrations-dir.d.ts +9 -0
  19. package/dist/migrations-dir.js +10 -0
  20. package/dist/plan/calls-matrix.d.ts +2 -0
  21. package/dist/plan/calls-matrix.js +2 -0
  22. package/dist/plan/diff.d.ts +3 -1
  23. package/dist/plan/diff.js +76 -14
  24. package/dist/plan/executor.d.ts +305 -16
  25. package/dist/plan/executor.js +606 -9
  26. package/dist/plan/login-host.d.ts +24 -0
  27. package/dist/plan/login-host.js +21 -0
  28. package/dist/plan/plan-apply.d.ts +2 -0
  29. package/dist/plan/plan-apply.js +20 -1
  30. package/dist/plan/platform-diff.d.ts +79 -1
  31. package/dist/plan/platform-diff.js +197 -1
  32. package/dist/plan/platform-providers.d.ts +36 -1
  33. package/dist/plan/platform-providers.js +153 -21
  34. package/dist/plan.d.ts +43 -0
  35. package/dist/plan.js +16 -1
  36. package/dist/store/audit.d.ts +13 -17
  37. package/dist/store/audit.js +14 -18
  38. package/dist/store/postgres.d.ts +14 -0
  39. package/dist/store/postgres.js +13 -8
  40. package/package.json +6 -3
  41. package/versions.json +1 -1
  42. package/dist/migrate.d.ts +0 -38
  43. package/dist/migrate.js +0 -47
@@ -49,6 +49,14 @@ export interface LoginHostBinding {
49
49
  */
50
50
  export interface LoginHostBindingsPort {
51
51
  upsertBinding(binding: LoginHostBinding): Promise<void>;
52
+ /**
53
+ * #274: removes a host's entry from zitadel-login's own `LOGIN_HOST_BINDINGS`
54
+ * -- the old host's binding, once a host change or an org destroy has
55
+ * moved or removed what it pointed at. Treat "not found" as done, the same
56
+ * rule `executor.ts`'s `isNotFoundError` gives every other removal in this
57
+ * package.
58
+ */
59
+ deleteBinding(host: string): Promise<void>;
52
60
  }
53
61
  /**
54
62
  * Scope id holding what the binding was last written with
@@ -62,3 +70,19 @@ export declare const LOGIN_BINDING_SCOPE_KEY = "login_host_binding";
62
70
  export declare function loginHostClientIds(recorded: Readonly<Record<string, string>>): string[];
63
71
  /** The host plus its sorted client ids, as recorded under {@link LOGIN_BINDING_SCOPE_KEY}. */
64
72
  export declare function loginBindingState(host: string, clientIds: readonly string[]): string;
73
+ /**
74
+ * #274 fix round 2: hosts a company-login-host change (or an org destroy)
75
+ * tried to remove and could not yet -- some live app's own ZITADEL Login V2
76
+ * base URI still pointed at one of them, so removing it would have broken
77
+ * that app's sign-in. `zitadelLoginHost` always advances
78
+ * `zitadel:login-host:<key>` to the org's *current* desired host on every
79
+ * successful run (never leaving it on an old one), so this is the only
80
+ * record of a removal still owed -- lost otherwise the moment a second host
81
+ * change moves `previousHost` on. Comma-joined, sorted, the same shape
82
+ * `MAIL_ROLE_MAILBOXES_SCOPE_KEY` (`executor.ts`) holds a list in.
83
+ */
84
+ export declare const LOGIN_HOST_PENDING_REMOVAL_SCOPE_KEY = "zitadel_login_host_pending_removal";
85
+ /** The hosts {@link LOGIN_HOST_PENDING_REMOVAL_SCOPE_KEY} currently names, parsed back out. */
86
+ export declare function pendingRemovalHosts(recorded: Readonly<Record<string, string>>): string[];
87
+ /** {@link pendingRemovalHosts}' own serialisation, sorted so the recorded value is stable regardless of check order. */
88
+ export declare function pendingRemovalState(hosts: readonly string[]): string;
@@ -50,3 +50,24 @@ export function loginHostClientIds(recorded) {
50
50
  export function loginBindingState(host, clientIds) {
51
51
  return `${host} ${clientIds.join(',')}`;
52
52
  }
53
+ /**
54
+ * #274 fix round 2: hosts a company-login-host change (or an org destroy)
55
+ * tried to remove and could not yet -- some live app's own ZITADEL Login V2
56
+ * base URI still pointed at one of them, so removing it would have broken
57
+ * that app's sign-in. `zitadelLoginHost` always advances
58
+ * `zitadel:login-host:<key>` to the org's *current* desired host on every
59
+ * successful run (never leaving it on an old one), so this is the only
60
+ * record of a removal still owed -- lost otherwise the moment a second host
61
+ * change moves `previousHost` on. Comma-joined, sorted, the same shape
62
+ * `MAIL_ROLE_MAILBOXES_SCOPE_KEY` (`executor.ts`) holds a list in.
63
+ */
64
+ export const LOGIN_HOST_PENDING_REMOVAL_SCOPE_KEY = 'zitadel_login_host_pending_removal';
65
+ /** The hosts {@link LOGIN_HOST_PENDING_REMOVAL_SCOPE_KEY} currently names, parsed back out. */
66
+ export function pendingRemovalHosts(recorded) {
67
+ const raw = recorded[LOGIN_HOST_PENDING_REMOVAL_SCOPE_KEY];
68
+ return raw ? raw.split(',').filter((host) => host.length > 0) : [];
69
+ }
70
+ /** {@link pendingRemovalHosts}' own serialisation, sorted so the recorded value is stable regardless of check order. */
71
+ export function pendingRemovalState(hosts) {
72
+ return [...hosts].sort().join(',');
73
+ }
@@ -192,6 +192,8 @@ export interface PlatformStepExecutor {
192
192
  ai?: string;
193
193
  archon?: string;
194
194
  mail?: string;
195
+ /** #257: see `PlanPlatformOptions.blocked.backupExecutorMissing` (`platform-diff.ts`). */
196
+ backupExecutorMissing?: boolean;
195
197
  }>;
196
198
  /**
197
199
  * Steps whose live state differs from the declared one in a way the recorded
@@ -1,4 +1,5 @@
1
1
  import { createHash } from 'node:crypto';
2
+ import { VERSIONS, floatingTagBlocked } from '../bootstrap/versions.js';
2
3
  import { NO_AGORA_HOST_VALUES, readAgoraHost, } from './agora.js';
3
4
  import { AI_KEY_UNDELIVERED_SCOPE_KEY, JOBS_REGISTRATION_UNDELIVERED_SCOPE_KEY, computePlanDetail, toPlan, } from './diff.js';
4
5
  import { readLivePlatformResources, readLiveResources, } from './live.js';
@@ -428,6 +429,15 @@ export class FoundryPlanApply {
428
429
  async sfuBlockedReason(platform) {
429
430
  if (!platform.sfu.enabled)
430
431
  return null;
432
+ // #248: both images the SFU compose actually runs, checked before the
433
+ // connection -- a floating tag is a reason to block independent of
434
+ // whether a connection is held.
435
+ const livekitBlocked = floatingTagBlocked('LiveKit', VERSIONS.livekit, 'v1.13.7');
436
+ if (livekitBlocked)
437
+ return `the platform SFU is blocked, not planned: ${livekitBlocked}`;
438
+ const cloudflaredBlocked = floatingTagBlocked('cloudflared', VERSIONS.cloudflared, '2026.9.3');
439
+ if (cloudflaredBlocked)
440
+ return `the platform SFU is blocked, not planned: ${cloudflaredBlocked}`;
431
441
  const port = this.requirePlatformDeps().sfuConnection;
432
442
  if (!port) {
433
443
  return 'the platform SFU is blocked, not planned: the host supplies no SFU connection port (#170)';
@@ -512,7 +522,10 @@ const ZITADEL_ORG_SCOPE_KEY = 'zitadel_org';
512
522
  /**
513
523
  * Every resource `FoundryPlanApply.destroy` tears down, built from
514
524
  * `AppliedState.serviceIds`, in the order the goal line and this campaign's
515
- * report settle on: the Cloudflare tunnel first, then the Access gate (stop routing traffic to the
525
+ * report settle on: the org's own company login host first (#274 -- an
526
+ * instance-wide ZITADEL trust and a zitadel-login binding, neither depending
527
+ * on anything else this org owns, so nothing is lost by going first), then
528
+ * the Cloudflare tunnel, then the Access gate (stop routing traffic to the
516
529
  * app before tearing it down), then the org's own Coolify database and app,
517
530
  * then the org's customers ZITADEL org (plan gap A9), then every ZITADEL app
518
531
  * foundry recorded, then the ZITADEL project, then the
@@ -541,6 +554,7 @@ function destroyTargets(serviceIds) {
541
554
  // collected in this order, then every ZITADEL app (there can be several,
542
555
  // #71), then the two scope ids last.
543
556
  const ORDERED_PREFIXES = [
557
+ 'zitadel:login-host:',
544
558
  'cloudflare:tunnel:',
545
559
  'cloudflare:access:',
546
560
  'coolify:database:',
@@ -689,6 +703,11 @@ function canonical(plan) {
689
703
  orgId: plan.orgId,
690
704
  steps: [...plan.steps].sort((x, y) => x.description.localeCompare(y.description)),
691
705
  unmanaged: [...plan.unmanaged].sort((x, y) => x.description.localeCompare(y.description)),
706
+ // #251: display-only, but still part of what a plan says -- live state
707
+ // moving between `plan()` and `apply()` in a way that only changes a
708
+ // version-change line must still be caught as stale, the same as any
709
+ // other field here.
710
+ versionChanges: [...(plan.versionChanges ?? [])].sort((x, y) => x.description.localeCompare(y.description)),
692
711
  };
693
712
  }
694
713
  /**
@@ -1,5 +1,5 @@
1
1
  import type { DeclaredOrg, OrgId } from '../org.js';
2
- import type { Plan, UnmanagedItem } from '../plan.js';
2
+ import { type Plan, type UnmanagedItem, type VersionChangeItem } from '../plan.js';
3
3
  import type { DeclaredPlatform } from '../platform.js';
4
4
  import type { PlannedStep } from './diff.js';
5
5
  import type { LiveResource } from './live.js';
@@ -14,6 +14,8 @@ import type { LiveResource } from './live.js';
14
14
  export interface PlatformPlanDetail {
15
15
  plannedSteps: PlannedStep[];
16
16
  unmanaged: UnmanagedItem[];
17
+ /** #251: see {@link VersionChangeItem}. Never folded into `plannedSteps`. */
18
+ versionChanges: VersionChangeItem[];
17
19
  }
18
20
  /**
19
21
  * The `Plan` (#19's public contract) a {@link PlatformPlanDetail} carries,
@@ -33,6 +35,47 @@ export declare function toPlatformPlan(detail: PlatformPlanDetail, ownerOrgId: O
33
35
  * an org's own (which is keyed by that org's id).
34
36
  */
35
37
  export declare const PLATFORM_AUTH_KEY = "platform:auth";
38
+ /**
39
+ * #251: the ZITADEL image tag the last successful auth run actually set
40
+ * live. `provisionPlatformAuth` records this only when it set the tag --
41
+ * today that is creation alone, `findOrCreatePlatformApp`'s own
42
+ * `justCreated`, since nothing yet patches `docker_registry_image_tag` on
43
+ * an app found already running (unlike mail, which does). Recording it on
44
+ * every run regardless, the way the app id itself is recorded, would make
45
+ * {@link versionNotAppliedLine} compare the pinned version against a value
46
+ * that was never actually applied.
47
+ */
48
+ export declare const PLATFORM_AUTH_ZITADEL_APPLIED_IMAGE_TAG_SCOPE_KEY = "auth_zitadel_applied_image_tag";
49
+ /**
50
+ * #251: the Synapse/MAS image tags an org's calls Matrix stack last
51
+ * successful apply actually set live. `coolifyCalls` (`executor.ts`)
52
+ * records these only at creation, for the same reason
53
+ * {@link PLATFORM_AUTH_ZITADEL_APPLIED_IMAGE_TAG_SCOPE_KEY} is: nothing yet
54
+ * patches either tag on an app `findOrCreateCallsApp` found already
55
+ * running.
56
+ */
57
+ export declare const CALLS_SYNAPSE_APPLIED_IMAGE_TAG_SCOPE_KEY = "calls_synapse_applied_image_tag";
58
+ export declare const CALLS_MAS_APPLIED_IMAGE_TAG_SCOPE_KEY = "calls_mas_applied_image_tag";
59
+ /**
60
+ * One line for a service whose own step genuinely redeploys when its
61
+ * recorded applied ref differs from the declared or pinned one (mail,
62
+ * archon, ai, files, integrations) -- `from` undefined (nothing applied
63
+ * yet, a first-ever apply) or already equal to `to` means nothing to
64
+ * report.
65
+ */
66
+ export declare function versionChangeLine(service: string, from: string | undefined, to: string, note: string): VersionChangeItem | null;
67
+ /**
68
+ * One line for a service whose own step never patches a running app's
69
+ * image tag once created -- zitadel, synapse, mas. #251 round 3: a prior
70
+ * attempt worded this line the same as {@link versionChangeLine}'s
71
+ * "X -> Y (image, redeploy)", which promised a redeploy `apply` can never
72
+ * actually perform for these three. `appLive` false means nothing live yet
73
+ * to compare against (no line, the same first-apply case
74
+ * {@link versionChangeLine} has); a live app with no recorded `applied`
75
+ * value predates this tracking and is reported as `running tag unknown`
76
+ * rather than silently showing nothing.
77
+ */
78
+ export declare function versionNotAppliedLine(service: string, appLive: boolean, applied: string | undefined, pinned: string): VersionChangeItem | null;
36
79
  /**
37
80
  * Scope keys (colon-free, so never mistaken for a `system:kind:key` resource
38
81
  * id) for foundry's ZITADEL admin machine user. The delivered key is the guard
@@ -441,6 +484,13 @@ export interface PlanPlatformOptions {
441
484
  ai?: string;
442
485
  archon?: string;
443
486
  mail?: string;
487
+ /**
488
+ * #257: true once a service's database exists but the host built the
489
+ * platform executor with no backup dependencies -- every such service's
490
+ * `coolify:backup:<service>` step is blocked, not planned, the same
491
+ * "executor missing" shape {@link authExecutorMissing} has for auth.
492
+ */
493
+ backupExecutorMissing?: boolean;
444
494
  };
445
495
  /**
446
496
  * Steps whose live state differs from the declared one in a way the recorded
@@ -486,6 +536,34 @@ export declare function planPlatform(platform: DeclaredPlatform, live: LiveResou
486
536
  * it, so the planner and the executor/reader can never key it apart).
487
537
  */
488
538
  export declare const JOBS_SCHEDULER_KEY = "jobs";
539
+ /**
540
+ * #257: the six platform databases foundry itself creates, each keyed by
541
+ * the scope id `platform-providers.ts`'s own `createPostgresDatabase` call
542
+ * site records once that service's database exists --
543
+ * `JOBS_DATABASE_SCOPE_KEY`, `PLATFORM_AUTH_DB_SCOPE_KEY`,
544
+ * `PLATFORM_ARCHON_DB_SCOPE_KEY`, `PLATFORM_AI_DB_SCOPE_KEY`,
545
+ * `PLATFORM_FILES_DB_SCOPE_KEY`, `INTEGRATIONS_DATABASE_SCOPE_KEY`. Those
546
+ * constants stay module-private in `platform-providers.ts`, grouped with
547
+ * each service's other scope keys; the string values here are a second,
548
+ * documented copy rather than an import, so `planBackups` below never
549
+ * reaches into that file and `platform-providers.ts` keeps importing *from*
550
+ * this one, never the other way (every other scope key constant already
551
+ * flows that direction). `platform-providers.test.ts`'s own
552
+ * `coolify:backup` suite exercises both the executor's real constants and
553
+ * this planner end to end, so a renamed key on either side shows up as a
554
+ * database the planner can no longer see -- reported blocked, per rule 2,
555
+ * never a wrong step.
556
+ */
557
+ export declare const PLATFORM_BACKUP_DATABASE_SCOPE_KEYS: {
558
+ readonly jobs: "coolify_jobs_database";
559
+ readonly auth: "coolify_platform_auth_database";
560
+ readonly archon: "coolify_platform_archon_database";
561
+ readonly ai: "coolify_platform_ai_database";
562
+ readonly files: "coolify_platform_files_database";
563
+ readonly integrations: "coolify_integrations_database";
564
+ };
565
+ export type PlatformBackupServiceKey = keyof typeof PLATFORM_BACKUP_DATABASE_SCOPE_KEYS;
566
+ export declare const PLATFORM_BACKUP_SERVICE_KEYS: readonly PlatformBackupServiceKey[];
489
567
  /**
490
568
  * #174: the resource key the integrations host is planned and recorded under,
491
569
  * `platform:integrations`, like {@link PLATFORM_AUTH_KEY}: the step is
@@ -1,3 +1,5 @@
1
+ import { VERSIONS } from '../bootstrap/versions.js';
2
+ import { VERSION_CHANGE_KIND, } from '../plan.js';
1
3
  /**
2
4
  * The `Plan` (#19's public contract) a {@link PlatformPlanDetail} carries,
3
5
  * mirroring `diff.ts`'s `toPlan`. There is no separate platform-shaped `Plan`
@@ -12,6 +14,7 @@ export function toPlatformPlan(detail, ownerOrgId) {
12
14
  orgId: ownerOrgId,
13
15
  steps: detail.plannedSteps.map((p) => p.step),
14
16
  unmanaged: detail.unmanaged,
17
+ versionChanges: detail.versionChanges,
15
18
  };
16
19
  }
17
20
  function resourceKey(system, kind, key) {
@@ -25,6 +28,72 @@ function resourceKey(system, kind, key) {
25
28
  * an org's own (which is keyed by that org's id).
26
29
  */
27
30
  export const PLATFORM_AUTH_KEY = 'platform:auth';
31
+ /**
32
+ * #251: the ZITADEL image tag the last successful auth run actually set
33
+ * live. `provisionPlatformAuth` records this only when it set the tag --
34
+ * today that is creation alone, `findOrCreatePlatformApp`'s own
35
+ * `justCreated`, since nothing yet patches `docker_registry_image_tag` on
36
+ * an app found already running (unlike mail, which does). Recording it on
37
+ * every run regardless, the way the app id itself is recorded, would make
38
+ * {@link versionNotAppliedLine} compare the pinned version against a value
39
+ * that was never actually applied.
40
+ */
41
+ export const PLATFORM_AUTH_ZITADEL_APPLIED_IMAGE_TAG_SCOPE_KEY = 'auth_zitadel_applied_image_tag';
42
+ /**
43
+ * #251: the Synapse/MAS image tags an org's calls Matrix stack last
44
+ * successful apply actually set live. `coolifyCalls` (`executor.ts`)
45
+ * records these only at creation, for the same reason
46
+ * {@link PLATFORM_AUTH_ZITADEL_APPLIED_IMAGE_TAG_SCOPE_KEY} is: nothing yet
47
+ * patches either tag on an app `findOrCreateCallsApp` found already
48
+ * running.
49
+ */
50
+ export const CALLS_SYNAPSE_APPLIED_IMAGE_TAG_SCOPE_KEY = 'calls_synapse_applied_image_tag';
51
+ export const CALLS_MAS_APPLIED_IMAGE_TAG_SCOPE_KEY = 'calls_mas_applied_image_tag';
52
+ /**
53
+ * One line for a service whose own step genuinely redeploys when its
54
+ * recorded applied ref differs from the declared or pinned one (mail,
55
+ * archon, ai, files, integrations) -- `from` undefined (nothing applied
56
+ * yet, a first-ever apply) or already equal to `to` means nothing to
57
+ * report.
58
+ */
59
+ export function versionChangeLine(service, from, to, note) {
60
+ if (!from || from === to)
61
+ return null;
62
+ return {
63
+ kind: VERSION_CHANGE_KIND,
64
+ service,
65
+ description: `${service}: ${from} -> ${to} (${note})`,
66
+ };
67
+ }
68
+ /**
69
+ * One line for a service whose own step never patches a running app's
70
+ * image tag once created -- zitadel, synapse, mas. #251 round 3: a prior
71
+ * attempt worded this line the same as {@link versionChangeLine}'s
72
+ * "X -> Y (image, redeploy)", which promised a redeploy `apply` can never
73
+ * actually perform for these three. `appLive` false means nothing live yet
74
+ * to compare against (no line, the same first-apply case
75
+ * {@link versionChangeLine} has); a live app with no recorded `applied`
76
+ * value predates this tracking and is reported as `running tag unknown`
77
+ * rather than silently showing nothing.
78
+ */
79
+ export function versionNotAppliedLine(service, appLive, applied, pinned) {
80
+ if (!appLive)
81
+ return null;
82
+ if (!applied) {
83
+ return {
84
+ kind: VERSION_CHANGE_KIND,
85
+ service,
86
+ description: `${service}: pinned ${pinned}, running tag unknown`,
87
+ };
88
+ }
89
+ if (applied === pinned)
90
+ return null;
91
+ return {
92
+ kind: VERSION_CHANGE_KIND,
93
+ service,
94
+ description: `${service}: pinned ${pinned}, running ${applied} (not applied by foundry)`,
95
+ };
96
+ }
28
97
  /**
29
98
  * Scope keys (colon-free, so never mistaken for a `system:kind:key` resource
30
99
  * id) for foundry's ZITADEL admin machine user. The delivered key is the guard
@@ -490,6 +559,40 @@ export function planPlatform(platform, live, options = {}) {
490
559
  const archon = planArchon(platform, recordedIds, blocked.archon, authReady, drifted.archon);
491
560
  if (archon.step)
492
561
  plannedSteps.push(archon.step);
562
+ // #257: after every service above, so a database any of them just
563
+ // recorded is already visible to `recordedIds` on this same plan.
564
+ const backups = planBackups(byKey, claimed, recordedIds, blocked.backupExecutorMissing ?? false);
565
+ plannedSteps.push(...backups.steps);
566
+ // #251: one line per service whose recorded applied ref no longer matches
567
+ // what the next apply would use (or, for zitadel, what is actually live).
568
+ // Never pushed into `plannedSteps` -- see `VersionChangeItem`'s own doc
569
+ // comment (`plan.ts`) for why. Withheld exactly where that service's own
570
+ // step is blocked: advertising a version it cannot run yet, or cannot be
571
+ // read for, is premature, not informational.
572
+ const versionChanges = [];
573
+ for (const change of [
574
+ mail.blocked
575
+ ? null
576
+ : versionChangeLine('mail', recordedIds[PLATFORM_MAIL_APPLIED_GIT_REF_SCOPE_KEY], platform.mail.gitRef, 'image, redeploy'),
577
+ archon.blocked
578
+ ? null
579
+ : versionChangeLine('archon', recordedIds[PLATFORM_ARCHON_APPLIED_GIT_REF_SCOPE_KEY], platform.archon.gitRef, 'git ref, redeploy'),
580
+ ai.blocked
581
+ ? null
582
+ : versionChangeLine('ai', recordedIds[PLATFORM_AI_APPLIED_GITREF_SCOPE_KEY], platform.ai.gitRef, 'git ref, redeploy'),
583
+ files.blocked
584
+ ? null
585
+ : versionChangeLine('files', recordedIds[PLATFORM_FILES_APPLIED_GIT_REF_SCOPE_KEY], platform.files.gitRef, 'git ref, redeploy'),
586
+ integrations.blocked
587
+ ? null
588
+ : versionChangeLine('integrations', recordedIds[PLATFORM_INTEGRATIONS_APPLIED_GITREF_SCOPE_KEY], platform.integrations.gitRef, 'git ref, redeploy'),
589
+ auth.blocked
590
+ ? null
591
+ : versionNotAppliedLine('zitadel', Boolean(recordedIds[resourceKey('coolify', 'app', PLATFORM_AUTH_KEY)]), recordedIds[PLATFORM_AUTH_ZITADEL_APPLIED_IMAGE_TAG_SCOPE_KEY], VERSIONS.zitadel),
592
+ ]) {
593
+ if (change)
594
+ versionChanges.push(change);
595
+ }
493
596
  const unmanaged = live
494
597
  .filter((r) => !claimed.has(resourceKey(r.system, r.kind, r.key)))
495
598
  .map((r) => ({
@@ -516,7 +619,8 @@ export function planPlatform(platform, live, options = {}) {
516
619
  unmanaged.push({ description: ai.blocked, key: 'ai' });
517
620
  if (archon.blocked)
518
621
  unmanaged.push({ description: archon.blocked, key: 'archon' });
519
- return { plannedSteps, unmanaged };
622
+ unmanaged.push(...backups.blocked);
623
+ return { plannedSteps, unmanaged, versionChanges };
520
624
  }
521
625
  /**
522
626
  * The platform's own Cloudflare zone for its shared apex (`platform.ts`'s
@@ -610,6 +714,98 @@ function planJobs(platform, byKey, claimed, observable) {
610
714
  },
611
715
  };
612
716
  }
717
+ /**
718
+ * #257: the six platform databases foundry itself creates, each keyed by
719
+ * the scope id `platform-providers.ts`'s own `createPostgresDatabase` call
720
+ * site records once that service's database exists --
721
+ * `JOBS_DATABASE_SCOPE_KEY`, `PLATFORM_AUTH_DB_SCOPE_KEY`,
722
+ * `PLATFORM_ARCHON_DB_SCOPE_KEY`, `PLATFORM_AI_DB_SCOPE_KEY`,
723
+ * `PLATFORM_FILES_DB_SCOPE_KEY`, `INTEGRATIONS_DATABASE_SCOPE_KEY`. Those
724
+ * constants stay module-private in `platform-providers.ts`, grouped with
725
+ * each service's other scope keys; the string values here are a second,
726
+ * documented copy rather than an import, so `planBackups` below never
727
+ * reaches into that file and `platform-providers.ts` keeps importing *from*
728
+ * this one, never the other way (every other scope key constant already
729
+ * flows that direction). `platform-providers.test.ts`'s own
730
+ * `coolify:backup` suite exercises both the executor's real constants and
731
+ * this planner end to end, so a renamed key on either side shows up as a
732
+ * database the planner can no longer see -- reported blocked, per rule 2,
733
+ * never a wrong step.
734
+ */
735
+ export const PLATFORM_BACKUP_DATABASE_SCOPE_KEYS = {
736
+ jobs: 'coolify_jobs_database',
737
+ auth: 'coolify_platform_auth_database',
738
+ archon: 'coolify_platform_archon_database',
739
+ ai: 'coolify_platform_ai_database',
740
+ files: 'coolify_platform_files_database',
741
+ integrations: 'coolify_integrations_database',
742
+ };
743
+ export const PLATFORM_BACKUP_SERVICE_KEYS = [
744
+ 'jobs',
745
+ 'auth',
746
+ 'archon',
747
+ 'ai',
748
+ 'files',
749
+ 'integrations',
750
+ ];
751
+ /**
752
+ * #257: a nightly backup for each platform database foundry itself creates,
753
+ * one `coolify:backup:<service>` step per service -- unconditional, the
754
+ * same "no enabled toggle, always wanted" shape {@link planApexZone} has,
755
+ * since a database that exists always wants a backup whether or not its own
756
+ * service is currently `enabled` (platform-steps rule 2, this repo's
757
+ * CLAUDE.md: blocked, not failing). A service with no database recorded yet
758
+ * has nothing to back up, so it is reported blocked rather than planned --
759
+ * the same way `planJobs` blocks rather than plans with no Coolify reader --
760
+ * and so is every service once a database exists but `executorMissing` says
761
+ * the host built the platform executor without backup dependencies (the
762
+ * R2/S3 credentials `FoundryPlatformStepExecutorOptions.backup` carries).
763
+ * Once a step's own `coolify:backup:<service>` resource is live, it is
764
+ * claimed and never re-planned: like every other provision-only step here,
765
+ * there is no park branch for a backup already scheduled.
766
+ */
767
+ function planBackups(byKey, claimed, recordedIds, executorMissing) {
768
+ const steps = [];
769
+ const blocked = [];
770
+ for (const service of PLATFORM_BACKUP_SERVICE_KEYS) {
771
+ const key = resourceKey('coolify', 'backup', service);
772
+ const live = byKey.get(key);
773
+ if (live) {
774
+ claimed.add(key);
775
+ continue;
776
+ }
777
+ // No database yet is not reported: unlike a Coolify reader or a backup
778
+ // executor missing (below), there is nothing here for a host to fix --
779
+ // the database arrives on its own once that service's own step is
780
+ // enabled and applied, and this step simply follows next time. Reporting
781
+ // it would make every platform with a service not yet applied carry a
782
+ // standing "blocked" note nobody can act on.
783
+ const databaseId = recordedIds[PLATFORM_BACKUP_DATABASE_SCOPE_KEYS[service]];
784
+ if (!databaseId)
785
+ continue;
786
+ if (executorMissing) {
787
+ blocked.push({
788
+ description: `platform:backup:${service} is blocked, not planned: the host built the platform executor without its backup dependencies`,
789
+ key: `backup:${service}`,
790
+ });
791
+ continue;
792
+ }
793
+ steps.push({
794
+ action: {
795
+ system: 'coolify',
796
+ kind: 'backup',
797
+ key: service,
798
+ desired: { databaseId },
799
+ },
800
+ step: {
801
+ description: `schedule a nightly R2 backup of the platform's ${service} database`,
802
+ destroys: false,
803
+ migrates: false,
804
+ },
805
+ });
806
+ }
807
+ return { steps, blocked };
808
+ }
613
809
  /**
614
810
  * The platform's shared calls SFU (#170), keyed `sfu:livekit:platform` --
615
811
  * there is exactly one, so `key` names the platform itself rather than a
@@ -102,6 +102,13 @@ export interface FoundryPlatformStepExecutorOptions {
102
102
  * -- optional for the same reason as {@link auth}.
103
103
  */
104
104
  archon?: PlatformArchonDeps;
105
+ /**
106
+ * #257: dependencies for every `coolify:backup:<service>` step -- optional
107
+ * for the same reason as `auth`; a service whose database already exists
108
+ * without this set has its backup step reported blocked rather than
109
+ * planned.
110
+ */
111
+ backup?: PlatformBackupDeps;
105
112
  }
106
113
  /**
107
114
  * The real {@link PlatformStepExecutor} for the two steps built so far:
@@ -159,6 +166,10 @@ export interface FoundryPlatformStepExecutorOptions {
159
166
  * #175 adds `coolify:app` keyed {@link PLATFORM_ARCHON_KEY}, Archon itself
160
167
  * ({@link provisionPlatformArchon}), once a host passes
161
168
  * {@link PlatformArchonDeps}.
169
+ *
170
+ * #257 adds `coolify:backup:<service>`, a nightly R2 backup of each
171
+ * platform database ({@link provisionPlatformBackup}), once a host passes
172
+ * {@link PlatformBackupDeps}.
162
173
  */
163
174
  export declare class FoundryPlatformStepExecutor implements PlatformStepExecutor {
164
175
  private readonly cloudflare;
@@ -172,6 +183,7 @@ export declare class FoundryPlatformStepExecutor implements PlatformStepExecutor
172
183
  private readonly files?;
173
184
  private readonly ai?;
174
185
  private readonly archon?;
186
+ private readonly backup?;
175
187
  constructor(cloudflare: Pick<CloudflarePort, 'findZoneByApex' | 'createZone'>, declaredOrgStore: Pick<DeclaredOrgStore, 'findOrgIdForZone' | 'list'>, options?: FoundryPlatformStepExecutorOptions);
176
188
  blockedSteps(platform: DeclaredPlatform, recordedIds?: Readonly<Record<string, string>>): Promise<{
177
189
  auth?: string;
@@ -182,6 +194,7 @@ export declare class FoundryPlatformStepExecutor implements PlatformStepExecutor
182
194
  ai?: string;
183
195
  archon?: string;
184
196
  mail?: string;
197
+ backupExecutorMissing?: boolean;
185
198
  }>;
186
199
  /**
187
200
  * Whether Archon's live Access policy lets through anyone but the declared
@@ -580,6 +593,28 @@ export declare const INTEGRATIONS_MANAGEMENT_ISSUER = "foundry";
580
593
  * its signed request; fixed, since there is one key until rotation is built.
581
594
  */
582
595
  export declare const INTEGRATIONS_MANAGEMENT_KEY_ID = "foundry-1";
596
+ /**
597
+ * #257: everything {@link provisionPlatformBackup} needs beyond
598
+ * {@link PlatformStepExecutionContext} -- the narrow Coolify slice for the
599
+ * backup calls themselves, and the R2 (or any S3-compatible) bucket every
600
+ * platform database's dump lands in. A person creates or confirms the
601
+ * bucket and these credentials by hand (the issue's own "Human steps"); this
602
+ * package only ever registers the bucket with Coolify and schedules the
603
+ * dump, never creates or reads the bucket itself.
604
+ */
605
+ export interface PlatformBackupDeps {
606
+ coolify: Pick<CoolifyPort, 'listS3Storages' | 'createS3Storage' | 'listDatabaseBackups' | 'createDatabaseBackup'>;
607
+ r2: {
608
+ endpoint: string;
609
+ bucket: string;
610
+ accessKeyId: string;
611
+ secretAccessKey: string;
612
+ /** R2 takes `'auto'` (`CoolifyPort.createS3Storage`'s own doc comment). Default `'auto'`. */
613
+ region?: string;
614
+ };
615
+ /** A cron string or one of Coolify's names (`daily`, `weekly`, ...). Default `'daily'`. */
616
+ frequency?: string;
617
+ }
583
618
  /**
584
619
  * The real {@link PlatformCloudflareReader}: the platform's apex zone as
585
620
  * `planApexZone` expects to see it, and nothing else. The zone's DNS records
@@ -609,7 +644,7 @@ export declare class ProvidersPlatformCloudflareReader implements PlatformCloudf
609
644
  export declare class ProvidersPlatformCoolifyReader implements PlatformCoolifyReader {
610
645
  private readonly client;
611
646
  private readonly platformStore;
612
- constructor(client: Pick<CoolifyPort, 'getApplication'>, platformStore: Pick<PlatformStore, 'getServiceIds'>);
647
+ constructor(client: Pick<CoolifyPort, 'getApplication' | 'listDatabaseBackups'>, platformStore: Pick<PlatformStore, 'getServiceIds'>);
613
648
  listResources(): Promise<LiveResource[]>;
614
649
  }
615
650
  export {};