@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.
- package/dist/bootstrap/bootstrap.d.ts +2 -0
- package/dist/bootstrap/bootstrap.js +9 -1
- package/dist/bootstrap/declare.d.ts +1 -1
- package/dist/bootstrap/declare.js +7 -2
- package/dist/index.d.ts +3 -3
- package/dist/index.js +2 -2
- package/dist/migrations/0036_applied_versions.sql +45 -0
- package/dist/migrations/0037_platform_server_ipv4.sql +8 -0
- package/dist/org.d.ts +39 -0
- package/dist/org.js +46 -1
- package/dist/plan/boule.d.ts +8 -2
- package/dist/plan/boule.js +10 -1
- package/dist/plan/diff.d.ts +2 -0
- package/dist/plan/diff.js +56 -7
- package/dist/plan/executor.d.ts +50 -6
- package/dist/plan/executor.js +95 -21
- package/dist/plan/plan-apply.d.ts +21 -1
- package/dist/plan/plan-apply.js +38 -3
- package/dist/plan/platform-diff.d.ts +5 -1
- package/dist/plan/platform-diff.js +17 -5
- package/dist/plan/platform-providers.d.ts +55 -3
- package/dist/plan/platform-providers.js +368 -58
- package/dist/platform.d.ts +11 -0
- package/dist/platform.js +5 -0
- package/dist/store/postgres.d.ts +9 -1
- package/dist/store/postgres.js +36 -2
- package/dist/store/schema.d.ts +134 -0
- package/dist/store/schema.js +15 -0
- package/dist/store.d.ts +37 -0
- package/package.json +1 -1
|
@@ -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
|
|
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
|
-
{
|
|
980
|
-
|
|
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
|
-
{
|
|
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
|
-
{
|
|
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
|
-
{
|
|
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: {
|
|
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
|
-
|
|
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({
|
|
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
|
-
|
|
1561
|
-
|
|
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}
|
|
1564
|
-
mount_path:
|
|
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
|
-
|
|
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
|
-
{
|
|
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
|
-
/**
|
|
1778
|
-
|
|
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;
|
|
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;
|
|
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
|
-
{
|
|
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) => ({
|
|
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
|
-
? {
|
|
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({
|
|
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({
|
|
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
|
-
}, {
|
|
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 {
|
|
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: {
|
|
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 = {
|
|
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
|
-
/**
|
|
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, {
|
|
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 = {
|
|
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
|
|
4037
|
-
* `AUTH_ISSUER`, `AUTH_PERSON_TOKEN_*`, `MANAGEMENT_
|
|
4038
|
-
* values the host supplies through {@link PlatformFilesDeps.keys}
|
|
4039
|
-
*
|
|
4040
|
-
*
|
|
4041
|
-
*
|
|
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
|
|
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
|
-
|
|
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
|
-
...
|
|
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
|
-
...
|
|
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
|
-
{
|
|
4203
|
-
|
|
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
|
|
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 = {
|
|
4337
|
-
|
|
4338
|
-
|
|
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
|
-
{
|
|
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
|
-
{
|
|
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: {
|
|
5431
|
+
config: {
|
|
5432
|
+
id: zone.id,
|
|
5433
|
+
status: zone.status,
|
|
5434
|
+
name_servers: zone.name_servers,
|
|
5435
|
+
},
|
|
5126
5436
|
},
|
|
5127
5437
|
];
|
|
5128
5438
|
}
|