@wtfalch/foundry 0.17.0 → 0.18.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.js +6 -4
- package/dist/index.d.ts +2 -2
- package/dist/index.js +1 -1
- package/dist/migrations/0043_platform_names.sql +25 -0
- package/dist/migrations/0044_platform_mail_lettermint_project.sql +11 -0
- package/dist/migrations/0045_names_to_edge.sql +107 -0
- package/dist/org.d.ts +74 -3
- package/dist/org.js +133 -0
- package/dist/plan/chat-matrix.d.ts +0 -1
- package/dist/plan/chat-matrix.js +4 -1
- package/dist/plan/diff.d.ts +18 -0
- package/dist/plan/diff.js +239 -26
- package/dist/plan/entry-edge-key.d.ts +79 -0
- package/dist/plan/entry-edge-key.js +260 -0
- package/dist/plan/executor.d.ts +246 -1
- package/dist/plan/executor.js +684 -32
- package/dist/plan/index.d.ts +2 -2
- package/dist/plan/index.js +1 -1
- package/dist/plan/live.d.ts +4 -2
- package/dist/plan/login-host.d.ts +12 -1
- package/dist/plan/login-host.js +20 -2
- package/dist/plan/plan-apply.d.ts +15 -0
- package/dist/plan/plan-apply.js +102 -9
- package/dist/plan/platform-diff.d.ts +92 -1
- package/dist/plan/platform-diff.js +166 -1
- package/dist/plan/platform-org.d.ts +29 -1
- package/dist/plan/platform-org.js +17 -2
- package/dist/plan/platform-providers.d.ts +45 -1
- package/dist/plan/platform-providers.js +514 -7
- package/dist/plan/step-system.js +1 -0
- package/dist/plan/worker.d.ts +134 -0
- package/dist/plan/worker.js +239 -0
- package/dist/platform.d.ts +22 -3
- package/dist/platform.js +6 -0
- package/dist/store/postgres.js +15 -1
- package/dist/store/schema.d.ts +70 -2
- package/dist/store/schema.js +6 -0
- package/package.json +1 -1
|
@@ -7,7 +7,7 @@ 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
9
|
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_CONNECTOR_APP_SCOPE_KEY, PLATFORM_ARCHON_COOLIFY_READ_TOKEN_REF, 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_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';
|
|
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
11
|
import { applyCallsSfuPlatformStep } from './platform-sfu.js';
|
|
12
12
|
import { CreateInstanceError, createZitadelInstance, generateSystemApiKeypair, instanceWithoutBearerMessage, systemApiUsersEnv, } from './zitadel-system-api.js';
|
|
13
13
|
/** The key `runPlatformSteps` records the apex zone's id under (`system:kind:key`). */
|
|
@@ -94,6 +94,9 @@ const JOBS_SCHEDULER_REPO = 'wtfalch/jobs';
|
|
|
94
94
|
* #172 adds `coolify:app` keyed {@link PLATFORM_AI_KEY}, the platform's AI
|
|
95
95
|
* host ({@link provisionPlatformAi}), once a host passes {@link PlatformAiDeps}.
|
|
96
96
|
*
|
|
97
|
+
* #478 adds `coolify:app` keyed {@link PLATFORM_EDGE_KEY}, the edge host
|
|
98
|
+
* ({@link provisionPlatformEdge}), once a host passes {@link PlatformEdgeDeps}.
|
|
99
|
+
*
|
|
97
100
|
* #175 adds `coolify:app` keyed {@link PLATFORM_ARCHON_KEY}, Archon itself
|
|
98
101
|
* ({@link provisionPlatformArchon}), once a host passes
|
|
99
102
|
* {@link PlatformArchonDeps}.
|
|
@@ -113,6 +116,7 @@ export class FoundryPlatformStepExecutor {
|
|
|
113
116
|
integrations;
|
|
114
117
|
files;
|
|
115
118
|
ai;
|
|
119
|
+
edge;
|
|
116
120
|
archon;
|
|
117
121
|
backup;
|
|
118
122
|
constructor(cloudflare, declaredOrgStore, options = {}) {
|
|
@@ -126,6 +130,7 @@ export class FoundryPlatformStepExecutor {
|
|
|
126
130
|
this.integrations = options.integrations;
|
|
127
131
|
this.files = options.files;
|
|
128
132
|
this.ai = options.ai;
|
|
133
|
+
this.edge = options.edge;
|
|
129
134
|
this.archon = options.archon;
|
|
130
135
|
this.backup = options.backup;
|
|
131
136
|
}
|
|
@@ -212,6 +217,15 @@ export class FoundryPlatformStepExecutor {
|
|
|
212
217
|
blocked.ai =
|
|
213
218
|
"the platform's AI host is blocked, not planned: its signing key is already pinned but the host built the platform executor without a held-key reader to read back its private half (PlatformAiDeps.heldKeys)";
|
|
214
219
|
}
|
|
220
|
+
if (platform.edge.enabled && !this.edge) {
|
|
221
|
+
blocked.edge =
|
|
222
|
+
'the edge host is blocked, not planned: the host built the platform executor without its edge dependencies';
|
|
223
|
+
}
|
|
224
|
+
else if (platform.edge.enabled && this.edge) {
|
|
225
|
+
const edgeBlocked = await edgeBlockedReason(this.edge);
|
|
226
|
+
if (edgeBlocked)
|
|
227
|
+
blocked.edge = edgeBlocked;
|
|
228
|
+
}
|
|
215
229
|
if (platform.archon.enabled) {
|
|
216
230
|
if (!this.archon) {
|
|
217
231
|
blocked.archon =
|
|
@@ -262,7 +276,17 @@ export class FoundryPlatformStepExecutor {
|
|
|
262
276
|
const bootstrapOperatorDrifted = !accessDrifted &&
|
|
263
277
|
!brokerDrifted &&
|
|
264
278
|
(await archonBootstrapOperatorDrifted(this.archon.coolify, recordedIds[`coolify:app:${PLATFORM_ARCHON_KEY}`], recordedIds));
|
|
265
|
-
|
|
279
|
+
// #480: re-reads the live app's rolling-update/health-check settings
|
|
280
|
+
// even once `PLATFORM_ARCHON_APPLIED_ROLLING_UPDATE_SCOPE_KEY` is
|
|
281
|
+
// recorded -- that marker only ever proves one past apply patched them,
|
|
282
|
+
// and nothing else notices a later hand-edit in the Coolify UI turning
|
|
283
|
+
// `is_consistent_container_name_enabled` back on or the health check
|
|
284
|
+
// back off.
|
|
285
|
+
const rollingUpdateDrifted = !accessDrifted &&
|
|
286
|
+
!brokerDrifted &&
|
|
287
|
+
!bootstrapOperatorDrifted &&
|
|
288
|
+
(await archonRollingUpdateDrifted(this.archon.coolify, recordedIds[`coolify:app:${PLATFORM_ARCHON_KEY}`]));
|
|
289
|
+
if (accessDrifted || brokerDrifted || bootstrapOperatorDrifted || rollingUpdateDrifted)
|
|
266
290
|
result.archon = true;
|
|
267
291
|
}
|
|
268
292
|
if (platform.auth.enabled && this.auth) {
|
|
@@ -351,6 +375,12 @@ export class FoundryPlatformStepExecutor {
|
|
|
351
375
|
}
|
|
352
376
|
return provisionPlatformAi(context, this.ai);
|
|
353
377
|
}
|
|
378
|
+
if (action.system === 'coolify' && action.kind === 'app' && action.key === PLATFORM_EDGE_KEY) {
|
|
379
|
+
if (!this.edge) {
|
|
380
|
+
throw new Error('platform:edge needs FoundryPlatformStepExecutor to be constructed with its edge dependencies (#478)');
|
|
381
|
+
}
|
|
382
|
+
return provisionPlatformEdge(context, this.edge);
|
|
383
|
+
}
|
|
354
384
|
if (action.system === 'coolify' &&
|
|
355
385
|
action.kind === 'app' &&
|
|
356
386
|
action.key === PLATFORM_ARCHON_KEY) {
|
|
@@ -2314,6 +2344,24 @@ const PLATFORM_ARCHON_CLOUDFLARED_APP_NAME = 'platform-archon-cloudflared';
|
|
|
2314
2344
|
/** https://github.com/wtfalch/archon: one Dockerfile at its root, `runner` stage on port 3000. */
|
|
2315
2345
|
const ARCHON_REPO = 'wtfalch/archon';
|
|
2316
2346
|
const ARCHON_PORT = '3000';
|
|
2347
|
+
/**
|
|
2348
|
+
* #480: Archon's own health route (`@wtfalch/reporting`'s `healthHandler`,
|
|
2349
|
+
* the same one `waitForHealthyVersion` below already polls over the public
|
|
2350
|
+
* hostname once the tunnel exists).
|
|
2351
|
+
*/
|
|
2352
|
+
const ARCHON_HEALTH_CHECK_PATH = '/api/health';
|
|
2353
|
+
/**
|
|
2354
|
+
* #480: Coolify stops the old container as soon as its own health check
|
|
2355
|
+
* passes against the new one. Archon's container runs `migrate.mjs` (over a
|
|
2356
|
+
* hundred migrations, append-only and fast individually) and then boots
|
|
2357
|
+
* Next.js before it answers anything, so a check run from the moment the
|
|
2358
|
+
* container starts would fail and fail again until Coolify gave up. A start
|
|
2359
|
+
* period this long means Coolify does not count a failing check against the
|
|
2360
|
+
* new container until this many seconds after it starts -- long enough for
|
|
2361
|
+
* the slowest migration run plus boot seen so far, with margin; there is no
|
|
2362
|
+
* live measurement to pin it to instead.
|
|
2363
|
+
*/
|
|
2364
|
+
const ARCHON_HEALTH_CHECK_START_PERIOD_SECONDS = 30;
|
|
2317
2365
|
/**
|
|
2318
2366
|
* The name of foundry's own Cloudflare Access app in front of Archon. Same
|
|
2319
2367
|
* `foundry:<key>` convention `executor.ts`'s `cloudflareAccess` uses for an
|
|
@@ -2413,6 +2461,24 @@ export const PLATFORM_ARCHON_CLIENT_ID_SCOPE_KEY = 'zitadel_platform_archon_clie
|
|
|
2413
2461
|
* the tunnel hostname, deletes the Access app and builds it again.
|
|
2414
2462
|
* - #197: a `cloudflared` connector runs for this tunnel, as in `provisionPlatformAuth`.
|
|
2415
2463
|
* - Retiring `operator.wtfalch.dev` stays a human step.
|
|
2464
|
+
* - #475: deploys this app with a stable `custom_network_aliases` instead of a
|
|
2465
|
+
* fixed `custom_internal_name`, so Coolify runs its rolling update (start
|
|
2466
|
+
* the new container, wait for health, then stop the old one) instead of
|
|
2467
|
+
* stopping the old container first -- the cause of the #475 incident.
|
|
2468
|
+
* - #480: also sends `health_check_enabled: true`,
|
|
2469
|
+
* `health_check_path: '/api/health'` and `health_check_port: 3000` on
|
|
2470
|
+
* every run, plus a start period long enough for Archon's migrations and
|
|
2471
|
+
* boot ({@link ARCHON_HEALTH_CHECK_START_PERIOD_SECONDS}). Without a
|
|
2472
|
+
* health check Coolify's rolling update has nothing to wait on and stops
|
|
2473
|
+
* the old container immediately -- the #475 fix alone was not enough, and
|
|
2474
|
+
* the live app only answered a health check because someone had set this
|
|
2475
|
+
* by hand outside foundry. {@link archonRollingUpdateDrifted} re-reads
|
|
2476
|
+
* these settings (and the #475 ones) at plan time, so a human turning any
|
|
2477
|
+
* of them back off in the Coolify UI is noticed and repaired, not just a
|
|
2478
|
+
* platform that applied before this patch existed. The same fix for
|
|
2479
|
+
* `platform:auth`, `platform:files`, `platform:ai` and the calls stack's
|
|
2480
|
+
* stateless app is deliberately left to a follow-up once each image's
|
|
2481
|
+
* health probe is confirmed.
|
|
2416
2482
|
*/
|
|
2417
2483
|
async function provisionPlatformArchon(context, deps) {
|
|
2418
2484
|
const { platform } = context;
|
|
@@ -2519,8 +2585,30 @@ async function provisionPlatformArchon(context, deps) {
|
|
|
2519
2585
|
// right after the create must not leave it unnamed for good.
|
|
2520
2586
|
// `git_branch` is patched too, so a changed `gitRef` reaches the app; the
|
|
2521
2587
|
// deploy below is decided from the last applied `gitRef`.
|
|
2588
|
+
//
|
|
2589
|
+
// #475: `custom_network_aliases` (a stable Docker-network DNS name) plus
|
|
2590
|
+
// `is_consistent_container_name_enabled: false`, not the old
|
|
2591
|
+
// `custom_internal_name` alone -- a fixed *container* name is what disables
|
|
2592
|
+
// Coolify's rolling update (the new container cannot take a name the old
|
|
2593
|
+
// one still holds), while a fixed *alias* lets Coolify name each container
|
|
2594
|
+
// uniquely and still answer `http://platform-archon:3000` (the tunnel
|
|
2595
|
+
// ingress below) throughout the swap. Same string as before, so nothing
|
|
2596
|
+
// downstream changes.
|
|
2597
|
+
//
|
|
2598
|
+
// #480: the health check fields too, every run -- the rolling update above
|
|
2599
|
+
// has nothing to wait on without one, so Coolify stops the old container
|
|
2600
|
+
// immediately regardless of the alias/consistent-name patch. This was
|
|
2601
|
+
// live on platform-archon already, set by hand outside foundry; sending it
|
|
2602
|
+
// here means a freshly provisioned platform gets it too, and a human
|
|
2603
|
+
// turning it back off is repaired the same way `is_consistent_container_name_enabled`
|
|
2604
|
+
// is (see `archonRollingUpdateDrifted`).
|
|
2522
2605
|
await deps.coolify.updateApplication(appId, {
|
|
2523
|
-
|
|
2606
|
+
custom_network_aliases: PLATFORM_ARCHON_INTERNAL_NAME,
|
|
2607
|
+
is_consistent_container_name_enabled: false,
|
|
2608
|
+
health_check_enabled: true,
|
|
2609
|
+
health_check_path: ARCHON_HEALTH_CHECK_PATH,
|
|
2610
|
+
health_check_port: Number(ARCHON_PORT),
|
|
2611
|
+
health_check_start_period: ARCHON_HEALTH_CHECK_START_PERIOD_SECONDS,
|
|
2524
2612
|
git_branch: gitRef,
|
|
2525
2613
|
});
|
|
2526
2614
|
// ---- sign-in client in the platform's own ZITADEL -------------------------
|
|
@@ -2586,7 +2674,8 @@ async function provisionPlatformArchon(context, deps) {
|
|
|
2586
2674
|
else {
|
|
2587
2675
|
envs.push({ key: 'DATABASE_URL_OWNER', value: db.internal_db_url });
|
|
2588
2676
|
}
|
|
2589
|
-
|
|
2677
|
+
const liveReportingSite = await currentEnvValue(deps.coolify, appId, 'REPORTING_SITE');
|
|
2678
|
+
if (!liveReportingSite) {
|
|
2590
2679
|
envs.push({ key: 'REPORTING_SITE', value: 'archon' });
|
|
2591
2680
|
}
|
|
2592
2681
|
const cookieSecret = await ensureGeneratedEnv(deps.coolify, appId, 'AUTH_COOKIE_SECRET', () => randomBytes(32).toString('base64'));
|
|
@@ -2595,6 +2684,25 @@ async function provisionPlatformArchon(context, deps) {
|
|
|
2595
2684
|
const runtimeUrl = await ensureArchonRuntimeUrl(deps.coolify, appId, db.internal_db_url);
|
|
2596
2685
|
if (runtimeUrl)
|
|
2597
2686
|
envs.push({ key: 'DATABASE_URL', value: runtimeUrl });
|
|
2687
|
+
// #467: `envs` above only ever carries these four keys on the run that
|
|
2688
|
+
// generates or adopts them -- a later run that keeps them as they are
|
|
2689
|
+
// pushes none of it, so `envs` itself shrinks from one run to the next
|
|
2690
|
+
// with nothing having actually changed. `hashArchonEnvs` below hashes
|
|
2691
|
+
// `envs` plus this map's values instead of `envs` alone, so the hash
|
|
2692
|
+
// reflects the env Archon actually ends up running with, not just this
|
|
2693
|
+
// run's delta.
|
|
2694
|
+
const archonEnvHashOverrides = new Map([
|
|
2695
|
+
['DATABASE_URL_OWNER', liveOwnerUrl ?? db.internal_db_url],
|
|
2696
|
+
['REPORTING_SITE', liveReportingSite ?? 'archon'],
|
|
2697
|
+
]);
|
|
2698
|
+
const effectiveCookieSecret = cookieSecret ?? (await currentEnvValue(deps.coolify, appId, 'AUTH_COOKIE_SECRET'));
|
|
2699
|
+
if (effectiveCookieSecret) {
|
|
2700
|
+
archonEnvHashOverrides.set('AUTH_COOKIE_SECRET', effectiveCookieSecret);
|
|
2701
|
+
}
|
|
2702
|
+
const effectiveRuntimeUrl = runtimeUrl ?? (await currentEnvValue(deps.coolify, appId, 'DATABASE_URL'));
|
|
2703
|
+
if (effectiveRuntimeUrl) {
|
|
2704
|
+
archonEnvHashOverrides.set('DATABASE_URL', effectiveRuntimeUrl);
|
|
2705
|
+
}
|
|
2598
2706
|
// `AUTH_APP_KEY` (#204): minted once like the cookie secret above, but read
|
|
2599
2707
|
// back (never regenerated) rather than skipped when already set, since its
|
|
2600
2708
|
// value is needed below to keep the broker's own registration in sync even
|
|
@@ -2650,6 +2758,13 @@ async function provisionPlatformArchon(context, deps) {
|
|
|
2650
2758
|
}
|
|
2651
2759
|
}
|
|
2652
2760
|
const changed = await envsDiffer(deps.coolify, appId, envs);
|
|
2761
|
+
// #467: this run's intended env set, hashed before anything below writes
|
|
2762
|
+
// it -- compared against {@link PLATFORM_ARCHON_ENVS_DEPLOYED_SCOPE_KEY}
|
|
2763
|
+
// (that constant's own doc comment explains why `changed` above, which
|
|
2764
|
+
// only ever reads the *live* store, cannot catch a crash or a failed
|
|
2765
|
+
// deploy between this run's write and its confirmation).
|
|
2766
|
+
const envsHash = hashArchonEnvs(envs, archonEnvHashOverrides);
|
|
2767
|
+
const envsHashChanged = recorded[PLATFORM_ARCHON_ENVS_DEPLOYED_SCOPE_KEY] !== envsHash;
|
|
2653
2768
|
await deps.coolify.bulkUpsertEnvs(appId, envs);
|
|
2654
2769
|
// #436 round 2: `bulkUpsertEnvs` only adds or updates, so the
|
|
2655
2770
|
// `FILES_MANAGEMENT_KEY` this step used to write before the fix above
|
|
@@ -2665,7 +2780,9 @@ async function provisionPlatformArchon(context, deps) {
|
|
|
2665
2780
|
// stale `FILES_MANAGEMENT_KEY` row still present must still redeploy --
|
|
2666
2781
|
// the running container otherwise keeps reading the stale key out of its
|
|
2667
2782
|
// own process env until someone redeploys by hand. `staleKeyDeleted`
|
|
2668
|
-
// feeds `archonDeployedNow` below for exactly that case
|
|
2783
|
+
// feeds `archonDeployedNow` below for exactly that case; `envsHashChanged`
|
|
2784
|
+
// above does not cover it, because the key's removal is never part of
|
|
2785
|
+
// what either hash names (#467).
|
|
2669
2786
|
let staleKeyDeleted = false;
|
|
2670
2787
|
for (const stale of await deps.coolify.listEnvs(appId)) {
|
|
2671
2788
|
if (stale.key === 'FILES_MANAGEMENT_KEY' && stale.uuid) {
|
|
@@ -2793,14 +2910,29 @@ async function provisionPlatformArchon(context, deps) {
|
|
|
2793
2910
|
const keyUnsynced = Boolean(integrationsKeyId) &&
|
|
2794
2911
|
Boolean(recorded[PLATFORM_INTEGRATIONS_KEY_ID_SCOPE_KEY]) &&
|
|
2795
2912
|
recorded[PLATFORM_ARCHON_INTEGRATIONS_KEY_SYNCED_SCOPE_KEY] !== integrationsKeyId;
|
|
2913
|
+
// #475: a platform applied before the rolling-update fix (or an already-live
|
|
2914
|
+
// app the Coolify UI set the consistent-name flag on) has this unset; force
|
|
2915
|
+
// one redeploy so the `custom_network_aliases`/`is_consistent_container_name_enabled`
|
|
2916
|
+
// patch above actually takes effect on the running container, not just on
|
|
2917
|
+
// Coolify's stored config.
|
|
2918
|
+
const rollingUpdateApplied = recorded[PLATFORM_ARCHON_APPLIED_ROLLING_UPDATE_SCOPE_KEY] === '1';
|
|
2796
2919
|
const archonDeployedNow = keyUnsynced ||
|
|
2797
2920
|
app.justCreated ||
|
|
2798
2921
|
!app.running ||
|
|
2799
2922
|
changed ||
|
|
2800
2923
|
staleKeyDeleted ||
|
|
2924
|
+
envsHashChanged ||
|
|
2801
2925
|
appliedGitRef !== gitRef ||
|
|
2926
|
+
!rollingUpdateApplied ||
|
|
2802
2927
|
recorded[PLATFORM_ARCHON_APPLIED_HOSTNAME_SCOPE_KEY] !== hostname;
|
|
2803
2928
|
if (archonDeployedNow) {
|
|
2929
|
+
// #467: cleared before the deploy below is even attempted, not after --
|
|
2930
|
+
// a deploy that then fails, or a crash before `waitForHealthyVersion`
|
|
2931
|
+
// passes, must leave this run's attempt unconfirmed. Recording it only
|
|
2932
|
+
// on success would not do that on its own: the env content a failed
|
|
2933
|
+
// retry intends is identical to what a successful one intends, so
|
|
2934
|
+
// leaving the old confirmation in place would read as still correct.
|
|
2935
|
+
await record(PLATFORM_ARCHON_ENVS_DEPLOYED_SCOPE_KEY, '');
|
|
2804
2936
|
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'}`);
|
|
2805
2937
|
}
|
|
2806
2938
|
// ---- tunnel and DNS ---------------------------------------------------------
|
|
@@ -2873,6 +3005,16 @@ async function provisionPlatformArchon(context, deps) {
|
|
|
2873
3005
|
if (keyUnsynced && integrationsKeyId) {
|
|
2874
3006
|
await record(PLATFORM_ARCHON_INTEGRATIONS_KEY_SYNCED_SCOPE_KEY, integrationsKeyId);
|
|
2875
3007
|
}
|
|
3008
|
+
// #475: confirmed only now, after the health check above has passed on a
|
|
3009
|
+
// container that was deployed with the alias patch in place -- the same
|
|
3010
|
+
// confirm-after-health-check timing as the two records just above.
|
|
3011
|
+
if (!rollingUpdateApplied) {
|
|
3012
|
+
await record(PLATFORM_ARCHON_APPLIED_ROLLING_UPDATE_SCOPE_KEY, '1');
|
|
3013
|
+
}
|
|
3014
|
+
// #467: confirmed only now, after the health check above has passed (or,
|
|
3015
|
+
// when no deploy was needed this run, left exactly as it already was --
|
|
3016
|
+
// `envsHashChanged` false means it already equals `envsHash`).
|
|
3017
|
+
await record(PLATFORM_ARCHON_ENVS_DEPLOYED_SCOPE_KEY, envsHash);
|
|
2876
3018
|
// Last, after every write the applied values depend on, so a run that failed
|
|
2877
3019
|
// halfway plans again.
|
|
2878
3020
|
await record(PLATFORM_ARCHON_APPLIED_HOSTNAME_SCOPE_KEY, hostname);
|
|
@@ -3284,6 +3426,37 @@ async function archonBootstrapOperatorDrifted(coolify, appId, recordedIds) {
|
|
|
3284
3426
|
return false;
|
|
3285
3427
|
}
|
|
3286
3428
|
}
|
|
3429
|
+
/**
|
|
3430
|
+
* #480: whether the live app disagrees with the patch
|
|
3431
|
+
* `provisionPlatformArchon` sends on every run -- `is_consistent_container_name_enabled`
|
|
3432
|
+
* turned back on, `custom_network_aliases` missing or changed, or the health
|
|
3433
|
+
* check disabled -- any of which means Coolify stops the old container
|
|
3434
|
+
* before the new one is up, the #475 incident's own failure mode.
|
|
3435
|
+
* `PLATFORM_ARCHON_APPLIED_ROLLING_UPDATE_SCOPE_KEY` only proves one past
|
|
3436
|
+
* apply sent this patch; nothing re-reads the live app afterward, so a
|
|
3437
|
+
* setting changed back by hand in the Coolify UI would otherwise go
|
|
3438
|
+
* unnoticed forever. A failed read is not drift, the same rule
|
|
3439
|
+
* {@link archonAccessDrifted} follows: the next plan reads again.
|
|
3440
|
+
*
|
|
3441
|
+
* #480 PR review: `is_consistent_container_name_enabled` comes back nested
|
|
3442
|
+
* under `settings`, confirmed against a live read of platform-archon on
|
|
3443
|
+
* Coolify 4.3.23 -- a top-level read always came back absent, so the first
|
|
3444
|
+
* version of this check never actually fired. `custom_network_aliases` and
|
|
3445
|
+
* `health_check_enabled` are both top-level there.
|
|
3446
|
+
*/
|
|
3447
|
+
async function archonRollingUpdateDrifted(coolify, appId) {
|
|
3448
|
+
if (!appId)
|
|
3449
|
+
return false;
|
|
3450
|
+
try {
|
|
3451
|
+
const app = await coolify.getApplication(appId);
|
|
3452
|
+
return (app.settings?.is_consistent_container_name_enabled === true ||
|
|
3453
|
+
app.custom_network_aliases !== PLATFORM_ARCHON_INTERNAL_NAME ||
|
|
3454
|
+
app.health_check_enabled !== true);
|
|
3455
|
+
}
|
|
3456
|
+
catch {
|
|
3457
|
+
return false;
|
|
3458
|
+
}
|
|
3459
|
+
}
|
|
3287
3460
|
/**
|
|
3288
3461
|
* What Archon reads from each enabled service, gathered before anything is
|
|
3289
3462
|
* built. Either everything is there and the env values come back, or the first
|
|
@@ -4430,6 +4603,24 @@ function hashAuthApps(raw) {
|
|
|
4430
4603
|
.update(raw ?? '')
|
|
4431
4604
|
.digest('hex');
|
|
4432
4605
|
}
|
|
4606
|
+
/**
|
|
4607
|
+
* #467: sha256 of the full env set `provisionPlatformArchon` intends
|
|
4608
|
+
* Archon to be running with, sorted by key so two calls that build the
|
|
4609
|
+
* identical set in a different push order still hash the same. `overrides`
|
|
4610
|
+
* replaces (never adds to) a same-named entry already in `envs` -- see its
|
|
4611
|
+
* call site: `envs` itself only ever carries a handful of keys on the run
|
|
4612
|
+
* that generates or adopts them, so those keys' actual values have to come
|
|
4613
|
+
* from the caller's own read-back for every other run to hash the same.
|
|
4614
|
+
* Paired with {@link PLATFORM_ARCHON_ENVS_DEPLOYED_SCOPE_KEY}, which records
|
|
4615
|
+
* this value only once a deploy carrying it is confirmed healthy.
|
|
4616
|
+
*/
|
|
4617
|
+
function hashArchonEnvs(envs, overrides) {
|
|
4618
|
+
const values = new Map(envs.map((e) => [e.key, e.value]));
|
|
4619
|
+
for (const [key, value] of overrides)
|
|
4620
|
+
values.set(key, value);
|
|
4621
|
+
const sorted = [...values.entries()].sort(([a], [b]) => a.localeCompare(b));
|
|
4622
|
+
return createHash('sha256').update(JSON.stringify(sorted)).digest('hex');
|
|
4623
|
+
}
|
|
4433
4624
|
/**
|
|
4434
4625
|
* #211 round 5: the one rule `provisionPlatformAuth` and
|
|
4435
4626
|
* `provisionPlatformArchon` both call to decide whether the broker needs a
|
|
@@ -4914,6 +5105,320 @@ shareDomains) {
|
|
|
4914
5105
|
await record(PLATFORM_FILES_APPLIED_GIT_REF_SCOPE_KEY, gitRef);
|
|
4915
5106
|
return { resourceId: runnerApp.appId };
|
|
4916
5107
|
}
|
|
5108
|
+
/** https://github.com/wtfalch/edge -- root Dockerfile, one `runner` target (see {@link provisionPlatformEdge}'s own doc comment for why there is no `migration` target any more). */
|
|
5109
|
+
const EDGE_REPO = 'wtfalch/edge';
|
|
5110
|
+
const PLATFORM_EDGE_PROJECT = 'platform';
|
|
5111
|
+
const PLATFORM_EDGE_DB_NAME = 'platform-edge-db';
|
|
5112
|
+
const EDGE_APP_NAME = 'edge';
|
|
5113
|
+
/** The app's fixed `custom_internal_name`, the way `platform:files`' runner names itself. */
|
|
5114
|
+
const EDGE_RUNNER_INTERNAL_NAME = 'platform-edge';
|
|
5115
|
+
/** The runner's port (its Dockerfile: `PORT=3000`, `EXPOSE 3000`). */
|
|
5116
|
+
const EDGE_PORT = '3000';
|
|
5117
|
+
/** The Postgres database name, and the owner role's name per the host's own `.env.example`. */
|
|
5118
|
+
const EDGE_DB = 'names';
|
|
5119
|
+
const EDGE_OWNER_ROLE = 'names_owner';
|
|
5120
|
+
/** The restricted runtime role `migrate.mjs`'s own `ensureRuntimeRole` creates or syncs at every boot. */
|
|
5121
|
+
const EDGE_RUNTIME_ROLE = 'names_rt';
|
|
5122
|
+
const EDGE_TUNNEL_NAME = 'platform-edge-tunnel';
|
|
5123
|
+
/** #197: the one Coolify app that runs `cloudflared` for {@link EDGE_TUNNEL_NAME}. */
|
|
5124
|
+
const EDGE_CLOUDFLARED_APP_NAME = 'platform-edge-cloudflared';
|
|
5125
|
+
const PLATFORM_EDGE_PROJECT_SCOPE_KEY = 'coolify_project_platform_edge';
|
|
5126
|
+
const PLATFORM_EDGE_DB_SCOPE_KEY = 'coolify_platform_edge_database';
|
|
5127
|
+
const PLATFORM_EDGE_APP_SCOPE_KEY = 'coolify_platform_edge_app';
|
|
5128
|
+
const PLATFORM_EDGE_TUNNEL_SCOPE_KEY = 'cloudflare_platform_edge_tunnel';
|
|
5129
|
+
const PLATFORM_EDGE_CONNECTOR_APP_SCOPE_KEY = 'coolify_platform_edge_connector_app';
|
|
5130
|
+
/**
|
|
5131
|
+
* Written only when absent, never overwritten -- the `PLATFORM_FILES_RUNNER_DEFAULTS`
|
|
5132
|
+
* convention, so an operator's own cap or rate survives every apply. Values
|
|
5133
|
+
* are the host's own `.env.example` defaults.
|
|
5134
|
+
*/
|
|
5135
|
+
const PLATFORM_EDGE_RUNNER_DEFAULTS = [
|
|
5136
|
+
{ key: 'MAX_CLAIMS_PER_ORGANISATION', value: '100' },
|
|
5137
|
+
{ key: 'EDGE_RATE_BURST', value: '10' },
|
|
5138
|
+
{ key: 'EDGE_RATE_PER_SECOND', value: '0.5' },
|
|
5139
|
+
];
|
|
5140
|
+
/**
|
|
5141
|
+
* Why `coolify:app` keyed {@link PLATFORM_EDGE_KEY} cannot be planned yet,
|
|
5142
|
+
* or `null` when it can -- the host built no edge dependencies (checked by
|
|
5143
|
+
* the caller, `FoundryPlatformStepExecutor.blockedSteps`, before this is
|
|
5144
|
+
* even called), no Cloudflare API token held under
|
|
5145
|
+
* {@link PLATFORM_EDGE_CLOUDFLARE_TOKEN_REF} (CLAUDE.md's "Human steps"),
|
|
5146
|
+
* or no Cloudflare account id supplied beside it.
|
|
5147
|
+
*/
|
|
5148
|
+
async function edgeBlockedReason(names) {
|
|
5149
|
+
let token;
|
|
5150
|
+
try {
|
|
5151
|
+
token = (await names.heldKeys?.resolve(PLATFORM_EDGE_CLOUDFLARE_TOKEN_REF)) ?? null;
|
|
5152
|
+
}
|
|
5153
|
+
catch {
|
|
5154
|
+
token = null;
|
|
5155
|
+
}
|
|
5156
|
+
if (!token) {
|
|
5157
|
+
return `the edge host is blocked, not planned: no Cloudflare API token is held under ${PLATFORM_EDGE_CLOUDFLARE_TOKEN_REF} yet (the edge service's own token, tunnel edit + DNS edit, never the platform's) -- a human step`;
|
|
5158
|
+
}
|
|
5159
|
+
if (!names.cloudflareAccountId) {
|
|
5160
|
+
return 'the edge host is blocked, not planned: the host supplies no Cloudflare account id (PlatformEdgeDeps.cloudflareAccountId) for the token above';
|
|
5161
|
+
}
|
|
5162
|
+
return null;
|
|
5163
|
+
}
|
|
5164
|
+
/**
|
|
5165
|
+
* #478: makes `coolify:app` keyed {@link PLATFORM_EDGE_KEY} true -- the
|
|
5166
|
+
* edge host (https://github.com/wtfalch/edge), its own database, and
|
|
5167
|
+
* foundry's own platform key, reached at `platform.edge.hostname` through
|
|
5168
|
+
* a Cloudflare tunnel and DNS record (the public-service shape
|
|
5169
|
+
* {@link provisionPlatformMail}/{@link provisionPlatformAuth} use, not
|
|
5170
|
+
* `platform:files`' internal-only one -- `EDGE_URL` has to be reachable by
|
|
5171
|
+
* every org's own app, which runs outside this Coolify network, and by
|
|
5172
|
+
* Archon).
|
|
5173
|
+
*
|
|
5174
|
+
* One Coolify app only, unlike `platform:files`/`platform:ai`'s separate
|
|
5175
|
+
* `migrate` app: edge' own Dockerfile dropped its `migration` target
|
|
5176
|
+
* (its own comment: no generated package's Coolify app ever built or ran
|
|
5177
|
+
* that stage, which once shipped `wtfalch/cms` with an empty database and
|
|
5178
|
+
* no runtime role). `apps/host/scripts/start.sh` now runs `migrate.mjs`
|
|
5179
|
+
* inside the runner's own entrypoint, before `exec`'ing the server, so the
|
|
5180
|
+
* one app's own Coolify health check (`/api/health`, a 120s start period)
|
|
5181
|
+
* is the migration's own proof of success -- there is no separate
|
|
5182
|
+
* `running:healthy` to wait on first.
|
|
5183
|
+
*
|
|
5184
|
+
* Two roles on one database, the `platform:files` pattern: Coolify creates
|
|
5185
|
+
* it with `postgres_user: 'names_owner'`, which is `DATABASE_URL_OWNER` as
|
|
5186
|
+
* returned (the migrate step's own connection, stripped from the server's
|
|
5187
|
+
* environment before it starts, per `start.sh`'s own doc comment); the
|
|
5188
|
+
* restricted `names_rt` role is never created here -- `migrate.mjs`'s own
|
|
5189
|
+
* `ensureRuntimeRole` call creates or syncs it and sets its password from
|
|
5190
|
+
* `DATABASE_URL` at every boot, so this function only has to mint that
|
|
5191
|
+
* password once and reuse it (`ensureEdgeRuntimeUrl`, the
|
|
5192
|
+
* `ensureFilesRuntimeUrl` shape) -- never rotated, since the role's
|
|
5193
|
+
* password is set from it on every migration.
|
|
5194
|
+
*
|
|
5195
|
+
* The platform key follows `platform:files`' management-key discipline
|
|
5196
|
+
* (CLAUDE.md rule 1): a random secret is minted once, its id recorded
|
|
5197
|
+
* undelivered and persisted before {@link PlatformEdgeDeps.deliverSecret}
|
|
5198
|
+
* is even tried, and the guard recorded only once delivery AND the env
|
|
5199
|
+
* write (the SHA-256 hash, as `EDGE_PLATFORM_KEY_HASH`) both succeeded. The
|
|
5200
|
+
* secret itself is never written to the host's own env, logged, returned
|
|
5201
|
+
* or put in an error -- only its hash ever reaches names, and the full
|
|
5202
|
+
* value goes to {@link PlatformEdgeDeps.deliverSecret} under
|
|
5203
|
+
* {@link PLATFORM_EDGE_KEY_REF} alone, for Archon's own `EdgePort` to read
|
|
5204
|
+
* back later when it mints or revokes an entry's own edge key.
|
|
5205
|
+
*
|
|
5206
|
+
* `CLOUDFLARE_API_TOKEN`/`CLOUDFLARE_ACCOUNT_ID` on the runner are never
|
|
5207
|
+
* minted here either: they are the edge service's own credential (never
|
|
5208
|
+
* Archon's), read back from {@link PlatformEdgeDeps.heldKeys} under
|
|
5209
|
+
* {@link PLATFORM_EDGE_CLOUDFLARE_TOKEN_REF} once a human has put one there
|
|
5210
|
+
* (the issue's own "Human steps") -- `blockedSteps`
|
|
5211
|
+
* ({@link edgeBlockedReason}) reports the step blocked, not planned, until
|
|
5212
|
+
* then.
|
|
5213
|
+
*
|
|
5214
|
+
* Every part is safe to run again after a throw partway, `platform:files`'
|
|
5215
|
+
* own rules: each id is recorded (and persisted) as soon as its resource
|
|
5216
|
+
* exists, the app is deployed on every run that needs it, and the platform
|
|
5217
|
+
* key is delivered before the guard that skips the mint is recorded. A
|
|
5218
|
+
* changed hostname or `gitRef` (recorded as applied scope ids) plans the
|
|
5219
|
+
* step again: `git_branch` and `APP_URL` are patched on every run and the
|
|
5220
|
+
* app redeploys, with no new secret minted.
|
|
5221
|
+
*
|
|
5222
|
+
* Known gaps, left for review:
|
|
5223
|
+
* - `EDGE_MAX_QUEUE_DEPTH` (the host's `.env.example`) is not set; the host
|
|
5224
|
+
* falls back to its own default.
|
|
5225
|
+
* - `EDGE_TRUST_CF_CONNECTING_IP` is always written `true` rather than
|
|
5226
|
+
* left to the host's own default, since this step always puts the
|
|
5227
|
+
* runner behind the Cloudflare tunnel it also creates.
|
|
5228
|
+
*/
|
|
5229
|
+
async function provisionPlatformEdge(context, deps) {
|
|
5230
|
+
const { platform } = context;
|
|
5231
|
+
const hostname = platform.edge.hostname;
|
|
5232
|
+
const gitRef = platform.edge.gitRef;
|
|
5233
|
+
const recorded = context.recordedIds();
|
|
5234
|
+
const runtime = deps.runtime ?? {};
|
|
5235
|
+
const sleep = runtime.sleep ?? defaultSleep;
|
|
5236
|
+
const attempts = runtime.deliverAttempts ?? 3;
|
|
5237
|
+
const cloudflareToken = await deps.heldKeys?.resolve(PLATFORM_EDGE_CLOUDFLARE_TOKEN_REF);
|
|
5238
|
+
if (!cloudflareToken || !deps.cloudflareAccountId) {
|
|
5239
|
+
throw new Error(`platform:edge needs the edge service's own Cloudflare API token (held under ${PLATFORM_EDGE_CLOUDFLARE_TOKEN_REF}) and account id (PlatformEdgeDeps.cloudflareAccountId); names never becomes healthy without them`);
|
|
5240
|
+
}
|
|
5241
|
+
// Never plan-and-throw (CLAUDE.md rule 2): the planner reports a lost
|
|
5242
|
+
// secret and plans nothing; this is the same rule for a caller that
|
|
5243
|
+
// reaches the step anyway.
|
|
5244
|
+
const lostKey = recorded[PLATFORM_EDGE_PLATFORM_KEY_UNDELIVERED_SCOPE_KEY];
|
|
5245
|
+
if (lostKey && !recorded[PLATFORM_EDGE_PLATFORM_KEY_SET_SCOPE_KEY]) {
|
|
5246
|
+
throw new Error(`platform:edge: the platform key (${lostKey}) was minted but never delivered; remove the recorded key ${PLATFORM_EDGE_PLATFORM_KEY_UNDELIVERED_SCOPE_KEY} to mint a new one`);
|
|
5247
|
+
}
|
|
5248
|
+
const record = async (key, id) => {
|
|
5249
|
+
context.recordScopeId(key, id);
|
|
5250
|
+
await context.persist?.();
|
|
5251
|
+
};
|
|
5252
|
+
let projectUuid = recorded[PLATFORM_EDGE_PROJECT_SCOPE_KEY];
|
|
5253
|
+
if (!projectUuid) {
|
|
5254
|
+
const found = await deps.coolify.findProjectByName(PLATFORM_EDGE_PROJECT);
|
|
5255
|
+
projectUuid = found
|
|
5256
|
+
? found.uuid
|
|
5257
|
+
: (await deps.coolify.createProject(PLATFORM_EDGE_PROJECT)).uuid;
|
|
5258
|
+
await record(PLATFORM_EDGE_PROJECT_SCOPE_KEY, projectUuid);
|
|
5259
|
+
}
|
|
5260
|
+
const { environment } = await deps.coolify.findOrCreateEnvironment(projectUuid, 'production');
|
|
5261
|
+
const server = await resolvePlatformServer(deps.coolify);
|
|
5262
|
+
// ---- database ----------------------------------------------------------
|
|
5263
|
+
let dbId = recorded[PLATFORM_EDGE_DB_SCOPE_KEY];
|
|
5264
|
+
if (!dbId) {
|
|
5265
|
+
const found = (await deps.coolify.listDatabases()).find((db) => db.name === PLATFORM_EDGE_DB_NAME && db.environment_id === environment.id);
|
|
5266
|
+
if (found) {
|
|
5267
|
+
dbId = found.uuid;
|
|
5268
|
+
}
|
|
5269
|
+
else {
|
|
5270
|
+
const created = await deps.coolify.createPostgresDatabase({
|
|
5271
|
+
project_uuid: projectUuid,
|
|
5272
|
+
server_uuid: server.uuid,
|
|
5273
|
+
environment_name: environment.name,
|
|
5274
|
+
environment_uuid: environment.uuid,
|
|
5275
|
+
name: PLATFORM_EDGE_DB_NAME,
|
|
5276
|
+
postgres_db: EDGE_DB,
|
|
5277
|
+
postgres_user: EDGE_OWNER_ROLE,
|
|
5278
|
+
});
|
|
5279
|
+
dbId = created.uuid;
|
|
5280
|
+
}
|
|
5281
|
+
await record(PLATFORM_EDGE_DB_SCOPE_KEY, dbId);
|
|
5282
|
+
}
|
|
5283
|
+
const db = await getDatabaseStarted(deps.coolify, dbId);
|
|
5284
|
+
if (!db.internal_db_url) {
|
|
5285
|
+
throw new Error('coolify:app for the platform (edge): the database has no internal_db_url yet -- retry this apply once Coolify reports one');
|
|
5286
|
+
}
|
|
5287
|
+
// ---- the app -------------------------------------------------------------
|
|
5288
|
+
const app = await findOrCreatePlatformApp(deps.coolify, EDGE_APP_NAME, environment, async () => {
|
|
5289
|
+
const githubApp = await deps.coolify.requireGithubApp();
|
|
5290
|
+
return deps.coolify.createGithubAppApplication({
|
|
5291
|
+
project_uuid: projectUuid,
|
|
5292
|
+
server_uuid: server.uuid,
|
|
5293
|
+
environment_name: environment.name,
|
|
5294
|
+
environment_uuid: environment.uuid,
|
|
5295
|
+
github_app_uuid: githubApp.uuid,
|
|
5296
|
+
git_repository: EDGE_REPO,
|
|
5297
|
+
git_branch: gitRef,
|
|
5298
|
+
build_pack: 'dockerfile',
|
|
5299
|
+
name: EDGE_APP_NAME,
|
|
5300
|
+
});
|
|
5301
|
+
});
|
|
5302
|
+
const appId = app.appId;
|
|
5303
|
+
await record(PLATFORM_EDGE_APP_SCOPE_KEY, appId);
|
|
5304
|
+
// Set on every run, not only after a create: a throw right after the
|
|
5305
|
+
// create must not leave the app on the wrong target for good, and a
|
|
5306
|
+
// changed `gitRef` has to reach an app made on an earlier one.
|
|
5307
|
+
await deps.coolify.updateApplication(appId, {
|
|
5308
|
+
dockerfile_target_build: 'runner',
|
|
5309
|
+
custom_internal_name: EDGE_RUNNER_INTERNAL_NAME,
|
|
5310
|
+
git_branch: gitRef,
|
|
5311
|
+
});
|
|
5312
|
+
const runtimeUrl = await ensureEdgeRuntimeUrl(deps.coolify, appId, db.internal_db_url);
|
|
5313
|
+
// ---- the platform key, once ----------------------------------------------
|
|
5314
|
+
// The recorded `set` id *is* the hash (never read back from Coolify's own
|
|
5315
|
+
// env, which can be missing or stale on a re-apply): the hash is never
|
|
5316
|
+
// secret, so it doubles as both the undelivered id and the value the base
|
|
5317
|
+
// env below writes, with nothing extra to compute or a live read that
|
|
5318
|
+
// could fall back to a blank value.
|
|
5319
|
+
let keyHash = recorded[PLATFORM_EDGE_PLATFORM_KEY_SET_SCOPE_KEY] ?? '';
|
|
5320
|
+
if (!keyHash) {
|
|
5321
|
+
const secret = randomBytes(32).toString('base64url');
|
|
5322
|
+
keyHash = createHash('sha256').update(secret).digest('hex');
|
|
5323
|
+
await record(PLATFORM_EDGE_PLATFORM_KEY_UNDELIVERED_SCOPE_KEY, keyHash);
|
|
5324
|
+
await deliverWithRetry(deps.deliverSecret, PLATFORM_EDGE_KEY_REF, secret, attempts, sleep, 'the edge platform key', 'platform:edge');
|
|
5325
|
+
// Recorded as soon as delivery succeeds, before the env write below (the
|
|
5326
|
+
// base env block, further down, is what actually writes the hash) --
|
|
5327
|
+
// so a failed or crashed env write is simply retried on the next apply
|
|
5328
|
+
// instead of leaving an undelivered secret with no `set` partner and no
|
|
5329
|
+
// way to recover but a hand edit of the recorded ids.
|
|
5330
|
+
await record(PLATFORM_EDGE_PLATFORM_KEY_SET_SCOPE_KEY, keyHash);
|
|
5331
|
+
}
|
|
5332
|
+
// ---- base env, written on every run --------------------------------------
|
|
5333
|
+
await deps.coolify.bulkUpsertEnvs(appId, [
|
|
5334
|
+
{ key: 'DATABASE_URL_OWNER', value: db.internal_db_url },
|
|
5335
|
+
{ key: 'DATABASE_URL', value: runtimeUrl },
|
|
5336
|
+
{ key: 'APP_URL', value: `https://${hostname}` },
|
|
5337
|
+
{ key: 'CLOUDFLARE_API_TOKEN', value: cloudflareToken },
|
|
5338
|
+
{ key: 'CLOUDFLARE_ACCOUNT_ID', value: deps.cloudflareAccountId },
|
|
5339
|
+
{ key: 'EDGE_TRUST_CF_CONNECTING_IP', value: 'true' },
|
|
5340
|
+
{ key: 'NODE_ENV', value: 'production' },
|
|
5341
|
+
{ key: 'EDGE_PLATFORM_KEY_HASH', value: keyHash },
|
|
5342
|
+
...(await missingEnvs(deps.coolify, appId, PLATFORM_EDGE_RUNNER_DEFAULTS)),
|
|
5343
|
+
]);
|
|
5344
|
+
// ---- tunnel and DNS -------------------------------------------------------
|
|
5345
|
+
const existingTunnel = await deps.cloudflare.findTunnelByName(EDGE_TUNNEL_NAME);
|
|
5346
|
+
const tunnel = existingTunnel ?? (await deps.cloudflare.createTunnel(EDGE_TUNNEL_NAME));
|
|
5347
|
+
await record(PLATFORM_EDGE_TUNNEL_SCOPE_KEY, tunnel.id);
|
|
5348
|
+
await deps.cloudflare.putTunnelConfig(tunnel.id, {
|
|
5349
|
+
ingress: [
|
|
5350
|
+
{ hostname, service: `http://${EDGE_RUNNER_INTERNAL_NAME}:${EDGE_PORT}` },
|
|
5351
|
+
{ service: 'http_status:404' },
|
|
5352
|
+
],
|
|
5353
|
+
});
|
|
5354
|
+
// #197: without a connector, `hostname` answers with a Cloudflare error.
|
|
5355
|
+
await ensureCloudflaredConnector({
|
|
5356
|
+
coolify: deps.coolify,
|
|
5357
|
+
cloudflare: deps.cloudflare,
|
|
5358
|
+
tunnelId: tunnel.id,
|
|
5359
|
+
appName: EDGE_CLOUDFLARED_APP_NAME,
|
|
5360
|
+
projectUuid,
|
|
5361
|
+
environment,
|
|
5362
|
+
resolveServer: async () => server,
|
|
5363
|
+
recordedConnectorAppId: recorded[PLATFORM_EDGE_CONNECTOR_APP_SCOPE_KEY] ?? null,
|
|
5364
|
+
recordAppId: (connectorAppId) => record(PLATFORM_EDGE_CONNECTOR_APP_SCOPE_KEY, connectorAppId),
|
|
5365
|
+
});
|
|
5366
|
+
const zoneId = recorded[apexZoneKey(platform.apex)] ?? (await exactZone(deps.cloudflare, platform.apex))?.id;
|
|
5367
|
+
if (!zoneId) {
|
|
5368
|
+
throw new Error(`platform:edge: no Cloudflare zone for ${platform.apex} to put ${hostname} in`);
|
|
5369
|
+
}
|
|
5370
|
+
const dnsTarget = `${tunnel.id}.cfargotunnel.com`;
|
|
5371
|
+
const [dnsRecord] = await deps.cloudflare.findDnsRecord(zoneId, hostname, 'CNAME');
|
|
5372
|
+
if (!dnsRecord) {
|
|
5373
|
+
await deps.cloudflare.createDnsRecord(zoneId, {
|
|
5374
|
+
type: 'CNAME',
|
|
5375
|
+
name: hostname,
|
|
5376
|
+
content: dnsTarget,
|
|
5377
|
+
proxied: true,
|
|
5378
|
+
});
|
|
5379
|
+
}
|
|
5380
|
+
else if (dnsRecord.content !== dnsTarget) {
|
|
5381
|
+
await deps.cloudflare.updateDnsRecord(zoneId, dnsRecord.id, {
|
|
5382
|
+
type: 'CNAME',
|
|
5383
|
+
name: hostname,
|
|
5384
|
+
content: dnsTarget,
|
|
5385
|
+
proxied: true,
|
|
5386
|
+
});
|
|
5387
|
+
}
|
|
5388
|
+
// ---- deploy and wait -------------------------------------------------------
|
|
5389
|
+
// #252: the tunnel and DNS above already route `hostname`, so the combined
|
|
5390
|
+
// helper (deployment-record check, then the `/api/health` probe) can run
|
|
5391
|
+
// in one call -- unlike `platform:mail`, nothing here needs to happen
|
|
5392
|
+
// between the two phases.
|
|
5393
|
+
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'}`);
|
|
5394
|
+
// Last: a run that failed anywhere above is planned again.
|
|
5395
|
+
context.recordScopeId(PLATFORM_EDGE_APPLIED_HOSTNAME_SCOPE_KEY, hostname);
|
|
5396
|
+
await record(PLATFORM_EDGE_APPLIED_GIT_REF_SCOPE_KEY, gitRef);
|
|
5397
|
+
return { resourceId: appId };
|
|
5398
|
+
}
|
|
5399
|
+
/**
|
|
5400
|
+
* The runtime `DATABASE_URL` the names app gets: the value already set on
|
|
5401
|
+
* it, else a new `names_rt` credential on the database's host, the
|
|
5402
|
+
* `ensureFilesRuntimeUrl` shape for one app instead of two. A parse failure
|
|
5403
|
+
* throws a message with no part of the URL in it, since the URL carries the
|
|
5404
|
+
* owner's password.
|
|
5405
|
+
*/
|
|
5406
|
+
async function ensureEdgeRuntimeUrl(coolify, appId, dbInternalUrl) {
|
|
5407
|
+
const existing = (await coolify.listEnvs(appId)).find((e) => e.key === 'DATABASE_URL' && !e.is_preview && e.value);
|
|
5408
|
+
if (existing)
|
|
5409
|
+
return existing.value;
|
|
5410
|
+
let host;
|
|
5411
|
+
let pathname;
|
|
5412
|
+
let search;
|
|
5413
|
+
try {
|
|
5414
|
+
({ host, pathname, search } = new URL(dbInternalUrl));
|
|
5415
|
+
}
|
|
5416
|
+
catch {
|
|
5417
|
+
throw new Error("coolify:app for the platform (edge): the database's internal_db_url could not be parsed to build the runtime DATABASE_URL");
|
|
5418
|
+
}
|
|
5419
|
+
const password = randomBytes(24).toString('base64url');
|
|
5420
|
+
return `postgres://${EDGE_RUNTIME_ROLE}:${password}@${host}${pathname}${search}`;
|
|
5421
|
+
}
|
|
4917
5422
|
function aiKeys(deps) {
|
|
4918
5423
|
const keys = deps?.keys;
|
|
4919
5424
|
return keys?.workerUrl && keys.credential && keys.verifyKeys ? keys : null;
|
|
@@ -5017,7 +5522,9 @@ async function stopIfRunning(coolify, appId) {
|
|
|
5017
5522
|
* Hands `secret` to the host, retrying. The thrown error names `what` only,
|
|
5018
5523
|
* never the secret or the cause, which could echo it.
|
|
5019
5524
|
*/
|
|
5020
|
-
async function deliverWithRetry(deliverSecret, ref, secret, attempts, sleep, what
|
|
5525
|
+
async function deliverWithRetry(deliverSecret, ref, secret, attempts, sleep, what,
|
|
5526
|
+
/** The step name every error is prefixed with (default `'platform:files'`, this helper's first caller). */
|
|
5527
|
+
label = 'platform:files') {
|
|
5021
5528
|
for (let attempt = 1;; attempt += 1) {
|
|
5022
5529
|
try {
|
|
5023
5530
|
await deliverSecret(ref, secret);
|
|
@@ -5025,7 +5532,7 @@ async function deliverWithRetry(deliverSecret, ref, secret, attempts, sleep, wha
|
|
|
5025
5532
|
}
|
|
5026
5533
|
catch {
|
|
5027
5534
|
if (attempt >= attempts) {
|
|
5028
|
-
throw new Error(
|
|
5535
|
+
throw new Error(`${label}: ${what} could not be delivered after ${attempts} tries; the next plan reports it as lost and mints no new one`);
|
|
5029
5536
|
}
|
|
5030
5537
|
await sleep(1000 * attempt);
|
|
5031
5538
|
}
|