@wtfalch/foundry 0.17.1 → 0.19.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/bootstrap/declare.d.ts +11 -2
- package/dist/bootstrap/declare.js +20 -11
- package/dist/index.d.ts +4 -2
- package/dist/index.js +2 -1
- package/dist/migrations/0045_names_to_edge.sql +107 -0
- package/dist/org.d.ts +17 -17
- package/dist/org.js +48 -58
- package/dist/plan/agora.d.ts +25 -0
- package/dist/plan/agora.js +25 -0
- package/dist/plan/boule.d.ts +8 -0
- package/dist/plan/boule.js +8 -0
- package/dist/plan/diff.d.ts +3 -1
- package/dist/plan/diff.js +115 -52
- package/dist/plan/entry-edge-key.d.ts +79 -0
- package/dist/plan/{entry-names-key.js → entry-edge-key.js} +90 -66
- package/dist/plan/executor.d.ts +87 -42
- package/dist/plan/executor.js +277 -120
- package/dist/plan/index.d.ts +2 -2
- package/dist/plan/index.js +1 -1
- package/dist/plan/live.d.ts +3 -3
- package/dist/plan/login-host.d.ts +15 -11
- package/dist/plan/login-host.js +16 -16
- package/dist/plan/plan-apply.d.ts +6 -5
- package/dist/plan/plan-apply.js +40 -38
- package/dist/plan/platform-diff.d.ts +22 -22
- package/dist/plan/platform-diff.js +45 -45
- package/dist/plan/platform-org-ports.d.ts +1 -0
- package/dist/plan/platform-org-ports.js +3 -0
- package/dist/plan/platform-org.d.ts +8 -8
- package/dist/plan/platform-org.js +7 -7
- package/dist/plan/platform-providers.d.ts +17 -16
- package/dist/plan/platform-providers.js +136 -118
- package/dist/plan/providers-readers.d.ts +34 -11
- package/dist/plan/providers-readers.js +72 -15
- package/dist/plan/step-system.js +1 -1
- package/dist/plan/{edge-worker.d.ts → worker.d.ts} +53 -33
- package/dist/plan/{edge-worker.js → worker.js} +92 -54
- package/dist/platform.d.ts +3 -3
- package/dist/platform.js +1 -1
- package/dist/store/postgres.js +7 -7
- package/dist/store/schema.d.ts +6 -6
- package/dist/store/schema.js +4 -4
- package/package.json +1 -1
- package/dist/plan/entry-names-key.d.ts +0 -73
|
@@ -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_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,
|
|
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,8 +94,8 @@ 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
|
|
98
|
-
* ({@link
|
|
97
|
+
* #478 adds `coolify:app` keyed {@link PLATFORM_EDGE_KEY}, the edge host
|
|
98
|
+
* ({@link provisionPlatformEdge}), once a host passes {@link PlatformEdgeDeps}.
|
|
99
99
|
*
|
|
100
100
|
* #175 adds `coolify:app` keyed {@link PLATFORM_ARCHON_KEY}, Archon itself
|
|
101
101
|
* ({@link provisionPlatformArchon}), once a host passes
|
|
@@ -116,7 +116,7 @@ export class FoundryPlatformStepExecutor {
|
|
|
116
116
|
integrations;
|
|
117
117
|
files;
|
|
118
118
|
ai;
|
|
119
|
-
|
|
119
|
+
edge;
|
|
120
120
|
archon;
|
|
121
121
|
backup;
|
|
122
122
|
constructor(cloudflare, declaredOrgStore, options = {}) {
|
|
@@ -130,7 +130,7 @@ export class FoundryPlatformStepExecutor {
|
|
|
130
130
|
this.integrations = options.integrations;
|
|
131
131
|
this.files = options.files;
|
|
132
132
|
this.ai = options.ai;
|
|
133
|
-
this.
|
|
133
|
+
this.edge = options.edge;
|
|
134
134
|
this.archon = options.archon;
|
|
135
135
|
this.backup = options.backup;
|
|
136
136
|
}
|
|
@@ -217,14 +217,14 @@ export class FoundryPlatformStepExecutor {
|
|
|
217
217
|
blocked.ai =
|
|
218
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)";
|
|
219
219
|
}
|
|
220
|
-
if (platform.
|
|
221
|
-
blocked.
|
|
222
|
-
'the
|
|
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
223
|
}
|
|
224
|
-
else if (platform.
|
|
225
|
-
const
|
|
226
|
-
if (
|
|
227
|
-
blocked.
|
|
224
|
+
else if (platform.edge.enabled && this.edge) {
|
|
225
|
+
const edgeBlocked = await edgeBlockedReason(this.edge);
|
|
226
|
+
if (edgeBlocked)
|
|
227
|
+
blocked.edge = edgeBlocked;
|
|
228
228
|
}
|
|
229
229
|
if (platform.archon.enabled) {
|
|
230
230
|
if (!this.archon) {
|
|
@@ -375,11 +375,11 @@ export class FoundryPlatformStepExecutor {
|
|
|
375
375
|
}
|
|
376
376
|
return provisionPlatformAi(context, this.ai);
|
|
377
377
|
}
|
|
378
|
-
if (action.system === 'coolify' && action.kind === 'app' && action.key ===
|
|
379
|
-
if (!this.
|
|
380
|
-
throw new Error('platform:
|
|
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
381
|
}
|
|
382
|
-
return
|
|
382
|
+
return provisionPlatformEdge(context, this.edge);
|
|
383
383
|
}
|
|
384
384
|
if (action.system === 'coolify' &&
|
|
385
385
|
action.kind === 'app' &&
|
|
@@ -2330,11 +2330,14 @@ const PLATFORM_ARCHON_PROJECT = 'platform';
|
|
|
2330
2330
|
const PLATFORM_ARCHON_APP_NAME = 'platform-archon';
|
|
2331
2331
|
const PLATFORM_ARCHON_DB_NAME = 'platform-archon-db';
|
|
2332
2332
|
/**
|
|
2333
|
-
* The database
|
|
2334
|
-
*
|
|
2335
|
-
* `
|
|
2333
|
+
* The database Archon runs on. Live platform-archon moved to `archon_v2` on
|
|
2334
|
+
* the same server (2026-10-02, the move onto `@wtfalch/db`), while its Coolify
|
|
2335
|
+
* resource still names `archon` as its first database. So both URLs built
|
|
2336
|
+
* here set this database themselves, never `internal_db_url`'s path.
|
|
2337
|
+
* Archon's own migrations grant to `current_database() || '_rt'`, so
|
|
2338
|
+
* {@link ARCHON_RUNTIME_ROLE} follows.
|
|
2336
2339
|
*/
|
|
2337
|
-
const ARCHON_POSTGRES_DB = '
|
|
2340
|
+
const ARCHON_POSTGRES_DB = 'archon_v2';
|
|
2338
2341
|
const ARCHON_RUNTIME_ROLE = `${ARCHON_POSTGRES_DB}_rt`;
|
|
2339
2342
|
/** Fixed so the tunnel addresses the container directly (see `provisionPlatformAuth`). */
|
|
2340
2343
|
const PLATFORM_ARCHON_INTERNAL_NAME = 'platform-archon';
|
|
@@ -2668,11 +2671,12 @@ async function provisionPlatformArchon(context, deps) {
|
|
|
2668
2671
|
// The owner URL must name the adopted database's host, or the app's two
|
|
2669
2672
|
// database URLs would point at different databases.
|
|
2670
2673
|
const liveOwnerUrl = await currentEnvValue(deps.coolify, appId, 'DATABASE_URL_OWNER');
|
|
2674
|
+
const ownerUrl = archonDatabaseUrl(db.internal_db_url);
|
|
2671
2675
|
if (liveOwnerUrl) {
|
|
2672
2676
|
assertSameDatabaseHost('DATABASE_URL_OWNER', liveOwnerUrl, db.internal_db_url);
|
|
2673
2677
|
}
|
|
2674
2678
|
else {
|
|
2675
|
-
envs.push({ key: 'DATABASE_URL_OWNER', value:
|
|
2679
|
+
envs.push({ key: 'DATABASE_URL_OWNER', value: ownerUrl });
|
|
2676
2680
|
}
|
|
2677
2681
|
const liveReportingSite = await currentEnvValue(deps.coolify, appId, 'REPORTING_SITE');
|
|
2678
2682
|
if (!liveReportingSite) {
|
|
@@ -2692,7 +2696,7 @@ async function provisionPlatformArchon(context, deps) {
|
|
|
2692
2696
|
// reflects the env Archon actually ends up running with, not just this
|
|
2693
2697
|
// run's delta.
|
|
2694
2698
|
const archonEnvHashOverrides = new Map([
|
|
2695
|
-
['DATABASE_URL_OWNER', liveOwnerUrl ??
|
|
2699
|
+
['DATABASE_URL_OWNER', liveOwnerUrl ?? ownerUrl],
|
|
2696
2700
|
['REPORTING_SITE', liveReportingSite ?? 'archon'],
|
|
2697
2701
|
]);
|
|
2698
2702
|
const effectiveCookieSecret = cookieSecret ?? (await currentEnvValue(deps.coolify, appId, 'AUTH_COOKIE_SECRET'));
|
|
@@ -3625,10 +3629,9 @@ async function ensureArchonRuntimeUrl(coolify, appId, dbInternalUrl) {
|
|
|
3625
3629
|
return null;
|
|
3626
3630
|
}
|
|
3627
3631
|
let host;
|
|
3628
|
-
let pathname;
|
|
3629
3632
|
let search;
|
|
3630
3633
|
try {
|
|
3631
|
-
({ host,
|
|
3634
|
+
({ host, search } = new URL(dbInternalUrl));
|
|
3632
3635
|
}
|
|
3633
3636
|
catch {
|
|
3634
3637
|
// Node's `ERR_INVALID_URL` carries the whole input, credential included, so
|
|
@@ -3636,7 +3639,19 @@ async function ensureArchonRuntimeUrl(coolify, appId, dbInternalUrl) {
|
|
|
3636
3639
|
throw new Error('platform:archon: the database internal_db_url could not be parsed; not generating DATABASE_URL. Check the database in Coolify and apply again');
|
|
3637
3640
|
}
|
|
3638
3641
|
const password = randomBytes(24).toString('base64url');
|
|
3639
|
-
return `postgres://${ARCHON_RUNTIME_ROLE}:${password}@${host}
|
|
3642
|
+
return `postgres://${ARCHON_RUNTIME_ROLE}:${password}@${host}/${ARCHON_POSTGRES_DB}${search}`;
|
|
3643
|
+
}
|
|
3644
|
+
/** `internalUrl` on {@link ARCHON_POSTGRES_DB}. The error never carries the URL: it holds a credential. */
|
|
3645
|
+
function archonDatabaseUrl(internalUrl) {
|
|
3646
|
+
let url;
|
|
3647
|
+
try {
|
|
3648
|
+
url = new URL(internalUrl);
|
|
3649
|
+
}
|
|
3650
|
+
catch {
|
|
3651
|
+
throw new Error('platform:archon: the database internal_db_url could not be parsed; not generating DATABASE_URL_OWNER. Check the database in Coolify and apply again');
|
|
3652
|
+
}
|
|
3653
|
+
url.pathname = `/${ARCHON_POSTGRES_DB}`;
|
|
3654
|
+
return url.toString();
|
|
3640
3655
|
}
|
|
3641
3656
|
/**
|
|
3642
3657
|
* Thrown by {@link createAiManagementKeyMinter} (never by a test's own
|
|
@@ -3720,8 +3735,9 @@ export const PLATFORM_AI_MANAGEMENT_KEY_REF = 'ai:platform:managementKey';
|
|
|
3720
3735
|
* What Archon's management key may do in ai: exactly `ai`'s own
|
|
3721
3736
|
* `ARCHON_AI_PERMISSIONS` (`apps/host/scripts/authz/bootstrap-archon.ts`) --
|
|
3722
3737
|
* configuration only (offerings, provisioning, offers, budgets, usage, cost)
|
|
3723
|
-
* plus deleting (archiving) an organisation
|
|
3724
|
-
*
|
|
3738
|
+
* plus deleting (archiving) an organisation and setting another
|
|
3739
|
+
* organisation's webhook address and budget, never connections, runs or
|
|
3740
|
+
* webhook deliveries. ai's `/v1/platform/keys` route refuses any permission outside
|
|
3725
3741
|
* this same list, so a request for more than this is a 403, not a wider key.
|
|
3726
3742
|
*/
|
|
3727
3743
|
export const PLATFORM_AI_MANAGEMENT_PERMISSIONS = [
|
|
@@ -3732,6 +3748,8 @@ export const PLATFORM_AI_MANAGEMENT_PERMISSIONS = [
|
|
|
3732
3748
|
'ai.organisations:provision',
|
|
3733
3749
|
'ai.organisations:offer',
|
|
3734
3750
|
'ai.organisations:delete',
|
|
3751
|
+
'ai.organisations:webhook',
|
|
3752
|
+
'ai.organisations:budget',
|
|
3735
3753
|
'ai.keys:issue',
|
|
3736
3754
|
'ai.budgets:read',
|
|
3737
3755
|
'ai.budgets:update',
|
|
@@ -5105,74 +5123,74 @@ shareDomains) {
|
|
|
5105
5123
|
await record(PLATFORM_FILES_APPLIED_GIT_REF_SCOPE_KEY, gitRef);
|
|
5106
5124
|
return { resourceId: runnerApp.appId };
|
|
5107
5125
|
}
|
|
5108
|
-
/** https://github.com/wtfalch/
|
|
5109
|
-
const
|
|
5110
|
-
const
|
|
5111
|
-
const
|
|
5112
|
-
const
|
|
5126
|
+
/** 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). */
|
|
5127
|
+
const EDGE_REPO = 'wtfalch/edge';
|
|
5128
|
+
const PLATFORM_EDGE_PROJECT = 'platform';
|
|
5129
|
+
const PLATFORM_EDGE_DB_NAME = 'platform-edge-db';
|
|
5130
|
+
const EDGE_APP_NAME = 'edge';
|
|
5113
5131
|
/** The app's fixed `custom_internal_name`, the way `platform:files`' runner names itself. */
|
|
5114
|
-
const
|
|
5132
|
+
const EDGE_RUNNER_INTERNAL_NAME = 'platform-edge';
|
|
5115
5133
|
/** The runner's port (its Dockerfile: `PORT=3000`, `EXPOSE 3000`). */
|
|
5116
|
-
const
|
|
5134
|
+
const EDGE_PORT = '3000';
|
|
5117
5135
|
/** The Postgres database name, and the owner role's name per the host's own `.env.example`. */
|
|
5118
|
-
const
|
|
5119
|
-
const
|
|
5136
|
+
const EDGE_DB = 'names';
|
|
5137
|
+
const EDGE_OWNER_ROLE = 'names_owner';
|
|
5120
5138
|
/** The restricted runtime role `migrate.mjs`'s own `ensureRuntimeRole` creates or syncs at every boot. */
|
|
5121
|
-
const
|
|
5122
|
-
const
|
|
5123
|
-
/** #197: the one Coolify app that runs `cloudflared` for {@link
|
|
5124
|
-
const
|
|
5125
|
-
const
|
|
5126
|
-
const
|
|
5127
|
-
const
|
|
5128
|
-
const
|
|
5129
|
-
const
|
|
5139
|
+
const EDGE_RUNTIME_ROLE = 'names_rt';
|
|
5140
|
+
const EDGE_TUNNEL_NAME = 'platform-edge-tunnel';
|
|
5141
|
+
/** #197: the one Coolify app that runs `cloudflared` for {@link EDGE_TUNNEL_NAME}. */
|
|
5142
|
+
const EDGE_CLOUDFLARED_APP_NAME = 'platform-edge-cloudflared';
|
|
5143
|
+
const PLATFORM_EDGE_PROJECT_SCOPE_KEY = 'coolify_project_platform_edge';
|
|
5144
|
+
const PLATFORM_EDGE_DB_SCOPE_KEY = 'coolify_platform_edge_database';
|
|
5145
|
+
const PLATFORM_EDGE_APP_SCOPE_KEY = 'coolify_platform_edge_app';
|
|
5146
|
+
const PLATFORM_EDGE_TUNNEL_SCOPE_KEY = 'cloudflare_platform_edge_tunnel';
|
|
5147
|
+
const PLATFORM_EDGE_CONNECTOR_APP_SCOPE_KEY = 'coolify_platform_edge_connector_app';
|
|
5130
5148
|
/**
|
|
5131
5149
|
* Written only when absent, never overwritten -- the `PLATFORM_FILES_RUNNER_DEFAULTS`
|
|
5132
5150
|
* convention, so an operator's own cap or rate survives every apply. Values
|
|
5133
5151
|
* are the host's own `.env.example` defaults.
|
|
5134
5152
|
*/
|
|
5135
|
-
const
|
|
5153
|
+
const PLATFORM_EDGE_RUNNER_DEFAULTS = [
|
|
5136
5154
|
{ key: 'MAX_CLAIMS_PER_ORGANISATION', value: '100' },
|
|
5137
|
-
{ key: '
|
|
5138
|
-
{ key: '
|
|
5155
|
+
{ key: 'EDGE_RATE_BURST', value: '10' },
|
|
5156
|
+
{ key: 'EDGE_RATE_PER_SECOND', value: '0.5' },
|
|
5139
5157
|
];
|
|
5140
5158
|
/**
|
|
5141
|
-
* Why `coolify:app` keyed {@link
|
|
5142
|
-
* or `null` when it can -- the host built no
|
|
5159
|
+
* Why `coolify:app` keyed {@link PLATFORM_EDGE_KEY} cannot be planned yet,
|
|
5160
|
+
* or `null` when it can -- the host built no edge dependencies (checked by
|
|
5143
5161
|
* the caller, `FoundryPlatformStepExecutor.blockedSteps`, before this is
|
|
5144
5162
|
* even called), no Cloudflare API token held under
|
|
5145
|
-
* {@link
|
|
5163
|
+
* {@link PLATFORM_EDGE_CLOUDFLARE_TOKEN_REF} (CLAUDE.md's "Human steps"),
|
|
5146
5164
|
* or no Cloudflare account id supplied beside it.
|
|
5147
5165
|
*/
|
|
5148
|
-
async function
|
|
5166
|
+
async function edgeBlockedReason(names) {
|
|
5149
5167
|
let token;
|
|
5150
5168
|
try {
|
|
5151
|
-
token = (await names.heldKeys?.resolve(
|
|
5169
|
+
token = (await names.heldKeys?.resolve(PLATFORM_EDGE_CLOUDFLARE_TOKEN_REF)) ?? null;
|
|
5152
5170
|
}
|
|
5153
5171
|
catch {
|
|
5154
5172
|
token = null;
|
|
5155
5173
|
}
|
|
5156
5174
|
if (!token) {
|
|
5157
|
-
return `the
|
|
5175
|
+
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
5176
|
}
|
|
5159
5177
|
if (!names.cloudflareAccountId) {
|
|
5160
|
-
return 'the
|
|
5178
|
+
return 'the edge host is blocked, not planned: the host supplies no Cloudflare account id (PlatformEdgeDeps.cloudflareAccountId) for the token above';
|
|
5161
5179
|
}
|
|
5162
5180
|
return null;
|
|
5163
5181
|
}
|
|
5164
5182
|
/**
|
|
5165
|
-
* #478: makes `coolify:app` keyed {@link
|
|
5166
|
-
*
|
|
5167
|
-
* foundry's own platform key, reached at `platform.
|
|
5183
|
+
* #478: makes `coolify:app` keyed {@link PLATFORM_EDGE_KEY} true -- the
|
|
5184
|
+
* edge host (https://github.com/wtfalch/edge), its own database, and
|
|
5185
|
+
* foundry's own platform key, reached at `platform.edge.hostname` through
|
|
5168
5186
|
* a Cloudflare tunnel and DNS record (the public-service shape
|
|
5169
5187
|
* {@link provisionPlatformMail}/{@link provisionPlatformAuth} use, not
|
|
5170
|
-
* `platform:files`' internal-only one -- `
|
|
5188
|
+
* `platform:files`' internal-only one -- `EDGE_URL` has to be reachable by
|
|
5171
5189
|
* every org's own app, which runs outside this Coolify network, and by
|
|
5172
5190
|
* Archon).
|
|
5173
5191
|
*
|
|
5174
5192
|
* One Coolify app only, unlike `platform:files`/`platform:ai`'s separate
|
|
5175
|
-
* `migrate` app:
|
|
5193
|
+
* `migrate` app: edge' own Dockerfile dropped its `migration` target
|
|
5176
5194
|
* (its own comment: no generated package's Coolify app ever built or ran
|
|
5177
5195
|
* that stage, which once shipped `wtfalch/cms` with an empty database and
|
|
5178
5196
|
* no runtime role). `apps/host/scripts/start.sh` now runs `migrate.mjs`
|
|
@@ -5188,27 +5206,27 @@ async function namesBlockedReason(names) {
|
|
|
5188
5206
|
* restricted `names_rt` role is never created here -- `migrate.mjs`'s own
|
|
5189
5207
|
* `ensureRuntimeRole` call creates or syncs it and sets its password from
|
|
5190
5208
|
* `DATABASE_URL` at every boot, so this function only has to mint that
|
|
5191
|
-
* password once and reuse it (`
|
|
5209
|
+
* password once and reuse it (`ensureEdgeRuntimeUrl`, the
|
|
5192
5210
|
* `ensureFilesRuntimeUrl` shape) -- never rotated, since the role's
|
|
5193
5211
|
* password is set from it on every migration.
|
|
5194
5212
|
*
|
|
5195
5213
|
* The platform key follows `platform:files`' management-key discipline
|
|
5196
5214
|
* (CLAUDE.md rule 1): a random secret is minted once, its id recorded
|
|
5197
|
-
* undelivered and persisted before {@link
|
|
5215
|
+
* undelivered and persisted before {@link PlatformEdgeDeps.deliverSecret}
|
|
5198
5216
|
* is even tried, and the guard recorded only once delivery AND the env
|
|
5199
|
-
* write (the SHA-256 hash, as `
|
|
5217
|
+
* write (the SHA-256 hash, as `EDGE_PLATFORM_KEY_HASH`) both succeeded. The
|
|
5200
5218
|
* secret itself is never written to the host's own env, logged, returned
|
|
5201
5219
|
* or put in an error -- only its hash ever reaches names, and the full
|
|
5202
|
-
* value goes to {@link
|
|
5203
|
-
* {@link
|
|
5204
|
-
* back later when it mints or revokes an entry's own
|
|
5220
|
+
* value goes to {@link PlatformEdgeDeps.deliverSecret} under
|
|
5221
|
+
* {@link PLATFORM_EDGE_KEY_REF} alone, for Archon's own `EdgePort` to read
|
|
5222
|
+
* back later when it mints or revokes an entry's own edge key.
|
|
5205
5223
|
*
|
|
5206
5224
|
* `CLOUDFLARE_API_TOKEN`/`CLOUDFLARE_ACCOUNT_ID` on the runner are never
|
|
5207
|
-
* minted here either: they are the
|
|
5208
|
-
* Archon's), read back from {@link
|
|
5209
|
-
* {@link
|
|
5225
|
+
* minted here either: they are the edge service's own credential (never
|
|
5226
|
+
* Archon's), read back from {@link PlatformEdgeDeps.heldKeys} under
|
|
5227
|
+
* {@link PLATFORM_EDGE_CLOUDFLARE_TOKEN_REF} once a human has put one there
|
|
5210
5228
|
* (the issue's own "Human steps") -- `blockedSteps`
|
|
5211
|
-
* ({@link
|
|
5229
|
+
* ({@link edgeBlockedReason}) reports the step blocked, not planned, until
|
|
5212
5230
|
* then.
|
|
5213
5231
|
*
|
|
5214
5232
|
* Every part is safe to run again after a throw partway, `platform:files`'
|
|
@@ -5220,49 +5238,49 @@ async function namesBlockedReason(names) {
|
|
|
5220
5238
|
* app redeploys, with no new secret minted.
|
|
5221
5239
|
*
|
|
5222
5240
|
* Known gaps, left for review:
|
|
5223
|
-
* - `
|
|
5241
|
+
* - `EDGE_MAX_QUEUE_DEPTH` (the host's `.env.example`) is not set; the host
|
|
5224
5242
|
* falls back to its own default.
|
|
5225
|
-
* - `
|
|
5243
|
+
* - `EDGE_TRUST_CF_CONNECTING_IP` is always written `true` rather than
|
|
5226
5244
|
* left to the host's own default, since this step always puts the
|
|
5227
5245
|
* runner behind the Cloudflare tunnel it also creates.
|
|
5228
5246
|
*/
|
|
5229
|
-
async function
|
|
5247
|
+
async function provisionPlatformEdge(context, deps) {
|
|
5230
5248
|
const { platform } = context;
|
|
5231
|
-
const hostname = platform.
|
|
5232
|
-
const gitRef = platform.
|
|
5249
|
+
const hostname = platform.edge.hostname;
|
|
5250
|
+
const gitRef = platform.edge.gitRef;
|
|
5233
5251
|
const recorded = context.recordedIds();
|
|
5234
5252
|
const runtime = deps.runtime ?? {};
|
|
5235
5253
|
const sleep = runtime.sleep ?? defaultSleep;
|
|
5236
5254
|
const attempts = runtime.deliverAttempts ?? 3;
|
|
5237
|
-
const cloudflareToken = await deps.heldKeys?.resolve(
|
|
5255
|
+
const cloudflareToken = await deps.heldKeys?.resolve(PLATFORM_EDGE_CLOUDFLARE_TOKEN_REF);
|
|
5238
5256
|
if (!cloudflareToken || !deps.cloudflareAccountId) {
|
|
5239
|
-
throw new Error(`platform:
|
|
5257
|
+
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
5258
|
}
|
|
5241
5259
|
// Never plan-and-throw (CLAUDE.md rule 2): the planner reports a lost
|
|
5242
5260
|
// secret and plans nothing; this is the same rule for a caller that
|
|
5243
5261
|
// reaches the step anyway.
|
|
5244
|
-
const lostKey = recorded[
|
|
5245
|
-
if (lostKey && !recorded[
|
|
5246
|
-
throw new Error(`platform:
|
|
5262
|
+
const lostKey = recorded[PLATFORM_EDGE_PLATFORM_KEY_UNDELIVERED_SCOPE_KEY];
|
|
5263
|
+
if (lostKey && !recorded[PLATFORM_EDGE_PLATFORM_KEY_SET_SCOPE_KEY]) {
|
|
5264
|
+
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
5265
|
}
|
|
5248
5266
|
const record = async (key, id) => {
|
|
5249
5267
|
context.recordScopeId(key, id);
|
|
5250
5268
|
await context.persist?.();
|
|
5251
5269
|
};
|
|
5252
|
-
let projectUuid = recorded[
|
|
5270
|
+
let projectUuid = recorded[PLATFORM_EDGE_PROJECT_SCOPE_KEY];
|
|
5253
5271
|
if (!projectUuid) {
|
|
5254
|
-
const found = await deps.coolify.findProjectByName(
|
|
5272
|
+
const found = await deps.coolify.findProjectByName(PLATFORM_EDGE_PROJECT);
|
|
5255
5273
|
projectUuid = found
|
|
5256
5274
|
? found.uuid
|
|
5257
|
-
: (await deps.coolify.createProject(
|
|
5258
|
-
await record(
|
|
5275
|
+
: (await deps.coolify.createProject(PLATFORM_EDGE_PROJECT)).uuid;
|
|
5276
|
+
await record(PLATFORM_EDGE_PROJECT_SCOPE_KEY, projectUuid);
|
|
5259
5277
|
}
|
|
5260
5278
|
const { environment } = await deps.coolify.findOrCreateEnvironment(projectUuid, 'production');
|
|
5261
5279
|
const server = await resolvePlatformServer(deps.coolify);
|
|
5262
5280
|
// ---- database ----------------------------------------------------------
|
|
5263
|
-
let dbId = recorded[
|
|
5281
|
+
let dbId = recorded[PLATFORM_EDGE_DB_SCOPE_KEY];
|
|
5264
5282
|
if (!dbId) {
|
|
5265
|
-
const found = (await deps.coolify.listDatabases()).find((db) => db.name ===
|
|
5283
|
+
const found = (await deps.coolify.listDatabases()).find((db) => db.name === PLATFORM_EDGE_DB_NAME && db.environment_id === environment.id);
|
|
5266
5284
|
if (found) {
|
|
5267
5285
|
dbId = found.uuid;
|
|
5268
5286
|
}
|
|
@@ -5272,20 +5290,20 @@ async function provisionPlatformNames(context, deps) {
|
|
|
5272
5290
|
server_uuid: server.uuid,
|
|
5273
5291
|
environment_name: environment.name,
|
|
5274
5292
|
environment_uuid: environment.uuid,
|
|
5275
|
-
name:
|
|
5276
|
-
postgres_db:
|
|
5277
|
-
postgres_user:
|
|
5293
|
+
name: PLATFORM_EDGE_DB_NAME,
|
|
5294
|
+
postgres_db: EDGE_DB,
|
|
5295
|
+
postgres_user: EDGE_OWNER_ROLE,
|
|
5278
5296
|
});
|
|
5279
5297
|
dbId = created.uuid;
|
|
5280
5298
|
}
|
|
5281
|
-
await record(
|
|
5299
|
+
await record(PLATFORM_EDGE_DB_SCOPE_KEY, dbId);
|
|
5282
5300
|
}
|
|
5283
5301
|
const db = await getDatabaseStarted(deps.coolify, dbId);
|
|
5284
5302
|
if (!db.internal_db_url) {
|
|
5285
|
-
throw new Error('coolify:app for the platform (
|
|
5303
|
+
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
5304
|
}
|
|
5287
5305
|
// ---- the app -------------------------------------------------------------
|
|
5288
|
-
const app = await findOrCreatePlatformApp(deps.coolify,
|
|
5306
|
+
const app = await findOrCreatePlatformApp(deps.coolify, EDGE_APP_NAME, environment, async () => {
|
|
5289
5307
|
const githubApp = await deps.coolify.requireGithubApp();
|
|
5290
5308
|
return deps.coolify.createGithubAppApplication({
|
|
5291
5309
|
project_uuid: projectUuid,
|
|
@@ -5293,41 +5311,41 @@ async function provisionPlatformNames(context, deps) {
|
|
|
5293
5311
|
environment_name: environment.name,
|
|
5294
5312
|
environment_uuid: environment.uuid,
|
|
5295
5313
|
github_app_uuid: githubApp.uuid,
|
|
5296
|
-
git_repository:
|
|
5314
|
+
git_repository: EDGE_REPO,
|
|
5297
5315
|
git_branch: gitRef,
|
|
5298
5316
|
build_pack: 'dockerfile',
|
|
5299
|
-
name:
|
|
5317
|
+
name: EDGE_APP_NAME,
|
|
5300
5318
|
});
|
|
5301
5319
|
});
|
|
5302
5320
|
const appId = app.appId;
|
|
5303
|
-
await record(
|
|
5321
|
+
await record(PLATFORM_EDGE_APP_SCOPE_KEY, appId);
|
|
5304
5322
|
// Set on every run, not only after a create: a throw right after the
|
|
5305
5323
|
// create must not leave the app on the wrong target for good, and a
|
|
5306
5324
|
// changed `gitRef` has to reach an app made on an earlier one.
|
|
5307
5325
|
await deps.coolify.updateApplication(appId, {
|
|
5308
5326
|
dockerfile_target_build: 'runner',
|
|
5309
|
-
custom_internal_name:
|
|
5327
|
+
custom_internal_name: EDGE_RUNNER_INTERNAL_NAME,
|
|
5310
5328
|
git_branch: gitRef,
|
|
5311
5329
|
});
|
|
5312
|
-
const runtimeUrl = await
|
|
5330
|
+
const runtimeUrl = await ensureEdgeRuntimeUrl(deps.coolify, appId, db.internal_db_url);
|
|
5313
5331
|
// ---- the platform key, once ----------------------------------------------
|
|
5314
5332
|
// The recorded `set` id *is* the hash (never read back from Coolify's own
|
|
5315
5333
|
// env, which can be missing or stale on a re-apply): the hash is never
|
|
5316
5334
|
// secret, so it doubles as both the undelivered id and the value the base
|
|
5317
5335
|
// env below writes, with nothing extra to compute or a live read that
|
|
5318
5336
|
// could fall back to a blank value.
|
|
5319
|
-
let keyHash = recorded[
|
|
5337
|
+
let keyHash = recorded[PLATFORM_EDGE_PLATFORM_KEY_SET_SCOPE_KEY] ?? '';
|
|
5320
5338
|
if (!keyHash) {
|
|
5321
5339
|
const secret = randomBytes(32).toString('base64url');
|
|
5322
5340
|
keyHash = createHash('sha256').update(secret).digest('hex');
|
|
5323
|
-
await record(
|
|
5324
|
-
await deliverWithRetry(deps.deliverSecret,
|
|
5341
|
+
await record(PLATFORM_EDGE_PLATFORM_KEY_UNDELIVERED_SCOPE_KEY, keyHash);
|
|
5342
|
+
await deliverWithRetry(deps.deliverSecret, PLATFORM_EDGE_KEY_REF, secret, attempts, sleep, 'the edge platform key', 'platform:edge');
|
|
5325
5343
|
// Recorded as soon as delivery succeeds, before the env write below (the
|
|
5326
5344
|
// base env block, further down, is what actually writes the hash) --
|
|
5327
5345
|
// so a failed or crashed env write is simply retried on the next apply
|
|
5328
5346
|
// instead of leaving an undelivered secret with no `set` partner and no
|
|
5329
5347
|
// way to recover but a hand edit of the recorded ids.
|
|
5330
|
-
await record(
|
|
5348
|
+
await record(PLATFORM_EDGE_PLATFORM_KEY_SET_SCOPE_KEY, keyHash);
|
|
5331
5349
|
}
|
|
5332
5350
|
// ---- base env, written on every run --------------------------------------
|
|
5333
5351
|
await deps.coolify.bulkUpsertEnvs(appId, [
|
|
@@ -5336,18 +5354,18 @@ async function provisionPlatformNames(context, deps) {
|
|
|
5336
5354
|
{ key: 'APP_URL', value: `https://${hostname}` },
|
|
5337
5355
|
{ key: 'CLOUDFLARE_API_TOKEN', value: cloudflareToken },
|
|
5338
5356
|
{ key: 'CLOUDFLARE_ACCOUNT_ID', value: deps.cloudflareAccountId },
|
|
5339
|
-
{ key: '
|
|
5357
|
+
{ key: 'EDGE_TRUST_CF_CONNECTING_IP', value: 'true' },
|
|
5340
5358
|
{ key: 'NODE_ENV', value: 'production' },
|
|
5341
|
-
{ key: '
|
|
5342
|
-
...(await missingEnvs(deps.coolify, appId,
|
|
5359
|
+
{ key: 'EDGE_PLATFORM_KEY_HASH', value: keyHash },
|
|
5360
|
+
...(await missingEnvs(deps.coolify, appId, PLATFORM_EDGE_RUNNER_DEFAULTS)),
|
|
5343
5361
|
]);
|
|
5344
5362
|
// ---- tunnel and DNS -------------------------------------------------------
|
|
5345
|
-
const existingTunnel = await deps.cloudflare.findTunnelByName(
|
|
5346
|
-
const tunnel = existingTunnel ?? (await deps.cloudflare.createTunnel(
|
|
5347
|
-
await record(
|
|
5363
|
+
const existingTunnel = await deps.cloudflare.findTunnelByName(EDGE_TUNNEL_NAME);
|
|
5364
|
+
const tunnel = existingTunnel ?? (await deps.cloudflare.createTunnel(EDGE_TUNNEL_NAME));
|
|
5365
|
+
await record(PLATFORM_EDGE_TUNNEL_SCOPE_KEY, tunnel.id);
|
|
5348
5366
|
await deps.cloudflare.putTunnelConfig(tunnel.id, {
|
|
5349
5367
|
ingress: [
|
|
5350
|
-
{ hostname, service: `http://${
|
|
5368
|
+
{ hostname, service: `http://${EDGE_RUNNER_INTERNAL_NAME}:${EDGE_PORT}` },
|
|
5351
5369
|
{ service: 'http_status:404' },
|
|
5352
5370
|
],
|
|
5353
5371
|
});
|
|
@@ -5356,16 +5374,16 @@ async function provisionPlatformNames(context, deps) {
|
|
|
5356
5374
|
coolify: deps.coolify,
|
|
5357
5375
|
cloudflare: deps.cloudflare,
|
|
5358
5376
|
tunnelId: tunnel.id,
|
|
5359
|
-
appName:
|
|
5377
|
+
appName: EDGE_CLOUDFLARED_APP_NAME,
|
|
5360
5378
|
projectUuid,
|
|
5361
5379
|
environment,
|
|
5362
5380
|
resolveServer: async () => server,
|
|
5363
|
-
recordedConnectorAppId: recorded[
|
|
5364
|
-
recordAppId: (connectorAppId) => record(
|
|
5381
|
+
recordedConnectorAppId: recorded[PLATFORM_EDGE_CONNECTOR_APP_SCOPE_KEY] ?? null,
|
|
5382
|
+
recordAppId: (connectorAppId) => record(PLATFORM_EDGE_CONNECTOR_APP_SCOPE_KEY, connectorAppId),
|
|
5365
5383
|
});
|
|
5366
5384
|
const zoneId = recorded[apexZoneKey(platform.apex)] ?? (await exactZone(deps.cloudflare, platform.apex))?.id;
|
|
5367
5385
|
if (!zoneId) {
|
|
5368
|
-
throw new Error(`platform:
|
|
5386
|
+
throw new Error(`platform:edge: no Cloudflare zone for ${platform.apex} to put ${hostname} in`);
|
|
5369
5387
|
}
|
|
5370
5388
|
const dnsTarget = `${tunnel.id}.cfargotunnel.com`;
|
|
5371
5389
|
const [dnsRecord] = await deps.cloudflare.findDnsRecord(zoneId, hostname, 'CNAME');
|
|
@@ -5390,10 +5408,10 @@ async function provisionPlatformNames(context, deps) {
|
|
|
5390
5408
|
// helper (deployment-record check, then the `/api/health` probe) can run
|
|
5391
5409
|
// in one call -- unlike `platform:mail`, nothing here needs to happen
|
|
5392
5410
|
// between the two phases.
|
|
5393
|
-
await waitForNewDeployment(deps.coolify, appId, { kind: 'ready', url: `https://${hostname}/api/health` }, runtime, sleep, 'platform:
|
|
5411
|
+
await waitForNewDeployment(deps.coolify, appId, { kind: 'ready', url: `https://${hostname}/api/health` }, runtime, sleep, 'platform:edge', `edge stays at ${recorded[PLATFORM_EDGE_APPLIED_GIT_REF_SCOPE_KEY] ?? 'no recorded ref'}`);
|
|
5394
5412
|
// Last: a run that failed anywhere above is planned again.
|
|
5395
|
-
context.recordScopeId(
|
|
5396
|
-
await record(
|
|
5413
|
+
context.recordScopeId(PLATFORM_EDGE_APPLIED_HOSTNAME_SCOPE_KEY, hostname);
|
|
5414
|
+
await record(PLATFORM_EDGE_APPLIED_GIT_REF_SCOPE_KEY, gitRef);
|
|
5397
5415
|
return { resourceId: appId };
|
|
5398
5416
|
}
|
|
5399
5417
|
/**
|
|
@@ -5403,7 +5421,7 @@ async function provisionPlatformNames(context, deps) {
|
|
|
5403
5421
|
* throws a message with no part of the URL in it, since the URL carries the
|
|
5404
5422
|
* owner's password.
|
|
5405
5423
|
*/
|
|
5406
|
-
async function
|
|
5424
|
+
async function ensureEdgeRuntimeUrl(coolify, appId, dbInternalUrl) {
|
|
5407
5425
|
const existing = (await coolify.listEnvs(appId)).find((e) => e.key === 'DATABASE_URL' && !e.is_preview && e.value);
|
|
5408
5426
|
if (existing)
|
|
5409
5427
|
return existing.value;
|
|
@@ -5414,10 +5432,10 @@ async function ensureNamesRuntimeUrl(coolify, appId, dbInternalUrl) {
|
|
|
5414
5432
|
({ host, pathname, search } = new URL(dbInternalUrl));
|
|
5415
5433
|
}
|
|
5416
5434
|
catch {
|
|
5417
|
-
throw new Error("coolify:app for the platform (
|
|
5435
|
+
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
5436
|
}
|
|
5419
5437
|
const password = randomBytes(24).toString('base64url');
|
|
5420
|
-
return `postgres://${
|
|
5438
|
+
return `postgres://${EDGE_RUNTIME_ROLE}:${password}@${host}${pathname}${search}`;
|
|
5421
5439
|
}
|
|
5422
5440
|
function aiKeys(deps) {
|
|
5423
5441
|
const keys = deps?.keys;
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import type { CloudflareClient } from '@wtfalch/providers/cloudflare';
|
|
2
2
|
import type { CoolifyClient } from '@wtfalch/providers/coolify';
|
|
3
|
-
import type
|
|
3
|
+
import { type ZitadelClient } from '@wtfalch/providers/zitadel';
|
|
4
4
|
import type { OrgId } from '../org.js';
|
|
5
5
|
import type { DeclaredOrgStore } from '../store.js';
|
|
6
6
|
import type { CloudflareReader, CmsReader, CoolifyReader, LiveResource, ZitadelReader } from './live.js';
|
|
@@ -53,15 +53,17 @@ export type ScopeResolver = (orgId: OrgId) => Promise<string | null>;
|
|
|
53
53
|
* resource it creates, keyed `system:kind:key` ({@link storeRecordedIds}
|
|
54
54
|
* below reads those back), and #20's `FoundryStepExecutor` (`executor.ts`)
|
|
55
55
|
* now also calls `StepExecutionContext.recordScopeId` for `coolify_project`,
|
|
56
|
-
* `zitadel_org` and `
|
|
57
|
-
*
|
|
58
|
-
*
|
|
59
|
-
*
|
|
60
|
-
*
|
|
61
|
-
*
|
|
62
|
-
*
|
|
63
|
-
*
|
|
64
|
-
*
|
|
56
|
+
* `zitadel_org`, `zitadel_project` and (#510) `cloudflare_zone`, so a
|
|
57
|
+
* resolver built with one of those `serviceKey`s returns the real id once
|
|
58
|
+
* that step has run. Before #510, `cloudflareZone` recorded its result only
|
|
59
|
+
* under `cloudflare:zone:<domain>` (a resource id, read back by
|
|
60
|
+
* {@link storeRecordedIds}), never under the plain `cloudflare_zone` scope
|
|
61
|
+
* key — so a resolver built with that `serviceKey` always returned `null`,
|
|
62
|
+
* `ProvidersCloudflareReader.listResources` always returned `[]`, and
|
|
63
|
+
* `diff.ts`'s `planZone` re-proposed provisioning the zone on every apply
|
|
64
|
+
* even once it was live. A scope key that genuinely has nothing recorded yet
|
|
65
|
+
* still correctly resolves to `null`: a reader passed `null` returns `[]`,
|
|
66
|
+
* which `computePlanDetail` (`diff.ts`) already reads as "no live resource".
|
|
65
67
|
*/
|
|
66
68
|
export declare function storeScopeResolver(store: DeclaredOrgStore, serviceKey: string): ScopeResolver;
|
|
67
69
|
/**
|
|
@@ -112,7 +114,8 @@ export declare class ProvidersCloudflareReader implements CloudflareReader {
|
|
|
112
114
|
/**
|
|
113
115
|
* Backed by `@wtfalch/providers/zitadel`'s `ZitadelClient.listResources`: one
|
|
114
116
|
* resource per project and per project grant in a ZITADEL org (v1 scope —
|
|
115
|
-
* see that method's own doc comment)
|
|
117
|
+
* see that method's own doc comment), plus (#510) a synthesized `issuer`
|
|
118
|
+
* resource once the org's own ZITADEL org is confirmed live.
|
|
116
119
|
*/
|
|
117
120
|
export declare class ProvidersZitadelReader implements ZitadelReader {
|
|
118
121
|
private readonly client;
|
|
@@ -120,6 +123,26 @@ export declare class ProvidersZitadelReader implements ZitadelReader {
|
|
|
120
123
|
private readonly resolveRecordedIds;
|
|
121
124
|
constructor(client: ZitadelClient, resolveScope: ScopeResolver, resolveRecordedIds: RecordedIds);
|
|
122
125
|
listResources(orgId: OrgId): Promise<LiveResource[]>;
|
|
126
|
+
/**
|
|
127
|
+
* #510: `client.listResources` reports only what is *inside* a ZITADEL org
|
|
128
|
+
* (its projects and grants), never the org record itself, so nothing ever
|
|
129
|
+
* matched `diff.ts`'s `planIssuer`'s own `zitadel:issuer:<orgId>` lookup —
|
|
130
|
+
* every plan re-proposed "provision the org's own ZITADEL org" after a
|
|
131
|
+
* clean apply, even once `zitadel_org` was recorded and live. `orgId` here
|
|
132
|
+
* is foundry's own id ({@link resourceKey}'s `key` for an issuer, `diff.ts`'s
|
|
133
|
+
* `planIssuer`), not `zitadelOrgId` — the resource foundry tracks is "this
|
|
134
|
+
* org's issuer", and ZITADEL's own org id is an implementation detail of
|
|
135
|
+
* where it lives, the same distinction {@link applyRecordedIds} draws for
|
|
136
|
+
* every other recorded resource.
|
|
137
|
+
*
|
|
138
|
+
* `resolveScope` only reads what was last recorded; it never confirms the
|
|
139
|
+
* org still exists in ZITADEL. A 404 here means the recorded id no longer
|
|
140
|
+
* resolves (the org was removed out of band) and reads as "not live" — the
|
|
141
|
+
* same `[]` answer a never-recorded scope already gives. Any other failure
|
|
142
|
+
* propagates, same as every other call in this reader. An org ZITADEL still
|
|
143
|
+
* returns but marks `ORG_STATE_REMOVED` is not live either.
|
|
144
|
+
*/
|
|
145
|
+
private liveIssuer;
|
|
123
146
|
}
|
|
124
147
|
/**
|
|
125
148
|
* Backed by the same `@wtfalch/providers/coolify`'s `CoolifyClient.listResources`
|