@wtfalch/foundry 0.23.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.
@@ -6,10 +6,12 @@ import { redactSecrets } from '../calls-sfu-deploy-support.js';
6
6
  import { ensureCloudflaredConnector } from './cloudflared-connector.js';
7
7
  import { waitForDeploymentFinished, waitForHealthyVersion, waitForNewDeployment, } from './deploy-health.js';
8
8
  import { ACCESS_POLICY_NAME, getDatabaseStarted, isNotFoundError, isRunningOrStarting, } from './executor.js';
9
+ import { ACCESS_PROVIDER_RECORD_KEY, GATE_CLAIM_NAME } from './gates.js';
9
10
  import { healthCheckSettings } from './health-check-settings.js';
10
11
  import { coolifyDomains, coolifyTunnelDomains, filesShareHostname } from './hostnames.js';
11
- import { AUTH_ZITADEL_RUN_OPTIONS, JOBS_DEPLOY_PENDING_SCOPE_KEY, JOBS_INTERNAL_URL_SCOPE_KEY, 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_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';
12
13
  import { applyCallsSfuPlatformStep } from './platform-sfu.js';
14
+ import { heldUptimeValues, provisionPlatformUptime, } from './uptime-worker.js';
13
15
  import { CreateInstanceError, createZitadelInstance, generateSystemApiKeypair, instanceWithoutBearerMessage, systemApiUsersEnv, } from './zitadel-system-api.js';
14
16
  /** The key `runPlatformSteps` records the apex zone's id under (`system:kind:key`). */
15
17
  function apexZoneKey(apex) {
@@ -155,6 +157,8 @@ export class FoundryPlatformStepExecutor {
155
157
  backup;
156
158
  hc;
157
159
  heartbeats;
160
+ uptime;
161
+ accessProvider;
158
162
  constructor(cloudflare, declaredOrgStore, options = {}) {
159
163
  this.cloudflare = cloudflare;
160
164
  this.declaredOrgStore = declaredOrgStore;
@@ -172,6 +176,8 @@ export class FoundryPlatformStepExecutor {
172
176
  this.backup = options.backup;
173
177
  this.hc = options.hc;
174
178
  this.heartbeats = options.heartbeats;
179
+ this.uptime = options.uptime;
180
+ this.accessProvider = options.accessProvider;
175
181
  }
176
182
  async blockedSteps(platform, recordedIds = {}) {
177
183
  const blocked = {};
@@ -323,6 +329,27 @@ export class FoundryPlatformStepExecutor {
323
329
  if (!apiKey)
324
330
  blocked.heartbeatsApiKeyMissing = true;
325
331
  }
332
+ await this.uptimeBlocked(platform, recordedIds, blocked);
333
+ // #604: only once edge and sign-in are applied (the planner reports
334
+ // that case itself) and no provider is recorded yet.
335
+ if (platform.edge.enabled &&
336
+ platform.auth.enabled &&
337
+ !recordedIds[ACCESS_PROVIDER_RECORD_KEY] &&
338
+ recordedIds[PLATFORM_EDGE_INTERNAL_URL_SCOPE_KEY]) {
339
+ if (!this.accessProvider?.zitadel.createOidcAppWithSecret) {
340
+ blocked.accessProvider =
341
+ 'the Access sign-in provider is blocked, not planned: the host built the platform executor without its Access provider dependencies (a ZITADEL client that can make a client with a secret)';
342
+ }
343
+ else {
344
+ try {
345
+ await (await platformAccess(this.accessProvider, recordedIds, 'platform:access')).readiness();
346
+ }
347
+ catch {
348
+ blocked.accessProvider =
349
+ 'the Access sign-in provider is blocked, not planned: edge could not confirm that Cloudflare Access is ready (its Cloudflare token needs every Access right)';
350
+ }
351
+ }
352
+ }
326
353
  // Not a block: tells the planner to order Archon's step last (`PlanPlatformOptions.blocked.archonRunsInsideApp`).
327
354
  if (this.archon?.runsInsideApp) {
328
355
  blocked.archonRunsInsideApp = true;
@@ -337,6 +364,69 @@ export class FoundryPlatformStepExecutor {
337
364
  }
338
365
  return blocked;
339
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
+ }
340
430
  /**
341
431
  * Whether Archon's live Access policy lets through anyone but the declared
342
432
  * emails, or (#211 round 4) the broker it last registered with no longer
@@ -349,7 +439,9 @@ export class FoundryPlatformStepExecutor {
349
439
  if (platform.archon.enabled &&
350
440
  this.archon &&
351
441
  recordedIds[`coolify:app:${PLATFORM_ARCHON_KEY}`]) {
352
- 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);
353
445
  const brokerDrifted = !accessDrifted && (await archonBrokerRegistrationDrifted(this.archon.coolify, recordedIds));
354
446
  const bootstrapOperatorDrifted = !accessDrifted &&
355
447
  !brokerDrifted &&
@@ -490,12 +582,26 @@ export class FoundryPlatformStepExecutor {
490
582
  }
491
583
  return provisionPlatformHc(context, this.hc);
492
584
  }
585
+ if (action.system === 'cloudflare' && action.kind === 'access-provider') {
586
+ if (!this.accessProvider) {
587
+ throw new Error('cloudflare:access-provider needs FoundryPlatformStepExecutor to be constructed with its Access provider dependencies (#604)');
588
+ }
589
+ return provisionPlatformAccessProvider(context, this.accessProvider);
590
+ }
493
591
  if (action.system === 'heartbeats' && action.kind === 'reconcile') {
494
592
  if (!this.heartbeats) {
495
593
  throw new Error('heartbeats:reconcile needs FoundryPlatformStepExecutor to be constructed with its heartbeats dependencies (#540)');
496
594
  }
497
595
  return provisionPlatformHeartbeats(context, this.heartbeats);
498
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
+ }
499
605
  if (action.system === 'cloudflare' && action.kind === 'zone') {
500
606
  return this.cloudflareZone(action, context);
501
607
  }
@@ -2915,6 +3021,15 @@ async function provisionPlatformArchon(context, deps) {
2915
3021
  envs.push({ key: 'COOLIFY_URL', value: coolifyUrl }, { key: 'COOLIFY_READ_TOKEN', value: coolifyReadToken });
2916
3022
  }
2917
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 });
2918
3033
  const changed = await envsDiffer(deps.coolify, appId, envs);
2919
3034
  // #467: this run's intended env set, hashed before anything below writes
2920
3035
  // it -- compared against {@link PLATFORM_ARCHON_ENVS_DEPLOYED_SCOPE_KEY}
@@ -2943,7 +3058,9 @@ async function provisionPlatformArchon(context, deps) {
2943
3058
  // what either hash names (#467).
2944
3059
  let staleKeyDeleted = false;
2945
3060
  for (const stale of await deps.coolify.listEnvs(appId)) {
2946
- if (stale.key === 'FILES_MANAGEMENT_KEY' && stale.uuid) {
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) {
2947
3064
  await deps.coolify.deleteEnv(appId, stale.uuid);
2948
3065
  staleKeyDeleted = true;
2949
3066
  }
@@ -3035,7 +3152,10 @@ async function provisionPlatformArchon(context, deps) {
3035
3152
  const tunnels = await platformTunnels(deps, context.recordedIds(), 'platform:archon');
3036
3153
  const access = await platformAccess(deps, context.recordedIds(), 'platform:archon');
3037
3154
  let gateClosed = Boolean(recorded[PLATFORM_ARCHON_GATE_CLOSED_SCOPE_KEY]);
3038
- const accessAppId = await ensureArchonAccess(access, hostname, deps.accessEmails, async () => {
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 () => {
3039
3159
  // The Access app is about to be rebuilt: answer 404 for every hostname
3040
3160
  // meanwhile, so it is never served without the gate. The marker is
3041
3161
  // persisted first: a failure before the real ingress is written back
@@ -3177,6 +3297,18 @@ async function provisionPlatformArchon(context, deps) {
3177
3297
  if (keyUnsynced && integrationsKeyId && (!selfRedeploy || deployState === 'finished')) {
3178
3298
  await record(PLATFORM_ARCHON_INTEGRATIONS_KEY_SYNCED_SCOPE_KEY, integrationsKeyId);
3179
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
+ }
3180
3312
  // #475: confirmed only now, after the health check above has passed on a
3181
3313
  // container that was deployed with the alias patch in place -- the same
3182
3314
  // confirm-after-health-check timing as the two records just above.
@@ -3496,14 +3628,18 @@ async function putArchonMasterKeys(heldKeys, masterKeys) {
3496
3628
  await heldKeys.put?.(ref, value);
3497
3629
  }
3498
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';
3499
3633
  /**
3500
3634
  * `executor.ts`'s `cloudflareAccess`, for the platform's Archon: reuses only
3501
3635
  * an Access app named {@link PLATFORM_ARCHON_ACCESS_APP_NAME}, refuses to
3502
3636
  * adopt any other Access app already holding the hostname, and refuses when
3503
3637
  * its own app carries a policy foundry did not create, since that policy
3504
- * 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.
3505
3641
  */
3506
- async function ensureArchonAccess(cf, domain, emails, closeGate) {
3642
+ async function ensureArchonAccess(cf, domain, emails, uptimeTokenId, closeGate) {
3507
3643
  const onDomain = (await cf.listAccessApps()).filter((a) => a.domain === domain);
3508
3644
  const foreign = onDomain.find((a) => a.name !== PLATFORM_ARCHON_ACCESS_APP_NAME);
3509
3645
  if (foreign) {
@@ -3516,22 +3652,31 @@ async function ensureArchonAccess(cf, domain, emails, closeGate) {
3516
3652
  type: 'self_hosted',
3517
3653
  });
3518
3654
  await cf.createAccessPolicy(created.id, archonAccessPolicy(emails));
3655
+ if (uptimeTokenId) {
3656
+ await cf.createAccessPolicy(created.id, archonUptimePolicy(uptimeTokenId));
3657
+ }
3519
3658
  return created;
3520
3659
  };
3521
3660
  const existing = onDomain[0];
3522
3661
  if (!existing)
3523
3662
  return (await create()).id;
3524
3663
  const policies = await cf.listAccessPolicies(existing.id);
3525
- 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);
3526
3665
  if (other) {
3527
3666
  throw new Error(`platform:archon: Access app ${existing.id} has policy '${other.name}' that foundry did not create`);
3528
3667
  }
3529
- if (policies.length === 0) {
3530
- await cf.createAccessPolicy(existing.id, archonAccessPolicy(emails));
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
+ }
3531
3678
  return existing.id;
3532
3679
  }
3533
- if (policies.every((p) => sameEmails(p.include, emails)))
3534
- return existing.id;
3535
3680
  // No call updates an app's policy: rebuild the app, hostname closed meanwhile.
3536
3681
  await closeGate();
3537
3682
  await cf.deleteAccessApp(existing.id);
@@ -3544,6 +3689,30 @@ function archonAccessPolicy(emails) {
3544
3689
  include: emails.map((email) => ({ email: { email } })),
3545
3690
  };
3546
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
+ }
3547
3716
  /** Whether an Access policy's include list is exactly `emails` (case-insensitive) and nothing else. */
3548
3717
  function sameEmails(include, emails) {
3549
3718
  if (!Array.isArray(include))
@@ -3563,14 +3732,23 @@ function sameEmails(include, emails) {
3563
3732
  * exactly the declared emails. A failed read is not drift: the next plan reads
3564
3733
  * again.
3565
3734
  */
3566
- async function archonAccessDrifted(deps, recorded, domain, emails) {
3735
+ async function archonAccessDrifted(deps, recorded, domain, emails, uptimeTokenId) {
3567
3736
  try {
3568
3737
  const cf = await platformAccess(deps, recorded, 'platform:archon');
3569
3738
  const app = (await cf.listAccessApps()).find((a) => a.domain === domain && a.name === PLATFORM_ARCHON_ACCESS_APP_NAME);
3570
3739
  if (!app)
3571
3740
  return true;
3572
3741
  const policies = await cf.listAccessPolicies(app.id);
3573
- return !(policies.length > 0 && policies.every((p) => sameEmails(p.include, emails)));
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;
3574
3752
  }
3575
3753
  catch {
3576
3754
  return false;
@@ -5668,6 +5846,85 @@ async function provisionPlatformEdge(context, deps) {
5668
5846
  await record(PLATFORM_EDGE_APPLIED_GIT_REF_SCOPE_KEY, gitRef);
5669
5847
  return { resourceId: appId };
5670
5848
  }
5849
+ /** The ZITADEL project and client the platform's Access provider signs in through (#604). */
5850
+ const PLATFORM_ACCESS_ZITADEL_PROJECT = 'cloudflare-access';
5851
+ const PLATFORM_ACCESS_ZITADEL_APP = 'cloudflare-access';
5852
+ /** The name edge finds the Cloudflare Access provider by. */
5853
+ const PLATFORM_ACCESS_PROVIDER_NAME = 'foundry';
5854
+ /**
5855
+ * #604: makes the platform's ZITADEL a sign-in provider in Cloudflare Access.
5856
+ * Reads the Access team address from edge, makes a ZITADEL client whose
5857
+ * redirect is that address's callback, and hands its id and secret to edge's
5858
+ * `ensureProvider`, which is the delivery. The client's secret exists once
5859
+ * (CLAUDE.md rules 1 and 6): its app id is recorded and persisted before the
5860
+ * secret leaves, the guard {@link ACCESS_PROVIDER_RECORD_KEY} only after edge
5861
+ * returned the provider id, and a recorded undelivered id stops a second
5862
+ * mint. The secret reaches only `ensureProvider`: no error, log or record
5863
+ * holds it. A client app by this name with no recorded id is one whose create
5864
+ * response was lost before anything saw its secret, so it is deleted and made
5865
+ * again.
5866
+ */
5867
+ async function provisionPlatformAccessProvider(context, deps) {
5868
+ const { platform } = context;
5869
+ const recorded = context.recordedIds();
5870
+ const sleep = deps.runtime?.sleep ?? defaultSleep;
5871
+ const attempts = deps.runtime?.deliverAttempts ?? 3;
5872
+ const label = 'platform:access-provider';
5873
+ if (recorded[ACCESS_PROVIDER_RECORD_KEY])
5874
+ return {};
5875
+ const lost = recorded[PLATFORM_ACCESS_PROVIDER_UNDELIVERED_SCOPE_KEY];
5876
+ if (lost) {
5877
+ throw new Error(`${label}: the ZITADEL client ${lost} was made and its secret may or may not have reached edge; delete it and remove the recorded keys ${PLATFORM_ACCESS_PROVIDER_UNDELIVERED_SCOPE_KEY} and ${ACCESS_PROVIDER_RECORD_KEY}, then apply again to make both anew`);
5878
+ }
5879
+ const access = await platformAccess(deps, recorded, label);
5880
+ let authDomain;
5881
+ try {
5882
+ ({ authDomain } = await access.readiness());
5883
+ }
5884
+ catch {
5885
+ throw new Error(`${label}: edge could not confirm that Cloudflare Access is ready`);
5886
+ }
5887
+ const issuer = `https://${platform.auth.hostname}`;
5888
+ const zitadel = deps.zitadelForInstance?.(issuer) ?? deps.zitadel;
5889
+ const { org } = await zitadel.findOrCreateOrg(PLATFORM_AUTH_ORG_NAME);
5890
+ const { project } = await zitadel.findOrCreateProject(org.id, PLATFORM_ACCESS_ZITADEL_PROJECT);
5891
+ const orphan = (await zitadel.listApps(org.id, project.id)).apps.find((a) => a.name === PLATFORM_ACCESS_ZITADEL_APP && a.state !== 'APP_STATE_REMOVED');
5892
+ if (orphan) {
5893
+ try {
5894
+ await zitadel.deleteApp(org.id, project.id, orphan.id);
5895
+ }
5896
+ catch (error) {
5897
+ if (!isNotFoundError(error))
5898
+ throw error;
5899
+ }
5900
+ }
5901
+ const client = await zitadel.createOidcAppWithSecret(org.id, project.id, {
5902
+ name: PLATFORM_ACCESS_ZITADEL_APP,
5903
+ redirectUris: [`https://${authDomain}/cdn-cgi/access/callback`],
5904
+ postLogoutRedirectUris: [`https://${authDomain}/`],
5905
+ });
5906
+ context.recordScopeId(PLATFORM_ACCESS_PROVIDER_UNDELIVERED_SCOPE_KEY, client.appId);
5907
+ await context.persist?.();
5908
+ let providerId = '';
5909
+ await deliverWithRetry(async (_ref, secret) => {
5910
+ providerId = (await access.ensureProvider({
5911
+ name: PLATFORM_ACCESS_PROVIDER_NAME,
5912
+ clientId: client.clientId,
5913
+ clientSecret: secret,
5914
+ authUrl: `${issuer}/oauth/v2/authorize`,
5915
+ tokenUrl: `${issuer}/oauth/v2/token`,
5916
+ certsUrl: `${issuer}/oauth/v2/keys`,
5917
+ scopes: ['openid', 'email', 'profile', 'urn:zitadel:iam:user:resourceowner'],
5918
+ claims: [GATE_CLAIM_NAME],
5919
+ })).id;
5920
+ }, ACCESS_PROVIDER_RECORD_KEY, client.clientSecret, attempts, sleep, `the ZITADEL client ${client.appId}'s secret`, label);
5921
+ // Last: the guard says the secret reached edge.
5922
+ context.recordScopeId(PLATFORM_ACCESS_PROVIDER_APPLIED_HOSTNAME_SCOPE_KEY, platform.auth.hostname);
5923
+ context.recordScopeId(PLATFORM_ACCESS_PROVIDER_APPLIED_AUTH_DOMAIN_SCOPE_KEY, authDomain);
5924
+ context.recordScopeId(ACCESS_PROVIDER_RECORD_KEY, providerId);
5925
+ await context.persist?.();
5926
+ return {};
5927
+ }
5671
5928
  /** `healthchecks/healthchecks` on Docker Hub -- #540's own "third-party, not this estate's own repo" image. */
5672
5929
  const HC_IMAGE = 'healthchecks/healthchecks';
5673
5930
  const PLATFORM_HC_PROJECT = 'platform';
@@ -6022,7 +6279,7 @@ label = 'platform:files') {
6022
6279
  }
6023
6280
  catch {
6024
6281
  if (attempt >= attempts) {
6025
- throw new Error(`${label}: ${what} could not be delivered after ${attempts} tries; the next plan reports it as lost and mints no new one`);
6282
+ throw new Error(`${label}: ${what} could not be delivered after ${attempts} tries; the next plan reports it as blocked and mints no new one`);
6026
6283
  }
6027
6284
  await sleep(1000 * attempt);
6028
6285
  }
@@ -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
+ }>;