@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.
- package/dist/bootstrap/declare.d.ts +13 -0
- package/dist/bootstrap/declare.js +31 -7
- package/dist/bootstrap/versions.d.ts +1 -0
- package/dist/index.d.ts +7 -3
- package/dist/index.js +4 -2
- package/dist/migrations/0046_platform_hc.sql +19 -0
- package/dist/plan/cloudflared-connector.d.ts +3 -2
- package/dist/plan/cloudflared-connector.js +2 -2
- package/dist/plan/deploy-health.js +5 -1
- package/dist/plan/diff.js +27 -4
- package/dist/plan/edge-access.d.ts +68 -0
- package/dist/plan/edge-access.js +123 -0
- package/dist/plan/edge-dns.d.ts +89 -0
- package/dist/plan/edge-dns.js +227 -0
- package/dist/plan/edge-ratelimits.d.ts +54 -0
- package/dist/plan/edge-ratelimits.js +79 -0
- package/dist/plan/edge-tunnels.d.ts +44 -0
- package/dist/plan/edge-tunnels.js +108 -0
- package/dist/plan/edge-workers.d.ts +31 -0
- package/dist/plan/edge-workers.js +165 -0
- package/dist/plan/edge-zones.d.ts +37 -0
- package/dist/plan/edge-zones.js +64 -0
- package/dist/plan/entry-edge-key.d.ts +10 -0
- package/dist/plan/entry-edge-key.js +38 -8
- package/dist/plan/executor.d.ts +146 -143
- package/dist/plan/executor.js +370 -115
- package/dist/plan/health-check-settings.d.ts +47 -0
- package/dist/plan/health-check-settings.js +47 -0
- package/dist/plan/health-targets.d.ts +86 -0
- package/dist/plan/health-targets.js +344 -0
- package/dist/plan/index.d.ts +17 -3
- package/dist/plan/index.js +9 -2
- package/dist/plan/live.d.ts +12 -1
- package/dist/plan/plan-apply.d.ts +4 -0
- package/dist/plan/plan-apply.js +5 -1
- package/dist/plan/platform-diff.d.ts +127 -0
- package/dist/plan/platform-diff.js +304 -8
- package/dist/plan/platform-providers.d.ts +202 -7
- package/dist/plan/platform-providers.js +545 -79
- package/dist/plan/providers-readers.d.ts +23 -1
- package/dist/plan/providers-readers.js +61 -1
- package/dist/plan/rollout.d.ts +9 -8
- package/dist/platform.d.ts +14 -1
- package/dist/platform.js +1 -0
- package/dist/store/postgres.js +8 -0
- package/dist/store/schema.d.ts +51 -0
- package/dist/store/schema.js +4 -0
- package/package.json +2 -1
- 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
|
-
|
|
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
|