@wtfalch/foundry 0.19.1 → 0.21.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 (49) hide show
  1. package/dist/bootstrap/declare.d.ts +13 -0
  2. package/dist/bootstrap/declare.js +31 -7
  3. package/dist/bootstrap/versions.d.ts +1 -0
  4. package/dist/index.d.ts +7 -3
  5. package/dist/index.js +4 -2
  6. package/dist/migrations/0046_platform_hc.sql +19 -0
  7. package/dist/plan/cloudflared-connector.d.ts +3 -2
  8. package/dist/plan/cloudflared-connector.js +2 -2
  9. package/dist/plan/deploy-health.js +5 -1
  10. package/dist/plan/diff.js +27 -4
  11. package/dist/plan/edge-access.d.ts +68 -0
  12. package/dist/plan/edge-access.js +123 -0
  13. package/dist/plan/edge-dns.d.ts +89 -0
  14. package/dist/plan/edge-dns.js +227 -0
  15. package/dist/plan/edge-ratelimits.d.ts +54 -0
  16. package/dist/plan/edge-ratelimits.js +79 -0
  17. package/dist/plan/edge-tunnels.d.ts +44 -0
  18. package/dist/plan/edge-tunnels.js +108 -0
  19. package/dist/plan/edge-workers.d.ts +31 -0
  20. package/dist/plan/edge-workers.js +165 -0
  21. package/dist/plan/edge-zones.d.ts +37 -0
  22. package/dist/plan/edge-zones.js +64 -0
  23. package/dist/plan/entry-edge-key.d.ts +10 -0
  24. package/dist/plan/entry-edge-key.js +38 -8
  25. package/dist/plan/executor.d.ts +146 -143
  26. package/dist/plan/executor.js +370 -115
  27. package/dist/plan/health-check-settings.d.ts +47 -0
  28. package/dist/plan/health-check-settings.js +47 -0
  29. package/dist/plan/health-targets.d.ts +86 -0
  30. package/dist/plan/health-targets.js +344 -0
  31. package/dist/plan/index.d.ts +17 -3
  32. package/dist/plan/index.js +9 -2
  33. package/dist/plan/live.d.ts +12 -1
  34. package/dist/plan/plan-apply.d.ts +4 -0
  35. package/dist/plan/plan-apply.js +5 -1
  36. package/dist/plan/platform-diff.d.ts +127 -0
  37. package/dist/plan/platform-diff.js +304 -8
  38. package/dist/plan/platform-providers.d.ts +202 -7
  39. package/dist/plan/platform-providers.js +545 -79
  40. package/dist/plan/providers-readers.d.ts +23 -1
  41. package/dist/plan/providers-readers.js +61 -1
  42. package/dist/plan/rollout.d.ts +9 -8
  43. package/dist/platform.d.ts +14 -1
  44. package/dist/platform.js +1 -0
  45. package/dist/store/postgres.js +8 -0
  46. package/dist/store/schema.d.ts +51 -0
  47. package/dist/store/schema.js +4 -0
  48. package/package.json +2 -1
  49. package/versions.json +2 -1
@@ -665,6 +665,113 @@ export declare const PLATFORM_EDGE_PLATFORM_KEY_SET_SCOPE_KEY = "edge_platform_k
665
665
  */
666
666
  export declare const PLATFORM_EDGE_APPLIED_HOSTNAME_SCOPE_KEY = "edge_applied_hostname";
667
667
  export declare const PLATFORM_EDGE_APPLIED_GIT_REF_SCOPE_KEY = "edge_applied_git_ref";
668
+ /**
669
+ * F4.dns: where edge answers on the Coolify network
670
+ * (`http://<custom_internal_name>:3000`), recorded by the edge step once the
671
+ * host reports healthy there. Edge is the only caller of Cloudflare, so every
672
+ * platform step that writes a DNS record (auth, mail, archon, and edge's own
673
+ * CNAME) reaches edge at this address, before any public hostname exists
674
+ * (the {@link PLATFORM_FILES_INTERNAL_URL_SCOPE_KEY} pattern). Not a secret.
675
+ */
676
+ export declare const PLATFORM_EDGE_INTERNAL_URL_SCOPE_KEY = "edge_platform_internal_url";
677
+ /**
678
+ * #540: the resource key `planHc` plans under and `FoundryPlatformStepExecutor`
679
+ * dispatches on -- the {@link PLATFORM_EDGE_KEY} shape for the platform's own
680
+ * heartbeat server (self-hosted Healthchecks). `runPlatformSteps` records
681
+ * the app's uuid under `coolify:app:platform:hc` once the step succeeds,
682
+ * which is what stops `planHc` planning it again.
683
+ */
684
+ export declare const PLATFORM_HC_KEY = "platform:hc";
685
+ /**
686
+ * #540: the hostname and `gitRef` (a Docker image tag, like `mail.gitRef`)
687
+ * the last successful `hc` run applied. `planHc` plans the step again when
688
+ * either differs from the declared value; a re-run patches the image tag
689
+ * and redeploys, and mints nothing new (`SECRET_KEY` is generated once and
690
+ * read back, never regenerated -- see `ensureGeneratedEnv`).
691
+ */
692
+ export declare const PLATFORM_HC_APPLIED_HOSTNAME_SCOPE_KEY = "hc_applied_hostname";
693
+ export declare const PLATFORM_HC_APPLIED_GIT_REF_SCOPE_KEY = "hc_applied_git_ref";
694
+ /**
695
+ * #540 round 2: the held-key ref a human puts hc's first project API key
696
+ * under -- `deliverSecret`'s naming convention (`EDGE_URL`'s own pattern),
697
+ * `<service>:<scope>:<name>` like {@link PLATFORM_EDGE_KEY_REF}. Nothing in
698
+ * this package mints this key: `provisionPlatformHc` stands up an empty
699
+ * Healthchecks instance with no user, no project and so no key, and neither
700
+ * a `manage.py` command nor a documented API mints a project's own API key
701
+ * (confirmed against `healthchecks/healthchecks`'s own source, 2026-10) --
702
+ * only its web UI does, after a first superuser signs in by hand. Until a
703
+ * human does that and holds the key here, `FoundryPlatformStepExecutor
704
+ * .blockedSteps` (`platform-providers.ts`) reports the heartbeat checks
705
+ * blocked (`PlanPlatformOptions.blocked.heartbeatsApiKeyMissing`) rather
706
+ * than let `planHeartbeats` plan a step that would call hc's real
707
+ * Management API with no account and no key behind it -- see issue #540's
708
+ * own "Human steps" note. This package never reads the value back: whoever
709
+ * implements {@link HeartbeatsPort} against hc's real HTTP API is the host,
710
+ * and that implementation is where the key is actually used.
711
+ */
712
+ export declare const PLATFORM_HC_API_KEY_REF = "hc:platform:apiKey";
713
+ /**
714
+ * #540: the heartbeat check named after the uptime Worker (foundry#542,
715
+ * which is what actually pings it -- blocked today on edge#26, so nothing
716
+ * pings this check yet; it still exists so the Worker has a URL to deliver
717
+ * to the moment it can). `platformBackupHeartbeatCheckName` is the matching
718
+ * name for each platform database backup (`PLATFORM_BACKUP_SERVICE_KEYS`).
719
+ * Both names are also the prefix `provisionPlatformHeartbeats`
720
+ * (`platform-providers.ts`) uses to recognise which live Healthchecks
721
+ * checks are its own to delete when they are no longer wanted -- it never
722
+ * touches a check outside this naming scheme.
723
+ */
724
+ export declare const PLATFORM_UPTIME_HEARTBEAT_CHECK_NAME = "uptime-worker";
725
+ /** #540: see {@link PLATFORM_UPTIME_HEARTBEAT_CHECK_NAME}'s own doc comment. */
726
+ export declare function platformBackupHeartbeatCheckName(service: PlatformBackupServiceKey): string;
727
+ /**
728
+ * #540: the held-key ref each heartbeat check's own ping URL is recorded
729
+ * under, once created -- `deliverSecret`'s ref-naming convention
730
+ * (`EDGE_URL`'s own pattern), read back by whoever owns that piece of
731
+ * scheduled work. `platformBackupHeartbeatUrlRef` is the per-service
732
+ * counterpart. Unlike a minted credential (CLAUDE.md platform-step rule 1),
733
+ * a ping URL is not retried-and-guarded the same way: it is closer to a
734
+ * resource id, so `provisionPlatformHeartbeats` simply records it again
735
+ * every time the check it names is (re)created.
736
+ */
737
+ export declare const PLATFORM_UPTIME_HEARTBEAT_URL_REF = "uptime:heartbeatUrl";
738
+ /** #540: see {@link PLATFORM_UPTIME_HEARTBEAT_URL_REF}'s own doc comment. */
739
+ export declare function platformBackupHeartbeatUrlRef(service: PlatformBackupServiceKey): string;
740
+ /**
741
+ * #540: every heartbeat check name foundry currently wants -- the uptime
742
+ * Worker's own, plus one per platform database backup that already has a
743
+ * recorded database id (`PLATFORM_BACKUP_SERVICE_KEYS`, the same "nothing to
744
+ * report yet" gate {@link planBackups} itself uses for a service with no
745
+ * database). Shared by {@link planHeartbeats} (to tell whether anything
746
+ * declared has changed since the last successful reconcile) and
747
+ * `provisionPlatformHeartbeats` (`platform-providers.ts`, to decide what to
748
+ * create or delete live) so the two can never compute a different desired
749
+ * set from each other.
750
+ */
751
+ export declare function desiredHeartbeatCheckNames(recordedIds: Readonly<Record<string, string>>): readonly string[];
752
+ /**
753
+ * #540: the held-key ref a check named `name` (one {@link desiredHeartbeatCheckNames}
754
+ * produced) records its ping URL under -- `platform-providers.ts`'s
755
+ * `provisionPlatformHeartbeats` looks this up rather than re-deriving it
756
+ * inline, so the two name-to-ref mappings ({@link PLATFORM_UPTIME_HEARTBEAT_URL_REF}
757
+ * and {@link platformBackupHeartbeatUrlRef}) stay reachable from the name
758
+ * alone. Throws on a name this module did not itself produce -- a
759
+ * programmer error, never a live value.
760
+ */
761
+ export declare function heartbeatCheckUrlRef(name: string): string;
762
+ /**
763
+ * #540: the sorted, comma-joined {@link desiredHeartbeatCheckNames} as of the
764
+ * last successful reconcile -- recorded by `provisionPlatformHeartbeats`
765
+ * once it has created every missing check and deleted every stale one.
766
+ * {@link planHeartbeats} plans the reconcile step again only when the
767
+ * declared desired set has since changed (a backup service's database
768
+ * arrived, say), not on every apply: `driveApply`'s bootstrap loop
769
+ * (`bootstrap/drive.ts`) only ever terminates once a plan has zero steps, so
770
+ * a step planned unconditionally on every round -- as this one once was --
771
+ * never lets a platform with `hc` enabled (the default, #540) finish
772
+ * bootstrapping at all.
773
+ */
774
+ export declare const PLATFORM_HEARTBEATS_APPLIED_SCOPE_KEY = "heartbeats_applied_desired";
668
775
  /**
669
776
  * Options for {@link planPlatform}, replacing four positional arguments that
670
777
  * shifted on every merge that added a platform service (#186, #187, #188) --
@@ -712,6 +819,26 @@ export interface PlanPlatformOptions {
712
819
  * "executor missing" shape {@link authExecutorMissing} has for auth.
713
820
  */
714
821
  backupExecutorMissing?: boolean;
822
+ /** #540: why `hc` is blocked, or absent when it is not. */
823
+ hc?: string;
824
+ /**
825
+ * #540: true once `hc` is applied but the host built the platform
826
+ * executor with no heartbeats dependencies -- the heartbeat-checks step
827
+ * is blocked, not planned, the same "executor missing" shape
828
+ * {@link backupExecutorMissing} has for a backup.
829
+ */
830
+ heartbeatsExecutorMissing?: boolean;
831
+ /**
832
+ * #540 round 2: true while the heartbeats dependencies are wired but no
833
+ * value is held under {@link PLATFORM_HC_API_KEY_REF} -- there is no
834
+ * bootstrap that mints hc's first superuser and project API key yet
835
+ * (issue #540's own "Decide"), so a fresh platform's heartbeats step
836
+ * would otherwise call hc's real Management API with no account and no
837
+ * key behind it. Blocked, not planned, until a human creates both by
838
+ * hand and holds the key under that ref; see this flag's own "Human
839
+ * steps" note in `platform-providers.ts`'s `FoundryPlatformStepExecutor.blockedSteps`.
840
+ */
841
+ heartbeatsApiKeyMissing?: boolean;
715
842
  };
716
843
  /**
717
844
  * Steps whose live state differs from the declared one in a way the recorded
@@ -741,6 +741,133 @@ export const PLATFORM_EDGE_PLATFORM_KEY_SET_SCOPE_KEY = 'edge_platform_key_set';
741
741
  */
742
742
  export const PLATFORM_EDGE_APPLIED_HOSTNAME_SCOPE_KEY = 'edge_applied_hostname';
743
743
  export const PLATFORM_EDGE_APPLIED_GIT_REF_SCOPE_KEY = 'edge_applied_git_ref';
744
+ /**
745
+ * F4.dns: where edge answers on the Coolify network
746
+ * (`http://<custom_internal_name>:3000`), recorded by the edge step once the
747
+ * host reports healthy there. Edge is the only caller of Cloudflare, so every
748
+ * platform step that writes a DNS record (auth, mail, archon, and edge's own
749
+ * CNAME) reaches edge at this address, before any public hostname exists
750
+ * (the {@link PLATFORM_FILES_INTERNAL_URL_SCOPE_KEY} pattern). Not a secret.
751
+ */
752
+ export const PLATFORM_EDGE_INTERNAL_URL_SCOPE_KEY = 'edge_platform_internal_url';
753
+ /**
754
+ * #540: the resource key `planHc` plans under and `FoundryPlatformStepExecutor`
755
+ * dispatches on -- the {@link PLATFORM_EDGE_KEY} shape for the platform's own
756
+ * heartbeat server (self-hosted Healthchecks). `runPlatformSteps` records
757
+ * the app's uuid under `coolify:app:platform:hc` once the step succeeds,
758
+ * which is what stops `planHc` planning it again.
759
+ */
760
+ export const PLATFORM_HC_KEY = 'platform:hc';
761
+ /**
762
+ * #540: the hostname and `gitRef` (a Docker image tag, like `mail.gitRef`)
763
+ * the last successful `hc` run applied. `planHc` plans the step again when
764
+ * either differs from the declared value; a re-run patches the image tag
765
+ * and redeploys, and mints nothing new (`SECRET_KEY` is generated once and
766
+ * read back, never regenerated -- see `ensureGeneratedEnv`).
767
+ */
768
+ export const PLATFORM_HC_APPLIED_HOSTNAME_SCOPE_KEY = 'hc_applied_hostname';
769
+ export const PLATFORM_HC_APPLIED_GIT_REF_SCOPE_KEY = 'hc_applied_git_ref';
770
+ /**
771
+ * #540 round 2: the held-key ref a human puts hc's first project API key
772
+ * under -- `deliverSecret`'s naming convention (`EDGE_URL`'s own pattern),
773
+ * `<service>:<scope>:<name>` like {@link PLATFORM_EDGE_KEY_REF}. Nothing in
774
+ * this package mints this key: `provisionPlatformHc` stands up an empty
775
+ * Healthchecks instance with no user, no project and so no key, and neither
776
+ * a `manage.py` command nor a documented API mints a project's own API key
777
+ * (confirmed against `healthchecks/healthchecks`'s own source, 2026-10) --
778
+ * only its web UI does, after a first superuser signs in by hand. Until a
779
+ * human does that and holds the key here, `FoundryPlatformStepExecutor
780
+ * .blockedSteps` (`platform-providers.ts`) reports the heartbeat checks
781
+ * blocked (`PlanPlatformOptions.blocked.heartbeatsApiKeyMissing`) rather
782
+ * than let `planHeartbeats` plan a step that would call hc's real
783
+ * Management API with no account and no key behind it -- see issue #540's
784
+ * own "Human steps" note. This package never reads the value back: whoever
785
+ * implements {@link HeartbeatsPort} against hc's real HTTP API is the host,
786
+ * and that implementation is where the key is actually used.
787
+ */
788
+ export const PLATFORM_HC_API_KEY_REF = 'hc:platform:apiKey';
789
+ /**
790
+ * #540: the heartbeat check named after the uptime Worker (foundry#542,
791
+ * which is what actually pings it -- blocked today on edge#26, so nothing
792
+ * pings this check yet; it still exists so the Worker has a URL to deliver
793
+ * to the moment it can). `platformBackupHeartbeatCheckName` is the matching
794
+ * name for each platform database backup (`PLATFORM_BACKUP_SERVICE_KEYS`).
795
+ * Both names are also the prefix `provisionPlatformHeartbeats`
796
+ * (`platform-providers.ts`) uses to recognise which live Healthchecks
797
+ * checks are its own to delete when they are no longer wanted -- it never
798
+ * touches a check outside this naming scheme.
799
+ */
800
+ export const PLATFORM_UPTIME_HEARTBEAT_CHECK_NAME = 'uptime-worker';
801
+ /** #540: see {@link PLATFORM_UPTIME_HEARTBEAT_CHECK_NAME}'s own doc comment. */
802
+ export function platformBackupHeartbeatCheckName(service) {
803
+ return `backup-${service}`;
804
+ }
805
+ /**
806
+ * #540: the held-key ref each heartbeat check's own ping URL is recorded
807
+ * under, once created -- `deliverSecret`'s ref-naming convention
808
+ * (`EDGE_URL`'s own pattern), read back by whoever owns that piece of
809
+ * scheduled work. `platformBackupHeartbeatUrlRef` is the per-service
810
+ * counterpart. Unlike a minted credential (CLAUDE.md platform-step rule 1),
811
+ * a ping URL is not retried-and-guarded the same way: it is closer to a
812
+ * resource id, so `provisionPlatformHeartbeats` simply records it again
813
+ * every time the check it names is (re)created.
814
+ */
815
+ export const PLATFORM_UPTIME_HEARTBEAT_URL_REF = 'uptime:heartbeatUrl';
816
+ /** #540: see {@link PLATFORM_UPTIME_HEARTBEAT_URL_REF}'s own doc comment. */
817
+ export function platformBackupHeartbeatUrlRef(service) {
818
+ return `backup:${service}:heartbeatUrl`;
819
+ }
820
+ /**
821
+ * #540: every heartbeat check name foundry currently wants -- the uptime
822
+ * Worker's own, plus one per platform database backup that already has a
823
+ * recorded database id (`PLATFORM_BACKUP_SERVICE_KEYS`, the same "nothing to
824
+ * report yet" gate {@link planBackups} itself uses for a service with no
825
+ * database). Shared by {@link planHeartbeats} (to tell whether anything
826
+ * declared has changed since the last successful reconcile) and
827
+ * `provisionPlatformHeartbeats` (`platform-providers.ts`, to decide what to
828
+ * create or delete live) so the two can never compute a different desired
829
+ * set from each other.
830
+ */
831
+ export function desiredHeartbeatCheckNames(recordedIds) {
832
+ const names = [PLATFORM_UPTIME_HEARTBEAT_CHECK_NAME];
833
+ for (const service of PLATFORM_BACKUP_SERVICE_KEYS) {
834
+ if (recordedIds[PLATFORM_BACKUP_DATABASE_SCOPE_KEYS[service]]) {
835
+ names.push(platformBackupHeartbeatCheckName(service));
836
+ }
837
+ }
838
+ return names;
839
+ }
840
+ /**
841
+ * #540: the held-key ref a check named `name` (one {@link desiredHeartbeatCheckNames}
842
+ * produced) records its ping URL under -- `platform-providers.ts`'s
843
+ * `provisionPlatformHeartbeats` looks this up rather than re-deriving it
844
+ * inline, so the two name-to-ref mappings ({@link PLATFORM_UPTIME_HEARTBEAT_URL_REF}
845
+ * and {@link platformBackupHeartbeatUrlRef}) stay reachable from the name
846
+ * alone. Throws on a name this module did not itself produce -- a
847
+ * programmer error, never a live value.
848
+ */
849
+ export function heartbeatCheckUrlRef(name) {
850
+ if (name === PLATFORM_UPTIME_HEARTBEAT_CHECK_NAME)
851
+ return PLATFORM_UPTIME_HEARTBEAT_URL_REF;
852
+ for (const service of PLATFORM_BACKUP_SERVICE_KEYS) {
853
+ if (name === platformBackupHeartbeatCheckName(service))
854
+ return platformBackupHeartbeatUrlRef(service);
855
+ }
856
+ throw new Error(`heartbeatCheckUrlRef: unrecognised heartbeat check name '${name}'`);
857
+ }
858
+ /**
859
+ * #540: the sorted, comma-joined {@link desiredHeartbeatCheckNames} as of the
860
+ * last successful reconcile -- recorded by `provisionPlatformHeartbeats`
861
+ * once it has created every missing check and deleted every stale one.
862
+ * {@link planHeartbeats} plans the reconcile step again only when the
863
+ * declared desired set has since changed (a backup service's database
864
+ * arrived, say), not on every apply: `driveApply`'s bootstrap loop
865
+ * (`bootstrap/drive.ts`) only ever terminates once a plan has zero steps, so
866
+ * a step planned unconditionally on every round -- as this one once was --
867
+ * never lets a platform with `hc` enabled (the default, #540) finish
868
+ * bootstrapping at all.
869
+ */
870
+ export const PLATFORM_HEARTBEATS_APPLIED_SCOPE_KEY = 'heartbeats_applied_desired';
744
871
  /**
745
872
  * #167: the platform's own diff engine, mirroring `diff.ts`'s
746
873
  * `computePlanDetail` for the one shared {@link DeclaredPlatform} record
@@ -782,7 +909,35 @@ export function planPlatform(platform, live, options = {}) {
782
909
  const sfu = platform.sfu.enabled && sfuBlocked ? null : planSfu(platform);
783
910
  if (sfu)
784
911
  plannedSteps.push(sfu);
785
- const auth = planAuth(platform, recordedIds, blocked.auth, blocked.authExecutorMissing ?? false, drifted.auth ?? false, blocked.authOtherInputsMissing ?? []);
912
+ // #478, F4.dns: first of the services, ahead of auth. Edge is the only
913
+ // caller of Cloudflare, so auth, mail and archon write their DNS records
914
+ // through it; it needs none of them (a Coolify database and app, its own
915
+ // tunnel and DNS record, a held Cloudflare token) and records the address
916
+ // they reach it at.
917
+ const edge = planEdge(platform, recordedIds, blocked.edge);
918
+ if (edge.step)
919
+ plannedSteps.push(edge.step);
920
+ // Auth, mail and archon would build their tunnels and then fail at the DNS
921
+ // write, so while edge is neither applied nor planned in this plan they are
922
+ // blocked, not planned (rule 2), naming edge as the cause.
923
+ const edgeApplied = Boolean(recordedIds[PLATFORM_EDGE_INTERNAL_URL_SCOPE_KEY]);
924
+ const dnsBlocked = edgeApplied || edge.step
925
+ ? undefined
926
+ : (service) => `the platform's ${service} is blocked, not planned: every DNS record is written through edge, and edge is neither applied nor planned (${platform.edge.enabled
927
+ ? (edge.blocked ?? 'its step is not planned')
928
+ : 'the platform does not enable edge'})`;
929
+ // #540: hc needs only edge's DNS, the same as mail and archon -- no
930
+ // auth/ZITADEL client, so it is planned here rather than after `authReady`
931
+ // further down.
932
+ const hc = planHc(platform, recordedIds, blocked.hc ?? dnsBlocked?.('heartbeat server'));
933
+ if (hc.step)
934
+ plannedSteps.push(hc.step);
935
+ // #540: the `authReady` pattern, for hc -- true once hc is either already
936
+ // applied or about to run earlier in this very plan, so the heartbeats
937
+ // reconcile below can be planned in the *same* round hc first comes up,
938
+ // the same way files/mail/archon are planned in auth's own first round.
939
+ const hcReady = hc.step !== null || Boolean(recordedIds[resourceKey('coolify', 'app', PLATFORM_HC_KEY)]);
940
+ const auth = planAuth(platform, recordedIds, blocked.auth ?? dnsBlocked?.('sign-in'), blocked.authExecutorMissing ?? false, drifted.auth ?? false, blocked.authOtherInputsMissing ?? []);
786
941
  if (auth.step)
787
942
  plannedSteps.push(auth.step);
788
943
  // #174: the integrations host. Planned after auth; neither step reads the
@@ -798,27 +953,29 @@ export function planPlatform(platform, live, options = {}) {
798
953
  plannedSteps.push(files.step);
799
954
  // #169: the platform's own mail server. After auth, because its OIDC
800
955
  // directory is built against the platform's sign-in.
801
- const mail = planMail(platform, recordedIds, blocked.mail, authReady);
956
+ const mail = planMail(platform, recordedIds, blocked.mail ?? dnsBlocked?.('mail server'), authReady);
802
957
  if (mail.step)
803
958
  plannedSteps.push(mail.step);
804
959
  // #172: a throw halts every step after it, so this one goes late.
805
960
  const ai = planAi(platform, recordedIds, blocked.ai);
806
961
  if (ai.step)
807
962
  plannedSteps.push(ai.step);
808
- // #478: independent of auth and files -- the edge host needs neither.
809
- const edge = planEdge(platform, recordedIds, blocked.edge);
810
- if (edge.step)
811
- plannedSteps.push(edge.step);
812
963
  // #175: last, on purpose. Archon's sign-in client lives in the sign-in
813
964
  // `planAuth` builds, and its env reads what every other service's step
814
965
  // records or delivers, so in one apply it runs after all of them.
815
- const archon = planArchon(platform, recordedIds, blocked.archon, authReady, drifted.archon);
966
+ const archon = planArchon(platform, recordedIds, blocked.archon ?? dnsBlocked?.('Archon'), authReady, drifted.archon);
816
967
  if (archon.step)
817
968
  plannedSteps.push(archon.step);
818
969
  // #257: after every service above, so a database any of them just
819
970
  // recorded is already visible to `recordedIds` on this same plan.
820
971
  const backups = planBackups(byKey, claimed, recordedIds, blocked.backupExecutorMissing ?? false, drifted.backupStorage ?? false, drifted.backupStorageBlocked);
821
972
  plannedSteps.push(...backups.steps);
973
+ // #540: last of all, so the backup databases `planBackups` just saw above
974
+ // are the same set this reconcile sees too -- one check per platform
975
+ // database backup, plus the uptime Worker's own.
976
+ const heartbeats = planHeartbeats(platform, recordedIds, hcReady, blocked.heartbeatsExecutorMissing ?? false, blocked.heartbeatsApiKeyMissing ?? false);
977
+ if (heartbeats.step)
978
+ plannedSteps.push(heartbeats.step);
822
979
  // #251: one line per service whose recorded applied ref no longer matches
823
980
  // what the next apply would use (or, for zitadel, what is actually live).
824
981
  // Never pushed into `plannedSteps` -- see `VersionChangeItem`'s own doc
@@ -845,6 +1002,9 @@ export function planPlatform(platform, live, options = {}) {
845
1002
  edge.blocked
846
1003
  ? null
847
1004
  : versionChangeLine('edge', recordedIds[PLATFORM_EDGE_APPLIED_GIT_REF_SCOPE_KEY], platform.edge.gitRef, 'git ref, redeploy'),
1005
+ hc.blocked
1006
+ ? null
1007
+ : versionChangeLine('hc', recordedIds[PLATFORM_HC_APPLIED_GIT_REF_SCOPE_KEY], platform.hc.gitRef, 'image, redeploy'),
848
1008
  auth.blocked
849
1009
  ? null
850
1010
  : versionNotAppliedLine('zitadel', Boolean(recordedIds[resourceKey('coolify', 'app', PLATFORM_AUTH_KEY)]), recordedIds[PLATFORM_AUTH_ZITADEL_APPLIED_IMAGE_TAG_SCOPE_KEY], VERSIONS.zitadel),
@@ -878,6 +1038,8 @@ export function planPlatform(platform, live, options = {}) {
878
1038
  unmanaged.push({ description: ai.blocked, key: 'ai' });
879
1039
  if (edge.blocked)
880
1040
  unmanaged.push({ description: edge.blocked, key: 'edge' });
1041
+ if (hc.blocked)
1042
+ unmanaged.push({ description: hc.blocked, key: 'hc' });
881
1043
  if (archon.blocked)
882
1044
  unmanaged.push({ description: archon.blocked, key: 'archon' });
883
1045
  // #398: the old integrations key stays pinned until Archon holds the new
@@ -892,6 +1054,8 @@ export function planPlatform(platform, live, options = {}) {
892
1054
  });
893
1055
  }
894
1056
  unmanaged.push(...backups.blocked);
1057
+ if (heartbeats.blocked)
1058
+ unmanaged.push({ description: heartbeats.blocked, key: 'heartbeats' });
895
1059
  return { plannedSteps: plannedSteps.map(withStepSystem), unmanaged, versionChanges };
896
1060
  }
897
1061
  /**
@@ -1780,7 +1944,9 @@ function planEdge(platform, recordedIds, executorBlocked) {
1780
1944
  }
1781
1945
  const applied = Boolean(recordedIds[resourceKey('coolify', 'app', PLATFORM_EDGE_KEY)]) &&
1782
1946
  recordedIds[PLATFORM_EDGE_APPLIED_HOSTNAME_SCOPE_KEY] === platform.edge.hostname &&
1783
- recordedIds[PLATFORM_EDGE_APPLIED_GIT_REF_SCOPE_KEY] === platform.edge.gitRef;
1947
+ recordedIds[PLATFORM_EDGE_APPLIED_GIT_REF_SCOPE_KEY] === platform.edge.gitRef &&
1948
+ // F4.dns: an edge applied before its address was recorded runs once more to record it.
1949
+ Boolean(recordedIds[PLATFORM_EDGE_INTERNAL_URL_SCOPE_KEY]);
1784
1950
  if (applied)
1785
1951
  return { step: null, blocked: null };
1786
1952
  if (executorBlocked)
@@ -1802,6 +1968,136 @@ function planEdge(platform, recordedIds, executorBlocked) {
1802
1968
  blocked: null,
1803
1969
  };
1804
1970
  }
1971
+ /**
1972
+ * #540: a self-hosted heartbeat server (Healthchecks) for the platform, for
1973
+ * now -- a Coolify Postgres plus one Docker-image app
1974
+ * (`healthchecks/healthchecks`), reached through edge's tunnel and DNS at
1975
+ * `platform.hc.hostname`, the {@link planEdge} bundling shape minus any
1976
+ * auth/ZITADEL client: `hc` is an internal operational tool, not something
1977
+ * an organisation member signs into. Whether this stays the platform's
1978
+ * heartbeat server long-term, instead of or alongside the hand-set-up
1979
+ * `hc.wtfalch.dev` instance, and what happens to that instance's own data,
1980
+ * are both still open "Decide" items on issue #540's own thread -- not
1981
+ * settled by this code.
1982
+ *
1983
+ * Idempotent on the recorded app id plus the applied hostname and `gitRef`,
1984
+ * for {@link planAuth}'s own reason: no platform reader reports this
1985
+ * Coolify app. A changed hostname or `gitRef` plans the step again (a
1986
+ * redeploy, no new secret -- `SECRET_KEY` is generated once by
1987
+ * `provisionPlatformHc` and read back, never regenerated).
1988
+ *
1989
+ * Blocked, not planned: `executorBlocked`, from `dnsBlocked` (edge neither
1990
+ * applied nor planned) or the host building no `hc` dependencies.
1991
+ */
1992
+ function planHc(platform, recordedIds, executorBlocked) {
1993
+ if (!platform.hc.enabled)
1994
+ return { step: null, blocked: null };
1995
+ const { hostname, gitRef } = platform.hc;
1996
+ const applied = Boolean(recordedIds[resourceKey('coolify', 'app', PLATFORM_HC_KEY)]) &&
1997
+ recordedIds[PLATFORM_HC_APPLIED_HOSTNAME_SCOPE_KEY] === hostname &&
1998
+ recordedIds[PLATFORM_HC_APPLIED_GIT_REF_SCOPE_KEY] === gitRef;
1999
+ if (applied)
2000
+ return { step: null, blocked: null };
2001
+ if (executorBlocked)
2002
+ return { step: null, blocked: executorBlocked };
2003
+ return {
2004
+ step: {
2005
+ action: {
2006
+ system: 'coolify',
2007
+ kind: 'app',
2008
+ key: PLATFORM_HC_KEY,
2009
+ desired: { hostname, gitRef },
2010
+ },
2011
+ step: {
2012
+ description: `provision the heartbeat server (Healthchecks) at ${hostname}, with its own database`,
2013
+ destroys: false,
2014
+ migrates: false,
2015
+ },
2016
+ },
2017
+ blocked: null,
2018
+ };
2019
+ }
2020
+ /**
2021
+ * #540: one reconcile step for every heartbeat check foundry owns -- the
2022
+ * uptime Worker's own check plus one per platform database backup
2023
+ * (`PLATFORM_BACKUP_SERVICE_KEYS`, filtered the same way {@link planBackups}
2024
+ * is to "has a recorded database", so a service with no database yet gets
2025
+ * no standing check either). The actual create-missing/delete-stale
2026
+ * reconciling against the live Healthchecks list (`HeartbeatsPort.listChecks`,
2027
+ * `platform-providers.ts`) happens inside `provisionPlatformHeartbeats`
2028
+ * itself, not here -- this function only decides *whether* that step runs
2029
+ * at all: never before `hc` is ready (`hcReady`, the {@link planPlatform}
2030
+ * own `authReady` pattern -- already applied, or about to be, earlier in
2031
+ * this same plan), never with no heartbeats dependencies wired, and never
2032
+ * again once {@link desiredHeartbeatCheckNames} already matches
2033
+ * {@link PLATFORM_HEARTBEATS_APPLIED_SCOPE_KEY}'s recorded value.
2034
+ *
2035
+ * That last check is the point: an earlier version of this function planned
2036
+ * the step unconditionally every round, on the theory that
2037
+ * `provisionPlatformHeartbeats` listing live checks first was idempotency
2038
+ * enough. It is, for what it creates -- but `bootstrap/drive.ts`'s
2039
+ * `driveApply` (the loop `bootstrapPlatform` runs to actually stand a
2040
+ * platform up) only stops once a round's plan has zero steps, and `hc` is
2041
+ * enabled by default (`bootstrap/declare.ts`), so a platform that never
2042
+ * recorded "nothing left to reconcile" could never finish bootstrapping at
2043
+ * all -- it would hit `driveApply`'s round cap on every single run. Compare
2044
+ * against the planner's own recorded desired-set marker, declared-to-declared,
2045
+ * so a plan with nothing left to do actually has nothing left to do.
2046
+ *
2047
+ * #540 round 2: also withheld while `apiKeyMissing` -- no human has held a
2048
+ * project API key under {@link PLATFORM_HC_API_KEY_REF} yet, so there is
2049
+ * nothing for the host's own `HeartbeatsPort` to authenticate a real call
2050
+ * with. Without this, a fresh platform's heartbeats step is ready
2051
+ * (`hcReady`) the same round `hc` is merely *planned*, long before anyone
2052
+ * has signed in to it to create that key.
2053
+ */
2054
+ function planHeartbeats(platform, recordedIds, hcReady, executorMissing, apiKeyMissing) {
2055
+ if (!platform.hc.enabled) {
2056
+ // Removing checks is not planned. The recorded marker names the checks
2057
+ // foundry's last reconcile wanted, so they are foundry's own; report
2058
+ // them rather than leave them unseen.
2059
+ const left = recordedIds[PLATFORM_HEARTBEATS_APPLIED_SCOPE_KEY];
2060
+ return {
2061
+ step: null,
2062
+ blocked: left
2063
+ ? `the heartbeat server is not enabled, but checks foundry created remain on it (${left}); removing them is not planned, delete them in Healthchecks by hand`
2064
+ : null,
2065
+ };
2066
+ }
2067
+ if (!hcReady) {
2068
+ return {
2069
+ step: null,
2070
+ blocked: 'the heartbeat checks are blocked, not planned: platform:hc has not been applied yet',
2071
+ };
2072
+ }
2073
+ if (executorMissing) {
2074
+ return {
2075
+ step: null,
2076
+ blocked: 'the heartbeat checks are blocked, not planned: the host built the platform executor without its heartbeats dependencies',
2077
+ };
2078
+ }
2079
+ if (apiKeyMissing) {
2080
+ return {
2081
+ step: null,
2082
+ blocked: `the heartbeat checks are blocked, not planned: no project API key is held yet for hc (${PLATFORM_HC_API_KEY_REF}); a human must sign in to hc, create the first superuser account and a project, mint that project's API key, and hold it under that ref (issue #540 "Human steps") before this step can call hc's check-creation API`,
2083
+ };
2084
+ }
2085
+ const desired = [...desiredHeartbeatCheckNames(recordedIds)].sort().join(',');
2086
+ if (recordedIds[PLATFORM_HEARTBEATS_APPLIED_SCOPE_KEY] === desired) {
2087
+ return { step: null, blocked: null };
2088
+ }
2089
+ return {
2090
+ step: {
2091
+ action: { system: 'heartbeats', kind: 'reconcile', key: 'platform', desired: {} },
2092
+ step: {
2093
+ description: 'reconcile heartbeat checks for the uptime Worker and every platform backup service',
2094
+ destroys: false,
2095
+ migrates: false,
2096
+ },
2097
+ },
2098
+ blocked: null,
2099
+ };
2100
+ }
1805
2101
  /**
1806
2102
  * #172: the platform's AI host (https://github.com/wtfalch/ai) -- a Coolify
1807
2103
  * Postgres plus three apps built from that repo's root Dockerfile, one per