@wtfalch/foundry 0.20.0 → 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.
@@ -674,6 +674,104 @@ export declare const PLATFORM_EDGE_APPLIED_GIT_REF_SCOPE_KEY = "edge_applied_git
674
674
  * (the {@link PLATFORM_FILES_INTERNAL_URL_SCOPE_KEY} pattern). Not a secret.
675
675
  */
676
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";
677
775
  /**
678
776
  * Options for {@link planPlatform}, replacing four positional arguments that
679
777
  * shifted on every merge that added a platform service (#186, #187, #188) --
@@ -721,6 +819,26 @@ export interface PlanPlatformOptions {
721
819
  * "executor missing" shape {@link authExecutorMissing} has for auth.
722
820
  */
723
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;
724
842
  };
725
843
  /**
726
844
  * Steps whose live state differs from the declared one in a way the recorded
@@ -750,6 +750,124 @@ export const PLATFORM_EDGE_APPLIED_GIT_REF_SCOPE_KEY = 'edge_applied_git_ref';
750
750
  * (the {@link PLATFORM_FILES_INTERNAL_URL_SCOPE_KEY} pattern). Not a secret.
751
751
  */
752
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';
753
871
  /**
754
872
  * #167: the platform's own diff engine, mirroring `diff.ts`'s
755
873
  * `computePlanDetail` for the one shared {@link DeclaredPlatform} record
@@ -808,6 +926,17 @@ export function planPlatform(platform, live, options = {}) {
808
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
809
927
  ? (edge.blocked ?? 'its step is not planned')
810
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)]);
811
940
  const auth = planAuth(platform, recordedIds, blocked.auth ?? dnsBlocked?.('sign-in'), blocked.authExecutorMissing ?? false, drifted.auth ?? false, blocked.authOtherInputsMissing ?? []);
812
941
  if (auth.step)
813
942
  plannedSteps.push(auth.step);
@@ -841,6 +970,12 @@ export function planPlatform(platform, live, options = {}) {
841
970
  // recorded is already visible to `recordedIds` on this same plan.
842
971
  const backups = planBackups(byKey, claimed, recordedIds, blocked.backupExecutorMissing ?? false, drifted.backupStorage ?? false, drifted.backupStorageBlocked);
843
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);
844
979
  // #251: one line per service whose recorded applied ref no longer matches
845
980
  // what the next apply would use (or, for zitadel, what is actually live).
846
981
  // Never pushed into `plannedSteps` -- see `VersionChangeItem`'s own doc
@@ -867,6 +1002,9 @@ export function planPlatform(platform, live, options = {}) {
867
1002
  edge.blocked
868
1003
  ? null
869
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'),
870
1008
  auth.blocked
871
1009
  ? null
872
1010
  : versionNotAppliedLine('zitadel', Boolean(recordedIds[resourceKey('coolify', 'app', PLATFORM_AUTH_KEY)]), recordedIds[PLATFORM_AUTH_ZITADEL_APPLIED_IMAGE_TAG_SCOPE_KEY], VERSIONS.zitadel),
@@ -900,6 +1038,8 @@ export function planPlatform(platform, live, options = {}) {
900
1038
  unmanaged.push({ description: ai.blocked, key: 'ai' });
901
1039
  if (edge.blocked)
902
1040
  unmanaged.push({ description: edge.blocked, key: 'edge' });
1041
+ if (hc.blocked)
1042
+ unmanaged.push({ description: hc.blocked, key: 'hc' });
903
1043
  if (archon.blocked)
904
1044
  unmanaged.push({ description: archon.blocked, key: 'archon' });
905
1045
  // #398: the old integrations key stays pinned until Archon holds the new
@@ -914,6 +1054,8 @@ export function planPlatform(platform, live, options = {}) {
914
1054
  });
915
1055
  }
916
1056
  unmanaged.push(...backups.blocked);
1057
+ if (heartbeats.blocked)
1058
+ unmanaged.push({ description: heartbeats.blocked, key: 'heartbeats' });
917
1059
  return { plannedSteps: plannedSteps.map(withStepSystem), unmanaged, versionChanges };
918
1060
  }
919
1061
  /**
@@ -1826,6 +1968,136 @@ function planEdge(platform, recordedIds, executorBlocked) {
1826
1968
  blocked: null,
1827
1969
  };
1828
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
+ }
1829
2101
  /**
1830
2102
  * #172: the platform's AI host (https://github.com/wtfalch/ai) -- a Coolify
1831
2103
  * Postgres plus three apps built from that repo's root Dockerfile, one per
@@ -182,6 +182,18 @@ export interface FoundryPlatformStepExecutorOptions {
182
182
  * planned.
183
183
  */
184
184
  backup?: PlatformBackupDeps;
185
+ /**
186
+ * #540: dependencies for `coolify:app` keyed {@link PLATFORM_HC_KEY} --
187
+ * optional for the same reason as `auth`; a host that declares
188
+ * `platform.hc.enabled` without it gets the step reported as blocked.
189
+ */
190
+ hc?: PlatformHcDeps;
191
+ /**
192
+ * #540: dependencies for the `heartbeats:reconcile` step -- optional for
193
+ * the same reason as `auth`; `hc` applied with this unset gets the
194
+ * heartbeat checks reported as blocked rather than planned.
195
+ */
196
+ heartbeats?: PlatformHeartbeatsDeps;
185
197
  }
186
198
  /**
187
199
  * The real {@link PlatformStepExecutor} for the two steps built so far:
@@ -261,6 +273,8 @@ export declare class FoundryPlatformStepExecutor implements PlatformStepExecutor
261
273
  private readonly edge?;
262
274
  private readonly archon?;
263
275
  private readonly backup?;
276
+ private readonly hc?;
277
+ private readonly heartbeats?;
264
278
  constructor(cloudflare: Pick<CloudflarePort, 'findZoneByApex' | 'createZone'>, declaredOrgStore: Pick<DeclaredOrgStore, 'findOrgIdForZone' | 'list' | 'get'>, options?: FoundryPlatformStepExecutorOptions);
265
279
  blockedSteps(platform: DeclaredPlatform, recordedIds?: Readonly<Record<string, string>>): Promise<{
266
280
  auth?: string;
@@ -273,6 +287,9 @@ export declare class FoundryPlatformStepExecutor implements PlatformStepExecutor
273
287
  archon?: string;
274
288
  mail?: string;
275
289
  backupExecutorMissing?: boolean;
290
+ hc?: string;
291
+ heartbeatsExecutorMissing?: boolean;
292
+ heartbeatsApiKeyMissing?: boolean;
276
293
  }>;
277
294
  /**
278
295
  * Whether Archon's live Access policy lets through anyone but the declared
@@ -676,6 +693,17 @@ export interface PlatformAiRuntime {
676
693
  migrateIntervalMs?: number;
677
694
  /** Tries at handing a secret to the host (default 3): the management key and the signing key both use this. */
678
695
  deliverAttempts?: number;
696
+ /**
697
+ * #543: injected for tests, against a fake runner host -- the runner's own
698
+ * deploy wait now does a `'ready'` probe of its `/api/health`, the same
699
+ * `DeployHealthRuntime.fetch` field `provisionPlatformMail`/
700
+ * `provisionPlatformArchon` already take. Defaults to the global `fetch`.
701
+ */
702
+ fetch?: typeof fetch;
703
+ /** How long to wait for the runner to answer ready (default 5 minutes). */
704
+ readyTimeoutMs?: number;
705
+ /** Pause between readiness polls (default 5 seconds). */
706
+ readyIntervalMs?: number;
679
707
  }
680
708
  /** The ref the AI management key is delivered under; the host's `AiOrganisationPort` adapter reads it back. */
681
709
  export declare const PLATFORM_AI_MANAGEMENT_KEY_REF = "ai:platform:managementKey";
@@ -849,6 +877,17 @@ export interface PlatformFilesRuntime {
849
877
  migrateIntervalMs?: number;
850
878
  /** Tries at handing each secret to the host (default 3). */
851
879
  deliverAttempts?: number;
880
+ /**
881
+ * #543: injected for tests, against a fake runner host -- the runner's own
882
+ * deploy wait now does a `'ready'` probe of its `/api/health`, same as
883
+ * `provisionPlatformMail`/`provisionPlatformArchon` already take. Defaults
884
+ * to the global `fetch`.
885
+ */
886
+ fetch?: typeof fetch;
887
+ /** How long to wait for the runner to answer ready (default 5 minutes). */
888
+ readyTimeoutMs?: number;
889
+ /** Pause between readiness polls (default 5 seconds). */
890
+ readyIntervalMs?: number;
852
891
  }
853
892
  /**
854
893
  * #171: how the host signs a files management request with the key this step
@@ -903,6 +942,30 @@ export interface PlatformEdgeDeps {
903
942
  /** Clock, sleep and fetch for the readiness wait and delivery retries; real ones by default. */
904
943
  runtime?: PlatformEdgeRuntime;
905
944
  }
945
+ export interface PlatformHcRuntime {
946
+ fetch?: typeof fetch;
947
+ sleep?: (ms: number) => Promise<void>;
948
+ now?: () => number;
949
+ readyTimeoutMs?: number;
950
+ readyIntervalMs?: number;
951
+ }
952
+ /**
953
+ * #540: dependencies for `coolify:app` keyed {@link PLATFORM_HC_KEY} -- the
954
+ * {@link PlatformEdgeDeps} bundling shape minus a Cloudflare token of its
955
+ * own: `hc` writes its tunnel and DNS record through edge the same way
956
+ * mail and archon do, and needs no `heldKeys`/`deliverSecret` at all --
957
+ * `SECRET_KEY` (Django's session-signing key) is generated once by
958
+ * {@link provisionPlatformHc} itself and never leaves this Coolify app.
959
+ */
960
+ export interface PlatformHcDeps {
961
+ coolify: Pick<CoolifyPort, 'findProjectByName' | 'createProject' | 'findOrCreateEnvironment' | 'listServers' | 'findApplicationByName' | 'getApplication' | 'createDockerImageApplication' | 'createDockerfileApplication' | 'listDatabases' | 'createPostgresDatabase' | 'getDatabase' | 'bulkUpsertEnvs' | 'listEnvs' | 'updateApplication' | 'deploy' | 'deployAndWait' | 'raw'>;
962
+ /** F4.dns: every DNS record call, through edge at its recorded address (see {@link PlatformDnsFactory}). */
963
+ dns: PlatformDnsFactory;
964
+ /** F4.tunnels: every tunnel call, through edge at its recorded address (see {@link PlatformTunnelsFactory}). */
965
+ tunnels: PlatformTunnelsFactory;
966
+ /** Clock, sleep and fetch for the readiness wait; real ones by default. */
967
+ runtime?: PlatformHcRuntime;
968
+ }
906
969
  /**
907
970
  * #174: everything {@link provisionPlatformIntegrations} needs beyond
908
971
  * {@link PlatformStepExecutionContext} -- a bundle, like
@@ -977,6 +1040,91 @@ export interface PlatformBackupDeps {
977
1040
  /** A cron string or one of Coolify's names (`daily`, `weekly`, ...). Default `'daily'`. */
978
1041
  frequency?: string;
979
1042
  }
1043
+ /** One check as self-hosted Healthchecks' own Management API reports it. */
1044
+ export interface HeartbeatCheck {
1045
+ id: string;
1046
+ name: string;
1047
+ pingUrl: string;
1048
+ }
1049
+ /**
1050
+ * #540: the heartbeat server's own Management API -- a project API key in a
1051
+ * header, `POST /api/v3/checks/` with `name`/`unique: ['name']` to upsert a
1052
+ * check idempotently by name, `GET /api/v3/checks/` to list every one,
1053
+ * `DELETE /api/v3/checks/<uuid>` to remove one -- restated here as the port
1054
+ * `provisionPlatformHeartbeats` plans and provisions against, never a real
1055
+ * HTTP client: this package never connects itself (`README.md`), so the
1056
+ * host implements this against its own running `hc` instance and its own
1057
+ * project API key, the same seam every other real I/O in this file crosses.
1058
+ */
1059
+ export interface HeartbeatsPort {
1060
+ listChecks(): Promise<HeartbeatCheck[]>;
1061
+ /** Creates a check named `name`, or returns the existing one of that name -- the Management API's own upsert. */
1062
+ createCheck(name: string): Promise<HeartbeatCheck>;
1063
+ /**
1064
+ * #540 round 2: pauses a check (`POST /api/v3/checks/<uuid>/pause/`) so it
1065
+ * reads "paused" rather than "down" while nothing actually pings it yet --
1066
+ * both the uptime Worker's own check and every backup check (see this
1067
+ * file's own doc comment on `PlatformHeartbeatsDeps` for why). Sending a
1068
+ * check a ping resumes it automatically (Healthchecks' own documented
1069
+ * behaviour), so foundry never has to call the reverse of this: once a
1070
+ * real pinger exists, the check un-pauses itself on its first ping.
1071
+ * `provisionPlatformHeartbeats` calls this exactly once, right after
1072
+ * `createCheck`, and never again on a check it merely finds already live
1073
+ * -- a check already being pinged, or one an operator unpaused by hand,
1074
+ * must never be paused again by a reconcile run.
1075
+ */
1076
+ pauseCheck(id: string): Promise<void>;
1077
+ deleteCheck(id: string): Promise<void>;
1078
+ }
1079
+ /**
1080
+ * #540: dependencies for the `heartbeats:reconcile` step
1081
+ * (`platform-diff.ts`'s `planHeartbeats`).
1082
+ *
1083
+ * #540 round 2: `resolve` is new. It guards only the *delete* half of
1084
+ * {@link provisionPlatformHeartbeats} -- a live check is only ever deleted
1085
+ * when foundry itself holds a ping URL for its exact name
1086
+ * (`heartbeatCheckUrlRef`, `platform-diff.ts`), proof this run or an
1087
+ * earlier one created it. Without that guard, a hand-made check sharing
1088
+ * foundry's naming scheme (an operator's own `uptime-worker`, say, made
1089
+ * before foundry ever ran) could be deleted the moment that name was not
1090
+ * yet desired -- a backup service with no recorded database yet is in
1091
+ * `provisionPlatformHeartbeats`'s own `ownedNames` but not `desiredSet`.
1092
+ * `resolve` is never consulted on the *create* half: a check already live
1093
+ * under a desired name is always treated as foundry's, the way it always
1094
+ * was, and (the round-2 fix for a second bug) its ping address is now
1095
+ * always re-saved rather than only on the create that first makes it live
1096
+ * -- see `put`'s own doc comment below for why that half carries no
1097
+ * adoption risk the way deleting does.
1098
+ *
1099
+ * `put` records a check's own ping URL -- on every create, and (new this
1100
+ * round) on every run that finds a desired check already live, not only
1101
+ * the run that created it. Before this fix, a save that failed once, or a
1102
+ * run that died between create and save, or a host that wired `heldKeys`
1103
+ * only after the original create, lost that address for good: the next run
1104
+ * found the check already live and never saved anything, despite this very
1105
+ * doc comment already claiming the step "can simply ask the live check
1106
+ * again." Required whenever an address actually needs saving -- optional
1107
+ * only so a host without one still compiles, in which case nothing here
1108
+ * can ever be created or re-confirmed, and the step throws rather than
1109
+ * silently drop an address: CLAUDE.md platform-step rule 1 is written for a
1110
+ * minted credential, not a ping URL (a lost one is simply re-asked from the
1111
+ * live check, never re-minted), but the rule's "never silently lose it"
1112
+ * half applies just as well here.
1113
+ *
1114
+ * Both fields are optional only so a host without either still compiles;
1115
+ * while `PlatformHeartbeatsDeps` is wired at all, `FoundryPlatformStepExecutor
1116
+ * .blockedSteps`'s own `heartbeatsApiKeyMissing` check blocks the step
1117
+ * until a project API key is held under {@link PLATFORM_HC_API_KEY_REF}, so
1118
+ * the live Healthchecks calls below are never reached with nothing behind
1119
+ * them.
1120
+ */
1121
+ export interface PlatformHeartbeatsDeps {
1122
+ heartbeats: HeartbeatsPort;
1123
+ heldKeys?: {
1124
+ resolve(ref: string): Promise<string | null>;
1125
+ put?(ref: string, value: string): Promise<void>;
1126
+ };
1127
+ }
980
1128
  /**
981
1129
  * The real {@link PlatformCloudflareReader}: the platform's apex zone as
982
1130
  * `planApexZone` expects to see it, and nothing else. The zone's DNS records
@@ -1006,7 +1154,7 @@ export declare class ProvidersPlatformCloudflareReader implements PlatformCloudf
1006
1154
  export declare class ProvidersPlatformCoolifyReader implements PlatformCoolifyReader {
1007
1155
  private readonly client;
1008
1156
  private readonly platformStore;
1009
- constructor(client: Pick<CoolifyPort, 'getApplication' | 'getDatabase' | 'listDatabaseBackups'>, platformStore: Pick<PlatformStore, 'getServiceIds'>);
1157
+ constructor(client: Pick<CoolifyPort, 'getApplication' | 'getDatabase' | 'listDatabaseBackups' | 'raw'>, platformStore: Pick<PlatformStore, 'getServiceIds'>);
1010
1158
  listResources(): Promise<LiveResource[]>;
1011
1159
  }
1012
1160
  /** Scope id (no `:`): the share domains the runner's Coolify `domains` was last set to, comma-joined. */