@wtfalch/foundry 0.16.16 → 0.17.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/apply.d.ts +22 -1
- package/dist/bootstrap/bootstrap.d.ts +6 -1
- package/dist/bootstrap/bootstrap.js +26 -3
- package/dist/bootstrap/declare.d.ts +11 -1
- package/dist/bootstrap/declare.js +18 -6
- package/dist/index.d.ts +11 -6
- package/dist/index.js +8 -1
- package/dist/migrations/0038_calls_version_pin.sql +8 -0
- package/dist/migrations/0039_destroy_record.sql +7 -0
- package/dist/migrations/0040_company_included_uuid.sql +72 -0
- package/dist/migrations/0041_company_included_uuid_keys.sql +132 -0
- package/dist/migrations/0042_mutation_leases.sql +12 -0
- package/dist/migrations/0043_platform_names.sql +25 -0
- package/dist/migrations/0044_platform_mail_lettermint_project.sql +11 -0
- package/dist/org.d.ts +128 -3
- package/dist/org.js +186 -1
- package/dist/plan/adopt.d.ts +76 -0
- package/dist/plan/adopt.js +184 -0
- package/dist/plan/agora.d.ts +21 -0
- package/dist/plan/agora.js +37 -0
- package/dist/plan/broker-entries.d.ts +9 -1
- package/dist/plan/broker-entries.js +13 -5
- package/dist/plan/chat-matrix.d.ts +1 -2
- package/dist/plan/chat-matrix.js +15 -4
- package/dist/plan/core-files.d.ts +25 -0
- package/dist/plan/core-files.js +25 -0
- package/dist/plan/diff.d.ts +27 -3
- package/dist/plan/diff.js +264 -86
- package/dist/plan/edge-worker.d.ts +114 -0
- package/dist/plan/edge-worker.js +201 -0
- package/dist/plan/entry-ai-key.d.ts +3 -3
- package/dist/plan/entry-files-key.d.ts +3 -3
- package/dist/plan/entry-names-key.d.ts +73 -0
- package/dist/plan/entry-names-key.js +236 -0
- package/dist/plan/executor.d.ts +292 -24
- package/dist/plan/executor.js +745 -77
- package/dist/plan/index.d.ts +6 -2
- package/dist/plan/index.js +3 -1
- package/dist/plan/live.d.ts +9 -2
- package/dist/plan/login-host.d.ts +13 -2
- package/dist/plan/login-host.js +26 -7
- package/dist/plan/plan-apply.d.ts +153 -6
- package/dist/plan/plan-apply.js +639 -78
- package/dist/plan/platform-diff.d.ts +106 -1
- package/dist/plan/platform-diff.js +182 -2
- package/dist/plan/platform-org.d.ts +45 -1
- package/dist/plan/platform-org.js +34 -3
- package/dist/plan/platform-providers.d.ts +45 -1
- package/dist/plan/platform-providers.js +585 -16
- package/dist/plan/providers-readers.d.ts +2 -0
- package/dist/plan/providers-readers.js +4 -0
- package/dist/plan/rollout.d.ts +52 -0
- package/dist/plan/rollout.js +37 -0
- package/dist/plan/step-system.d.ts +22 -0
- package/dist/plan/step-system.js +38 -0
- package/dist/plan.d.ts +3 -0
- package/dist/platform.d.ts +22 -3
- package/dist/platform.js +6 -0
- package/dist/state.d.ts +13 -0
- package/dist/store/audit.js +15 -1
- package/dist/store/postgres.d.ts +1 -1
- package/dist/store/postgres.js +142 -15
- package/dist/store/schema.d.ts +202 -5
- package/dist/store/schema.js +18 -1
- package/dist/store.d.ts +47 -4
- package/dist/store.js +9 -1
- 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_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_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, PLATFORM_NAMES_APPLIED_GIT_REF_SCOPE_KEY, PLATFORM_NAMES_APPLIED_HOSTNAME_SCOPE_KEY, PLATFORM_NAMES_CLOUDFLARE_TOKEN_REF, PLATFORM_NAMES_KEY, PLATFORM_NAMES_KEY_REF, PLATFORM_NAMES_PLATFORM_KEY_SET_SCOPE_KEY, PLATFORM_NAMES_PLATFORM_KEY_UNDELIVERED_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_NAMES_KEY}, the names host
|
|
98
|
+
* ({@link provisionPlatformNames}), once a host passes {@link PlatformNamesDeps}.
|
|
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
|
+
names;
|
|
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.names = options.names;
|
|
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.names.enabled && !this.names) {
|
|
221
|
+
blocked.names =
|
|
222
|
+
'the names host is blocked, not planned: the host built the platform executor without its names dependencies';
|
|
223
|
+
}
|
|
224
|
+
else if (platform.names.enabled && this.names) {
|
|
225
|
+
const namesBlocked = await namesBlockedReason(this.names);
|
|
226
|
+
if (namesBlocked)
|
|
227
|
+
blocked.names = namesBlocked;
|
|
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_NAMES_KEY) {
|
|
379
|
+
if (!this.names) {
|
|
380
|
+
throw new Error('platform:names needs FoundryPlatformStepExecutor to be constructed with its names dependencies (#478)');
|
|
381
|
+
}
|
|
382
|
+
return provisionPlatformNames(context, this.names);
|
|
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
|
|
@@ -2365,7 +2413,15 @@ export const PLATFORM_ARCHON_CLIENT_ID_SCOPE_KEY = 'zitadel_platform_archon_clie
|
|
|
2365
2413
|
* does not parse fails the step without printing it.
|
|
2366
2414
|
* - For each of files, ai, integrations and jobs that the platform declares
|
|
2367
2415
|
* enabled: its `https://<hostname>` URL, and its management key when
|
|
2368
|
-
* {@link PlatformArchonDeps.heldKeys} holds one.
|
|
2416
|
+
* {@link PlatformArchonDeps.heldKeys} holds one. Files also gets
|
|
2417
|
+
* `FILES_ORGANISATION_ID` (`platform.ownerOrgId`, Company Included's own
|
|
2418
|
+
* Files organisation -- `executor.ts`'s `filesOrganisation` provisions it
|
|
2419
|
+
* under that same id) and its signed-assertion identity
|
|
2420
|
+
* (`FILES_MANAGEMENT_ISSUER`, `_KEY_ID`, `_PRIVATE_KEY`; #436).
|
|
2421
|
+
* - `COOLIFY_URL`/`COOLIFY_READ_TOKEN` for `/backups`, once a human has
|
|
2422
|
+
* created a Coolify token scoped to read only and held it under
|
|
2423
|
+
* {@link PLATFORM_ARCHON_COOLIFY_READ_TOKEN_REF} -- that ref's own doc
|
|
2424
|
+
* comment says why foundry cannot mint one itself (#436).
|
|
2369
2425
|
*
|
|
2370
2426
|
* The OIDC client is ZITADEL's public web client (authorization code with
|
|
2371
2427
|
* PKCE, no client secret: `createOidcApp` never returns one, see
|
|
@@ -2397,10 +2453,7 @@ export const PLATFORM_ARCHON_CLIENT_ID_SCOPE_KEY = 'zitadel_platform_archon_clie
|
|
|
2397
2453
|
* steps are exported by those steps' own modules. A
|
|
2398
2454
|
* missing one blocks the plan (`blockedSteps`); it is never skipped. The
|
|
2399
2455
|
* env names `AI_*`, `INTEGRATIONS_*`, `JOBS_URL` and `JOBS_ADMIN_TOKEN` are
|
|
2400
|
-
* this step's proposal
|
|
2401
|
-
* `FILES_MANAGEMENT_KEY` of these today. Files' signed-assertion identity
|
|
2402
|
-
* (`FILES_MANAGEMENT_ISSUER`, `_KEY_ID`, `_PRIVATE_KEY`, `_SCOPE`) and
|
|
2403
|
-
* `FILES_ORGANISATION_ID` are not wired.
|
|
2456
|
+
* this step's proposal.
|
|
2404
2457
|
* - The step plans again when the hostname, `gitRef` or the Access policy's
|
|
2405
2458
|
* include list changes. Env for a service enabled later is not pushed until
|
|
2406
2459
|
* one of those changes.
|
|
@@ -2408,6 +2461,24 @@ export const PLATFORM_ARCHON_CLIENT_ID_SCOPE_KEY = 'zitadel_platform_archon_clie
|
|
|
2408
2461
|
* the tunnel hostname, deletes the Access app and builds it again.
|
|
2409
2462
|
* - #197: a `cloudflared` connector runs for this tunnel, as in `provisionPlatformAuth`.
|
|
2410
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.
|
|
2411
2482
|
*/
|
|
2412
2483
|
async function provisionPlatformArchon(context, deps) {
|
|
2413
2484
|
const { platform } = context;
|
|
@@ -2514,8 +2585,30 @@ async function provisionPlatformArchon(context, deps) {
|
|
|
2514
2585
|
// right after the create must not leave it unnamed for good.
|
|
2515
2586
|
// `git_branch` is patched too, so a changed `gitRef` reaches the app; the
|
|
2516
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`).
|
|
2517
2605
|
await deps.coolify.updateApplication(appId, {
|
|
2518
|
-
|
|
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,
|
|
2519
2612
|
git_branch: gitRef,
|
|
2520
2613
|
});
|
|
2521
2614
|
// ---- sign-in client in the platform's own ZITADEL -------------------------
|
|
@@ -2581,7 +2674,8 @@ async function provisionPlatformArchon(context, deps) {
|
|
|
2581
2674
|
else {
|
|
2582
2675
|
envs.push({ key: 'DATABASE_URL_OWNER', value: db.internal_db_url });
|
|
2583
2676
|
}
|
|
2584
|
-
|
|
2677
|
+
const liveReportingSite = await currentEnvValue(deps.coolify, appId, 'REPORTING_SITE');
|
|
2678
|
+
if (!liveReportingSite) {
|
|
2585
2679
|
envs.push({ key: 'REPORTING_SITE', value: 'archon' });
|
|
2586
2680
|
}
|
|
2587
2681
|
const cookieSecret = await ensureGeneratedEnv(deps.coolify, appId, 'AUTH_COOKIE_SECRET', () => randomBytes(32).toString('base64'));
|
|
@@ -2590,6 +2684,25 @@ async function provisionPlatformArchon(context, deps) {
|
|
|
2590
2684
|
const runtimeUrl = await ensureArchonRuntimeUrl(deps.coolify, appId, db.internal_db_url);
|
|
2591
2685
|
if (runtimeUrl)
|
|
2592
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
|
+
}
|
|
2593
2706
|
// `AUTH_APP_KEY` (#204): minted once like the cookie secret above, but read
|
|
2594
2707
|
// back (never regenerated) rather than skipped when already set, since its
|
|
2595
2708
|
// value is needed below to keep the broker's own registration in sync even
|
|
@@ -2630,8 +2743,53 @@ async function provisionPlatformArchon(context, deps) {
|
|
|
2630
2743
|
// unflagged, Coolify writes it into `.env` unquoted and the container never
|
|
2631
2744
|
// starts.
|
|
2632
2745
|
envs.push(...inputs.envs.map((e) => (e.value.includes('\n') ? { ...e, multiline: true } : e)));
|
|
2746
|
+
// #436: /backups needs a Coolify token scoped to read only, never the
|
|
2747
|
+
// root token `putArchonMasterKeys` above already held. Nothing mints that
|
|
2748
|
+
// token over Coolify's API (this ref's own doc comment), so until a human
|
|
2749
|
+
// creates one and holds it under PLATFORM_ARCHON_COOLIFY_READ_TOKEN_REF,
|
|
2750
|
+
// this pushes nothing and /backups keeps showing its own "set COOLIFY_URL
|
|
2751
|
+
// and COOLIFY_READ_TOKEN" message -- never a reason to block the rest of
|
|
2752
|
+
// this step.
|
|
2753
|
+
const coolifyReadToken = await deps.heldKeys.resolve(PLATFORM_ARCHON_COOLIFY_READ_TOKEN_REF);
|
|
2754
|
+
if (coolifyReadToken) {
|
|
2755
|
+
const coolifyUrl = await deps.heldKeys.resolve(PLATFORM_ARCHON_MASTER_COOLIFY_URL_REF);
|
|
2756
|
+
if (coolifyUrl) {
|
|
2757
|
+
envs.push({ key: 'COOLIFY_URL', value: coolifyUrl }, { key: 'COOLIFY_READ_TOKEN', value: coolifyReadToken });
|
|
2758
|
+
}
|
|
2759
|
+
}
|
|
2633
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;
|
|
2634
2768
|
await deps.coolify.bulkUpsertEnvs(appId, envs);
|
|
2769
|
+
// #436 round 2: `bulkUpsertEnvs` only adds or updates, so the
|
|
2770
|
+
// `FILES_MANAGEMENT_KEY` this step used to write before the fix above
|
|
2771
|
+
// would otherwise survive on an Archon already deployed with it --
|
|
2772
|
+
// `filesManagementFetch` checks that var first, so a stale copy kept
|
|
2773
|
+
// steering it into the issued-key path this issue's wiring fixes, and it
|
|
2774
|
+
// left a second copy of the private key sitting in Coolify. Every row
|
|
2775
|
+
// under this key, including `listEnvs`'s own preview duplicate, is
|
|
2776
|
+
// deleted outright -- never overwritten, which would still leave a row.
|
|
2777
|
+
//
|
|
2778
|
+
// #462: the deletes happen after `changed` is already computed above, so
|
|
2779
|
+
// a retry that finds the envs already matching (`changed` false) but a
|
|
2780
|
+
// stale `FILES_MANAGEMENT_KEY` row still present must still redeploy --
|
|
2781
|
+
// the running container otherwise keeps reading the stale key out of its
|
|
2782
|
+
// own process env until someone redeploys by hand. `staleKeyDeleted`
|
|
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).
|
|
2786
|
+
let staleKeyDeleted = false;
|
|
2787
|
+
for (const stale of await deps.coolify.listEnvs(appId)) {
|
|
2788
|
+
if (stale.key === 'FILES_MANAGEMENT_KEY' && stale.uuid) {
|
|
2789
|
+
await deps.coolify.deleteEnv(appId, stale.uuid);
|
|
2790
|
+
staleKeyDeleted = true;
|
|
2791
|
+
}
|
|
2792
|
+
}
|
|
2635
2793
|
// ---- register with the broker (#204) ---------------------------------------
|
|
2636
2794
|
// After Archon's own `AUTH_APP_KEY` is durably written above, so a throw
|
|
2637
2795
|
// earlier in this function (a bad database URL, a Cloudflare failure) never
|
|
@@ -2752,13 +2910,29 @@ async function provisionPlatformArchon(context, deps) {
|
|
|
2752
2910
|
const keyUnsynced = Boolean(integrationsKeyId) &&
|
|
2753
2911
|
Boolean(recorded[PLATFORM_INTEGRATIONS_KEY_ID_SCOPE_KEY]) &&
|
|
2754
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';
|
|
2755
2919
|
const archonDeployedNow = keyUnsynced ||
|
|
2756
2920
|
app.justCreated ||
|
|
2757
2921
|
!app.running ||
|
|
2758
2922
|
changed ||
|
|
2923
|
+
staleKeyDeleted ||
|
|
2924
|
+
envsHashChanged ||
|
|
2759
2925
|
appliedGitRef !== gitRef ||
|
|
2926
|
+
!rollingUpdateApplied ||
|
|
2760
2927
|
recorded[PLATFORM_ARCHON_APPLIED_HOSTNAME_SCOPE_KEY] !== hostname;
|
|
2761
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, '');
|
|
2762
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'}`);
|
|
2763
2937
|
}
|
|
2764
2938
|
// ---- tunnel and DNS ---------------------------------------------------------
|
|
@@ -2831,6 +3005,16 @@ async function provisionPlatformArchon(context, deps) {
|
|
|
2831
3005
|
if (keyUnsynced && integrationsKeyId) {
|
|
2832
3006
|
await record(PLATFORM_ARCHON_INTEGRATIONS_KEY_SYNCED_SCOPE_KEY, integrationsKeyId);
|
|
2833
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);
|
|
2834
3018
|
// Last, after every write the applied values depend on, so a run that failed
|
|
2835
3019
|
// halfway plans again.
|
|
2836
3020
|
await record(PLATFORM_ARCHON_APPLIED_HOSTNAME_SCOPE_KEY, hostname);
|
|
@@ -3242,6 +3426,37 @@ async function archonBootstrapOperatorDrifted(coolify, appId, recordedIds) {
|
|
|
3242
3426
|
return false;
|
|
3243
3427
|
}
|
|
3244
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
|
+
}
|
|
3245
3460
|
/**
|
|
3246
3461
|
* What Archon reads from each enabled service, gathered before anything is
|
|
3247
3462
|
* built. Either everything is there and the env values come back, or the first
|
|
@@ -3309,11 +3524,31 @@ async function collectArchonServiceInputs(platform, deps, recorded) {
|
|
|
3309
3524
|
const held = (ref) => heldByRef.get(ref);
|
|
3310
3525
|
for (const service of services) {
|
|
3311
3526
|
if (service === 'files') {
|
|
3312
|
-
envs.push(
|
|
3313
|
-
|
|
3314
|
-
|
|
3315
|
-
|
|
3316
|
-
|
|
3527
|
+
envs.push(
|
|
3528
|
+
// #436 round 2: not `PLATFORM_FILES_INTERNAL_URL_SCOPE_KEY`'s
|
|
3529
|
+
// internal `http://<name>:3000` -- Archon's signed path needs an
|
|
3530
|
+
// exact HTTPS origin (`filesOrigin()`/`filesBaseUrl()`,
|
|
3531
|
+
// `wtfalch/archon`'s `src/lib/authz/files-management.ts` and
|
|
3532
|
+
// `src/lib/files-admin/client.ts`), and that origin is also the
|
|
3533
|
+
// signing audience, so it must equal Files' own `APP_URL` exactly
|
|
3534
|
+
// (this file, the files runner env above: `https://${platform.
|
|
3535
|
+
// files.hostname}`).
|
|
3536
|
+
{ key: 'FILES_URL', value: `https://${platform.files.hostname}` },
|
|
3537
|
+
// #436: Company Included's own Files organisation, provisioned by
|
|
3538
|
+
// the ordinary per-org `planFilesOrganisation` step under its own
|
|
3539
|
+
// org id -- `executor.ts`'s `filesOrganisation` calls
|
|
3540
|
+
// `provisionOrganisation({ organisationId: org.id, ... })`, so
|
|
3541
|
+
// `platform.ownerOrgId` is already that organisation's id, with no
|
|
3542
|
+
// separate platform-level mint needed.
|
|
3543
|
+
{ key: 'FILES_ORGANISATION_ID', value: platform.ownerOrgId },
|
|
3544
|
+
// #436: the signed-assertion identity `filesSigner()` reads
|
|
3545
|
+
// (`archon`'s `src/lib/authz/files-management.ts`), not the bearer
|
|
3546
|
+
// token `FILES_MANAGEMENT_KEY` used to name here -- that name made
|
|
3547
|
+
// Archon take the issued-key branch of `filesManagementFetch`,
|
|
3548
|
+
// which then failed on the `FILES_MANAGEMENT_SCOPE` this step never
|
|
3549
|
+
// set, surfacing as "Files is not configured" (foundry#436).
|
|
3550
|
+
{ key: 'FILES_MANAGEMENT_ISSUER', value: FILES_MANAGEMENT_ISSUER }, { key: 'FILES_MANAGEMENT_KEY_ID', value: FILES_MANAGEMENT_KEY_ID }, {
|
|
3551
|
+
key: 'FILES_MANAGEMENT_PRIVATE_KEY',
|
|
3317
3552
|
value: held(ARCHON_SERVICE_INPUTS.files.refs[0]),
|
|
3318
3553
|
});
|
|
3319
3554
|
}
|
|
@@ -4368,6 +4603,24 @@ function hashAuthApps(raw) {
|
|
|
4368
4603
|
.update(raw ?? '')
|
|
4369
4604
|
.digest('hex');
|
|
4370
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
|
+
}
|
|
4371
4624
|
/**
|
|
4372
4625
|
* #211 round 5: the one rule `provisionPlatformAuth` and
|
|
4373
4626
|
* `provisionPlatformArchon` both call to decide whether the broker needs a
|
|
@@ -4852,6 +5105,320 @@ shareDomains) {
|
|
|
4852
5105
|
await record(PLATFORM_FILES_APPLIED_GIT_REF_SCOPE_KEY, gitRef);
|
|
4853
5106
|
return { resourceId: runnerApp.appId };
|
|
4854
5107
|
}
|
|
5108
|
+
/** https://github.com/wtfalch/names -- root Dockerfile, one `runner` target (see {@link provisionPlatformNames}'s own doc comment for why there is no `migration` target any more). */
|
|
5109
|
+
const NAMES_REPO = 'wtfalch/names';
|
|
5110
|
+
const PLATFORM_NAMES_PROJECT = 'platform';
|
|
5111
|
+
const PLATFORM_NAMES_DB_NAME = 'platform-names-db';
|
|
5112
|
+
const NAMES_APP_NAME = 'names';
|
|
5113
|
+
/** The app's fixed `custom_internal_name`, the way `platform:files`' runner names itself. */
|
|
5114
|
+
const NAMES_RUNNER_INTERNAL_NAME = 'platform-names';
|
|
5115
|
+
/** The runner's port (its Dockerfile: `PORT=3000`, `EXPOSE 3000`). */
|
|
5116
|
+
const NAMES_PORT = '3000';
|
|
5117
|
+
/** The Postgres database name, and the owner role's name per the host's own `.env.example`. */
|
|
5118
|
+
const NAMES_DB = 'names';
|
|
5119
|
+
const NAMES_OWNER_ROLE = 'names_owner';
|
|
5120
|
+
/** The restricted runtime role `migrate.mjs`'s own `ensureRuntimeRole` creates or syncs at every boot. */
|
|
5121
|
+
const NAMES_RUNTIME_ROLE = 'names_rt';
|
|
5122
|
+
const NAMES_TUNNEL_NAME = 'platform-names-tunnel';
|
|
5123
|
+
/** #197: the one Coolify app that runs `cloudflared` for {@link NAMES_TUNNEL_NAME}. */
|
|
5124
|
+
const NAMES_CLOUDFLARED_APP_NAME = 'platform-names-cloudflared';
|
|
5125
|
+
const PLATFORM_NAMES_PROJECT_SCOPE_KEY = 'coolify_project_platform_names';
|
|
5126
|
+
const PLATFORM_NAMES_DB_SCOPE_KEY = 'coolify_platform_names_database';
|
|
5127
|
+
const PLATFORM_NAMES_APP_SCOPE_KEY = 'coolify_platform_names_app';
|
|
5128
|
+
const PLATFORM_NAMES_TUNNEL_SCOPE_KEY = 'cloudflare_platform_names_tunnel';
|
|
5129
|
+
const PLATFORM_NAMES_CONNECTOR_APP_SCOPE_KEY = 'coolify_platform_names_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_NAMES_RUNNER_DEFAULTS = [
|
|
5136
|
+
{ key: 'MAX_CLAIMS_PER_ORGANISATION', value: '100' },
|
|
5137
|
+
{ key: 'NAMES_RATE_BURST', value: '10' },
|
|
5138
|
+
{ key: 'NAMES_RATE_PER_SECOND', value: '0.5' },
|
|
5139
|
+
];
|
|
5140
|
+
/**
|
|
5141
|
+
* Why `coolify:app` keyed {@link PLATFORM_NAMES_KEY} cannot be planned yet,
|
|
5142
|
+
* or `null` when it can -- the host built no names dependencies (checked by
|
|
5143
|
+
* the caller, `FoundryPlatformStepExecutor.blockedSteps`, before this is
|
|
5144
|
+
* even called), no Cloudflare API token held under
|
|
5145
|
+
* {@link PLATFORM_NAMES_CLOUDFLARE_TOKEN_REF} (CLAUDE.md's "Human steps"),
|
|
5146
|
+
* or no Cloudflare account id supplied beside it.
|
|
5147
|
+
*/
|
|
5148
|
+
async function namesBlockedReason(names) {
|
|
5149
|
+
let token;
|
|
5150
|
+
try {
|
|
5151
|
+
token = (await names.heldKeys?.resolve(PLATFORM_NAMES_CLOUDFLARE_TOKEN_REF)) ?? null;
|
|
5152
|
+
}
|
|
5153
|
+
catch {
|
|
5154
|
+
token = null;
|
|
5155
|
+
}
|
|
5156
|
+
if (!token) {
|
|
5157
|
+
return `the names host is blocked, not planned: no Cloudflare API token is held under ${PLATFORM_NAMES_CLOUDFLARE_TOKEN_REF} yet (the names service's own token, tunnel edit + DNS edit, never the platform's) -- a human step`;
|
|
5158
|
+
}
|
|
5159
|
+
if (!names.cloudflareAccountId) {
|
|
5160
|
+
return 'the names host is blocked, not planned: the host supplies no Cloudflare account id (PlatformNamesDeps.cloudflareAccountId) for the token above';
|
|
5161
|
+
}
|
|
5162
|
+
return null;
|
|
5163
|
+
}
|
|
5164
|
+
/**
|
|
5165
|
+
* #478: makes `coolify:app` keyed {@link PLATFORM_NAMES_KEY} true -- the
|
|
5166
|
+
* names host (https://github.com/wtfalch/names), its own database, and
|
|
5167
|
+
* foundry's own platform key, reached at `platform.names.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 -- `NAMES_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: names' 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 (`ensureNamesRuntimeUrl`, 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 PlatformNamesDeps.deliverSecret}
|
|
5198
|
+
* is even tried, and the guard recorded only once delivery AND the env
|
|
5199
|
+
* write (the SHA-256 hash, as `NAMES_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 PlatformNamesDeps.deliverSecret} under
|
|
5203
|
+
* {@link PLATFORM_NAMES_KEY_REF} alone, for Archon's own `NamesPort` to read
|
|
5204
|
+
* back later when it mints or revokes an entry's own names key.
|
|
5205
|
+
*
|
|
5206
|
+
* `CLOUDFLARE_API_TOKEN`/`CLOUDFLARE_ACCOUNT_ID` on the runner are never
|
|
5207
|
+
* minted here either: they are the names service's own credential (never
|
|
5208
|
+
* Archon's), read back from {@link PlatformNamesDeps.heldKeys} under
|
|
5209
|
+
* {@link PLATFORM_NAMES_CLOUDFLARE_TOKEN_REF} once a human has put one there
|
|
5210
|
+
* (the issue's own "Human steps") -- `blockedSteps`
|
|
5211
|
+
* ({@link namesBlockedReason}) 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
|
+
* - `NAMES_MAX_QUEUE_DEPTH` (the host's `.env.example`) is not set; the host
|
|
5224
|
+
* falls back to its own default.
|
|
5225
|
+
* - `NAMES_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 provisionPlatformNames(context, deps) {
|
|
5230
|
+
const { platform } = context;
|
|
5231
|
+
const hostname = platform.names.hostname;
|
|
5232
|
+
const gitRef = platform.names.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_NAMES_CLOUDFLARE_TOKEN_REF);
|
|
5238
|
+
if (!cloudflareToken || !deps.cloudflareAccountId) {
|
|
5239
|
+
throw new Error(`platform:names needs the names service's own Cloudflare API token (held under ${PLATFORM_NAMES_CLOUDFLARE_TOKEN_REF}) and account id (PlatformNamesDeps.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_NAMES_PLATFORM_KEY_UNDELIVERED_SCOPE_KEY];
|
|
5245
|
+
if (lostKey && !recorded[PLATFORM_NAMES_PLATFORM_KEY_SET_SCOPE_KEY]) {
|
|
5246
|
+
throw new Error(`platform:names: the platform key (${lostKey}) was minted but never delivered; remove the recorded key ${PLATFORM_NAMES_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_NAMES_PROJECT_SCOPE_KEY];
|
|
5253
|
+
if (!projectUuid) {
|
|
5254
|
+
const found = await deps.coolify.findProjectByName(PLATFORM_NAMES_PROJECT);
|
|
5255
|
+
projectUuid = found
|
|
5256
|
+
? found.uuid
|
|
5257
|
+
: (await deps.coolify.createProject(PLATFORM_NAMES_PROJECT)).uuid;
|
|
5258
|
+
await record(PLATFORM_NAMES_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_NAMES_DB_SCOPE_KEY];
|
|
5264
|
+
if (!dbId) {
|
|
5265
|
+
const found = (await deps.coolify.listDatabases()).find((db) => db.name === PLATFORM_NAMES_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_NAMES_DB_NAME,
|
|
5276
|
+
postgres_db: NAMES_DB,
|
|
5277
|
+
postgres_user: NAMES_OWNER_ROLE,
|
|
5278
|
+
});
|
|
5279
|
+
dbId = created.uuid;
|
|
5280
|
+
}
|
|
5281
|
+
await record(PLATFORM_NAMES_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 (names): 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, NAMES_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: NAMES_REPO,
|
|
5297
|
+
git_branch: gitRef,
|
|
5298
|
+
build_pack: 'dockerfile',
|
|
5299
|
+
name: NAMES_APP_NAME,
|
|
5300
|
+
});
|
|
5301
|
+
});
|
|
5302
|
+
const appId = app.appId;
|
|
5303
|
+
await record(PLATFORM_NAMES_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: NAMES_RUNNER_INTERNAL_NAME,
|
|
5310
|
+
git_branch: gitRef,
|
|
5311
|
+
});
|
|
5312
|
+
const runtimeUrl = await ensureNamesRuntimeUrl(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_NAMES_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_NAMES_PLATFORM_KEY_UNDELIVERED_SCOPE_KEY, keyHash);
|
|
5324
|
+
await deliverWithRetry(deps.deliverSecret, PLATFORM_NAMES_KEY_REF, secret, attempts, sleep, 'the names platform key', 'platform:names');
|
|
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_NAMES_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: 'NAMES_TRUST_CF_CONNECTING_IP', value: 'true' },
|
|
5340
|
+
{ key: 'NODE_ENV', value: 'production' },
|
|
5341
|
+
{ key: 'NAMES_PLATFORM_KEY_HASH', value: keyHash },
|
|
5342
|
+
...(await missingEnvs(deps.coolify, appId, PLATFORM_NAMES_RUNNER_DEFAULTS)),
|
|
5343
|
+
]);
|
|
5344
|
+
// ---- tunnel and DNS -------------------------------------------------------
|
|
5345
|
+
const existingTunnel = await deps.cloudflare.findTunnelByName(NAMES_TUNNEL_NAME);
|
|
5346
|
+
const tunnel = existingTunnel ?? (await deps.cloudflare.createTunnel(NAMES_TUNNEL_NAME));
|
|
5347
|
+
await record(PLATFORM_NAMES_TUNNEL_SCOPE_KEY, tunnel.id);
|
|
5348
|
+
await deps.cloudflare.putTunnelConfig(tunnel.id, {
|
|
5349
|
+
ingress: [
|
|
5350
|
+
{ hostname, service: `http://${NAMES_RUNNER_INTERNAL_NAME}:${NAMES_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: NAMES_CLOUDFLARED_APP_NAME,
|
|
5360
|
+
projectUuid,
|
|
5361
|
+
environment,
|
|
5362
|
+
resolveServer: async () => server,
|
|
5363
|
+
recordedConnectorAppId: recorded[PLATFORM_NAMES_CONNECTOR_APP_SCOPE_KEY] ?? null,
|
|
5364
|
+
recordAppId: (connectorAppId) => record(PLATFORM_NAMES_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:names: 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:names', `names stays at ${recorded[PLATFORM_NAMES_APPLIED_GIT_REF_SCOPE_KEY] ?? 'no recorded ref'}`);
|
|
5394
|
+
// Last: a run that failed anywhere above is planned again.
|
|
5395
|
+
context.recordScopeId(PLATFORM_NAMES_APPLIED_HOSTNAME_SCOPE_KEY, hostname);
|
|
5396
|
+
await record(PLATFORM_NAMES_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 ensureNamesRuntimeUrl(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 (names): 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://${NAMES_RUNTIME_ROLE}:${password}@${host}${pathname}${search}`;
|
|
5421
|
+
}
|
|
4855
5422
|
function aiKeys(deps) {
|
|
4856
5423
|
const keys = deps?.keys;
|
|
4857
5424
|
return keys?.workerUrl && keys.credential && keys.verifyKeys ? keys : null;
|
|
@@ -4955,7 +5522,9 @@ async function stopIfRunning(coolify, appId) {
|
|
|
4955
5522
|
* Hands `secret` to the host, retrying. The thrown error names `what` only,
|
|
4956
5523
|
* never the secret or the cause, which could echo it.
|
|
4957
5524
|
*/
|
|
4958
|
-
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') {
|
|
4959
5528
|
for (let attempt = 1;; attempt += 1) {
|
|
4960
5529
|
try {
|
|
4961
5530
|
await deliverSecret(ref, secret);
|
|
@@ -4963,7 +5532,7 @@ async function deliverWithRetry(deliverSecret, ref, secret, attempts, sleep, wha
|
|
|
4963
5532
|
}
|
|
4964
5533
|
catch {
|
|
4965
5534
|
if (attempt >= attempts) {
|
|
4966
|
-
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`);
|
|
4967
5536
|
}
|
|
4968
5537
|
await sleep(1000 * attempt);
|
|
4969
5538
|
}
|