@wtfalch/foundry 0.24.0 → 0.25.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/bootstrap/declare.js +4 -0
- package/dist/index.d.ts +1 -1
- package/dist/migrations/0049_platform_uptime.sql +11 -0
- package/dist/plan/edge-access.d.ts +14 -0
- package/dist/plan/edge-access.js +21 -1
- package/dist/plan/edge-workers.d.ts +16 -1
- package/dist/plan/edge-workers.js +8 -0
- package/dist/plan/executor.d.ts +14 -1
- package/dist/plan/index.d.ts +2 -1
- package/dist/plan/plan-apply.d.ts +4 -0
- package/dist/plan/platform-diff.d.ts +88 -0
- package/dist/plan/platform-diff.js +210 -0
- package/dist/plan/platform-providers.d.ts +23 -0
- package/dist/plan/platform-providers.js +162 -13
- package/dist/plan/uptime-worker.d.ts +50 -0
- package/dist/plan/uptime-worker.js +229 -0
- package/dist/platform.d.ts +18 -0
- package/dist/platform.js +14 -0
- package/dist/store/postgres.js +4 -0
- package/dist/store/schema.d.ts +51 -0
- package/dist/store/schema.js +4 -0
- package/package.json +2 -2
|
@@ -9,8 +9,9 @@ import { ACCESS_POLICY_NAME, getDatabaseStarted, isNotFoundError, isRunningOrSta
|
|
|
9
9
|
import { ACCESS_PROVIDER_RECORD_KEY, GATE_CLAIM_NAME } from './gates.js';
|
|
10
10
|
import { healthCheckSettings } from './health-check-settings.js';
|
|
11
11
|
import { coolifyDomains, coolifyTunnelDomains, filesShareHostname } from './hostnames.js';
|
|
12
|
-
import { AUTH_ZITADEL_RUN_OPTIONS, JOBS_DEPLOY_PENDING_SCOPE_KEY, JOBS_INTERNAL_URL_SCOPE_KEY, JOBS_SCHEDULER_KEY, PLATFORM_ACCESS_PROVIDER_APPLIED_AUTH_DOMAIN_SCOPE_KEY, PLATFORM_ACCESS_PROVIDER_APPLIED_HOSTNAME_SCOPE_KEY, PLATFORM_ACCESS_PROVIDER_UNDELIVERED_SCOPE_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_DEPLOYMENT_SCOPE_KEY, PLATFORM_ARCHON_ENVS_DEPLOYED_SCOPE_KEY, PLATFORM_ARCHON_GATE_CLOSED_SCOPE_KEY, PLATFORM_ARCHON_INTEGRATIONS_KEY_SYNCED_SCOPE_KEY, PLATFORM_ARCHON_KEY, PLATFORM_ARCHON_MASTER_CLOUDFLARE_TOKEN_REF, PLATFORM_ARCHON_MASTER_COOLIFY_TOKEN_REF, PLATFORM_ARCHON_MASTER_COOLIFY_URL_REF, PLATFORM_AUTH_ADMIN_UNDELIVERED_SCOPE_KEY, PLATFORM_AUTH_ADMIN_USER_SCOPE_KEY, PLATFORM_AUTH_BROKER_DEPLOYED_SCOPE_KEY, PLATFORM_AUTH_BROKER_SCOPE_KEY, PLATFORM_AUTH_CONNECTOR_APP_SCOPE_KEY, PLATFORM_AUTH_INSTANCE_SCOPE_KEY, PLATFORM_AUTH_INSTANCE_UNDELIVERED_SCOPE_KEY, PLATFORM_AUTH_KEY, PLATFORM_AUTH_LOGIN_CLIENT_UNDELIVERED_SCOPE_KEY, PLATFORM_AUTH_LOGIN_CLIENT_USER_SCOPE_KEY, PLATFORM_AUTH_OWNER_INVITE_SCOPE_KEY, PLATFORM_AUTH_SMTP_SCOPE_KEY, PLATFORM_AUTH_ZITADEL_APPLIED_IMAGE_TAG_SCOPE_KEY, PLATFORM_AUTH_ZITADEL_RUN_OPTIONS_SCOPE_KEY, PLATFORM_BACKUP_DATABASE_SCOPE_KEYS, PLATFORM_BACKUP_SERVICE_KEYS, PLATFORM_EDGE_APPLIED_GIT_REF_SCOPE_KEY, PLATFORM_EDGE_APPLIED_HOSTNAME_SCOPE_KEY, PLATFORM_EDGE_CLOUDFLARE_TOKEN_REF, PLATFORM_EDGE_INTERNAL_URL_SCOPE_KEY, PLATFORM_EDGE_KEY, PLATFORM_EDGE_KEY_REF, PLATFORM_EDGE_PLATFORM_KEY_SET_SCOPE_KEY, PLATFORM_EDGE_PLATFORM_KEY_UNDELIVERED_SCOPE_KEY, PLATFORM_FILES_APPLIED_GIT_REF_SCOPE_KEY, PLATFORM_FILES_APPLIED_HOSTNAME_SCOPE_KEY, PLATFORM_FILES_INTERNAL_URL_SCOPE_KEY, PLATFORM_FILES_INTROSPECTION_SCOPE_KEY, PLATFORM_FILES_INTROSPECTION_UNDELIVERED_SCOPE_KEY, PLATFORM_FILES_KEY, PLATFORM_FILES_MANAGEMENT_KEY_SCOPE_KEY, PLATFORM_FILES_MANAGEMENT_UNDELIVERED_SCOPE_KEY, PLATFORM_FILES_ZITADEL_PROJECT_SCOPE_KEY, PLATFORM_HC_API_KEY_REF, PLATFORM_HC_APPLIED_GIT_REF_SCOPE_KEY, PLATFORM_HC_APPLIED_HOSTNAME_SCOPE_KEY, PLATFORM_HC_KEY, PLATFORM_HEARTBEATS_APPLIED_SCOPE_KEY, PLATFORM_INTEGRATIONS_APPLIED_GITREF_SCOPE_KEY, PLATFORM_INTEGRATIONS_APPLIED_HOSTNAME_SCOPE_KEY, PLATFORM_INTEGRATIONS_KEY, PLATFORM_INTEGRATIONS_KEY_ID_SCOPE_KEY, PLATFORM_INTEGRATIONS_KEY_ROTATION_PREVIOUS_SCOPE_KEY, PLATFORM_INTEGRATIONS_KEY_ROTATION_REQUESTED_SCOPE_KEY, PLATFORM_INTEGRATIONS_KEY_ROTATION_UNDELIVERED_SCOPE_KEY, PLATFORM_INTEGRATIONS_KEY_SET_SCOPE_KEY, PLATFORM_INTEGRATIONS_KEY_UNDELIVERED_SCOPE_KEY, PLATFORM_INTEGRATIONS_RUNNER_KEYRING_DEPLOYED_SCOPE_KEY, PLATFORM_MAIL_ADMIN_KEY_SCOPE_KEY, PLATFORM_MAIL_ADMIN_UNDELIVERED_SCOPE_KEY, PLATFORM_MAIL_APPLIED_GIT_REF_SCOPE_KEY, PLATFORM_MAIL_APPLIED_HOSTNAME_SCOPE_KEY, PLATFORM_MAIL_CONNECTOR_APP_SCOPE_KEY, PLATFORM_MAIL_INTERNAL_URL_SCOPE_KEY, PLATFORM_MAIL_KEY, PLATFORM_MAIL_ZITADEL_PROJECT_SCOPE_KEY, PLATFORM_UPTIME_HEARTBEAT_CHECK_NAME, archonBootstrapOperator, desiredHeartbeatCheckNames, heartbeatCheckUrlRef, parseBrokerDeployRecord, parseLoginClientRecord, platformBackupHeartbeatCheckName, platformMailMxHost, } from './platform-diff.js';
|
|
12
|
+
import { AUTH_ZITADEL_RUN_OPTIONS, JOBS_DEPLOY_PENDING_SCOPE_KEY, JOBS_INTERNAL_URL_SCOPE_KEY, JOBS_SCHEDULER_KEY, PLATFORM_ACCESS_PROVIDER_APPLIED_AUTH_DOMAIN_SCOPE_KEY, PLATFORM_ACCESS_PROVIDER_APPLIED_HOSTNAME_SCOPE_KEY, PLATFORM_ACCESS_PROVIDER_UNDELIVERED_SCOPE_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_DEPLOYMENT_SCOPE_KEY, 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_ARCHON_UPTIME_TOKEN_SYNCED_SCOPE_KEY, PLATFORM_AUTH_ADMIN_UNDELIVERED_SCOPE_KEY, PLATFORM_AUTH_ADMIN_USER_SCOPE_KEY, PLATFORM_AUTH_BROKER_DEPLOYED_SCOPE_KEY, PLATFORM_AUTH_BROKER_SCOPE_KEY, PLATFORM_AUTH_CONNECTOR_APP_SCOPE_KEY, PLATFORM_AUTH_INSTANCE_SCOPE_KEY, PLATFORM_AUTH_INSTANCE_UNDELIVERED_SCOPE_KEY, PLATFORM_AUTH_KEY, PLATFORM_AUTH_LOGIN_CLIENT_UNDELIVERED_SCOPE_KEY, PLATFORM_AUTH_LOGIN_CLIENT_USER_SCOPE_KEY, PLATFORM_AUTH_OWNER_INVITE_SCOPE_KEY, PLATFORM_AUTH_SMTP_SCOPE_KEY, PLATFORM_AUTH_ZITADEL_APPLIED_IMAGE_TAG_SCOPE_KEY, PLATFORM_AUTH_ZITADEL_RUN_OPTIONS_SCOPE_KEY, PLATFORM_BACKUP_DATABASE_SCOPE_KEYS, PLATFORM_BACKUP_SERVICE_KEYS, PLATFORM_EDGE_APPLIED_GIT_REF_SCOPE_KEY, PLATFORM_EDGE_APPLIED_HOSTNAME_SCOPE_KEY, PLATFORM_EDGE_CLOUDFLARE_TOKEN_REF, PLATFORM_EDGE_INTERNAL_URL_SCOPE_KEY, PLATFORM_EDGE_KEY, PLATFORM_EDGE_KEY_REF, PLATFORM_EDGE_PLATFORM_KEY_SET_SCOPE_KEY, PLATFORM_EDGE_PLATFORM_KEY_UNDELIVERED_SCOPE_KEY, PLATFORM_FILES_APPLIED_GIT_REF_SCOPE_KEY, PLATFORM_FILES_APPLIED_HOSTNAME_SCOPE_KEY, PLATFORM_FILES_INTERNAL_URL_SCOPE_KEY, PLATFORM_FILES_INTROSPECTION_SCOPE_KEY, PLATFORM_FILES_INTROSPECTION_UNDELIVERED_SCOPE_KEY, PLATFORM_FILES_KEY, PLATFORM_FILES_MANAGEMENT_KEY_SCOPE_KEY, PLATFORM_FILES_MANAGEMENT_UNDELIVERED_SCOPE_KEY, PLATFORM_FILES_ZITADEL_PROJECT_SCOPE_KEY, PLATFORM_HC_API_KEY_REF, PLATFORM_HC_APPLIED_GIT_REF_SCOPE_KEY, PLATFORM_HC_APPLIED_HOSTNAME_SCOPE_KEY, PLATFORM_HC_KEY, PLATFORM_HEARTBEATS_APPLIED_SCOPE_KEY, PLATFORM_INTEGRATIONS_APPLIED_GITREF_SCOPE_KEY, PLATFORM_INTEGRATIONS_APPLIED_HOSTNAME_SCOPE_KEY, PLATFORM_INTEGRATIONS_KEY, PLATFORM_INTEGRATIONS_KEY_ID_SCOPE_KEY, PLATFORM_INTEGRATIONS_KEY_ROTATION_PREVIOUS_SCOPE_KEY, PLATFORM_INTEGRATIONS_KEY_ROTATION_REQUESTED_SCOPE_KEY, PLATFORM_INTEGRATIONS_KEY_ROTATION_UNDELIVERED_SCOPE_KEY, PLATFORM_INTEGRATIONS_KEY_SET_SCOPE_KEY, PLATFORM_INTEGRATIONS_KEY_UNDELIVERED_SCOPE_KEY, PLATFORM_INTEGRATIONS_RUNNER_KEYRING_DEPLOYED_SCOPE_KEY, PLATFORM_MAIL_ADMIN_KEY_SCOPE_KEY, PLATFORM_MAIL_ADMIN_UNDELIVERED_SCOPE_KEY, PLATFORM_MAIL_APPLIED_GIT_REF_SCOPE_KEY, PLATFORM_MAIL_APPLIED_HOSTNAME_SCOPE_KEY, PLATFORM_MAIL_CONNECTOR_APP_SCOPE_KEY, PLATFORM_MAIL_INTERNAL_URL_SCOPE_KEY, PLATFORM_MAIL_KEY, PLATFORM_MAIL_ZITADEL_PROJECT_SCOPE_KEY, PLATFORM_UPTIME_ACCESS_CLIENT_ID_REF, PLATFORM_UPTIME_ACCESS_CLIENT_SECRET_REF, PLATFORM_UPTIME_ACCESS_TOKEN_ID_SCOPE_KEY, PLATFORM_UPTIME_ACCESS_TOKEN_NAME, PLATFORM_UPTIME_ACCESS_UNDELIVERED_SCOPE_KEY, PLATFORM_UPTIME_HEARTBEAT_CHECK_NAME, PLATFORM_UPTIME_KEY, PLATFORM_UPTIME_REMOVED_SCOPE_KEY, PLATFORM_UPTIME_SCRIPT_NAME, PLATFORM_UPTIME_TOKEN_ID_SCOPE_KEY, PLATFORM_UPTIME_TOKEN_REF, archonBootstrapOperator, desiredHeartbeatCheckNames, heartbeatCheckUrlRef, parseBrokerDeployRecord, parseLoginClientRecord, platformBackupHeartbeatCheckName, platformMailMxHost, uptimeAccessTokenConflict, uptimeRecorded, uptimeVarsDigest, } from './platform-diff.js';
|
|
13
13
|
import { applyCallsSfuPlatformStep } from './platform-sfu.js';
|
|
14
|
+
import { heldUptimeValues, provisionPlatformUptime, } from './uptime-worker.js';
|
|
14
15
|
import { CreateInstanceError, createZitadelInstance, generateSystemApiKeypair, instanceWithoutBearerMessage, systemApiUsersEnv, } from './zitadel-system-api.js';
|
|
15
16
|
/** The key `runPlatformSteps` records the apex zone's id under (`system:kind:key`). */
|
|
16
17
|
function apexZoneKey(apex) {
|
|
@@ -156,6 +157,7 @@ export class FoundryPlatformStepExecutor {
|
|
|
156
157
|
backup;
|
|
157
158
|
hc;
|
|
158
159
|
heartbeats;
|
|
160
|
+
uptime;
|
|
159
161
|
accessProvider;
|
|
160
162
|
constructor(cloudflare, declaredOrgStore, options = {}) {
|
|
161
163
|
this.cloudflare = cloudflare;
|
|
@@ -174,6 +176,7 @@ export class FoundryPlatformStepExecutor {
|
|
|
174
176
|
this.backup = options.backup;
|
|
175
177
|
this.hc = options.hc;
|
|
176
178
|
this.heartbeats = options.heartbeats;
|
|
179
|
+
this.uptime = options.uptime;
|
|
177
180
|
this.accessProvider = options.accessProvider;
|
|
178
181
|
}
|
|
179
182
|
async blockedSteps(platform, recordedIds = {}) {
|
|
@@ -326,6 +329,7 @@ export class FoundryPlatformStepExecutor {
|
|
|
326
329
|
if (!apiKey)
|
|
327
330
|
blocked.heartbeatsApiKeyMissing = true;
|
|
328
331
|
}
|
|
332
|
+
await this.uptimeBlocked(platform, recordedIds, blocked);
|
|
329
333
|
// #604: only once edge and sign-in are applied (the planner reports
|
|
330
334
|
// that case itself) and no provider is recorded yet.
|
|
331
335
|
if (platform.edge.enabled &&
|
|
@@ -360,6 +364,69 @@ export class FoundryPlatformStepExecutor {
|
|
|
360
364
|
}
|
|
361
365
|
return blocked;
|
|
362
366
|
}
|
|
367
|
+
/**
|
|
368
|
+
* foundry#542: why the uptime Worker cannot run yet, which optional secrets
|
|
369
|
+
* are held, and whether the minted values are held. A Worker token or Access
|
|
370
|
+
* pair that is gone is not a block here: the step repairs it. The planner
|
|
371
|
+
* checks the pin and Archon first, so none of the reads here happens before
|
|
372
|
+
* they hold.
|
|
373
|
+
*/
|
|
374
|
+
async uptimeBlocked(platform, recordedIds, blocked) {
|
|
375
|
+
const deployed = Boolean(recordedIds[`cloudflare:worker:${PLATFORM_UPTIME_KEY}`]) &&
|
|
376
|
+
!recordedIds[PLATFORM_UPTIME_REMOVED_SCOPE_KEY];
|
|
377
|
+
if (!platform.uptime.enabled && !uptimeRecorded(recordedIds))
|
|
378
|
+
return;
|
|
379
|
+
const deps = this.uptime;
|
|
380
|
+
if (!deps) {
|
|
381
|
+
blocked.uptime =
|
|
382
|
+
'the uptime Worker is blocked, not planned: the host built the platform executor without its uptime dependencies';
|
|
383
|
+
return;
|
|
384
|
+
}
|
|
385
|
+
if (!platform.uptime.enabled)
|
|
386
|
+
return;
|
|
387
|
+
blocked.uptimeSecretsHeld = Object.keys(await heldUptimeValues(deps));
|
|
388
|
+
const accessTokenId = recordedIds[PLATFORM_UPTIME_ACCESS_TOKEN_ID_SCOPE_KEY];
|
|
389
|
+
const accessHeld = Boolean((await deps.heldKeys.resolve(PLATFORM_UPTIME_ACCESS_CLIENT_ID_REF)) &&
|
|
390
|
+
(await deps.heldKeys.resolve(PLATFORM_UPTIME_ACCESS_CLIENT_SECRET_REF)));
|
|
391
|
+
blocked.uptimeHeld = {
|
|
392
|
+
token: Boolean(await deps.heldKeys.resolve(PLATFORM_UPTIME_TOKEN_REF)),
|
|
393
|
+
access: accessHeld,
|
|
394
|
+
vars: uptimeVarsDigest(deps.vars),
|
|
395
|
+
};
|
|
396
|
+
const edgeUrl = recordedIds[PLATFORM_EDGE_INTERNAL_URL_SCOPE_KEY];
|
|
397
|
+
// A token of the name with no id recorded cannot be minted over, and
|
|
398
|
+
// foundry does not remove what it did not make. With an id recorded the
|
|
399
|
+
// step removes that token through edge and mints again.
|
|
400
|
+
if (!accessTokenId &&
|
|
401
|
+
!recordedIds[PLATFORM_UPTIME_ACCESS_UNDELIVERED_SCOPE_KEY] &&
|
|
402
|
+
edgeUrl &&
|
|
403
|
+
platform.uptime.tag) {
|
|
404
|
+
try {
|
|
405
|
+
const access = await deps.access(edgeUrl);
|
|
406
|
+
if (await access.findServiceTokenByName(PLATFORM_UPTIME_ACCESS_TOKEN_NAME)) {
|
|
407
|
+
blocked.uptime = uptimeAccessTokenConflict();
|
|
408
|
+
return;
|
|
409
|
+
}
|
|
410
|
+
}
|
|
411
|
+
catch {
|
|
412
|
+
blocked.uptime =
|
|
413
|
+
'the uptime Worker is blocked, not planned: edge could not be asked whether the Access service token exists';
|
|
414
|
+
return;
|
|
415
|
+
}
|
|
416
|
+
}
|
|
417
|
+
if (!deployed && edgeUrl && platform.uptime.tag) {
|
|
418
|
+
try {
|
|
419
|
+
const workers = await deps.workers(edgeUrl);
|
|
420
|
+
if (await workers.scriptExists(PLATFORM_UPTIME_SCRIPT_NAME)) {
|
|
421
|
+
blocked.uptime = `the uptime Worker is blocked, not planned: a Worker named ${PLATFORM_UPTIME_SCRIPT_NAME} already exists at Cloudflare and foundry did not make it; remove it by hand`;
|
|
422
|
+
}
|
|
423
|
+
}
|
|
424
|
+
catch {
|
|
425
|
+
blocked.uptime =
|
|
426
|
+
'the uptime Worker is blocked, not planned: edge could not be asked whether the Worker exists';
|
|
427
|
+
}
|
|
428
|
+
}
|
|
429
|
+
}
|
|
363
430
|
/**
|
|
364
431
|
* Whether Archon's live Access policy lets through anyone but the declared
|
|
365
432
|
* emails, or (#211 round 4) the broker it last registered with no longer
|
|
@@ -372,7 +439,9 @@ export class FoundryPlatformStepExecutor {
|
|
|
372
439
|
if (platform.archon.enabled &&
|
|
373
440
|
this.archon &&
|
|
374
441
|
recordedIds[`coolify:app:${PLATFORM_ARCHON_KEY}`]) {
|
|
375
|
-
const accessDrifted = await archonAccessDrifted(this.archon, recordedIds, platform.archon.hostname, this.archon.accessEmails
|
|
442
|
+
const accessDrifted = await archonAccessDrifted(this.archon, recordedIds, platform.archon.hostname, this.archon.accessEmails, platform.uptime.enabled
|
|
443
|
+
? recordedIds[PLATFORM_UPTIME_ACCESS_TOKEN_ID_SCOPE_KEY]
|
|
444
|
+
: undefined);
|
|
376
445
|
const brokerDrifted = !accessDrifted && (await archonBrokerRegistrationDrifted(this.archon.coolify, recordedIds));
|
|
377
446
|
const bootstrapOperatorDrifted = !accessDrifted &&
|
|
378
447
|
!brokerDrifted &&
|
|
@@ -525,6 +594,14 @@ export class FoundryPlatformStepExecutor {
|
|
|
525
594
|
}
|
|
526
595
|
return provisionPlatformHeartbeats(context, this.heartbeats);
|
|
527
596
|
}
|
|
597
|
+
if (action.system === 'cloudflare' &&
|
|
598
|
+
action.kind === 'worker' &&
|
|
599
|
+
action.key === PLATFORM_UPTIME_KEY) {
|
|
600
|
+
if (!this.uptime) {
|
|
601
|
+
throw new Error('platform:uptime needs FoundryPlatformStepExecutor to be constructed with its uptime dependencies (foundry#542)');
|
|
602
|
+
}
|
|
603
|
+
return provisionPlatformUptime(context, this.uptime);
|
|
604
|
+
}
|
|
528
605
|
if (action.system === 'cloudflare' && action.kind === 'zone') {
|
|
529
606
|
return this.cloudflareZone(action, context);
|
|
530
607
|
}
|
|
@@ -2944,6 +3021,15 @@ async function provisionPlatformArchon(context, deps) {
|
|
|
2944
3021
|
envs.push({ key: 'COOLIFY_URL', value: coolifyUrl }, { key: 'COOLIFY_READ_TOKEN', value: coolifyReadToken });
|
|
2945
3022
|
}
|
|
2946
3023
|
}
|
|
3024
|
+
// foundry#542: the token the uptime Worker sends, once the Worker step has
|
|
3025
|
+
// minted and delivered it. Absent until then; that step's guard makes
|
|
3026
|
+
// `planArchon` plan this one again.
|
|
3027
|
+
const uptimeTokenId = platform.uptime.enabled
|
|
3028
|
+
? recorded[PLATFORM_UPTIME_TOKEN_ID_SCOPE_KEY]
|
|
3029
|
+
: undefined;
|
|
3030
|
+
const uptimeToken = uptimeTokenId ? await deps.heldKeys.resolve(PLATFORM_UPTIME_TOKEN_REF) : null;
|
|
3031
|
+
if (uptimeToken)
|
|
3032
|
+
envs.push({ key: 'UPTIME_TOKEN', value: uptimeToken });
|
|
2947
3033
|
const changed = await envsDiffer(deps.coolify, appId, envs);
|
|
2948
3034
|
// #467: this run's intended env set, hashed before anything below writes
|
|
2949
3035
|
// it -- compared against {@link PLATFORM_ARCHON_ENVS_DEPLOYED_SCOPE_KEY}
|
|
@@ -2972,7 +3058,9 @@ async function provisionPlatformArchon(context, deps) {
|
|
|
2972
3058
|
// what either hash names (#467).
|
|
2973
3059
|
let staleKeyDeleted = false;
|
|
2974
3060
|
for (const stale of await deps.coolify.listEnvs(appId)) {
|
|
2975
|
-
|
|
3061
|
+
// foundry#542: a disabled Worker's token leaves Archon's env too.
|
|
3062
|
+
const staleUptime = !platform.uptime.enabled && stale.key === 'UPTIME_TOKEN';
|
|
3063
|
+
if ((stale.key === 'FILES_MANAGEMENT_KEY' || staleUptime) && stale.uuid) {
|
|
2976
3064
|
await deps.coolify.deleteEnv(appId, stale.uuid);
|
|
2977
3065
|
staleKeyDeleted = true;
|
|
2978
3066
|
}
|
|
@@ -3064,7 +3152,10 @@ async function provisionPlatformArchon(context, deps) {
|
|
|
3064
3152
|
const tunnels = await platformTunnels(deps, context.recordedIds(), 'platform:archon');
|
|
3065
3153
|
const access = await platformAccess(deps, context.recordedIds(), 'platform:archon');
|
|
3066
3154
|
let gateClosed = Boolean(recorded[PLATFORM_ARCHON_GATE_CLOSED_SCOPE_KEY]);
|
|
3067
|
-
const
|
|
3155
|
+
const uptimeAccessTokenId = platform.uptime.enabled
|
|
3156
|
+
? recorded[PLATFORM_UPTIME_ACCESS_TOKEN_ID_SCOPE_KEY]
|
|
3157
|
+
: undefined;
|
|
3158
|
+
const accessAppId = await ensureArchonAccess(access, hostname, deps.accessEmails, uptimeAccessTokenId, async () => {
|
|
3068
3159
|
// The Access app is about to be rebuilt: answer 404 for every hostname
|
|
3069
3160
|
// meanwhile, so it is never served without the gate. The marker is
|
|
3070
3161
|
// persisted first: a failure before the real ingress is written back
|
|
@@ -3206,6 +3297,18 @@ async function provisionPlatformArchon(context, deps) {
|
|
|
3206
3297
|
if (keyUnsynced && integrationsKeyId && (!selfRedeploy || deployState === 'finished')) {
|
|
3207
3298
|
await record(PLATFORM_ARCHON_INTEGRATIONS_KEY_SYNCED_SCOPE_KEY, integrationsKeyId);
|
|
3208
3299
|
}
|
|
3300
|
+
// foundry#542: the same confirmation for the uptime token, and for its removal.
|
|
3301
|
+
if (!platform.uptime.enabled &&
|
|
3302
|
+
recorded[PLATFORM_ARCHON_UPTIME_TOKEN_SYNCED_SCOPE_KEY] &&
|
|
3303
|
+
(!selfRedeploy || deployState === 'finished')) {
|
|
3304
|
+
await record(PLATFORM_ARCHON_UPTIME_TOKEN_SYNCED_SCOPE_KEY, '');
|
|
3305
|
+
}
|
|
3306
|
+
if (uptimeToken &&
|
|
3307
|
+
uptimeTokenId &&
|
|
3308
|
+
recorded[PLATFORM_ARCHON_UPTIME_TOKEN_SYNCED_SCOPE_KEY] !== uptimeTokenId &&
|
|
3309
|
+
(!selfRedeploy || deployState === 'finished')) {
|
|
3310
|
+
await record(PLATFORM_ARCHON_UPTIME_TOKEN_SYNCED_SCOPE_KEY, uptimeTokenId);
|
|
3311
|
+
}
|
|
3209
3312
|
// #475: confirmed only now, after the health check above has passed on a
|
|
3210
3313
|
// container that was deployed with the alias patch in place -- the same
|
|
3211
3314
|
// confirm-after-health-check timing as the two records just above.
|
|
@@ -3525,14 +3628,18 @@ async function putArchonMasterKeys(heldKeys, masterKeys) {
|
|
|
3525
3628
|
await heldKeys.put?.(ref, value);
|
|
3526
3629
|
}
|
|
3527
3630
|
}
|
|
3631
|
+
/** The policy that lets the uptime Worker's Access service token through Archon's app, beside {@link ACCESS_POLICY_NAME}. */
|
|
3632
|
+
const PLATFORM_ARCHON_UPTIME_POLICY_NAME = 'foundry: uptime service token';
|
|
3528
3633
|
/**
|
|
3529
3634
|
* `executor.ts`'s `cloudflareAccess`, for the platform's Archon: reuses only
|
|
3530
3635
|
* an Access app named {@link PLATFORM_ARCHON_ACCESS_APP_NAME}, refuses to
|
|
3531
3636
|
* adopt any other Access app already holding the hostname, and refuses when
|
|
3532
3637
|
* its own app carries a policy foundry did not create, since that policy
|
|
3533
|
-
* could let others in.
|
|
3638
|
+
* could let others in. foundry#542: with `uptimeTokenId` the app also carries
|
|
3639
|
+
* a `non_identity` policy for the uptime Worker's service token; a missing one
|
|
3640
|
+
* is added in place, one for another token rebuilds the app.
|
|
3534
3641
|
*/
|
|
3535
|
-
async function ensureArchonAccess(cf, domain, emails, closeGate) {
|
|
3642
|
+
async function ensureArchonAccess(cf, domain, emails, uptimeTokenId, closeGate) {
|
|
3536
3643
|
const onDomain = (await cf.listAccessApps()).filter((a) => a.domain === domain);
|
|
3537
3644
|
const foreign = onDomain.find((a) => a.name !== PLATFORM_ARCHON_ACCESS_APP_NAME);
|
|
3538
3645
|
if (foreign) {
|
|
@@ -3545,22 +3652,31 @@ async function ensureArchonAccess(cf, domain, emails, closeGate) {
|
|
|
3545
3652
|
type: 'self_hosted',
|
|
3546
3653
|
});
|
|
3547
3654
|
await cf.createAccessPolicy(created.id, archonAccessPolicy(emails));
|
|
3655
|
+
if (uptimeTokenId) {
|
|
3656
|
+
await cf.createAccessPolicy(created.id, archonUptimePolicy(uptimeTokenId));
|
|
3657
|
+
}
|
|
3548
3658
|
return created;
|
|
3549
3659
|
};
|
|
3550
3660
|
const existing = onDomain[0];
|
|
3551
3661
|
if (!existing)
|
|
3552
3662
|
return (await create()).id;
|
|
3553
3663
|
const policies = await cf.listAccessPolicies(existing.id);
|
|
3554
|
-
const other = policies.find((p) => p.name !== ACCESS_POLICY_NAME);
|
|
3664
|
+
const other = policies.find((p) => p.name !== ACCESS_POLICY_NAME && p.name !== PLATFORM_ARCHON_UPTIME_POLICY_NAME);
|
|
3555
3665
|
if (other) {
|
|
3556
3666
|
throw new Error(`platform:archon: Access app ${existing.id} has policy '${other.name}' that foundry did not create`);
|
|
3557
3667
|
}
|
|
3558
|
-
|
|
3559
|
-
|
|
3668
|
+
const emailPolicies = policies.filter((p) => p.name === ACCESS_POLICY_NAME);
|
|
3669
|
+
const tokenPolicies = policies.filter((p) => p.name === PLATFORM_ARCHON_UPTIME_POLICY_NAME);
|
|
3670
|
+
const staleToken = tokenPolicies.some((p) => !uptimePolicyIntact(p, uptimeTokenId));
|
|
3671
|
+
if (emailPolicies.every((p) => sameEmails(p.include, emails)) && !staleToken) {
|
|
3672
|
+
if (emailPolicies.length === 0) {
|
|
3673
|
+
await cf.createAccessPolicy(existing.id, archonAccessPolicy(emails));
|
|
3674
|
+
}
|
|
3675
|
+
if (uptimeTokenId && tokenPolicies.length === 0) {
|
|
3676
|
+
await cf.createAccessPolicy(existing.id, archonUptimePolicy(uptimeTokenId));
|
|
3677
|
+
}
|
|
3560
3678
|
return existing.id;
|
|
3561
3679
|
}
|
|
3562
|
-
if (policies.every((p) => sameEmails(p.include, emails)))
|
|
3563
|
-
return existing.id;
|
|
3564
3680
|
// No call updates an app's policy: rebuild the app, hostname closed meanwhile.
|
|
3565
3681
|
await closeGate();
|
|
3566
3682
|
await cf.deleteAccessApp(existing.id);
|
|
@@ -3573,6 +3689,30 @@ function archonAccessPolicy(emails) {
|
|
|
3573
3689
|
include: emails.map((email) => ({ email: { email } })),
|
|
3574
3690
|
};
|
|
3575
3691
|
}
|
|
3692
|
+
function archonUptimePolicy(tokenId) {
|
|
3693
|
+
return {
|
|
3694
|
+
name: PLATFORM_ARCHON_UPTIME_POLICY_NAME,
|
|
3695
|
+
decision: 'non_identity',
|
|
3696
|
+
include: [{ service_token: { token_id: tokenId } }],
|
|
3697
|
+
};
|
|
3698
|
+
}
|
|
3699
|
+
/**
|
|
3700
|
+
* Whether the uptime policy is what foundry makes: decision `non_identity` and
|
|
3701
|
+
* exactly one include, a service token -- the recorded `tokenId` when there is
|
|
3702
|
+
* one, any token when none is recorded (the Worker is off or not applied yet).
|
|
3703
|
+
*/
|
|
3704
|
+
function uptimePolicyIntact(policy, tokenId) {
|
|
3705
|
+
if (policy.decision !== 'non_identity')
|
|
3706
|
+
return false;
|
|
3707
|
+
const include = policy.include;
|
|
3708
|
+
if (!Array.isArray(include) || include.length !== 1)
|
|
3709
|
+
return false;
|
|
3710
|
+
const entry = include[0];
|
|
3711
|
+
if (Object.keys(entry ?? {}).length !== 1)
|
|
3712
|
+
return false;
|
|
3713
|
+
const live = entry?.service_token?.token_id;
|
|
3714
|
+
return typeof live === 'string' && live !== '' && (tokenId === undefined || live === tokenId);
|
|
3715
|
+
}
|
|
3576
3716
|
/** Whether an Access policy's include list is exactly `emails` (case-insensitive) and nothing else. */
|
|
3577
3717
|
function sameEmails(include, emails) {
|
|
3578
3718
|
if (!Array.isArray(include))
|
|
@@ -3592,14 +3732,23 @@ function sameEmails(include, emails) {
|
|
|
3592
3732
|
* exactly the declared emails. A failed read is not drift: the next plan reads
|
|
3593
3733
|
* again.
|
|
3594
3734
|
*/
|
|
3595
|
-
async function archonAccessDrifted(deps, recorded, domain, emails) {
|
|
3735
|
+
async function archonAccessDrifted(deps, recorded, domain, emails, uptimeTokenId) {
|
|
3596
3736
|
try {
|
|
3597
3737
|
const cf = await platformAccess(deps, recorded, 'platform:archon');
|
|
3598
3738
|
const app = (await cf.listAccessApps()).find((a) => a.domain === domain && a.name === PLATFORM_ARCHON_ACCESS_APP_NAME);
|
|
3599
3739
|
if (!app)
|
|
3600
3740
|
return true;
|
|
3601
3741
|
const policies = await cf.listAccessPolicies(app.id);
|
|
3602
|
-
|
|
3742
|
+
const emailPolicies = policies.filter((p) => p.name === ACCESS_POLICY_NAME);
|
|
3743
|
+
const tokenPolicies = policies.filter((p) => p.name === PLATFORM_ARCHON_UPTIME_POLICY_NAME);
|
|
3744
|
+
if (policies.length !== emailPolicies.length + tokenPolicies.length)
|
|
3745
|
+
return true;
|
|
3746
|
+
if (emailPolicies.length === 0 || !emailPolicies.every((p) => sameEmails(p.include, emails))) {
|
|
3747
|
+
return true;
|
|
3748
|
+
}
|
|
3749
|
+
if (!tokenPolicies.every((p) => uptimePolicyIntact(p, uptimeTokenId)))
|
|
3750
|
+
return true;
|
|
3751
|
+
return uptimeTokenId !== undefined && tokenPolicies.length === 0;
|
|
3603
3752
|
}
|
|
3604
3753
|
catch {
|
|
3605
3754
|
return false;
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
import type { AccessPort } from './edge-access.js';
|
|
2
|
+
import type { PlatformWorkersPort } from './edge-workers.js';
|
|
3
|
+
import type { PlatformStepExecutionContext } from './plan-apply.js';
|
|
4
|
+
/** The optional plain text variables the Worker reads, supplied by the host (not secrets). */
|
|
5
|
+
export declare const UPTIME_OPTIONAL_VARS: readonly ["CHAT_HOMESERVER_URL", "CHAT_ROOM_ID", "ALERT_EMAIL_FROM", "ALERT_EMAIL_TO"];
|
|
6
|
+
/** Builds the Workers port at edge's recorded address, the {@link import('./platform-providers.js').PlatformDnsFactory} way. */
|
|
7
|
+
export type PlatformWorkersFactory = (edgeUrl: string) => PlatformWorkersPort | Promise<PlatformWorkersPort>;
|
|
8
|
+
/** The Access calls the uptime step makes: it mints, finds and removes its one service token. */
|
|
9
|
+
export type PlatformUptimeAccessPort = Pick<AccessPort, 'createServiceToken' | 'removeServiceToken' | 'findServiceTokenByName'>;
|
|
10
|
+
/** Builds the Access port at edge's recorded address, the {@link PlatformWorkersFactory} way. */
|
|
11
|
+
export type PlatformUptimeAccessFactory = (edgeUrl: string) => PlatformUptimeAccessPort | Promise<PlatformUptimeAccessPort>;
|
|
12
|
+
/**
|
|
13
|
+
* Dependencies of the uptime Worker step. `deliverSecret` and `heldKeys` are
|
|
14
|
+
* the host's: the minted token goes only to `deliverSecret`, and every secret
|
|
15
|
+
* is read back only to be handed to edge, which never returns one.
|
|
16
|
+
*/
|
|
17
|
+
export interface PlatformUptimeDeps {
|
|
18
|
+
workers: PlatformWorkersFactory;
|
|
19
|
+
/** The Access service token the Worker passes Archon's gate with, minted through edge. */
|
|
20
|
+
access: PlatformUptimeAccessFactory;
|
|
21
|
+
/**
|
|
22
|
+
* Holds `secret` under `ref`. An empty `secret` clears it, which is how the
|
|
23
|
+
* step forgets the Access pair when the Worker is removed.
|
|
24
|
+
*/
|
|
25
|
+
deliverSecret: (ref: string, secret: string) => Promise<void>;
|
|
26
|
+
heldKeys: {
|
|
27
|
+
resolve(ref: string): Promise<string | null>;
|
|
28
|
+
};
|
|
29
|
+
/** Host config for the Worker's optional plain text variables; an absent one is left unset. */
|
|
30
|
+
vars?: Partial<Record<(typeof UPTIME_OPTIONAL_VARS)[number], string>>;
|
|
31
|
+
}
|
|
32
|
+
/**
|
|
33
|
+
* What is held now: the optional secrets, and `UPTIME_HEARTBEAT_URL` (a ping
|
|
34
|
+
* URL that works as a capability, so a secret too), name to value. Callers
|
|
35
|
+
* that report or record take the names only.
|
|
36
|
+
*/
|
|
37
|
+
export declare function heldUptimeValues(deps: Pick<PlatformUptimeDeps, 'heldKeys'>): Promise<Record<string, string>>;
|
|
38
|
+
/**
|
|
39
|
+
* foundry#542: makes the uptime Worker true, in an order where every failure
|
|
40
|
+
* is repaired by the next apply and none needs a hand edit. Upload the pinned
|
|
41
|
+
* Archon release (the host fetches and verifies it) and record the script at
|
|
42
|
+
* once, so a later failure is a half-made Worker foundry owns. Then hold the
|
|
43
|
+
* Worker token and the Access pair (minted once each, each guard recorded as
|
|
44
|
+
* soon as its value is held), set every held secret on the Worker, and last
|
|
45
|
+
* the cron trigger, so the Worker never runs a round without its token. The
|
|
46
|
+
* applied values are recorded only when all of it succeeded.
|
|
47
|
+
*/
|
|
48
|
+
export declare function provisionPlatformUptime(context: PlatformStepExecutionContext, deps: PlatformUptimeDeps): Promise<{
|
|
49
|
+
resourceId?: string;
|
|
50
|
+
}>;
|
|
@@ -0,0 +1,229 @@
|
|
|
1
|
+
import { randomBytes, randomUUID } from 'node:crypto';
|
|
2
|
+
import { EdgeApiError } from '@wtfalch/edge';
|
|
3
|
+
import { uptimeArtifactUrl } from '../platform.js';
|
|
4
|
+
import { PLATFORM_EDGE_INTERNAL_URL_SCOPE_KEY, PLATFORM_UPTIME_ACCESS_CLIENT_ID_REF, PLATFORM_UPTIME_ACCESS_CLIENT_SECRET_REF, PLATFORM_UPTIME_ACCESS_TOKEN_ID_SCOPE_KEY, PLATFORM_UPTIME_ACCESS_TOKEN_NAME, PLATFORM_UPTIME_ACCESS_UNDELIVERED_SCOPE_KEY, PLATFORM_UPTIME_APPLIED_HOSTNAME_SCOPE_KEY, PLATFORM_UPTIME_APPLIED_SECRETS_SCOPE_KEY, PLATFORM_UPTIME_APPLIED_SHA256_SCOPE_KEY, PLATFORM_UPTIME_APPLIED_TAG_SCOPE_KEY, PLATFORM_UPTIME_APPLIED_VARS_SCOPE_KEY, PLATFORM_UPTIME_HEARTBEAT_URL_REF, PLATFORM_UPTIME_KEY, PLATFORM_UPTIME_KV_TITLE, PLATFORM_UPTIME_OPTIONAL_SECRETS, PLATFORM_UPTIME_REMOVED_SCOPE_KEY, PLATFORM_UPTIME_SCRIPT_NAME, PLATFORM_UPTIME_TOKEN_ID_SCOPE_KEY, PLATFORM_UPTIME_TOKEN_REF, PLATFORM_UPTIME_TOKEN_UNDELIVERED_SCOPE_KEY, uptimeAccessTokenConflict, uptimeSecretNames, uptimeVarsDigest, } from './platform-diff.js';
|
|
5
|
+
/** The Worker's cron trigger, from Archon's `workers/uptime/manifest.json`. */
|
|
6
|
+
const UPTIME_CRONS = ['* * * * *'];
|
|
7
|
+
/** The date Archon's own Worker config used. */
|
|
8
|
+
const UPTIME_COMPATIBILITY_DATE = '2026-09-27';
|
|
9
|
+
/** How many times a delivery or a secrets write is tried before the step fails. */
|
|
10
|
+
const UPTIME_ATTEMPTS = 3;
|
|
11
|
+
/** The optional plain text variables the Worker reads, supplied by the host (not secrets). */
|
|
12
|
+
export const UPTIME_OPTIONAL_VARS = [
|
|
13
|
+
'CHAT_HOMESERVER_URL',
|
|
14
|
+
'CHAT_ROOM_ID',
|
|
15
|
+
'ALERT_EMAIL_FROM',
|
|
16
|
+
'ALERT_EMAIL_TO',
|
|
17
|
+
];
|
|
18
|
+
/**
|
|
19
|
+
* What is held now: the optional secrets, and `UPTIME_HEARTBEAT_URL` (a ping
|
|
20
|
+
* URL that works as a capability, so a secret too), name to value. Callers
|
|
21
|
+
* that report or record take the names only.
|
|
22
|
+
*/
|
|
23
|
+
export async function heldUptimeValues(deps) {
|
|
24
|
+
const refs = {
|
|
25
|
+
...PLATFORM_UPTIME_OPTIONAL_SECRETS,
|
|
26
|
+
UPTIME_HEARTBEAT_URL: PLATFORM_UPTIME_HEARTBEAT_URL_REF,
|
|
27
|
+
};
|
|
28
|
+
const held = {};
|
|
29
|
+
for (const [name, ref] of Object.entries(refs)) {
|
|
30
|
+
const value = await deps.heldKeys.resolve(ref);
|
|
31
|
+
if (value)
|
|
32
|
+
held[name] = value;
|
|
33
|
+
}
|
|
34
|
+
return held;
|
|
35
|
+
}
|
|
36
|
+
/** Retries `task`; the cause is dropped in case it echoes a secret, and the error names `what` only. */
|
|
37
|
+
async function attempt(what, task) {
|
|
38
|
+
for (let n = 1; n <= UPTIME_ATTEMPTS; n++) {
|
|
39
|
+
try {
|
|
40
|
+
await task();
|
|
41
|
+
return;
|
|
42
|
+
}
|
|
43
|
+
catch {
|
|
44
|
+
// Retried; the error is dropped.
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
throw new Error(`platform:uptime: ${what} failed after ${UPTIME_ATTEMPTS} attempts`);
|
|
48
|
+
}
|
|
49
|
+
/** Removes an Access token edge made; one already gone is success. */
|
|
50
|
+
async function removeAccessToken(access, id, what) {
|
|
51
|
+
try {
|
|
52
|
+
await access.removeServiceToken(id);
|
|
53
|
+
}
|
|
54
|
+
catch (error) {
|
|
55
|
+
if (error instanceof EdgeApiError && error.code === 'not_found')
|
|
56
|
+
return;
|
|
57
|
+
// The cause is dropped; edge's text is not ours to pass on.
|
|
58
|
+
throw new Error(`platform:uptime: ${what} ${id} could not be removed through edge`);
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
/**
|
|
62
|
+
* foundry#542: makes the uptime Worker true, in an order where every failure
|
|
63
|
+
* is repaired by the next apply and none needs a hand edit. Upload the pinned
|
|
64
|
+
* Archon release (the host fetches and verifies it) and record the script at
|
|
65
|
+
* once, so a later failure is a half-made Worker foundry owns. Then hold the
|
|
66
|
+
* Worker token and the Access pair (minted once each, each guard recorded as
|
|
67
|
+
* soon as its value is held), set every held secret on the Worker, and last
|
|
68
|
+
* the cron trigger, so the Worker never runs a round without its token. The
|
|
69
|
+
* applied values are recorded only when all of it succeeded.
|
|
70
|
+
*/
|
|
71
|
+
export async function provisionPlatformUptime(context, deps) {
|
|
72
|
+
const { platform } = context;
|
|
73
|
+
const { uptime } = platform;
|
|
74
|
+
const recorded = context.recordedIds();
|
|
75
|
+
const edgeUrl = recorded[PLATFORM_EDGE_INTERNAL_URL_SCOPE_KEY];
|
|
76
|
+
if (!edgeUrl) {
|
|
77
|
+
throw new Error(`platform:uptime: edge has not been applied yet (no recorded ${PLATFORM_EDGE_INTERNAL_URL_SCOPE_KEY}); the Worker is managed through it`);
|
|
78
|
+
}
|
|
79
|
+
const workers = await deps.workers(edgeUrl);
|
|
80
|
+
if (!uptime.enabled) {
|
|
81
|
+
// Whatever of a (possibly half-made) deploy exists is removed; what does not exist is tolerated.
|
|
82
|
+
await workers.deleteScript(PLATFORM_UPTIME_SCRIPT_NAME);
|
|
83
|
+
const accessTokenId = recorded[PLATFORM_UPTIME_ACCESS_TOKEN_ID_SCOPE_KEY] ||
|
|
84
|
+
recorded[PLATFORM_UPTIME_ACCESS_UNDELIVERED_SCOPE_KEY];
|
|
85
|
+
if (accessTokenId) {
|
|
86
|
+
await removeAccessToken(await deps.access(edgeUrl), accessTokenId, 'Access token');
|
|
87
|
+
}
|
|
88
|
+
for (const ref of [
|
|
89
|
+
PLATFORM_UPTIME_TOKEN_REF,
|
|
90
|
+
PLATFORM_UPTIME_ACCESS_CLIENT_ID_REF,
|
|
91
|
+
PLATFORM_UPTIME_ACCESS_CLIENT_SECRET_REF,
|
|
92
|
+
]) {
|
|
93
|
+
await attempt('clearing a held uptime value', () => deps.deliverSecret(ref, ''));
|
|
94
|
+
}
|
|
95
|
+
for (const key of [
|
|
96
|
+
PLATFORM_UPTIME_ACCESS_TOKEN_ID_SCOPE_KEY,
|
|
97
|
+
PLATFORM_UPTIME_ACCESS_UNDELIVERED_SCOPE_KEY,
|
|
98
|
+
PLATFORM_UPTIME_TOKEN_ID_SCOPE_KEY,
|
|
99
|
+
PLATFORM_UPTIME_TOKEN_UNDELIVERED_SCOPE_KEY,
|
|
100
|
+
]) {
|
|
101
|
+
context.recordScopeId(key, '');
|
|
102
|
+
}
|
|
103
|
+
context.recordScopeId(PLATFORM_UPTIME_REMOVED_SCOPE_KEY, '1');
|
|
104
|
+
return {};
|
|
105
|
+
}
|
|
106
|
+
if (!uptime.tag || !uptime.sha256) {
|
|
107
|
+
throw new Error('platform:uptime: no artifact is pinned (uptime.tag, uptime.sha256)');
|
|
108
|
+
}
|
|
109
|
+
const hostname = platform.archon.hostname;
|
|
110
|
+
const held = await heldUptimeValues(deps);
|
|
111
|
+
const bindings = [
|
|
112
|
+
{ type: 'kv_namespace', name: 'UPTIME_KV', title: PLATFORM_UPTIME_KV_TITLE },
|
|
113
|
+
{ type: 'plain_text', name: 'ARCHON_BASE_URL', text: `https://${hostname}` },
|
|
114
|
+
...UPTIME_OPTIONAL_VARS.flatMap((name) => {
|
|
115
|
+
const text = deps.vars?.[name];
|
|
116
|
+
return text ? [{ type: 'plain_text', name, text }] : [];
|
|
117
|
+
}),
|
|
118
|
+
];
|
|
119
|
+
await workers.uploadScript({
|
|
120
|
+
name: PLATFORM_UPTIME_SCRIPT_NAME,
|
|
121
|
+
artifactUrl: uptimeArtifactUrl(uptime.tag),
|
|
122
|
+
artifactSha256: uptime.sha256,
|
|
123
|
+
compatibilityDate: UPTIME_COMPATIBILITY_DATE,
|
|
124
|
+
bindings,
|
|
125
|
+
});
|
|
126
|
+
// The script is foundry's from here on. Recorded now, not at the end: after
|
|
127
|
+
// a later failure the next plan must repair this Worker, not take it for a
|
|
128
|
+
// foreign one. "Fully applied" is the applied values recorded at the end.
|
|
129
|
+
context.recordScopeId(`cloudflare:worker:${PLATFORM_UPTIME_KEY}`, PLATFORM_UPTIME_SCRIPT_NAME);
|
|
130
|
+
context.recordScopeId(PLATFORM_UPTIME_REMOVED_SCOPE_KEY, '');
|
|
131
|
+
await context.persist?.();
|
|
132
|
+
// The Worker token. It is generated here and registered nowhere else until
|
|
133
|
+
// it is held (the Worker and Archon get it from the held value, later), so a
|
|
134
|
+
// marker without its guard means the value never left this process, and a
|
|
135
|
+
// guard with nothing held means it is gone: either way a new one is
|
|
136
|
+
// generated and the old id is dropped. Nothing is blocked and nothing needs
|
|
137
|
+
// a hand edit.
|
|
138
|
+
let tokenId = recorded[PLATFORM_UPTIME_TOKEN_ID_SCOPE_KEY];
|
|
139
|
+
let token = tokenId ? await deps.heldKeys.resolve(PLATFORM_UPTIME_TOKEN_REF) : null;
|
|
140
|
+
if (!token) {
|
|
141
|
+
const newId = randomUUID();
|
|
142
|
+
const minted = randomBytes(32).toString('hex');
|
|
143
|
+
context.recordScopeId(PLATFORM_UPTIME_TOKEN_UNDELIVERED_SCOPE_KEY, newId);
|
|
144
|
+
await context.persist?.();
|
|
145
|
+
await attempt(`holding token ${newId}`, () => deps.deliverSecret(PLATFORM_UPTIME_TOKEN_REF, minted));
|
|
146
|
+
// Guard as soon as the value is held.
|
|
147
|
+
context.recordScopeId(PLATFORM_UPTIME_TOKEN_ID_SCOPE_KEY, newId);
|
|
148
|
+
context.recordScopeId(PLATFORM_UPTIME_TOKEN_UNDELIVERED_SCOPE_KEY, '');
|
|
149
|
+
await context.persist?.();
|
|
150
|
+
tokenId = newId;
|
|
151
|
+
token = minted;
|
|
152
|
+
}
|
|
153
|
+
// The Access service token pair, minted through edge. The id is recorded
|
|
154
|
+
// and persisted before the pair is held, the guard once both are held. A
|
|
155
|
+
// pair that may be lost (marker without guard, or guard with nothing held)
|
|
156
|
+
// is repaired here: edge made that token, so it is removed through edge and
|
|
157
|
+
// minted again, never two live tokens. A token of the name with no id
|
|
158
|
+
// recorded is not ours to remove: edge answers `conflict` and the planner
|
|
159
|
+
// reports it as blocked; the throw here is only a race with that report.
|
|
160
|
+
let accessId = recorded[PLATFORM_UPTIME_ACCESS_TOKEN_ID_SCOPE_KEY];
|
|
161
|
+
let clientId = accessId
|
|
162
|
+
? await deps.heldKeys.resolve(PLATFORM_UPTIME_ACCESS_CLIENT_ID_REF)
|
|
163
|
+
: null;
|
|
164
|
+
let clientSecret = accessId
|
|
165
|
+
? await deps.heldKeys.resolve(PLATFORM_UPTIME_ACCESS_CLIENT_SECRET_REF)
|
|
166
|
+
: null;
|
|
167
|
+
if (!accessId || !clientId || !clientSecret) {
|
|
168
|
+
const access = await deps.access(edgeUrl);
|
|
169
|
+
const lost = accessId || recorded[PLATFORM_UPTIME_ACCESS_UNDELIVERED_SCOPE_KEY];
|
|
170
|
+
if (lost) {
|
|
171
|
+
await removeAccessToken(access, lost, 'lost Access token');
|
|
172
|
+
context.recordScopeId(PLATFORM_UPTIME_ACCESS_TOKEN_ID_SCOPE_KEY, '');
|
|
173
|
+
context.recordScopeId(PLATFORM_UPTIME_ACCESS_UNDELIVERED_SCOPE_KEY, '');
|
|
174
|
+
await context.persist?.();
|
|
175
|
+
}
|
|
176
|
+
let minted;
|
|
177
|
+
try {
|
|
178
|
+
minted = await access.createServiceToken(PLATFORM_UPTIME_ACCESS_TOKEN_NAME);
|
|
179
|
+
}
|
|
180
|
+
catch (error) {
|
|
181
|
+
if (error instanceof EdgeApiError && error.code === 'conflict') {
|
|
182
|
+
throw new Error(`platform:uptime: ${uptimeAccessTokenConflict()}`);
|
|
183
|
+
}
|
|
184
|
+
throw error;
|
|
185
|
+
}
|
|
186
|
+
context.recordScopeId(PLATFORM_UPTIME_ACCESS_UNDELIVERED_SCOPE_KEY, minted.id);
|
|
187
|
+
await context.persist?.();
|
|
188
|
+
if (!minted.clientId || !minted.clientSecret) {
|
|
189
|
+
await removeAccessToken(access, minted.id, 'incomplete Access token');
|
|
190
|
+
context.recordScopeId(PLATFORM_UPTIME_ACCESS_UNDELIVERED_SCOPE_KEY, '');
|
|
191
|
+
await context.persist?.();
|
|
192
|
+
throw new Error(`platform:uptime: edge returned Access token ${minted.id} without a client id or secret; it was removed`);
|
|
193
|
+
}
|
|
194
|
+
const newId = minted.id;
|
|
195
|
+
const idToHold = minted.clientId;
|
|
196
|
+
const secretToHold = minted.clientSecret;
|
|
197
|
+
await attempt(`holding Access token ${newId} client id`, () => deps.deliverSecret(PLATFORM_UPTIME_ACCESS_CLIENT_ID_REF, idToHold));
|
|
198
|
+
await attempt(`holding Access token ${newId} client secret`, () => deps.deliverSecret(PLATFORM_UPTIME_ACCESS_CLIENT_SECRET_REF, secretToHold));
|
|
199
|
+
context.recordScopeId(PLATFORM_UPTIME_ACCESS_TOKEN_ID_SCOPE_KEY, newId);
|
|
200
|
+
context.recordScopeId(PLATFORM_UPTIME_ACCESS_UNDELIVERED_SCOPE_KEY, '');
|
|
201
|
+
await context.persist?.();
|
|
202
|
+
accessId = newId;
|
|
203
|
+
clientId = idToHold;
|
|
204
|
+
clientSecret = secretToHold;
|
|
205
|
+
}
|
|
206
|
+
// Set on the Worker from the held values, every time this step runs. The
|
|
207
|
+
// held secrets' version label is the pinned digest, so a new release sets
|
|
208
|
+
// them again. The held-key reader exposes no per-value id or update time,
|
|
209
|
+
// so a value rotated at the same pin is not seen; the minted ones carry
|
|
210
|
+
// their id.
|
|
211
|
+
const version = `pin-${uptime.sha256.slice(0, 12)}`;
|
|
212
|
+
const secrets = [
|
|
213
|
+
{ name: 'UPTIME_TOKEN', value: token, version: tokenId },
|
|
214
|
+
{ name: 'CF_ACCESS_CLIENT_ID', value: clientId, version: accessId },
|
|
215
|
+
{ name: 'CF_ACCESS_CLIENT_SECRET', value: clientSecret, version: accessId },
|
|
216
|
+
// A ping URL works as a capability: a secret, not a plain text binding.
|
|
217
|
+
...Object.entries(held).map(([name, value]) => ({ name, value, version })),
|
|
218
|
+
];
|
|
219
|
+
await attempt(`setting the Worker's secrets (${secrets.map((s) => s.name).join(', ')})`, () => workers.setSecrets(PLATFORM_UPTIME_SCRIPT_NAME, secrets));
|
|
220
|
+
// After the secrets: the Worker never runs a round without its token.
|
|
221
|
+
await workers.setSchedules(PLATFORM_UPTIME_SCRIPT_NAME, UPTIME_CRONS);
|
|
222
|
+
// Last, after every write the applied values depend on.
|
|
223
|
+
context.recordScopeId(PLATFORM_UPTIME_APPLIED_TAG_SCOPE_KEY, uptime.tag);
|
|
224
|
+
context.recordScopeId(PLATFORM_UPTIME_APPLIED_SHA256_SCOPE_KEY, uptime.sha256);
|
|
225
|
+
context.recordScopeId(PLATFORM_UPTIME_APPLIED_HOSTNAME_SCOPE_KEY, hostname);
|
|
226
|
+
context.recordScopeId(PLATFORM_UPTIME_APPLIED_VARS_SCOPE_KEY, uptimeVarsDigest(deps.vars));
|
|
227
|
+
context.recordScopeId(PLATFORM_UPTIME_APPLIED_SECRETS_SCOPE_KEY, uptimeSecretNames(Object.keys(held)));
|
|
228
|
+
return { resourceId: PLATFORM_UPTIME_SCRIPT_NAME };
|
|
229
|
+
}
|
package/dist/platform.d.ts
CHANGED
|
@@ -24,6 +24,22 @@ export interface PlatformService {
|
|
|
24
24
|
export interface PlatformMailService extends PlatformService {
|
|
25
25
|
lettermintProjectId?: string | null;
|
|
26
26
|
}
|
|
27
|
+
/**
|
|
28
|
+
* The uptime Worker (foundry#542): the outside prober Archon publishes as a
|
|
29
|
+
* built script with each `v*` release. Foundry deploys the pinned release and
|
|
30
|
+
* never builds it. `tag` and `sha256` are the pin; both empty means nothing is
|
|
31
|
+
* pinned yet, and the planner reports that instead of planning the step.
|
|
32
|
+
* It has no hostname: it answers no request, only probes on a cron trigger.
|
|
33
|
+
*/
|
|
34
|
+
export interface PlatformUptimeWorker {
|
|
35
|
+
enabled: boolean;
|
|
36
|
+
/** An Archon release tag, `v1.2.3`. */
|
|
37
|
+
tag: string;
|
|
38
|
+
/** Lower-case hex SHA-256 of `archon-uptime-worker.js` at that tag. */
|
|
39
|
+
sha256: string;
|
|
40
|
+
}
|
|
41
|
+
/** Where Archon publishes the Worker script for `tag` (a private repo; the host's `fetchArtifact` fetches it). */
|
|
42
|
+
export declare function uptimeArtifactUrl(tag: string): string;
|
|
27
43
|
/**
|
|
28
44
|
* The shared services a declared platform names, decided in #165, plus
|
|
29
45
|
* `edge` (#317, F7): the service that mints an entry's own edge key and
|
|
@@ -87,6 +103,8 @@ export interface DeclaredPlatform {
|
|
|
87
103
|
* `gitRef` empty.
|
|
88
104
|
*/
|
|
89
105
|
coolify: PlatformService;
|
|
106
|
+
/** foundry#542: the outside prober Worker; see {@link PlatformUptimeWorker}. */
|
|
107
|
+
uptime: PlatformUptimeWorker;
|
|
90
108
|
}
|
|
91
109
|
/** `<service>.<apex>` -- the default `hostname` for a platform service, before an operator overrides it. */
|
|
92
110
|
export declare function defaultPlatformHostname(service: PlatformServiceKey, apex: string): string;
|
package/dist/platform.js
CHANGED
|
@@ -1,5 +1,9 @@
|
|
|
1
1
|
import { isIPv4 } from 'node:net';
|
|
2
2
|
import { validateDomain } from './org.js';
|
|
3
|
+
/** Where Archon publishes the Worker script for `tag` (a private repo; the host's `fetchArtifact` fetches it). */
|
|
4
|
+
export function uptimeArtifactUrl(tag) {
|
|
5
|
+
return `https://github.com/wtfalch/archon/releases/download/${tag}/archon-uptime-worker.js`;
|
|
6
|
+
}
|
|
3
7
|
export const PLATFORM_SERVICE_KEYS = [
|
|
4
8
|
'auth',
|
|
5
9
|
'mail',
|
|
@@ -50,6 +54,16 @@ export function validatePlatform(platform) {
|
|
|
50
54
|
return `${key}.gitRef must not be empty once ${key}.enabled is true`;
|
|
51
55
|
}
|
|
52
56
|
}
|
|
57
|
+
const { uptime } = platform;
|
|
58
|
+
if (Boolean(uptime.tag.trim()) !== Boolean(uptime.sha256.trim())) {
|
|
59
|
+
return 'uptime.tag and uptime.sha256 must be set together: a pin is both';
|
|
60
|
+
}
|
|
61
|
+
if (uptime.tag && !/^v[0-9A-Za-z][0-9A-Za-z.+_-]*$/.test(uptime.tag)) {
|
|
62
|
+
return `uptime.tag '${uptime.tag}' must be a release tag such as v1.2.3`;
|
|
63
|
+
}
|
|
64
|
+
if (uptime.sha256 && !/^[0-9a-f]{64}$/.test(uptime.sha256)) {
|
|
65
|
+
return 'uptime.sha256 must be 64 lower-case hex characters';
|
|
66
|
+
}
|
|
53
67
|
// #491: declared, never discovered -- a present-but-blank id is refused the
|
|
54
68
|
// same way `serverIpv4`'s blank string is, rather than being read as unset.
|
|
55
69
|
if (platform.mail.lettermintProjectId != null && !platform.mail.lettermintProjectId.trim()) {
|