@wtfalch/foundry 0.19.1 → 0.21.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (49) hide show
  1. package/dist/bootstrap/declare.d.ts +13 -0
  2. package/dist/bootstrap/declare.js +31 -7
  3. package/dist/bootstrap/versions.d.ts +1 -0
  4. package/dist/index.d.ts +7 -3
  5. package/dist/index.js +4 -2
  6. package/dist/migrations/0046_platform_hc.sql +19 -0
  7. package/dist/plan/cloudflared-connector.d.ts +3 -2
  8. package/dist/plan/cloudflared-connector.js +2 -2
  9. package/dist/plan/deploy-health.js +5 -1
  10. package/dist/plan/diff.js +27 -4
  11. package/dist/plan/edge-access.d.ts +68 -0
  12. package/dist/plan/edge-access.js +123 -0
  13. package/dist/plan/edge-dns.d.ts +89 -0
  14. package/dist/plan/edge-dns.js +227 -0
  15. package/dist/plan/edge-ratelimits.d.ts +54 -0
  16. package/dist/plan/edge-ratelimits.js +79 -0
  17. package/dist/plan/edge-tunnels.d.ts +44 -0
  18. package/dist/plan/edge-tunnels.js +108 -0
  19. package/dist/plan/edge-workers.d.ts +31 -0
  20. package/dist/plan/edge-workers.js +165 -0
  21. package/dist/plan/edge-zones.d.ts +37 -0
  22. package/dist/plan/edge-zones.js +64 -0
  23. package/dist/plan/entry-edge-key.d.ts +10 -0
  24. package/dist/plan/entry-edge-key.js +38 -8
  25. package/dist/plan/executor.d.ts +146 -143
  26. package/dist/plan/executor.js +370 -115
  27. package/dist/plan/health-check-settings.d.ts +47 -0
  28. package/dist/plan/health-check-settings.js +47 -0
  29. package/dist/plan/health-targets.d.ts +86 -0
  30. package/dist/plan/health-targets.js +344 -0
  31. package/dist/plan/index.d.ts +17 -3
  32. package/dist/plan/index.js +9 -2
  33. package/dist/plan/live.d.ts +12 -1
  34. package/dist/plan/plan-apply.d.ts +4 -0
  35. package/dist/plan/plan-apply.js +5 -1
  36. package/dist/plan/platform-diff.d.ts +127 -0
  37. package/dist/plan/platform-diff.js +304 -8
  38. package/dist/plan/platform-providers.d.ts +202 -7
  39. package/dist/plan/platform-providers.js +545 -79
  40. package/dist/plan/providers-readers.d.ts +23 -1
  41. package/dist/plan/providers-readers.js +61 -1
  42. package/dist/plan/rollout.d.ts +9 -8
  43. package/dist/platform.d.ts +14 -1
  44. package/dist/platform.js +1 -0
  45. package/dist/store/postgres.js +8 -0
  46. package/dist/store/schema.d.ts +51 -0
  47. package/dist/store/schema.js +4 -0
  48. package/package.json +2 -1
  49. package/versions.json +2 -1
@@ -0,0 +1,47 @@
1
+ /**
2
+ * #543: the one shared helper for the Coolify `updateApplication` quartet
3
+ * that turns an app's own container health check on -- lifted out of
4
+ * `platform-providers.ts`'s `provisionPlatformArchon` (#480), which sent
5
+ * these four fields by hand for Archon alone:
6
+ *
7
+ * ```
8
+ * health_check_enabled: true,
9
+ * health_check_path: ARCHON_HEALTH_CHECK_PATH,
10
+ * health_check_port: Number(ARCHON_PORT),
11
+ * health_check_start_period: ARCHON_HEALTH_CHECK_START_PERIOD_SECONDS,
12
+ * ```
13
+ *
14
+ * Archon's own doc comment explains why this exists at all: without a health
15
+ * check, Coolify's rolling update has nothing to wait on and stops the old
16
+ * container the moment the new one starts, before it can possibly be
17
+ * healthy -- the #475 incident's own failure mode, and the 2026-10-05 Archon
18
+ * 502 this issue (#543) was opened over. Every other app foundry creates
19
+ * that answers an HTTP health route gets the same four fields, through this
20
+ * one function, so a change to the shape (a fifth field, a different
21
+ * default) reaches every caller instead of drifting between Archon's copy
22
+ * and whichever app copied it next.
23
+ *
24
+ * `startPeriodSeconds` defaults to 30 -- Archon's own number
25
+ * (`ARCHON_HEALTH_CHECK_START_PERIOD_SECONDS`'s doc comment: "long enough
26
+ * for the slowest migration run plus boot seen so far, with margin; there is
27
+ * no live measurement to pin it to instead"). A caller whose app is known to
28
+ * be slower or faster to boot passes its own.
29
+ */
30
+ /** The four Coolify `updateApplication` fields {@link healthCheckSettings} returns. */
31
+ export interface HealthCheckPatch {
32
+ health_check_enabled: true;
33
+ health_check_path: string;
34
+ health_check_port: number;
35
+ health_check_start_period: number;
36
+ }
37
+ /** Archon's own number (see this module's doc comment) -- the default every other caller inherits unless it knows better. */
38
+ export declare const DEFAULT_HEALTH_CHECK_START_PERIOD_SECONDS = 30;
39
+ /**
40
+ * The quartet of `updateApplication` fields that turn on and configure an
41
+ * app's own Coolify health check, spread into whatever patch object a caller
42
+ * is already building (`{ ...patch, ...healthCheckSettings(path, port) }`).
43
+ * `port` is the port Coolify's health check probes *inside the container's
44
+ * own network* -- the same port the app's `ports_exposes` (or its
45
+ * Dockerfile's `EXPOSE`) names, not a public one.
46
+ */
47
+ export declare function healthCheckSettings(path: string, port: number, startPeriodSeconds?: number): HealthCheckPatch;
@@ -0,0 +1,47 @@
1
+ /**
2
+ * #543: the one shared helper for the Coolify `updateApplication` quartet
3
+ * that turns an app's own container health check on -- lifted out of
4
+ * `platform-providers.ts`'s `provisionPlatformArchon` (#480), which sent
5
+ * these four fields by hand for Archon alone:
6
+ *
7
+ * ```
8
+ * health_check_enabled: true,
9
+ * health_check_path: ARCHON_HEALTH_CHECK_PATH,
10
+ * health_check_port: Number(ARCHON_PORT),
11
+ * health_check_start_period: ARCHON_HEALTH_CHECK_START_PERIOD_SECONDS,
12
+ * ```
13
+ *
14
+ * Archon's own doc comment explains why this exists at all: without a health
15
+ * check, Coolify's rolling update has nothing to wait on and stops the old
16
+ * container the moment the new one starts, before it can possibly be
17
+ * healthy -- the #475 incident's own failure mode, and the 2026-10-05 Archon
18
+ * 502 this issue (#543) was opened over. Every other app foundry creates
19
+ * that answers an HTTP health route gets the same four fields, through this
20
+ * one function, so a change to the shape (a fifth field, a different
21
+ * default) reaches every caller instead of drifting between Archon's copy
22
+ * and whichever app copied it next.
23
+ *
24
+ * `startPeriodSeconds` defaults to 30 -- Archon's own number
25
+ * (`ARCHON_HEALTH_CHECK_START_PERIOD_SECONDS`'s doc comment: "long enough
26
+ * for the slowest migration run plus boot seen so far, with margin; there is
27
+ * no live measurement to pin it to instead"). A caller whose app is known to
28
+ * be slower or faster to boot passes its own.
29
+ */
30
+ /** Archon's own number (see this module's doc comment) -- the default every other caller inherits unless it knows better. */
31
+ export const DEFAULT_HEALTH_CHECK_START_PERIOD_SECONDS = 30;
32
+ /**
33
+ * The quartet of `updateApplication` fields that turn on and configure an
34
+ * app's own Coolify health check, spread into whatever patch object a caller
35
+ * is already building (`{ ...patch, ...healthCheckSettings(path, port) }`).
36
+ * `port` is the port Coolify's health check probes *inside the container's
37
+ * own network* -- the same port the app's `ports_exposes` (or its
38
+ * Dockerfile's `EXPOSE`) names, not a public one.
39
+ */
40
+ export function healthCheckSettings(path, port, startPeriodSeconds = DEFAULT_HEALTH_CHECK_START_PERIOD_SECONDS) {
41
+ return {
42
+ health_check_enabled: true,
43
+ health_check_path: path,
44
+ health_check_port: port,
45
+ health_check_start_period: startPeriodSeconds,
46
+ };
47
+ }
@@ -0,0 +1,86 @@
1
+ import type { DeclaredOrg } from '../org.js';
2
+ import type { DeclaredPlatform } from '../platform.js';
3
+ /**
4
+ * #541: every service foundry runs, for an outside uptime prober -- the gap
5
+ * `healthUrls` (`executor.ts`) left open. That function returns one bare URL
6
+ * per hosting/site app and `[]` for a company that runs neither, even though
7
+ * foundry always runs Boule, Agora and sign-in for every org, and mail, chat,
8
+ * calls and the shared sign-in broker whenever that org has them
9
+ * (`health-urls.test.ts` line 59 is the empty list this issue replaces for
10
+ * probing). The platform's own services (ZITADEL, the broker, Archon, mail,
11
+ * ai, files, integrations, edge) had no health list at all.
12
+ *
13
+ * `healthTargets`/`platformHealthTargets` are that list, richer than a bare
14
+ * URL string: `url: null` plus `reason` says "not probed" rather than hiding
15
+ * the service, and `restartSafe` tells a future auto-restart step which
16
+ * failing checks it must never act on by itself.
17
+ *
18
+ * Every hostname below comes from the function that already owns it --
19
+ * `bouleHostnameFor`, `agoraHostnameFor`, `loginHostnameFor`, `cmsHostnameFor`,
20
+ * `callsHostnameFor`, `entryHostnames` -- never a second copy of the rule
21
+ * that builds one, the same discipline `healthUrls`'s own doc comment states
22
+ * ("Exported so archon ... never repeats this file's hostname rules").
23
+ * `cmsHostnameFor`/`callsHostnameFor` were private to `executor.ts`; this
24
+ * issue exports them for that reason alone, and `healthUrls` itself is
25
+ * untouched -- Archon still imports it.
26
+ *
27
+ * The *paths* below are not discovered here -- none of the apps they belong
28
+ * to (auth, chat, calls) are built in this repo -- so they are hand-verified
29
+ * facts (#541's own "Real paths today"), cited beside each one against
30
+ * whatever this repo already knows about the service that owns it.
31
+ * `expect` is `{ status: 200 }` everywhere: nothing in this estate's own
32
+ * wait-for-ready logic (`platform-providers.ts`'s `waitForInstance`/
33
+ * `waitForMailInstance`, both `response.ok` alone, no body read) asserts a
34
+ * response body today, so this catalogue does not invent one either.
35
+ */
36
+ /** `'deep'` when the route queries its own database, `'shallow'` when it answers without one, `'page'` when only a page load (no API) is possible. */
37
+ export type HealthTargetDepth = 'deep' | 'shallow' | 'page';
38
+ export interface HealthTargetExpect {
39
+ status: number;
40
+ /** Set only for a route this estate's own code already asserts a body against; none do today (see this file's own doc comment). */
41
+ body?: string;
42
+ }
43
+ export interface HealthTarget {
44
+ /**
45
+ * A stable id, e.g. `'boule'`, `'agora'`, `'login'`, `'mail'`. Not unique
46
+ * *across* `healthTargets` and `platformHealthTargets` -- the two catalogue
47
+ * different things reached at different hostnames (a company's own
48
+ * `loginHostnameFor` vs. the platform's shared `platform.auth.hostname`),
49
+ * even when the id reads the same.
50
+ */
51
+ service: string;
52
+ /** `null` when nothing here can be probed from outside -- see `reason`. */
53
+ url: string | null;
54
+ expect: HealthTargetExpect;
55
+ depth: HealthTargetDepth;
56
+ /**
57
+ * False for anything a future "restart on red" step must never touch
58
+ * mid-incident on its own: a database, auth (ZITADEL, zitadel-login, the
59
+ * broker), mail, chat's Synapse and MAS, the calls service, jobs, Archon.
60
+ * Everything else here is a stateless app container, safe to bounce.
61
+ */
62
+ restartSafe: boolean;
63
+ /** True when Cloudflare Access gates the hostname, so a prober with no service token reads every check as a redirect, never a real answer. */
64
+ behindAccess: boolean;
65
+ /** Set only when `url` is `null`: why nothing can be probed yet. */
66
+ reason?: string;
67
+ }
68
+ /**
69
+ * One target per service foundry runs for this company (#541). Always Boule,
70
+ * Agora and login; mail, chat (the chat service, Synapse, MAS), calls and
71
+ * the broker whenever the org has them; then whichever of hosting or site
72
+ * the org runs, if either.
73
+ */
74
+ export declare function healthTargets(org: DeclaredOrg): HealthTarget[];
75
+ /**
76
+ * The seven platform services #541 names (`platform:auth`, `platform:archon`,
77
+ * `platform:mail`, `platform:ai`, `platform:files`, `platform:integrations`,
78
+ * `platform:edge`) -- `platform:sfu` and `platform:jobs` are shared platform
79
+ * resources too, but neither is in that list, and this issue does not expand
80
+ * it: the SFU has no hostname (`platform-sfu.ts`'s own doc comment: no cheap
81
+ * way to read it over SSH, let alone over HTTP) and the jobs scheduler's
82
+ * admin API answers 401 with no token (`platform-providers.ts`'s own
83
+ * `coolify:jobs` step), so an outside prober could not usefully reach either
84
+ * one anyway.
85
+ */
86
+ export declare function platformHealthTargets(platform: DeclaredPlatform): HealthTarget[];
@@ -0,0 +1,344 @@
1
+ import { APP_ENTRY_KEY, appEntry } from '../org.js';
2
+ import { agoraHostnameFor } from './agora.js';
3
+ import { bouleHostnameFor } from './boule.js';
4
+ import { callsHostnameFor, cmsHostnameFor } from './executor.js';
5
+ import { entryHostnames } from './hostnames.js';
6
+ import { loginHostnameFor } from './login-host.js';
7
+ function okExpect() {
8
+ return { status: 200 };
9
+ }
10
+ const DEFAULT_API_HEALTH_PATH = '/api/health';
11
+ const STATIC_PAGE_PATH = '/';
12
+ /** zitadel-login's own readiness route (#541's "Real paths today"). `depth: 'shallow'`, not `'deep'`: login holds no database of its own (`login-host.ts`), whatever its name suggests. */
13
+ const LOGIN_READY_PATH = '/ready';
14
+ /** The broker answers on `platform.auth.hostname` under `AUTH_BROKER_PATH_PATTERN` (`platform-providers.ts`, `'^/api(/|$)'`), so its own health path carries that prefix. */
15
+ const BROKER_HEALTH_PATH = '/api/healthy';
16
+ /** `platform-providers.ts`'s own `waitForInstance` call target (`` `https://${hostname}/debug/healthz}` ``), also the one path `AUTH_ZITADEL_PATH_PATTERN` names by name. */
17
+ const ZITADEL_HEALTH_PATH = '/debug/healthz';
18
+ /**
19
+ * `platform-providers.ts`'s own private `STALWART_READY_PATH` ('/healthz/ready'),
20
+ * restated here: that constant is not exported, and exporting it (or
21
+ * importing the whole provider module for one string) costs more than a
22
+ * second copy of a value both cite back to Stalwart's own route.
23
+ */
24
+ const STALWART_READY_PATH = '/healthz/ready';
25
+ const CHAT_SERVICE_HEALTH_PATH = '/_chat/health';
26
+ const CALLS_HEALTH_PATH = '/healthz';
27
+ /** Boule, Agora and the company's own login host: every org gets these, with no flag to check (the "Why" this issue opens with). */
28
+ function alwaysOnTargets(org) {
29
+ return [
30
+ {
31
+ service: 'boule',
32
+ url: `https://${bouleHostnameFor(org)}${DEFAULT_API_HEALTH_PATH}`,
33
+ expect: okExpect(),
34
+ depth: 'deep',
35
+ restartSafe: true,
36
+ behindAccess: false,
37
+ },
38
+ {
39
+ // Caddy serves Agora statically (`agora.ts`'s own doc comment: its
40
+ // Dockerfile serves Caddy on 8080) -- no `/api/health` to ask, so only
41
+ // a page load proves it is up ("static Agora /" in #541's own list).
42
+ service: 'agora',
43
+ url: `https://${agoraHostnameFor(org)}${STATIC_PAGE_PATH}`,
44
+ expect: okExpect(),
45
+ depth: 'page',
46
+ restartSafe: true,
47
+ behindAccess: false,
48
+ },
49
+ {
50
+ service: 'login',
51
+ url: `https://${loginHostnameFor(org)}${LOGIN_READY_PATH}`,
52
+ expect: okExpect(),
53
+ depth: 'shallow',
54
+ restartSafe: false,
55
+ behindAccess: false,
56
+ },
57
+ ];
58
+ }
59
+ /**
60
+ * The shared sign-in broker (`broker-entries.ts`), when this company depends
61
+ * on it: any hosting entry declaring `uses.auth` signs in through it. The
62
+ * broker is the platform's one app, not a per-company address `org` alone
63
+ * can build -- its real, reachable URL is `platformHealthTargets`'s own
64
+ * `'auth-broker'` target, under `platform.auth.hostname`. This target still
65
+ * belongs in a company's own list (#541: "Include ... the broker when the
66
+ * company has them") so a status page scoped to one company shows every
67
+ * service that company's sign-in actually depends on, even a shared one.
68
+ */
69
+ function brokerTarget(org) {
70
+ const usesBroker = org.hosting.entries.some((entry) => Boolean(entry.uses?.auth));
71
+ if (!usesBroker)
72
+ return null;
73
+ return {
74
+ service: 'broker',
75
+ url: null,
76
+ expect: okExpect(),
77
+ depth: 'shallow',
78
+ restartSafe: false,
79
+ behindAccess: false,
80
+ reason: "the sign-in broker is the platform's one shared app (broker-entries.ts), not a per-company address this function can build from `org` alone -- see platformHealthTargets's 'auth-broker' target",
81
+ };
82
+ }
83
+ /**
84
+ * Mail, when this company's mail is not off. Every org's mail is served by
85
+ * the one shared Stalwart instance (`executor.ts`'s `mailOrgFor`), never a
86
+ * per-org app, so there is no per-company hostname to build here either --
87
+ * `platformHealthTargets`'s own `'mail'` target is the real, reachable one.
88
+ */
89
+ function mailTarget(org) {
90
+ if (org.mail.mode === 'off')
91
+ return null;
92
+ return {
93
+ service: 'mail',
94
+ url: null,
95
+ expect: okExpect(),
96
+ depth: 'deep',
97
+ restartSafe: false,
98
+ behindAccess: false,
99
+ reason: "mail is the platform's one shared Stalwart instance, not a per-company address this function can build from `org` alone -- see platformHealthTargets's 'mail' target",
100
+ };
101
+ }
102
+ /**
103
+ * Chat, when enabled (on by default for a new org -- `org.ts`'s own
104
+ * `ChatConfig` doc comment). The chat service is the only one of the three
105
+ * with a public route of its own, proxied through Agora
106
+ * (`chat-matrix.ts`'s own "NO PUBLIC HOSTNAME OF ITS OWN": Synapse and MAS
107
+ * answer only on Coolify's internal network, never a tunnel or DNS record).
108
+ */
109
+ function chatTargets(org) {
110
+ if (!org.chat.enabled)
111
+ return [];
112
+ const noPublicHostname = "chat's Synapse and MAS have no public hostname of their own (chat-matrix.ts: 'NO PUBLIC HOSTNAME OF ITS OWN') -- only the chat service's own route through Agora is reachable from outside";
113
+ return [
114
+ {
115
+ service: 'chat',
116
+ url: `https://${agoraHostnameFor(org)}${CHAT_SERVICE_HEALTH_PATH}`,
117
+ expect: okExpect(),
118
+ depth: 'deep',
119
+ restartSafe: true,
120
+ behindAccess: false,
121
+ },
122
+ {
123
+ service: 'chat-synapse',
124
+ url: null,
125
+ expect: okExpect(),
126
+ depth: 'shallow',
127
+ restartSafe: false,
128
+ behindAccess: false,
129
+ reason: noPublicHostname,
130
+ },
131
+ {
132
+ service: 'chat-mas',
133
+ url: null,
134
+ expect: okExpect(),
135
+ depth: 'shallow',
136
+ restartSafe: false,
137
+ behindAccess: false,
138
+ reason: noPublicHostname,
139
+ },
140
+ ];
141
+ }
142
+ /**
143
+ * Calls, when enabled. Unlike chat, the calls *service* is the one thing
144
+ * with its own public hostname (`callsHostnameFor`); its own Synapse/MAS are
145
+ * internal-only the same way chat's are (`calls-matrix.ts`'s own "NO PUBLIC
146
+ * HOSTNAME OF ITS OWN"), so this issue does not add separate targets for
147
+ * them -- #541's own "How" lists "calls" as one service here, not three the
148
+ * way chat's is spelled out.
149
+ */
150
+ function callsTargets(org) {
151
+ if (!org.calls.enabled)
152
+ return [];
153
+ return [
154
+ {
155
+ service: 'calls',
156
+ url: `https://${callsHostnameFor(org)}${CALLS_HEALTH_PATH}`,
157
+ expect: okExpect(),
158
+ depth: 'shallow',
159
+ restartSafe: false,
160
+ behindAccess: false,
161
+ },
162
+ ];
163
+ }
164
+ /** One hosting entry's own target, the `healthUrls` fallback rule restated richly rather than copied differently. */
165
+ function hostingEntryTarget(org, entry) {
166
+ const [hostname] = entryHostnames(org, entry);
167
+ if (!hostname) {
168
+ return {
169
+ service: entry.key,
170
+ url: null,
171
+ expect: okExpect(),
172
+ depth: 'shallow',
173
+ restartSafe: true,
174
+ behindAccess: false,
175
+ reason: `the '${entry.key}' entry is internal: it has no public hostname (entryHostnames) to probe`,
176
+ };
177
+ }
178
+ const path = entry.healthPath ?? (entry.buildPack === 'static' ? STATIC_PAGE_PATH : DEFAULT_API_HEALTH_PATH);
179
+ return {
180
+ service: entry.key,
181
+ url: `https://${hostname}${path}`,
182
+ expect: okExpect(),
183
+ depth: entry.buildPack === 'static' ? 'page' : entry.database ? 'deep' : 'shallow',
184
+ restartSafe: true,
185
+ // Only the `app` entry may be gated behind Cloudflare Access at all
186
+ // (`org.ts`'s own `HostingEntry.accessEmails` doc comment).
187
+ behindAccess: entry.key === APP_ENTRY_KEY && entry.accessEmails.length > 0,
188
+ };
189
+ }
190
+ /**
191
+ * The org's own app: either its hosting entries, or its site + cms pair,
192
+ * never both -- the same `if` / `else if` `healthUrls` uses, restated so the
193
+ * richer shape stays in step with it rather than drifting from a second,
194
+ * differently-written version of the same choice.
195
+ */
196
+ function appTargets(org) {
197
+ if (org.hosting.enabled && appEntry(org.hosting)) {
198
+ return org.hosting.entries.map((entry) => hostingEntryTarget(org, entry));
199
+ }
200
+ if (org.site.enabled && org.domain) {
201
+ return [
202
+ {
203
+ service: 'site',
204
+ url: `https://${org.domain}${DEFAULT_API_HEALTH_PATH}`,
205
+ expect: okExpect(),
206
+ depth: 'deep',
207
+ restartSafe: true,
208
+ behindAccess: false,
209
+ },
210
+ {
211
+ service: 'cms',
212
+ url: `https://${cmsHostnameFor(org)}${DEFAULT_API_HEALTH_PATH}`,
213
+ expect: okExpect(),
214
+ depth: 'deep',
215
+ restartSafe: true,
216
+ behindAccess: false,
217
+ },
218
+ ];
219
+ }
220
+ return [];
221
+ }
222
+ /**
223
+ * One target per service foundry runs for this company (#541). Always Boule,
224
+ * Agora and login; mail, chat (the chat service, Synapse, MAS), calls and
225
+ * the broker whenever the org has them; then whichever of hosting or site
226
+ * the org runs, if either.
227
+ */
228
+ export function healthTargets(org) {
229
+ const targets = alwaysOnTargets(org);
230
+ const broker = brokerTarget(org);
231
+ if (broker)
232
+ targets.push(broker);
233
+ const mail = mailTarget(org);
234
+ if (mail)
235
+ targets.push(mail);
236
+ targets.push(...chatTargets(org));
237
+ targets.push(...callsTargets(org));
238
+ targets.push(...appTargets(org));
239
+ return targets;
240
+ }
241
+ /**
242
+ * ZITADEL, zitadel-login and the broker all answer on the one
243
+ * `platform.auth.hostname`, path-routed (`platform-providers.ts`'s own
244
+ * `provisionPlatformAuth`: `AUTH_ZITADEL_PATH_PATTERN` and
245
+ * `AUTH_BROKER_PATH_PATTERN`, with login the catch-all for everything
246
+ * neither matches) -- three services foundry runs under the one
247
+ * `platform:auth` step, so three targets here, not one.
248
+ */
249
+ function authTargets(platform) {
250
+ const hostname = platform.auth.hostname;
251
+ return [
252
+ {
253
+ service: 'auth-zitadel',
254
+ url: `https://${hostname}${ZITADEL_HEALTH_PATH}`,
255
+ expect: okExpect(),
256
+ depth: 'shallow',
257
+ restartSafe: false,
258
+ behindAccess: false,
259
+ },
260
+ {
261
+ service: 'auth-login',
262
+ url: `https://${hostname}${LOGIN_READY_PATH}`,
263
+ expect: okExpect(),
264
+ depth: 'shallow',
265
+ restartSafe: false,
266
+ behindAccess: false,
267
+ },
268
+ {
269
+ service: 'auth-broker',
270
+ url: `https://${hostname}${BROKER_HEALTH_PATH}`,
271
+ expect: okExpect(),
272
+ depth: 'shallow',
273
+ restartSafe: false,
274
+ behindAccess: false,
275
+ },
276
+ ];
277
+ }
278
+ /** Why `platform:ai`/`platform:files` have nothing to probe yet: both runners are created with no Coolify `domains`, tunnel or DNS of their own (`platform-providers.ts`'s own doc comments, verbatim in each reason below). */
279
+ function internalOnlyTarget(service, reason) {
280
+ return {
281
+ service,
282
+ url: null,
283
+ expect: okExpect(),
284
+ depth: 'shallow',
285
+ restartSafe: true,
286
+ behindAccess: false,
287
+ reason,
288
+ };
289
+ }
290
+ /**
291
+ * The seven platform services #541 names (`platform:auth`, `platform:archon`,
292
+ * `platform:mail`, `platform:ai`, `platform:files`, `platform:integrations`,
293
+ * `platform:edge`) -- `platform:sfu` and `platform:jobs` are shared platform
294
+ * resources too, but neither is in that list, and this issue does not expand
295
+ * it: the SFU has no hostname (`platform-sfu.ts`'s own doc comment: no cheap
296
+ * way to read it over SSH, let alone over HTTP) and the jobs scheduler's
297
+ * admin API answers 401 with no token (`platform-providers.ts`'s own
298
+ * `coolify:jobs` step), so an outside prober could not usefully reach either
299
+ * one anyway.
300
+ */
301
+ export function platformHealthTargets(platform) {
302
+ return [
303
+ ...authTargets(platform),
304
+ {
305
+ service: 'archon',
306
+ url: `https://${platform.archon.hostname}${DEFAULT_API_HEALTH_PATH}`,
307
+ expect: okExpect(),
308
+ depth: 'deep',
309
+ restartSafe: false,
310
+ behindAccess: false,
311
+ },
312
+ {
313
+ service: 'mail',
314
+ url: `https://${platform.mail.hostname}${STALWART_READY_PATH}`,
315
+ expect: okExpect(),
316
+ depth: 'deep',
317
+ restartSafe: false,
318
+ behindAccess: false,
319
+ },
320
+ internalOnlyTarget('ai', "platform:ai's runner is created with no Coolify domains, tunnel, DNS record or Access app (platform-providers.ts) -- nothing public to probe yet"),
321
+ internalOnlyTarget('files', "platform:files' runner is created with no domains either, the same as platform:auth's apps (platform-providers.ts) -- nothing public to probe yet"),
322
+ {
323
+ service: 'integrations',
324
+ url: null,
325
+ expect: okExpect(),
326
+ depth: 'shallow',
327
+ restartSafe: true,
328
+ behindAccess: false,
329
+ reason: "platform:integrations' hostname has no Cloudflare wiring of its own (platform-providers.ts: 'no Cloudflare wiring is created for hostname') -- whether it resolves at all depends on DNS this executor never set up",
330
+ },
331
+ {
332
+ // Edge gets a real tunnel and DNS record, unlike ai/files/integrations
333
+ // (`provisionPlatformEdge`'s own doc comment: "the public-service shape
334
+ // provisionPlatformMail/provisionPlatformAuth use ... EDGE_URL has to
335
+ // be reachable by every org's own app").
336
+ service: 'edge',
337
+ url: `https://${platform.edge.hostname}${DEFAULT_API_HEALTH_PATH}`,
338
+ expect: okExpect(),
339
+ depth: 'deep',
340
+ restartSafe: true,
341
+ behindAccess: false,
342
+ },
343
+ ];
344
+ }
@@ -5,7 +5,7 @@ export { computePlanDetail, toPlan } from './diff.js';
5
5
  export type { StepSystem } from './step-system.js';
6
6
  export { stepSystemOf } from './step-system.js';
7
7
  export type { PlatformPlanDetail } from './platform-diff.js';
8
- export { planPlatform, toPlatformPlan, JOBS_SCHEDULER_KEY, PLATFORM_AI_KEY, PLATFORM_AI_INTERNAL_URL_SCOPE_KEY, PLATFORM_AI_MANAGEMENT_KEY_SCOPE_KEY, PLATFORM_AI_ORGANISATION_SCOPE_KEY, PLATFORM_AUTH_KEY, PLATFORM_FILES_KEY, PLATFORM_FILES_INTERNAL_URL_SCOPE_KEY, PLATFORM_FILES_ZITADEL_PROJECT_SCOPE_KEY, PLATFORM_MAIL_KEY, PLATFORM_MAIL_INTERNAL_URL_SCOPE_KEY, PLATFORM_MAIL_ZITADEL_PROJECT_SCOPE_KEY, PLATFORM_EDGE_KEY, PLATFORM_EDGE_KEY_REF, PLATFORM_EDGE_CLOUDFLARE_TOKEN_REF, } from './platform-diff.js';
8
+ export { planPlatform, toPlatformPlan, JOBS_SCHEDULER_KEY, PLATFORM_AI_KEY, PLATFORM_AI_INTERNAL_URL_SCOPE_KEY, PLATFORM_AI_MANAGEMENT_KEY_SCOPE_KEY, PLATFORM_AI_ORGANISATION_SCOPE_KEY, PLATFORM_AUTH_KEY, PLATFORM_FILES_KEY, PLATFORM_FILES_INTERNAL_URL_SCOPE_KEY, PLATFORM_FILES_ZITADEL_PROJECT_SCOPE_KEY, PLATFORM_MAIL_KEY, PLATFORM_MAIL_INTERNAL_URL_SCOPE_KEY, PLATFORM_MAIL_ZITADEL_PROJECT_SCOPE_KEY, PLATFORM_EDGE_KEY, PLATFORM_EDGE_INTERNAL_URL_SCOPE_KEY, PLATFORM_EDGE_KEY_REF, PLATFORM_EDGE_CLOUDFLARE_TOKEN_REF, } from './platform-diff.js';
9
9
  export type { HeldKeyResolver, OrgPlatformReadiness } from './platform-org.js';
10
10
  export { DEFAULT_ORG_PLATFORM_READINESS, ORG_PLATFORM_KEY_REFS, agoraHostValuesFromPlatform, heldPlatformKeyRefs, orgPlatformReadinessFrom, } from './platform-org.js';
11
11
  export type { PlatformOrgClientFactories } from './platform-org-ports.js';
@@ -15,7 +15,7 @@ export { AdoptRefusedError } from './adopt.js';
15
15
  export type { AuditActor, AuditLog, AuthzGate, StepExecutor, StepDestroyer, DestroyTarget, StepExecutionContext, PlatformStepExecutor, PlatformStepExecutionContext, FoundryPlanApplyOptions, } from './plan-apply.js';
16
16
  export { FoundryPlanApply, DeclaredOrgNotFoundError, StalePlanError, ConfirmationRequiredError, NotAuthorizedError, NotATestOrgError, DeclaredPlatformNotFoundError, PlatformStalePlanError, PlatformConfirmationRequiredError, planNeedsConfirmation, } from './plan-apply.js';
17
17
  export { FoundryPlatformStepExecutor, ProvidersPlatformCloudflareReader, ProvidersPlatformCoolifyReader, PLATFORM_MAIL_ADMIN_KEY_DESCRIPTION, filesPublicHostsValue, filesShareDomains, } from './platform-providers.js';
18
- export type { PlatformAuthDeps, PlatformAuthRuntime, PlatformFilesDeps, PlatformFilesRuntime, PlatformEdgeDeps, PlatformEdgeRuntime, } from './platform-providers.js';
18
+ export type { PlatformAuthDeps, PlatformAuthRuntime, PlatformFilesDeps, PlatformFilesRuntime, PlatformEdgeDeps, PlatformDnsFactory, PlatformAccessFactory, PlatformAccessPort, PlatformTunnelsFactory, PlatformEdgeRuntime, } from './platform-providers.js';
19
19
  export type { PlatformMailDeps, PlatformMailAdminPort, PlatformMailRuntime, } from './platform-providers.js';
20
20
  export { FILES_INTROSPECTION_SECRET_REF, FILES_MANAGEMENT_ISSUER, FILES_MANAGEMENT_KEY_ID, FILES_MANAGEMENT_KEY_REF, PLATFORM_AUTH_INSTANCE_BEARER_REF, } from './platform-providers.js';
21
21
  export { AI_MANAGEMENT_ISSUER, AI_MANAGEMENT_KEY_ID, AI_SIGNING_KEY_REF, AiManagementKeyExistsError, createAiManagementKeyMinter, PLATFORM_AI_MANAGEMENT_KEY_REF, PLATFORM_AI_MANAGEMENT_PERMISSIONS, } from './platform-providers.js';
@@ -27,7 +27,7 @@ export { MissingCallsSfuConnectionError, applyCallsSfuPlatformStep } from './pla
27
27
  export type { MailCopyJob, MailCopyPort, MailCopyStepResult } from './mail-copy.js';
28
28
  export { MAIL_COPY_COMPLETED_SCOPE_KEY, MAIL_COPY_DONE_SCOPE_KEY, MissingMailCopyAccountsError, MissingMailCopySourceTokenError, MissingMailCopyStalwartTokenError, parseMailCopyCompleted, runMailCopyStep, } from './mail-copy.js';
29
29
  export type { ScopeResolver, RecordedIds } from './providers-readers.js';
30
- export { ProvidersCloudflareReader, ProvidersCmsReader, ProvidersCoolifyReader, ProvidersZitadelReader, storeScopeResolver, storeRecordedIds, } from './providers-readers.js';
30
+ export { ProvidersAiReader, ProvidersCloudflareReader, ProvidersCmsReader, ProvidersCoolifyReader, ProvidersZitadelReader, storeScopeResolver, storeRecordedIds, } from './providers-readers.js';
31
31
  export type { AgoraHostPort, AgoraHostValues } from './agora.js';
32
32
  export type { BrokerPort } from './broker-entries.js';
33
33
  export { PlatformBrokerPort } from './broker-entries.js';
@@ -39,6 +39,20 @@ export type { MailAllowlistApp } from './mail-identity-allowlist.js';
39
39
  export { checkMailAppsAllowlist } from './mail-identity-allowlist.js';
40
40
  export type { CoolifyPort, CloudflarePort, ZitadelPort, CallsSfuPort, JobsSchedulerPort, FilesOrganisationPort, AiOrganisationPort, FoundryStepExecutorPorts, PlatformOrgPortSource, } from './executor.js';
41
41
  export { FoundryStepExecutor, NotBuiltError, healthUrls } from './executor.js';
42
+ export type { HealthTarget, HealthTargetDepth, HealthTargetExpect } from './health-targets.js';
43
+ export { healthTargets, platformHealthTargets } from './health-targets.js';
44
+ export { createEdgeDns, EDGE_DNS_WRITE_CONCURRENCY } from './edge-dns.js';
45
+ export type { DnsPort, DnsRecordInput, DnsRecordSummary, EdgePlatformDns } from './edge-dns.js';
46
+ export { createEdgeAccess } from './edge-access.js';
47
+ export type { AccessAppSummary, AccessDecision, AccessPolicyRule, AccessPort, EdgePlatformAccess, } from './edge-access.js';
48
+ export { createEdgeWorkers } from './edge-workers.js';
49
+ export type { EdgePlatformWorkers, EdgeWorkersOptions } from './edge-workers.js';
50
+ export { createEdgeRateLimits } from './edge-ratelimits.js';
51
+ export type { EdgePlatformRateLimits, EdgeRateLimitsOptions, RateLimitPort, RateLimitRuleInput, } from './edge-ratelimits.js';
52
+ export { createEdgeZones } from './edge-zones.js';
53
+ export type { EdgePlatformZones, EdgeZonesOptions, ZonePort } from './edge-zones.js';
54
+ export { createEdgeTunnels } from './edge-tunnels.js';
55
+ export type { EdgePlatformTunnels, TunnelIngress, TunnelPort, TunnelSummary, } from './edge-tunnels.js';
42
56
  export type { ZitadelListAppsRawApp } from './executor.js';
43
57
  export { adaptZitadelListApps } from './executor.js';
44
58
  export { HttpJobsSchedulerClient } from './jobs-scheduler-http.js';
@@ -1,7 +1,7 @@
1
1
  export { readLiveResources, readLivePlatformResources } from './live.js';
2
2
  export { computePlanDetail, toPlan } from './diff.js';
3
3
  export { stepSystemOf } from './step-system.js';
4
- export { planPlatform, toPlatformPlan, JOBS_SCHEDULER_KEY, PLATFORM_AI_KEY, PLATFORM_AI_INTERNAL_URL_SCOPE_KEY, PLATFORM_AI_MANAGEMENT_KEY_SCOPE_KEY, PLATFORM_AI_ORGANISATION_SCOPE_KEY, PLATFORM_AUTH_KEY, PLATFORM_FILES_KEY, PLATFORM_FILES_INTERNAL_URL_SCOPE_KEY, PLATFORM_FILES_ZITADEL_PROJECT_SCOPE_KEY, PLATFORM_MAIL_KEY, PLATFORM_MAIL_INTERNAL_URL_SCOPE_KEY, PLATFORM_MAIL_ZITADEL_PROJECT_SCOPE_KEY, PLATFORM_EDGE_KEY, PLATFORM_EDGE_KEY_REF, PLATFORM_EDGE_CLOUDFLARE_TOKEN_REF, } from './platform-diff.js';
4
+ export { planPlatform, toPlatformPlan, JOBS_SCHEDULER_KEY, PLATFORM_AI_KEY, PLATFORM_AI_INTERNAL_URL_SCOPE_KEY, PLATFORM_AI_MANAGEMENT_KEY_SCOPE_KEY, PLATFORM_AI_ORGANISATION_SCOPE_KEY, PLATFORM_AUTH_KEY, PLATFORM_FILES_KEY, PLATFORM_FILES_INTERNAL_URL_SCOPE_KEY, PLATFORM_FILES_ZITADEL_PROJECT_SCOPE_KEY, PLATFORM_MAIL_KEY, PLATFORM_MAIL_INTERNAL_URL_SCOPE_KEY, PLATFORM_MAIL_ZITADEL_PROJECT_SCOPE_KEY, PLATFORM_EDGE_KEY, PLATFORM_EDGE_INTERNAL_URL_SCOPE_KEY, PLATFORM_EDGE_KEY_REF, PLATFORM_EDGE_CLOUDFLARE_TOKEN_REF, } from './platform-diff.js';
5
5
  export { DEFAULT_ORG_PLATFORM_READINESS, ORG_PLATFORM_KEY_REFS, agoraHostValuesFromPlatform, heldPlatformKeyRefs, orgPlatformReadinessFrom, } from './platform-org.js';
6
6
  export { PlatformOrgBlockedError, PlatformOrgPorts } from './platform-org-ports.js';
7
7
  export { AdoptRefusedError } from './adopt.js';
@@ -14,11 +14,18 @@ export { createZitadelAdminMinter } from './zitadel-machine-user.js';
14
14
  export { createZitadelProjectRoles } from './zitadel-project-roles.js';
15
15
  export { MissingCallsSfuConnectionError, applyCallsSfuPlatformStep } from './platform-sfu.js';
16
16
  export { MAIL_COPY_COMPLETED_SCOPE_KEY, MAIL_COPY_DONE_SCOPE_KEY, MissingMailCopyAccountsError, MissingMailCopySourceTokenError, MissingMailCopyStalwartTokenError, parseMailCopyCompleted, runMailCopyStep, } from './mail-copy.js';
17
- export { ProvidersCloudflareReader, ProvidersCmsReader, ProvidersCoolifyReader, ProvidersZitadelReader, storeScopeResolver, storeRecordedIds, } from './providers-readers.js';
17
+ export { ProvidersAiReader, ProvidersCloudflareReader, ProvidersCmsReader, ProvidersCoolifyReader, ProvidersZitadelReader, storeScopeResolver, storeRecordedIds, } from './providers-readers.js';
18
18
  export { PlatformBrokerPort } from './broker-entries.js';
19
19
  export { loginHostnameFor } from './login-host.js';
20
20
  export { checkMailIdentityAudience } from './mail-identity.js';
21
21
  export { checkMailAppsAllowlist } from './mail-identity-allowlist.js';
22
22
  export { FoundryStepExecutor, NotBuiltError, healthUrls } from './executor.js';
23
+ export { healthTargets, platformHealthTargets } from './health-targets.js';
24
+ export { createEdgeDns, EDGE_DNS_WRITE_CONCURRENCY } from './edge-dns.js';
25
+ export { createEdgeAccess } from './edge-access.js';
26
+ export { createEdgeWorkers } from './edge-workers.js';
27
+ export { createEdgeRateLimits } from './edge-ratelimits.js';
28
+ export { createEdgeZones } from './edge-zones.js';
29
+ export { createEdgeTunnels } from './edge-tunnels.js';
23
30
  export { adaptZitadelListApps } from './executor.js';
24
31
  export { HttpJobsSchedulerClient } from './jobs-scheduler-http.js';
@@ -29,8 +29,19 @@ export interface LiveResource {
29
29
  * this union so `StepAction.system` (`diff.ts`) can name it. `'edge'`
30
30
  * (#317) is the same: a edge key has no live lookup of its own, the same
31
31
  * shape `ai`/`files` entry keys already follow.
32
+ *
33
+ * `'heartbeats'` (#540) is the one system here with a cheap live list of
34
+ * its own (`HeartbeatsPort.listChecks`, `platform-providers.ts`) --
35
+ * `platform-diff.ts`'s `planHeartbeats` still does not diff against a
36
+ * `LiveResource` list here: the reconcile step itself (list, create
37
+ * missing, delete stale) is what does that diffing, against the live
38
+ * check names. `planHeartbeats` plans that step only when its own
39
+ * recorded desired-set marker (`PLATFORM_HEARTBEATS_APPLIED_SCOPE_KEY`)
40
+ * no longer matches what is currently declared, never unconditionally --
41
+ * see that function's own doc comment for why "always planned" broke
42
+ * `bootstrapPlatform`.
32
43
  */
33
- system: 'zitadel' | 'coolify' | 'cloudflare' | 'files' | 'ai' | 'cms' | 'jobs' | 'sfu' | 'edge';
44
+ system: 'zitadel' | 'coolify' | 'cloudflare' | 'files' | 'ai' | 'cms' | 'jobs' | 'sfu' | 'edge' | 'heartbeats';
34
45
  kind: string;
35
46
  key: string;
36
47
  config: Record<string, unknown>;
@@ -208,6 +208,10 @@ export interface PlatformStepExecutor {
208
208
  mail?: string;
209
209
  /** #257: see `PlanPlatformOptions.blocked.backupExecutorMissing` (`platform-diff.ts`). */
210
210
  backupExecutorMissing?: boolean;
211
+ /** #540: see `PlanPlatformOptions.blocked.hc` (`platform-diff.ts`). */
212
+ hc?: string;
213
+ /** #540: see `PlanPlatformOptions.blocked.heartbeatsExecutorMissing` (`platform-diff.ts`). */
214
+ heartbeatsExecutorMissing?: boolean;
211
215
  }>;
212
216
  /**
213
217
  * Steps whose live state differs from the declared one in a way the recorded
@@ -8,7 +8,7 @@ import { AdoptRefusedError, adoptCandidates, adoptedKeysUnreadable, adoptedScope
8
8
  import { NO_AGORA_HOST_VALUES, readAgoraHost, } from './agora.js';
9
9
  import { AI_KEY_UNDELIVERED_SCOPE_KEY, JOBS_REGISTRATION_UNDELIVERED_SCOPE_KEY, computePlanDetail, toPlan, } from './diff.js';
10
10
  import { entryAiUndeliveredScopeKey } from './entry-ai-key.js';
11
- import { entryEdgeRetiredScopeKey, entryEdgeUndeliveredScopeKey } from './entry-edge-key.js';
11
+ import { BOULE_EDGE_ENTRY_KEY, entryEdgeRetiredScopeKey, entryEdgeUndeliveredScopeKey, } from './entry-edge-key.js';
12
12
  import { FILES_ROOT_UNDELIVERED_SCOPE_KEY, entryFilesUndeliveredScopeKey, } from './entry-files-key.js';
13
13
  import { readLivePlatformResources, readLiveResources, } from './live.js';
14
14
  import { lockedHostnames } from './locked-hostnames.js';
@@ -1376,6 +1376,10 @@ function failedFields(org, detail, completed, serviceIds) {
1376
1376
  failed.add('hosting.entries.uses.edge');
1377
1377
  }
1378
1378
  }
1379
+ // F3: Boule's own key, wanted whenever the org has a domain.
1380
+ if (org.domain && serviceIds[entryEdgeUndeliveredScopeKey(BOULE_EDGE_ENTRY_KEY)]) {
1381
+ failed.add('hosting.entries.uses.edge');
1382
+ }
1379
1383
  // The same for a jobs registration whose credentials never reached the host.
1380
1384
  if (serviceIds[JOBS_REGISTRATION_UNDELIVERED_SCOPE_KEY] &&
1381
1385
  !serviceIds[`jobs:registration:${org.id}`]) {