@wtfalch/foundry 0.16.7 → 0.16.9

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.
@@ -178,7 +178,19 @@ export class FoundryPlatformStepExecutor {
178
178
  }
179
179
  else if (platform.files.enabled && !filesKeys(this.files)) {
180
180
  blocked.files =
181
- 'the files host is blocked, not planned: the host supplies no keys worker URL and credential (KEYS_WORKER_URL, KEYS_CREDENTIAL), so files-migrate would never become healthy';
181
+ 'the files host is blocked, not planned: the host supplies no keys worker URL, credential and verify keys (KEYS_WORKER_URL, KEYS_CREDENTIAL, KEYS_VERIFY_KEYS), so files would never become healthy';
182
+ }
183
+ else if (platform.files.enabled && !filesR2(this.files)) {
184
+ // #380: the platform files host's own gap -- it deployed and recorded
185
+ // applied, but never turned healthy, because nothing set R2_BUCKET or
186
+ // R2_ENDPOINT on the runner.
187
+ blocked.files =
188
+ 'the files host is blocked, not planned: the host supplies no R2 bucket and endpoint (R2_BUCKET, R2_ENDPOINT), so files would never become healthy';
189
+ }
190
+ else if (platform.files.enabled) {
191
+ const invalid = filesDepsInvalid(this.files);
192
+ if (invalid)
193
+ blocked.files = `the files host is blocked, not planned: ${invalid}`;
182
194
  }
183
195
  if (platform.ai.enabled && !this.ai) {
184
196
  blocked.ai =
@@ -254,6 +266,23 @@ export class FoundryPlatformStepExecutor {
254
266
  if (loginClientDrifted)
255
267
  result.auth = true;
256
268
  }
269
+ // #380: once the runner app is recorded, re-check its live env against
270
+ // every setting `ensureStarted()` requires -- the gap that left the
271
+ // platform's own files host `running:unhealthy` from the day it first
272
+ // deployed, with no live reader ever telling the planner so. Reported as
273
+ // drift so `planFiles` replans the step despite the hostname/gitRef
274
+ // match, and `provisionPlatformFiles` (which redeploys unconditionally
275
+ // on every run) fills in whatever is still missing.
276
+ if (platform.files.enabled && this.files && recordedIds[`coolify:app:${PLATFORM_FILES_KEY}`]) {
277
+ // A Coolify read error must not fail the whole plan: treat it as not drifted.
278
+ try {
279
+ if (await filesRunnerEnvDrifted(this.files.coolify, recordedIds))
280
+ result.files = true;
281
+ }
282
+ catch {
283
+ // Nothing logged: the error can carry Coolify's response.
284
+ }
285
+ }
257
286
  // #257 fix round 2: only once a platform database actually exists to
258
287
  // back up -- the same "nothing to report yet" gate `blockedSteps`'s own
259
288
  // `backupExecutorMissing` check follows above, so a platform that has
@@ -976,13 +1005,22 @@ async function provisionPlatformAuth(context, deps) {
976
1005
  { key: 'ZITADEL_DATABASE_POSTGRES_DSN', value: db.internal_db_url },
977
1006
  { key: 'ZITADEL_FIRSTINSTANCE_SKIP', value: 'true' },
978
1007
  { key: 'ZITADEL_DEFAULTINSTANCE_FEATURES_LOGINV2_REQUIRED', value: 'true' },
979
- { key: 'ZITADEL_DEFAULTINSTANCE_FEATURES_LOGINV2_BASEURI', value: `https://${hostname}/` },
980
- { key: 'ZITADEL_OIDC_DEFAULTLOGINURLV2', value: `https://${hostname}/login?authRequest=` },
1008
+ {
1009
+ key: 'ZITADEL_DEFAULTINSTANCE_FEATURES_LOGINV2_BASEURI',
1010
+ value: `https://${hostname}/`,
1011
+ },
1012
+ {
1013
+ key: 'ZITADEL_OIDC_DEFAULTLOGINURLV2',
1014
+ value: `https://${hostname}/login?authRequest=`,
1015
+ },
981
1016
  {
982
1017
  key: 'ZITADEL_OIDC_DEFAULTLOGOUTURLV2',
983
1018
  value: `https://${hostname}/logout?post_logout_redirect=`,
984
1019
  },
985
- { key: 'ZITADEL_SAML_DEFAULTLOGINURLV2', value: `https://${hostname}/login?samlRequest=` },
1020
+ {
1021
+ key: 'ZITADEL_SAML_DEFAULTLOGINURLV2',
1022
+ value: `https://${hostname}/login?samlRequest=`,
1023
+ },
986
1024
  { key: 'ZITADEL_LOGSTORE_ACCESS_STDOUT_ENABLED', value: 'true' },
987
1025
  ];
988
1026
  if (masterkey)
@@ -998,7 +1036,10 @@ async function provisionPlatformAuth(context, deps) {
998
1036
  key: 'ZITADEL_API_URL',
999
1037
  value: `http://${PLATFORM_AUTH_ZITADEL_INTERNAL_NAME}:${AUTH_ZITADEL_PORT}`,
1000
1038
  },
1001
- { key: 'CUSTOM_REQUEST_HEADERS', value: `Host:${hostname},X-Forwarded-Proto:https` },
1039
+ {
1040
+ key: 'CUSTOM_REQUEST_HEADERS',
1041
+ value: `Host:${hostname},X-Forwarded-Proto:https`,
1042
+ },
1002
1043
  { key: 'OTEL_NODE_RESOURCE_DETECTORS', value: 'none' },
1003
1044
  { key: 'API_CACHE_CONFIG', value: '{"defaultMinutes":2,"longMinutes":5}' },
1004
1045
  ];
@@ -1073,7 +1114,10 @@ async function provisionPlatformAuth(context, deps) {
1073
1114
  path: AUTH_BROKER_PATH_PATTERN,
1074
1115
  service: `http://${PLATFORM_AUTH_BROKER_INTERNAL_NAME}:${AUTH_BROKER_PORT}`,
1075
1116
  },
1076
- { hostname, service: `http://${PLATFORM_AUTH_LOGIN_INTERNAL_NAME}:${AUTH_LOGIN_PORT}` },
1117
+ {
1118
+ hostname,
1119
+ service: `http://${PLATFORM_AUTH_LOGIN_INTERNAL_NAME}:${AUTH_LOGIN_PORT}`,
1120
+ },
1077
1121
  { service: 'http_status:404' },
1078
1122
  ],
1079
1123
  });
@@ -1137,7 +1181,10 @@ async function provisionPlatformAuth(context, deps) {
1137
1181
  privateKey: systemKeys.privateKey,
1138
1182
  instanceName: PLATFORM_AUTH_ORG_NAME,
1139
1183
  firstOrgName: PLATFORM_AUTH_ORG_NAME,
1140
- owner: { userName: PLATFORM_AUTH_BEARER_USER_NAME, name: 'Foundry (instance owner)' },
1184
+ owner: {
1185
+ userName: PLATFORM_AUTH_BEARER_USER_NAME,
1186
+ name: 'Foundry (instance owner)',
1187
+ },
1141
1188
  }, { fetch: runtime.fetch, now: runtime.now ?? Date.now });
1142
1189
  }
1143
1190
  catch (error) {
@@ -1366,7 +1413,20 @@ const STALWART_PORTS_EXPOSES = [STALWART_HTTP_PORT, ...STALWART_SMTP_PORTS].join
1366
1413
  * ZITADEL/login apps.
1367
1414
  */
1368
1415
  const STALWART_PORTS_MAPPINGS = STALWART_SMTP_PORTS.map((p) => `${p}:${p}`).join(',');
1369
- const STALWART_DATA_MOUNT = '/opt/stalwart';
1416
+ /**
1417
+ * The v0.16 image's two VOLUMEs (its Dockerfile): the data store, and the
1418
+ * `config.json` Stalwart writes when bootstrap completes. The pre-0.16 image
1419
+ * used `/opt/stalwart`, which v0.16 does not use. Each is a Coolify
1420
+ * persistent storage (a Docker named volume, not a host path), so Docker
1421
+ * seeds it from the image on first use and keeps the image's UID 2000
1422
+ * ownership, which the container needs to write it.
1423
+ */
1424
+ // Suffixes differ from the pre-0.16 storage's `-data` (still attached at
1425
+ // /opt/stalwart on apps created before), so a new storage never shares its name.
1426
+ const STALWART_STORAGES = [
1427
+ { suffix: 'var-lib', mount_path: '/var/lib/stalwart' },
1428
+ { suffix: 'etc', mount_path: '/etc/stalwart' },
1429
+ ];
1370
1430
  /** `STALWART_RECOVERY_ADMIN=<user>:<password>` -- the fixed recovery account name; only the password half is generated. */
1371
1431
  const STALWART_RECOVERY_USER = 'admin';
1372
1432
  /**
@@ -1420,9 +1480,27 @@ export const PLATFORM_MAIL_ADMIN_KEY_DESCRIPTION = 'foundry:platform:admin';
1420
1480
  * `stalwartForInstance` fake.
1421
1481
  */
1422
1482
  export function defaultStalwartForInstance(input) {
1423
- const instance = openInstance({ url: input.url, secret: input.password, user: input.user });
1483
+ const instance = openInstance({
1484
+ url: input.url,
1485
+ secret: input.password,
1486
+ user: input.user,
1487
+ });
1424
1488
  return {
1425
1489
  async applySettings({ mailHost, apex, oidc }) {
1490
+ // A fresh Stalwart (0.16) is in bootstrap mode and refuses every write
1491
+ // but the `Bootstrap` object, so complete that first. A server already
1492
+ // past it skips this. `update` drops the response, which carries the
1493
+ // generated `admin@<apex>` secret: this step signs in with the
1494
+ // recovery admin and never needs it.
1495
+ if ((await instance.client.probe()) === 'bootstrap') {
1496
+ await instance.client.update('Bootstrap', 'singleton', {
1497
+ serverHostname: mailHost,
1498
+ defaultDomain: apex,
1499
+ // The server sits behind a tunnel; no ACME challenge can reach it.
1500
+ requestTlsCertificate: false,
1501
+ });
1502
+ await instance.client.waitFor('ready');
1503
+ }
1426
1504
  await applyObjects(instance.client, platformMailSettingsObjects({ apex, mailHost, oidc }), () => { });
1427
1505
  },
1428
1506
  createApiKey: (description, permissions) => instance.client.createApiKey(description, permissions),
@@ -1525,7 +1603,7 @@ async function provisionPlatformMail(context, deps) {
1525
1603
  // app is not.
1526
1604
  const server = await resolveMailServer(deps.coolify, recorded[PLATFORM_MAIL_SERVER_SCOPE_KEY]);
1527
1605
  await record(PLATFORM_MAIL_SERVER_SCOPE_KEY, server.uuid);
1528
- const serverIpBlocked = mailServerIpBlocked(server.ip);
1606
+ const serverIpBlocked = mailServerIpBlocked(server.ip, platform.serverIpv4);
1529
1607
  if (serverIpBlocked)
1530
1608
  throw new Error(serverIpBlocked);
1531
1609
  const app = await findOrCreatePlatformApp(deps.coolify, PLATFORM_MAIL_APP_NAME, environment, async () => {
@@ -1557,12 +1635,19 @@ async function provisionPlatformMail(context, deps) {
1557
1635
  await record(PLATFORM_MAIL_INTERNAL_URL_SCOPE_KEY, `http://${PLATFORM_MAIL_INTERNAL_NAME}:${STALWART_HTTP_PORT}`);
1558
1636
  // ---- storage ---------------------------------------------------------------
1559
1637
  const storages = await deps.coolify.listStorages(appId);
1560
- const hasDataVolume = (storages.persistent_storages ?? []).some((s) => s.mount_path === STALWART_DATA_MOUNT);
1561
- if (!hasDataVolume) {
1638
+ // An app made before this release only has the old `/opt/stalwart` volume.
1639
+ // That one is left in place (empty and unused; detaching it needs a call this
1640
+ // step has no port for) and the two real ones are added.
1641
+ let storagesAdded = false;
1642
+ for (const wanted of STALWART_STORAGES) {
1643
+ if ((storages.persistent_storages ?? []).some((st) => st.mount_path === wanted.mount_path)) {
1644
+ continue;
1645
+ }
1562
1646
  await deps.coolify.createPersistentStorage(appId, {
1563
- name: `${PLATFORM_MAIL_APP_NAME}-data`,
1564
- mount_path: STALWART_DATA_MOUNT,
1647
+ name: `${PLATFORM_MAIL_APP_NAME}-${wanted.suffix}`,
1648
+ mount_path: wanted.mount_path,
1565
1649
  });
1650
+ storagesAdded = true;
1566
1651
  }
1567
1652
  // ---- env ---------------------------------------------------------------------
1568
1653
  // Generated once; unlike ZITADEL's masterkey this is never re-derived from
@@ -1579,7 +1664,8 @@ async function provisionPlatformMail(context, deps) {
1579
1664
  await deps.coolify.bulkUpsertEnvs(appId, envs);
1580
1665
  const declaredChanged = recorded[PLATFORM_MAIL_DEPLOYED_HOSTNAME_SCOPE_KEY] !== hostname ||
1581
1666
  recorded[PLATFORM_MAIL_DEPLOYED_GIT_REF_SCOPE_KEY] !== imageTag;
1582
- if (app.justCreated || !app.running || envsChanged || declaredChanged) {
1667
+ // A new storage binds into the container only at its next start.
1668
+ if (app.justCreated || !app.running || envsChanged || declaredChanged || storagesAdded) {
1583
1669
  // A container still up from before would answer the readiness probe with
1584
1670
  // the old deployment's health. Stop it and see it stopped first, so the
1585
1671
  // wait below only ever reads the new deployment.
@@ -1600,7 +1686,10 @@ async function provisionPlatformMail(context, deps) {
1600
1686
  await record(PLATFORM_MAIL_TUNNEL_SCOPE_KEY, tunnel.id);
1601
1687
  await deps.cloudflare.putTunnelConfig(tunnel.id, {
1602
1688
  ingress: [
1603
- { hostname, service: `http://${PLATFORM_MAIL_INTERNAL_NAME}:${STALWART_HTTP_PORT}` },
1689
+ {
1690
+ hostname,
1691
+ service: `http://${PLATFORM_MAIL_INTERNAL_NAME}:${STALWART_HTTP_PORT}`,
1692
+ },
1604
1693
  { service: 'http_status:404' },
1605
1694
  ],
1606
1695
  });
@@ -1640,7 +1729,7 @@ async function provisionPlatformMail(context, deps) {
1640
1729
  });
1641
1730
  }
1642
1731
  // ---- the MX hostname: a direct, unproxied A record --------------------------
1643
- const serverIp = server.ip; // checked by mailServerIpBlocked above
1732
+ const serverIp = (platform.serverIpv4 ?? server.ip); // checked by mailServerIpBlocked above
1644
1733
  const [mxRecord] = await deps.cloudflare.findDnsRecord(zoneId, mailHost, 'A');
1645
1734
  if (!mxRecord) {
1646
1735
  await deps.cloudflare.createDnsRecord(zoneId, {
@@ -1774,13 +1863,19 @@ function mailImageTagBlocked(tag) {
1774
1863
  ? `the platform's mail server is blocked, not planned: ${reason} as the mail gitRef`
1775
1864
  : null;
1776
1865
  }
1777
- /** Why the server's address cannot be the MX A record's content, or `null`. */
1778
- function mailServerIpBlocked(ip) {
1866
+ /**
1867
+ * Why no address can be the MX A record's content, or `null`. The declared
1868
+ * `platform.serverIpv4` wins; else the Coolify server's own ip, when it is an
1869
+ * IPv4 (the localhost server reports `host.docker.internal`, which is not).
1870
+ */
1871
+ function mailServerIpBlocked(ip, declared) {
1872
+ if (declared)
1873
+ return null; // validatePlatform already refused a non-IPv4
1779
1874
  if (!ip) {
1780
- return "the platform's mail server is blocked, not planned: the Coolify server reports no ip, so mx.<apex> has no address to point at; set the server's IP in Coolify";
1875
+ return "the platform's mail server is blocked, not planned: the Coolify server reports no ip, so mx.<apex> has no address to point at; declare the server's public IPv4 as serverIpv4 in the platform config";
1781
1876
  }
1782
1877
  if (!isIPv4(ip)) {
1783
- return `the platform's mail server is blocked, not planned: the Coolify server's ip "${ip}" is not an IPv4 address, so it cannot be the mx.<apex> A record; set the server's public IPv4 in Coolify`;
1878
+ return `the platform's mail server is blocked, not planned: the Coolify server's ip "${ip}" is not an IPv4 address, so it cannot be the mx.<apex> A record; declare the server's public IPv4 as serverIpv4 in the platform config`;
1784
1879
  }
1785
1880
  return null;
1786
1881
  }
@@ -1815,7 +1910,7 @@ async function mailBlockedReason(platform, mail, recordedIds) {
1815
1910
  return tagBlocked;
1816
1911
  try {
1817
1912
  const server = await resolveMailServer(mail.coolify, recordedIds[PLATFORM_MAIL_SERVER_SCOPE_KEY]);
1818
- return mailServerIpBlocked(server.ip);
1913
+ return mailServerIpBlocked(server.ip, platform.serverIpv4);
1819
1914
  }
1820
1915
  catch {
1821
1916
  return "the platform's mail server is blocked, not planned: no usable Coolify server could be resolved for it";
@@ -2366,7 +2461,10 @@ async function provisionPlatformArchon(context, deps) {
2366
2461
  }
2367
2462
  await deps.cloudflare.putTunnelConfig(tunnel.id, {
2368
2463
  ingress: [
2369
- { hostname, service: `http://${PLATFORM_ARCHON_INTERNAL_NAME}:${ARCHON_PORT}` },
2464
+ {
2465
+ hostname,
2466
+ service: `http://${PLATFORM_ARCHON_INTERNAL_NAME}:${ARCHON_PORT}`,
2467
+ },
2370
2468
  { service: 'http_status:404' },
2371
2469
  ],
2372
2470
  });
@@ -2799,7 +2897,9 @@ async function archonBrokerRegistrationDrifted(coolify, recordedIds) {
2799
2897
  * vary between calls.
2800
2898
  */
2801
2899
  async function collectArchonServiceInputs(platform, deps, recorded) {
2802
- const block = (why) => ({ blocked: `Archon is blocked, not planned: ${why}` });
2900
+ const block = (why) => ({
2901
+ blocked: `Archon is blocked, not planned: ${why}`,
2902
+ });
2803
2903
  const envs = [];
2804
2904
  const heldByRef = new Map();
2805
2905
  const services = ['files', 'ai', 'integrations', 'jobs'].filter((s) => platform[s].enabled);
@@ -2808,7 +2908,11 @@ async function collectArchonServiceInputs(platform, deps, recorded) {
2808
2908
  }
2809
2909
  for (const service of services) {
2810
2910
  const needs = service === 'jobs'
2811
- ? { stepKey: null, scopeKeys: [], refs: [JOBS_ADMIN_TOKEN_REF] }
2911
+ ? {
2912
+ stepKey: null,
2913
+ scopeKeys: [],
2914
+ refs: [JOBS_ADMIN_TOKEN_REF],
2915
+ }
2812
2916
  : ARCHON_SERVICE_INPUTS[service];
2813
2917
  if (needs.stepKey && !recorded[`coolify:app:${needs.stepKey}`]) {
2814
2918
  return block(`the ${service} step (${needs.stepKey}) is not applied yet`);
@@ -2835,10 +2939,22 @@ async function collectArchonServiceInputs(platform, deps, recorded) {
2835
2939
  const held = (ref) => heldByRef.get(ref);
2836
2940
  for (const service of services) {
2837
2941
  if (service === 'files') {
2838
- envs.push({ key: 'FILES_URL', value: recorded[PLATFORM_FILES_INTERNAL_URL_SCOPE_KEY] }, { key: 'FILES_MANAGEMENT_KEY', value: held(ARCHON_SERVICE_INPUTS.files.refs[0]) });
2942
+ envs.push({
2943
+ key: 'FILES_URL',
2944
+ value: recorded[PLATFORM_FILES_INTERNAL_URL_SCOPE_KEY],
2945
+ }, {
2946
+ key: 'FILES_MANAGEMENT_KEY',
2947
+ value: held(ARCHON_SERVICE_INPUTS.files.refs[0]),
2948
+ });
2839
2949
  }
2840
2950
  else if (service === 'ai') {
2841
- envs.push({ key: 'AI_URL', value: recorded[PLATFORM_AI_INTERNAL_URL_SCOPE_KEY] }, { key: 'AI_MANAGEMENT_KEY', value: held(ARCHON_SERVICE_INPUTS.ai.refs[0]) }, {
2951
+ envs.push({
2952
+ key: 'AI_URL',
2953
+ value: recorded[PLATFORM_AI_INTERNAL_URL_SCOPE_KEY],
2954
+ }, {
2955
+ key: 'AI_MANAGEMENT_KEY',
2956
+ value: held(ARCHON_SERVICE_INPUTS.ai.refs[0]),
2957
+ }, {
2842
2958
  key: 'AI_ORGANISATION_ID',
2843
2959
  value: recorded[PLATFORM_AI_ORGANISATION_SCOPE_KEY],
2844
2960
  });
@@ -2848,7 +2964,13 @@ async function collectArchonServiceInputs(platform, deps, recorded) {
2848
2964
  envs.push({ key: 'INTEGRATIONS_URL', value: url }, {
2849
2965
  key: 'INTEGRATIONS_MANAGEMENT_PRIVATE_KEY',
2850
2966
  value: held(INTEGRATIONS_MANAGEMENT_KEY_REF),
2851
- }, { key: 'INTEGRATIONS_MANAGEMENT_ISSUER', value: INTEGRATIONS_MANAGEMENT_ISSUER }, { key: 'INTEGRATIONS_MANAGEMENT_KEY_ID', value: INTEGRATIONS_MANAGEMENT_KEY_ID }, { key: 'INTEGRATIONS_MANAGEMENT_AUDIENCE', value: url });
2967
+ }, {
2968
+ key: 'INTEGRATIONS_MANAGEMENT_ISSUER',
2969
+ value: INTEGRATIONS_MANAGEMENT_ISSUER,
2970
+ }, {
2971
+ key: 'INTEGRATIONS_MANAGEMENT_KEY_ID',
2972
+ value: INTEGRATIONS_MANAGEMENT_KEY_ID,
2973
+ }, { key: 'INTEGRATIONS_MANAGEMENT_AUDIENCE', value: url });
2852
2974
  }
2853
2975
  else {
2854
2976
  envs.push({ key: 'JOBS_URL', value: `https://${platform.jobs.hostname}` }, { key: 'JOBS_ADMIN_TOKEN', value: held(JOBS_ADMIN_TOKEN_REF) });
@@ -2964,7 +3086,10 @@ function parseAiKeyExistsBody(body) {
2964
3086
  typeof record.organisationId !== 'string') {
2965
3087
  throw new Error("ai's platform/keys route answered 200 with a body missing credentialId/organisationId");
2966
3088
  }
2967
- return { credentialId: record.credentialId, organisationId: record.organisationId };
3089
+ return {
3090
+ credentialId: record.credentialId,
3091
+ organisationId: record.organisationId,
3092
+ };
2968
3093
  }
2969
3094
  function parseAiKeyMintedBody(body) {
2970
3095
  const record = body;
@@ -3093,7 +3218,10 @@ export function createAiManagementKeyMinter(options) {
3093
3218
  });
3094
3219
  const response = await fetchImpl(`${internalUrl}${AI_PLATFORM_KEYS_PATH}`, {
3095
3220
  method: 'POST',
3096
- headers: { 'content-type': contentType, [AI_MANAGEMENT_HEADER]: token },
3221
+ headers: {
3222
+ 'content-type': contentType,
3223
+ [AI_MANAGEMENT_HEADER]: token,
3224
+ },
3097
3225
  body,
3098
3226
  // A hung ai must not hold the apply open.
3099
3227
  signal: AbortSignal.timeout(30_000),
@@ -3130,7 +3258,11 @@ const PLATFORM_AI_MIGRATE = {
3130
3258
  target: 'migration',
3131
3259
  internalName: 'platform-ai-migrate',
3132
3260
  };
3133
- const PLATFORM_AI_RUNNER = { name: 'ai', target: 'runner', internalName: 'platform-ai' };
3261
+ const PLATFORM_AI_RUNNER = {
3262
+ name: 'ai',
3263
+ target: 'runner',
3264
+ internalName: 'platform-ai',
3265
+ };
3134
3266
  const PLATFORM_AI_WORKER = {
3135
3267
  name: 'ai-worker',
3136
3268
  target: 'worker',
@@ -3156,10 +3288,7 @@ const PLATFORM_AI_RUNNER_DEFAULTS = [
3156
3288
  { key: 'AI_FORGE_BINDINGS', value: '[]' },
3157
3289
  { key: 'AI_WORKER_TIMEOUT_MS', value: '60000' },
3158
3290
  ];
3159
- const PLATFORM_AI_WORKER_DEFAULTS = [
3160
- ...PLATFORM_AI_RUNNER_DEFAULTS,
3161
- { key: 'AI_WORKER_BATCH', value: '10' },
3162
- ];
3291
+ const PLATFORM_AI_WORKER_DEFAULTS = [...PLATFORM_AI_RUNNER_DEFAULTS, { key: 'AI_WORKER_BATCH', value: '10' }];
3163
3292
  /**
3164
3293
  * #172: makes `coolify:app` keyed {@link PLATFORM_AI_KEY} true -- the ai host
3165
3294
  * (https://github.com/wtfalch/ai) on the platform's Coolify, then foundry's
@@ -3712,9 +3841,14 @@ async function ensureAiRuntimeUrl(coolify, appIds, ownerUrl) {
3712
3841
  const password = randomBytes(24).toString('base64url');
3713
3842
  return `postgres://${database}_rt:${password}@${host}${pathname}${search}`;
3714
3843
  }
3715
- /** The entries of `defaults` whose key the app does not have yet. */
3844
+ /**
3845
+ * The entries of `defaults` whose key the app does not have yet. A key held
3846
+ * with a blank value counts as absent (#380), matching `filesRunnerEnvDrifted`,
3847
+ * so one apply clears the drift instead of replanning every round (#377's loop
3848
+ * class); a blank is never a valid setting, so the AI defaults share this.
3849
+ */
3716
3850
  async function missingEnvs(coolify, appId, defaults) {
3717
- const present = new Set((await coolify.listEnvs(appId)).filter((e) => !e.is_preview).map((e) => e.key));
3851
+ const present = new Set((await coolify.listEnvs(appId)).filter((e) => !e.is_preview && e.value).map((e) => e.key));
3718
3852
  return defaults.filter((d) => !present.has(d.key));
3719
3853
  }
3720
3854
  const defaultSleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
@@ -3754,7 +3888,9 @@ async function resolvePlatformServer(coolify) {
3754
3888
  * the app was already running or starting; the caller deploys it when not.
3755
3889
  */
3756
3890
  async function findOrCreatePlatformApp(coolify, name, environment, create) {
3757
- const existing = await coolify.findApplicationByName(name, { environmentIds: [environment.id] });
3891
+ const existing = await coolify.findApplicationByName(name, {
3892
+ environmentIds: [environment.id],
3893
+ });
3758
3894
  if (existing) {
3759
3895
  const live = await coolify.getApplication(existing.uuid);
3760
3896
  return {
@@ -3880,7 +4016,10 @@ export function brokerDeployNeeded(missingInputs, brokerAppId, authAppsRaw, reco
3880
4016
  * wrote there. Paired with {@link brokerDeployNeeded}, which reads it back.
3881
4017
  */
3882
4018
  export function brokerDeployRecordValue(brokerAppId, authAppsRaw) {
3883
- const record = { brokerAppId, authAppsHash: hashAuthApps(authAppsRaw) };
4019
+ const record = {
4020
+ brokerAppId,
4021
+ authAppsHash: hashAuthApps(authAppsRaw),
4022
+ };
3884
4023
  return JSON.stringify(record);
3885
4024
  }
3886
4025
  /** Whether any desired env is missing from the app, or set to another value. */
@@ -3982,6 +4121,20 @@ const FILES_DB = 'files';
3982
4121
  const FILES_RUNTIME_ROLE = `${FILES_DB}_rt`;
3983
4122
  const FILES_ZITADEL_PROJECT_NAME = 'files';
3984
4123
  const FILES_INTROSPECTION_APP_NAME = 'files-introspection';
4124
+ /**
4125
+ * #380: written only when absent, never overwritten, the same
4126
+ * {@link PLATFORM_AI_RUNNER_DEFAULTS} convention -- an operator's own limits
4127
+ * survive every apply. Values are files' own `.env.example`. The runner
4128
+ * needs all three: files' `ensureStarted()`
4129
+ * (`apps/host/src/lib/authz/files-host.ts`) requires each with
4130
+ * `required(...)`, and the health check fails without them -- the same gap
4131
+ * #375 fixed for the AI host's `AI_WORKER_TIMEOUT_MS`.
4132
+ */
4133
+ const PLATFORM_FILES_RUNNER_DEFAULTS = [
4134
+ { key: 'FILES_MAX_FILE_BYTES', value: '26214400' },
4135
+ { key: 'FILES_UPLOAD_TTL_SECONDS', value: '300' },
4136
+ { key: 'FILES_SCRATCH_TTL_DAYS', value: '1' },
4137
+ ];
3985
4138
  const PLATFORM_FILES_PROJECT_SCOPE_KEY = 'coolify_project_platform_files';
3986
4139
  const PLATFORM_FILES_DB_SCOPE_KEY = 'coolify_platform_files_database';
3987
4140
  const PLATFORM_FILES_MIGRATE_SCOPE_KEY = 'coolify_platform_files_migrate_app';
@@ -4033,12 +4186,17 @@ const PLATFORM_FILES_RUNNER_SCOPE_KEY = 'coolify_platform_files_runner_app';
4033
4186
  * field for it. Not yet checked against a live Coolify.
4034
4187
  * - The runner is created without `domains`, like `platform:auth`'s apps.
4035
4188
  * Whether Coolify adds a generated domain anyway is not yet checked.
4036
- * - Foundry sets only the env it owns: the two database URLs, `APP_URL`,
4037
- * `AUTH_ISSUER`, `AUTH_PERSON_TOKEN_*`, `MANAGEMENT_*` and the two `KEYS_*`
4038
- * values the host supplies through {@link PlatformFilesDeps.keys}, which
4039
- * files-migrate needs to become healthy. The rest of files' `.env.example`
4040
- * (`R2_*`, `KEYS_VERIFY_KEYS`, `JOBS_*`, `AUTHZ_BOOTSTRAP_OPERATOR`, ...)
4041
- * is set by a person on the runner.
4189
+ * - Foundry sets the env it owns: the two database URLs, `APP_URL`,
4190
+ * `AUTH_ISSUER`, `AUTH_PERSON_TOKEN_*`, `MANAGEMENT_*`, the `KEYS_*`
4191
+ * values the host supplies through {@link PlatformFilesDeps.keys} (#380
4192
+ * added `KEYS_VERIFY_KEYS`, required for the runner to become healthy,
4193
+ * and the optional `KEYS_AUDIT_TOKEN`), `R2_BUCKET`/`R2_ENDPOINT` from
4194
+ * {@link PlatformFilesDeps.r2} (#380, also required), the optional
4195
+ * `FILES_CLAMD_HOST`/`FILES_CLAMD_PORT`, `FILES_PUBLIC_HOSTS` and
4196
+ * `JOBS_SIGNING_SECRETS` the host may supply (#380), and the three
4197
+ * {@link PLATFORM_FILES_RUNNER_DEFAULTS} (#380, written only when absent).
4198
+ * `AUTHZ_BOOTSTRAP_OPERATOR` and the rest of files' `.env.example` are
4199
+ * still set by a person on the runner.
4042
4200
  * - The files project lives in the platform's `foundry` ZITADEL org. An
4043
4201
  * org's Agora requests it as an audience scope from its own ZITADEL org;
4044
4202
  * whether that needs a project grant per org is not decided here.
@@ -4055,7 +4213,15 @@ async function provisionPlatformFiles(context, deps) {
4055
4213
  const attempts = runtime.deliverAttempts ?? 3;
4056
4214
  const keys = filesKeys(deps);
4057
4215
  if (!keys) {
4058
- throw new Error('platform:files needs the keys worker URL and credential in its files dependencies (KEYS_WORKER_URL, KEYS_CREDENTIAL); files-migrate never becomes healthy without them');
4216
+ throw new Error('platform:files needs the keys worker URL, credential and verify keys in its files dependencies (KEYS_WORKER_URL, KEYS_CREDENTIAL, KEYS_VERIFY_KEYS); files never becomes healthy without them');
4217
+ }
4218
+ // #380: R2_BUCKET/R2_ENDPOINT, the same "block the whole step" treatment
4219
+ // as a missing `keys` above -- the runner's `ensureStarted()` requires
4220
+ // both, and only this function's caller (`blockedSteps`, `planFiles`) ever
4221
+ // has anything useful to report while they are unset.
4222
+ const r2 = filesR2(deps);
4223
+ if (!r2) {
4224
+ throw new Error('platform:files needs the R2 bucket and endpoint in its files dependencies (R2_BUCKET, R2_ENDPOINT); files never becomes healthy without them');
4059
4225
  }
4060
4226
  // Never plan-and-throw: the planner reports a lost secret and plans
4061
4227
  // nothing; this is the same rule for a caller that reaches the step anyway.
@@ -4149,14 +4315,16 @@ async function provisionPlatformFiles(context, deps) {
4149
4315
  const runtimeUrl = await ensureFilesRuntimeUrl(deps.coolify, [migrateApp.appId, runnerApp.appId], db.internal_db_url);
4150
4316
  // The keys worker the migrate app needs to become healthy (files' README:
4151
4317
  // `DATABASE_URL_OWNER`, `DATABASE_URL`, `KEYS_WORKER_URL`, `KEYS_CREDENTIAL`).
4152
- const keysEnvs = [
4318
+ // `files-migrate` never runs `ensureStarted()`, so it needs neither
4319
+ // `KEYS_VERIFY_KEYS` nor the `files` runner's R2/optional settings below.
4320
+ const migrateKeysEnvs = [
4153
4321
  { key: 'KEYS_WORKER_URL', value: keys.workerUrl },
4154
4322
  { key: 'KEYS_CREDENTIAL', value: keys.credential },
4155
4323
  ];
4156
4324
  await deps.coolify.bulkUpsertEnvs(migrateApp.appId, [
4157
4325
  { key: 'DATABASE_URL_OWNER', value: db.internal_db_url },
4158
4326
  { key: 'DATABASE_URL', value: runtimeUrl },
4159
- ...keysEnvs,
4327
+ ...migrateKeysEnvs,
4160
4328
  ]);
4161
4329
  // ---- the files ZITADEL project, in the platform's sign-in ----------------
4162
4330
  const issuer = `https://${platform.auth.hostname}`;
@@ -4164,6 +4332,41 @@ async function provisionPlatformFiles(context, deps) {
4164
4332
  const { org } = await zitadel.findOrCreateOrg(PLATFORM_AUTH_ORG_NAME);
4165
4333
  const { project } = await zitadel.findOrCreateProject(org.id, FILES_ZITADEL_PROJECT_NAME);
4166
4334
  await record(PLATFORM_FILES_ZITADEL_PROJECT_SCOPE_KEY, project.id);
4335
+ // #380: the runner's own required settings -- `KEYS_VERIFY_KEYS` and
4336
+ // `R2_BUCKET`/`R2_ENDPOINT` -- plus whatever optional ones the host
4337
+ // supplies. `files-migrate` needs none of these (see `migrateKeysEnvs`
4338
+ // above).
4339
+ const runnerKeysEnvs = [
4340
+ ...migrateKeysEnvs,
4341
+ { key: 'KEYS_VERIFY_KEYS', value: keys.verifyKeys },
4342
+ ];
4343
+ if (deps.keys?.auditToken)
4344
+ runnerKeysEnvs.push({
4345
+ key: 'KEYS_AUDIT_TOKEN',
4346
+ value: deps.keys.auditToken,
4347
+ });
4348
+ const runnerOptionalEnvs = [];
4349
+ if (deps.clamd?.host) {
4350
+ runnerOptionalEnvs.push({
4351
+ key: 'FILES_CLAMD_HOST',
4352
+ value: deps.clamd.host,
4353
+ });
4354
+ if (deps.clamd.port)
4355
+ runnerOptionalEnvs.push({
4356
+ key: 'FILES_CLAMD_PORT',
4357
+ value: deps.clamd.port,
4358
+ });
4359
+ }
4360
+ if (deps.publicHosts)
4361
+ runnerOptionalEnvs.push({
4362
+ key: 'FILES_PUBLIC_HOSTS',
4363
+ value: deps.publicHosts,
4364
+ });
4365
+ if (deps.jobsSigningSecrets)
4366
+ runnerOptionalEnvs.push({
4367
+ key: 'JOBS_SIGNING_SECRETS',
4368
+ value: deps.jobsSigningSecrets,
4369
+ });
4167
4370
  const runnerEnvs = [
4168
4371
  { key: 'DATABASE_URL', value: runtimeUrl },
4169
4372
  // The management verifier's audience, which must be an HTTPS origin. The
@@ -4171,7 +4374,14 @@ async function provisionPlatformFiles(context, deps) {
4171
4374
  { key: 'APP_URL', value: `https://${platform.files.hostname}` },
4172
4375
  { key: 'AUTH_ISSUER', value: issuer },
4173
4376
  { key: 'AUTH_PERSON_TOKEN_AUDIENCE', value: project.id },
4174
- ...keysEnvs,
4377
+ ...runnerKeysEnvs,
4378
+ { key: 'R2_BUCKET', value: r2.bucket },
4379
+ { key: 'R2_ENDPOINT', value: r2.endpoint },
4380
+ ...runnerOptionalEnvs,
4381
+ // #380: written only when absent, never overwriting an operator's own
4382
+ // limit (`missingEnvs`'s own "written only when absent" contract, the
4383
+ // same `provisionPlatformAi` uses for `PLATFORM_AI_RUNNER_DEFAULTS`).
4384
+ ...(await missingEnvs(deps.coolify, runnerApp.appId, PLATFORM_FILES_RUNNER_DEFAULTS)),
4175
4385
  ];
4176
4386
  // Base env first, so a secret's own write below cannot be the only one
4177
4387
  // that lands, and a re-run with a changed hostname updates `APP_URL`.
@@ -4199,8 +4409,14 @@ async function provisionPlatformFiles(context, deps) {
4199
4409
  await record(PLATFORM_FILES_INTROSPECTION_UNDELIVERED_SCOPE_KEY, client.appId);
4200
4410
  await deliverWithRetry(deps.deliverSecret, FILES_INTROSPECTION_SECRET_REF, client.clientSecret, attempts, sleep, `the introspection client secret (ZITADEL app ${client.appId})`);
4201
4411
  await deps.coolify.bulkUpsertEnvs(runnerApp.appId, [
4202
- { key: 'AUTH_PERSON_TOKEN_INTROSPECTION_CLIENT_ID', value: client.clientId },
4203
- { key: 'AUTH_PERSON_TOKEN_INTROSPECTION_CLIENT_SECRET', value: client.clientSecret },
4412
+ {
4413
+ key: 'AUTH_PERSON_TOKEN_INTROSPECTION_CLIENT_ID',
4414
+ value: client.clientId,
4415
+ },
4416
+ {
4417
+ key: 'AUTH_PERSON_TOKEN_INTROSPECTION_CLIENT_SECRET',
4418
+ value: client.clientSecret,
4419
+ },
4204
4420
  ]);
4205
4421
  await record(PLATFORM_FILES_INTROSPECTION_SCOPE_KEY, client.appId);
4206
4422
  }
@@ -4242,7 +4458,82 @@ function aiKeys(deps) {
4242
4458
  }
4243
4459
  function filesKeys(deps) {
4244
4460
  const keys = deps?.keys;
4245
- return keys?.workerUrl && keys.credential ? keys : null;
4461
+ return keys?.workerUrl && keys.credential && keys.verifyKeys
4462
+ ? {
4463
+ workerUrl: keys.workerUrl,
4464
+ credential: keys.credential,
4465
+ verifyKeys: keys.verifyKeys,
4466
+ }
4467
+ : null;
4468
+ }
4469
+ /** #380: `R2_BUCKET`/`R2_ENDPOINT`, both `required(...)` by the files runner's `ensureStarted()`. */
4470
+ function filesR2(deps) {
4471
+ const r2 = deps?.r2;
4472
+ return r2?.bucket && r2.endpoint ? r2 : null;
4473
+ }
4474
+ /**
4475
+ * #380: the files host's own checks on what the host supplies, mirrored from
4476
+ * `apps/host/src/lib/authz/files-host.ts` (`R2_ENDPOINT`: a bare https origin)
4477
+ * and `src/lib/keys/config.ts` (`KEYS_VERIFY_KEYS`: 1 or 2 comma-separated
4478
+ * base64url values, each exactly 32 bytes), so a bad value blocks the step
4479
+ * naming the field instead of deploying a host that never turns healthy.
4480
+ * Never echoes the value.
4481
+ */
4482
+ function filesDepsInvalid(deps) {
4483
+ const r2 = filesR2(deps);
4484
+ if (r2) {
4485
+ let ok = false;
4486
+ try {
4487
+ const u = new URL(r2.endpoint);
4488
+ ok =
4489
+ u.protocol === 'https:' &&
4490
+ !u.username &&
4491
+ !u.password &&
4492
+ u.pathname === '/' &&
4493
+ !u.search &&
4494
+ !u.hash;
4495
+ }
4496
+ catch {
4497
+ // Not a URL: ok stays false.
4498
+ }
4499
+ if (!ok)
4500
+ return 'R2_ENDPOINT (r2.endpoint) must be an HTTPS origin, with no path, query or fragment';
4501
+ }
4502
+ const verifyKeys = filesKeys(deps)?.verifyKeys;
4503
+ if (verifyKeys !== undefined) {
4504
+ const parts = verifyKeys.split(',');
4505
+ const valid = parts.length <= 2 &&
4506
+ parts.every((part) => /^[A-Za-z0-9_-]+$/.test(part) &&
4507
+ Buffer.from(part, 'base64url').length === 32 &&
4508
+ Buffer.from(part, 'base64url').toString('base64url') === part);
4509
+ if (!valid)
4510
+ return 'KEYS_VERIFY_KEYS (keys.verifyKeys) must be 1 or 2 comma-separated base64url values, each decoding to exactly 32 bytes';
4511
+ }
4512
+ return null;
4513
+ }
4514
+ /**
4515
+ * #380: whether the `files` runner's live env is missing a setting its own
4516
+ * `ensureStarted()` (`apps/host/src/lib/authz/files-host.ts`) requires --
4517
+ * every key `PLATFORM_FILES_RUNNER_DEFAULTS` writes, plus `R2_BUCKET`,
4518
+ * `R2_ENDPOINT` and `KEYS_VERIFY_KEYS`, none of which this step's
4519
+ * idempotency check (hostname/gitRef alone) could ever see. This is exactly
4520
+ * the gap that left the platform's own files host `running:unhealthy` from
4521
+ * the day it first deployed: the app was created and recorded applied, but
4522
+ * nothing ever re-checked whether its env could actually pass a health
4523
+ * check.
4524
+ */
4525
+ async function filesRunnerEnvDrifted(coolify, recordedIds) {
4526
+ const runnerAppId = recordedIds[`coolify:app:${PLATFORM_FILES_KEY}`];
4527
+ if (!runnerAppId)
4528
+ return false;
4529
+ const present = new Set((await coolify.listEnvs(runnerAppId)).filter((e) => !e.is_preview && e.value).map((e) => e.key));
4530
+ const required = [
4531
+ 'R2_BUCKET',
4532
+ 'R2_ENDPOINT',
4533
+ 'KEYS_VERIFY_KEYS',
4534
+ ...PLATFORM_FILES_RUNNER_DEFAULTS.map((d) => d.key),
4535
+ ];
4536
+ return required.some((key) => !present.has(key));
4246
4537
  }
4247
4538
  /** Stops the app when Coolify reports it running; a stop on an app that is not running can fail. */
4248
4539
  async function stopIfRunning(coolify, appId) {
@@ -4333,9 +4624,18 @@ const INTEGRATIONS_REPO = 'wtfalch/integrations';
4333
4624
  * The three Coolify apps `apps/host/README.md`'s "Deploy: migrate, web,
4334
4625
  * worker" names, each with the root Dockerfile target it builds.
4335
4626
  */
4336
- const INTEGRATIONS_MIGRATE_APP = { name: 'integrations-migrate', target: 'migration' };
4337
- const INTEGRATIONS_RUNNER_APP = { name: 'integrations', target: 'runner' };
4338
- const INTEGRATIONS_WORKER_APP = { name: 'integrations-worker', target: 'worker' };
4627
+ const INTEGRATIONS_MIGRATE_APP = {
4628
+ name: 'integrations-migrate',
4629
+ target: 'migration',
4630
+ };
4631
+ const INTEGRATIONS_RUNNER_APP = {
4632
+ name: 'integrations',
4633
+ target: 'runner',
4634
+ };
4635
+ const INTEGRATIONS_WORKER_APP = {
4636
+ name: 'integrations-worker',
4637
+ target: 'worker',
4638
+ };
4339
4639
  /** The runner's `EXPOSE 3000`; readiness is its own `HEALTHCHECK` on `/api/health`. */
4340
4640
  const INTEGRATIONS_RUNNER_PORT = '3000';
4341
4641
  const INTEGRATIONS_DB_NAME = 'integrations-db';
@@ -4535,13 +4835,19 @@ async function provisionPlatformIntegrations(action, context, deps) {
4535
4835
  const runnerEnvs = [
4536
4836
  ...sharedEnvs,
4537
4837
  ...(await envsIfAbsent(coolify, runnerApp.appId, [
4538
- { key: 'INTEGRATIONS_OPERATOR_TENANT_ID', value: INTEGRATIONS_OPERATOR_TENANT_ID },
4838
+ {
4839
+ key: 'INTEGRATIONS_OPERATOR_TENANT_ID',
4840
+ value: INTEGRATIONS_OPERATOR_TENANT_ID,
4841
+ },
4539
4842
  ])),
4540
4843
  ];
4541
4844
  const workerEnvs = [
4542
4845
  ...sharedEnvs,
4543
4846
  ...(await envsIfAbsent(coolify, workerApp.appId, [
4544
- { key: 'INTEGRATIONS_OPERATOR_TENANT_ID', value: INTEGRATIONS_OPERATOR_TENANT_ID },
4847
+ {
4848
+ key: 'INTEGRATIONS_OPERATOR_TENANT_ID',
4849
+ value: INTEGRATIONS_OPERATOR_TENANT_ID,
4850
+ },
4545
4851
  { key: 'INTEGRATIONS_WORKER_BATCH', value: INTEGRATIONS_WORKER_BATCH },
4546
4852
  ])),
4547
4853
  ];
@@ -5122,7 +5428,11 @@ export class ProvidersPlatformCloudflareReader {
5122
5428
  system: 'cloudflare',
5123
5429
  kind: 'zone',
5124
5430
  key: platform.apex,
5125
- config: { id: zone.id, status: zone.status, name_servers: zone.name_servers },
5431
+ config: {
5432
+ id: zone.id,
5433
+ status: zone.status,
5434
+ name_servers: zone.name_servers,
5435
+ },
5126
5436
  },
5127
5437
  ];
5128
5438
  }