@wtfalch/foundry 0.20.0 → 0.21.1
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 +23 -1
- package/dist/bootstrap/versions.d.ts +1 -0
- package/dist/index.d.ts +2 -0
- package/dist/index.js +1 -0
- package/dist/migrations/0046_platform_hc.sql +19 -0
- package/dist/plan/deploy-health.js +5 -1
- package/dist/plan/diff.js +23 -0
- package/dist/plan/executor.d.ts +34 -0
- package/dist/plan/executor.js +181 -28
- 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 +2 -0
- package/dist/plan/index.js +1 -0
- package/dist/plan/live.d.ts +12 -1
- package/dist/plan/plan-apply.d.ts +4 -0
- package/dist/plan/platform-diff.d.ts +124 -0
- package/dist/plan/platform-diff.js +287 -3
- package/dist/plan/platform-providers.d.ts +150 -2
- package/dist/plan/platform-providers.js +498 -42
- package/dist/plan/providers-readers.js +32 -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 +1 -1
- package/versions.json +2 -1
|
@@ -577,6 +577,12 @@ export declare const PLATFORM_AI_MIGRATE_BLOCKED_SCOPE_KEY = "ai_platform_migrat
|
|
|
577
577
|
* that throws) is repaired by the next one.
|
|
578
578
|
*/
|
|
579
579
|
export declare const PLATFORM_AI_DEPLOY_PENDING_SCOPE_KEY = "ai_platform_deploy_pending";
|
|
580
|
+
/**
|
|
581
|
+
* Set before the `coolify:jobs` step writes the scheduler's env and cleared
|
|
582
|
+
* once its deploy finished, so a run that dies in between is repaired by the
|
|
583
|
+
* next one. The planner plans the step while it is set.
|
|
584
|
+
*/
|
|
585
|
+
export declare const JOBS_DEPLOY_PENDING_SCOPE_KEY = "jobs_deploy_pending";
|
|
580
586
|
/**
|
|
581
587
|
* #171: the resource key `planFiles` plans under and `FoundryPlatformStepExecutor`
|
|
582
588
|
* dispatches on, the {@link PLATFORM_AUTH_KEY} shape for the files host.
|
|
@@ -674,6 +680,104 @@ export declare const PLATFORM_EDGE_APPLIED_GIT_REF_SCOPE_KEY = "edge_applied_git
|
|
|
674
680
|
* (the {@link PLATFORM_FILES_INTERNAL_URL_SCOPE_KEY} pattern). Not a secret.
|
|
675
681
|
*/
|
|
676
682
|
export declare const PLATFORM_EDGE_INTERNAL_URL_SCOPE_KEY = "edge_platform_internal_url";
|
|
683
|
+
/**
|
|
684
|
+
* #540: the resource key `planHc` plans under and `FoundryPlatformStepExecutor`
|
|
685
|
+
* dispatches on -- the {@link PLATFORM_EDGE_KEY} shape for the platform's own
|
|
686
|
+
* heartbeat server (self-hosted Healthchecks). `runPlatformSteps` records
|
|
687
|
+
* the app's uuid under `coolify:app:platform:hc` once the step succeeds,
|
|
688
|
+
* which is what stops `planHc` planning it again.
|
|
689
|
+
*/
|
|
690
|
+
export declare const PLATFORM_HC_KEY = "platform:hc";
|
|
691
|
+
/**
|
|
692
|
+
* #540: the hostname and `gitRef` (a Docker image tag, like `mail.gitRef`)
|
|
693
|
+
* the last successful `hc` run applied. `planHc` plans the step again when
|
|
694
|
+
* either differs from the declared value; a re-run patches the image tag
|
|
695
|
+
* and redeploys, and mints nothing new (`SECRET_KEY` is generated once and
|
|
696
|
+
* read back, never regenerated -- see `ensureGeneratedEnv`).
|
|
697
|
+
*/
|
|
698
|
+
export declare const PLATFORM_HC_APPLIED_HOSTNAME_SCOPE_KEY = "hc_applied_hostname";
|
|
699
|
+
export declare const PLATFORM_HC_APPLIED_GIT_REF_SCOPE_KEY = "hc_applied_git_ref";
|
|
700
|
+
/**
|
|
701
|
+
* #540 round 2: the held-key ref a human puts hc's first project API key
|
|
702
|
+
* under -- `deliverSecret`'s naming convention (`EDGE_URL`'s own pattern),
|
|
703
|
+
* `<service>:<scope>:<name>` like {@link PLATFORM_EDGE_KEY_REF}. Nothing in
|
|
704
|
+
* this package mints this key: `provisionPlatformHc` stands up an empty
|
|
705
|
+
* Healthchecks instance with no user, no project and so no key, and neither
|
|
706
|
+
* a `manage.py` command nor a documented API mints a project's own API key
|
|
707
|
+
* (confirmed against `healthchecks/healthchecks`'s own source, 2026-10) --
|
|
708
|
+
* only its web UI does, after a first superuser signs in by hand. Until a
|
|
709
|
+
* human does that and holds the key here, `FoundryPlatformStepExecutor
|
|
710
|
+
* .blockedSteps` (`platform-providers.ts`) reports the heartbeat checks
|
|
711
|
+
* blocked (`PlanPlatformOptions.blocked.heartbeatsApiKeyMissing`) rather
|
|
712
|
+
* than let `planHeartbeats` plan a step that would call hc's real
|
|
713
|
+
* Management API with no account and no key behind it -- see issue #540's
|
|
714
|
+
* own "Human steps" note. This package never reads the value back: whoever
|
|
715
|
+
* implements {@link HeartbeatsPort} against hc's real HTTP API is the host,
|
|
716
|
+
* and that implementation is where the key is actually used.
|
|
717
|
+
*/
|
|
718
|
+
export declare const PLATFORM_HC_API_KEY_REF = "hc:platform:apiKey";
|
|
719
|
+
/**
|
|
720
|
+
* #540: the heartbeat check named after the uptime Worker (foundry#542,
|
|
721
|
+
* which is what actually pings it -- blocked today on edge#26, so nothing
|
|
722
|
+
* pings this check yet; it still exists so the Worker has a URL to deliver
|
|
723
|
+
* to the moment it can). `platformBackupHeartbeatCheckName` is the matching
|
|
724
|
+
* name for each platform database backup (`PLATFORM_BACKUP_SERVICE_KEYS`).
|
|
725
|
+
* Both names are also the prefix `provisionPlatformHeartbeats`
|
|
726
|
+
* (`platform-providers.ts`) uses to recognise which live Healthchecks
|
|
727
|
+
* checks are its own to delete when they are no longer wanted -- it never
|
|
728
|
+
* touches a check outside this naming scheme.
|
|
729
|
+
*/
|
|
730
|
+
export declare const PLATFORM_UPTIME_HEARTBEAT_CHECK_NAME = "uptime-worker";
|
|
731
|
+
/** #540: see {@link PLATFORM_UPTIME_HEARTBEAT_CHECK_NAME}'s own doc comment. */
|
|
732
|
+
export declare function platformBackupHeartbeatCheckName(service: PlatformBackupServiceKey): string;
|
|
733
|
+
/**
|
|
734
|
+
* #540: the held-key ref each heartbeat check's own ping URL is recorded
|
|
735
|
+
* under, once created -- `deliverSecret`'s ref-naming convention
|
|
736
|
+
* (`EDGE_URL`'s own pattern), read back by whoever owns that piece of
|
|
737
|
+
* scheduled work. `platformBackupHeartbeatUrlRef` is the per-service
|
|
738
|
+
* counterpart. Unlike a minted credential (CLAUDE.md platform-step rule 1),
|
|
739
|
+
* a ping URL is not retried-and-guarded the same way: it is closer to a
|
|
740
|
+
* resource id, so `provisionPlatformHeartbeats` simply records it again
|
|
741
|
+
* every time the check it names is (re)created.
|
|
742
|
+
*/
|
|
743
|
+
export declare const PLATFORM_UPTIME_HEARTBEAT_URL_REF = "uptime:heartbeatUrl";
|
|
744
|
+
/** #540: see {@link PLATFORM_UPTIME_HEARTBEAT_URL_REF}'s own doc comment. */
|
|
745
|
+
export declare function platformBackupHeartbeatUrlRef(service: PlatformBackupServiceKey): string;
|
|
746
|
+
/**
|
|
747
|
+
* #540: every heartbeat check name foundry currently wants -- the uptime
|
|
748
|
+
* Worker's own, plus one per platform database backup that already has a
|
|
749
|
+
* recorded database id (`PLATFORM_BACKUP_SERVICE_KEYS`, the same "nothing to
|
|
750
|
+
* report yet" gate {@link planBackups} itself uses for a service with no
|
|
751
|
+
* database). Shared by {@link planHeartbeats} (to tell whether anything
|
|
752
|
+
* declared has changed since the last successful reconcile) and
|
|
753
|
+
* `provisionPlatformHeartbeats` (`platform-providers.ts`, to decide what to
|
|
754
|
+
* create or delete live) so the two can never compute a different desired
|
|
755
|
+
* set from each other.
|
|
756
|
+
*/
|
|
757
|
+
export declare function desiredHeartbeatCheckNames(recordedIds: Readonly<Record<string, string>>): readonly string[];
|
|
758
|
+
/**
|
|
759
|
+
* #540: the held-key ref a check named `name` (one {@link desiredHeartbeatCheckNames}
|
|
760
|
+
* produced) records its ping URL under -- `platform-providers.ts`'s
|
|
761
|
+
* `provisionPlatformHeartbeats` looks this up rather than re-deriving it
|
|
762
|
+
* inline, so the two name-to-ref mappings ({@link PLATFORM_UPTIME_HEARTBEAT_URL_REF}
|
|
763
|
+
* and {@link platformBackupHeartbeatUrlRef}) stay reachable from the name
|
|
764
|
+
* alone. Throws on a name this module did not itself produce -- a
|
|
765
|
+
* programmer error, never a live value.
|
|
766
|
+
*/
|
|
767
|
+
export declare function heartbeatCheckUrlRef(name: string): string;
|
|
768
|
+
/**
|
|
769
|
+
* #540: the sorted, comma-joined {@link desiredHeartbeatCheckNames} as of the
|
|
770
|
+
* last successful reconcile -- recorded by `provisionPlatformHeartbeats`
|
|
771
|
+
* once it has created every missing check and deleted every stale one.
|
|
772
|
+
* {@link planHeartbeats} plans the reconcile step again only when the
|
|
773
|
+
* declared desired set has since changed (a backup service's database
|
|
774
|
+
* arrived, say), not on every apply: `driveApply`'s bootstrap loop
|
|
775
|
+
* (`bootstrap/drive.ts`) only ever terminates once a plan has zero steps, so
|
|
776
|
+
* a step planned unconditionally on every round -- as this one once was --
|
|
777
|
+
* never lets a platform with `hc` enabled (the default, #540) finish
|
|
778
|
+
* bootstrapping at all.
|
|
779
|
+
*/
|
|
780
|
+
export declare const PLATFORM_HEARTBEATS_APPLIED_SCOPE_KEY = "heartbeats_applied_desired";
|
|
677
781
|
/**
|
|
678
782
|
* Options for {@link planPlatform}, replacing four positional arguments that
|
|
679
783
|
* shifted on every merge that added a platform service (#186, #187, #188) --
|
|
@@ -721,6 +825,26 @@ export interface PlanPlatformOptions {
|
|
|
721
825
|
* "executor missing" shape {@link authExecutorMissing} has for auth.
|
|
722
826
|
*/
|
|
723
827
|
backupExecutorMissing?: boolean;
|
|
828
|
+
/** #540: why `hc` is blocked, or absent when it is not. */
|
|
829
|
+
hc?: string;
|
|
830
|
+
/**
|
|
831
|
+
* #540: true once `hc` is applied but the host built the platform
|
|
832
|
+
* executor with no heartbeats dependencies -- the heartbeat-checks step
|
|
833
|
+
* is blocked, not planned, the same "executor missing" shape
|
|
834
|
+
* {@link backupExecutorMissing} has for a backup.
|
|
835
|
+
*/
|
|
836
|
+
heartbeatsExecutorMissing?: boolean;
|
|
837
|
+
/**
|
|
838
|
+
* #540 round 2: true while the heartbeats dependencies are wired but no
|
|
839
|
+
* value is held under {@link PLATFORM_HC_API_KEY_REF} -- there is no
|
|
840
|
+
* bootstrap that mints hc's first superuser and project API key yet
|
|
841
|
+
* (issue #540's own "Decide"), so a fresh platform's heartbeats step
|
|
842
|
+
* would otherwise call hc's real Management API with no account and no
|
|
843
|
+
* key behind it. Blocked, not planned, until a human creates both by
|
|
844
|
+
* hand and holds the key under that ref; see this flag's own "Human
|
|
845
|
+
* steps" note in `platform-providers.ts`'s `FoundryPlatformStepExecutor.blockedSteps`.
|
|
846
|
+
*/
|
|
847
|
+
heartbeatsApiKeyMissing?: boolean;
|
|
724
848
|
};
|
|
725
849
|
/**
|
|
726
850
|
* Steps whose live state differs from the declared one in a way the recorded
|
|
@@ -653,6 +653,12 @@ export const PLATFORM_AI_MIGRATE_BLOCKED_SCOPE_KEY = 'ai_platform_migrate_blocke
|
|
|
653
653
|
* that throws) is repaired by the next one.
|
|
654
654
|
*/
|
|
655
655
|
export const PLATFORM_AI_DEPLOY_PENDING_SCOPE_KEY = 'ai_platform_deploy_pending';
|
|
656
|
+
/**
|
|
657
|
+
* Set before the `coolify:jobs` step writes the scheduler's env and cleared
|
|
658
|
+
* once its deploy finished, so a run that dies in between is repaired by the
|
|
659
|
+
* next one. The planner plans the step while it is set.
|
|
660
|
+
*/
|
|
661
|
+
export const JOBS_DEPLOY_PENDING_SCOPE_KEY = 'jobs_deploy_pending';
|
|
656
662
|
/**
|
|
657
663
|
* #171: the resource key `planFiles` plans under and `FoundryPlatformStepExecutor`
|
|
658
664
|
* dispatches on, the {@link PLATFORM_AUTH_KEY} shape for the files host.
|
|
@@ -750,6 +756,124 @@ export const PLATFORM_EDGE_APPLIED_GIT_REF_SCOPE_KEY = 'edge_applied_git_ref';
|
|
|
750
756
|
* (the {@link PLATFORM_FILES_INTERNAL_URL_SCOPE_KEY} pattern). Not a secret.
|
|
751
757
|
*/
|
|
752
758
|
export const PLATFORM_EDGE_INTERNAL_URL_SCOPE_KEY = 'edge_platform_internal_url';
|
|
759
|
+
/**
|
|
760
|
+
* #540: the resource key `planHc` plans under and `FoundryPlatformStepExecutor`
|
|
761
|
+
* dispatches on -- the {@link PLATFORM_EDGE_KEY} shape for the platform's own
|
|
762
|
+
* heartbeat server (self-hosted Healthchecks). `runPlatformSteps` records
|
|
763
|
+
* the app's uuid under `coolify:app:platform:hc` once the step succeeds,
|
|
764
|
+
* which is what stops `planHc` planning it again.
|
|
765
|
+
*/
|
|
766
|
+
export const PLATFORM_HC_KEY = 'platform:hc';
|
|
767
|
+
/**
|
|
768
|
+
* #540: the hostname and `gitRef` (a Docker image tag, like `mail.gitRef`)
|
|
769
|
+
* the last successful `hc` run applied. `planHc` plans the step again when
|
|
770
|
+
* either differs from the declared value; a re-run patches the image tag
|
|
771
|
+
* and redeploys, and mints nothing new (`SECRET_KEY` is generated once and
|
|
772
|
+
* read back, never regenerated -- see `ensureGeneratedEnv`).
|
|
773
|
+
*/
|
|
774
|
+
export const PLATFORM_HC_APPLIED_HOSTNAME_SCOPE_KEY = 'hc_applied_hostname';
|
|
775
|
+
export const PLATFORM_HC_APPLIED_GIT_REF_SCOPE_KEY = 'hc_applied_git_ref';
|
|
776
|
+
/**
|
|
777
|
+
* #540 round 2: the held-key ref a human puts hc's first project API key
|
|
778
|
+
* under -- `deliverSecret`'s naming convention (`EDGE_URL`'s own pattern),
|
|
779
|
+
* `<service>:<scope>:<name>` like {@link PLATFORM_EDGE_KEY_REF}. Nothing in
|
|
780
|
+
* this package mints this key: `provisionPlatformHc` stands up an empty
|
|
781
|
+
* Healthchecks instance with no user, no project and so no key, and neither
|
|
782
|
+
* a `manage.py` command nor a documented API mints a project's own API key
|
|
783
|
+
* (confirmed against `healthchecks/healthchecks`'s own source, 2026-10) --
|
|
784
|
+
* only its web UI does, after a first superuser signs in by hand. Until a
|
|
785
|
+
* human does that and holds the key here, `FoundryPlatformStepExecutor
|
|
786
|
+
* .blockedSteps` (`platform-providers.ts`) reports the heartbeat checks
|
|
787
|
+
* blocked (`PlanPlatformOptions.blocked.heartbeatsApiKeyMissing`) rather
|
|
788
|
+
* than let `planHeartbeats` plan a step that would call hc's real
|
|
789
|
+
* Management API with no account and no key behind it -- see issue #540's
|
|
790
|
+
* own "Human steps" note. This package never reads the value back: whoever
|
|
791
|
+
* implements {@link HeartbeatsPort} against hc's real HTTP API is the host,
|
|
792
|
+
* and that implementation is where the key is actually used.
|
|
793
|
+
*/
|
|
794
|
+
export const PLATFORM_HC_API_KEY_REF = 'hc:platform:apiKey';
|
|
795
|
+
/**
|
|
796
|
+
* #540: the heartbeat check named after the uptime Worker (foundry#542,
|
|
797
|
+
* which is what actually pings it -- blocked today on edge#26, so nothing
|
|
798
|
+
* pings this check yet; it still exists so the Worker has a URL to deliver
|
|
799
|
+
* to the moment it can). `platformBackupHeartbeatCheckName` is the matching
|
|
800
|
+
* name for each platform database backup (`PLATFORM_BACKUP_SERVICE_KEYS`).
|
|
801
|
+
* Both names are also the prefix `provisionPlatformHeartbeats`
|
|
802
|
+
* (`platform-providers.ts`) uses to recognise which live Healthchecks
|
|
803
|
+
* checks are its own to delete when they are no longer wanted -- it never
|
|
804
|
+
* touches a check outside this naming scheme.
|
|
805
|
+
*/
|
|
806
|
+
export const PLATFORM_UPTIME_HEARTBEAT_CHECK_NAME = 'uptime-worker';
|
|
807
|
+
/** #540: see {@link PLATFORM_UPTIME_HEARTBEAT_CHECK_NAME}'s own doc comment. */
|
|
808
|
+
export function platformBackupHeartbeatCheckName(service) {
|
|
809
|
+
return `backup-${service}`;
|
|
810
|
+
}
|
|
811
|
+
/**
|
|
812
|
+
* #540: the held-key ref each heartbeat check's own ping URL is recorded
|
|
813
|
+
* under, once created -- `deliverSecret`'s ref-naming convention
|
|
814
|
+
* (`EDGE_URL`'s own pattern), read back by whoever owns that piece of
|
|
815
|
+
* scheduled work. `platformBackupHeartbeatUrlRef` is the per-service
|
|
816
|
+
* counterpart. Unlike a minted credential (CLAUDE.md platform-step rule 1),
|
|
817
|
+
* a ping URL is not retried-and-guarded the same way: it is closer to a
|
|
818
|
+
* resource id, so `provisionPlatformHeartbeats` simply records it again
|
|
819
|
+
* every time the check it names is (re)created.
|
|
820
|
+
*/
|
|
821
|
+
export const PLATFORM_UPTIME_HEARTBEAT_URL_REF = 'uptime:heartbeatUrl';
|
|
822
|
+
/** #540: see {@link PLATFORM_UPTIME_HEARTBEAT_URL_REF}'s own doc comment. */
|
|
823
|
+
export function platformBackupHeartbeatUrlRef(service) {
|
|
824
|
+
return `backup:${service}:heartbeatUrl`;
|
|
825
|
+
}
|
|
826
|
+
/**
|
|
827
|
+
* #540: every heartbeat check name foundry currently wants -- the uptime
|
|
828
|
+
* Worker's own, plus one per platform database backup that already has a
|
|
829
|
+
* recorded database id (`PLATFORM_BACKUP_SERVICE_KEYS`, the same "nothing to
|
|
830
|
+
* report yet" gate {@link planBackups} itself uses for a service with no
|
|
831
|
+
* database). Shared by {@link planHeartbeats} (to tell whether anything
|
|
832
|
+
* declared has changed since the last successful reconcile) and
|
|
833
|
+
* `provisionPlatformHeartbeats` (`platform-providers.ts`, to decide what to
|
|
834
|
+
* create or delete live) so the two can never compute a different desired
|
|
835
|
+
* set from each other.
|
|
836
|
+
*/
|
|
837
|
+
export function desiredHeartbeatCheckNames(recordedIds) {
|
|
838
|
+
const names = [PLATFORM_UPTIME_HEARTBEAT_CHECK_NAME];
|
|
839
|
+
for (const service of PLATFORM_BACKUP_SERVICE_KEYS) {
|
|
840
|
+
if (recordedIds[PLATFORM_BACKUP_DATABASE_SCOPE_KEYS[service]]) {
|
|
841
|
+
names.push(platformBackupHeartbeatCheckName(service));
|
|
842
|
+
}
|
|
843
|
+
}
|
|
844
|
+
return names;
|
|
845
|
+
}
|
|
846
|
+
/**
|
|
847
|
+
* #540: the held-key ref a check named `name` (one {@link desiredHeartbeatCheckNames}
|
|
848
|
+
* produced) records its ping URL under -- `platform-providers.ts`'s
|
|
849
|
+
* `provisionPlatformHeartbeats` looks this up rather than re-deriving it
|
|
850
|
+
* inline, so the two name-to-ref mappings ({@link PLATFORM_UPTIME_HEARTBEAT_URL_REF}
|
|
851
|
+
* and {@link platformBackupHeartbeatUrlRef}) stay reachable from the name
|
|
852
|
+
* alone. Throws on a name this module did not itself produce -- a
|
|
853
|
+
* programmer error, never a live value.
|
|
854
|
+
*/
|
|
855
|
+
export function heartbeatCheckUrlRef(name) {
|
|
856
|
+
if (name === PLATFORM_UPTIME_HEARTBEAT_CHECK_NAME)
|
|
857
|
+
return PLATFORM_UPTIME_HEARTBEAT_URL_REF;
|
|
858
|
+
for (const service of PLATFORM_BACKUP_SERVICE_KEYS) {
|
|
859
|
+
if (name === platformBackupHeartbeatCheckName(service))
|
|
860
|
+
return platformBackupHeartbeatUrlRef(service);
|
|
861
|
+
}
|
|
862
|
+
throw new Error(`heartbeatCheckUrlRef: unrecognised heartbeat check name '${name}'`);
|
|
863
|
+
}
|
|
864
|
+
/**
|
|
865
|
+
* #540: the sorted, comma-joined {@link desiredHeartbeatCheckNames} as of the
|
|
866
|
+
* last successful reconcile -- recorded by `provisionPlatformHeartbeats`
|
|
867
|
+
* once it has created every missing check and deleted every stale one.
|
|
868
|
+
* {@link planHeartbeats} plans the reconcile step again only when the
|
|
869
|
+
* declared desired set has since changed (a backup service's database
|
|
870
|
+
* arrived, say), not on every apply: `driveApply`'s bootstrap loop
|
|
871
|
+
* (`bootstrap/drive.ts`) only ever terminates once a plan has zero steps, so
|
|
872
|
+
* a step planned unconditionally on every round -- as this one once was --
|
|
873
|
+
* never lets a platform with `hc` enabled (the default, #540) finish
|
|
874
|
+
* bootstrapping at all.
|
|
875
|
+
*/
|
|
876
|
+
export const PLATFORM_HEARTBEATS_APPLIED_SCOPE_KEY = 'heartbeats_applied_desired';
|
|
753
877
|
/**
|
|
754
878
|
* #167: the platform's own diff engine, mirroring `diff.ts`'s
|
|
755
879
|
* `computePlanDetail` for the one shared {@link DeclaredPlatform} record
|
|
@@ -782,7 +906,7 @@ export function planPlatform(platform, live, options = {}) {
|
|
|
782
906
|
plannedSteps.push(zone);
|
|
783
907
|
// #173: the jobs scheduler, second of the eight services this frame leaves
|
|
784
908
|
// open (this file's own doc comment above).
|
|
785
|
-
const jobs = planJobs(platform, byKey, claimed, jobsObservable);
|
|
909
|
+
const jobs = planJobs(platform, byKey, claimed, jobsObservable, recordedIds);
|
|
786
910
|
if (jobs.step)
|
|
787
911
|
plannedSteps.push(jobs.step);
|
|
788
912
|
// A blocked SFU is reported in `unmanaged`, not planned: a step the
|
|
@@ -808,6 +932,17 @@ export function planPlatform(platform, live, options = {}) {
|
|
|
808
932
|
: (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
933
|
? (edge.blocked ?? 'its step is not planned')
|
|
810
934
|
: 'the platform does not enable edge'})`;
|
|
935
|
+
// #540: hc needs only edge's DNS, the same as mail and archon -- no
|
|
936
|
+
// auth/ZITADEL client, so it is planned here rather than after `authReady`
|
|
937
|
+
// further down.
|
|
938
|
+
const hc = planHc(platform, recordedIds, blocked.hc ?? dnsBlocked?.('heartbeat server'));
|
|
939
|
+
if (hc.step)
|
|
940
|
+
plannedSteps.push(hc.step);
|
|
941
|
+
// #540: the `authReady` pattern, for hc -- true once hc is either already
|
|
942
|
+
// applied or about to run earlier in this very plan, so the heartbeats
|
|
943
|
+
// reconcile below can be planned in the *same* round hc first comes up,
|
|
944
|
+
// the same way files/mail/archon are planned in auth's own first round.
|
|
945
|
+
const hcReady = hc.step !== null || Boolean(recordedIds[resourceKey('coolify', 'app', PLATFORM_HC_KEY)]);
|
|
811
946
|
const auth = planAuth(platform, recordedIds, blocked.auth ?? dnsBlocked?.('sign-in'), blocked.authExecutorMissing ?? false, drifted.auth ?? false, blocked.authOtherInputsMissing ?? []);
|
|
812
947
|
if (auth.step)
|
|
813
948
|
plannedSteps.push(auth.step);
|
|
@@ -841,6 +976,12 @@ export function planPlatform(platform, live, options = {}) {
|
|
|
841
976
|
// recorded is already visible to `recordedIds` on this same plan.
|
|
842
977
|
const backups = planBackups(byKey, claimed, recordedIds, blocked.backupExecutorMissing ?? false, drifted.backupStorage ?? false, drifted.backupStorageBlocked);
|
|
843
978
|
plannedSteps.push(...backups.steps);
|
|
979
|
+
// #540: last of all, so the backup databases `planBackups` just saw above
|
|
980
|
+
// are the same set this reconcile sees too -- one check per platform
|
|
981
|
+
// database backup, plus the uptime Worker's own.
|
|
982
|
+
const heartbeats = planHeartbeats(platform, recordedIds, hcReady, blocked.heartbeatsExecutorMissing ?? false, blocked.heartbeatsApiKeyMissing ?? false);
|
|
983
|
+
if (heartbeats.step)
|
|
984
|
+
plannedSteps.push(heartbeats.step);
|
|
844
985
|
// #251: one line per service whose recorded applied ref no longer matches
|
|
845
986
|
// what the next apply would use (or, for zitadel, what is actually live).
|
|
846
987
|
// Never pushed into `plannedSteps` -- see `VersionChangeItem`'s own doc
|
|
@@ -867,6 +1008,9 @@ export function planPlatform(platform, live, options = {}) {
|
|
|
867
1008
|
edge.blocked
|
|
868
1009
|
? null
|
|
869
1010
|
: versionChangeLine('edge', recordedIds[PLATFORM_EDGE_APPLIED_GIT_REF_SCOPE_KEY], platform.edge.gitRef, 'git ref, redeploy'),
|
|
1011
|
+
hc.blocked
|
|
1012
|
+
? null
|
|
1013
|
+
: versionChangeLine('hc', recordedIds[PLATFORM_HC_APPLIED_GIT_REF_SCOPE_KEY], platform.hc.gitRef, 'image, redeploy'),
|
|
870
1014
|
auth.blocked
|
|
871
1015
|
? null
|
|
872
1016
|
: versionNotAppliedLine('zitadel', Boolean(recordedIds[resourceKey('coolify', 'app', PLATFORM_AUTH_KEY)]), recordedIds[PLATFORM_AUTH_ZITADEL_APPLIED_IMAGE_TAG_SCOPE_KEY], VERSIONS.zitadel),
|
|
@@ -900,6 +1044,8 @@ export function planPlatform(platform, live, options = {}) {
|
|
|
900
1044
|
unmanaged.push({ description: ai.blocked, key: 'ai' });
|
|
901
1045
|
if (edge.blocked)
|
|
902
1046
|
unmanaged.push({ description: edge.blocked, key: 'edge' });
|
|
1047
|
+
if (hc.blocked)
|
|
1048
|
+
unmanaged.push({ description: hc.blocked, key: 'hc' });
|
|
903
1049
|
if (archon.blocked)
|
|
904
1050
|
unmanaged.push({ description: archon.blocked, key: 'archon' });
|
|
905
1051
|
// #398: the old integrations key stays pinned until Archon holds the new
|
|
@@ -914,6 +1060,8 @@ export function planPlatform(platform, live, options = {}) {
|
|
|
914
1060
|
});
|
|
915
1061
|
}
|
|
916
1062
|
unmanaged.push(...backups.blocked);
|
|
1063
|
+
if (heartbeats.blocked)
|
|
1064
|
+
unmanaged.push({ description: heartbeats.blocked, key: 'heartbeats' });
|
|
917
1065
|
return { plannedSteps: plannedSteps.map(withStepSystem), unmanaged, versionChanges };
|
|
918
1066
|
}
|
|
919
1067
|
/**
|
|
@@ -972,7 +1120,7 @@ export const JOBS_SCHEDULER_KEY = 'jobs';
|
|
|
972
1120
|
* (`diff.ts`'s eventual `jobs:registration`) has nothing to register against
|
|
973
1121
|
* before this exists.
|
|
974
1122
|
*/
|
|
975
|
-
function planJobs(platform, byKey, claimed, observable) {
|
|
1123
|
+
function planJobs(platform, byKey, claimed, observable, recordedIds) {
|
|
976
1124
|
const key = resourceKey('coolify', 'jobs', JOBS_SCHEDULER_KEY);
|
|
977
1125
|
const live = byKey.get(key);
|
|
978
1126
|
if (live)
|
|
@@ -983,8 +1131,14 @@ function planJobs(platform, byKey, claimed, observable) {
|
|
|
983
1131
|
// The step also starts the scheduler's database (0.16.3), so a live app
|
|
984
1132
|
// whose database the reader saw stopped plans it again. A status the
|
|
985
1133
|
// reader could not read is absent, and plans nothing.
|
|
1134
|
+
// An app without `DATABASE_URL_OWNER` also plans it again: the step
|
|
1135
|
+
// sets the owner and runtime URLs the scheduler's migrate stage needs.
|
|
1136
|
+
// So does a recorded pending deploy: the step wrote its env and died
|
|
1137
|
+
// before the deploy finished.
|
|
986
1138
|
const databaseStatus = live.config.databaseStatus;
|
|
987
|
-
if (
|
|
1139
|
+
if (live.config.ownerUrlMissing !== true &&
|
|
1140
|
+
!recordedIds[JOBS_DEPLOY_PENDING_SCOPE_KEY] &&
|
|
1141
|
+
(typeof databaseStatus !== 'string' || databaseStatus.startsWith('running'))) {
|
|
988
1142
|
return { step: null, blocked: null };
|
|
989
1143
|
}
|
|
990
1144
|
}
|
|
@@ -1826,6 +1980,136 @@ function planEdge(platform, recordedIds, executorBlocked) {
|
|
|
1826
1980
|
blocked: null,
|
|
1827
1981
|
};
|
|
1828
1982
|
}
|
|
1983
|
+
/**
|
|
1984
|
+
* #540: a self-hosted heartbeat server (Healthchecks) for the platform, for
|
|
1985
|
+
* now -- a Coolify Postgres plus one Docker-image app
|
|
1986
|
+
* (`healthchecks/healthchecks`), reached through edge's tunnel and DNS at
|
|
1987
|
+
* `platform.hc.hostname`, the {@link planEdge} bundling shape minus any
|
|
1988
|
+
* auth/ZITADEL client: `hc` is an internal operational tool, not something
|
|
1989
|
+
* an organisation member signs into. Whether this stays the platform's
|
|
1990
|
+
* heartbeat server long-term, instead of or alongside the hand-set-up
|
|
1991
|
+
* `hc.wtfalch.dev` instance, and what happens to that instance's own data,
|
|
1992
|
+
* are both still open "Decide" items on issue #540's own thread -- not
|
|
1993
|
+
* settled by this code.
|
|
1994
|
+
*
|
|
1995
|
+
* Idempotent on the recorded app id plus the applied hostname and `gitRef`,
|
|
1996
|
+
* for {@link planAuth}'s own reason: no platform reader reports this
|
|
1997
|
+
* Coolify app. A changed hostname or `gitRef` plans the step again (a
|
|
1998
|
+
* redeploy, no new secret -- `SECRET_KEY` is generated once by
|
|
1999
|
+
* `provisionPlatformHc` and read back, never regenerated).
|
|
2000
|
+
*
|
|
2001
|
+
* Blocked, not planned: `executorBlocked`, from `dnsBlocked` (edge neither
|
|
2002
|
+
* applied nor planned) or the host building no `hc` dependencies.
|
|
2003
|
+
*/
|
|
2004
|
+
function planHc(platform, recordedIds, executorBlocked) {
|
|
2005
|
+
if (!platform.hc.enabled)
|
|
2006
|
+
return { step: null, blocked: null };
|
|
2007
|
+
const { hostname, gitRef } = platform.hc;
|
|
2008
|
+
const applied = Boolean(recordedIds[resourceKey('coolify', 'app', PLATFORM_HC_KEY)]) &&
|
|
2009
|
+
recordedIds[PLATFORM_HC_APPLIED_HOSTNAME_SCOPE_KEY] === hostname &&
|
|
2010
|
+
recordedIds[PLATFORM_HC_APPLIED_GIT_REF_SCOPE_KEY] === gitRef;
|
|
2011
|
+
if (applied)
|
|
2012
|
+
return { step: null, blocked: null };
|
|
2013
|
+
if (executorBlocked)
|
|
2014
|
+
return { step: null, blocked: executorBlocked };
|
|
2015
|
+
return {
|
|
2016
|
+
step: {
|
|
2017
|
+
action: {
|
|
2018
|
+
system: 'coolify',
|
|
2019
|
+
kind: 'app',
|
|
2020
|
+
key: PLATFORM_HC_KEY,
|
|
2021
|
+
desired: { hostname, gitRef },
|
|
2022
|
+
},
|
|
2023
|
+
step: {
|
|
2024
|
+
description: `provision the heartbeat server (Healthchecks) at ${hostname}, with its own database`,
|
|
2025
|
+
destroys: false,
|
|
2026
|
+
migrates: false,
|
|
2027
|
+
},
|
|
2028
|
+
},
|
|
2029
|
+
blocked: null,
|
|
2030
|
+
};
|
|
2031
|
+
}
|
|
2032
|
+
/**
|
|
2033
|
+
* #540: one reconcile step for every heartbeat check foundry owns -- the
|
|
2034
|
+
* uptime Worker's own check plus one per platform database backup
|
|
2035
|
+
* (`PLATFORM_BACKUP_SERVICE_KEYS`, filtered the same way {@link planBackups}
|
|
2036
|
+
* is to "has a recorded database", so a service with no database yet gets
|
|
2037
|
+
* no standing check either). The actual create-missing/delete-stale
|
|
2038
|
+
* reconciling against the live Healthchecks list (`HeartbeatsPort.listChecks`,
|
|
2039
|
+
* `platform-providers.ts`) happens inside `provisionPlatformHeartbeats`
|
|
2040
|
+
* itself, not here -- this function only decides *whether* that step runs
|
|
2041
|
+
* at all: never before `hc` is ready (`hcReady`, the {@link planPlatform}
|
|
2042
|
+
* own `authReady` pattern -- already applied, or about to be, earlier in
|
|
2043
|
+
* this same plan), never with no heartbeats dependencies wired, and never
|
|
2044
|
+
* again once {@link desiredHeartbeatCheckNames} already matches
|
|
2045
|
+
* {@link PLATFORM_HEARTBEATS_APPLIED_SCOPE_KEY}'s recorded value.
|
|
2046
|
+
*
|
|
2047
|
+
* That last check is the point: an earlier version of this function planned
|
|
2048
|
+
* the step unconditionally every round, on the theory that
|
|
2049
|
+
* `provisionPlatformHeartbeats` listing live checks first was idempotency
|
|
2050
|
+
* enough. It is, for what it creates -- but `bootstrap/drive.ts`'s
|
|
2051
|
+
* `driveApply` (the loop `bootstrapPlatform` runs to actually stand a
|
|
2052
|
+
* platform up) only stops once a round's plan has zero steps, and `hc` is
|
|
2053
|
+
* enabled by default (`bootstrap/declare.ts`), so a platform that never
|
|
2054
|
+
* recorded "nothing left to reconcile" could never finish bootstrapping at
|
|
2055
|
+
* all -- it would hit `driveApply`'s round cap on every single run. Compare
|
|
2056
|
+
* against the planner's own recorded desired-set marker, declared-to-declared,
|
|
2057
|
+
* so a plan with nothing left to do actually has nothing left to do.
|
|
2058
|
+
*
|
|
2059
|
+
* #540 round 2: also withheld while `apiKeyMissing` -- no human has held a
|
|
2060
|
+
* project API key under {@link PLATFORM_HC_API_KEY_REF} yet, so there is
|
|
2061
|
+
* nothing for the host's own `HeartbeatsPort` to authenticate a real call
|
|
2062
|
+
* with. Without this, a fresh platform's heartbeats step is ready
|
|
2063
|
+
* (`hcReady`) the same round `hc` is merely *planned*, long before anyone
|
|
2064
|
+
* has signed in to it to create that key.
|
|
2065
|
+
*/
|
|
2066
|
+
function planHeartbeats(platform, recordedIds, hcReady, executorMissing, apiKeyMissing) {
|
|
2067
|
+
if (!platform.hc.enabled) {
|
|
2068
|
+
// Removing checks is not planned. The recorded marker names the checks
|
|
2069
|
+
// foundry's last reconcile wanted, so they are foundry's own; report
|
|
2070
|
+
// them rather than leave them unseen.
|
|
2071
|
+
const left = recordedIds[PLATFORM_HEARTBEATS_APPLIED_SCOPE_KEY];
|
|
2072
|
+
return {
|
|
2073
|
+
step: null,
|
|
2074
|
+
blocked: left
|
|
2075
|
+
? `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`
|
|
2076
|
+
: null,
|
|
2077
|
+
};
|
|
2078
|
+
}
|
|
2079
|
+
if (!hcReady) {
|
|
2080
|
+
return {
|
|
2081
|
+
step: null,
|
|
2082
|
+
blocked: 'the heartbeat checks are blocked, not planned: platform:hc has not been applied yet',
|
|
2083
|
+
};
|
|
2084
|
+
}
|
|
2085
|
+
if (executorMissing) {
|
|
2086
|
+
return {
|
|
2087
|
+
step: null,
|
|
2088
|
+
blocked: 'the heartbeat checks are blocked, not planned: the host built the platform executor without its heartbeats dependencies',
|
|
2089
|
+
};
|
|
2090
|
+
}
|
|
2091
|
+
if (apiKeyMissing) {
|
|
2092
|
+
return {
|
|
2093
|
+
step: null,
|
|
2094
|
+
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`,
|
|
2095
|
+
};
|
|
2096
|
+
}
|
|
2097
|
+
const desired = [...desiredHeartbeatCheckNames(recordedIds)].sort().join(',');
|
|
2098
|
+
if (recordedIds[PLATFORM_HEARTBEATS_APPLIED_SCOPE_KEY] === desired) {
|
|
2099
|
+
return { step: null, blocked: null };
|
|
2100
|
+
}
|
|
2101
|
+
return {
|
|
2102
|
+
step: {
|
|
2103
|
+
action: { system: 'heartbeats', kind: 'reconcile', key: 'platform', desired: {} },
|
|
2104
|
+
step: {
|
|
2105
|
+
description: 'reconcile heartbeat checks for the uptime Worker and every platform backup service',
|
|
2106
|
+
destroys: false,
|
|
2107
|
+
migrates: false,
|
|
2108
|
+
},
|
|
2109
|
+
},
|
|
2110
|
+
blocked: null,
|
|
2111
|
+
};
|
|
2112
|
+
}
|
|
1829
2113
|
/**
|
|
1830
2114
|
* #172: the platform's AI host (https://github.com/wtfalch/ai) -- a Coolify
|
|
1831
2115
|
* Postgres plus three apps built from that repo's root Dockerfile, one per
|