@wtfalch/foundry 0.16.7 → 0.16.8
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 +40 -5
- package/dist/plan/executor.d.ts +30 -6
- package/dist/plan/executor.js +86 -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 +51 -1
- package/dist/plan/platform-providers.js +327 -52
- 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) {
|
|
@@ -1420,7 +1467,11 @@ export const PLATFORM_MAIL_ADMIN_KEY_DESCRIPTION = 'foundry:platform:admin';
|
|
|
1420
1467
|
* `stalwartForInstance` fake.
|
|
1421
1468
|
*/
|
|
1422
1469
|
export function defaultStalwartForInstance(input) {
|
|
1423
|
-
const instance = openInstance({
|
|
1470
|
+
const instance = openInstance({
|
|
1471
|
+
url: input.url,
|
|
1472
|
+
secret: input.password,
|
|
1473
|
+
user: input.user,
|
|
1474
|
+
});
|
|
1424
1475
|
return {
|
|
1425
1476
|
async applySettings({ mailHost, apex, oidc }) {
|
|
1426
1477
|
await applyObjects(instance.client, platformMailSettingsObjects({ apex, mailHost, oidc }), () => { });
|
|
@@ -1525,7 +1576,7 @@ async function provisionPlatformMail(context, deps) {
|
|
|
1525
1576
|
// app is not.
|
|
1526
1577
|
const server = await resolveMailServer(deps.coolify, recorded[PLATFORM_MAIL_SERVER_SCOPE_KEY]);
|
|
1527
1578
|
await record(PLATFORM_MAIL_SERVER_SCOPE_KEY, server.uuid);
|
|
1528
|
-
const serverIpBlocked = mailServerIpBlocked(server.ip);
|
|
1579
|
+
const serverIpBlocked = mailServerIpBlocked(server.ip, platform.serverIpv4);
|
|
1529
1580
|
if (serverIpBlocked)
|
|
1530
1581
|
throw new Error(serverIpBlocked);
|
|
1531
1582
|
const app = await findOrCreatePlatformApp(deps.coolify, PLATFORM_MAIL_APP_NAME, environment, async () => {
|
|
@@ -1600,7 +1651,10 @@ async function provisionPlatformMail(context, deps) {
|
|
|
1600
1651
|
await record(PLATFORM_MAIL_TUNNEL_SCOPE_KEY, tunnel.id);
|
|
1601
1652
|
await deps.cloudflare.putTunnelConfig(tunnel.id, {
|
|
1602
1653
|
ingress: [
|
|
1603
|
-
{
|
|
1654
|
+
{
|
|
1655
|
+
hostname,
|
|
1656
|
+
service: `http://${PLATFORM_MAIL_INTERNAL_NAME}:${STALWART_HTTP_PORT}`,
|
|
1657
|
+
},
|
|
1604
1658
|
{ service: 'http_status:404' },
|
|
1605
1659
|
],
|
|
1606
1660
|
});
|
|
@@ -1640,7 +1694,7 @@ async function provisionPlatformMail(context, deps) {
|
|
|
1640
1694
|
});
|
|
1641
1695
|
}
|
|
1642
1696
|
// ---- the MX hostname: a direct, unproxied A record --------------------------
|
|
1643
|
-
const serverIp = server.ip; // checked by mailServerIpBlocked above
|
|
1697
|
+
const serverIp = (platform.serverIpv4 ?? server.ip); // checked by mailServerIpBlocked above
|
|
1644
1698
|
const [mxRecord] = await deps.cloudflare.findDnsRecord(zoneId, mailHost, 'A');
|
|
1645
1699
|
if (!mxRecord) {
|
|
1646
1700
|
await deps.cloudflare.createDnsRecord(zoneId, {
|
|
@@ -1774,13 +1828,19 @@ function mailImageTagBlocked(tag) {
|
|
|
1774
1828
|
? `the platform's mail server is blocked, not planned: ${reason} as the mail gitRef`
|
|
1775
1829
|
: null;
|
|
1776
1830
|
}
|
|
1777
|
-
/**
|
|
1778
|
-
|
|
1831
|
+
/**
|
|
1832
|
+
* Why no address can be the MX A record's content, or `null`. The declared
|
|
1833
|
+
* `platform.serverIpv4` wins; else the Coolify server's own ip, when it is an
|
|
1834
|
+
* IPv4 (the localhost server reports `host.docker.internal`, which is not).
|
|
1835
|
+
*/
|
|
1836
|
+
function mailServerIpBlocked(ip, declared) {
|
|
1837
|
+
if (declared)
|
|
1838
|
+
return null; // validatePlatform already refused a non-IPv4
|
|
1779
1839
|
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;
|
|
1840
|
+
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
1841
|
}
|
|
1782
1842
|
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;
|
|
1843
|
+
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
1844
|
}
|
|
1785
1845
|
return null;
|
|
1786
1846
|
}
|
|
@@ -1815,7 +1875,7 @@ async function mailBlockedReason(platform, mail, recordedIds) {
|
|
|
1815
1875
|
return tagBlocked;
|
|
1816
1876
|
try {
|
|
1817
1877
|
const server = await resolveMailServer(mail.coolify, recordedIds[PLATFORM_MAIL_SERVER_SCOPE_KEY]);
|
|
1818
|
-
return mailServerIpBlocked(server.ip);
|
|
1878
|
+
return mailServerIpBlocked(server.ip, platform.serverIpv4);
|
|
1819
1879
|
}
|
|
1820
1880
|
catch {
|
|
1821
1881
|
return "the platform's mail server is blocked, not planned: no usable Coolify server could be resolved for it";
|
|
@@ -2366,7 +2426,10 @@ async function provisionPlatformArchon(context, deps) {
|
|
|
2366
2426
|
}
|
|
2367
2427
|
await deps.cloudflare.putTunnelConfig(tunnel.id, {
|
|
2368
2428
|
ingress: [
|
|
2369
|
-
{
|
|
2429
|
+
{
|
|
2430
|
+
hostname,
|
|
2431
|
+
service: `http://${PLATFORM_ARCHON_INTERNAL_NAME}:${ARCHON_PORT}`,
|
|
2432
|
+
},
|
|
2370
2433
|
{ service: 'http_status:404' },
|
|
2371
2434
|
],
|
|
2372
2435
|
});
|
|
@@ -2799,7 +2862,9 @@ async function archonBrokerRegistrationDrifted(coolify, recordedIds) {
|
|
|
2799
2862
|
* vary between calls.
|
|
2800
2863
|
*/
|
|
2801
2864
|
async function collectArchonServiceInputs(platform, deps, recorded) {
|
|
2802
|
-
const block = (why) => ({
|
|
2865
|
+
const block = (why) => ({
|
|
2866
|
+
blocked: `Archon is blocked, not planned: ${why}`,
|
|
2867
|
+
});
|
|
2803
2868
|
const envs = [];
|
|
2804
2869
|
const heldByRef = new Map();
|
|
2805
2870
|
const services = ['files', 'ai', 'integrations', 'jobs'].filter((s) => platform[s].enabled);
|
|
@@ -2808,7 +2873,11 @@ async function collectArchonServiceInputs(platform, deps, recorded) {
|
|
|
2808
2873
|
}
|
|
2809
2874
|
for (const service of services) {
|
|
2810
2875
|
const needs = service === 'jobs'
|
|
2811
|
-
? {
|
|
2876
|
+
? {
|
|
2877
|
+
stepKey: null,
|
|
2878
|
+
scopeKeys: [],
|
|
2879
|
+
refs: [JOBS_ADMIN_TOKEN_REF],
|
|
2880
|
+
}
|
|
2812
2881
|
: ARCHON_SERVICE_INPUTS[service];
|
|
2813
2882
|
if (needs.stepKey && !recorded[`coolify:app:${needs.stepKey}`]) {
|
|
2814
2883
|
return block(`the ${service} step (${needs.stepKey}) is not applied yet`);
|
|
@@ -2835,10 +2904,22 @@ async function collectArchonServiceInputs(platform, deps, recorded) {
|
|
|
2835
2904
|
const held = (ref) => heldByRef.get(ref);
|
|
2836
2905
|
for (const service of services) {
|
|
2837
2906
|
if (service === 'files') {
|
|
2838
|
-
envs.push({
|
|
2907
|
+
envs.push({
|
|
2908
|
+
key: 'FILES_URL',
|
|
2909
|
+
value: recorded[PLATFORM_FILES_INTERNAL_URL_SCOPE_KEY],
|
|
2910
|
+
}, {
|
|
2911
|
+
key: 'FILES_MANAGEMENT_KEY',
|
|
2912
|
+
value: held(ARCHON_SERVICE_INPUTS.files.refs[0]),
|
|
2913
|
+
});
|
|
2839
2914
|
}
|
|
2840
2915
|
else if (service === 'ai') {
|
|
2841
|
-
envs.push({
|
|
2916
|
+
envs.push({
|
|
2917
|
+
key: 'AI_URL',
|
|
2918
|
+
value: recorded[PLATFORM_AI_INTERNAL_URL_SCOPE_KEY],
|
|
2919
|
+
}, {
|
|
2920
|
+
key: 'AI_MANAGEMENT_KEY',
|
|
2921
|
+
value: held(ARCHON_SERVICE_INPUTS.ai.refs[0]),
|
|
2922
|
+
}, {
|
|
2842
2923
|
key: 'AI_ORGANISATION_ID',
|
|
2843
2924
|
value: recorded[PLATFORM_AI_ORGANISATION_SCOPE_KEY],
|
|
2844
2925
|
});
|
|
@@ -2848,7 +2929,13 @@ async function collectArchonServiceInputs(platform, deps, recorded) {
|
|
|
2848
2929
|
envs.push({ key: 'INTEGRATIONS_URL', value: url }, {
|
|
2849
2930
|
key: 'INTEGRATIONS_MANAGEMENT_PRIVATE_KEY',
|
|
2850
2931
|
value: held(INTEGRATIONS_MANAGEMENT_KEY_REF),
|
|
2851
|
-
}, {
|
|
2932
|
+
}, {
|
|
2933
|
+
key: 'INTEGRATIONS_MANAGEMENT_ISSUER',
|
|
2934
|
+
value: INTEGRATIONS_MANAGEMENT_ISSUER,
|
|
2935
|
+
}, {
|
|
2936
|
+
key: 'INTEGRATIONS_MANAGEMENT_KEY_ID',
|
|
2937
|
+
value: INTEGRATIONS_MANAGEMENT_KEY_ID,
|
|
2938
|
+
}, { key: 'INTEGRATIONS_MANAGEMENT_AUDIENCE', value: url });
|
|
2852
2939
|
}
|
|
2853
2940
|
else {
|
|
2854
2941
|
envs.push({ key: 'JOBS_URL', value: `https://${platform.jobs.hostname}` }, { key: 'JOBS_ADMIN_TOKEN', value: held(JOBS_ADMIN_TOKEN_REF) });
|
|
@@ -2964,7 +3051,10 @@ function parseAiKeyExistsBody(body) {
|
|
|
2964
3051
|
typeof record.organisationId !== 'string') {
|
|
2965
3052
|
throw new Error("ai's platform/keys route answered 200 with a body missing credentialId/organisationId");
|
|
2966
3053
|
}
|
|
2967
|
-
return {
|
|
3054
|
+
return {
|
|
3055
|
+
credentialId: record.credentialId,
|
|
3056
|
+
organisationId: record.organisationId,
|
|
3057
|
+
};
|
|
2968
3058
|
}
|
|
2969
3059
|
function parseAiKeyMintedBody(body) {
|
|
2970
3060
|
const record = body;
|
|
@@ -3093,7 +3183,10 @@ export function createAiManagementKeyMinter(options) {
|
|
|
3093
3183
|
});
|
|
3094
3184
|
const response = await fetchImpl(`${internalUrl}${AI_PLATFORM_KEYS_PATH}`, {
|
|
3095
3185
|
method: 'POST',
|
|
3096
|
-
headers: {
|
|
3186
|
+
headers: {
|
|
3187
|
+
'content-type': contentType,
|
|
3188
|
+
[AI_MANAGEMENT_HEADER]: token,
|
|
3189
|
+
},
|
|
3097
3190
|
body,
|
|
3098
3191
|
// A hung ai must not hold the apply open.
|
|
3099
3192
|
signal: AbortSignal.timeout(30_000),
|
|
@@ -3130,7 +3223,11 @@ const PLATFORM_AI_MIGRATE = {
|
|
|
3130
3223
|
target: 'migration',
|
|
3131
3224
|
internalName: 'platform-ai-migrate',
|
|
3132
3225
|
};
|
|
3133
|
-
const PLATFORM_AI_RUNNER = {
|
|
3226
|
+
const PLATFORM_AI_RUNNER = {
|
|
3227
|
+
name: 'ai',
|
|
3228
|
+
target: 'runner',
|
|
3229
|
+
internalName: 'platform-ai',
|
|
3230
|
+
};
|
|
3134
3231
|
const PLATFORM_AI_WORKER = {
|
|
3135
3232
|
name: 'ai-worker',
|
|
3136
3233
|
target: 'worker',
|
|
@@ -3156,10 +3253,7 @@ const PLATFORM_AI_RUNNER_DEFAULTS = [
|
|
|
3156
3253
|
{ key: 'AI_FORGE_BINDINGS', value: '[]' },
|
|
3157
3254
|
{ key: 'AI_WORKER_TIMEOUT_MS', value: '60000' },
|
|
3158
3255
|
];
|
|
3159
|
-
const PLATFORM_AI_WORKER_DEFAULTS = [
|
|
3160
|
-
...PLATFORM_AI_RUNNER_DEFAULTS,
|
|
3161
|
-
{ key: 'AI_WORKER_BATCH', value: '10' },
|
|
3162
|
-
];
|
|
3256
|
+
const PLATFORM_AI_WORKER_DEFAULTS = [...PLATFORM_AI_RUNNER_DEFAULTS, { key: 'AI_WORKER_BATCH', value: '10' }];
|
|
3163
3257
|
/**
|
|
3164
3258
|
* #172: makes `coolify:app` keyed {@link PLATFORM_AI_KEY} true -- the ai host
|
|
3165
3259
|
* (https://github.com/wtfalch/ai) on the platform's Coolify, then foundry's
|
|
@@ -3712,9 +3806,14 @@ async function ensureAiRuntimeUrl(coolify, appIds, ownerUrl) {
|
|
|
3712
3806
|
const password = randomBytes(24).toString('base64url');
|
|
3713
3807
|
return `postgres://${database}_rt:${password}@${host}${pathname}${search}`;
|
|
3714
3808
|
}
|
|
3715
|
-
/**
|
|
3809
|
+
/**
|
|
3810
|
+
* The entries of `defaults` whose key the app does not have yet. A key held
|
|
3811
|
+
* with a blank value counts as absent (#380), matching `filesRunnerEnvDrifted`,
|
|
3812
|
+
* so one apply clears the drift instead of replanning every round (#377's loop
|
|
3813
|
+
* class); a blank is never a valid setting, so the AI defaults share this.
|
|
3814
|
+
*/
|
|
3716
3815
|
async function missingEnvs(coolify, appId, defaults) {
|
|
3717
|
-
const present = new Set((await coolify.listEnvs(appId)).filter((e) => !e.is_preview).map((e) => e.key));
|
|
3816
|
+
const present = new Set((await coolify.listEnvs(appId)).filter((e) => !e.is_preview && e.value).map((e) => e.key));
|
|
3718
3817
|
return defaults.filter((d) => !present.has(d.key));
|
|
3719
3818
|
}
|
|
3720
3819
|
const defaultSleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
|
|
@@ -3754,7 +3853,9 @@ async function resolvePlatformServer(coolify) {
|
|
|
3754
3853
|
* the app was already running or starting; the caller deploys it when not.
|
|
3755
3854
|
*/
|
|
3756
3855
|
async function findOrCreatePlatformApp(coolify, name, environment, create) {
|
|
3757
|
-
const existing = await coolify.findApplicationByName(name, {
|
|
3856
|
+
const existing = await coolify.findApplicationByName(name, {
|
|
3857
|
+
environmentIds: [environment.id],
|
|
3858
|
+
});
|
|
3758
3859
|
if (existing) {
|
|
3759
3860
|
const live = await coolify.getApplication(existing.uuid);
|
|
3760
3861
|
return {
|
|
@@ -3880,7 +3981,10 @@ export function brokerDeployNeeded(missingInputs, brokerAppId, authAppsRaw, reco
|
|
|
3880
3981
|
* wrote there. Paired with {@link brokerDeployNeeded}, which reads it back.
|
|
3881
3982
|
*/
|
|
3882
3983
|
export function brokerDeployRecordValue(brokerAppId, authAppsRaw) {
|
|
3883
|
-
const record = {
|
|
3984
|
+
const record = {
|
|
3985
|
+
brokerAppId,
|
|
3986
|
+
authAppsHash: hashAuthApps(authAppsRaw),
|
|
3987
|
+
};
|
|
3884
3988
|
return JSON.stringify(record);
|
|
3885
3989
|
}
|
|
3886
3990
|
/** Whether any desired env is missing from the app, or set to another value. */
|
|
@@ -3982,6 +4086,20 @@ const FILES_DB = 'files';
|
|
|
3982
4086
|
const FILES_RUNTIME_ROLE = `${FILES_DB}_rt`;
|
|
3983
4087
|
const FILES_ZITADEL_PROJECT_NAME = 'files';
|
|
3984
4088
|
const FILES_INTROSPECTION_APP_NAME = 'files-introspection';
|
|
4089
|
+
/**
|
|
4090
|
+
* #380: written only when absent, never overwritten, the same
|
|
4091
|
+
* {@link PLATFORM_AI_RUNNER_DEFAULTS} convention -- an operator's own limits
|
|
4092
|
+
* survive every apply. Values are files' own `.env.example`. The runner
|
|
4093
|
+
* needs all three: files' `ensureStarted()`
|
|
4094
|
+
* (`apps/host/src/lib/authz/files-host.ts`) requires each with
|
|
4095
|
+
* `required(...)`, and the health check fails without them -- the same gap
|
|
4096
|
+
* #375 fixed for the AI host's `AI_WORKER_TIMEOUT_MS`.
|
|
4097
|
+
*/
|
|
4098
|
+
const PLATFORM_FILES_RUNNER_DEFAULTS = [
|
|
4099
|
+
{ key: 'FILES_MAX_FILE_BYTES', value: '26214400' },
|
|
4100
|
+
{ key: 'FILES_UPLOAD_TTL_SECONDS', value: '300' },
|
|
4101
|
+
{ key: 'FILES_SCRATCH_TTL_DAYS', value: '1' },
|
|
4102
|
+
];
|
|
3985
4103
|
const PLATFORM_FILES_PROJECT_SCOPE_KEY = 'coolify_project_platform_files';
|
|
3986
4104
|
const PLATFORM_FILES_DB_SCOPE_KEY = 'coolify_platform_files_database';
|
|
3987
4105
|
const PLATFORM_FILES_MIGRATE_SCOPE_KEY = 'coolify_platform_files_migrate_app';
|
|
@@ -4033,12 +4151,17 @@ const PLATFORM_FILES_RUNNER_SCOPE_KEY = 'coolify_platform_files_runner_app';
|
|
|
4033
4151
|
* field for it. Not yet checked against a live Coolify.
|
|
4034
4152
|
* - The runner is created without `domains`, like `platform:auth`'s apps.
|
|
4035
4153
|
* 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
|
-
*
|
|
4154
|
+
* - Foundry sets the env it owns: the two database URLs, `APP_URL`,
|
|
4155
|
+
* `AUTH_ISSUER`, `AUTH_PERSON_TOKEN_*`, `MANAGEMENT_*`, the `KEYS_*`
|
|
4156
|
+
* values the host supplies through {@link PlatformFilesDeps.keys} (#380
|
|
4157
|
+
* added `KEYS_VERIFY_KEYS`, required for the runner to become healthy,
|
|
4158
|
+
* and the optional `KEYS_AUDIT_TOKEN`), `R2_BUCKET`/`R2_ENDPOINT` from
|
|
4159
|
+
* {@link PlatformFilesDeps.r2} (#380, also required), the optional
|
|
4160
|
+
* `FILES_CLAMD_HOST`/`FILES_CLAMD_PORT`, `FILES_PUBLIC_HOSTS` and
|
|
4161
|
+
* `JOBS_SIGNING_SECRETS` the host may supply (#380), and the three
|
|
4162
|
+
* {@link PLATFORM_FILES_RUNNER_DEFAULTS} (#380, written only when absent).
|
|
4163
|
+
* `AUTHZ_BOOTSTRAP_OPERATOR` and the rest of files' `.env.example` are
|
|
4164
|
+
* still set by a person on the runner.
|
|
4042
4165
|
* - The files project lives in the platform's `foundry` ZITADEL org. An
|
|
4043
4166
|
* org's Agora requests it as an audience scope from its own ZITADEL org;
|
|
4044
4167
|
* whether that needs a project grant per org is not decided here.
|
|
@@ -4055,7 +4178,15 @@ async function provisionPlatformFiles(context, deps) {
|
|
|
4055
4178
|
const attempts = runtime.deliverAttempts ?? 3;
|
|
4056
4179
|
const keys = filesKeys(deps);
|
|
4057
4180
|
if (!keys) {
|
|
4058
|
-
throw new Error('platform:files needs the keys worker URL and
|
|
4181
|
+
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');
|
|
4182
|
+
}
|
|
4183
|
+
// #380: R2_BUCKET/R2_ENDPOINT, the same "block the whole step" treatment
|
|
4184
|
+
// as a missing `keys` above -- the runner's `ensureStarted()` requires
|
|
4185
|
+
// both, and only this function's caller (`blockedSteps`, `planFiles`) ever
|
|
4186
|
+
// has anything useful to report while they are unset.
|
|
4187
|
+
const r2 = filesR2(deps);
|
|
4188
|
+
if (!r2) {
|
|
4189
|
+
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
4190
|
}
|
|
4060
4191
|
// Never plan-and-throw: the planner reports a lost secret and plans
|
|
4061
4192
|
// nothing; this is the same rule for a caller that reaches the step anyway.
|
|
@@ -4149,14 +4280,16 @@ async function provisionPlatformFiles(context, deps) {
|
|
|
4149
4280
|
const runtimeUrl = await ensureFilesRuntimeUrl(deps.coolify, [migrateApp.appId, runnerApp.appId], db.internal_db_url);
|
|
4150
4281
|
// The keys worker the migrate app needs to become healthy (files' README:
|
|
4151
4282
|
// `DATABASE_URL_OWNER`, `DATABASE_URL`, `KEYS_WORKER_URL`, `KEYS_CREDENTIAL`).
|
|
4152
|
-
|
|
4283
|
+
// `files-migrate` never runs `ensureStarted()`, so it needs neither
|
|
4284
|
+
// `KEYS_VERIFY_KEYS` nor the `files` runner's R2/optional settings below.
|
|
4285
|
+
const migrateKeysEnvs = [
|
|
4153
4286
|
{ key: 'KEYS_WORKER_URL', value: keys.workerUrl },
|
|
4154
4287
|
{ key: 'KEYS_CREDENTIAL', value: keys.credential },
|
|
4155
4288
|
];
|
|
4156
4289
|
await deps.coolify.bulkUpsertEnvs(migrateApp.appId, [
|
|
4157
4290
|
{ key: 'DATABASE_URL_OWNER', value: db.internal_db_url },
|
|
4158
4291
|
{ key: 'DATABASE_URL', value: runtimeUrl },
|
|
4159
|
-
...
|
|
4292
|
+
...migrateKeysEnvs,
|
|
4160
4293
|
]);
|
|
4161
4294
|
// ---- the files ZITADEL project, in the platform's sign-in ----------------
|
|
4162
4295
|
const issuer = `https://${platform.auth.hostname}`;
|
|
@@ -4164,6 +4297,41 @@ async function provisionPlatformFiles(context, deps) {
|
|
|
4164
4297
|
const { org } = await zitadel.findOrCreateOrg(PLATFORM_AUTH_ORG_NAME);
|
|
4165
4298
|
const { project } = await zitadel.findOrCreateProject(org.id, FILES_ZITADEL_PROJECT_NAME);
|
|
4166
4299
|
await record(PLATFORM_FILES_ZITADEL_PROJECT_SCOPE_KEY, project.id);
|
|
4300
|
+
// #380: the runner's own required settings -- `KEYS_VERIFY_KEYS` and
|
|
4301
|
+
// `R2_BUCKET`/`R2_ENDPOINT` -- plus whatever optional ones the host
|
|
4302
|
+
// supplies. `files-migrate` needs none of these (see `migrateKeysEnvs`
|
|
4303
|
+
// above).
|
|
4304
|
+
const runnerKeysEnvs = [
|
|
4305
|
+
...migrateKeysEnvs,
|
|
4306
|
+
{ key: 'KEYS_VERIFY_KEYS', value: keys.verifyKeys },
|
|
4307
|
+
];
|
|
4308
|
+
if (deps.keys?.auditToken)
|
|
4309
|
+
runnerKeysEnvs.push({
|
|
4310
|
+
key: 'KEYS_AUDIT_TOKEN',
|
|
4311
|
+
value: deps.keys.auditToken,
|
|
4312
|
+
});
|
|
4313
|
+
const runnerOptionalEnvs = [];
|
|
4314
|
+
if (deps.clamd?.host) {
|
|
4315
|
+
runnerOptionalEnvs.push({
|
|
4316
|
+
key: 'FILES_CLAMD_HOST',
|
|
4317
|
+
value: deps.clamd.host,
|
|
4318
|
+
});
|
|
4319
|
+
if (deps.clamd.port)
|
|
4320
|
+
runnerOptionalEnvs.push({
|
|
4321
|
+
key: 'FILES_CLAMD_PORT',
|
|
4322
|
+
value: deps.clamd.port,
|
|
4323
|
+
});
|
|
4324
|
+
}
|
|
4325
|
+
if (deps.publicHosts)
|
|
4326
|
+
runnerOptionalEnvs.push({
|
|
4327
|
+
key: 'FILES_PUBLIC_HOSTS',
|
|
4328
|
+
value: deps.publicHosts,
|
|
4329
|
+
});
|
|
4330
|
+
if (deps.jobsSigningSecrets)
|
|
4331
|
+
runnerOptionalEnvs.push({
|
|
4332
|
+
key: 'JOBS_SIGNING_SECRETS',
|
|
4333
|
+
value: deps.jobsSigningSecrets,
|
|
4334
|
+
});
|
|
4167
4335
|
const runnerEnvs = [
|
|
4168
4336
|
{ key: 'DATABASE_URL', value: runtimeUrl },
|
|
4169
4337
|
// The management verifier's audience, which must be an HTTPS origin. The
|
|
@@ -4171,7 +4339,14 @@ async function provisionPlatformFiles(context, deps) {
|
|
|
4171
4339
|
{ key: 'APP_URL', value: `https://${platform.files.hostname}` },
|
|
4172
4340
|
{ key: 'AUTH_ISSUER', value: issuer },
|
|
4173
4341
|
{ key: 'AUTH_PERSON_TOKEN_AUDIENCE', value: project.id },
|
|
4174
|
-
...
|
|
4342
|
+
...runnerKeysEnvs,
|
|
4343
|
+
{ key: 'R2_BUCKET', value: r2.bucket },
|
|
4344
|
+
{ key: 'R2_ENDPOINT', value: r2.endpoint },
|
|
4345
|
+
...runnerOptionalEnvs,
|
|
4346
|
+
// #380: written only when absent, never overwriting an operator's own
|
|
4347
|
+
// limit (`missingEnvs`'s own "written only when absent" contract, the
|
|
4348
|
+
// same `provisionPlatformAi` uses for `PLATFORM_AI_RUNNER_DEFAULTS`).
|
|
4349
|
+
...(await missingEnvs(deps.coolify, runnerApp.appId, PLATFORM_FILES_RUNNER_DEFAULTS)),
|
|
4175
4350
|
];
|
|
4176
4351
|
// Base env first, so a secret's own write below cannot be the only one
|
|
4177
4352
|
// that lands, and a re-run with a changed hostname updates `APP_URL`.
|
|
@@ -4199,8 +4374,14 @@ async function provisionPlatformFiles(context, deps) {
|
|
|
4199
4374
|
await record(PLATFORM_FILES_INTROSPECTION_UNDELIVERED_SCOPE_KEY, client.appId);
|
|
4200
4375
|
await deliverWithRetry(deps.deliverSecret, FILES_INTROSPECTION_SECRET_REF, client.clientSecret, attempts, sleep, `the introspection client secret (ZITADEL app ${client.appId})`);
|
|
4201
4376
|
await deps.coolify.bulkUpsertEnvs(runnerApp.appId, [
|
|
4202
|
-
{
|
|
4203
|
-
|
|
4377
|
+
{
|
|
4378
|
+
key: 'AUTH_PERSON_TOKEN_INTROSPECTION_CLIENT_ID',
|
|
4379
|
+
value: client.clientId,
|
|
4380
|
+
},
|
|
4381
|
+
{
|
|
4382
|
+
key: 'AUTH_PERSON_TOKEN_INTROSPECTION_CLIENT_SECRET',
|
|
4383
|
+
value: client.clientSecret,
|
|
4384
|
+
},
|
|
4204
4385
|
]);
|
|
4205
4386
|
await record(PLATFORM_FILES_INTROSPECTION_SCOPE_KEY, client.appId);
|
|
4206
4387
|
}
|
|
@@ -4242,7 +4423,82 @@ function aiKeys(deps) {
|
|
|
4242
4423
|
}
|
|
4243
4424
|
function filesKeys(deps) {
|
|
4244
4425
|
const keys = deps?.keys;
|
|
4245
|
-
return keys?.workerUrl && keys.credential
|
|
4426
|
+
return keys?.workerUrl && keys.credential && keys.verifyKeys
|
|
4427
|
+
? {
|
|
4428
|
+
workerUrl: keys.workerUrl,
|
|
4429
|
+
credential: keys.credential,
|
|
4430
|
+
verifyKeys: keys.verifyKeys,
|
|
4431
|
+
}
|
|
4432
|
+
: null;
|
|
4433
|
+
}
|
|
4434
|
+
/** #380: `R2_BUCKET`/`R2_ENDPOINT`, both `required(...)` by the files runner's `ensureStarted()`. */
|
|
4435
|
+
function filesR2(deps) {
|
|
4436
|
+
const r2 = deps?.r2;
|
|
4437
|
+
return r2?.bucket && r2.endpoint ? r2 : null;
|
|
4438
|
+
}
|
|
4439
|
+
/**
|
|
4440
|
+
* #380: the files host's own checks on what the host supplies, mirrored from
|
|
4441
|
+
* `apps/host/src/lib/authz/files-host.ts` (`R2_ENDPOINT`: a bare https origin)
|
|
4442
|
+
* and `src/lib/keys/config.ts` (`KEYS_VERIFY_KEYS`: 1 or 2 comma-separated
|
|
4443
|
+
* base64url values, each exactly 32 bytes), so a bad value blocks the step
|
|
4444
|
+
* naming the field instead of deploying a host that never turns healthy.
|
|
4445
|
+
* Never echoes the value.
|
|
4446
|
+
*/
|
|
4447
|
+
function filesDepsInvalid(deps) {
|
|
4448
|
+
const r2 = filesR2(deps);
|
|
4449
|
+
if (r2) {
|
|
4450
|
+
let ok = false;
|
|
4451
|
+
try {
|
|
4452
|
+
const u = new URL(r2.endpoint);
|
|
4453
|
+
ok =
|
|
4454
|
+
u.protocol === 'https:' &&
|
|
4455
|
+
!u.username &&
|
|
4456
|
+
!u.password &&
|
|
4457
|
+
u.pathname === '/' &&
|
|
4458
|
+
!u.search &&
|
|
4459
|
+
!u.hash;
|
|
4460
|
+
}
|
|
4461
|
+
catch {
|
|
4462
|
+
// Not a URL: ok stays false.
|
|
4463
|
+
}
|
|
4464
|
+
if (!ok)
|
|
4465
|
+
return 'R2_ENDPOINT (r2.endpoint) must be an HTTPS origin, with no path, query or fragment';
|
|
4466
|
+
}
|
|
4467
|
+
const verifyKeys = filesKeys(deps)?.verifyKeys;
|
|
4468
|
+
if (verifyKeys !== undefined) {
|
|
4469
|
+
const parts = verifyKeys.split(',');
|
|
4470
|
+
const valid = parts.length <= 2 &&
|
|
4471
|
+
parts.every((part) => /^[A-Za-z0-9_-]+$/.test(part) &&
|
|
4472
|
+
Buffer.from(part, 'base64url').length === 32 &&
|
|
4473
|
+
Buffer.from(part, 'base64url').toString('base64url') === part);
|
|
4474
|
+
if (!valid)
|
|
4475
|
+
return 'KEYS_VERIFY_KEYS (keys.verifyKeys) must be 1 or 2 comma-separated base64url values, each decoding to exactly 32 bytes';
|
|
4476
|
+
}
|
|
4477
|
+
return null;
|
|
4478
|
+
}
|
|
4479
|
+
/**
|
|
4480
|
+
* #380: whether the `files` runner's live env is missing a setting its own
|
|
4481
|
+
* `ensureStarted()` (`apps/host/src/lib/authz/files-host.ts`) requires --
|
|
4482
|
+
* every key `PLATFORM_FILES_RUNNER_DEFAULTS` writes, plus `R2_BUCKET`,
|
|
4483
|
+
* `R2_ENDPOINT` and `KEYS_VERIFY_KEYS`, none of which this step's
|
|
4484
|
+
* idempotency check (hostname/gitRef alone) could ever see. This is exactly
|
|
4485
|
+
* the gap that left the platform's own files host `running:unhealthy` from
|
|
4486
|
+
* the day it first deployed: the app was created and recorded applied, but
|
|
4487
|
+
* nothing ever re-checked whether its env could actually pass a health
|
|
4488
|
+
* check.
|
|
4489
|
+
*/
|
|
4490
|
+
async function filesRunnerEnvDrifted(coolify, recordedIds) {
|
|
4491
|
+
const runnerAppId = recordedIds[`coolify:app:${PLATFORM_FILES_KEY}`];
|
|
4492
|
+
if (!runnerAppId)
|
|
4493
|
+
return false;
|
|
4494
|
+
const present = new Set((await coolify.listEnvs(runnerAppId)).filter((e) => !e.is_preview && e.value).map((e) => e.key));
|
|
4495
|
+
const required = [
|
|
4496
|
+
'R2_BUCKET',
|
|
4497
|
+
'R2_ENDPOINT',
|
|
4498
|
+
'KEYS_VERIFY_KEYS',
|
|
4499
|
+
...PLATFORM_FILES_RUNNER_DEFAULTS.map((d) => d.key),
|
|
4500
|
+
];
|
|
4501
|
+
return required.some((key) => !present.has(key));
|
|
4246
4502
|
}
|
|
4247
4503
|
/** Stops the app when Coolify reports it running; a stop on an app that is not running can fail. */
|
|
4248
4504
|
async function stopIfRunning(coolify, appId) {
|
|
@@ -4333,9 +4589,18 @@ const INTEGRATIONS_REPO = 'wtfalch/integrations';
|
|
|
4333
4589
|
* The three Coolify apps `apps/host/README.md`'s "Deploy: migrate, web,
|
|
4334
4590
|
* worker" names, each with the root Dockerfile target it builds.
|
|
4335
4591
|
*/
|
|
4336
|
-
const INTEGRATIONS_MIGRATE_APP = {
|
|
4337
|
-
|
|
4338
|
-
|
|
4592
|
+
const INTEGRATIONS_MIGRATE_APP = {
|
|
4593
|
+
name: 'integrations-migrate',
|
|
4594
|
+
target: 'migration',
|
|
4595
|
+
};
|
|
4596
|
+
const INTEGRATIONS_RUNNER_APP = {
|
|
4597
|
+
name: 'integrations',
|
|
4598
|
+
target: 'runner',
|
|
4599
|
+
};
|
|
4600
|
+
const INTEGRATIONS_WORKER_APP = {
|
|
4601
|
+
name: 'integrations-worker',
|
|
4602
|
+
target: 'worker',
|
|
4603
|
+
};
|
|
4339
4604
|
/** The runner's `EXPOSE 3000`; readiness is its own `HEALTHCHECK` on `/api/health`. */
|
|
4340
4605
|
const INTEGRATIONS_RUNNER_PORT = '3000';
|
|
4341
4606
|
const INTEGRATIONS_DB_NAME = 'integrations-db';
|
|
@@ -4535,13 +4800,19 @@ async function provisionPlatformIntegrations(action, context, deps) {
|
|
|
4535
4800
|
const runnerEnvs = [
|
|
4536
4801
|
...sharedEnvs,
|
|
4537
4802
|
...(await envsIfAbsent(coolify, runnerApp.appId, [
|
|
4538
|
-
{
|
|
4803
|
+
{
|
|
4804
|
+
key: 'INTEGRATIONS_OPERATOR_TENANT_ID',
|
|
4805
|
+
value: INTEGRATIONS_OPERATOR_TENANT_ID,
|
|
4806
|
+
},
|
|
4539
4807
|
])),
|
|
4540
4808
|
];
|
|
4541
4809
|
const workerEnvs = [
|
|
4542
4810
|
...sharedEnvs,
|
|
4543
4811
|
...(await envsIfAbsent(coolify, workerApp.appId, [
|
|
4544
|
-
{
|
|
4812
|
+
{
|
|
4813
|
+
key: 'INTEGRATIONS_OPERATOR_TENANT_ID',
|
|
4814
|
+
value: INTEGRATIONS_OPERATOR_TENANT_ID,
|
|
4815
|
+
},
|
|
4545
4816
|
{ key: 'INTEGRATIONS_WORKER_BATCH', value: INTEGRATIONS_WORKER_BATCH },
|
|
4546
4817
|
])),
|
|
4547
4818
|
];
|
|
@@ -5122,7 +5393,11 @@ export class ProvidersPlatformCloudflareReader {
|
|
|
5122
5393
|
system: 'cloudflare',
|
|
5123
5394
|
kind: 'zone',
|
|
5124
5395
|
key: platform.apex,
|
|
5125
|
-
config: {
|
|
5396
|
+
config: {
|
|
5397
|
+
id: zone.id,
|
|
5398
|
+
status: zone.status,
|
|
5399
|
+
name_servers: zone.name_servers,
|
|
5400
|
+
},
|
|
5126
5401
|
},
|
|
5127
5402
|
];
|
|
5128
5403
|
}
|
package/dist/platform.d.ts
CHANGED
|
@@ -31,6 +31,16 @@ export interface DeclaredPlatform {
|
|
|
31
31
|
ownerOrgId: OrgId;
|
|
32
32
|
/** The shared apex every service defaults its hostname under, e.g. `companyincluded.com`. */
|
|
33
33
|
apex: string;
|
|
34
|
+
/**
|
|
35
|
+
* The Coolify server's public IPv4, for DNS A records (today `mx.<apex>`).
|
|
36
|
+
* Optional: Coolify's own localhost server reports `host.docker.internal`
|
|
37
|
+
* as its ip, which is not a public address and cannot be changed, so the
|
|
38
|
+
* operator declares the real one here, with the rest of the platform,
|
|
39
|
+
* instead of hand-setting it in Coolify. Platform-wide, not under `mail`,
|
|
40
|
+
* because it is a fact about the one shared server. Wins over the server's
|
|
41
|
+
* own reported ip when set.
|
|
42
|
+
*/
|
|
43
|
+
serverIpv4?: string | null;
|
|
34
44
|
auth: PlatformService;
|
|
35
45
|
mail: PlatformService;
|
|
36
46
|
sfu: PlatformService;
|
|
@@ -47,6 +57,7 @@ export declare function defaultPlatformHostname(service: PlatformServiceKey, ape
|
|
|
47
57
|
* shape as `org.ts`'s `validateBuyerIdentity`.
|
|
48
58
|
*
|
|
49
59
|
* - `apex` is a valid domain (`org.ts`'s `validateDomain`).
|
|
60
|
+
* - `serverIpv4`, when declared, is a dotted-quad IPv4 address.
|
|
50
61
|
* - Every service's `hostname` is the apex itself or a subdomain of it -- a
|
|
51
62
|
* platform service does not live outside the platform's own apex.
|
|
52
63
|
* - Every service's `gitRef` is non-empty once that service is `enabled` --
|