@wtfalch/foundry 0.16.14 → 0.16.16

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.
@@ -4,9 +4,10 @@ import { applyObjects, openInstance, platformMailSettingsObjects } from '@wtfalc
4
4
  import { VERSIONS, floatingTagBlocked } from '../bootstrap/versions.js';
5
5
  import { redactSecrets } from '../calls-sfu-deploy-support.js';
6
6
  import { ensureCloudflaredConnector } from './cloudflared-connector.js';
7
+ import { waitForDeploymentFinished, waitForHealthyVersion, waitForNewDeployment, } from './deploy-health.js';
7
8
  import { ACCESS_POLICY_NAME, getDatabaseStarted, isNotFoundError, isRunningOrStarting, } from './executor.js';
8
- import { coolifyDomains } from './hostnames.js';
9
- import { AUTH_ZITADEL_RUN_OPTIONS, JOBS_SCHEDULER_KEY, PLATFORM_AI_APPLIED_GITREF_SCOPE_KEY, PLATFORM_AI_APPLIED_HOSTNAME_SCOPE_KEY, PLATFORM_AI_DEPLOY_PENDING_SCOPE_KEY, PLATFORM_AI_INTERNAL_URL_SCOPE_KEY, PLATFORM_AI_KEY, PLATFORM_AI_MANAGEMENT_KEY_CONFLICT_SCOPE_KEY, PLATFORM_AI_MANAGEMENT_KEY_SCOPE_KEY, PLATFORM_AI_MANAGEMENT_KEY_UNDELIVERED_SCOPE_KEY, PLATFORM_AI_MIGRATE_BLOCKED_SCOPE_KEY, PLATFORM_AI_ORGANISATION_SCOPE_KEY, PLATFORM_AI_RUNNER_DEPLOYMENT_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_CONNECTOR_APP_SCOPE_KEY, PLATFORM_ARCHON_GATE_CLOSED_SCOPE_KEY, PLATFORM_ARCHON_KEY, PLATFORM_ARCHON_MASTER_CLOUDFLARE_TOKEN_REF, PLATFORM_ARCHON_MASTER_COOLIFY_TOKEN_REF, PLATFORM_ARCHON_MASTER_COOLIFY_URL_REF, PLATFORM_AUTH_ADMIN_UNDELIVERED_SCOPE_KEY, PLATFORM_AUTH_ADMIN_USER_SCOPE_KEY, PLATFORM_AUTH_BROKER_DEPLOYED_SCOPE_KEY, PLATFORM_AUTH_BROKER_SCOPE_KEY, PLATFORM_AUTH_CONNECTOR_APP_SCOPE_KEY, PLATFORM_AUTH_INSTANCE_SCOPE_KEY, PLATFORM_AUTH_INSTANCE_UNDELIVERED_SCOPE_KEY, PLATFORM_AUTH_KEY, PLATFORM_AUTH_LOGIN_CLIENT_UNDELIVERED_SCOPE_KEY, PLATFORM_AUTH_LOGIN_CLIENT_USER_SCOPE_KEY, PLATFORM_AUTH_OWNER_INVITE_SCOPE_KEY, PLATFORM_AUTH_SMTP_SCOPE_KEY, PLATFORM_AUTH_ZITADEL_APPLIED_IMAGE_TAG_SCOPE_KEY, PLATFORM_AUTH_ZITADEL_RUN_OPTIONS_SCOPE_KEY, PLATFORM_BACKUP_DATABASE_SCOPE_KEYS, PLATFORM_BACKUP_SERVICE_KEYS, PLATFORM_FILES_APPLIED_GIT_REF_SCOPE_KEY, PLATFORM_FILES_APPLIED_HOSTNAME_SCOPE_KEY, PLATFORM_FILES_INTERNAL_URL_SCOPE_KEY, PLATFORM_FILES_INTROSPECTION_SCOPE_KEY, PLATFORM_FILES_INTROSPECTION_UNDELIVERED_SCOPE_KEY, PLATFORM_FILES_KEY, PLATFORM_FILES_MANAGEMENT_KEY_SCOPE_KEY, PLATFORM_FILES_MANAGEMENT_UNDELIVERED_SCOPE_KEY, PLATFORM_FILES_ZITADEL_PROJECT_SCOPE_KEY, PLATFORM_INTEGRATIONS_APPLIED_GITREF_SCOPE_KEY, PLATFORM_INTEGRATIONS_APPLIED_HOSTNAME_SCOPE_KEY, PLATFORM_INTEGRATIONS_KEY, PLATFORM_INTEGRATIONS_KEY_SET_SCOPE_KEY, PLATFORM_INTEGRATIONS_KEY_UNDELIVERED_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, parseBrokerDeployRecord, parseLoginClientRecord, platformMailMxHost, } from './platform-diff.js';
9
+ import { coolifyDomains, filesShareHostname } from './hostnames.js';
10
+ import { AUTH_ZITADEL_RUN_OPTIONS, JOBS_SCHEDULER_KEY, PLATFORM_AI_APPLIED_GITREF_SCOPE_KEY, PLATFORM_AI_APPLIED_HOSTNAME_SCOPE_KEY, PLATFORM_AI_DEPLOY_PENDING_SCOPE_KEY, PLATFORM_AI_INTERNAL_URL_SCOPE_KEY, PLATFORM_AI_KEY, PLATFORM_AI_MANAGEMENT_KEY_CONFLICT_SCOPE_KEY, PLATFORM_AI_MANAGEMENT_KEY_SCOPE_KEY, PLATFORM_AI_MANAGEMENT_KEY_UNDELIVERED_SCOPE_KEY, PLATFORM_AI_MIGRATE_BLOCKED_SCOPE_KEY, PLATFORM_AI_ORGANISATION_SCOPE_KEY, PLATFORM_AI_SIGNING_KEY_SET_SCOPE_KEY, PLATFORM_AI_SIGNING_KEY_UNDELIVERED_SCOPE_KEY, PLATFORM_ARCHON_APPLIED_BROKER_SCOPE_KEY, PLATFORM_ARCHON_APPLIED_GIT_REF_SCOPE_KEY, PLATFORM_ARCHON_APPLIED_HOSTNAME_SCOPE_KEY, PLATFORM_ARCHON_APPLIED_OPERATOR_SCOPE_KEY, PLATFORM_ARCHON_CONNECTOR_APP_SCOPE_KEY, PLATFORM_ARCHON_GATE_CLOSED_SCOPE_KEY, PLATFORM_ARCHON_INTEGRATIONS_KEY_SYNCED_SCOPE_KEY, PLATFORM_ARCHON_KEY, PLATFORM_ARCHON_MASTER_CLOUDFLARE_TOKEN_REF, PLATFORM_ARCHON_MASTER_COOLIFY_TOKEN_REF, PLATFORM_ARCHON_MASTER_COOLIFY_URL_REF, PLATFORM_AUTH_ADMIN_UNDELIVERED_SCOPE_KEY, PLATFORM_AUTH_ADMIN_USER_SCOPE_KEY, PLATFORM_AUTH_BROKER_DEPLOYED_SCOPE_KEY, PLATFORM_AUTH_BROKER_SCOPE_KEY, PLATFORM_AUTH_CONNECTOR_APP_SCOPE_KEY, PLATFORM_AUTH_INSTANCE_SCOPE_KEY, PLATFORM_AUTH_INSTANCE_UNDELIVERED_SCOPE_KEY, PLATFORM_AUTH_KEY, PLATFORM_AUTH_LOGIN_CLIENT_UNDELIVERED_SCOPE_KEY, PLATFORM_AUTH_LOGIN_CLIENT_USER_SCOPE_KEY, PLATFORM_AUTH_OWNER_INVITE_SCOPE_KEY, PLATFORM_AUTH_SMTP_SCOPE_KEY, PLATFORM_AUTH_ZITADEL_APPLIED_IMAGE_TAG_SCOPE_KEY, PLATFORM_AUTH_ZITADEL_RUN_OPTIONS_SCOPE_KEY, PLATFORM_BACKUP_DATABASE_SCOPE_KEYS, PLATFORM_BACKUP_SERVICE_KEYS, PLATFORM_FILES_APPLIED_GIT_REF_SCOPE_KEY, PLATFORM_FILES_APPLIED_HOSTNAME_SCOPE_KEY, PLATFORM_FILES_INTERNAL_URL_SCOPE_KEY, PLATFORM_FILES_INTROSPECTION_SCOPE_KEY, PLATFORM_FILES_INTROSPECTION_UNDELIVERED_SCOPE_KEY, PLATFORM_FILES_KEY, PLATFORM_FILES_MANAGEMENT_KEY_SCOPE_KEY, PLATFORM_FILES_MANAGEMENT_UNDELIVERED_SCOPE_KEY, PLATFORM_FILES_ZITADEL_PROJECT_SCOPE_KEY, PLATFORM_INTEGRATIONS_APPLIED_GITREF_SCOPE_KEY, PLATFORM_INTEGRATIONS_APPLIED_HOSTNAME_SCOPE_KEY, PLATFORM_INTEGRATIONS_KEY, PLATFORM_INTEGRATIONS_KEY_ID_SCOPE_KEY, PLATFORM_INTEGRATIONS_KEY_ROTATION_PREVIOUS_SCOPE_KEY, PLATFORM_INTEGRATIONS_KEY_ROTATION_REQUESTED_SCOPE_KEY, PLATFORM_INTEGRATIONS_KEY_ROTATION_UNDELIVERED_SCOPE_KEY, PLATFORM_INTEGRATIONS_KEY_SET_SCOPE_KEY, PLATFORM_INTEGRATIONS_KEY_UNDELIVERED_SCOPE_KEY, PLATFORM_INTEGRATIONS_RUNNER_KEYRING_DEPLOYED_SCOPE_KEY, PLATFORM_MAIL_ADMIN_KEY_SCOPE_KEY, PLATFORM_MAIL_ADMIN_UNDELIVERED_SCOPE_KEY, PLATFORM_MAIL_APPLIED_GIT_REF_SCOPE_KEY, PLATFORM_MAIL_APPLIED_HOSTNAME_SCOPE_KEY, PLATFORM_MAIL_CONNECTOR_APP_SCOPE_KEY, PLATFORM_MAIL_INTERNAL_URL_SCOPE_KEY, PLATFORM_MAIL_KEY, PLATFORM_MAIL_ZITADEL_PROJECT_SCOPE_KEY, archonBootstrapOperator, parseBrokerDeployRecord, parseLoginClientRecord, platformMailMxHost, } from './platform-diff.js';
10
11
  import { applyCallsSfuPlatformStep } from './platform-sfu.js';
11
12
  import { CreateInstanceError, createZitadelInstance, generateSystemApiKeypair, instanceWithoutBearerMessage, systemApiUsersEnv, } from './zitadel-system-api.js';
12
13
  /** The key `runPlatformSteps` records the apex zone's id under (`system:kind:key`). */
@@ -258,7 +259,10 @@ export class FoundryPlatformStepExecutor {
258
259
  recordedIds[`coolify:app:${PLATFORM_ARCHON_KEY}`]) {
259
260
  const accessDrifted = await archonAccessDrifted(this.archon.cloudflare, platform.archon.hostname, this.archon.accessEmails);
260
261
  const brokerDrifted = !accessDrifted && (await archonBrokerRegistrationDrifted(this.archon.coolify, recordedIds));
261
- if (accessDrifted || brokerDrifted)
262
+ const bootstrapOperatorDrifted = !accessDrifted &&
263
+ !brokerDrifted &&
264
+ (await archonBootstrapOperatorDrifted(this.archon.coolify, recordedIds[`coolify:app:${PLATFORM_ARCHON_KEY}`], recordedIds));
265
+ if (accessDrifted || brokerDrifted || bootstrapOperatorDrifted)
262
266
  result.archon = true;
263
267
  }
264
268
  if (platform.auth.enabled && this.auth) {
@@ -276,7 +280,10 @@ export class FoundryPlatformStepExecutor {
276
280
  if (platform.files.enabled && this.files && recordedIds[`coolify:app:${PLATFORM_FILES_KEY}`]) {
277
281
  // A Coolify read error must not fail the whole plan: treat it as not drifted.
278
282
  try {
279
- if (await filesRunnerEnvDrifted(this.files.coolify, recordedIds))
283
+ if (await filesRunnerEnvDrifted(this.files.coolify, recordedIds, {
284
+ publicHosts: this.files.publicHosts,
285
+ shareDomains: filesShareDomains(await this.declaredOrgStore.list()),
286
+ }))
280
287
  result.files = true;
281
288
  }
282
289
  catch {
@@ -297,6 +304,25 @@ export class FoundryPlatformStepExecutor {
297
304
  }
298
305
  return result;
299
306
  }
307
+ /**
308
+ * Gives the files runner a changed share-domain set and nothing else: its
309
+ * Coolify `domains`, `FILES_PUBLIC_HOSTS` and one redeploy of the runner.
310
+ * Returns the scope ids to record, or `null` when nothing changed
311
+ * (or the runner was never applied).
312
+ */
313
+ async refreshFilesShare(_platform, recordedIds) {
314
+ const runnerAppId = recordedIds[`coolify:app:${PLATFORM_FILES_KEY}`];
315
+ if (!this.files || !runnerAppId)
316
+ return null;
317
+ const wanted = filesShareDomains(await this.declaredOrgStore.list());
318
+ if (!shareDomainsChanged(recordedIds, wanted))
319
+ return null;
320
+ const { coolify } = this.files;
321
+ await coolify.updateApplication(runnerAppId, filesShareDomainsPatch(recordedIds, wanted));
322
+ await coolify.bulkUpsertEnvs(runnerAppId, filesPublicHostsEnvs(recordedIds, this.files.publicHosts, wanted));
323
+ await coolify.deploy(runnerAppId);
324
+ return { [PLATFORM_FILES_SHARE_DOMAINS_SCOPE_KEY]: normalHosts(wanted).join(',') };
325
+ }
300
326
  async execute(action, context) {
301
327
  if (action.system === 'coolify' && action.kind === 'app' && action.key === PLATFORM_AUTH_KEY) {
302
328
  if (!this.auth) {
@@ -309,7 +335,7 @@ export class FoundryPlatformStepExecutor {
309
335
  if (!this.files) {
310
336
  throw new Error('platform:files needs FoundryPlatformStepExecutor to be constructed with its files dependencies (#171)');
311
337
  }
312
- return provisionPlatformFiles(context, this.files);
338
+ return provisionPlatformFiles(context, this.files, filesShareDomains(await this.declaredOrgStore.list()));
313
339
  }
314
340
  if (action.system === 'coolify' &&
315
341
  action.kind === 'app' &&
@@ -550,7 +576,11 @@ const AUTH_BROKER_PORT = '3000';
550
576
  * falls through to the login app's own ingress rule below (login v2 answers
551
577
  * everything else, including the root).
552
578
  */
553
- const AUTH_ZITADEL_PATH_PATTERN = '^/(oidc|oauth|saml|management|admin|system|auth|assets|debug/healthz|ui/console|\\.well-known)(/|$)';
579
+ // `v2`, `v2beta` and `zitadel.<service>` are ZITADEL's v2 REST and connect
580
+ // APIs (providers' `inviteOrgOwner` calls `POST /v2/users`); without them those
581
+ // calls reach the login app and answer 404. The login app itself lives under
582
+ // `/ui/v2/login`, which none of these match.
583
+ export const AUTH_ZITADEL_PATH_PATTERN = '^/(oidc|oauth|saml|management|admin|system|auth|assets|debug/healthz|ui/console|\\.well-known|v2|v2beta|zitadel\\.[A-Za-z0-9.]+)(/|$)';
554
584
  /**
555
585
  * `@wtfalch/auth`'s own `brokerUrl` default, `<issuer>/api`
556
586
  * (`packages/auth/src/config.ts`) -- `deploy/proxy/Caddyfile`'s own
@@ -1057,6 +1087,15 @@ async function provisionPlatformAuth(context, deps, owners = []) {
1057
1087
  },
1058
1088
  { key: 'OTEL_NODE_RESOURCE_DETECTORS', value: 'none' },
1059
1089
  { key: 'API_CACHE_CONFIG', value: '{"defaultMinutes":2,"longMinutes":5}' },
1090
+ // #405: the login app's own requestId-free fallback (`resolveRedirectUri`
1091
+ // in `wtfalch/auth`'s `login/src/lib/client.ts`) is what a user without an
1092
+ // OIDC request in flight lands on after `/signedin` -- exactly the owner
1093
+ // invite's own path (set a password, then nowhere to go). Only set once
1094
+ // Archon is declared, and only ever to Archon: the platform's one landing
1095
+ // app for anyone who reaches the login app outside an app's own sign-in.
1096
+ ...(platform.archon.enabled
1097
+ ? [{ key: 'DEFAULT_REDIRECT_URI', value: `https://${platform.archon.hostname}` }]
1098
+ : []),
1060
1099
  ];
1061
1100
  // #210: read back now, not inside the deploy-decision block below -- this
1062
1101
  // push has to land before `missingBrokerInputs` is read a few lines down,
@@ -1423,8 +1462,12 @@ async function provisionPlatformAuth(context, deps, owners = []) {
1423
1462
  });
1424
1463
  await record(PLATFORM_AUTH_SMTP_SCOPE_KEY, smtp.id);
1425
1464
  }
1426
- catch {
1427
- throw new Error(`platform:auth: could not configure ZITADEL's SMTP provider for ${hostname}; the cause is left out because it can carry the Lettermint key`);
1465
+ catch (error) {
1466
+ // The cause, with the Lettermint key redacted, so a failure says why.
1467
+ const cause = redactSecrets(error instanceof Error ? error.message : String(error), [
1468
+ mailToken,
1469
+ ]);
1470
+ throw new Error(`platform:auth: could not configure ZITADEL's SMTP provider for ${hostname}: ${cause}`);
1428
1471
  }
1429
1472
  if (!context.recordedIds()[PLATFORM_AUTH_OWNER_INVITE_SCOPE_KEY]) {
1430
1473
  const owner = owners[0];
@@ -1438,14 +1481,22 @@ async function provisionPlatformAuth(context, deps, owners = []) {
1438
1481
  await record(PLATFORM_ZITADEL_ORG_SCOPE_KEY, ownerOrgId);
1439
1482
  let invitedUserId;
1440
1483
  try {
1484
+ // ZITADEL's default invite link is `/ui/v2/login/verify`, which 404s on the login app;
1485
+ // it serves `/verify` at the root.
1441
1486
  ({ userId: invitedUserId } = await mailZitadel.inviteOrgOwner(ownerOrgId, {
1442
1487
  givenName: owner.givenName,
1443
1488
  familyName: owner.familyName,
1444
1489
  email: owner.email,
1445
- }));
1490
+ }, `https://${hostname}/verify?userId={{.UserID}}&code={{.Code}}&organization={{.OrgID}}&invite=true`));
1446
1491
  }
1447
- catch {
1448
- throw new Error(`platform:auth: could not invite the platform owner into ZITADEL org ${ownerOrgId}; the cause is left out because it can carry the owner's email`);
1492
+ catch (error) {
1493
+ // The cause, with the owner's email and the Lettermint key redacted, so a failure says why.
1494
+ const cause = redactSecrets(error instanceof Error ? error.message : String(error), [
1495
+ owner.email,
1496
+ owner.email.toLowerCase(),
1497
+ mailToken,
1498
+ ]);
1499
+ throw new Error(`platform:auth: could not invite the platform owner into ZITADEL org ${ownerOrgId}: ${cause}`);
1449
1500
  }
1450
1501
  await record(PLATFORM_AUTH_OWNER_INVITE_SCOPE_KEY, invitedUserId);
1451
1502
  }
@@ -1777,7 +1828,8 @@ async function provisionPlatformMail(context, deps) {
1777
1828
  const declaredChanged = recorded[PLATFORM_MAIL_DEPLOYED_HOSTNAME_SCOPE_KEY] !== hostname ||
1778
1829
  recorded[PLATFORM_MAIL_DEPLOYED_GIT_REF_SCOPE_KEY] !== imageTag;
1779
1830
  // A new storage binds into the container only at its next start.
1780
- if (app.justCreated || !app.running || envsChanged || declaredChanged || storagesAdded) {
1831
+ const mailDeployedNow = app.justCreated || !app.running || envsChanged || declaredChanged || storagesAdded;
1832
+ if (mailDeployedNow) {
1781
1833
  // A container still up from before would answer the readiness probe with
1782
1834
  // the old deployment's health. Stop it and see it stopped first, so the
1783
1835
  // wait below only ever reads the new deployment.
@@ -1785,7 +1837,16 @@ async function provisionPlatformMail(context, deps) {
1785
1837
  await deps.coolify.raw('POST', `/applications/${appId}/stop`);
1786
1838
  await waitForMailStopped(deps.coolify, appId, runtime, sleep);
1787
1839
  }
1788
- await deps.coolify.deploy(appId);
1840
+ // #252: phase 1 only -- Coolify's own deployment record reaching
1841
+ // `'finished'`. Phase 2 (the readiness probe, `ensureMailReady` below)
1842
+ // runs right after the tunnel and DNS wiring that follows, which is what
1843
+ // `hostname` needs before it answers anything at all; running it here
1844
+ // instead would just poll a hostname that cannot resolve yet on a first
1845
+ // apply. Either way it runs before any applied ref is recorded.
1846
+ // The old container was stopped above (or never ran), so nothing stays live.
1847
+ await waitForDeploymentFinished(deps.coolify, appId, runtime, 'platform:mail', app.justCreated || !app.running
1848
+ ? `mail stays at ${recorded[PLATFORM_MAIL_APPLIED_GIT_REF_SCOPE_KEY] ?? 'no recorded ref'}`
1849
+ : 'mail is stopped until the next apply');
1789
1850
  await record(PLATFORM_MAIL_DEPLOYED_SCOPE_KEY, appId);
1790
1851
  // Right after the deploy, so a later failure (tunnel, DNS, readiness,
1791
1852
  // settings, mint) does not make the next run stop and redeploy the app.
@@ -1859,6 +1920,31 @@ async function provisionPlatformMail(context, deps) {
1859
1920
  proxied: false,
1860
1921
  });
1861
1922
  }
1923
+ // #252: phase 2, now that the tunnel and DNS above actually route
1924
+ // `hostname`. `'ready'`, not `'version'`: Stalwart's `/healthz/ready`
1925
+ // carries no version field. Run once per apply, whichever of the three
1926
+ // things below needs it first -- a redeploy just above, the OIDC-audience
1927
+ // re-apply right after, or the admin-key mint further down, none of which
1928
+ // can reach an instance that is not up yet either. Combined with phase 1
1929
+ // above having already confirmed Coolify's own deployment record
1930
+ // finished, this is what actually closes the loophole named in #252 (an
1931
+ // old container staying healthy while the deployment itself fails) -- not
1932
+ // this probe alone, which a container that merely kept answering could
1933
+ // still pass.
1934
+ let mailReadyConfirmed = false;
1935
+ const ensureMailReady = async () => {
1936
+ if (mailReadyConfirmed)
1937
+ return;
1938
+ await waitForHealthyVersion(appId, { kind: 'ready', url: `https://${hostname}${STALWART_READY_PATH}` }, runtime, sleep, 'platform:mail', `mail stays at ${recorded[PLATFORM_MAIL_APPLIED_GIT_REF_SCOPE_KEY] ?? 'no recorded ref'}`);
1939
+ mailReadyConfirmed = true;
1940
+ };
1941
+ // Also probe when the applied ref lags the target: the deployed refs are
1942
+ // recorded before this probe, so a probe that timed out on the last run must
1943
+ // run again, not be skipped because the deploy itself is already recorded.
1944
+ const appliedLagsTarget = recorded[PLATFORM_MAIL_APPLIED_HOSTNAME_SCOPE_KEY] !== hostname ||
1945
+ recorded[PLATFORM_MAIL_APPLIED_GIT_REF_SCOPE_KEY] !== imageTag;
1946
+ if (mailDeployedNow || appliedLagsTarget)
1947
+ await ensureMailReady();
1862
1948
  // ---- the mail ZITADEL project, in the platform's sign-in -------------------
1863
1949
  // #346: found or created on every run, like the files/Archon steps' own
1864
1950
  // projects -- its id is the OIDC audience Stalwart's directory checks an
@@ -1884,7 +1970,7 @@ async function provisionPlatformMail(context, deps) {
1884
1970
  // run to retry. A project deleted out of band is noticed only here, on
1885
1971
  // the next plan/apply -- the same way the files/Archon steps' own
1886
1972
  // projects are.
1887
- await waitForMailInstance(`https://${hostname}${STALWART_READY_PATH}`, runtime, sleep);
1973
+ await ensureMailReady();
1888
1974
  const recoveryEnv = (await deps.coolify.listEnvs(appId)).find((e) => e.key === 'STALWART_RECOVERY_ADMIN' && !e.is_preview);
1889
1975
  if (!recoveryEnv) {
1890
1976
  throw new Error('platform:mail: STALWART_RECOVERY_ADMIN is not set on the app; cannot authenticate to re-apply the OIDC audience');
@@ -1924,7 +2010,7 @@ async function provisionPlatformMail(context, deps) {
1924
2010
  if (lost) {
1925
2011
  throw new Error(`platform:mail: an admin API key ("${lost}") was minted but never delivered; revoke it in Stalwart and remove the recorded key ${PLATFORM_MAIL_ADMIN_UNDELIVERED_SCOPE_KEY} to mint a new one`);
1926
2012
  }
1927
- await waitForMailInstance(`https://${hostname}${STALWART_READY_PATH}`, runtime, sleep);
2013
+ await ensureMailReady();
1928
2014
  const recoveryEnv = (await deps.coolify.listEnvs(appId)).find((e) => e.key === 'STALWART_RECOVERY_ADMIN' && !e.is_preview);
1929
2015
  if (!recoveryEnv) {
1930
2016
  throw new Error('platform:mail: STALWART_RECOVERY_ADMIN is not set on the app; cannot authenticate to mint the admin key');
@@ -2304,9 +2390,6 @@ export const PLATFORM_ARCHON_CLIENT_ID_SCOPE_KEY = 'zitadel_platform_archon_clie
2304
2390
  * needed so the two apps agree the moment the broker can run.
2305
2391
  *
2306
2392
  * Known gaps, left for review:
2307
- * - `AUTHZ_BOOTSTRAP_OPERATOR`: the first operator's ZITADEL subject id. A
2308
- * person has to exist in the new instance first; not something foundry
2309
- * mints.
2310
2393
  * - Foundry's own database URL. Archon runs foundry in its panel, but foundry
2311
2394
  * does not know its own connection string at plan/apply time: that is host
2312
2395
  * config, not something `DeclaredPlatform` or held keys carry.
@@ -2333,6 +2416,8 @@ async function provisionPlatformArchon(context, deps) {
2333
2416
  const recorded = context.recordedIds();
2334
2417
  const issuer = `https://${platform.auth.hostname}`;
2335
2418
  const appliedGitRef = recorded[PLATFORM_ARCHON_APPLIED_GIT_REF_SCOPE_KEY];
2419
+ const runtime = deps.runtime ?? {};
2420
+ const sleep = runtime.sleep ?? defaultSleep;
2336
2421
  if (deps.accessEmails.length === 0) {
2337
2422
  throw new Error('platform:archon: no Access emails given; refusing to put Archon behind an Access app nobody can pass');
2338
2423
  }
@@ -2523,6 +2608,24 @@ async function provisionPlatformArchon(context, deps) {
2523
2608
  throw new Error(`platform:archon: app ${appId} lost its AUTH_APP_KEY between two reads`);
2524
2609
  }
2525
2610
  envs.push({ key: 'AUTH_APP_KEY', value: appKey });
2611
+ // The owner `platform:auth` invited is Archon's first operator.
2612
+ const bootstrapOperator = archonBootstrapOperator(recorded);
2613
+ if (bootstrapOperator)
2614
+ envs.push({ key: 'AUTHZ_BOOTSTRAP_OPERATOR', value: bootstrapOperator });
2615
+ // #406: the invited platform owner's ZITADEL subject id, once
2616
+ // `platform:auth` has recorded one -- the guard at the top of this
2617
+ // function already requires `platform:auth` to have been applied before
2618
+ // this step is reached, and `provisionPlatformAuth` records the owner
2619
+ // invite (or, with no owner declared, the literal string 'none') as part
2620
+ // of that same step, so by the time this runs the value here is whatever
2621
+ // is actually true. `bootstrapOperator` (archon's `src/lib/authz/
2622
+ // bootstrap.ts`) compares this verbatim against a signed-in user's
2623
+ // subject id, so 'none' is skipped rather than written -- it would simply
2624
+ // never match, but it is not a real id and should not look like one.
2625
+ const ownerInviteUserId = recorded[PLATFORM_AUTH_OWNER_INVITE_SCOPE_KEY];
2626
+ if (ownerInviteUserId && ownerInviteUserId !== 'none') {
2627
+ envs.push({ key: 'AUTHZ_BOOTSTRAP_OPERATOR', value: ownerInviteUserId });
2628
+ }
2526
2629
  // A value with a line break (the PEM management keys) must be flagged:
2527
2630
  // unflagged, Coolify writes it into `.env` unquoted and the container never
2528
2631
  // starts.
@@ -2631,12 +2734,32 @@ async function provisionPlatformArchon(context, deps) {
2631
2734
  await record(PLATFORM_ARCHON_ACCESS_SCOPE_KEY, accessAppId);
2632
2735
  }
2633
2736
  // ---- deploy ---------------------------------------------------------------
2634
- if (app.justCreated ||
2737
+ // #252: phase 1 of the shared deploy-health helper only -- Coolify's own
2738
+ // deployment record reaching `'finished'`, which is enough on its own to
2739
+ // catch the bug this closes (the old container stays healthy regardless
2740
+ // of whether the deployment record itself fails). Phase 2, the health
2741
+ // check, has to wait until after the tunnel and DNS below are wired: on a
2742
+ // first apply `hostname` answers nothing until then, and splitting the two
2743
+ // phases (`waitForDeploymentFinished` then `waitForHealthyVersion`,
2744
+ // instead of one `waitForNewDeployment` call) is exactly for a step shaped
2745
+ // like this one.
2746
+ // #398: the integrations key id this run wrote into Archon's env. Archon
2747
+ // counts as synced to it only after a deployment finishes and the health
2748
+ // check passes, so a still-unsynced id forces both even when nothing else
2749
+ // changed (a run that died between the env write and the deploy).
2750
+ const integrationsKeyId = inputs.envs.find((e) => e.key === 'INTEGRATIONS_MANAGEMENT_KEY_ID')?.value;
2751
+ // Not forced on a platform recorded before #398, which has no recorded id.
2752
+ const keyUnsynced = Boolean(integrationsKeyId) &&
2753
+ Boolean(recorded[PLATFORM_INTEGRATIONS_KEY_ID_SCOPE_KEY]) &&
2754
+ recorded[PLATFORM_ARCHON_INTEGRATIONS_KEY_SYNCED_SCOPE_KEY] !== integrationsKeyId;
2755
+ const archonDeployedNow = keyUnsynced ||
2756
+ app.justCreated ||
2635
2757
  !app.running ||
2636
2758
  changed ||
2637
2759
  appliedGitRef !== gitRef ||
2638
- recorded[PLATFORM_ARCHON_APPLIED_HOSTNAME_SCOPE_KEY] !== hostname) {
2639
- await deps.coolify.deploy(appId);
2760
+ recorded[PLATFORM_ARCHON_APPLIED_HOSTNAME_SCOPE_KEY] !== hostname;
2761
+ if (archonDeployedNow) {
2762
+ await waitForDeploymentFinished(deps.coolify, appId, runtime, 'platform:archon', `archon stays at ${appliedGitRef ?? 'no recorded ref'} on ${recorded[PLATFORM_ARCHON_APPLIED_HOSTNAME_SCOPE_KEY] ?? 'no recorded hostname'}`);
2640
2763
  }
2641
2764
  // ---- tunnel and DNS ---------------------------------------------------------
2642
2765
  const existingTunnel = await deps.cloudflare.findTunnelByName(PLATFORM_ARCHON_TUNNEL_NAME);
@@ -2690,6 +2813,24 @@ async function provisionPlatformArchon(context, deps) {
2690
2813
  proxied: true,
2691
2814
  });
2692
2815
  }
2816
+ // #252: phase 2, now that the tunnel and DNS above actually route
2817
+ // `hostname`. `'ready'`, not `'version'`: archon's `/api/health`
2818
+ // (`@wtfalch/reporting`'s `healthHandler`, the same route `executor.ts`'s
2819
+ // `healthUrls` already assumes every hosted app serves) carries no
2820
+ // version field this package knows to check, so a plain 2xx is the mode
2821
+ // available -- combined with phase 1 above having already confirmed
2822
+ // Coolify's own deployment record finished, which is what actually closes
2823
+ // the loophole named in #252 (an old container staying healthy while the
2824
+ // deployment itself fails).
2825
+ if (archonDeployedNow) {
2826
+ await waitForHealthyVersion(appId, { kind: 'ready', url: `https://${hostname}/api/health` }, runtime, sleep, 'platform:archon', `archon stays at ${appliedGitRef ?? 'no recorded ref'} on ${recorded[PLATFORM_ARCHON_APPLIED_HOSTNAME_SCOPE_KEY] ?? 'no recorded hostname'}`);
2827
+ }
2828
+ // #398: the deployment finished and answers health, so Archon now runs with
2829
+ // the key id its env carries. `provisionPlatformIntegrations` drops the
2830
+ // previous public key only on this marker.
2831
+ if (keyUnsynced && integrationsKeyId) {
2832
+ await record(PLATFORM_ARCHON_INTEGRATIONS_KEY_SYNCED_SCOPE_KEY, integrationsKeyId);
2833
+ }
2693
2834
  // Last, after every write the applied values depend on, so a run that failed
2694
2835
  // halfway plans again.
2695
2836
  await record(PLATFORM_ARCHON_APPLIED_HOSTNAME_SCOPE_KEY, hostname);
@@ -2699,6 +2840,7 @@ async function provisionPlatformArchon(context, deps) {
2699
2840
  // (whose AUTH_APPS never carried this registration) from one still holding
2700
2841
  // it.
2701
2842
  await record(PLATFORM_ARCHON_APPLIED_BROKER_SCOPE_KEY, brokerAppId);
2843
+ await record(PLATFORM_ARCHON_APPLIED_OPERATOR_SCOPE_KEY, bootstrapOperator ?? 'none');
2702
2844
  return { resourceId: appId };
2703
2845
  }
2704
2846
  // #211 round 6: mirrors `wtfalch/auth/broker/src/apps.ts`'s own `isValidFrom`
@@ -3074,6 +3216,32 @@ async function archonBrokerRegistrationDrifted(coolify, recordedIds) {
3074
3216
  return false;
3075
3217
  }
3076
3218
  }
3219
+ /**
3220
+ * #406: whether the platform owner invite recorded under
3221
+ * {@link PLATFORM_AUTH_OWNER_INVITE_SCOPE_KEY} is a real user id (not
3222
+ * absent, not the literal `'none'` {@link provisionPlatformAuth} records
3223
+ * when no owner is declared) that Archon's live env does not yet carry as
3224
+ * `AUTHZ_BOOTSTRAP_OPERATOR`. `provisionPlatformAuth`'s own invite can
3225
+ * record after Archon's `coolify:app` id already has -- the owner invite
3226
+ * happens inside `platform:auth` only once its Lettermint token is held,
3227
+ * which Archon's own apply does not wait on -- so without this, an owner
3228
+ * invited after Archon's first deploy would need a redeploy by hand, the
3229
+ * way companyincluded.com's did. A failed read is not drift, the same rule
3230
+ * {@link archonAccessDrifted} and {@link archonBrokerRegistrationDrifted}
3231
+ * follow: the next plan reads again.
3232
+ */
3233
+ async function archonBootstrapOperatorDrifted(coolify, appId, recordedIds) {
3234
+ const invitedUserId = recordedIds[PLATFORM_AUTH_OWNER_INVITE_SCOPE_KEY];
3235
+ if (!appId || !invitedUserId || invitedUserId === 'none')
3236
+ return false;
3237
+ try {
3238
+ const live = await currentEnvValue(coolify, appId, 'AUTHZ_BOOTSTRAP_OPERATOR');
3239
+ return live !== invitedUserId;
3240
+ }
3241
+ catch {
3242
+ return false;
3243
+ }
3244
+ }
3077
3245
  /**
3078
3246
  * What Archon reads from each enabled service, gathered before anything is
3079
3247
  * built. Either everything is there and the env values come back, or the first
@@ -3091,6 +3259,23 @@ async function collectArchonServiceInputs(platform, deps, recorded) {
3091
3259
  if (services.length > 0 && !deps.heldKeys) {
3092
3260
  return block(`the host built the platform executor without a held-key reader, and ${services.join(', ')} ${services.length === 1 ? 'is' : 'are'} enabled`);
3093
3261
  }
3262
+ const rotationUndelivered = recorded[PLATFORM_INTEGRATIONS_KEY_ROTATION_UNDELIVERED_SCOPE_KEY];
3263
+ if (services.includes('integrations') &&
3264
+ rotationUndelivered &&
3265
+ rotationUndelivered !== recorded[PLATFORM_INTEGRATIONS_KEY_ID_SCOPE_KEY]) {
3266
+ // #398: the held key may already be the new pair while the recorded id is
3267
+ // still the old one; writing both would sign with a key id the runner
3268
+ // does not match.
3269
+ return block(`the integrations rotation key ${rotationUndelivered} is minted but not confirmed delivered and pinned`);
3270
+ }
3271
+ const activeKeyId = recorded[PLATFORM_INTEGRATIONS_KEY_ID_SCOPE_KEY];
3272
+ if (services.includes('integrations') &&
3273
+ activeKeyId &&
3274
+ recorded[PLATFORM_INTEGRATIONS_RUNNER_KEYRING_DEPLOYED_SCOPE_KEY] !== activeKeyId) {
3275
+ // #398: the runner must run with the new public key before Archon signs
3276
+ // with the new private key.
3277
+ return block(`the integrations runner is not yet deployed with management key ${activeKeyId}`);
3278
+ }
3094
3279
  for (const service of services) {
3095
3280
  const needs = service === 'jobs'
3096
3281
  ? {
@@ -3154,7 +3339,10 @@ async function collectArchonServiceInputs(platform, deps, recorded) {
3154
3339
  value: INTEGRATIONS_MANAGEMENT_ISSUER,
3155
3340
  }, {
3156
3341
  key: 'INTEGRATIONS_MANAGEMENT_KEY_ID',
3157
- value: INTEGRATIONS_MANAGEMENT_KEY_ID,
3342
+ // #398: the active id, which a rotation moves past `foundry-1` --
3343
+ // falls back to the fixed constant for a platform that applied
3344
+ // integrations before this scope key existed.
3345
+ value: recorded[PLATFORM_INTEGRATIONS_KEY_ID_SCOPE_KEY] ?? INTEGRATIONS_MANAGEMENT_KEY_ID,
3158
3346
  }, { key: 'INTEGRATIONS_MANAGEMENT_AUDIENCE', value: url });
3159
3347
  }
3160
3348
  else {
@@ -3703,9 +3891,15 @@ async function provisionPlatformAi(context, deps) {
3703
3891
  await coolify.raw('POST', `/applications/${migrate.appId}/stop`);
3704
3892
  await waitForStopped(coolify, migrate.appId, runtime, sleep);
3705
3893
  }
3706
- await coolify.deploy(migrate.appId);
3894
+ const migrateDeployed = await coolify.deploy(migrate.appId);
3707
3895
  try {
3708
3896
  await waitForAiMigration(coolify, migrate.appId, runtime, sleep);
3897
+ // #399: the migrate job can read healthy well before Coolify's own
3898
+ // deployment does -- it is still waiting out the image's HEALTHCHECK
3899
+ // start period -- so stopping it below waits for Coolify's own record
3900
+ // too, or it can find the container already gone and mark a deployment
3901
+ // that actually migrated as `failed`.
3902
+ await waitForMigrateDeployment(coolify, migrate.appId, queuedDeploymentUuid(migrateDeployed) ?? 'untracked', 'platform:ai', 'ai-migrate', runtime.now ?? Date.now, runtime.migrateTimeoutMs ?? 600_000, runtime.migrateIntervalMs ?? 5_000, sleep);
3709
3903
  }
3710
3904
  catch (error) {
3711
3905
  if (error instanceof MigrationNotHealthyError) {
@@ -3718,49 +3912,44 @@ async function provisionPlatformAi(context, deps) {
3718
3912
  }
3719
3913
  await coolify.raw('POST', `/applications/${migrate.appId}/stop`);
3720
3914
  }
3721
- // `deploy` only queues a build that takes minutes, so the id of the host's
3722
- // deployment is kept for the mint below to wait on.
3723
- const runnerDeployment = deployRunner ? await coolify.deploy(runner.appId) : undefined;
3915
+ // #252: confirmed before anything below -- the applied ref recorded just
3916
+ // after, and the mint further down -- depends on the new version actually
3917
+ // being live. Never read the old container's healthy status as the new
3918
+ // deployment's (`CLAUDE.md`'s platform-step rule 5): `'deployment-only'`,
3919
+ // because the runner's own doc comment above says why there is no url to
3920
+ // check instead ("Nothing public is wired: no Coolify `domains`, tunnel,
3921
+ // DNS record or Access app"). This replaces the old deploy-then-defer-the-
3922
+ // wait-until-the-mint-needs-it shape (#375) with the one every other
3923
+ // platform step uses: the wait happens once, right here, so a failed
3924
+ // deployment is caught before either thing downstream trusts it.
3724
3925
  if (deployRunner) {
3725
- await record(PLATFORM_AI_RUNNER_DEPLOYMENT_SCOPE_KEY, queuedDeploymentUuid(runnerDeployment) ?? 'untracked');
3926
+ await waitForNewDeployment(coolify, runner.appId, { kind: 'deployment-only' }, runtime, sleep, 'platform:ai', `the runner stays at ${recorded[PLATFORM_AI_APPLIED_GITREF_SCOPE_KEY] ?? 'no recorded ref'}`);
3927
+ }
3928
+ if (deployWorker) {
3929
+ await waitForNewDeployment(coolify, worker.appId, { kind: 'deployment-only' }, runtime, sleep, 'platform:ai', `the worker stays at ${recorded[PLATFORM_AI_APPLIED_GITREF_SCOPE_KEY] ?? 'no recorded ref'}`);
3726
3930
  }
3727
- if (deployWorker)
3728
- await coolify.deploy(worker.appId);
3729
3931
  await record(PLATFORM_AI_DEPLOY_PENDING_SCOPE_KEY, '');
3730
- // The deploys are done, so record what they deployed now. A later failure
3731
- // at the mint or delivery must not make the next apply see a changed
3732
- // hostname or gitRef and redeploy the live host again. The planner still
3733
- // plans the step until the resource id is recorded, once the key is delivered.
3932
+ // The deploys are confirmed, so record what they deployed now. A later
3933
+ // failure at the mint or delivery must not make the next apply see a
3934
+ // changed hostname or gitRef and redeploy the live host again. The
3935
+ // planner still plans the step until the resource id is recorded, once
3936
+ // the key is delivered.
3734
3937
  await record(PLATFORM_AI_APPLIED_HOSTNAME_SCOPE_KEY, hostname);
3735
3938
  await record(PLATFORM_AI_APPLIED_GITREF_SCOPE_KEY, gitRef);
3736
3939
  const internalUrl = `http://${PLATFORM_AI_RUNNER.internalName}:${PLATFORM_AI_PORT}`;
3737
3940
  await record(PLATFORM_AI_INTERNAL_URL_SCOPE_KEY, internalUrl);
3738
3941
  // ---- Archon's AI management key, once ------------------------------------
3942
+ // #252: nothing left to wait on here -- the deployment above is already
3943
+ // confirmed finished by the time this runs. The host's own readiness
3944
+ // beyond that (a 503 for a moment after `AI_WORKER_TIMEOUT_MS` warms up)
3945
+ // is unchanged from before this change: a single mint attempt, not
3946
+ // retried here.
3739
3947
  const now = context.recordedIds();
3740
3948
  if (!now[PLATFORM_AI_MANAGEMENT_KEY_SCOPE_KEY]) {
3741
3949
  const lost = now[PLATFORM_AI_MANAGEMENT_KEY_UNDELIVERED_SCOPE_KEY];
3742
3950
  if (lost) {
3743
3951
  throw new Error(`platform:ai: AI management key ${lost} was minted but never delivered; revoke it and remove the recorded key ${PLATFORM_AI_MANAGEMENT_KEY_UNDELIVERED_SCOPE_KEY} to mint a new one`);
3744
3952
  }
3745
- // The host answers 503 (or nothing) until the deployment that carries its
3746
- // management settings has finished.
3747
- const queued = context.recordedIds()[PLATFORM_AI_RUNNER_DEPLOYMENT_SCOPE_KEY];
3748
- if (queued) {
3749
- try {
3750
- await waitForAiDeployment(coolify, runner.appId, queued, runtime, sleep);
3751
- }
3752
- catch (error) {
3753
- // Only a build still running is worth waiting on again. A deployment
3754
- // that failed, was cancelled or cannot be read is not: mark the host
3755
- // pending so the next apply queues a new one instead of polling it.
3756
- if (!(error instanceof AiDeploymentTimeoutError)) {
3757
- await record(PLATFORM_AI_DEPLOY_PENDING_SCOPE_KEY, '1');
3758
- await record(PLATFORM_AI_RUNNER_DEPLOYMENT_SCOPE_KEY, '');
3759
- }
3760
- throw error;
3761
- }
3762
- await record(PLATFORM_AI_RUNNER_DEPLOYMENT_SCOPE_KEY, '');
3763
- }
3764
3953
  // #201: a test injects its own minter; without one, build the real one
3765
3954
  // over ai's own POST /v1/platform/keys, signed with the key pinned above.
3766
3955
  const minter = deps.managementKeyMinter ??
@@ -3900,22 +4089,23 @@ function queuedDeploymentUuid(result) {
3900
4089
  return typeof uuid === 'string' && uuid ? uuid : undefined;
3901
4090
  }
3902
4091
  /**
3903
- * Waits until the host's queued deployment has `finished`; throws on
3904
- * `failed`/`cancelled-by-user` or once `migrateTimeoutMs` has passed. With no
3905
- * deployment id (`untracked`) the only signal left is the app's own status,
3906
- * which an old container can already satisfy, so that is best effort.
4092
+ * Waits until `deploymentUuid`'s Coolify deployment has `finished`; throws on
4093
+ * `failed`/`cancelled-by-user` (via `onEnded`) or once `timeoutMs` has passed
4094
+ * (via `onTimeout`). With no deployment id (`untracked`) the only signal left
4095
+ * is the app's own status, which an old container can already satisfy, so
4096
+ * that case is best effort and returns as soon as the app itself reads
4097
+ * healthy. Shared by every caller that must not act on an app's status
4098
+ * until Coolify's own deployment record agrees it is done (#399: Coolify's
4099
+ * own `HEALTHCHECK` start period can outlive the app already reading
4100
+ * `running:healthy`; acting then finds the container already gone, and
4101
+ * Coolify marks its own deployment `failed` -- "Removing the new version" --
4102
+ * though whatever the app was doing had already finished).
3907
4103
  */
3908
- /** The host's deployment was still running when the wait gave up. */
3909
- class AiDeploymentTimeoutError extends Error {
3910
- }
3911
- async function waitForAiDeployment(coolify, appId, deploymentUuid, runtime, sleep) {
3912
- const now = runtime.now ?? Date.now;
3913
- const timeout = runtime.migrateTimeoutMs ?? 600_000;
3914
- const interval = runtime.migrateIntervalMs ?? 5_000;
3915
- const deadline = now() + timeout;
4104
+ async function waitForCoolifyDeployment(coolify, appId, deploymentUuid, now, timeoutMs, intervalMs, sleep, onEnded, onTimeout) {
4105
+ const deadline = now() + timeoutMs;
3916
4106
  let status;
3917
4107
  for (;;) {
3918
- await sleep(interval);
4108
+ await sleep(intervalMs);
3919
4109
  if (deploymentUuid === 'untracked') {
3920
4110
  status = (await coolify.getApplication(appId)).status;
3921
4111
  if (status?.startsWith('running:healthy'))
@@ -3926,15 +4116,25 @@ async function waitForAiDeployment(coolify, appId, deploymentUuid, runtime, slee
3926
4116
  status = typeof found?.status === 'string' ? found.status : undefined;
3927
4117
  if (status === 'finished')
3928
4118
  return;
3929
- if (status === 'failed' || status === 'cancelled-by-user') {
3930
- throw new Error(`platform:ai: the host's deployment for ${appId} ended ${status}; the AI management key was not minted. Check its build log in Coolify, then apply again`);
3931
- }
3932
- }
3933
- if (now() >= deadline) {
3934
- throw new AiDeploymentTimeoutError(`platform:ai: the host's deployment for ${appId} did not finish within ${Math.round(timeout / 1000)}s (last status ${status ?? 'unknown'}); the AI management key was not minted. Apply again`);
4119
+ if (status === 'failed' || status === 'cancelled-by-user')
4120
+ throw onEnded(status);
3935
4121
  }
4122
+ if (now() >= deadline)
4123
+ throw onTimeout(status ?? 'unknown');
3936
4124
  }
3937
4125
  }
4126
+ /**
4127
+ * Waits until a migrate job's own queued deployment has `finished`, so stopping a migrate job never races Coolify's own
4128
+ * `HEALTHCHECK` start period (#399: every platform migrate job idles rather
4129
+ * than exits once its migration succeeds, so it reports `running:healthy`
4130
+ * well before Coolify's own deployment does, and stopping it on that signal
4131
+ * alone can make Coolify mark a deployment that actually migrated `failed`).
4132
+ * `label` and `jobName` build an error naming the step and the job, never
4133
+ * just "the deployment".
4134
+ */
4135
+ async function waitForMigrateDeployment(coolify, appId, deploymentUuid, label, jobName, now, timeoutMs, intervalMs, sleep) {
4136
+ await waitForCoolifyDeployment(coolify, appId, deploymentUuid, now, timeoutMs, intervalMs, sleep, (status) => new Error(`${label}: ${jobName} (${appId})'s deployment ended ${status}; check its build log in Coolify, then apply again`), (status) => new Error(`${label}: ${jobName} (${appId})'s deployment did not finish within ${Math.round(timeoutMs / 1000)}s (last status ${status}); apply again`));
4137
+ }
3938
4138
  /**
3939
4139
  * Why a mint failed, safe to show: the status ai answered (the minter's own
3940
4140
  * fixed message), or a network error's `name` and `cause.code`. Never a
@@ -4390,7 +4590,9 @@ const PLATFORM_FILES_RUNNER_SCOPE_KEY = 'coolify_platform_files_runner_app';
4390
4590
  * Coolify network, so the host must run there to use it for management
4391
4591
  * calls.
4392
4592
  */
4393
- async function provisionPlatformFiles(context, deps) {
4593
+ async function provisionPlatformFiles(context, deps,
4594
+ /** {@link filesShareDomains} of every declared org: each is a runner domain and a public host. */
4595
+ shareDomains) {
4394
4596
  const { platform } = context;
4395
4597
  const gitRef = platform.files.gitRef;
4396
4598
  const recorded = context.recordedIds();
@@ -4492,7 +4694,11 @@ async function provisionPlatformFiles(context, deps) {
4492
4694
  dockerfile_target_build: 'runner',
4493
4695
  custom_internal_name: FILES_RUNNER_INTERNAL_NAME,
4494
4696
  git_branch: gitRef,
4697
+ // Coolify wants full URLs. Written only when the set changed, so a runner
4698
+ // nobody gave a share host keeps no `domains`.
4699
+ ...filesShareDomainsPatch(recorded, shareDomains),
4495
4700
  });
4701
+ context.recordScopeId(PLATFORM_FILES_SHARE_DOMAINS_SCOPE_KEY, normalHosts(shareDomains).join(','));
4496
4702
  // ---- database env ----------------------------------------------------------
4497
4703
  // The migrate app sets `files_rt`'s password from DATABASE_URL on every
4498
4704
  // boot (files' `scripts/db-runtime-role.sh`), and the runner connects with
@@ -4543,11 +4749,7 @@ async function provisionPlatformFiles(context, deps) {
4543
4749
  value: deps.clamd.port,
4544
4750
  });
4545
4751
  }
4546
- if (deps.publicHosts)
4547
- runnerOptionalEnvs.push({
4548
- key: 'FILES_PUBLIC_HOSTS',
4549
- value: deps.publicHosts,
4550
- });
4752
+ runnerOptionalEnvs.push(...filesPublicHostsEnvs(recorded, deps.publicHosts, shareDomains));
4551
4753
  if (deps.jobsSigningSecrets)
4552
4754
  runnerOptionalEnvs.push({
4553
4755
  key: 'JOBS_SIGNING_SECRETS',
@@ -4628,10 +4830,22 @@ async function provisionPlatformFiles(context, deps) {
4628
4830
  // deploy. Stopped first, so the wait below cannot pass on the old
4629
4831
  // container before the new migration has run.
4630
4832
  await stopIfRunning(deps.coolify, migrateApp.appId);
4631
- await deps.coolify.deploy(migrateApp.appId);
4833
+ const migrateDeployed = await deps.coolify.deploy(migrateApp.appId);
4632
4834
  await waitForMigration(deps.coolify, migrateApp.appId, runtime, sleep);
4835
+ // #399: files-migrate idles rather than exits once it has migrated, so it
4836
+ // reads healthy well before Coolify's own deployment does; stopping it on
4837
+ // that signal alone can find the container already gone and make Coolify
4838
+ // mark a deployment that actually migrated as `failed`.
4839
+ await waitForMigrateDeployment(deps.coolify, migrateApp.appId, queuedDeploymentUuid(migrateDeployed) ?? 'untracked', 'platform:files', 'files-migrate', runtime.now ?? Date.now, runtime.migrateTimeoutMs ?? 900_000, runtime.migrateIntervalMs ?? 10_000, sleep);
4633
4840
  await deps.coolify.raw('POST', `/applications/${migrateApp.appId}/stop`);
4634
- await deps.coolify.deploy(runnerApp.appId);
4841
+ // #252: `'deployment-only'` -- the runner's own doc comment above says why
4842
+ // ("The runner is created without `domains`... Nothing public is wired"):
4843
+ // there is no url this executor, running outside files' own Docker
4844
+ // network, could ever reach, so `coolify.deployAndWait` reaching Coolify's
4845
+ // own deployment record is the whole check. Still enough to catch the bug
4846
+ // this closes: the applied ref recorded below while the *old* container
4847
+ // (never read here) stayed healthy through a failed deploy.
4848
+ await waitForNewDeployment(deps.coolify, runnerApp.appId, { kind: 'deployment-only' }, runtime, sleep, 'platform:files', `the runner stays at ${recorded[PLATFORM_FILES_APPLIED_GIT_REF_SCOPE_KEY] ?? 'no recorded ref'}`);
4635
4849
  await record(PLATFORM_FILES_INTERNAL_URL_SCOPE_KEY, `http://${FILES_RUNNER_INTERNAL_NAME}:${FILES_PORT}`);
4636
4850
  // Last: a run that failed anywhere above is planned again.
4637
4851
  context.recordScopeId(PLATFORM_FILES_APPLIED_HOSTNAME_SCOPE_KEY, platform.files.hostname);
@@ -4708,11 +4922,21 @@ function filesDepsInvalid(deps) {
4708
4922
  * nothing ever re-checked whether its env could actually pass a health
4709
4923
  * check.
4710
4924
  */
4711
- async function filesRunnerEnvDrifted(coolify, recordedIds) {
4925
+ async function filesRunnerEnvDrifted(coolify, recordedIds, hosts = {}) {
4712
4926
  const runnerAppId = recordedIds[`coolify:app:${PLATFORM_FILES_KEY}`];
4713
4927
  if (!runnerAppId)
4714
4928
  return false;
4715
- const present = new Set((await coolify.listEnvs(runnerAppId)).filter((e) => !e.is_preview && e.value).map((e) => e.key));
4929
+ // The share set the runner was last given differs from the one now wanted.
4930
+ if (shareDomainsChanged(recordedIds, hosts.shareDomains))
4931
+ return true;
4932
+ const live = (await coolify.listEnvs(runnerAppId)).filter((e) => !e.is_preview);
4933
+ const envs = live.filter((e) => e.value);
4934
+ const present = new Set(envs.map((e) => e.key));
4935
+ // An unset or blank live value equals an empty wanted one.
4936
+ const wantedHosts = filesPublicHostsValue(hosts.publicHosts, hosts.shareDomains);
4937
+ if ((envs.find((e) => e.key === 'FILES_PUBLIC_HOSTS')?.value ?? '') !== wantedHosts) {
4938
+ return true;
4939
+ }
4716
4940
  const required = [
4717
4941
  'R2_BUCKET',
4718
4942
  'R2_ENDPOINT',
@@ -4876,11 +5100,19 @@ export const INTEGRATIONS_MANAGEMENT_KEY_ID = 'foundry-1';
4876
5100
  * 3. The migrate app first, once per `gitRef`: stop it if running, deploy it,
4877
5101
  * wait until it reports healthy, stop it, and record the ref it migrated.
4878
5102
  * Only then are the runner and worker deployed.
4879
- * 4. The management key, once: an Ed25519 pair minted here, its id recorded
4880
- * before delivery, its private half handed to
4881
- * {@link PlatformIntegrationsDeps.deliverSecret} and its public half pinned
4882
- * on the runner as `MANAGEMENT_PUBLIC_KEYS`, with `MANAGEMENT_ISSUER`. The
4883
- * guard is recorded after both.
5103
+ * 4. The management key, once, and rotated on request (#398): an Ed25519
5104
+ * pair minted here, its id recorded before delivery, its private half
5105
+ * handed to {@link PlatformIntegrationsDeps.deliverSecret} and its public
5106
+ * half pinned on the runner as `MANAGEMENT_PUBLIC_KEYS`, with
5107
+ * `MANAGEMENT_ISSUER`. The guard is recorded after both.
5108
+ * A rotation, requested by recording
5109
+ * {@link PLATFORM_INTEGRATIONS_KEY_ROTATION_REQUESTED_SCOPE_KEY}, mints a second
5110
+ * pair under a new key id the same way, pinned *beside* the first rather
5111
+ * than replacing it; the first is dropped only once
5112
+ * {@link PLATFORM_ARCHON_INTEGRATIONS_KEY_SYNCED_SCOPE_KEY} shows Archon
5113
+ * holding the new id. Archon reads the active id back from
5114
+ * {@link PLATFORM_INTEGRATIONS_KEY_ID_SCOPE_KEY}, so a rotation reaches it
5115
+ * without this step touching Archon directly.
4884
5116
  * 5. The applied `hostname` and `gitRef` are recorded last. A later change
4885
5117
  * plans the step again (`planIntegrations`), which patches `git_branch`
4886
5118
  * and the runner's `domains`, reruns the migration for a new ref, and
@@ -5053,21 +5285,28 @@ async function provisionPlatformIntegrations(action, context, deps) {
5053
5285
  // container's healthy status is never read as the new deployment's.
5054
5286
  if (migrateApp.running)
5055
5287
  await coolify.raw('POST', `/applications/${migrateApp.appId}/stop`);
5056
- await coolify.deploy(migrateApp.appId);
5288
+ const migrateDeployed = await coolify.deploy(migrateApp.appId);
5057
5289
  await waitForIntegrationsMigration(coolify, migrateApp.appId, runtime, sleep);
5290
+ // #399: integrations-migrate idles rather than exits once it has
5291
+ // migrated (the Dockerfile's own comment, below), so it reads healthy
5292
+ // well before Coolify's own deployment does; stopping it on that signal
5293
+ // alone can find the container already gone and make Coolify mark a
5294
+ // deployment that actually migrated as `failed`.
5295
+ await waitForMigrateDeployment(coolify, migrateApp.appId, queuedDeploymentUuid(migrateDeployed) ?? 'untracked', 'platform:integrations', 'integrations-migrate', runtime.now ?? Date.now, runtime.migrateTimeoutMs ?? 20 * 60_000, runtime.migratePollMs ?? 10_000, sleep);
5058
5296
  // It idles after migrating rather than exiting (the Dockerfile's own
5059
5297
  // comment), so it is stopped here; a person starts it again for
5060
5298
  // maintenance commands.
5061
5299
  await coolify.raw('POST', `/applications/${migrateApp.appId}/stop`);
5062
5300
  await record(INTEGRATIONS_MIGRATED_SCOPE_KEY, gitRef);
5063
5301
  }
5064
- // ---- the management key, once --------------------------------------------------
5302
+ // ---- the management key, once, and rotated on request (#398) -------------
5065
5303
  // The pair's id is recorded and persisted before the private half is
5066
5304
  // delivered. The guard is recorded only after delivery and the write of the
5067
5305
  // public half. A run that dies in between leaves the id without the guard:
5068
5306
  // the planner then reports the key and mints nothing, so a second pair can
5069
5307
  // never replace one the host may already hold.
5070
5308
  let keyJustSet = false;
5309
+ let rotated = false;
5071
5310
  if (!context.recordedIds()[PLATFORM_INTEGRATIONS_KEY_SET_SCOPE_KEY]) {
5072
5311
  if (context.recordedIds()[PLATFORM_INTEGRATIONS_KEY_UNDELIVERED_SCOPE_KEY]) {
5073
5312
  throw new Error(`platform:integrations: management key ${INTEGRATIONS_MANAGEMENT_KEY_ID} was minted but not confirmed delivered and pinned; not minting another. Check the held key and the runner's MANAGEMENT_PUBLIC_KEYS, then remove the recorded key ${PLATFORM_INTEGRATIONS_KEY_UNDELIVERED_SCOPE_KEY} to mint a new one`);
@@ -5077,20 +5316,7 @@ async function provisionPlatformIntegrations(action, context, deps) {
5077
5316
  privateKeyEncoding: { type: 'pkcs8', format: 'pem' },
5078
5317
  });
5079
5318
  await record(PLATFORM_INTEGRATIONS_KEY_UNDELIVERED_SCOPE_KEY, INTEGRATIONS_MANAGEMENT_KEY_ID);
5080
- const attempts = runtime.deliverAttempts ?? 3;
5081
- for (let attempt = 1;; attempt += 1) {
5082
- try {
5083
- await deps.deliverSecret(INTEGRATIONS_MANAGEMENT_KEY_REF, privateKey);
5084
- break;
5085
- }
5086
- catch {
5087
- // Retried; the error is dropped in case it echoes the key.
5088
- if (attempt >= attempts) {
5089
- throw new Error(`platform:integrations: management key ${INTEGRATIONS_MANAGEMENT_KEY_ID} was minted but could not be delivered after ${attempts} attempts`);
5090
- }
5091
- await sleep(1000 * attempt);
5092
- }
5093
- }
5319
+ await deliverManagementKey(deps, runtime, sleep, privateKey, INTEGRATIONS_MANAGEMENT_KEY_ID);
5094
5320
  await coolify.bulkUpsertEnvs(runnerApp.appId, [
5095
5321
  { key: 'MANAGEMENT_ISSUER', value: INTEGRATIONS_MANAGEMENT_ISSUER },
5096
5322
  {
@@ -5098,28 +5324,190 @@ async function provisionPlatformIntegrations(action, context, deps) {
5098
5324
  value: JSON.stringify({ [INTEGRATIONS_MANAGEMENT_KEY_ID]: publicKey }),
5099
5325
  },
5100
5326
  ]);
5327
+ await record(PLATFORM_INTEGRATIONS_KEY_ID_SCOPE_KEY, INTEGRATIONS_MANAGEMENT_KEY_ID);
5101
5328
  await record(PLATFORM_INTEGRATIONS_KEY_SET_SCOPE_KEY, runnerApp.appId);
5102
5329
  keyJustSet = true;
5103
5330
  }
5331
+ else if (context.recordedIds()[PLATFORM_INTEGRATIONS_KEY_ROTATION_REQUESTED_SCOPE_KEY] &&
5332
+ !context.recordedIds()[PLATFORM_INTEGRATIONS_KEY_ROTATION_PREVIOUS_SCOPE_KEY]) {
5333
+ // A rotation, requested by the recorded scope id
5334
+ // `PLATFORM_INTEGRATIONS_KEY_ROTATION_REQUESTED_SCOPE_KEY`.
5335
+ // Mints a new pair under the next key id and pins it *beside* the
5336
+ // current one -- never replacing it outright, so a request signed with
5337
+ // the current key during the overlap window still verifies.
5338
+ // `rotationUndelivered` is never cleared on success (the once-only
5339
+ // mint's own `UNDELIVERED` key never is either) -- a completed rotation
5340
+ // leaves it equal to the now-active id, which is what lets the *next*
5341
+ // rotation request read "no attempt in flight" without this step ever
5342
+ // clearing it. It differs from the active id only while a mint is
5343
+ // unconfirmed: minted and recorded, but not yet delivered and pinned.
5344
+ const previousKeyId = context.recordedIds()[PLATFORM_INTEGRATIONS_KEY_ID_SCOPE_KEY] ??
5345
+ INTEGRATIONS_MANAGEMENT_KEY_ID;
5346
+ const rotationUndelivered = context.recordedIds()[PLATFORM_INTEGRATIONS_KEY_ROTATION_UNDELIVERED_SCOPE_KEY];
5347
+ if (rotationUndelivered && rotationUndelivered !== previousKeyId) {
5348
+ throw new Error(`platform:integrations: rotation key ${rotationUndelivered} was minted but not confirmed delivered and pinned; not minting another. Check the held key and the runner's MANAGEMENT_PUBLIC_KEYS, then remove the recorded key ${PLATFORM_INTEGRATIONS_KEY_ROTATION_UNDELIVERED_SCOPE_KEY} and set ${PLATFORM_INTEGRATIONS_KEY_ROTATION_REQUESTED_SCOPE_KEY} again to mint a new one`);
5349
+ }
5350
+ const keyring = await currentManagementPublicKeys(coolify, runnerApp.appId);
5351
+ const nextKeyId = nextManagementKeyId(previousKeyId, keyring);
5352
+ const { publicKey, privateKey } = generateKeyPairSync('ed25519', {
5353
+ publicKeyEncoding: { type: 'spki', format: 'pem' },
5354
+ privateKeyEncoding: { type: 'pkcs8', format: 'pem' },
5355
+ });
5356
+ await record(PLATFORM_INTEGRATIONS_KEY_ROTATION_UNDELIVERED_SCOPE_KEY, nextKeyId);
5357
+ await deliverManagementKey(deps, runtime, sleep, privateKey, nextKeyId);
5358
+ keyring[nextKeyId] = publicKey;
5359
+ await coolify.bulkUpsertEnvs(runnerApp.appId, [
5360
+ { key: 'MANAGEMENT_PUBLIC_KEYS', value: JSON.stringify(keyring) },
5361
+ ]);
5362
+ // The previous id and the new active id go down in one persist: a crash
5363
+ // can never leave the new id active with the previous one unrecorded, or
5364
+ // the previous one recorded while still active.
5365
+ context.recordScopeId(PLATFORM_INTEGRATIONS_KEY_ROTATION_PREVIOUS_SCOPE_KEY, previousKeyId);
5366
+ context.recordScopeId(PLATFORM_INTEGRATIONS_KEY_ID_SCOPE_KEY, nextKeyId);
5367
+ await context.persist?.();
5368
+ // One request, one rotation: cleared only now the new key is delivered
5369
+ // and pinned. A failure before this leaves the request set and the
5370
+ // undelivered id ahead of the active id, which the planner reports as
5371
+ // blocked and the next run refuses to mint past.
5372
+ await record(PLATFORM_INTEGRATIONS_KEY_ROTATION_REQUESTED_SCOPE_KEY, '');
5373
+ rotated = true;
5374
+ keyJustSet = true;
5375
+ }
5376
+ // A request that was not honoured this run (no key minted before it, or an
5377
+ // earlier rotation still waiting to drop its previous key) is cleared, not
5378
+ // kept to fire later; the plan item says so.
5379
+ if (!rotated && context.recordedIds()[PLATFORM_INTEGRATIONS_KEY_ROTATION_REQUESTED_SCOPE_KEY]) {
5380
+ await record(PLATFORM_INTEGRATIONS_KEY_ROTATION_REQUESTED_SCOPE_KEY, '');
5381
+ }
5382
+ // ---- drop the previous key once Archon is confirmed on the new one (#398) -
5383
+ // Dropping it any earlier would refuse a request signed by an Archon
5384
+ // container that is still running the previous key while its own redeploy
5385
+ // catches up.
5386
+ const rotationPrevious = context.recordedIds()[PLATFORM_INTEGRATIONS_KEY_ROTATION_PREVIOUS_SCOPE_KEY];
5387
+ const activeKeyId = context.recordedIds()[PLATFORM_INTEGRATIONS_KEY_ID_SCOPE_KEY];
5388
+ if (rotationPrevious && rotationPrevious === activeKeyId) {
5389
+ // Corrupt record: the previous id is the active one. Never delete it.
5390
+ await record(PLATFORM_INTEGRATIONS_KEY_ROTATION_PREVIOUS_SCOPE_KEY, '');
5391
+ }
5392
+ else if (rotationPrevious &&
5393
+ // The last delivered mint (the held key) is the active id.
5394
+ context.recordedIds()[PLATFORM_INTEGRATIONS_KEY_ROTATION_UNDELIVERED_SCOPE_KEY] ===
5395
+ activeKeyId &&
5396
+ // No Archon to wait for when it is not enabled: nothing else holds the key.
5397
+ (!context.platform.archon.enabled ||
5398
+ context.recordedIds()[PLATFORM_ARCHON_INTEGRATIONS_KEY_SYNCED_SCOPE_KEY] === activeKeyId)) {
5399
+ const keyring = await currentManagementPublicKeys(coolify, runnerApp.appId);
5400
+ // The active key must be pinned before any other is removed. The planner
5401
+ // cannot see the live env, so a missing active key is an error here, not
5402
+ // a silent no-op on every apply.
5403
+ if (!activeKeyId || !keyring[activeKeyId]) {
5404
+ throw new Error(`platform:integrations: the runner's MANAGEMENT_PUBLIC_KEYS does not hold the active key ${activeKeyId ?? ''}; not removing ${rotationPrevious}. Pin the active public key on the runner, then apply again`);
5405
+ }
5406
+ delete keyring[rotationPrevious];
5407
+ await coolify.bulkUpsertEnvs(runnerApp.appId, [
5408
+ { key: 'MANAGEMENT_PUBLIC_KEYS', value: JSON.stringify(keyring) },
5409
+ ]);
5410
+ // One persist: the previous id is gone and the runner is marked as not
5411
+ // yet deployed with this keyring, so a failed deploy below is retried by
5412
+ // the next plan (`runnerKeyStale`) until the live runner stops accepting
5413
+ // the old key.
5414
+ context.recordScopeId(PLATFORM_INTEGRATIONS_KEY_ROTATION_PREVIOUS_SCOPE_KEY, '');
5415
+ context.recordScopeId(PLATFORM_INTEGRATIONS_RUNNER_KEYRING_DEPLOYED_SCOPE_KEY, '');
5416
+ await context.persist?.();
5417
+ keyJustSet = true;
5418
+ }
5419
+ const activeKeyForRunner = context.recordedIds()[PLATFORM_INTEGRATIONS_KEY_ID_SCOPE_KEY];
5420
+ const runnerKeyStale = Boolean(activeKeyForRunner) &&
5421
+ context.recordedIds()[PLATFORM_INTEGRATIONS_RUNNER_KEYRING_DEPLOYED_SCOPE_KEY] !==
5422
+ activeKeyForRunner;
5104
5423
  // ---- then the runner and the worker ------------------------------------------------
5105
5424
  // Decided from the live state: an env change only reaches a container on its
5106
5425
  // next deploy, and a stopped runner is deployed so an earlier failed deploy
5107
5426
  // is retried. The worker exits after one pass by design, so "not running"
5108
5427
  // says nothing about it and it is deployed only when created or changed.
5428
+ // #252: `'deployment-only'` -- like `coolify:jobs`, the runner is reached
5429
+ // through its Coolify `domains` binding, not a tunnel/DNS record this
5430
+ // executor set up itself, and no Cloudflare wiring is created for
5431
+ // `hostname` (this function's own doc comment, "Known gaps"). With no
5432
+ // reachability confirmed from here, `coolify.deployAndWait` reaching
5433
+ // Coolify's own deployment record is the whole check -- still enough to
5434
+ // catch the bug this closes: the applied ref recorded below while the
5435
+ // *old* container (never read here) stayed healthy through a failed
5436
+ // deploy.
5109
5437
  if (runnerApp.justCreated ||
5110
5438
  !runnerApp.running ||
5111
5439
  runnerEnvsChanged ||
5112
5440
  keyJustSet ||
5441
+ runnerKeyStale ||
5113
5442
  configChanged) {
5114
- await coolify.deploy(runnerApp.appId);
5443
+ await waitForNewDeployment(coolify, runnerApp.appId, { kind: 'deployment-only' }, runtime, sleep, 'platform:integrations', `the runner stays at ${recorded[PLATFORM_INTEGRATIONS_APPLIED_GITREF_SCOPE_KEY] ?? 'no recorded ref'}`);
5444
+ }
5445
+ // #398: the runner is up with the keyring that holds the active id; only
5446
+ // now may Archon move to it.
5447
+ if (runnerKeyStale && activeKeyForRunner) {
5448
+ await record(PLATFORM_INTEGRATIONS_RUNNER_KEYRING_DEPLOYED_SCOPE_KEY, activeKeyForRunner);
5115
5449
  }
5116
5450
  if (workerApp.justCreated || workerEnvsChanged || configChanged) {
5117
- await coolify.deploy(workerApp.appId);
5451
+ await waitForNewDeployment(coolify, workerApp.appId, { kind: 'deployment-only' }, runtime, sleep, 'platform:integrations', `the worker stays at ${recorded[PLATFORM_INTEGRATIONS_APPLIED_GITREF_SCOPE_KEY] ?? 'no recorded ref'}`);
5118
5452
  }
5119
5453
  await record(PLATFORM_INTEGRATIONS_APPLIED_HOSTNAME_SCOPE_KEY, hostname);
5120
5454
  await record(PLATFORM_INTEGRATIONS_APPLIED_GITREF_SCOPE_KEY, gitRef);
5121
5455
  return { resourceId: runnerApp.appId };
5122
5456
  }
5457
+ /**
5458
+ * #398: `deliverSecret` with the once-only mint's own retry discipline,
5459
+ * shared with the rotation path so both mint the same way. The error never
5460
+ * carries the key, only its id.
5461
+ */
5462
+ async function deliverManagementKey(deps, runtime, sleep, privateKey, keyId) {
5463
+ const attempts = runtime.deliverAttempts ?? 3;
5464
+ for (let attempt = 1;; attempt += 1) {
5465
+ try {
5466
+ await deps.deliverSecret(INTEGRATIONS_MANAGEMENT_KEY_REF, privateKey);
5467
+ return;
5468
+ }
5469
+ catch {
5470
+ // Retried; the error is dropped in case it echoes the key.
5471
+ if (attempt >= attempts) {
5472
+ throw new Error(`platform:integrations: management key ${keyId} was minted but could not be delivered after ${attempts} attempts`);
5473
+ }
5474
+ await sleep(1000 * attempt);
5475
+ }
5476
+ }
5477
+ }
5478
+ /**
5479
+ * #398: the runner's live `MANAGEMENT_PUBLIC_KEYS`, parsed -- `{}` when the
5480
+ * env is absent (no key minted yet). The rotation path reads this rather
5481
+ * than keeping its own copy, so it edits whatever the runner actually holds,
5482
+ * including a key id a person pinned by hand.
5483
+ */
5484
+ async function currentManagementPublicKeys(coolify, runnerAppId) {
5485
+ const raw = (await coolify.listEnvs(runnerAppId)).find((e) => e.key === 'MANAGEMENT_PUBLIC_KEYS' && !e.is_preview)?.value;
5486
+ if (!raw)
5487
+ return {};
5488
+ try {
5489
+ return JSON.parse(raw);
5490
+ }
5491
+ catch {
5492
+ throw new Error("platform:integrations: the runner's MANAGEMENT_PUBLIC_KEYS is not valid JSON; not touching the keyring");
5493
+ }
5494
+ }
5495
+ /**
5496
+ * #398: the key id a rotation mints next -- `foundry-1` to `foundry-2`, and
5497
+ * so on, by incrementing the trailing number. A current id with no trailing
5498
+ * number (hand-pinned, never by this step) gets `-2` appended rather than
5499
+ * refusing: the host's keyring only requires `^[A-Za-z0-9_-]{1,100}$`, and
5500
+ * this never collides with a number the current id does not already carry.
5501
+ */
5502
+ function nextManagementKeyId(currentKeyId, keyring) {
5503
+ const match = /^(.*-)(\d+)$/.exec(currentKeyId);
5504
+ const prefix = match ? match[1] : `${currentKeyId}-`;
5505
+ let n = match ? Number(match[2]) + 1 : 2;
5506
+ // Never overwrite an id already pinned on the runner.
5507
+ while (keyring[`${prefix}${n}`])
5508
+ n += 1;
5509
+ return `${prefix}${n}`;
5510
+ }
5123
5511
  /**
5124
5512
  * The runtime `DATABASE_URL` already on the migrate app, or a new one:
5125
5513
  * {@link INTEGRATIONS_RUNTIME_ROLE} with a password minted once from a
@@ -5759,3 +6147,50 @@ const ARCHON_SERVICE_INPUTS = {
5759
6147
  refs: [INTEGRATIONS_MANAGEMENT_KEY_REF],
5760
6148
  },
5761
6149
  };
6150
+ /** Scope id (no `:`): the share domains the runner's Coolify `domains` was last set to, comma-joined. */
6151
+ export const PLATFORM_FILES_SHARE_DOMAINS_SCOPE_KEY = 'platform_files_share_domains';
6152
+ /** Lowercased, trimmed, deduplicated and sorted, so the same set always gives the same value. */
6153
+ function normalHosts(hosts) {
6154
+ return [...new Set((hosts ?? []).map((h) => h.trim().toLowerCase()).filter(Boolean))].sort();
6155
+ }
6156
+ /** The runner's Coolify `domains`, written only when the share set changed (a runner nobody gave a share host keeps none). */
6157
+ function filesShareDomainsPatch(recorded, shareDomains) {
6158
+ return shareDomainsChanged(recorded, shareDomains)
6159
+ ? { domains: coolifyDomains(normalHosts(shareDomains)) }
6160
+ : {};
6161
+ }
6162
+ /**
6163
+ * The runner's `FILES_PUBLIC_HOSTS` env. Always written when an earlier apply
6164
+ * recorded a share set: files reads an empty value as no public host
6165
+ * (`filesPublicHosts()`), so an emptied set blanks the old value (the port has
6166
+ * no env delete). A runner never given a host keeps no such env.
6167
+ */
6168
+ function filesPublicHostsEnvs(recorded, publicHosts, shareDomains) {
6169
+ const value = filesPublicHostsValue(publicHosts, shareDomains);
6170
+ return value || recorded[PLATFORM_FILES_SHARE_DOMAINS_SCOPE_KEY]
6171
+ ? [{ key: 'FILES_PUBLIC_HOSTS', value }]
6172
+ : [];
6173
+ }
6174
+ function shareDomainsChanged(recorded, shareDomains) {
6175
+ return ((recorded[PLATFORM_FILES_SHARE_DOMAINS_SCOPE_KEY] ?? '') !== normalHosts(shareDomains).join(','));
6176
+ }
6177
+ /**
6178
+ * The runner's `FILES_PUBLIC_HOSTS`: the host-supplied `publicHosts` first, in
6179
+ * their given order, then the share domains sorted, with no host twice.
6180
+ * Empty when there is neither.
6181
+ */
6182
+ export function filesPublicHostsValue(publicHosts, shareDomains) {
6183
+ const given = (publicHosts ?? '')
6184
+ .split(',')
6185
+ .map((h) => h.trim())
6186
+ .filter(Boolean);
6187
+ return [...new Set([...given, ...normalHosts(shareDomains)])].join(',');
6188
+ }
6189
+ /**
6190
+ * The share domain of every org that has a domain and an entry declaring
6191
+ * `uses.files.shareHost`: `share.<domain>`. The platform files step derives it
6192
+ * from the declared orgs, for its apply and its drift check alike.
6193
+ */
6194
+ export function filesShareDomains(orgs) {
6195
+ return normalHosts(orgs.map((org) => filesShareHostname(org) ?? ''));
6196
+ }