@wtfalch/foundry 0.20.0 → 0.21.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -6,8 +6,9 @@ import { redactSecrets } from '../calls-sfu-deploy-support.js';
6
6
  import { ensureCloudflaredConnector } from './cloudflared-connector.js';
7
7
  import { waitForDeploymentFinished, waitForHealthyVersion, waitForNewDeployment, } from './deploy-health.js';
8
8
  import { ACCESS_POLICY_NAME, getDatabaseStarted, isNotFoundError, isRunningOrStarting, } from './executor.js';
9
+ import { healthCheckSettings } from './health-check-settings.js';
9
10
  import { coolifyDomains, filesShareHostname } from './hostnames.js';
10
- import { AUTH_ZITADEL_RUN_OPTIONS, JOBS_SCHEDULER_KEY, PLATFORM_AI_APPLIED_GITREF_SCOPE_KEY, PLATFORM_AI_APPLIED_HOSTNAME_SCOPE_KEY, PLATFORM_AI_DEPLOY_PENDING_SCOPE_KEY, PLATFORM_AI_INTERNAL_URL_SCOPE_KEY, PLATFORM_AI_KEY, PLATFORM_AI_MANAGEMENT_KEY_CONFLICT_SCOPE_KEY, PLATFORM_AI_MANAGEMENT_KEY_SCOPE_KEY, PLATFORM_AI_MANAGEMENT_KEY_UNDELIVERED_SCOPE_KEY, PLATFORM_AI_MIGRATE_BLOCKED_SCOPE_KEY, PLATFORM_AI_ORGANISATION_SCOPE_KEY, PLATFORM_AI_SIGNING_KEY_SET_SCOPE_KEY, PLATFORM_AI_SIGNING_KEY_UNDELIVERED_SCOPE_KEY, PLATFORM_ARCHON_APPLIED_BROKER_SCOPE_KEY, PLATFORM_ARCHON_APPLIED_GIT_REF_SCOPE_KEY, PLATFORM_ARCHON_APPLIED_HOSTNAME_SCOPE_KEY, PLATFORM_ARCHON_APPLIED_OPERATOR_SCOPE_KEY, PLATFORM_ARCHON_APPLIED_ROLLING_UPDATE_SCOPE_KEY, PLATFORM_ARCHON_CONNECTOR_APP_SCOPE_KEY, PLATFORM_ARCHON_COOLIFY_READ_TOKEN_REF, PLATFORM_ARCHON_ENVS_DEPLOYED_SCOPE_KEY, PLATFORM_ARCHON_GATE_CLOSED_SCOPE_KEY, PLATFORM_ARCHON_INTEGRATIONS_KEY_SYNCED_SCOPE_KEY, PLATFORM_ARCHON_KEY, PLATFORM_ARCHON_MASTER_CLOUDFLARE_TOKEN_REF, PLATFORM_ARCHON_MASTER_COOLIFY_TOKEN_REF, PLATFORM_ARCHON_MASTER_COOLIFY_URL_REF, PLATFORM_AUTH_ADMIN_UNDELIVERED_SCOPE_KEY, PLATFORM_AUTH_ADMIN_USER_SCOPE_KEY, PLATFORM_AUTH_BROKER_DEPLOYED_SCOPE_KEY, PLATFORM_AUTH_BROKER_SCOPE_KEY, PLATFORM_AUTH_CONNECTOR_APP_SCOPE_KEY, PLATFORM_AUTH_INSTANCE_SCOPE_KEY, PLATFORM_AUTH_INSTANCE_UNDELIVERED_SCOPE_KEY, PLATFORM_AUTH_KEY, PLATFORM_AUTH_LOGIN_CLIENT_UNDELIVERED_SCOPE_KEY, PLATFORM_AUTH_LOGIN_CLIENT_USER_SCOPE_KEY, PLATFORM_AUTH_OWNER_INVITE_SCOPE_KEY, PLATFORM_AUTH_SMTP_SCOPE_KEY, PLATFORM_AUTH_ZITADEL_APPLIED_IMAGE_TAG_SCOPE_KEY, PLATFORM_AUTH_ZITADEL_RUN_OPTIONS_SCOPE_KEY, PLATFORM_BACKUP_DATABASE_SCOPE_KEYS, PLATFORM_BACKUP_SERVICE_KEYS, PLATFORM_EDGE_APPLIED_GIT_REF_SCOPE_KEY, PLATFORM_EDGE_APPLIED_HOSTNAME_SCOPE_KEY, PLATFORM_EDGE_CLOUDFLARE_TOKEN_REF, PLATFORM_EDGE_INTERNAL_URL_SCOPE_KEY, PLATFORM_EDGE_KEY, PLATFORM_EDGE_KEY_REF, PLATFORM_EDGE_PLATFORM_KEY_SET_SCOPE_KEY, PLATFORM_EDGE_PLATFORM_KEY_UNDELIVERED_SCOPE_KEY, PLATFORM_FILES_APPLIED_GIT_REF_SCOPE_KEY, PLATFORM_FILES_APPLIED_HOSTNAME_SCOPE_KEY, PLATFORM_FILES_INTERNAL_URL_SCOPE_KEY, PLATFORM_FILES_INTROSPECTION_SCOPE_KEY, PLATFORM_FILES_INTROSPECTION_UNDELIVERED_SCOPE_KEY, PLATFORM_FILES_KEY, PLATFORM_FILES_MANAGEMENT_KEY_SCOPE_KEY, PLATFORM_FILES_MANAGEMENT_UNDELIVERED_SCOPE_KEY, PLATFORM_FILES_ZITADEL_PROJECT_SCOPE_KEY, PLATFORM_INTEGRATIONS_APPLIED_GITREF_SCOPE_KEY, PLATFORM_INTEGRATIONS_APPLIED_HOSTNAME_SCOPE_KEY, PLATFORM_INTEGRATIONS_KEY, PLATFORM_INTEGRATIONS_KEY_ID_SCOPE_KEY, PLATFORM_INTEGRATIONS_KEY_ROTATION_PREVIOUS_SCOPE_KEY, PLATFORM_INTEGRATIONS_KEY_ROTATION_REQUESTED_SCOPE_KEY, PLATFORM_INTEGRATIONS_KEY_ROTATION_UNDELIVERED_SCOPE_KEY, PLATFORM_INTEGRATIONS_KEY_SET_SCOPE_KEY, PLATFORM_INTEGRATIONS_KEY_UNDELIVERED_SCOPE_KEY, PLATFORM_INTEGRATIONS_RUNNER_KEYRING_DEPLOYED_SCOPE_KEY, PLATFORM_MAIL_ADMIN_KEY_SCOPE_KEY, PLATFORM_MAIL_ADMIN_UNDELIVERED_SCOPE_KEY, PLATFORM_MAIL_APPLIED_GIT_REF_SCOPE_KEY, PLATFORM_MAIL_APPLIED_HOSTNAME_SCOPE_KEY, PLATFORM_MAIL_CONNECTOR_APP_SCOPE_KEY, PLATFORM_MAIL_INTERNAL_URL_SCOPE_KEY, PLATFORM_MAIL_KEY, PLATFORM_MAIL_ZITADEL_PROJECT_SCOPE_KEY, archonBootstrapOperator, parseBrokerDeployRecord, parseLoginClientRecord, platformMailMxHost, } from './platform-diff.js';
11
+ import { AUTH_ZITADEL_RUN_OPTIONS, JOBS_SCHEDULER_KEY, PLATFORM_AI_APPLIED_GITREF_SCOPE_KEY, PLATFORM_AI_APPLIED_HOSTNAME_SCOPE_KEY, PLATFORM_AI_DEPLOY_PENDING_SCOPE_KEY, PLATFORM_AI_INTERNAL_URL_SCOPE_KEY, PLATFORM_AI_KEY, PLATFORM_AI_MANAGEMENT_KEY_CONFLICT_SCOPE_KEY, PLATFORM_AI_MANAGEMENT_KEY_SCOPE_KEY, PLATFORM_AI_MANAGEMENT_KEY_UNDELIVERED_SCOPE_KEY, PLATFORM_AI_MIGRATE_BLOCKED_SCOPE_KEY, PLATFORM_AI_ORGANISATION_SCOPE_KEY, PLATFORM_AI_SIGNING_KEY_SET_SCOPE_KEY, PLATFORM_AI_SIGNING_KEY_UNDELIVERED_SCOPE_KEY, PLATFORM_ARCHON_APPLIED_BROKER_SCOPE_KEY, PLATFORM_ARCHON_APPLIED_GIT_REF_SCOPE_KEY, PLATFORM_ARCHON_APPLIED_HOSTNAME_SCOPE_KEY, PLATFORM_ARCHON_APPLIED_OPERATOR_SCOPE_KEY, PLATFORM_ARCHON_APPLIED_ROLLING_UPDATE_SCOPE_KEY, PLATFORM_ARCHON_CONNECTOR_APP_SCOPE_KEY, PLATFORM_ARCHON_COOLIFY_READ_TOKEN_REF, PLATFORM_ARCHON_ENVS_DEPLOYED_SCOPE_KEY, PLATFORM_ARCHON_GATE_CLOSED_SCOPE_KEY, PLATFORM_ARCHON_INTEGRATIONS_KEY_SYNCED_SCOPE_KEY, PLATFORM_ARCHON_KEY, PLATFORM_ARCHON_MASTER_CLOUDFLARE_TOKEN_REF, PLATFORM_ARCHON_MASTER_COOLIFY_TOKEN_REF, PLATFORM_ARCHON_MASTER_COOLIFY_URL_REF, PLATFORM_AUTH_ADMIN_UNDELIVERED_SCOPE_KEY, PLATFORM_AUTH_ADMIN_USER_SCOPE_KEY, PLATFORM_AUTH_BROKER_DEPLOYED_SCOPE_KEY, PLATFORM_AUTH_BROKER_SCOPE_KEY, PLATFORM_AUTH_CONNECTOR_APP_SCOPE_KEY, PLATFORM_AUTH_INSTANCE_SCOPE_KEY, PLATFORM_AUTH_INSTANCE_UNDELIVERED_SCOPE_KEY, PLATFORM_AUTH_KEY, PLATFORM_AUTH_LOGIN_CLIENT_UNDELIVERED_SCOPE_KEY, PLATFORM_AUTH_LOGIN_CLIENT_USER_SCOPE_KEY, PLATFORM_AUTH_OWNER_INVITE_SCOPE_KEY, PLATFORM_AUTH_SMTP_SCOPE_KEY, PLATFORM_AUTH_ZITADEL_APPLIED_IMAGE_TAG_SCOPE_KEY, PLATFORM_AUTH_ZITADEL_RUN_OPTIONS_SCOPE_KEY, PLATFORM_BACKUP_DATABASE_SCOPE_KEYS, PLATFORM_BACKUP_SERVICE_KEYS, PLATFORM_EDGE_APPLIED_GIT_REF_SCOPE_KEY, PLATFORM_EDGE_APPLIED_HOSTNAME_SCOPE_KEY, PLATFORM_EDGE_CLOUDFLARE_TOKEN_REF, PLATFORM_EDGE_INTERNAL_URL_SCOPE_KEY, PLATFORM_EDGE_KEY, PLATFORM_EDGE_KEY_REF, PLATFORM_EDGE_PLATFORM_KEY_SET_SCOPE_KEY, PLATFORM_EDGE_PLATFORM_KEY_UNDELIVERED_SCOPE_KEY, PLATFORM_FILES_APPLIED_GIT_REF_SCOPE_KEY, PLATFORM_FILES_APPLIED_HOSTNAME_SCOPE_KEY, PLATFORM_FILES_INTERNAL_URL_SCOPE_KEY, PLATFORM_FILES_INTROSPECTION_SCOPE_KEY, PLATFORM_FILES_INTROSPECTION_UNDELIVERED_SCOPE_KEY, PLATFORM_FILES_KEY, PLATFORM_FILES_MANAGEMENT_KEY_SCOPE_KEY, PLATFORM_FILES_MANAGEMENT_UNDELIVERED_SCOPE_KEY, PLATFORM_FILES_ZITADEL_PROJECT_SCOPE_KEY, PLATFORM_HC_API_KEY_REF, PLATFORM_HC_APPLIED_GIT_REF_SCOPE_KEY, PLATFORM_HC_APPLIED_HOSTNAME_SCOPE_KEY, PLATFORM_HC_KEY, PLATFORM_HEARTBEATS_APPLIED_SCOPE_KEY, PLATFORM_INTEGRATIONS_APPLIED_GITREF_SCOPE_KEY, PLATFORM_INTEGRATIONS_APPLIED_HOSTNAME_SCOPE_KEY, PLATFORM_INTEGRATIONS_KEY, PLATFORM_INTEGRATIONS_KEY_ID_SCOPE_KEY, PLATFORM_INTEGRATIONS_KEY_ROTATION_PREVIOUS_SCOPE_KEY, PLATFORM_INTEGRATIONS_KEY_ROTATION_REQUESTED_SCOPE_KEY, PLATFORM_INTEGRATIONS_KEY_ROTATION_UNDELIVERED_SCOPE_KEY, PLATFORM_INTEGRATIONS_KEY_SET_SCOPE_KEY, PLATFORM_INTEGRATIONS_KEY_UNDELIVERED_SCOPE_KEY, PLATFORM_INTEGRATIONS_RUNNER_KEYRING_DEPLOYED_SCOPE_KEY, PLATFORM_MAIL_ADMIN_KEY_SCOPE_KEY, PLATFORM_MAIL_ADMIN_UNDELIVERED_SCOPE_KEY, PLATFORM_MAIL_APPLIED_GIT_REF_SCOPE_KEY, PLATFORM_MAIL_APPLIED_HOSTNAME_SCOPE_KEY, PLATFORM_MAIL_CONNECTOR_APP_SCOPE_KEY, PLATFORM_MAIL_INTERNAL_URL_SCOPE_KEY, PLATFORM_MAIL_KEY, PLATFORM_MAIL_ZITADEL_PROJECT_SCOPE_KEY, PLATFORM_UPTIME_HEARTBEAT_CHECK_NAME, archonBootstrapOperator, desiredHeartbeatCheckNames, heartbeatCheckUrlRef, parseBrokerDeployRecord, parseLoginClientRecord, platformBackupHeartbeatCheckName, platformMailMxHost, } from './platform-diff.js';
11
12
  import { applyCallsSfuPlatformStep } from './platform-sfu.js';
12
13
  import { CreateInstanceError, createZitadelInstance, generateSystemApiKeypair, instanceWithoutBearerMessage, systemApiUsersEnv, } from './zitadel-system-api.js';
13
14
  /** The key `runPlatformSteps` records the apex zone's id under (`system:kind:key`). */
@@ -148,6 +149,8 @@ export class FoundryPlatformStepExecutor {
148
149
  edge;
149
150
  archon;
150
151
  backup;
152
+ hc;
153
+ heartbeats;
151
154
  constructor(cloudflare, declaredOrgStore, options = {}) {
152
155
  this.cloudflare = cloudflare;
153
156
  this.declaredOrgStore = declaredOrgStore;
@@ -162,6 +165,8 @@ export class FoundryPlatformStepExecutor {
162
165
  this.edge = options.edge;
163
166
  this.archon = options.archon;
164
167
  this.backup = options.backup;
168
+ this.hc = options.hc;
169
+ this.heartbeats = options.heartbeats;
165
170
  }
166
171
  async blockedSteps(platform, recordedIds = {}) {
167
172
  const blocked = {};
@@ -286,6 +291,33 @@ export class FoundryPlatformStepExecutor {
286
291
  PLATFORM_BACKUP_SERVICE_KEYS.some((service) => Boolean(recordedIds[PLATFORM_BACKUP_DATABASE_SCOPE_KEYS[service]]))) {
287
292
  blocked.backupExecutorMissing = true;
288
293
  }
294
+ if (platform.hc.enabled && !this.hc) {
295
+ blocked.hc =
296
+ 'the heartbeat server is blocked, not planned: the host built the platform executor without its hc dependencies';
297
+ }
298
+ else if (platform.hc.enabled && this.hc) {
299
+ const hcTagBlocked = hcImageTagBlocked(platform.hc.gitRef);
300
+ if (hcTagBlocked)
301
+ blocked.hc = hcTagBlocked;
302
+ }
303
+ // Only once `hc` is actually applied -- `planHeartbeats` itself already
304
+ // withholds the step while `hc` is not, so a host with no heartbeats
305
+ // deps wired carries no standing blocked note before there is anything
306
+ // for it to reconcile.
307
+ if (!this.heartbeats && Boolean(recordedIds[`coolify:app:${PLATFORM_HC_KEY}`])) {
308
+ blocked.heartbeatsExecutorMissing = true;
309
+ }
310
+ else if (platform.hc.enabled && this.heartbeats) {
311
+ // #540 round 2: checked whether or not `hc` is recorded applied yet --
312
+ // `hcReady` (`planPlatform`) goes true the same round `hc` is merely
313
+ // *planned*, so this has to hold the heartbeats step back from the
314
+ // very first round, before `coolify:app:platform:hc` is ever recorded.
315
+ // See `PLATFORM_HC_API_KEY_REF`'s own doc comment (`platform-diff.ts`)
316
+ // for why nothing here can mint this key itself.
317
+ const apiKey = await this.heartbeats.heldKeys?.resolve?.(PLATFORM_HC_API_KEY_REF);
318
+ if (!apiKey)
319
+ blocked.heartbeatsApiKeyMissing = true;
320
+ }
289
321
  return blocked;
290
322
  }
291
323
  /**
@@ -424,6 +456,18 @@ export class FoundryPlatformStepExecutor {
424
456
  }
425
457
  return provisionPlatformMail(context, this.mail);
426
458
  }
459
+ if (action.system === 'coolify' && action.kind === 'app' && action.key === PLATFORM_HC_KEY) {
460
+ if (!this.hc) {
461
+ throw new Error('platform:hc needs FoundryPlatformStepExecutor to be constructed with its hc dependencies (#540)');
462
+ }
463
+ return provisionPlatformHc(context, this.hc);
464
+ }
465
+ if (action.system === 'heartbeats' && action.kind === 'reconcile') {
466
+ if (!this.heartbeats) {
467
+ throw new Error('heartbeats:reconcile needs FoundryPlatformStepExecutor to be constructed with its heartbeats dependencies (#540)');
468
+ }
469
+ return provisionPlatformHeartbeats(context, this.heartbeats);
470
+ }
427
471
  if (action.system === 'cloudflare' && action.kind === 'zone') {
428
472
  return this.cloudflareZone(action, context);
429
473
  }
@@ -1849,9 +1893,14 @@ async function provisionPlatformMail(context, deps) {
1849
1893
  await record(PLATFORM_MAIL_APP_SCOPE_KEY, appId);
1850
1894
  // Set on every run, not only after a create -- same rule `provisionPlatformAuth` follows for its own apps.
1851
1895
  // The image tag too: a declared release change reaches the app here.
1896
+ // #543: the health-check quartet too, the same reason Archon's own doc
1897
+ // comment gives -- without it Coolify's rolling update stops the old
1898
+ // container before the new one can answer `waitForHealthyVersion`'s own
1899
+ // `'ready'` probe below. Stalwart's own readiness route and HTTP port.
1852
1900
  await deps.coolify.updateApplication(appId, {
1853
1901
  custom_internal_name: PLATFORM_MAIL_INTERNAL_NAME,
1854
1902
  docker_registry_image_tag: imageTag,
1903
+ ...healthCheckSettings(STALWART_READY_PATH, Number(STALWART_HTTP_PORT)),
1855
1904
  });
1856
1905
  // #177: org steps (an org's own `coolify:mail`/`coolify:agora`) read this
1857
1906
  // back instead of a literal, the same way files/ai's own internal
@@ -2511,10 +2560,14 @@ export const PLATFORM_ARCHON_CLIENT_ID_SCOPE_KEY = 'zitadel_platform_archon_clie
2511
2560
  * by hand outside foundry. {@link archonRollingUpdateDrifted} re-reads
2512
2561
  * these settings (and the #475 ones) at plan time, so a human turning any
2513
2562
  * of them back off in the Coolify UI is noticed and repaired, not just a
2514
- * platform that applied before this patch existed. The same fix for
2515
- * `platform:auth`, `platform:files`, `platform:ai` and the calls stack's
2516
- * stateless app is deliberately left to a follow-up once each image's
2517
- * health probe is confirmed.
2563
+ * platform that applied before this patch existed. #543 lifted the quartet
2564
+ * into the shared `healthCheckSettings` helper and applied it to every
2565
+ * other platform app with a confirmed health route (mail, edge, ai's and
2566
+ * files' runners) plus every company app `executor.ts` deploys; the
2567
+ * calls/chat Matrix stacks' Synapse and MAS stay off on purpose (neither
2568
+ * can answer a health check before the other is up) and `platform:auth`
2569
+ * and `platform:integrations` are left for a further follow-up, since
2570
+ * #543 did not establish a reachable url for either.
2518
2571
  */
2519
2572
  async function provisionPlatformArchon(context, deps) {
2520
2573
  const { platform } = context;
@@ -2641,10 +2694,10 @@ async function provisionPlatformArchon(context, deps) {
2641
2694
  await deps.coolify.updateApplication(appId, {
2642
2695
  custom_network_aliases: PLATFORM_ARCHON_INTERNAL_NAME,
2643
2696
  is_consistent_container_name_enabled: false,
2644
- health_check_enabled: true,
2645
- health_check_path: ARCHON_HEALTH_CHECK_PATH,
2646
- health_check_port: Number(ARCHON_PORT),
2647
- health_check_start_period: ARCHON_HEALTH_CHECK_START_PERIOD_SECONDS,
2697
+ // #543: the quartet below is the shared `healthCheckSettings` helper
2698
+ // every other app foundry creates now also goes through -- Archon's own
2699
+ // fields, unchanged in value, just no longer hand-rolled here.
2700
+ ...healthCheckSettings(ARCHON_HEALTH_CHECK_PATH, Number(ARCHON_PORT), ARCHON_HEALTH_CHECK_START_PERIOD_SECONDS),
2648
2701
  git_branch: gitRef,
2649
2702
  });
2650
2703
  // ---- sign-in client in the platform's own ZITADEL -------------------------
@@ -4116,8 +4169,13 @@ async function provisionPlatformAi(context, deps) {
4116
4169
  // Every run, so a changed gitRef reaches an app that already exists.
4117
4170
  git_branch: gitRef,
4118
4171
  };
4119
- if (spec === PLATFORM_AI_RUNNER)
4172
+ // #543: the health-check quartet too, runner only -- the worker exposes
4173
+ // no port at all (`ports_exposes` is never set on it, just below), so
4174
+ // there is nothing for Coolify's own container probe to reach.
4175
+ if (spec === PLATFORM_AI_RUNNER) {
4120
4176
  patch.ports_exposes = PLATFORM_AI_PORT;
4177
+ Object.assign(patch, healthCheckSettings('/api/health', Number(PLATFORM_AI_PORT)));
4178
+ }
4121
4179
  await coolify.updateApplication(app.appId, patch);
4122
4180
  return app;
4123
4181
  };
@@ -4202,18 +4260,23 @@ async function provisionPlatformAi(context, deps) {
4202
4260
  }
4203
4261
  await coolify.raw('POST', `/applications/${migrate.appId}/stop`);
4204
4262
  }
4205
- // #252: confirmed before anything below -- the applied ref recorded just
4206
- // after, and the mint further down -- depends on the new version actually
4207
- // being live. Never read the old container's healthy status as the new
4208
- // deployment's (`CLAUDE.md`'s platform-step rule 5): `'deployment-only'`,
4209
- // because the runner's own doc comment above says why there is no url to
4210
- // check instead ("Nothing public is wired: no Coolify `domains`, tunnel,
4211
- // DNS record or Access app"). This replaces the old deploy-then-defer-the-
4212
- // wait-until-the-mint-needs-it shape (#375) with the one every other
4213
- // platform step uses: the wait happens once, right here, so a failed
4214
- // deployment is caught before either thing downstream trusts it.
4263
+ // #252/#543: confirmed before anything below -- the applied ref recorded
4264
+ // just after, and the mint further down -- depends on the new version
4265
+ // actually being live. Never read the old container's healthy status as
4266
+ // the new deployment's (`CLAUDE.md`'s platform-step rule 5). Hoisted above
4267
+ // both waits (`record(PLATFORM_AI_INTERNAL_URL_SCOPE_KEY, ...)` below used
4268
+ // to be the first place this was built) so the runner's own wait can probe
4269
+ // it: the runner exposes `/api/health` on {@link PLATFORM_AI_PORT}
4270
+ // (`ports_exposes` is set on it, just above, for exactly this), so `'ready'`
4271
+ // closes the loophole `'deployment-only'` cannot (a deployment that
4272
+ // "finished" with a crash-looping container). The worker stays
4273
+ // `'deployment-only'`: it has no `ports_exposes` at all (see the comment on
4274
+ // that patch above), so there is no url this executor could ever reach to
4275
+ // probe it, the same reason the runner's own doc comment gives for why
4276
+ // nothing public is wired to either app.
4277
+ const internalUrl = `http://${PLATFORM_AI_RUNNER.internalName}:${PLATFORM_AI_PORT}`;
4215
4278
  if (deployRunner) {
4216
- await waitForNewDeployment(coolify, runner.appId, { kind: 'deployment-only' }, runtime, sleep, 'platform:ai', `the runner stays at ${recorded[PLATFORM_AI_APPLIED_GITREF_SCOPE_KEY] ?? 'no recorded ref'}`);
4279
+ await waitForNewDeployment(coolify, runner.appId, { kind: 'ready', url: `${internalUrl}/api/health` }, runtime, sleep, 'platform:ai', `the runner stays at ${recorded[PLATFORM_AI_APPLIED_GITREF_SCOPE_KEY] ?? 'no recorded ref'}`);
4217
4280
  }
4218
4281
  if (deployWorker) {
4219
4282
  await waitForNewDeployment(coolify, worker.appId, { kind: 'deployment-only' }, runtime, sleep, 'platform:ai', `the worker stays at ${recorded[PLATFORM_AI_APPLIED_GITREF_SCOPE_KEY] ?? 'no recorded ref'}`);
@@ -4226,7 +4289,6 @@ async function provisionPlatformAi(context, deps) {
4226
4289
  // the key is delivered.
4227
4290
  await record(PLATFORM_AI_APPLIED_HOSTNAME_SCOPE_KEY, hostname);
4228
4291
  await record(PLATFORM_AI_APPLIED_GITREF_SCOPE_KEY, gitRef);
4229
- const internalUrl = `http://${PLATFORM_AI_RUNNER.internalName}:${PLATFORM_AI_PORT}`;
4230
4292
  await record(PLATFORM_AI_INTERNAL_URL_SCOPE_KEY, internalUrl);
4231
4293
  // ---- Archon's AI management key, once ------------------------------------
4232
4294
  // #252: nothing left to wait on here -- the deployment above is already
@@ -4810,6 +4872,8 @@ const FILES_RUNNER_APP_NAME = 'files';
4810
4872
  const FILES_RUNNER_INTERNAL_NAME = 'platform-files';
4811
4873
  /** The runner's port (`Dockerfile`'s `runner` stage: `PORT=3000`, `EXPOSE 3000`). */
4812
4874
  const FILES_PORT = '3000';
4875
+ /** files' own health route, the same one `healthUrls` (`executor.ts`) assumes every hosted app serves. */
4876
+ const FILES_HEALTH_CHECK_PATH = '/api/health';
4813
4877
  /** The Postgres database name; files' runtime role is `<database>_rt`. */
4814
4878
  const FILES_DB = 'files';
4815
4879
  const FILES_RUNTIME_ROLE = `${FILES_DB}_rt`;
@@ -5002,6 +5066,12 @@ shareDomains) {
5002
5066
  dockerfile_target_build: 'runner',
5003
5067
  custom_internal_name: FILES_RUNNER_INTERNAL_NAME,
5004
5068
  git_branch: gitRef,
5069
+ // #543: the health-check quartet, same reason Archon's doc comment
5070
+ // gives. The migrate app gets none -- it idles rather than serves
5071
+ // anything (`waitForMigration`/`waitForMigrateDeployment` below are its
5072
+ // own, different wait), so there is no health route to point Coolify's
5073
+ // probe at.
5074
+ ...healthCheckSettings(FILES_HEALTH_CHECK_PATH, Number(FILES_PORT)),
5005
5075
  // Coolify wants full URLs. Written only when the set changed, so a runner
5006
5076
  // nobody gave a share host keeps no `domains`.
5007
5077
  ...filesShareDomainsPatch(recorded, shareDomains),
@@ -5146,15 +5216,18 @@ shareDomains) {
5146
5216
  // mark a deployment that actually migrated as `failed`.
5147
5217
  await waitForMigrateDeployment(deps.coolify, migrateApp.appId, queuedDeploymentUuid(migrateDeployed) ?? 'untracked', 'platform:files', 'files-migrate', runtime.now ?? Date.now, runtime.migrateTimeoutMs ?? 900_000, runtime.migrateIntervalMs ?? 10_000, sleep);
5148
5218
  await deps.coolify.raw('POST', `/applications/${migrateApp.appId}/stop`);
5149
- // #252: `'deployment-only'` -- the runner's own doc comment above says why
5150
- // ("The runner is created without `domains`... Nothing public is wired"):
5151
- // there is no url this executor, running outside files' own Docker
5152
- // network, could ever reach, so `coolify.deployAndWait` reaching Coolify's
5153
- // own deployment record is the whole check. Still enough to catch the bug
5154
- // this closes: the applied ref recorded below while the *old* container
5155
- // (never read here) stayed healthy through a failed deploy.
5156
- await waitForNewDeployment(deps.coolify, runnerApp.appId, { kind: 'deployment-only' }, runtime, sleep, 'platform:files', `the runner stays at ${recorded[PLATFORM_FILES_APPLIED_GIT_REF_SCOPE_KEY] ?? 'no recorded ref'}`);
5157
- await record(PLATFORM_FILES_INTERNAL_URL_SCOPE_KEY, `http://${FILES_RUNNER_INTERNAL_NAME}:${FILES_PORT}`);
5219
+ // #252/#543: no public `domains`, tunnel or DNS record is wired to the
5220
+ // runner (its own doc comment: "Nothing public is wired"), but it does
5221
+ // answer {@link FILES_HEALTH_CHECK_PATH} on its own Coolify-network
5222
+ // address -- reachable from this executor the same way `provisionPlatformAi`'s
5223
+ // runner now is. `'ready'` closes the loophole `'deployment-only'` alone
5224
+ // cannot (a deployment Coolify calls "finished" with a crash-looping
5225
+ // container); `coolify.deployAndWait` reaching Coolify's own deployment
5226
+ // record first is still what rules out the old container staying healthy
5227
+ // through a failed deploy.
5228
+ const internalUrl = `http://${FILES_RUNNER_INTERNAL_NAME}:${FILES_PORT}`;
5229
+ await waitForNewDeployment(deps.coolify, runnerApp.appId, { kind: 'ready', url: `${internalUrl}${FILES_HEALTH_CHECK_PATH}` }, runtime, sleep, 'platform:files', `the runner stays at ${recorded[PLATFORM_FILES_APPLIED_GIT_REF_SCOPE_KEY] ?? 'no recorded ref'}`);
5230
+ await record(PLATFORM_FILES_INTERNAL_URL_SCOPE_KEY, internalUrl);
5158
5231
  // Last: a run that failed anywhere above is planned again.
5159
5232
  context.recordScopeId(PLATFORM_FILES_APPLIED_HOSTNAME_SCOPE_KEY, platform.files.hostname);
5160
5233
  await record(PLATFORM_FILES_APPLIED_GIT_REF_SCOPE_KEY, gitRef);
@@ -5359,10 +5432,13 @@ async function provisionPlatformEdge(context, deps) {
5359
5432
  // Set on every run, not only after a create: a throw right after the
5360
5433
  // create must not leave the app on the wrong target for good, and a
5361
5434
  // changed `gitRef` has to reach an app made on an earlier one.
5435
+ // #543: the health-check quartet too -- edge's own `/api/health`, on the
5436
+ // port the `waitForNewDeployment` call below already probes internally.
5362
5437
  await deps.coolify.updateApplication(appId, {
5363
5438
  dockerfile_target_build: 'runner',
5364
5439
  custom_internal_name: EDGE_RUNNER_INTERNAL_NAME,
5365
5440
  git_branch: gitRef,
5441
+ ...healthCheckSettings('/api/health', Number(EDGE_PORT)),
5366
5442
  });
5367
5443
  const runtimeUrl = await ensureEdgeRuntimeUrl(deps.coolify, appId, db.internal_db_url);
5368
5444
  // ---- the platform key, once ----------------------------------------------
@@ -5457,6 +5533,224 @@ async function provisionPlatformEdge(context, deps) {
5457
5533
  await record(PLATFORM_EDGE_APPLIED_GIT_REF_SCOPE_KEY, gitRef);
5458
5534
  return { resourceId: appId };
5459
5535
  }
5536
+ /** `healthchecks/healthchecks` on Docker Hub -- #540's own "third-party, not this estate's own repo" image. */
5537
+ const HC_IMAGE = 'healthchecks/healthchecks';
5538
+ const PLATFORM_HC_PROJECT = 'platform';
5539
+ const PLATFORM_HC_DB_NAME = 'platform-hc-db';
5540
+ const PLATFORM_HC_APP_NAME = 'platform-hc';
5541
+ /** The app's fixed `custom_internal_name`, the `EDGE_RUNNER_INTERNAL_NAME` convention. */
5542
+ const HC_INTERNAL_NAME = 'platform-hc';
5543
+ /** Healthchecks' own default uWSGI/gunicorn port inside the container. */
5544
+ const HC_PORT = '8000';
5545
+ /**
5546
+ * Healthchecks ships no dedicated readiness route (confirmed against its
5547
+ * `hc/front/urls.py`: the closest is `checks/<uuid>/status/`, which needs a
5548
+ * check's own id) -- `/` (`views.index`) is the documented fallback, a
5549
+ * public page for an anonymous visitor.
5550
+ */
5551
+ const HC_HEALTH_CHECK_PATH = '/';
5552
+ const PLATFORM_HC_DB = 'hc';
5553
+ const PLATFORM_HC_DB_USER = 'hc';
5554
+ const HC_TUNNEL_NAME = 'platform-hc-tunnel';
5555
+ /** #197: the one Coolify app that runs `cloudflared` for {@link HC_TUNNEL_NAME}. */
5556
+ const HC_CLOUDFLARED_APP_NAME = 'platform-hc-cloudflared';
5557
+ const PLATFORM_HC_PROJECT_SCOPE_KEY = 'coolify_project_platform_hc';
5558
+ const PLATFORM_HC_DB_SCOPE_KEY = 'coolify_platform_hc_database';
5559
+ const PLATFORM_HC_APP_SCOPE_KEY = 'coolify_platform_hc_app';
5560
+ const PLATFORM_HC_TUNNEL_SCOPE_KEY = 'cloudflare_platform_hc_tunnel';
5561
+ const PLATFORM_HC_CONNECTOR_APP_SCOPE_KEY = 'coolify_platform_hc_connector_app';
5562
+ const PLATFORM_HC_INTERNAL_URL_SCOPE_KEY = 'platform_hc_internal_url';
5563
+ /**
5564
+ * #540 round 2: a thin wrapper over the estate-wide {@link floatingTagBlocked},
5565
+ * the {@link mailImageTagBlocked} pattern for hc's own image tag -- #540
5566
+ * shipped with no guard at all here, and `DEFAULT_HC_IMAGE_TAG` floating at
5567
+ * `'latest'` by default was exactly what `floatingTagBlocked`'s own
5568
+ * `FLOATING_TAGS` set exists to catch.
5569
+ */
5570
+ function hcImageTagBlocked(tag) {
5571
+ const reason = floatingTagBlocked('Healthchecks', tag, VERSIONS.hc);
5572
+ return reason ? `the heartbeat server is blocked, not planned: ${reason} as the hc gitRef` : null;
5573
+ }
5574
+ /**
5575
+ * #540: the platform's own heartbeat server (self-hosted Healthchecks) --
5576
+ * a Coolify Postgres plus one `healthchecks/healthchecks` Docker-image app,
5577
+ * reached through edge's tunnel and DNS at `platform.hc.hostname`. No
5578
+ * ZITADEL client, no sign-in: `hc` is an internal operational tool, read by
5579
+ * nobody outside foundry and whoever holds its checks' ping URLs.
5580
+ *
5581
+ * `REGISTRATION_OPEN=False` is set alongside the rest of the env below --
5582
+ * Healthchecks defaults that setting to `true` (confirmed against its own
5583
+ * `hc/settings.py`, 2026-10), and `hostname` is a public, Cloudflare-proxied
5584
+ * DNS record the moment this step's tunnel and DNS write land, so leaving
5585
+ * it unset would let anyone who finds the hostname sign themselves up.
5586
+ *
5587
+ * Idempotent the {@link provisionPlatformEdge} way: no reader reports this
5588
+ * Coolify app, so the planner's own recorded-id check
5589
+ * (`platform-diff.ts`'s `planHc`) is what stops this running on every
5590
+ * apply, not a live comparison in here.
5591
+ */
5592
+ async function provisionPlatformHc(context, deps) {
5593
+ const { platform } = context;
5594
+ const hostname = platform.hc.hostname;
5595
+ const gitRef = platform.hc.gitRef;
5596
+ const tagBlocked = hcImageTagBlocked(gitRef);
5597
+ if (tagBlocked)
5598
+ throw new Error(tagBlocked);
5599
+ const recorded = context.recordedIds();
5600
+ const runtime = deps.runtime ?? {};
5601
+ const sleep = runtime.sleep ?? defaultSleep;
5602
+ const record = async (key, id) => {
5603
+ context.recordScopeId(key, id);
5604
+ await context.persist?.();
5605
+ };
5606
+ let projectUuid = recorded[PLATFORM_HC_PROJECT_SCOPE_KEY];
5607
+ if (!projectUuid) {
5608
+ const found = await deps.coolify.findProjectByName(PLATFORM_HC_PROJECT);
5609
+ projectUuid = found ? found.uuid : (await deps.coolify.createProject(PLATFORM_HC_PROJECT)).uuid;
5610
+ await record(PLATFORM_HC_PROJECT_SCOPE_KEY, projectUuid);
5611
+ }
5612
+ const { environment } = await deps.coolify.findOrCreateEnvironment(projectUuid, 'production');
5613
+ const server = await resolvePlatformServer(deps.coolify);
5614
+ // ---- database ------------------------------------------------------------
5615
+ let dbId = recorded[PLATFORM_HC_DB_SCOPE_KEY];
5616
+ if (!dbId) {
5617
+ const found = (await deps.coolify.listDatabases()).find((db) => db.name === PLATFORM_HC_DB_NAME && db.environment_id === environment.id);
5618
+ if (found) {
5619
+ dbId = found.uuid;
5620
+ }
5621
+ else {
5622
+ const created = await deps.coolify.createPostgresDatabase({
5623
+ project_uuid: projectUuid,
5624
+ server_uuid: server.uuid,
5625
+ environment_name: environment.name,
5626
+ environment_uuid: environment.uuid,
5627
+ name: PLATFORM_HC_DB_NAME,
5628
+ postgres_db: PLATFORM_HC_DB,
5629
+ postgres_user: PLATFORM_HC_DB_USER,
5630
+ });
5631
+ dbId = created.uuid;
5632
+ }
5633
+ await record(PLATFORM_HC_DB_SCOPE_KEY, dbId);
5634
+ }
5635
+ const db = await getDatabaseStarted(deps.coolify, dbId);
5636
+ if (!db.internal_db_url) {
5637
+ throw new Error('coolify:app for the platform (hc): the database has no internal_db_url yet -- retry this apply once Coolify reports one');
5638
+ }
5639
+ // ---- the app --------------------------------------------------------------
5640
+ const app = await findOrCreatePlatformApp(deps.coolify, PLATFORM_HC_APP_NAME, environment, async () => {
5641
+ return deps.coolify.createDockerImageApplication({
5642
+ project_uuid: projectUuid,
5643
+ server_uuid: server.uuid,
5644
+ environment_name: environment.name,
5645
+ environment_uuid: environment.uuid,
5646
+ docker_registry_image_name: HC_IMAGE,
5647
+ docker_registry_image_tag: gitRef,
5648
+ ports_exposes: HC_PORT,
5649
+ name: PLATFORM_HC_APP_NAME,
5650
+ description: `Healthchecks for the platform's own heartbeats (#540, ${HC_IMAGE}:${gitRef})`,
5651
+ instant_deploy: false,
5652
+ });
5653
+ });
5654
+ const appId = app.appId;
5655
+ await record(PLATFORM_HC_APP_SCOPE_KEY, appId);
5656
+ // Set on every run, not only after a create -- `provisionPlatformAuth`'s
5657
+ // own rule. The image tag too, so a declared release change reaches the app.
5658
+ await deps.coolify.updateApplication(appId, {
5659
+ custom_internal_name: HC_INTERNAL_NAME,
5660
+ docker_registry_image_tag: gitRef,
5661
+ ...healthCheckSettings(HC_HEALTH_CHECK_PATH, Number(HC_PORT)),
5662
+ });
5663
+ // ---- env, parsed off the database's own owner credentials -----------------
5664
+ // Healthchecks takes discrete DB_* vars, not a DATABASE_URL (its own
5665
+ // `hc/settings.py`) -- no separate runtime role the way edge/files mint
5666
+ // one: Healthchecks runs migrations and serves from the same one process,
5667
+ // so there is no second, more-restricted role to split off here.
5668
+ let dbHost;
5669
+ let dbPort;
5670
+ let dbName;
5671
+ let dbUser;
5672
+ let dbPassword;
5673
+ try {
5674
+ const parsed = new URL(db.internal_db_url);
5675
+ dbHost = parsed.hostname;
5676
+ dbPort = parsed.port || '5432';
5677
+ dbName = parsed.pathname.replace(/^\//, '');
5678
+ dbUser = decodeURIComponent(parsed.username);
5679
+ dbPassword = decodeURIComponent(parsed.password);
5680
+ }
5681
+ catch {
5682
+ throw new Error("coolify:app for the platform (hc): the database's internal_db_url could not be parsed into DB_* env vars");
5683
+ }
5684
+ const secretKey = await ensureGeneratedEnv(deps.coolify, appId, 'SECRET_KEY', () => randomBytes(32).toString('base64url'));
5685
+ const envs = [
5686
+ { key: 'DB', value: 'postgres' },
5687
+ { key: 'DB_HOST', value: dbHost },
5688
+ { key: 'DB_PORT', value: dbPort },
5689
+ { key: 'DB_NAME', value: dbName },
5690
+ { key: 'DB_USER', value: dbUser },
5691
+ { key: 'DB_PASSWORD', value: dbPassword },
5692
+ { key: 'SITE_ROOT', value: `https://${hostname}` },
5693
+ { key: 'ALLOWED_HOSTS', value: hostname },
5694
+ // Healthchecks defaults this to `true` -- see this function's own doc
5695
+ // comment. `hostname` is public and Cloudflare-proxied, so sign-up stays
5696
+ // closed until a real invite mechanism exists.
5697
+ { key: 'REGISTRATION_OPEN', value: 'False' },
5698
+ ];
5699
+ if (secretKey)
5700
+ envs.push({ key: 'SECRET_KEY', value: secretKey });
5701
+ const envsChanged = await envsDiffer(deps.coolify, appId, envs);
5702
+ await deps.coolify.bulkUpsertEnvs(appId, envs);
5703
+ const declaredChanged = recorded[PLATFORM_HC_APPLIED_HOSTNAME_SCOPE_KEY] !== hostname ||
5704
+ recorded[PLATFORM_HC_APPLIED_GIT_REF_SCOPE_KEY] !== gitRef;
5705
+ const internalUrl = `http://${HC_INTERNAL_NAME}:${HC_PORT}`;
5706
+ if (app.justCreated || !app.running || envsChanged || declaredChanged) {
5707
+ await waitForNewDeployment(deps.coolify, appId, { kind: 'ready', url: `${internalUrl}${HC_HEALTH_CHECK_PATH}` }, runtime, sleep, 'platform:hc', `hc stays at ${recorded[PLATFORM_HC_APPLIED_GIT_REF_SCOPE_KEY] ?? 'no recorded ref'}`);
5708
+ }
5709
+ await record(PLATFORM_HC_INTERNAL_URL_SCOPE_KEY, internalUrl);
5710
+ // ---- tunnel and DNS ---------------------------------------------------------
5711
+ const tunnels = await platformTunnels(deps, context.recordedIds(), 'platform:hc');
5712
+ const existingTunnel = await tunnels.findTunnelByName(HC_TUNNEL_NAME);
5713
+ const tunnel = existingTunnel ?? (await tunnels.createTunnel(HC_TUNNEL_NAME));
5714
+ await record(PLATFORM_HC_TUNNEL_SCOPE_KEY, tunnel.id);
5715
+ await tunnels.putTunnelConfig(tunnel.id, {
5716
+ ingress: [{ hostname, service: internalUrl }, { service: 'http_status:404' }],
5717
+ });
5718
+ // #197: without a connector, `hostname` answers with a Cloudflare error.
5719
+ await ensureCloudflaredConnector({
5720
+ coolify: deps.coolify,
5721
+ tunnels,
5722
+ tunnelId: tunnel.id,
5723
+ appName: HC_CLOUDFLARED_APP_NAME,
5724
+ projectUuid,
5725
+ environment,
5726
+ resolveServer: async () => server,
5727
+ recordedConnectorAppId: recorded[PLATFORM_HC_CONNECTOR_APP_SCOPE_KEY] ?? null,
5728
+ recordAppId: (connectorAppId) => record(PLATFORM_HC_CONNECTOR_APP_SCOPE_KEY, connectorAppId),
5729
+ });
5730
+ const dns = await platformDns(deps, context.recordedIds(), 'platform:hc');
5731
+ const dnsTarget = `${tunnel.id}.cfargotunnel.com`;
5732
+ const [dnsRecord] = await dns.findDnsRecord(platform.apex, hostname, 'CNAME');
5733
+ if (!dnsRecord) {
5734
+ await dns.createDnsRecord(platform.apex, {
5735
+ type: 'CNAME',
5736
+ name: hostname,
5737
+ content: dnsTarget,
5738
+ proxied: true,
5739
+ });
5740
+ }
5741
+ else if (dnsRecord.content !== dnsTarget) {
5742
+ await dns.updateDnsRecord(platform.apex, dnsRecord.id, {
5743
+ type: 'CNAME',
5744
+ name: hostname,
5745
+ content: dnsTarget,
5746
+ proxied: true,
5747
+ });
5748
+ }
5749
+ // Last: a run that failed anywhere above is planned again.
5750
+ context.recordScopeId(PLATFORM_HC_APPLIED_HOSTNAME_SCOPE_KEY, hostname);
5751
+ await record(PLATFORM_HC_APPLIED_GIT_REF_SCOPE_KEY, gitRef);
5752
+ return { resourceId: appId };
5753
+ }
5460
5754
  /**
5461
5755
  * The runtime `DATABASE_URL` the names app gets: the value already set on
5462
5756
  * it, else a new `names_rt` credential on the database's host, the
@@ -6229,6 +6523,41 @@ const PLATFORM_BACKUP_S3_STORAGE_FINGERPRINT_SCOPE_KEY = 'coolify_platform_backu
6229
6523
  const PLATFORM_BACKUP_S3_STORAGE_PENDING_SCOPE_KEY = 'coolify_platform_backup_s3_storage_pending';
6230
6524
  /** Coolify's own name for "once a day" -- `createDatabaseBackup`'s `frequency`. */
6231
6525
  const PLATFORM_BACKUP_DEFAULT_FREQUENCY = 'daily';
6526
+ /**
6527
+ * The database a service's schedule must name. A schedule that names none
6528
+ * dumps the Coolify resource's first database, which for live Archon is the
6529
+ * abandoned `archon`, not the one it runs on (see {@link ARCHON_POSTGRES_DB}).
6530
+ * Every other service runs on its resource's first database and needs no entry.
6531
+ */
6532
+ const PLATFORM_BACKUP_DATABASE_NAMES = {
6533
+ archon: ARCHON_POSTGRES_DB,
6534
+ };
6535
+ /**
6536
+ * The one schedule both the reader and {@link provisionPlatformBackup} treat
6537
+ * as a service's backup, so the two never disagree: an enabled, `save_s3`
6538
+ * schedule that dumps the right database, else the first enabled, `save_s3`
6539
+ * one with `named: false` (it needs the repair). A service with no entry in
6540
+ * {@link PLATFORM_BACKUP_DATABASE_NAMES} is always `named`. The list
6541
+ * response is not confirmed to carry `databases_to_backup`, so a schedule
6542
+ * without the field is read off its detail route, the same fallback
6543
+ * {@link repointPlatformBackupSchedules} uses for `s3_storage_uuid`.
6544
+ */
6545
+ async function findPlatformBackupSchedule(coolify, service, databaseId) {
6546
+ const schedules = (await coolify.listDatabaseBackups(databaseId)).filter((backup) => backup.enabled && backup.save_s3);
6547
+ const database = PLATFORM_BACKUP_DATABASE_NAMES[service];
6548
+ if (!database)
6549
+ return { schedule: schedules[0], named: true };
6550
+ for (const schedule of schedules) {
6551
+ let databases = schedule.databases_to_backup;
6552
+ if (databases === undefined) {
6553
+ const detail = (await coolify.raw('GET', `/databases/${databaseId}/backups/${schedule.uuid}`));
6554
+ databases = detail?.databases_to_backup;
6555
+ }
6556
+ if (databases === database)
6557
+ return { schedule, named: true };
6558
+ }
6559
+ return { schedule: schedules[0], named: false };
6560
+ }
6232
6561
  /**
6233
6562
  * Trims a trailing slash and lowercases the host, so a Coolify-normalised
6234
6563
  * endpoint (or one a person typed with a trailing slash) is never read as a
@@ -6590,17 +6919,109 @@ async function provisionPlatformBackup(action, context, deps) {
6590
6919
  const databaseId = action.desired.databaseId;
6591
6920
  const recorded = context.recordedIds();
6592
6921
  const storageUuid = await ensurePlatformBackupStorage(coolify, r2, recorded, context);
6593
- const existing = (await coolify.listDatabaseBackups(databaseId)).find((backup) => backup.enabled && backup.save_s3);
6594
- if (!existing) {
6595
- await coolify.createDatabaseBackup(databaseId, {
6922
+ const service = action.key;
6923
+ const { schedule, named } = await findPlatformBackupSchedule(coolify, service, databaseId);
6924
+ const scheduleUuid = schedule?.uuid ??
6925
+ (await coolify.createDatabaseBackup(databaseId, {
6596
6926
  frequency: deps.frequency ?? PLATFORM_BACKUP_DEFAULT_FREQUENCY,
6597
6927
  enabled: true,
6598
6928
  save_s3: true,
6599
6929
  s3_storage_uuid: storageUuid,
6930
+ })).uuid;
6931
+ // `CoolifyPort.createDatabaseBackup` takes no database name, so a new
6932
+ // schedule is named here too, by the same PATCH that repairs an existing
6933
+ // one dumping the wrong database.
6934
+ if (!named) {
6935
+ await coolify.raw('PATCH', `/databases/${databaseId}/backups/${scheduleUuid}`, {
6936
+ databases_to_backup: PLATFORM_BACKUP_DATABASE_NAMES[service],
6600
6937
  });
6601
6938
  }
6602
6939
  return undefined;
6603
6940
  }
6941
+ /**
6942
+ * #540: reconciles the live Healthchecks checks against the ones foundry
6943
+ * wants -- `desiredHeartbeatCheckNames` (`platform-diff.ts`): the uptime
6944
+ * Worker's own, plus one per platform database backup that already has a
6945
+ * recorded database id. Idempotent twice over: `planHeartbeats` only plans
6946
+ * this step when the declared desired set has changed since the last
6947
+ * successful reconcile (its own doc comment says why), and this function
6948
+ * itself lists live checks first, so a name already live is never created
6949
+ * twice -- a second run, even one somehow forced, creates none (it still
6950
+ * re-saves the address every time; see `PlatformHeartbeatsDeps.put`'s own
6951
+ * doc comment for why that is the round-2 fix, not a regression).
6952
+ *
6953
+ * Deletes only a live check whose name matches this step's own naming
6954
+ * scheme (`PLATFORM_UPTIME_HEARTBEAT_CHECK_NAME` or
6955
+ * `platformBackupHeartbeatCheckName`), is no longer desired, AND is held
6956
+ * under that exact name's own ref -- #540 round 2 added the third
6957
+ * condition (`PlatformHeartbeatsDeps.resolve`'s own doc comment says why):
6958
+ * a name match alone is never enough to delete, so this reconcile can
6959
+ * never destroy a check it did not itself create, whether that check is a
6960
+ * stranger's or simply one this step never got to create in the first
6961
+ * place (no `resolve` wired at all reads as "prove nothing", the safe
6962
+ * direction).
6963
+ *
6964
+ * `uptime-worker`'s own ping URL is recorded under
6965
+ * `PLATFORM_UPTIME_HEARTBEAT_URL_REF`, but nothing pings it yet: delivering
6966
+ * it to a running uptime Worker is foundry#542's own job, and that issue is
6967
+ * blocked today on a missing KV/cron/secret-binding capability in
6968
+ * `@wtfalch/edge` (wtfalch/edge#26) -- a known gap, not an oversight here.
6969
+ * Each backup check's ping URL is recorded the same way under
6970
+ * `platformBackupHeartbeatUrlRef(service)`, and nothing pings those either:
6971
+ * Coolify's own `createDatabaseBackup` API (`executor.ts`'s
6972
+ * `CoolifyPort.createDatabaseBackup`) has no notification/webhook field at
6973
+ * all, so there is no way for Coolify's backup scheduler to call this check
6974
+ * when a backup actually runs. Both gaps are named here, not worked around
6975
+ * -- no polling, no wrapping Coolify's scheduler. Every check this function
6976
+ * creates is paused on creation for exactly that reason (see
6977
+ * `HeartbeatsPort.pauseCheck`'s own doc comment): until one of those two
6978
+ * gaps closes, a newly created check would otherwise read "down" within its
6979
+ * own grace period, with nothing actually wrong.
6980
+ */
6981
+ async function provisionPlatformHeartbeats(context, deps) {
6982
+ const recorded = context.recordedIds();
6983
+ const desiredNames = desiredHeartbeatCheckNames(recorded);
6984
+ const desiredSet = new Set(desiredNames);
6985
+ const live = await deps.heartbeats.listChecks();
6986
+ const liveByName = new Map(live.map((check) => [check.name, check]));
6987
+ for (const name of desiredNames) {
6988
+ const existing = liveByName.get(name);
6989
+ const put = deps.heldKeys?.put;
6990
+ if (!put) {
6991
+ throw new Error(`heartbeats:reconcile for the platform: ${name}'s ping address needs saving, but the host built the platform executor without a way to hold it (heldKeys.put, #540)`);
6992
+ }
6993
+ if (existing) {
6994
+ // Already live. Re-save its ping address on every run, whether or not
6995
+ // it looks already correct -- see `PlatformHeartbeatsDeps.put`'s own
6996
+ // doc comment for why this, not a one-time save at create, is the fix.
6997
+ await put(heartbeatCheckUrlRef(name), existing.pingUrl);
6998
+ continue;
6999
+ }
7000
+ const created = await deps.heartbeats.createCheck(name);
7001
+ await put(heartbeatCheckUrlRef(name), created.pingUrl);
7002
+ await deps.heartbeats.pauseCheck(created.id);
7003
+ }
7004
+ const ownedNames = new Set([
7005
+ PLATFORM_UPTIME_HEARTBEAT_CHECK_NAME,
7006
+ ...PLATFORM_BACKUP_SERVICE_KEYS.map((service) => platformBackupHeartbeatCheckName(service)),
7007
+ ]);
7008
+ const resolve = deps.heldKeys?.resolve;
7009
+ for (const check of live) {
7010
+ if (!ownedNames.has(check.name) || desiredSet.has(check.name))
7011
+ continue;
7012
+ // #540 round 2: a name match this reconcile holds no ref for is never
7013
+ // deleted -- see this function's own doc comment, third paragraph.
7014
+ const heldValue = resolve ? await resolve(heartbeatCheckUrlRef(check.name)) : null;
7015
+ if (!heldValue)
7016
+ continue;
7017
+ await deps.heartbeats.deleteCheck(check.id);
7018
+ }
7019
+ // Last: so a run that failed anywhere above (an unreconciled create or
7020
+ // delete) leaves this unset and `planHeartbeats` plans the step again --
7021
+ // the same "record markers last" rule `provisionPlatformHc` follows.
7022
+ context.recordScopeId(PLATFORM_HEARTBEATS_APPLIED_SCOPE_KEY, [...desiredNames].sort().join(','));
7023
+ return undefined;
7024
+ }
6604
7025
  /**
6605
7026
  * The real {@link PlatformCloudflareReader}: the platform's apex zone as
6606
7027
  * `planApexZone` expects to see it, and nothing else. The zone's DNS records
@@ -6702,8 +7123,10 @@ export class ProvidersPlatformCoolifyReader {
6702
7123
  if (!databaseId)
6703
7124
  continue;
6704
7125
  try {
6705
- const backups = await this.client.listDatabaseBackups(databaseId);
6706
- const scheduled = backups.find((backup) => backup.enabled && backup.save_s3);
7126
+ // A schedule that dumps the wrong database is not live: the step is
7127
+ // planned again and `provisionPlatformBackup` repairs it.
7128
+ const { schedule, named } = await findPlatformBackupSchedule(this.client, service, databaseId);
7129
+ const scheduled = named ? schedule : undefined;
6707
7130
  if (scheduled) {
6708
7131
  resources.push({
6709
7132
  system: 'coolify',