@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.
@@ -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
- 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) {
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 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 () => {
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
- if (policies.length === 0) {
3559
- 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
+ }
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
- 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;
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
+ }
@@ -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()) {