@wtfalch/foundry 0.19.1 → 0.21.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/bootstrap/declare.d.ts +13 -0
- package/dist/bootstrap/declare.js +31 -7
- package/dist/bootstrap/versions.d.ts +1 -0
- package/dist/index.d.ts +7 -3
- package/dist/index.js +4 -2
- package/dist/migrations/0046_platform_hc.sql +19 -0
- package/dist/plan/cloudflared-connector.d.ts +3 -2
- package/dist/plan/cloudflared-connector.js +2 -2
- package/dist/plan/deploy-health.js +5 -1
- package/dist/plan/diff.js +27 -4
- package/dist/plan/edge-access.d.ts +68 -0
- package/dist/plan/edge-access.js +123 -0
- package/dist/plan/edge-dns.d.ts +89 -0
- package/dist/plan/edge-dns.js +227 -0
- package/dist/plan/edge-ratelimits.d.ts +54 -0
- package/dist/plan/edge-ratelimits.js +79 -0
- package/dist/plan/edge-tunnels.d.ts +44 -0
- package/dist/plan/edge-tunnels.js +108 -0
- package/dist/plan/edge-workers.d.ts +31 -0
- package/dist/plan/edge-workers.js +165 -0
- package/dist/plan/edge-zones.d.ts +37 -0
- package/dist/plan/edge-zones.js +64 -0
- package/dist/plan/entry-edge-key.d.ts +10 -0
- package/dist/plan/entry-edge-key.js +38 -8
- package/dist/plan/executor.d.ts +146 -143
- package/dist/plan/executor.js +370 -115
- package/dist/plan/health-check-settings.d.ts +47 -0
- package/dist/plan/health-check-settings.js +47 -0
- package/dist/plan/health-targets.d.ts +86 -0
- package/dist/plan/health-targets.js +344 -0
- package/dist/plan/index.d.ts +17 -3
- package/dist/plan/index.js +9 -2
- package/dist/plan/live.d.ts +12 -1
- package/dist/plan/plan-apply.d.ts +4 -0
- package/dist/plan/plan-apply.js +5 -1
- package/dist/plan/platform-diff.d.ts +127 -0
- package/dist/plan/platform-diff.js +304 -8
- package/dist/plan/platform-providers.d.ts +202 -7
- package/dist/plan/platform-providers.js +545 -79
- package/dist/plan/providers-readers.d.ts +23 -1
- package/dist/plan/providers-readers.js +61 -1
- package/dist/plan/rollout.d.ts +9 -8
- package/dist/platform.d.ts +14 -1
- package/dist/platform.js +1 -0
- package/dist/store/postgres.js +8 -0
- package/dist/store/schema.d.ts +51 -0
- package/dist/store/schema.js +4 -0
- package/package.json +2 -1
- package/versions.json +2 -1
|
@@ -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_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`). */
|
|
@@ -16,6 +17,35 @@ function apexZoneKey(apex) {
|
|
|
16
17
|
}
|
|
17
18
|
/** The key `runPlatformSteps` records the jobs scheduler app's id under (`system:kind:key`, #173). */
|
|
18
19
|
const JOBS_APP_KEY = `coolify:jobs:${JOBS_SCHEDULER_KEY}`;
|
|
20
|
+
/**
|
|
21
|
+
* The DNS port for a platform step: edge's recorded address, never a public
|
|
22
|
+
* hostname, because auth, mail and archon write their records before any of
|
|
23
|
+
* those exist. Edge's step runs first and records the address
|
|
24
|
+
* (`planPlatform`), so a missing one means that step has not finished.
|
|
25
|
+
*/
|
|
26
|
+
async function platformDns(deps, recorded, step) {
|
|
27
|
+
const edgeUrl = recorded[PLATFORM_EDGE_INTERNAL_URL_SCOPE_KEY];
|
|
28
|
+
if (!edgeUrl) {
|
|
29
|
+
throw new Error(`${step}: edge has not been applied yet (no recorded ${PLATFORM_EDGE_INTERNAL_URL_SCOPE_KEY}); platform:edge runs first and every DNS record is written through it`);
|
|
30
|
+
}
|
|
31
|
+
return deps.dns(edgeUrl);
|
|
32
|
+
}
|
|
33
|
+
/** The tunnel port for a platform step; same address and same reason as {@link platformDns}. */
|
|
34
|
+
async function platformTunnels(deps, recorded, step) {
|
|
35
|
+
const edgeUrl = recorded[PLATFORM_EDGE_INTERNAL_URL_SCOPE_KEY];
|
|
36
|
+
if (!edgeUrl) {
|
|
37
|
+
throw new Error(`${step}: edge has not been applied yet (no recorded ${PLATFORM_EDGE_INTERNAL_URL_SCOPE_KEY}); platform:edge runs first and every tunnel is managed through it`);
|
|
38
|
+
}
|
|
39
|
+
return deps.tunnels(edgeUrl);
|
|
40
|
+
}
|
|
41
|
+
/** The Access port for a platform step; same address and same reason as {@link platformDns}. */
|
|
42
|
+
async function platformAccess(deps, recorded, step) {
|
|
43
|
+
const edgeUrl = recorded[PLATFORM_EDGE_INTERNAL_URL_SCOPE_KEY];
|
|
44
|
+
if (!edgeUrl) {
|
|
45
|
+
throw new Error(`${step}: edge has not been applied yet (no recorded ${PLATFORM_EDGE_INTERNAL_URL_SCOPE_KEY}); platform:edge runs first and every Access app is managed through it`);
|
|
46
|
+
}
|
|
47
|
+
return deps.access(edgeUrl);
|
|
48
|
+
}
|
|
19
49
|
/**
|
|
20
50
|
* #173: the jobs scheduler's own Coolify project and database, flat scope
|
|
21
51
|
* ids recorded via `PlatformStepExecutionContext.recordScopeId` -- not
|
|
@@ -119,6 +149,8 @@ export class FoundryPlatformStepExecutor {
|
|
|
119
149
|
edge;
|
|
120
150
|
archon;
|
|
121
151
|
backup;
|
|
152
|
+
hc;
|
|
153
|
+
heartbeats;
|
|
122
154
|
constructor(cloudflare, declaredOrgStore, options = {}) {
|
|
123
155
|
this.cloudflare = cloudflare;
|
|
124
156
|
this.declaredOrgStore = declaredOrgStore;
|
|
@@ -133,6 +165,8 @@ export class FoundryPlatformStepExecutor {
|
|
|
133
165
|
this.edge = options.edge;
|
|
134
166
|
this.archon = options.archon;
|
|
135
167
|
this.backup = options.backup;
|
|
168
|
+
this.hc = options.hc;
|
|
169
|
+
this.heartbeats = options.heartbeats;
|
|
136
170
|
}
|
|
137
171
|
async blockedSteps(platform, recordedIds = {}) {
|
|
138
172
|
const blocked = {};
|
|
@@ -257,6 +291,33 @@ export class FoundryPlatformStepExecutor {
|
|
|
257
291
|
PLATFORM_BACKUP_SERVICE_KEYS.some((service) => Boolean(recordedIds[PLATFORM_BACKUP_DATABASE_SCOPE_KEYS[service]]))) {
|
|
258
292
|
blocked.backupExecutorMissing = true;
|
|
259
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
|
+
}
|
|
260
321
|
return blocked;
|
|
261
322
|
}
|
|
262
323
|
/**
|
|
@@ -271,7 +332,7 @@ export class FoundryPlatformStepExecutor {
|
|
|
271
332
|
if (platform.archon.enabled &&
|
|
272
333
|
this.archon &&
|
|
273
334
|
recordedIds[`coolify:app:${PLATFORM_ARCHON_KEY}`]) {
|
|
274
|
-
const accessDrifted = await archonAccessDrifted(this.archon
|
|
335
|
+
const accessDrifted = await archonAccessDrifted(this.archon, recordedIds, platform.archon.hostname, this.archon.accessEmails);
|
|
275
336
|
const brokerDrifted = !accessDrifted && (await archonBrokerRegistrationDrifted(this.archon.coolify, recordedIds));
|
|
276
337
|
const bootstrapOperatorDrifted = !accessDrifted &&
|
|
277
338
|
!brokerDrifted &&
|
|
@@ -395,6 +456,18 @@ export class FoundryPlatformStepExecutor {
|
|
|
395
456
|
}
|
|
396
457
|
return provisionPlatformMail(context, this.mail);
|
|
397
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
|
+
}
|
|
398
471
|
if (action.system === 'cloudflare' && action.kind === 'zone') {
|
|
399
472
|
return this.cloudflareZone(action, context);
|
|
400
473
|
}
|
|
@@ -1193,10 +1266,11 @@ async function provisionPlatformAuth(context, deps, owners = []) {
|
|
|
1193
1266
|
await record(PLATFORM_AUTH_BROKER_DEPLOYED_SCOPE_KEY, brokerDeployRecordValue(brokerAppId, authAppsRaw));
|
|
1194
1267
|
}
|
|
1195
1268
|
// ---- tunnel and DNS ------------------------------------------------------
|
|
1196
|
-
const
|
|
1197
|
-
const
|
|
1269
|
+
const tunnels = await platformTunnels(deps, context.recordedIds(), 'platform:auth');
|
|
1270
|
+
const existingTunnel = await tunnels.findTunnelByName(PLATFORM_AUTH_TUNNEL_NAME);
|
|
1271
|
+
const tunnel = existingTunnel ?? (await tunnels.createTunnel(PLATFORM_AUTH_TUNNEL_NAME));
|
|
1198
1272
|
await record(PLATFORM_AUTH_TUNNEL_SCOPE_KEY, tunnel.id);
|
|
1199
|
-
await
|
|
1273
|
+
await tunnels.putTunnelConfig(tunnel.id, {
|
|
1200
1274
|
ingress: [
|
|
1201
1275
|
{
|
|
1202
1276
|
hostname,
|
|
@@ -1221,7 +1295,7 @@ async function provisionPlatformAuth(context, deps, owners = []) {
|
|
|
1221
1295
|
// step's own tunnel only (see this function's doc comment).
|
|
1222
1296
|
await ensureCloudflaredConnector({
|
|
1223
1297
|
coolify: deps.coolify,
|
|
1224
|
-
|
|
1298
|
+
tunnels,
|
|
1225
1299
|
tunnelId: tunnel.id,
|
|
1226
1300
|
appName: PLATFORM_AUTH_CLOUDFLARED_APP_NAME,
|
|
1227
1301
|
projectUuid,
|
|
@@ -1237,9 +1311,10 @@ async function provisionPlatformAuth(context, deps, owners = []) {
|
|
|
1237
1311
|
throw new Error(`platform:auth: no Cloudflare zone for ${platform.apex} to put ${hostname} in`);
|
|
1238
1312
|
}
|
|
1239
1313
|
const dnsTarget = `${tunnel.id}.cfargotunnel.com`;
|
|
1240
|
-
const
|
|
1314
|
+
const dns = await platformDns(deps, context.recordedIds(), 'platform:auth');
|
|
1315
|
+
const [dnsRecord] = await dns.findDnsRecord(platform.apex, hostname, 'CNAME');
|
|
1241
1316
|
if (!dnsRecord) {
|
|
1242
|
-
await
|
|
1317
|
+
await dns.createDnsRecord(platform.apex, {
|
|
1243
1318
|
type: 'CNAME',
|
|
1244
1319
|
name: hostname,
|
|
1245
1320
|
content: dnsTarget,
|
|
@@ -1247,7 +1322,7 @@ async function provisionPlatformAuth(context, deps, owners = []) {
|
|
|
1247
1322
|
});
|
|
1248
1323
|
}
|
|
1249
1324
|
else if (dnsRecord.content !== dnsTarget) {
|
|
1250
|
-
await
|
|
1325
|
+
await dns.updateDnsRecord(platform.apex, dnsRecord.id, {
|
|
1251
1326
|
type: 'CNAME',
|
|
1252
1327
|
name: hostname,
|
|
1253
1328
|
content: dnsTarget,
|
|
@@ -1818,9 +1893,14 @@ async function provisionPlatformMail(context, deps) {
|
|
|
1818
1893
|
await record(PLATFORM_MAIL_APP_SCOPE_KEY, appId);
|
|
1819
1894
|
// Set on every run, not only after a create -- same rule `provisionPlatformAuth` follows for its own apps.
|
|
1820
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.
|
|
1821
1900
|
await deps.coolify.updateApplication(appId, {
|
|
1822
1901
|
custom_internal_name: PLATFORM_MAIL_INTERNAL_NAME,
|
|
1823
1902
|
docker_registry_image_tag: imageTag,
|
|
1903
|
+
...healthCheckSettings(STALWART_READY_PATH, Number(STALWART_HTTP_PORT)),
|
|
1824
1904
|
});
|
|
1825
1905
|
// #177: org steps (an org's own `coolify:mail`/`coolify:agora`) read this
|
|
1826
1906
|
// back instead of a literal, the same way files/ai's own internal
|
|
@@ -1884,10 +1964,11 @@ async function provisionPlatformMail(context, deps) {
|
|
|
1884
1964
|
await record(PLATFORM_MAIL_DEPLOYED_GIT_REF_SCOPE_KEY, imageTag);
|
|
1885
1965
|
}
|
|
1886
1966
|
// ---- tunnel and DNS: web/JMAP/management ------------------------------------
|
|
1887
|
-
const
|
|
1888
|
-
const
|
|
1967
|
+
const tunnels = await platformTunnels(deps, context.recordedIds(), 'platform:mail');
|
|
1968
|
+
const existingTunnel = await tunnels.findTunnelByName(PLATFORM_MAIL_TUNNEL_NAME);
|
|
1969
|
+
const tunnel = existingTunnel ?? (await tunnels.createTunnel(PLATFORM_MAIL_TUNNEL_NAME));
|
|
1889
1970
|
await record(PLATFORM_MAIL_TUNNEL_SCOPE_KEY, tunnel.id);
|
|
1890
|
-
await
|
|
1971
|
+
await tunnels.putTunnelConfig(tunnel.id, {
|
|
1891
1972
|
ingress: [
|
|
1892
1973
|
{
|
|
1893
1974
|
hostname,
|
|
@@ -1899,7 +1980,7 @@ async function provisionPlatformMail(context, deps) {
|
|
|
1899
1980
|
// #197: without a connector, `hostname` answers with a Cloudflare error.
|
|
1900
1981
|
await ensureCloudflaredConnector({
|
|
1901
1982
|
coolify: deps.coolify,
|
|
1902
|
-
|
|
1983
|
+
tunnels,
|
|
1903
1984
|
tunnelId: tunnel.id,
|
|
1904
1985
|
appName: PLATFORM_MAIL_CLOUDFLARED_APP_NAME,
|
|
1905
1986
|
projectUuid,
|
|
@@ -1914,9 +1995,10 @@ async function provisionPlatformMail(context, deps) {
|
|
|
1914
1995
|
throw new Error(`platform:mail: no Cloudflare zone for ${platform.apex} to put ${hostname} in`);
|
|
1915
1996
|
}
|
|
1916
1997
|
const dnsTarget = `${tunnel.id}.cfargotunnel.com`;
|
|
1917
|
-
const
|
|
1998
|
+
const dns = await platformDns(deps, context.recordedIds(), 'platform:mail');
|
|
1999
|
+
const [dnsRecord] = await dns.findDnsRecord(platform.apex, hostname, 'CNAME');
|
|
1918
2000
|
if (!dnsRecord) {
|
|
1919
|
-
await
|
|
2001
|
+
await dns.createDnsRecord(platform.apex, {
|
|
1920
2002
|
type: 'CNAME',
|
|
1921
2003
|
name: hostname,
|
|
1922
2004
|
content: dnsTarget,
|
|
@@ -1924,7 +2006,7 @@ async function provisionPlatformMail(context, deps) {
|
|
|
1924
2006
|
});
|
|
1925
2007
|
}
|
|
1926
2008
|
else if (dnsRecord.content !== dnsTarget) {
|
|
1927
|
-
await
|
|
2009
|
+
await dns.updateDnsRecord(platform.apex, dnsRecord.id, {
|
|
1928
2010
|
type: 'CNAME',
|
|
1929
2011
|
name: hostname,
|
|
1930
2012
|
content: dnsTarget,
|
|
@@ -1933,9 +2015,9 @@ async function provisionPlatformMail(context, deps) {
|
|
|
1933
2015
|
}
|
|
1934
2016
|
// ---- the MX hostname: a direct, unproxied A record --------------------------
|
|
1935
2017
|
const serverIp = (platform.serverIpv4 ?? server.ip); // checked by mailServerIpBlocked above
|
|
1936
|
-
const [mxRecord] = await
|
|
2018
|
+
const [mxRecord] = await dns.findDnsRecord(platform.apex, mailHost, 'A');
|
|
1937
2019
|
if (!mxRecord) {
|
|
1938
|
-
await
|
|
2020
|
+
await dns.createDnsRecord(platform.apex, {
|
|
1939
2021
|
type: 'A',
|
|
1940
2022
|
name: mailHost,
|
|
1941
2023
|
content: serverIp,
|
|
@@ -1943,7 +2025,7 @@ async function provisionPlatformMail(context, deps) {
|
|
|
1943
2025
|
});
|
|
1944
2026
|
}
|
|
1945
2027
|
else if (mxRecord.content !== serverIp) {
|
|
1946
|
-
await
|
|
2028
|
+
await dns.updateDnsRecord(platform.apex, mxRecord.id, {
|
|
1947
2029
|
type: 'A',
|
|
1948
2030
|
name: mailHost,
|
|
1949
2031
|
content: serverIp,
|
|
@@ -2478,10 +2560,14 @@ export const PLATFORM_ARCHON_CLIENT_ID_SCOPE_KEY = 'zitadel_platform_archon_clie
|
|
|
2478
2560
|
* by hand outside foundry. {@link archonRollingUpdateDrifted} re-reads
|
|
2479
2561
|
* these settings (and the #475 ones) at plan time, so a human turning any
|
|
2480
2562
|
* of them back off in the Coolify UI is noticed and repaired, not just a
|
|
2481
|
-
* platform that applied before this patch existed.
|
|
2482
|
-
*
|
|
2483
|
-
*
|
|
2484
|
-
*
|
|
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.
|
|
2485
2571
|
*/
|
|
2486
2572
|
async function provisionPlatformArchon(context, deps) {
|
|
2487
2573
|
const { platform } = context;
|
|
@@ -2608,10 +2694,10 @@ async function provisionPlatformArchon(context, deps) {
|
|
|
2608
2694
|
await deps.coolify.updateApplication(appId, {
|
|
2609
2695
|
custom_network_aliases: PLATFORM_ARCHON_INTERNAL_NAME,
|
|
2610
2696
|
is_consistent_container_name_enabled: false,
|
|
2611
|
-
|
|
2612
|
-
|
|
2613
|
-
|
|
2614
|
-
|
|
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),
|
|
2615
2701
|
git_branch: gitRef,
|
|
2616
2702
|
});
|
|
2617
2703
|
// ---- sign-in client in the platform's own ZITADEL -------------------------
|
|
@@ -2878,17 +2964,19 @@ async function provisionPlatformArchon(context, deps) {
|
|
|
2878
2964
|
await record(PLATFORM_AUTH_BROKER_DEPLOYED_SCOPE_KEY, brokerDeployRecordValue(brokerAppId, desiredAuthAppsRaw));
|
|
2879
2965
|
}
|
|
2880
2966
|
// ---- Cloudflare Access, before the hostname resolves ----------------------
|
|
2967
|
+
const tunnels = await platformTunnels(deps, context.recordedIds(), 'platform:archon');
|
|
2968
|
+
const access = await platformAccess(deps, context.recordedIds(), 'platform:archon');
|
|
2881
2969
|
let gateClosed = Boolean(recorded[PLATFORM_ARCHON_GATE_CLOSED_SCOPE_KEY]);
|
|
2882
|
-
const accessAppId = await ensureArchonAccess(
|
|
2970
|
+
const accessAppId = await ensureArchonAccess(access, hostname, deps.accessEmails, async () => {
|
|
2883
2971
|
// The Access app is about to be rebuilt: answer 404 for every hostname
|
|
2884
2972
|
// meanwhile, so it is never served without the gate. The marker is
|
|
2885
2973
|
// persisted first: a failure before the real ingress is written back
|
|
2886
2974
|
// leaves it set, and the next plan repairs the step.
|
|
2887
2975
|
gateClosed = true;
|
|
2888
2976
|
await record(PLATFORM_ARCHON_GATE_CLOSED_SCOPE_KEY, '1');
|
|
2889
|
-
const tunnel = await
|
|
2977
|
+
const tunnel = await tunnels.findTunnelByName(PLATFORM_ARCHON_TUNNEL_NAME);
|
|
2890
2978
|
if (tunnel)
|
|
2891
|
-
await
|
|
2979
|
+
await tunnels.putTunnelConfig(tunnel.id, {
|
|
2892
2980
|
ingress: [{ service: 'http_status:404' }],
|
|
2893
2981
|
});
|
|
2894
2982
|
});
|
|
@@ -2940,12 +3028,12 @@ async function provisionPlatformArchon(context, deps) {
|
|
|
2940
3028
|
await waitForDeploymentFinished(deps.coolify, appId, runtime, 'platform:archon', `archon stays at ${appliedGitRef ?? 'no recorded ref'} on ${recorded[PLATFORM_ARCHON_APPLIED_HOSTNAME_SCOPE_KEY] ?? 'no recorded hostname'}`);
|
|
2941
3029
|
}
|
|
2942
3030
|
// ---- tunnel and DNS ---------------------------------------------------------
|
|
2943
|
-
const existingTunnel = await
|
|
2944
|
-
const tunnel = existingTunnel ?? (await
|
|
3031
|
+
const existingTunnel = await tunnels.findTunnelByName(PLATFORM_ARCHON_TUNNEL_NAME);
|
|
3032
|
+
const tunnel = existingTunnel ?? (await tunnels.createTunnel(PLATFORM_ARCHON_TUNNEL_NAME));
|
|
2945
3033
|
if (recorded[PLATFORM_ARCHON_TUNNEL_SCOPE_KEY] !== tunnel.id) {
|
|
2946
3034
|
await record(PLATFORM_ARCHON_TUNNEL_SCOPE_KEY, tunnel.id);
|
|
2947
3035
|
}
|
|
2948
|
-
await
|
|
3036
|
+
await tunnels.putTunnelConfig(tunnel.id, {
|
|
2949
3037
|
ingress: [
|
|
2950
3038
|
{
|
|
2951
3039
|
hostname,
|
|
@@ -2960,7 +3048,7 @@ async function provisionPlatformArchon(context, deps) {
|
|
|
2960
3048
|
// #197: without a connector, `hostname` answers with a Cloudflare error.
|
|
2961
3049
|
await ensureCloudflaredConnector({
|
|
2962
3050
|
coolify: deps.coolify,
|
|
2963
|
-
|
|
3051
|
+
tunnels,
|
|
2964
3052
|
tunnelId: tunnel.id,
|
|
2965
3053
|
appName: PLATFORM_ARCHON_CLOUDFLARED_APP_NAME,
|
|
2966
3054
|
projectUuid,
|
|
@@ -2974,9 +3062,10 @@ async function provisionPlatformArchon(context, deps) {
|
|
|
2974
3062
|
throw new Error(`platform:archon: no Cloudflare zone for ${platform.apex} to put ${hostname} in`);
|
|
2975
3063
|
}
|
|
2976
3064
|
const dnsTarget = `${tunnel.id}.cfargotunnel.com`;
|
|
2977
|
-
const
|
|
3065
|
+
const dns = await platformDns(deps, context.recordedIds(), 'platform:archon');
|
|
3066
|
+
const [dnsRecord] = await dns.findDnsRecord(platform.apex, hostname, 'CNAME');
|
|
2978
3067
|
if (!dnsRecord) {
|
|
2979
|
-
await
|
|
3068
|
+
await dns.createDnsRecord(platform.apex, {
|
|
2980
3069
|
type: 'CNAME',
|
|
2981
3070
|
name: hostname,
|
|
2982
3071
|
content: dnsTarget,
|
|
@@ -2984,7 +3073,7 @@ async function provisionPlatformArchon(context, deps) {
|
|
|
2984
3073
|
});
|
|
2985
3074
|
}
|
|
2986
3075
|
else if (dnsRecord.content !== dnsTarget) {
|
|
2987
|
-
await
|
|
3076
|
+
await dns.updateDnsRecord(platform.apex, dnsRecord.id, {
|
|
2988
3077
|
type: 'CNAME',
|
|
2989
3078
|
name: hostname,
|
|
2990
3079
|
content: dnsTarget,
|
|
@@ -3365,8 +3454,9 @@ function sameEmails(include, emails) {
|
|
|
3365
3454
|
* exactly the declared emails. A failed read is not drift: the next plan reads
|
|
3366
3455
|
* again.
|
|
3367
3456
|
*/
|
|
3368
|
-
async function archonAccessDrifted(
|
|
3457
|
+
async function archonAccessDrifted(deps, recorded, domain, emails) {
|
|
3369
3458
|
try {
|
|
3459
|
+
const cf = await platformAccess(deps, recorded, 'platform:archon');
|
|
3370
3460
|
const app = (await cf.listAccessApps()).find((a) => a.domain === domain && a.name === PLATFORM_ARCHON_ACCESS_APP_NAME);
|
|
3371
3461
|
if (!app)
|
|
3372
3462
|
return true;
|
|
@@ -4079,8 +4169,13 @@ async function provisionPlatformAi(context, deps) {
|
|
|
4079
4169
|
// Every run, so a changed gitRef reaches an app that already exists.
|
|
4080
4170
|
git_branch: gitRef,
|
|
4081
4171
|
};
|
|
4082
|
-
|
|
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) {
|
|
4083
4176
|
patch.ports_exposes = PLATFORM_AI_PORT;
|
|
4177
|
+
Object.assign(patch, healthCheckSettings('/api/health', Number(PLATFORM_AI_PORT)));
|
|
4178
|
+
}
|
|
4084
4179
|
await coolify.updateApplication(app.appId, patch);
|
|
4085
4180
|
return app;
|
|
4086
4181
|
};
|
|
@@ -4165,18 +4260,23 @@ async function provisionPlatformAi(context, deps) {
|
|
|
4165
4260
|
}
|
|
4166
4261
|
await coolify.raw('POST', `/applications/${migrate.appId}/stop`);
|
|
4167
4262
|
}
|
|
4168
|
-
// #252: confirmed before anything below -- the applied ref recorded
|
|
4169
|
-
// after, and the mint further down -- depends on the new version
|
|
4170
|
-
// being live. Never read the old container's healthy status as
|
|
4171
|
-
// deployment's (`CLAUDE.md`'s platform-step rule 5)
|
|
4172
|
-
//
|
|
4173
|
-
//
|
|
4174
|
-
//
|
|
4175
|
-
//
|
|
4176
|
-
//
|
|
4177
|
-
//
|
|
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}`;
|
|
4178
4278
|
if (deployRunner) {
|
|
4179
|
-
await waitForNewDeployment(coolify, runner.appId, { kind: '
|
|
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'}`);
|
|
4180
4280
|
}
|
|
4181
4281
|
if (deployWorker) {
|
|
4182
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'}`);
|
|
@@ -4189,7 +4289,6 @@ async function provisionPlatformAi(context, deps) {
|
|
|
4189
4289
|
// the key is delivered.
|
|
4190
4290
|
await record(PLATFORM_AI_APPLIED_HOSTNAME_SCOPE_KEY, hostname);
|
|
4191
4291
|
await record(PLATFORM_AI_APPLIED_GITREF_SCOPE_KEY, gitRef);
|
|
4192
|
-
const internalUrl = `http://${PLATFORM_AI_RUNNER.internalName}:${PLATFORM_AI_PORT}`;
|
|
4193
4292
|
await record(PLATFORM_AI_INTERNAL_URL_SCOPE_KEY, internalUrl);
|
|
4194
4293
|
// ---- Archon's AI management key, once ------------------------------------
|
|
4195
4294
|
// #252: nothing left to wait on here -- the deployment above is already
|
|
@@ -4773,6 +4872,8 @@ const FILES_RUNNER_APP_NAME = 'files';
|
|
|
4773
4872
|
const FILES_RUNNER_INTERNAL_NAME = 'platform-files';
|
|
4774
4873
|
/** The runner's port (`Dockerfile`'s `runner` stage: `PORT=3000`, `EXPOSE 3000`). */
|
|
4775
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';
|
|
4776
4877
|
/** The Postgres database name; files' runtime role is `<database>_rt`. */
|
|
4777
4878
|
const FILES_DB = 'files';
|
|
4778
4879
|
const FILES_RUNTIME_ROLE = `${FILES_DB}_rt`;
|
|
@@ -4965,6 +5066,12 @@ shareDomains) {
|
|
|
4965
5066
|
dockerfile_target_build: 'runner',
|
|
4966
5067
|
custom_internal_name: FILES_RUNNER_INTERNAL_NAME,
|
|
4967
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)),
|
|
4968
5075
|
// Coolify wants full URLs. Written only when the set changed, so a runner
|
|
4969
5076
|
// nobody gave a share host keeps no `domains`.
|
|
4970
5077
|
...filesShareDomainsPatch(recorded, shareDomains),
|
|
@@ -5109,15 +5216,18 @@ shareDomains) {
|
|
|
5109
5216
|
// mark a deployment that actually migrated as `failed`.
|
|
5110
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);
|
|
5111
5218
|
await deps.coolify.raw('POST', `/applications/${migrateApp.appId}/stop`);
|
|
5112
|
-
// #252: `
|
|
5113
|
-
//
|
|
5114
|
-
//
|
|
5115
|
-
//
|
|
5116
|
-
//
|
|
5117
|
-
//
|
|
5118
|
-
//
|
|
5119
|
-
|
|
5120
|
-
|
|
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);
|
|
5121
5231
|
// Last: a run that failed anywhere above is planned again.
|
|
5122
5232
|
context.recordScopeId(PLATFORM_FILES_APPLIED_HOSTNAME_SCOPE_KEY, platform.files.hostname);
|
|
5123
5233
|
await record(PLATFORM_FILES_APPLIED_GIT_REF_SCOPE_KEY, gitRef);
|
|
@@ -5322,10 +5432,13 @@ async function provisionPlatformEdge(context, deps) {
|
|
|
5322
5432
|
// Set on every run, not only after a create: a throw right after the
|
|
5323
5433
|
// create must not leave the app on the wrong target for good, and a
|
|
5324
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.
|
|
5325
5437
|
await deps.coolify.updateApplication(appId, {
|
|
5326
5438
|
dockerfile_target_build: 'runner',
|
|
5327
5439
|
custom_internal_name: EDGE_RUNNER_INTERNAL_NAME,
|
|
5328
5440
|
git_branch: gitRef,
|
|
5441
|
+
...healthCheckSettings('/api/health', Number(EDGE_PORT)),
|
|
5329
5442
|
});
|
|
5330
5443
|
const runtimeUrl = await ensureEdgeRuntimeUrl(deps.coolify, appId, db.internal_db_url);
|
|
5331
5444
|
// ---- the platform key, once ----------------------------------------------
|
|
@@ -5359,11 +5472,22 @@ async function provisionPlatformEdge(context, deps) {
|
|
|
5359
5472
|
{ key: 'EDGE_PLATFORM_KEY_HASH', value: keyHash },
|
|
5360
5473
|
...(await missingEnvs(deps.coolify, appId, PLATFORM_EDGE_RUNNER_DEFAULTS)),
|
|
5361
5474
|
]);
|
|
5362
|
-
// ----
|
|
5363
|
-
|
|
5364
|
-
|
|
5475
|
+
// ---- deploy and wait, on edge's own address ---------------------------------
|
|
5476
|
+
// F4.dns: edge is the only caller of Cloudflare, so its own CNAME is
|
|
5477
|
+
// written through edge. Edge has to be up before that, and no public name
|
|
5478
|
+
// routes to it yet: health is checked on its Coolify-network address, which
|
|
5479
|
+
// is then recorded for every step that writes DNS (auth, mail, archon). #252:
|
|
5480
|
+
// the combined helper checks the deployment record, then the `/api/health`
|
|
5481
|
+
// probe.
|
|
5482
|
+
const internalUrl = `http://${EDGE_RUNNER_INTERNAL_NAME}:${EDGE_PORT}`;
|
|
5483
|
+
await waitForNewDeployment(deps.coolify, appId, { kind: 'ready', url: `${internalUrl}/api/health` }, runtime, sleep, 'platform:edge', `edge stays at ${recorded[PLATFORM_EDGE_APPLIED_GIT_REF_SCOPE_KEY] ?? 'no recorded ref'}`);
|
|
5484
|
+
await record(PLATFORM_EDGE_INTERNAL_URL_SCOPE_KEY, internalUrl);
|
|
5485
|
+
// ---- tunnel and DNS, now that edge answers ---------------------------------
|
|
5486
|
+
const tunnels = await platformTunnels(deps, { [PLATFORM_EDGE_INTERNAL_URL_SCOPE_KEY]: internalUrl }, 'platform:edge');
|
|
5487
|
+
const existingTunnel = await tunnels.findTunnelByName(EDGE_TUNNEL_NAME);
|
|
5488
|
+
const tunnel = existingTunnel ?? (await tunnels.createTunnel(EDGE_TUNNEL_NAME));
|
|
5365
5489
|
await record(PLATFORM_EDGE_TUNNEL_SCOPE_KEY, tunnel.id);
|
|
5366
|
-
await
|
|
5490
|
+
await tunnels.putTunnelConfig(tunnel.id, {
|
|
5367
5491
|
ingress: [
|
|
5368
5492
|
{ hostname, service: `http://${EDGE_RUNNER_INTERNAL_NAME}:${EDGE_PORT}` },
|
|
5369
5493
|
{ service: 'http_status:404' },
|
|
@@ -5372,7 +5496,7 @@ async function provisionPlatformEdge(context, deps) {
|
|
|
5372
5496
|
// #197: without a connector, `hostname` answers with a Cloudflare error.
|
|
5373
5497
|
await ensureCloudflaredConnector({
|
|
5374
5498
|
coolify: deps.coolify,
|
|
5375
|
-
|
|
5499
|
+
tunnels,
|
|
5376
5500
|
tunnelId: tunnel.id,
|
|
5377
5501
|
appName: EDGE_CLOUDFLARED_APP_NAME,
|
|
5378
5502
|
projectUuid,
|
|
@@ -5386,9 +5510,10 @@ async function provisionPlatformEdge(context, deps) {
|
|
|
5386
5510
|
throw new Error(`platform:edge: no Cloudflare zone for ${platform.apex} to put ${hostname} in`);
|
|
5387
5511
|
}
|
|
5388
5512
|
const dnsTarget = `${tunnel.id}.cfargotunnel.com`;
|
|
5389
|
-
const
|
|
5513
|
+
const dns = await deps.dns(internalUrl);
|
|
5514
|
+
const [dnsRecord] = await dns.findDnsRecord(platform.apex, hostname, 'CNAME');
|
|
5390
5515
|
if (!dnsRecord) {
|
|
5391
|
-
await
|
|
5516
|
+
await dns.createDnsRecord(platform.apex, {
|
|
5392
5517
|
type: 'CNAME',
|
|
5393
5518
|
name: hostname,
|
|
5394
5519
|
content: dnsTarget,
|
|
@@ -5396,24 +5521,236 @@ async function provisionPlatformEdge(context, deps) {
|
|
|
5396
5521
|
});
|
|
5397
5522
|
}
|
|
5398
5523
|
else if (dnsRecord.content !== dnsTarget) {
|
|
5399
|
-
await
|
|
5524
|
+
await dns.updateDnsRecord(platform.apex, dnsRecord.id, {
|
|
5400
5525
|
type: 'CNAME',
|
|
5401
5526
|
name: hostname,
|
|
5402
5527
|
content: dnsTarget,
|
|
5403
5528
|
proxied: true,
|
|
5404
5529
|
});
|
|
5405
5530
|
}
|
|
5406
|
-
// ---- deploy and wait -------------------------------------------------------
|
|
5407
|
-
// #252: the tunnel and DNS above already route `hostname`, so the combined
|
|
5408
|
-
// helper (deployment-record check, then the `/api/health` probe) can run
|
|
5409
|
-
// in one call -- unlike `platform:mail`, nothing here needs to happen
|
|
5410
|
-
// between the two phases.
|
|
5411
|
-
await waitForNewDeployment(deps.coolify, appId, { kind: 'ready', url: `https://${hostname}/api/health` }, runtime, sleep, 'platform:edge', `edge stays at ${recorded[PLATFORM_EDGE_APPLIED_GIT_REF_SCOPE_KEY] ?? 'no recorded ref'}`);
|
|
5412
5531
|
// Last: a run that failed anywhere above is planned again.
|
|
5413
5532
|
context.recordScopeId(PLATFORM_EDGE_APPLIED_HOSTNAME_SCOPE_KEY, hostname);
|
|
5414
5533
|
await record(PLATFORM_EDGE_APPLIED_GIT_REF_SCOPE_KEY, gitRef);
|
|
5415
5534
|
return { resourceId: appId };
|
|
5416
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
|
+
}
|
|
5417
5754
|
/**
|
|
5418
5755
|
* The runtime `DATABASE_URL` the names app gets: the value already set on
|
|
5419
5756
|
* it, else a new `names_rt` credential on the database's host, the
|
|
@@ -6186,6 +6523,41 @@ const PLATFORM_BACKUP_S3_STORAGE_FINGERPRINT_SCOPE_KEY = 'coolify_platform_backu
|
|
|
6186
6523
|
const PLATFORM_BACKUP_S3_STORAGE_PENDING_SCOPE_KEY = 'coolify_platform_backup_s3_storage_pending';
|
|
6187
6524
|
/** Coolify's own name for "once a day" -- `createDatabaseBackup`'s `frequency`. */
|
|
6188
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
|
+
}
|
|
6189
6561
|
/**
|
|
6190
6562
|
* Trims a trailing slash and lowercases the host, so a Coolify-normalised
|
|
6191
6563
|
* endpoint (or one a person typed with a trailing slash) is never read as a
|
|
@@ -6547,17 +6919,109 @@ async function provisionPlatformBackup(action, context, deps) {
|
|
|
6547
6919
|
const databaseId = action.desired.databaseId;
|
|
6548
6920
|
const recorded = context.recordedIds();
|
|
6549
6921
|
const storageUuid = await ensurePlatformBackupStorage(coolify, r2, recorded, context);
|
|
6550
|
-
const
|
|
6551
|
-
|
|
6552
|
-
|
|
6922
|
+
const service = action.key;
|
|
6923
|
+
const { schedule, named } = await findPlatformBackupSchedule(coolify, service, databaseId);
|
|
6924
|
+
const scheduleUuid = schedule?.uuid ??
|
|
6925
|
+
(await coolify.createDatabaseBackup(databaseId, {
|
|
6553
6926
|
frequency: deps.frequency ?? PLATFORM_BACKUP_DEFAULT_FREQUENCY,
|
|
6554
6927
|
enabled: true,
|
|
6555
6928
|
save_s3: true,
|
|
6556
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],
|
|
6557
6937
|
});
|
|
6558
6938
|
}
|
|
6559
6939
|
return undefined;
|
|
6560
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
|
+
}
|
|
6561
7025
|
/**
|
|
6562
7026
|
* The real {@link PlatformCloudflareReader}: the platform's apex zone as
|
|
6563
7027
|
* `planApexZone` expects to see it, and nothing else. The zone's DNS records
|
|
@@ -6659,8 +7123,10 @@ export class ProvidersPlatformCoolifyReader {
|
|
|
6659
7123
|
if (!databaseId)
|
|
6660
7124
|
continue;
|
|
6661
7125
|
try {
|
|
6662
|
-
|
|
6663
|
-
|
|
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;
|
|
6664
7130
|
if (scheduled) {
|
|
6665
7131
|
resources.push({
|
|
6666
7132
|
system: 'coolify',
|