@wtfalch/foundry 0.12.0 → 0.14.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/apply.d.ts +0 -16
- package/dist/index.d.ts +2 -3
- package/dist/index.js +1 -1
- package/dist/migrations/0032_drop_adoptions.sql +16 -0
- package/dist/migrations/0033_declared_org_version.sql +13 -0
- package/dist/org.d.ts +6 -19
- package/dist/org.js +0 -52
- package/dist/plan/agora.d.ts +63 -1
- package/dist/plan/agora.js +91 -1
- package/dist/plan/broker-entries.d.ts +1 -5
- package/dist/plan/cloudflared-connector.d.ts +2 -10
- package/dist/plan/cloudflared-connector.js +16 -24
- package/dist/plan/diff.d.ts +0 -12
- package/dist/plan/diff.js +77 -239
- package/dist/plan/executor.d.ts +20 -93
- package/dist/plan/executor.js +208 -918
- package/dist/plan/plan-apply.d.ts +3 -47
- package/dist/plan/plan-apply.js +9 -125
- package/dist/plan/platform-diff.d.ts +0 -6
- package/dist/plan/platform-diff.js +0 -8
- package/dist/plan/providers-readers.d.ts +1 -8
- package/dist/plan/providers-readers.js +4 -58
- package/dist/plan.d.ts +0 -6
- package/dist/store/postgres.d.ts +1 -6
- package/dist/store/postgres.js +25 -74
- package/dist/store/schema.d.ts +17 -162
- package/dist/store/schema.js +3 -19
- package/dist/store.d.ts +18 -37
- package/package.json +1 -1
- package/dist/plan/adopt.d.ts +0 -112
- package/dist/plan/adopt.js +0 -252
- package/dist/plan/adoption-vault.d.ts +0 -9
- package/dist/plan/adoption-vault.js +0 -1
package/dist/plan/executor.js
CHANGED
|
@@ -1,17 +1,15 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { randomBytes } from 'node:crypto';
|
|
2
2
|
import { URL } from 'node:url';
|
|
3
|
-
import { isDeepStrictEqual } from 'node:util';
|
|
4
3
|
import { CALLS_SFU_PRIVATE_URL, CALLS_SFU_PUBLIC_URL } from '../calls-sfu.js';
|
|
5
4
|
import { APP_ENTRY_KEY, appEntry } from '../org.js';
|
|
6
|
-
import {
|
|
7
|
-
import { AGORA_CLIENT_ID_SCOPE_KEY, APP_CLIENT_ID_SCOPE_KEY, CMS_CLIENT_ID_SCOPE_KEY, ENTRY_CLIENT_ID_SCOPE_PREFIX, agoraHostnameFor, desiredAgoraEnv, entryClientIdScopeKey, } from './agora.js';
|
|
5
|
+
import { AGORA_CLIENT_ID_SCOPE_KEY, APP_CLIENT_ID_SCOPE_KEY, CALLS_INTROSPECTION_SCOPE_KEY, CALLS_INTROSPECTION_UNDELIVERED_SCOPE_KEY, CALLS_SERVICE_NAME_SCOPE_KEY, CALLS_SERVICE_PORT, CALLS_SERVICE_TOKEN_ENV_SCOPE_KEY, CMS_CLIENT_ID_SCOPE_KEY, ENTRY_CLIENT_ID_SCOPE_PREFIX, ZITADEL_ORG_SCOPE_KEY, agoraHostnameFor, callsIntrospectionAppName, callsServiceHasName, callsServiceInternalName, callsTokenEnvMarker, callsTokenEnvMovedMessage, desiredAgoraEnv, entryClientIdScopeKey, } from './agora.js';
|
|
8
6
|
import { brokerEntryAppliedScopeKey, brokerEntryWarningsScopeKey, entryAuthEnv, entryAuthEnvAppliedScopeKey, wantedBrokerEntry, writeBrokerEntry, } from './broker-entries.js';
|
|
9
7
|
import { MAS_IMAGE, MAS_TAG, SYNAPSE_IMAGE, SYNAPSE_TAG, callsMasConfigYaml, callsServerName, callsSynapseHomeserverYaml, mintCallsMatrixSecrets, parseCallsDbUrl, } from './calls-matrix.js';
|
|
10
|
-
import { ensureCloudflaredConnector
|
|
8
|
+
import { ensureCloudflaredConnector } from './cloudflared-connector.js';
|
|
11
9
|
import { appHostname, entryHostnames, tunnelDnsHostnames, tunnelIngressHostnames, } from './hostnames.js';
|
|
12
10
|
import { LOGIN_BINDING_SCOPE_KEY, LOGIN_HOST_PENDING_REMOVAL_SCOPE_KEY, loginBindingState, loginHostClientIds, pendingRemovalHosts, pendingRemovalState, } from './login-host.js';
|
|
13
11
|
import { MAIL_COPY_COMPLETED_SCOPE_KEY, MAIL_COPY_DONE_SCOPE_KEY, parseMailCopyCompleted, runMailCopyStep, } from './mail-copy.js';
|
|
14
|
-
import { CALLS_MAS_APPLIED_IMAGE_TAG_SCOPE_KEY, CALLS_SYNAPSE_APPLIED_IMAGE_TAG_SCOPE_KEY, ORG_MAIL_APPLIED_APEX_SCOPE_KEY, ORG_MAIL_APPLIED_HOST_SCOPE_KEY,
|
|
12
|
+
import { CALLS_MAS_APPLIED_IMAGE_TAG_SCOPE_KEY, CALLS_SYNAPSE_APPLIED_IMAGE_TAG_SCOPE_KEY, ORG_MAIL_APPLIED_APEX_SCOPE_KEY, ORG_MAIL_APPLIED_HOST_SCOPE_KEY, appDomainsAppliedScopeKey, appEnvAppliedScopeKey, appNameFor, appSettingsAppliedScopeKey, appSettingsOf, appStepKey, appliedAppDomains, appliedAppSettings, dbEnvAppliedScopeKey, resolveAppEnv, } from './platform-diff.js';
|
|
15
13
|
/**
|
|
16
14
|
* #274 fix round 4: bridges `@wtfalch/providers`' real `ZitadelClient.
|
|
17
15
|
* listApps` -- a plain array, with no `loginBaseUri` or completeness signal
|
|
@@ -295,34 +293,6 @@ const CLOUDFLARE_TUNNEL_CONNECTOR_APP_SCOPE_KEY = 'cloudflare_tunnel_connector_a
|
|
|
295
293
|
* already follows.
|
|
296
294
|
*/
|
|
297
295
|
const CLOUDFLARE_TUNNEL_DNS_DOMAIN_SCOPE_KEY = 'cloudflare_tunnel_dns_domain';
|
|
298
|
-
/**
|
|
299
|
-
* #312: the ingress hostnames foundry wrote on an adopted tunnel, kept in the
|
|
300
|
-
* tunnel row's `settings.written` for the life of the snapshot, so foundry's
|
|
301
|
-
* routes are told from a person's by this record, never by guessing. A name is
|
|
302
|
-
* added only after the write that carried it succeeded: a crash in between
|
|
303
|
-
* leaves a route taken as foreign and kept.
|
|
304
|
-
*/
|
|
305
|
-
function writtenIngressNames(row) {
|
|
306
|
-
const written = row?.settings.written;
|
|
307
|
-
return new Set(Array.isArray(written) ? written.filter((n) => typeof n === 'string') : []);
|
|
308
|
-
}
|
|
309
|
-
/** The names the row says a put may have carried (`writing`): restore treats one as written if its live route is exactly foundry's. */
|
|
310
|
-
function writingIngressNames(row) {
|
|
311
|
-
const writing = row?.settings.writing;
|
|
312
|
-
return Array.isArray(writing) ? writing.filter((n) => typeof n === 'string') : [];
|
|
313
|
-
}
|
|
314
|
-
/** {@link CreatedDnsRecord}s of the tunnel row's `settings`, ignoring any entry of another shape. */
|
|
315
|
-
function createdDnsRecords(settings) {
|
|
316
|
-
const list = settings?.dnsRecords;
|
|
317
|
-
return (Array.isArray(list) ? list : []).filter((r) => typeof r?.zoneId === 'string' && typeof r?.id === 'string' && typeof r?.hostname === 'string');
|
|
318
|
-
}
|
|
319
|
-
/** The hostnames the tunnel row says foundry is about to create a record for. */
|
|
320
|
-
const dnsCreatingNames = (settings) => {
|
|
321
|
-
const list = settings?.dnsCreating;
|
|
322
|
-
return (Array.isArray(list) ? list : []).filter((n) => typeof n === 'string');
|
|
323
|
-
};
|
|
324
|
-
/** The live `TUNNEL_TOKEN` row of a connector app, if Coolify lists one (#312). */
|
|
325
|
-
const tunnelTokenRows = (envs) => envs.filter((e) => e.key === 'TUNNEL_TOKEN' && !e.is_preview);
|
|
326
296
|
/**
|
|
327
297
|
* #236 round 3: every hostname {@link FoundryStepExecutor.publishTunnelDnsRecords}
|
|
328
298
|
* last wrote a CNAME for, joined by `,` (hostnames carry no `,`, the same
|
|
@@ -538,8 +508,6 @@ const CALLS_SERVICE_DB_SCOPE_KEY = 'coolify_calls_service_database';
|
|
|
538
508
|
const CALLS_SERVICE_REPO = 'wtfalch/calls';
|
|
539
509
|
/** `packages/service/Dockerfile`, built from the repo root (that Dockerfile's own top comment). */
|
|
540
510
|
const CALLS_SERVICE_DOCKERFILE = 'packages/service/Dockerfile';
|
|
541
|
-
/** The port `calls/packages/service/Dockerfile` documents (`EXPOSE 8080`) -- `config.mjs`'s own `PORT` has no default, so this must be pushed explicitly. */
|
|
542
|
-
const CALLS_SERVICE_PORT = '8080';
|
|
543
511
|
/**
|
|
544
512
|
* Whether an application's own Coolify `status` (`` `running:healthy`,
|
|
545
513
|
* `exited:unhealthy` `` -- `@wtfalch/providers`' own doc comment) means it is
|
|
@@ -556,100 +524,6 @@ export function isRunningOrStarting(status) {
|
|
|
556
524
|
return true;
|
|
557
525
|
return !status.startsWith('exited') && !status.startsWith('stopped');
|
|
558
526
|
}
|
|
559
|
-
/**
|
|
560
|
-
* The env names `pushAppEnv` writes from what the plan already holds, before
|
|
561
|
-
* the cookie secret and the entry's declared `env`. `coolifyApp` and an
|
|
562
|
-
* adoption's plan lines read the same list, so what an adopted app is told
|
|
563
|
-
* "will change" is what the step writes (#311).
|
|
564
|
-
*/
|
|
565
|
-
function ownedAppEnv(org, entry, recorded, authIssuer = null) {
|
|
566
|
-
const envs = [
|
|
567
|
-
// Only the `app` entry has a public name; the others are internal.
|
|
568
|
-
...(entry.key === APP_ENTRY_KEY
|
|
569
|
-
? [{ key: 'APP_URL', value: `https://${appHostname(org)}` }]
|
|
570
|
-
: []),
|
|
571
|
-
// #75: read back by the package-side pino sink (not this repo's job,
|
|
572
|
-
// #75 "What belongs where") once `ensureLogVolume` has mounted the
|
|
573
|
-
// matching volume.
|
|
574
|
-
{ key: 'LOG_DIR', value: LOG_DIR },
|
|
575
|
-
];
|
|
576
|
-
if (entry.uses?.auth) {
|
|
577
|
-
// F5: an entry that signs in has its own client, organisation and issuer.
|
|
578
|
-
for (const [key, value] of Object.entries(entryAuthEnv(org, entry, recorded, authIssuer))) {
|
|
579
|
-
envs.push({ key, value });
|
|
580
|
-
}
|
|
581
|
-
}
|
|
582
|
-
else {
|
|
583
|
-
const zitadelOrgId = recorded.zitadel_org;
|
|
584
|
-
if (zitadelOrgId)
|
|
585
|
-
envs.push({ key: 'AUTH_ORGANIZATION_ID', value: zitadelOrgId });
|
|
586
|
-
const clientId = recorded[APP_CLIENT_ID_SCOPE_KEY];
|
|
587
|
-
if (clientId)
|
|
588
|
-
envs.push({ key: 'AUTH_CLIENT_ID', value: clientId });
|
|
589
|
-
}
|
|
590
|
-
// The invited owner's ZITADEL user id (gap P3): the app uses it to make
|
|
591
|
-
// them its own owner in authz on first sign-in. `runSteps` records this
|
|
592
|
-
// under the same `system:kind:key` shape every step's `resourceId` uses
|
|
593
|
-
// (`zitadelOwner`'s own doc comment), not a scope id.
|
|
594
|
-
const ownerUserId = recorded[`zitadel:owner:${org.id}`];
|
|
595
|
-
if (ownerUserId)
|
|
596
|
-
envs.push({ key: 'OWNER_USER_ID', value: ownerUserId });
|
|
597
|
-
return envs;
|
|
598
|
-
}
|
|
599
|
-
/** Every env name foundry writes on an adopted app: what restore may touch (#311). */
|
|
600
|
-
function ownedEnvNames(org, entry, recorded) {
|
|
601
|
-
return new Set([
|
|
602
|
-
...ownedAppEnv(org, entry, recorded).map((e) => e.key),
|
|
603
|
-
...Object.keys(entry.env),
|
|
604
|
-
'AUTH_COOKIE_SECRET',
|
|
605
|
-
// An entry that signs in also gets the issuer and a minted broker key.
|
|
606
|
-
...(entry.uses?.auth ? ['AUTH_ISSUER', 'AUTH_APP_KEY'] : []),
|
|
607
|
-
]);
|
|
608
|
-
}
|
|
609
|
-
/** Owned names whose live non-preview row Coolify hid: no value to snapshot (#311). */
|
|
610
|
-
function hiddenOwnedEnv(owned, envs) {
|
|
611
|
-
return envs
|
|
612
|
-
.filter((e) => !e.is_preview && owned.has(e.key) && typeof e.value !== 'string')
|
|
613
|
-
.map((e) => e.key);
|
|
614
|
-
}
|
|
615
|
-
/**
|
|
616
|
-
* The app uuid and the env rows a snapshot's vault entry holds, for restore.
|
|
617
|
-
* Refuses, naming the resource only, when the uuid or the entry is missing or
|
|
618
|
-
* the entry is not a list of env rows (#311).
|
|
619
|
-
*/
|
|
620
|
-
async function readEnvSnapshot(vault, row) {
|
|
621
|
-
const { uuid } = row.settings;
|
|
622
|
-
const { vaultRef } = row;
|
|
623
|
-
const document = vaultRef && typeof uuid === 'string'
|
|
624
|
-
? await restoreStep(row.resourceKey, uuid, 'vault read', () => vault.resolve(vaultRef))
|
|
625
|
-
: null;
|
|
626
|
-
if (typeof uuid !== 'string' || document === null) {
|
|
627
|
-
throw new Error(`restore ${row.resourceKey}: the snapshot's vault entry or app uuid is missing; nothing was changed`);
|
|
628
|
-
}
|
|
629
|
-
let snapshot;
|
|
630
|
-
try {
|
|
631
|
-
snapshot = JSON.parse(document);
|
|
632
|
-
}
|
|
633
|
-
catch {
|
|
634
|
-
throw new Error(`restore ${row.resourceKey}: the vault entry is unreadable; nothing was changed`);
|
|
635
|
-
}
|
|
636
|
-
if (!Array.isArray(snapshot) ||
|
|
637
|
-
!snapshot.every((r) => r !== null && typeof r === 'object' && typeof r.key === 'string')) {
|
|
638
|
-
throw new Error(`restore ${row.resourceKey}: the vault entry is not a list of env rows; nothing was changed`);
|
|
639
|
-
}
|
|
640
|
-
return { uuid, envs: snapshot };
|
|
641
|
-
}
|
|
642
|
-
/** A sha256 hex digest: what the row keeps in place of a token. */
|
|
643
|
-
const sha256Hex = (value) => createHash('sha256').update(value).digest('hex');
|
|
644
|
-
/** Runs one restore call; a failure names the resource and the operation, never the cause (rule 6). */
|
|
645
|
-
async function restoreStep(resourceKey, uuid, operation, call) {
|
|
646
|
-
try {
|
|
647
|
-
return await call();
|
|
648
|
-
}
|
|
649
|
-
catch {
|
|
650
|
-
throw new Error(`restore ${resourceKey} (${uuid}): ${operation} failed`);
|
|
651
|
-
}
|
|
652
|
-
}
|
|
653
527
|
// ---------------------------------------------------------------------------
|
|
654
528
|
// Executor
|
|
655
529
|
// ---------------------------------------------------------------------------
|
|
@@ -671,8 +545,6 @@ export class FoundryStepExecutor {
|
|
|
671
545
|
return this.zitadelApp(action, org, context);
|
|
672
546
|
case 'zitadel:customers':
|
|
673
547
|
return this.zitadelCustomers(org, context);
|
|
674
|
-
case 'coolify:adopt':
|
|
675
|
-
return this.coolifyAdopt(action, org, context.recordedIds());
|
|
676
548
|
case 'coolify:app':
|
|
677
549
|
return this.coolifyApp(action, org, context);
|
|
678
550
|
case 'coolify:broker-entry':
|
|
@@ -1214,521 +1086,29 @@ export class FoundryStepExecutor {
|
|
|
1214
1086
|
throw new Error('no usable Coolify server is registered on this instance');
|
|
1215
1087
|
return server;
|
|
1216
1088
|
}
|
|
1217
|
-
/** The live app Coolify holds under `uuid`. The error names the id only (rule 6). */
|
|
1218
|
-
async readLiveApp(uuid) {
|
|
1219
|
-
try {
|
|
1220
|
-
return (await this.ports.coolify.raw('GET', `/applications/${uuid}`));
|
|
1221
|
-
}
|
|
1222
|
-
catch {
|
|
1223
|
-
throw new Error(`adopt: the live app ${uuid} could not be read`);
|
|
1224
|
-
}
|
|
1225
|
-
}
|
|
1226
|
-
/** The live ingress routes of the adopted tunnel `id`. The error names the id only (rule 6). */
|
|
1227
|
-
async readAdoptedIngress(id) {
|
|
1228
|
-
try {
|
|
1229
|
-
const { config } = await this.ports.cloudflare.getTunnelConfig(id);
|
|
1230
|
-
const ingress = config?.ingress ?? [];
|
|
1231
|
-
if (!Array.isArray(ingress))
|
|
1232
|
-
throw new Error('not a list');
|
|
1233
|
-
return ingress;
|
|
1234
|
-
}
|
|
1235
|
-
catch {
|
|
1236
|
-
throw new Error(`adopt: the live tunnel ${id} could not be read`);
|
|
1237
|
-
}
|
|
1238
|
-
}
|
|
1239
|
-
/**
|
|
1240
|
-
* #311: for each adopted resource, the lines its `coolify:adopt` step shows
|
|
1241
|
-
* (a setting with old value beside new; an env var by name only) and what
|
|
1242
|
-
* is blocked. Read from the live app on every plan. No value of an env var
|
|
1243
|
-
* leaves this method (rule 6).
|
|
1244
|
-
*/
|
|
1245
|
-
async adoptionReport(org, recorded) {
|
|
1246
|
-
const details = {};
|
|
1247
|
-
const blocked = [];
|
|
1248
|
-
const withheld = [];
|
|
1249
|
-
for (const resource of adoptedResources(org)) {
|
|
1250
|
-
// A uuid that cannot be read blocks that resource, not the whole plan (rule 2).
|
|
1251
|
-
let live = {};
|
|
1252
|
-
let envs = [];
|
|
1253
|
-
let routes = [];
|
|
1254
|
-
try {
|
|
1255
|
-
if (resource.kind === 'database')
|
|
1256
|
-
await this.ports.coolify.getDatabase(resource.uuid);
|
|
1257
|
-
else if (resource.kind === 'tunnel')
|
|
1258
|
-
routes = await this.readAdoptedIngress(resource.uuid);
|
|
1259
|
-
else {
|
|
1260
|
-
live = await this.readLiveApp(resource.uuid);
|
|
1261
|
-
envs = await this.ports.coolify.listEnvs(resource.uuid);
|
|
1262
|
-
}
|
|
1263
|
-
}
|
|
1264
|
-
catch {
|
|
1265
|
-
withheld.push(resource.resourceKey);
|
|
1266
|
-
blocked.push({
|
|
1267
|
-
description: `${org.slug}'s adoption of ${resource.resourceKey} is blocked, not planned: the live resource ${resource.uuid} could not be read`,
|
|
1268
|
-
key: `coolify:adopt:${resource.resourceKey}`,
|
|
1269
|
-
});
|
|
1270
|
-
continue;
|
|
1271
|
-
}
|
|
1272
|
-
if (resource.kind === 'database') {
|
|
1273
|
-
details[resource.resourceKey] = [
|
|
1274
|
-
'the database is taken over as it is; nothing in it changes',
|
|
1275
|
-
];
|
|
1276
|
-
continue;
|
|
1277
|
-
}
|
|
1278
|
-
if (resource.kind === 'tunnel') {
|
|
1279
|
-
// Hostnames only: a service address may carry credentials (rule 6).
|
|
1280
|
-
const declared = tunnelIngressHostnames(org);
|
|
1281
|
-
const row = await this.ports.adoptionStore?.get(org.id, adoptedTunnelKey(org));
|
|
1282
|
-
const written = new Set([...writtenIngressNames(row), ...writingIngressNames(row)]);
|
|
1283
|
-
const kept = foreignRoutes(routes, declared, COOLIFY_PROXY_ORIGIN, written);
|
|
1284
|
-
details[resource.resourceKey] = [
|
|
1285
|
-
...kept
|
|
1286
|
-
.filter((r) => r.hostname !== undefined)
|
|
1287
|
-
.map((r) => `kept, not managed: ${r.hostname}`),
|
|
1288
|
-
...(kept.some((r) => r.hostname === undefined)
|
|
1289
|
-
? ['kept, not managed: (no hostname)']
|
|
1290
|
-
: []),
|
|
1291
|
-
...replacedNames(routes, declared, COOLIFY_PROXY_ORIGIN).map((h) => `will replace: ${h}`),
|
|
1292
|
-
...removedNames(routes, declared, COOLIFY_PROXY_ORIGIN, written).map((h) => `will remove: ${h}`),
|
|
1293
|
-
];
|
|
1294
|
-
continue;
|
|
1295
|
-
}
|
|
1296
|
-
if (resource.kind === 'connector') {
|
|
1297
|
-
if (hiddenOwnedEnv(new Set(['TUNNEL_TOKEN']), envs).length > 0) {
|
|
1298
|
-
withheld.push(resource.resourceKey);
|
|
1299
|
-
blocked.push({
|
|
1300
|
-
description: `${org.slug}'s adoption of ${resource.resourceKey} is blocked, not planned: Coolify hides the value of TUNNEL_TOKEN, so it cannot be snapshotted`,
|
|
1301
|
-
key: `coolify:adopt:${resource.resourceKey}`,
|
|
1302
|
-
});
|
|
1303
|
-
continue;
|
|
1304
|
-
}
|
|
1305
|
-
details[resource.resourceKey] = [];
|
|
1306
|
-
// The token is compared and never shown; a failed read adds no line.
|
|
1307
|
-
try {
|
|
1308
|
-
const token = await this.ports.cloudflare.getTunnelToken(org.hosting.adopt?.tunnel ?? '');
|
|
1309
|
-
if (tunnelTokenRows(envs).every((e) => e.value !== token)) {
|
|
1310
|
-
details[resource.resourceKey]?.push('will change: TUNNEL_TOKEN');
|
|
1311
|
-
}
|
|
1312
|
-
}
|
|
1313
|
-
catch { }
|
|
1314
|
-
continue;
|
|
1315
|
-
}
|
|
1316
|
-
const { entry } = resource;
|
|
1317
|
-
const hidden = hiddenOwnedEnv(ownedEnvNames(org, entry, recorded), envs);
|
|
1318
|
-
if (hidden.length > 0) {
|
|
1319
|
-
withheld.push(resource.resourceKey);
|
|
1320
|
-
blocked.push({
|
|
1321
|
-
description: `${org.slug}'s adoption of ${resource.resourceKey} is blocked, not planned: Coolify hides the value of ${hidden.sort().join(', ')}, so it cannot be snapshotted`,
|
|
1322
|
-
key: `coolify:adopt:${resource.resourceKey}`,
|
|
1323
|
-
});
|
|
1324
|
-
continue;
|
|
1325
|
-
}
|
|
1326
|
-
const liveEnv = new Map(envs.filter((e) => !e.is_preview).map((e) => [e.key, e.value]));
|
|
1327
|
-
const owned = new Map([
|
|
1328
|
-
...ownedAppEnv(org, entry, recorded).map((e) => [e.key, e.value]),
|
|
1329
|
-
...Object.entries(resolveAppEnv(org, entry, recorded).values),
|
|
1330
|
-
]);
|
|
1331
|
-
const lines = adoptedPatchDetails(adoptedAppPatch(entry, entryHostnames(org, entry), live), live);
|
|
1332
|
-
for (const [key, value] of owned) {
|
|
1333
|
-
if (!liveEnv.has(key))
|
|
1334
|
-
lines.push(`will add: ${key}`);
|
|
1335
|
-
else if (liveEnv.get(key) !== value)
|
|
1336
|
-
lines.push(`will change: ${key}`);
|
|
1337
|
-
}
|
|
1338
|
-
// The issuer's value is read at apply time, so only its presence shows here.
|
|
1339
|
-
if (entry.uses?.auth) {
|
|
1340
|
-
for (const key of ['AUTH_ISSUER', 'AUTH_APP_KEY']) {
|
|
1341
|
-
owned.set(key, liveEnv.get(key) ?? '');
|
|
1342
|
-
if (!liveEnv.has(key))
|
|
1343
|
-
lines.push(`will add: ${key}`);
|
|
1344
|
-
}
|
|
1345
|
-
}
|
|
1346
|
-
if (!liveEnv.has('AUTH_COOKIE_SECRET') && !owned.has('AUTH_COOKIE_SECRET')) {
|
|
1347
|
-
lines.push('will add: AUTH_COOKIE_SECRET');
|
|
1348
|
-
}
|
|
1349
|
-
for (const key of [...liveEnv.keys()].sort()) {
|
|
1350
|
-
if (!owned.has(key) && key !== 'AUTH_COOKIE_SECRET')
|
|
1351
|
-
lines.push(`not managed: ${key}`);
|
|
1352
|
-
}
|
|
1353
|
-
details[resource.resourceKey] = lines;
|
|
1354
|
-
// Choice A of #311's "Decide": an adopted app with no runtime role
|
|
1355
|
-
// address is reported, never given one.
|
|
1356
|
-
if (entry.database) {
|
|
1357
|
-
const address = liveEnv.get('DATABASE_URL');
|
|
1358
|
-
const ownerAddresses = [liveEnv.get('DATABASE_URL_OWNER')];
|
|
1359
|
-
const databaseUuid = org.hosting.adopt?.database;
|
|
1360
|
-
if (databaseUuid) {
|
|
1361
|
-
ownerAddresses.push(await this.ports.coolify.getDatabase(databaseUuid).then((db) => db.internal_db_url, () => undefined));
|
|
1362
|
-
}
|
|
1363
|
-
if (!address || ownerAddresses.includes(address)) {
|
|
1364
|
-
withheld.push(resource.resourceKey);
|
|
1365
|
-
const label = entry.key === APP_ENTRY_KEY ? 'app' : `${entry.key} app`;
|
|
1366
|
-
blocked.push({
|
|
1367
|
-
description: `${org.slug}'s ${label} runtime database address is blocked, not planned: DATABASE_URL is missing or is the owner address, so set DATABASE_URL on the app; foundry leaves its env alone`,
|
|
1368
|
-
key: entry.key === APP_ENTRY_KEY
|
|
1369
|
-
? 'coolify:app:database-url'
|
|
1370
|
-
: `coolify:app:${entry.key}:database-url`,
|
|
1371
|
-
});
|
|
1372
|
-
}
|
|
1373
|
-
}
|
|
1374
|
-
}
|
|
1375
|
-
return { details, blocked, withheld };
|
|
1376
|
-
}
|
|
1377
|
-
/**
|
|
1378
|
-
* #311: puts each adopted app back as its snapshot recorded it, touching
|
|
1379
|
-
* only what foundry writes: the settings it patches and the env names it
|
|
1380
|
-
* owns (an edit a person made to anything else stays). Owned names the
|
|
1381
|
-
* snapshot has are written back with its value and flags; owned names it
|
|
1382
|
-
* lacks are deleted; preview rows are left alone. Every vault entry and
|
|
1383
|
-
* live read happens first, so a missing entry or a row that cannot be
|
|
1384
|
-
* deleted refuses before any Coolify write. Errors name ids only, and the
|
|
1385
|
-
* vault's env values go to `bulkUpsertEnvs` and nowhere else (rule 6).
|
|
1386
|
-
*/
|
|
1387
|
-
async restoreAdoption(org, recorded, rows) {
|
|
1388
|
-
const vault = this.ports.adoptionVault;
|
|
1389
|
-
if (!vault)
|
|
1390
|
-
throw new Error(`restore for ${org.slug}: no adoption vault is wired`);
|
|
1391
|
-
const apps = [];
|
|
1392
|
-
for (const row of rows.filter((r) => r.kind === 'app')) {
|
|
1393
|
-
// The row names the app: a declaration that changed or went away since
|
|
1394
|
-
// does not move the restore onto another app.
|
|
1395
|
-
const { uuid, envs: snapshot } = await readEnvSnapshot(vault, row);
|
|
1396
|
-
const entry = org.hosting.entries.find((e) => `coolify:app:${appStepKey(org, e.key)}` === row.resourceKey);
|
|
1397
|
-
// Without an entry, the names foundry writes with no declaration.
|
|
1398
|
-
const owned = ownedEnvNames(org, entry ?? {
|
|
1399
|
-
key: row.resourceKey.slice(`coolify:app:${org.id}:`.length),
|
|
1400
|
-
env: {},
|
|
1401
|
-
uses: undefined,
|
|
1402
|
-
}, recorded);
|
|
1403
|
-
const old = snapshot.filter((e) => !e.is_preview);
|
|
1404
|
-
const oldNames = new Set(old.map((e) => e.key));
|
|
1405
|
-
const live = (await restoreStep(row.resourceKey, uuid, 'env read', () => this.ports.coolify.listEnvs(uuid))).filter((e) => !e.is_preview);
|
|
1406
|
-
const liveApp = await restoreStep(row.resourceKey, uuid, 'settings read', () => this.readLiveApp(uuid));
|
|
1407
|
-
const patch = restoreAppPatch(entry, entry ? entryHostnames(org, entry) : [], row.settings, liveApp);
|
|
1408
|
-
const upserts = [];
|
|
1409
|
-
for (const e of old.filter((r) => owned.has(r.key))) {
|
|
1410
|
-
if (typeof e.value !== 'string') {
|
|
1411
|
-
throw new Error(`restore ${row.resourceKey}: the snapshot has no value for ${e.key}; nothing was changed`);
|
|
1412
|
-
}
|
|
1413
|
-
upserts.push({
|
|
1414
|
-
key: e.key,
|
|
1415
|
-
value: e.value,
|
|
1416
|
-
buildTime: Boolean(e.is_buildtime),
|
|
1417
|
-
literal: Boolean(e.is_literal),
|
|
1418
|
-
multiline: Boolean(e.is_multiline),
|
|
1419
|
-
});
|
|
1420
|
-
}
|
|
1421
|
-
const deletes = [];
|
|
1422
|
-
for (const e of live.filter((l) => owned.has(l.key) && !oldNames.has(l.key))) {
|
|
1423
|
-
if (!e.uuid) {
|
|
1424
|
-
throw new Error(`restore ${row.resourceKey}: the live env ${e.key} has no uuid, so it is not deleted; nothing was changed`);
|
|
1425
|
-
}
|
|
1426
|
-
deletes.push({ key: e.key, uuid: e.uuid });
|
|
1427
|
-
}
|
|
1428
|
-
apps.push({ row, uuid, patch, upserts, deletes });
|
|
1429
|
-
}
|
|
1430
|
-
// #312: the tunnel's ingress with foundry's routes swapped for the snapshot's,
|
|
1431
|
-
// read live now so a route a person added since stays.
|
|
1432
|
-
const cf = this.ports.cloudflare;
|
|
1433
|
-
const tunnels = [];
|
|
1434
|
-
for (const row of rows.filter((r) => r.kind === 'tunnel')) {
|
|
1435
|
-
const { uuid: id, ingress } = row.settings;
|
|
1436
|
-
if (typeof id !== 'string' || !Array.isArray(ingress)) {
|
|
1437
|
-
throw new Error(`restore ${row.resourceKey}: the snapshot's tunnel id or ingress is missing; nothing was changed`);
|
|
1438
|
-
}
|
|
1439
|
-
const { config } = await restoreStep(row.resourceKey, id, 'ingress read', () => cf.getTunnelConfig(id));
|
|
1440
|
-
const live = config?.ingress ?? [];
|
|
1441
|
-
// The names the row says foundry wrote: the declaration may have changed or gone since.
|
|
1442
|
-
const written = new Set([...writtenIngressNames(row), ...writingIngressNames(row)]);
|
|
1443
|
-
const restored = restoreIngress(live, ingress, COOLIFY_PROXY_ORIGIN, written);
|
|
1444
|
-
const swapped = removedNames(live, [], COOLIFY_PROXY_ORIGIN, written).map(normalizeHostname);
|
|
1445
|
-
// A name with a live route that is not exactly foundry's keeps its CNAME, or that route stops resolving.
|
|
1446
|
-
const leftAlone = (hostname) => live.filter((r) => r.hostname !== undefined &&
|
|
1447
|
-
normalizeHostname(r.hostname) === normalizeHostname(hostname)).length > (swapped.includes(normalizeHostname(hostname)) ? 1 : 0);
|
|
1448
|
-
tunnels.push({
|
|
1449
|
-
row,
|
|
1450
|
-
id,
|
|
1451
|
-
config: isDeepStrictEqual(restored, live) ? null : { ...config, ingress: restored },
|
|
1452
|
-
dnsRecords: createdDnsRecords(row.settings).filter((r) => !leftAlone(r.hostname)),
|
|
1453
|
-
});
|
|
1454
|
-
}
|
|
1455
|
-
// #312: the old TUNNEL_TOKEN back on the connector; a token the snapshot lacks is deleted.
|
|
1456
|
-
// Only a live token that is the one foundry wrote (its digest is in the row) is changed.
|
|
1457
|
-
const tokenLeft = [];
|
|
1458
|
-
const connectors = [];
|
|
1459
|
-
for (const row of rows.filter((r) => r.kind === 'connector')) {
|
|
1460
|
-
const { uuid, envs: snapshot } = await readEnvSnapshot(vault, row);
|
|
1461
|
-
const [old] = tunnelTokenRows(snapshot);
|
|
1462
|
-
const [live] = tunnelTokenRows(await restoreStep(row.resourceKey, uuid, 'env read', () => this.ports.coolify.listEnvs(uuid)));
|
|
1463
|
-
if (old && typeof old.value !== 'string') {
|
|
1464
|
-
throw new Error(`restore ${row.resourceKey}: the snapshot has no value for TUNNEL_TOKEN; nothing was changed`);
|
|
1465
|
-
}
|
|
1466
|
-
const foundrys = row.settings.tokenWritten === true &&
|
|
1467
|
-
typeof live?.value === 'string' &&
|
|
1468
|
-
row.settings.tokenDigest === sha256Hex(live.value);
|
|
1469
|
-
if (old && live?.value !== old.value && foundrys) {
|
|
1470
|
-
connectors.push({
|
|
1471
|
-
row,
|
|
1472
|
-
uuid,
|
|
1473
|
-
upsert: {
|
|
1474
|
-
key: 'TUNNEL_TOKEN',
|
|
1475
|
-
value: old.value ?? '',
|
|
1476
|
-
buildTime: Boolean(old.is_buildtime),
|
|
1477
|
-
literal: Boolean(old.is_literal),
|
|
1478
|
-
multiline: Boolean(old.is_multiline),
|
|
1479
|
-
},
|
|
1480
|
-
});
|
|
1481
|
-
}
|
|
1482
|
-
else if (!old && live && foundrys) {
|
|
1483
|
-
if (!live.uuid) {
|
|
1484
|
-
throw new Error(`restore ${row.resourceKey}: the live TUNNEL_TOKEN has no uuid, so it is not deleted; nothing was changed`);
|
|
1485
|
-
}
|
|
1486
|
-
connectors.push({ row, uuid, del: { uuid: live.uuid } });
|
|
1487
|
-
}
|
|
1488
|
-
else if (live &&
|
|
1489
|
-
!foundrys &&
|
|
1490
|
-
row.settings.tokenWritten === true &&
|
|
1491
|
-
(!old || live.value !== old.value)) {
|
|
1492
|
-
tokenLeft.push(row.resourceKey);
|
|
1493
|
-
}
|
|
1494
|
-
else if (old && !live && row.settings.tokenWritten === true) {
|
|
1495
|
-
// A person deleted the token: a redeploy would start the connector with none.
|
|
1496
|
-
tokenLeft.push(row.resourceKey);
|
|
1497
|
-
}
|
|
1498
|
-
else if (row.settings.tokenWritten === true) {
|
|
1499
|
-
// Foundry rewrote the token: a retry after a failed deploy still deploys.
|
|
1500
|
-
connectors.push({ row, uuid });
|
|
1501
|
-
}
|
|
1502
|
-
}
|
|
1503
|
-
const envDeleted = [];
|
|
1504
|
-
for (const { row, uuid, patch, upserts, deletes } of apps) {
|
|
1505
|
-
const key = row.resourceKey;
|
|
1506
|
-
if (Object.keys(patch).length > 0) {
|
|
1507
|
-
await restoreStep(key, uuid, 'settings update', () => this.ports.coolify.updateApplication(uuid, patch));
|
|
1508
|
-
}
|
|
1509
|
-
if (upserts.length > 0) {
|
|
1510
|
-
await restoreStep(key, uuid, 'env write', () => this.ports.coolify.bulkUpsertEnvs(uuid, upserts));
|
|
1511
|
-
}
|
|
1512
|
-
for (const del of deletes) {
|
|
1513
|
-
await restoreStep(key, uuid, 'env delete', () => this.ports.coolify.raw('DELETE', `/applications/${uuid}/envs/${del.uuid}`));
|
|
1514
|
-
if (!envDeleted.includes(del.key))
|
|
1515
|
-
envDeleted.push(del.key);
|
|
1516
|
-
}
|
|
1517
|
-
// A container reads env and domains only at start, a folder only at build (rule 4).
|
|
1518
|
-
// Decided from the snapshot, not from live: a retry after a failed deploy
|
|
1519
|
-
// finds live already written and must still deploy.
|
|
1520
|
-
if (Object.keys(patch).length > 0) {
|
|
1521
|
-
await restoreStep(key, uuid, 'deploy', () => this.ports.coolify.deploy(uuid));
|
|
1522
|
-
}
|
|
1523
|
-
else {
|
|
1524
|
-
await restoreStep(key, uuid, 'restart', () => this.ports.coolify.restart(uuid));
|
|
1525
|
-
}
|
|
1526
|
-
}
|
|
1527
|
-
for (const { row, id, config, dnsRecords } of tunnels) {
|
|
1528
|
-
if (config) {
|
|
1529
|
-
await restoreStep(row.resourceKey, id, 'ingress write', () => cf.putTunnelConfig(id, config));
|
|
1530
|
-
}
|
|
1531
|
-
// Only the records foundry created, and only while each is still a CNAME
|
|
1532
|
-
// to this tunnel: one a person repointed, or one already gone, is left.
|
|
1533
|
-
for (const record of dnsRecords) {
|
|
1534
|
-
await restoreStep(row.resourceKey, id, 'dns delete', async () => {
|
|
1535
|
-
const live = (await cf.findDnsRecord(record.zoneId, record.hostname, 'CNAME')).find((r) => r.id === record.id);
|
|
1536
|
-
if (live?.content !== `${id}.cfargotunnel.com`)
|
|
1537
|
-
return;
|
|
1538
|
-
try {
|
|
1539
|
-
await cf.deleteDnsRecord(record.zoneId, record.id);
|
|
1540
|
-
}
|
|
1541
|
-
catch (error) {
|
|
1542
|
-
if (!isNotFoundError(error))
|
|
1543
|
-
throw error;
|
|
1544
|
-
}
|
|
1545
|
-
});
|
|
1546
|
-
}
|
|
1547
|
-
}
|
|
1548
|
-
for (const { row, uuid, upsert, del } of connectors) {
|
|
1549
|
-
const key = row.resourceKey;
|
|
1550
|
-
if (upsert) {
|
|
1551
|
-
await restoreStep(key, uuid, 'env write', () => this.ports.coolify.bulkUpsertEnvs(uuid, [upsert]));
|
|
1552
|
-
}
|
|
1553
|
-
if (del) {
|
|
1554
|
-
await restoreStep(key, uuid, 'env delete', () => this.ports.coolify.raw('DELETE', `/applications/${uuid}/envs/${del.uuid}`));
|
|
1555
|
-
}
|
|
1556
|
-
// The container reads its token only at start.
|
|
1557
|
-
await restoreStep(key, uuid, 'deploy', () => this.ports.coolify.deploy(uuid));
|
|
1558
|
-
}
|
|
1559
|
-
return { envDeleted, tokenLeft };
|
|
1560
|
-
}
|
|
1561
|
-
/**
|
|
1562
|
-
* #311: records what a live app or database looked like before foundry
|
|
1563
|
-
* first writes to it, in this order: the env values to the vault, the
|
|
1564
|
-
* non-secret settings to the row, then both read back and compared. The
|
|
1565
|
-
* step writes nothing to Coolify, so a snapshot that fails stops the apply
|
|
1566
|
-
* before any later step does. Every error names ids only (rule 6).
|
|
1567
|
-
*/
|
|
1568
|
-
async coolifyAdopt(action, org, recorded) {
|
|
1569
|
-
const { adoptionVault: vault, adoptionStore: store } = this.ports;
|
|
1570
|
-
if (!vault || !store) {
|
|
1571
|
-
throw new Error(`coolify:adopt for ${org.slug}: no adoption vault is wired`);
|
|
1572
|
-
}
|
|
1573
|
-
const resource = adoptedResources(org).find((r) => r.resourceKey === action.key);
|
|
1574
|
-
if (!resource)
|
|
1575
|
-
throw new Error(`coolify:adopt for ${org.slug}: nothing declared for ${action.key}`);
|
|
1576
|
-
// Snapshotting another uuid now would record foundry's own earlier writes.
|
|
1577
|
-
const held = await store.get(org.id, action.key);
|
|
1578
|
-
if (held?.state === 'adopted' && held.settings.uuid !== resource.uuid) {
|
|
1579
|
-
throw new Error(`coolify:adopt ${action.key}: restore the adopted ${resource.kind} first (${String(held.settings.uuid)} is adopted, not ${resource.uuid}); nothing was changed`);
|
|
1580
|
-
}
|
|
1581
|
-
// Another key's snapshot of this uuid holds the original; a second one would record foundry's own writes.
|
|
1582
|
-
const holder = (await store.list(org.id)).find((r) => r.resourceKey !== action.key && protectsKey(r) && r.settings.uuid === resource.uuid);
|
|
1583
|
-
if (holder) {
|
|
1584
|
-
throw new Error(`coolify:adopt ${action.key}: ${resource.uuid} is already adopted under ${holder.resourceKey}; nothing was changed`);
|
|
1585
|
-
}
|
|
1586
|
-
let settings = { uuid: resource.uuid };
|
|
1587
|
-
let vaultRef = null;
|
|
1588
|
-
let envDocument = '';
|
|
1589
|
-
if (resource.kind === 'app') {
|
|
1590
|
-
settings = {
|
|
1591
|
-
uuid: resource.uuid,
|
|
1592
|
-
...snapshotAppSettings(await this.readLiveApp(resource.uuid)),
|
|
1593
|
-
};
|
|
1594
|
-
vaultRef = adoptionVaultRef(org, resource);
|
|
1595
|
-
let envs;
|
|
1596
|
-
try {
|
|
1597
|
-
envs = await this.ports.coolify.listEnvs(resource.uuid);
|
|
1598
|
-
}
|
|
1599
|
-
catch {
|
|
1600
|
-
throw new Error(`adopt: the env of the live app ${resource.uuid} could not be read`);
|
|
1601
|
-
}
|
|
1602
|
-
const hidden = hiddenOwnedEnv(ownedEnvNames(org, resource.entry, recorded), envs);
|
|
1603
|
-
if (hidden.length > 0) {
|
|
1604
|
-
throw new Error(`coolify:adopt ${action.key}: Coolify hides the value of ${hidden.sort().join(', ')}; nothing was changed`);
|
|
1605
|
-
}
|
|
1606
|
-
envDocument = JSON.stringify(envs);
|
|
1607
|
-
}
|
|
1608
|
-
else if (resource.kind === 'tunnel') {
|
|
1609
|
-
settings = {
|
|
1610
|
-
uuid: resource.uuid,
|
|
1611
|
-
ingress: await this.readAdoptedIngress(resource.uuid),
|
|
1612
|
-
written: [],
|
|
1613
|
-
};
|
|
1614
|
-
}
|
|
1615
|
-
else if (resource.kind === 'connector') {
|
|
1616
|
-
await this.readLiveApp(resource.uuid);
|
|
1617
|
-
vaultRef = adoptionVaultRef(org, resource);
|
|
1618
|
-
let envs;
|
|
1619
|
-
try {
|
|
1620
|
-
envs = await this.ports.coolify.listEnvs(resource.uuid);
|
|
1621
|
-
}
|
|
1622
|
-
catch {
|
|
1623
|
-
throw new Error(`adopt: the env of the live app ${resource.uuid} could not be read`);
|
|
1624
|
-
}
|
|
1625
|
-
if (hiddenOwnedEnv(new Set(['TUNNEL_TOKEN']), envs).length > 0) {
|
|
1626
|
-
throw new Error(`coolify:adopt ${action.key}: Coolify hides the value of TUNNEL_TOKEN; nothing was changed`);
|
|
1627
|
-
}
|
|
1628
|
-
// The old token is a secret: it goes to the vault, never to `settings`.
|
|
1629
|
-
envDocument = JSON.stringify(tunnelTokenRows(envs));
|
|
1630
|
-
}
|
|
1631
|
-
let sealed = false;
|
|
1632
|
-
let written = false;
|
|
1633
|
-
try {
|
|
1634
|
-
if (vaultRef)
|
|
1635
|
-
await vault.put(vaultRef, envDocument);
|
|
1636
|
-
await store.put({
|
|
1637
|
-
orgId: org.id,
|
|
1638
|
-
resourceKey: action.key,
|
|
1639
|
-
kind: resource.kind,
|
|
1640
|
-
takenAt: (this.ports.now?.() ?? new Date()).toISOString(),
|
|
1641
|
-
settings,
|
|
1642
|
-
vaultRef,
|
|
1643
|
-
});
|
|
1644
|
-
written = true;
|
|
1645
|
-
const row = await store.get(org.id, action.key);
|
|
1646
|
-
sealed =
|
|
1647
|
-
row !== null &&
|
|
1648
|
-
row.state === 'adopted' &&
|
|
1649
|
-
row.vaultRef === vaultRef &&
|
|
1650
|
-
isDeepStrictEqual(row.settings, settings) &&
|
|
1651
|
-
(vaultRef === null || (await vault.resolve(vaultRef)) === envDocument);
|
|
1652
|
-
}
|
|
1653
|
-
catch {
|
|
1654
|
-
// The cause may quote a value; the message below names ids only.
|
|
1655
|
-
}
|
|
1656
|
-
if (!sealed) {
|
|
1657
|
-
// A row left as `adopted` would make the retry skip the snapshot (#311).
|
|
1658
|
-
if (written) {
|
|
1659
|
-
try {
|
|
1660
|
-
await store.setState(org.id, action.key, 'released');
|
|
1661
|
-
}
|
|
1662
|
-
catch {
|
|
1663
|
-
throw new Error(`coolify:adopt ${action.key}: the snapshot could not be read back and its row could not be released; the row may still be active`);
|
|
1664
|
-
}
|
|
1665
|
-
}
|
|
1666
|
-
throw new Error(`coolify:adopt ${action.key}: the snapshot could not be written and read back; nothing was changed`);
|
|
1667
|
-
}
|
|
1668
|
-
return undefined;
|
|
1669
|
-
}
|
|
1670
|
-
/** #311: whether the resource was adopted: its row is `adopted` or `confirmed`, or `id` is a declared adopt uuid. */
|
|
1671
|
-
async isAdopted(org, resourceKey, id) {
|
|
1672
|
-
const row = await this.ports.adoptionStore?.get(org.id, resourceKey);
|
|
1673
|
-
return protectsKey(row) || declaredAdoptUuids(org).has(id);
|
|
1674
|
-
}
|
|
1675
|
-
/** #311: refuses to manage as foundry's own a resource whose row protects its key and whose declaration has no `adopt`. */
|
|
1676
|
-
async refuseTakeover(org, resourceKey) {
|
|
1677
|
-
if (protectsKey(await this.ports.adoptionStore?.get(org.id, resourceKey))) {
|
|
1678
|
-
throw new Error(`${resourceKey}: adopted, so it needs adopt declared; nothing was changed`);
|
|
1679
|
-
}
|
|
1680
|
-
}
|
|
1681
|
-
/** #311: refuses to write to a declared adopt uuid that has no active snapshot. */
|
|
1682
|
-
async requireSnapshot(org, resourceKey, uuid) {
|
|
1683
|
-
const row = await this.ports.adoptionStore?.get(org.id, resourceKey);
|
|
1684
|
-
if (!isActiveSnapshot(row, uuid)) {
|
|
1685
|
-
throw new Error(`${resourceKey}: no active adoption snapshot; nothing was changed`);
|
|
1686
|
-
}
|
|
1687
|
-
}
|
|
1688
1089
|
async coolifyApp(action, org, context) {
|
|
1689
1090
|
const recordedAppId = context.recordedIds()[`coolify:app:${action.key}`];
|
|
1690
1091
|
if (action.desired.enabled === false) {
|
|
1691
1092
|
if (!recordedAppId) {
|
|
1692
1093
|
throw new Error(`coolify:app for ${org.slug}: no live app recorded, nothing to park`);
|
|
1693
1094
|
}
|
|
1694
|
-
if (await this.isAdopted(org, `coolify:app:${action.key}`, recordedAppId)) {
|
|
1695
|
-
throw new Error(`coolify:app ${action.key}: adopted, not stopped`);
|
|
1696
|
-
}
|
|
1697
1095
|
await this.ports.coolify.raw('POST', `/applications/${recordedAppId}/stop`);
|
|
1698
1096
|
return { resourceId: recordedAppId };
|
|
1699
1097
|
}
|
|
1700
1098
|
const entry = org.hosting.entries.find((e) => appStepKey(org, e.key) === action.key);
|
|
1701
1099
|
if (!entry)
|
|
1702
1100
|
throw new Error(`coolify:app for ${org.slug}: no hosting entry for ${action.key}`);
|
|
1703
|
-
if (entry.adopt)
|
|
1704
|
-
await this.requireSnapshot(org, `coolify:app:${action.key}`, entry.adopt.app);
|
|
1705
|
-
else
|
|
1706
|
-
await this.refuseTakeover(org, `coolify:app:${action.key}`);
|
|
1707
1101
|
// A level-1 org with no bespoke app of its own gets the shared standard
|
|
1708
1102
|
// app instead (gap P1, `.plans/2026-09-26-level-1-org-map.md`): an entry
|
|
1709
1103
|
// with no `repo`.
|
|
1710
1104
|
const appRepo = entry.repo ?? DEFAULT_APP_REPO;
|
|
1711
|
-
|
|
1712
|
-
|
|
1713
|
-
let scoped;
|
|
1714
|
-
const scope = async () => {
|
|
1715
|
-
if (!scoped) {
|
|
1716
|
-
const projectUuid = await this.ensureCoolifyProject(org, context);
|
|
1717
|
-
const { environment } = await this.ports.coolify.findOrCreateEnvironment(projectUuid, 'production');
|
|
1718
|
-
scoped = { projectUuid, environment };
|
|
1719
|
-
}
|
|
1720
|
-
return scoped;
|
|
1721
|
-
};
|
|
1105
|
+
const projectUuid = await this.ensureCoolifyProject(org, context);
|
|
1106
|
+
const { environment } = await this.ports.coolify.findOrCreateEnvironment(projectUuid, 'production');
|
|
1722
1107
|
// The recorded id first; the name when nothing is recorded, or when the
|
|
1723
1108
|
// recorded app is gone.
|
|
1724
1109
|
let existing = null;
|
|
1725
1110
|
let live;
|
|
1726
|
-
|
|
1727
|
-
// never created (#311).
|
|
1728
|
-
if (entry.adopt) {
|
|
1729
|
-
existing = { uuid: entry.adopt.app };
|
|
1730
|
-
}
|
|
1731
|
-
else if (recordedAppId) {
|
|
1111
|
+
if (recordedAppId) {
|
|
1732
1112
|
try {
|
|
1733
1113
|
live = await this.ports.coolify.getApplication(recordedAppId);
|
|
1734
1114
|
existing = { uuid: recordedAppId };
|
|
@@ -1738,9 +1118,9 @@ export class FoundryStepExecutor {
|
|
|
1738
1118
|
throw error;
|
|
1739
1119
|
}
|
|
1740
1120
|
}
|
|
1741
|
-
if (!existing
|
|
1121
|
+
if (!existing) {
|
|
1742
1122
|
existing = await this.ports.coolify.findApplicationByName(appNameFor(org, entry), {
|
|
1743
|
-
environmentIds: [
|
|
1123
|
+
environmentIds: [environment.id],
|
|
1744
1124
|
});
|
|
1745
1125
|
}
|
|
1746
1126
|
// An entry with no names is internal: the other entries reach it as
|
|
@@ -1750,7 +1130,6 @@ export class FoundryStepExecutor {
|
|
|
1750
1130
|
const folderField = isStatic ? 'publish_directory' : 'base_directory';
|
|
1751
1131
|
const settingsKey = appSettingsAppliedScopeKey(entry.key);
|
|
1752
1132
|
const domainsKey = appDomainsAppliedScopeKey(entry.key);
|
|
1753
|
-
const pendingKey = appDeployPendingScopeKey(entry.key);
|
|
1754
1133
|
let haveNames;
|
|
1755
1134
|
let have;
|
|
1756
1135
|
let appId;
|
|
@@ -1762,18 +1141,14 @@ export class FoundryStepExecutor {
|
|
|
1762
1141
|
// stops the app without deleting it, so re-enabling found it here,
|
|
1763
1142
|
// still stopped, and it stayed stopped forever despite `apply`
|
|
1764
1143
|
// reporting success.
|
|
1765
|
-
|
|
1766
|
-
if (!
|
|
1767
|
-
|
|
1768
|
-
if (!isRunningOrStarting(live.status)) {
|
|
1769
|
-
await this.ports.coolify.raw('POST', `/applications/${existing.uuid}/start`);
|
|
1770
|
-
}
|
|
1144
|
+
live ??= await this.ports.coolify.getApplication(existing.uuid);
|
|
1145
|
+
if (!isRunningOrStarting(live.status)) {
|
|
1146
|
+
await this.ports.coolify.raw('POST', `/applications/${existing.uuid}/start`);
|
|
1771
1147
|
}
|
|
1772
1148
|
have = appliedAppSettings(context.recordedIds(), entry.key);
|
|
1773
1149
|
haveNames = appliedAppDomains(org, context.recordedIds(), entry.key);
|
|
1774
1150
|
}
|
|
1775
1151
|
else {
|
|
1776
|
-
const { projectUuid, environment } = await scope();
|
|
1777
1152
|
const server = await this.resolveServer();
|
|
1778
1153
|
const githubApp = await this.ports.coolify.requireGithubApp();
|
|
1779
1154
|
const created = await this.ports.coolify.createGithubAppApplication({
|
|
@@ -1805,39 +1180,26 @@ export class FoundryStepExecutor {
|
|
|
1805
1180
|
// recorded below leaves the old one, so the retry patches the same
|
|
1806
1181
|
// difference again.
|
|
1807
1182
|
const want = appSettingsOf(entry);
|
|
1808
|
-
|
|
1809
|
-
if (
|
|
1810
|
-
patch =
|
|
1811
|
-
|
|
1812
|
-
|
|
1813
|
-
|
|
1814
|
-
|
|
1815
|
-
|
|
1816
|
-
|
|
1817
|
-
|
|
1818
|
-
|
|
1819
|
-
|
|
1820
|
-
|
|
1821
|
-
|
|
1822
|
-
|
|
1823
|
-
|
|
1824
|
-
|
|
1825
|
-
if (names.length === 0 && (!existing || patch.domains !== undefined)) {
|
|
1826
|
-
patch.custom_internal_name = appNameFor(org, entry);
|
|
1827
|
-
}
|
|
1828
|
-
}
|
|
1829
|
-
if (Object.keys(patch).length > 0) {
|
|
1830
|
-
// An adopted app's patch is live from here, so a failed deploy must
|
|
1831
|
-
// replan the app step (#311).
|
|
1832
|
-
if (entry.adopt) {
|
|
1833
|
-
context.recordScopeId(pendingKey, '1');
|
|
1834
|
-
await context.persistScopeIds();
|
|
1835
|
-
}
|
|
1183
|
+
const patch = {};
|
|
1184
|
+
if (want.folder !== have.folder)
|
|
1185
|
+
patch[folderField] = want.folder ? `/${want.folder}` : '/';
|
|
1186
|
+
if (want.port !== have.port && want.port !== null && !isStatic) {
|
|
1187
|
+
patch.ports_exposes = String(want.port);
|
|
1188
|
+
}
|
|
1189
|
+
if (want.autoDeploy !== have.autoDeploy)
|
|
1190
|
+
patch.is_auto_deploy_enabled = want.autoDeploy;
|
|
1191
|
+
// Create names the app after `domains`; a changed name reaches a live app
|
|
1192
|
+
// here (rule 3). The internal name is set after create, as the calls
|
|
1193
|
+
// stack's apps do.
|
|
1194
|
+
if (JSON.stringify(names) !== JSON.stringify(haveNames))
|
|
1195
|
+
patch.domains = names.join(',');
|
|
1196
|
+
if (names.length === 0 && (!existing || patch.domains !== undefined)) {
|
|
1197
|
+
patch.custom_internal_name = appNameFor(org, entry);
|
|
1198
|
+
}
|
|
1199
|
+
if (Object.keys(patch).length > 0)
|
|
1836
1200
|
await this.ports.coolify.updateApplication(appId, patch);
|
|
1837
|
-
}
|
|
1838
1201
|
// The container builds and listens from these only at its next deploy.
|
|
1839
|
-
const settingsNeedDeploy = Boolean(
|
|
1840
|
-
patch.base_directory ||
|
|
1202
|
+
const settingsNeedDeploy = Boolean(patch.base_directory ||
|
|
1841
1203
|
patch.publish_directory ||
|
|
1842
1204
|
patch.ports_exposes ||
|
|
1843
1205
|
patch.domains !== undefined ||
|
|
@@ -1848,8 +1210,7 @@ export class FoundryStepExecutor {
|
|
|
1848
1210
|
// covers the database being turned on after the app already exists (the
|
|
1849
1211
|
// app step still ran this apply, found the app by name, and lands here).
|
|
1850
1212
|
let dbPush = { pushed: false, changed: false };
|
|
1851
|
-
|
|
1852
|
-
if (entry.database && !entry.adopt && !org.hosting.adopt?.database) {
|
|
1213
|
+
if (entry.database) {
|
|
1853
1214
|
const dbId = context.recordedIds()[`coolify:database:${org.id}`];
|
|
1854
1215
|
if (dbId) {
|
|
1855
1216
|
dbPush = await this.pushDatabaseUrl(appId, dbId, org.slug.replace(/-/g, '_'));
|
|
@@ -1866,10 +1227,7 @@ export class FoundryStepExecutor {
|
|
|
1866
1227
|
// `ensureLogVolume` whether this app was already running before this
|
|
1867
1228
|
// call -- see its own doc comment for why that decides whether to
|
|
1868
1229
|
// restart it.
|
|
1869
|
-
|
|
1870
|
-
if (!entry.adopt) {
|
|
1871
|
-
await this.ensureLogVolume(appId, org, Boolean(existing), appNameFor(org, entry));
|
|
1872
|
-
}
|
|
1230
|
+
await this.ensureLogVolume(appId, org, Boolean(existing), appNameFor(org, entry));
|
|
1873
1231
|
// The app receives whatever it needs to sign in (gap P2). Unconditional,
|
|
1874
1232
|
// unlike the database push above: every app has a hostname, and an
|
|
1875
1233
|
// upsert of the same value on a later apply is a no-op, not a change.
|
|
@@ -1889,8 +1247,6 @@ export class FoundryStepExecutor {
|
|
|
1889
1247
|
// one and `planApp` reports it instead of planning it.
|
|
1890
1248
|
context.recordScopeId(settingsKey, JSON.stringify({ ...want, port: want.port ?? have.port }));
|
|
1891
1249
|
context.recordScopeId(domainsKey, JSON.stringify(names));
|
|
1892
|
-
if (entry.adopt)
|
|
1893
|
-
context.recordScopeId(pendingKey, '');
|
|
1894
1250
|
context.recordScopeId(appEnvAppliedScopeKey(entry.key), appEnv.applied);
|
|
1895
1251
|
if (entry.uses?.auth) {
|
|
1896
1252
|
context.recordScopeId(entryAuthEnvAppliedScopeKey(entry.key), appEnv.authApplied);
|
|
@@ -1904,7 +1260,7 @@ export class FoundryStepExecutor {
|
|
|
1904
1260
|
// actually resolved a mail domain for this org.
|
|
1905
1261
|
// One Lettermint project, `<slug>-app`, belongs to the `app` entry; a
|
|
1906
1262
|
// second entry sharing the name would delete the first's live token.
|
|
1907
|
-
if (entry.key === APP_ENTRY_KEY
|
|
1263
|
+
if (entry.key === APP_ENTRY_KEY) {
|
|
1908
1264
|
await this.ensureAppLettermintMail(appId, org, context, Boolean(existing));
|
|
1909
1265
|
}
|
|
1910
1266
|
return { resourceId: appId };
|
|
@@ -1949,24 +1305,12 @@ export class FoundryStepExecutor {
|
|
|
1949
1305
|
if (!dbId) {
|
|
1950
1306
|
throw new Error(`coolify:database for ${org.slug}: no live database recorded, nothing to park`);
|
|
1951
1307
|
}
|
|
1952
|
-
if (await this.isAdopted(org, `coolify:database:${action.key}`, dbId)) {
|
|
1953
|
-
throw new Error(`coolify:database ${action.key}: adopted, not stopped`);
|
|
1954
|
-
}
|
|
1955
1308
|
await this.ports.coolify.raw('POST', `/databases/${dbId}/stop`);
|
|
1956
1309
|
return { resourceId: dbId };
|
|
1957
1310
|
}
|
|
1958
1311
|
const existing = context.recordedIds()[`coolify:database:${action.key}`];
|
|
1959
1312
|
let dbId;
|
|
1960
|
-
if (
|
|
1961
|
-
await this.refuseTakeover(org, `coolify:database:${action.key}`);
|
|
1962
|
-
}
|
|
1963
|
-
if (org.hosting.adopt?.database) {
|
|
1964
|
-
// The declared uuid is the database, over any recorded id: never found by
|
|
1965
|
-
// name or created (#311).
|
|
1966
|
-
dbId = org.hosting.adopt.database;
|
|
1967
|
-
await this.requireSnapshot(org, `coolify:database:${action.key}`, dbId);
|
|
1968
|
-
}
|
|
1969
|
-
else if (existing) {
|
|
1313
|
+
if (existing) {
|
|
1970
1314
|
dbId = existing;
|
|
1971
1315
|
}
|
|
1972
1316
|
else {
|
|
@@ -1997,7 +1341,7 @@ export class FoundryStepExecutor {
|
|
|
1997
1341
|
// before app on the same apply -- `diff.ts`'s `computePlanDetail`).
|
|
1998
1342
|
const dbEntry = org.hosting.entries.find((e) => e.database);
|
|
1999
1343
|
const appId = dbEntry && context.recordedIds()[`coolify:app:${appStepKey(org, dbEntry.key)}`];
|
|
2000
|
-
if (dbEntry && appId
|
|
1344
|
+
if (dbEntry && appId) {
|
|
2001
1345
|
const markerKey = dbEnvAppliedScopeKey(dbEntry.key);
|
|
2002
1346
|
const push = await this.pushDatabaseUrl(appId, dbId, org.slug.replace(/-/g, '_'));
|
|
2003
1347
|
if (push.pushed) {
|
|
@@ -2096,7 +1440,16 @@ export class FoundryStepExecutor {
|
|
|
2096
1440
|
*/
|
|
2097
1441
|
async pushAppEnv(appId, org, entry, context, authIssuer) {
|
|
2098
1442
|
const recorded = context.recordedIds();
|
|
2099
|
-
const envs =
|
|
1443
|
+
const envs = [
|
|
1444
|
+
// Only the `app` entry has a public name; the others are internal.
|
|
1445
|
+
...(entry.key === APP_ENTRY_KEY
|
|
1446
|
+
? [{ key: 'APP_URL', value: `https://${appHostname(org)}` }]
|
|
1447
|
+
: []),
|
|
1448
|
+
// #75: read back by the package-side pino sink (not this repo's job,
|
|
1449
|
+
// #75 "What belongs where") once `ensureLogVolume` has mounted the
|
|
1450
|
+
// matching volume.
|
|
1451
|
+
{ key: 'LOG_DIR', value: LOG_DIR },
|
|
1452
|
+
];
|
|
2100
1453
|
// An entry with `uses.auth` signs in one organisation, with its own client
|
|
2101
1454
|
// and the issuer; any other entry keeps the org's staff organisation and
|
|
2102
1455
|
// the one hosted-app client.
|
|
@@ -2106,10 +1459,27 @@ export class FoundryStepExecutor {
|
|
|
2106
1459
|
if (!authEnv.AUTH_ORGANIZATION_ID) {
|
|
2107
1460
|
throw new Error(`coolify:app for ${org.slug}: entry '${entry.key}' signs in ${entry.uses.auth.users}, whose organisation id is not recorded yet`);
|
|
2108
1461
|
}
|
|
1462
|
+
for (const [key, value] of Object.entries(authEnv))
|
|
1463
|
+
envs.push({ key, value });
|
|
2109
1464
|
const appKey = await this.ensureAuthAppKey(appId);
|
|
2110
1465
|
if (appKey)
|
|
2111
1466
|
envs.push({ key: 'AUTH_APP_KEY', value: appKey });
|
|
2112
1467
|
}
|
|
1468
|
+
else {
|
|
1469
|
+
const zitadelOrgId = recorded.zitadel_org;
|
|
1470
|
+
if (zitadelOrgId)
|
|
1471
|
+
envs.push({ key: 'AUTH_ORGANIZATION_ID', value: zitadelOrgId });
|
|
1472
|
+
const clientId = recorded[APP_CLIENT_ID_SCOPE_KEY];
|
|
1473
|
+
if (clientId)
|
|
1474
|
+
envs.push({ key: 'AUTH_CLIENT_ID', value: clientId });
|
|
1475
|
+
}
|
|
1476
|
+
// The invited owner's ZITADEL user id (gap P3): the app uses it to make
|
|
1477
|
+
// them its own owner in authz on first sign-in. `runSteps` records this
|
|
1478
|
+
// under the same `system:kind:key` shape every step's `resourceId` uses
|
|
1479
|
+
// (`zitadelOwner`'s own doc comment), not a scope id.
|
|
1480
|
+
const ownerUserId = recorded[`zitadel:owner:${org.id}`];
|
|
1481
|
+
if (ownerUserId)
|
|
1482
|
+
envs.push({ key: 'OWNER_USER_ID', value: ownerUserId });
|
|
2113
1483
|
// Idempotent the same way `pushDatabaseUrl`'s credential is treated:
|
|
2114
1484
|
// generated once, handed straight to `bulkUpsertEnvs` and nowhere else in
|
|
2115
1485
|
// this class -- never returned, recorded, logged or interpolated into a
|
|
@@ -2126,22 +1496,7 @@ export class FoundryStepExecutor {
|
|
|
2126
1496
|
const appEnv = resolveAppEnv(org, entry, recorded).values;
|
|
2127
1497
|
for (const [key, value] of Object.entries(appEnv))
|
|
2128
1498
|
envs.push({ key, value });
|
|
2129
|
-
|
|
2130
|
-
// exists live keeps the flags it has (#311).
|
|
2131
|
-
const live = entry.adopt
|
|
2132
|
-
? (await this.ports.coolify.listEnvs(appId)).filter((e) => !e.is_preview)
|
|
2133
|
-
: [];
|
|
2134
|
-
await this.ports.coolify.bulkUpsertEnvs(appId, envs.map((env) => {
|
|
2135
|
-
const row = live.find((e) => e.key === env.key);
|
|
2136
|
-
return row
|
|
2137
|
-
? {
|
|
2138
|
-
...env,
|
|
2139
|
-
buildTime: Boolean(row.is_buildtime),
|
|
2140
|
-
literal: Boolean(row.is_literal),
|
|
2141
|
-
multiline: Boolean(row.is_multiline),
|
|
2142
|
-
}
|
|
2143
|
-
: env;
|
|
2144
|
-
}));
|
|
1499
|
+
await this.ports.coolify.bulkUpsertEnvs(appId, envs);
|
|
2145
1500
|
// `coolifyApp` records `applied` once the restart succeeded, so `planApp`
|
|
2146
1501
|
// replans this step when the declared map changes (rule 3). Ids and a
|
|
2147
1502
|
// domain, never a secret.
|
|
@@ -3049,7 +2404,7 @@ export class FoundryStepExecutor {
|
|
|
3049
2404
|
}
|
|
3050
2405
|
const projectUuid = await this.ensureCoolifyProject(org, context);
|
|
3051
2406
|
const { environment } = await this.ports.coolify.findOrCreateEnvironment(projectUuid, 'production');
|
|
3052
|
-
const name =
|
|
2407
|
+
const name = callsServiceInternalName(org);
|
|
3053
2408
|
const synapseHost = `${org.slug}-calls-synapse`;
|
|
3054
2409
|
const masHost = `${org.slug}-calls-mas`;
|
|
3055
2410
|
const dbName = `${name}-db`;
|
|
@@ -3074,6 +2429,13 @@ export class FoundryStepExecutor {
|
|
|
3074
2429
|
domains: callsHostnameFor(org),
|
|
3075
2430
|
dockerfile_location: CALLS_SERVICE_DOCKERFILE,
|
|
3076
2431
|
}));
|
|
2432
|
+
// A fixed internal name, like Synapse and MAS above, so the org's Agora
|
|
2433
|
+
// reaches this app as `http://<name>:8080` (`agora.ts`'s `AGORA_CALLS_UPSTREAM`).
|
|
2434
|
+
// Set on every run, not only on create: `planCallsService` plans this
|
|
2435
|
+
// step again for a live app whose marker is missing or stale, so an app
|
|
2436
|
+
// made before this existed gets its name here. The marker, recorded last,
|
|
2437
|
+
// tells `desiredAgoraEnv` that the name and the env are both in place.
|
|
2438
|
+
await this.ports.coolify.updateApplication(app.appId, { custom_internal_name: name });
|
|
3077
2439
|
// Every app foundry deploys keeps its own logs (#75), reused as-is here.
|
|
3078
2440
|
await this.ensureLogVolume(app.appId, org, app.existed);
|
|
3079
2441
|
const bindingSecret = await this.ensureCallsBindingSecret(app.appId);
|
|
@@ -3088,16 +2450,120 @@ export class FoundryStepExecutor {
|
|
|
3088
2450
|
{ key: 'MAS_CLIENT_ID', value: masCredentials.clientId },
|
|
3089
2451
|
{ key: 'MAS_CLIENT_SECRET', value: masCredentials.clientSecret },
|
|
3090
2452
|
{ key: 'SYNAPSE_URL', value: `http://${synapseHost}:8008` },
|
|
3091
|
-
{ key: 'CALLS_ORIGIN', value: `https://${
|
|
2453
|
+
{ key: 'CALLS_ORIGIN', value: `https://${agoraHostnameFor(org)}` },
|
|
3092
2454
|
{ key: 'CALLS_HOST_ADAPTER', value: 'production' },
|
|
2455
|
+
// Deliberately kept as it was, not widened: the calls service refuses to
|
|
2456
|
+
// boot with no `CALLS_TRUSTED_IP_HEADER` (`config.mjs` has no default),
|
|
2457
|
+
// so unsetting it would stop the service. It keys the refused-bearer
|
|
2458
|
+
// limiter, which is only as honest as the proxy in front overwriting
|
|
2459
|
+
// this header (https://github.com/wtfalch/calls/issues/82).
|
|
3093
2460
|
{ key: 'CALLS_TRUSTED_IP_HEADER', value: 'cf-connecting-ip' },
|
|
3094
2461
|
{ key: 'PORT', value: CALLS_SERVICE_PORT },
|
|
3095
2462
|
];
|
|
3096
2463
|
if (bindingSecret)
|
|
3097
2464
|
envs.push({ key: 'CALLS_BINDING_SECRET', value: bindingSecret });
|
|
2465
|
+
// The seven ZITADEL token vars, all or none (the service refuses to boot
|
|
2466
|
+
// on a partial set). Deliberately absent: `CALLS_CHAT_URL` and
|
|
2467
|
+
// `CALLS_CHAT_TOKEN`. Foundry does not deploy chat
|
|
2468
|
+
// (https://github.com/wtfalch/chat/issues/40), so with both unset the
|
|
2469
|
+
// calls service admits only `event:` rooms.
|
|
2470
|
+
const tokenHost = action.desired.host;
|
|
2471
|
+
const tokenMarker = callsTokenEnvMarker(tokenHost ?? null, context.recordedIds());
|
|
2472
|
+
const token = tokenHost && tokenMarker
|
|
2473
|
+
? await this.callsTokenEnvs(org, app.appId, tokenHost, context)
|
|
2474
|
+
: null;
|
|
2475
|
+
if (token)
|
|
2476
|
+
envs.push(...token.envs);
|
|
3098
2477
|
await this.ports.coolify.bulkUpsertEnvs(app.appId, envs);
|
|
2478
|
+
// Recorded after the write, so a failure before it leaves the app
|
|
2479
|
+
// unguarded and the planner reports the lost secret instead of minting again.
|
|
2480
|
+
if (token)
|
|
2481
|
+
context.recordScopeId(CALLS_INTROSPECTION_SCOPE_KEY, token.appId);
|
|
2482
|
+
// A container reads its name and env only at start, so a running app is
|
|
2483
|
+
// restarted once, when this run is the one that gave it its name or its
|
|
2484
|
+
// token env (the same rule `coolifyApp` follows when an existing app's
|
|
2485
|
+
// env changed).
|
|
2486
|
+
const givesTokenEnv = token !== null && context.recordedIds()[CALLS_SERVICE_TOKEN_ENV_SCOPE_KEY] !== tokenMarker;
|
|
2487
|
+
if (app.existed && (!callsServiceHasName(org, context.recordedIds()) || givesTokenEnv)) {
|
|
2488
|
+
await this.ports.coolify.restart(app.appId);
|
|
2489
|
+
}
|
|
2490
|
+
context.recordScopeId(CALLS_SERVICE_NAME_SCOPE_KEY, name);
|
|
2491
|
+
if (token && tokenMarker)
|
|
2492
|
+
context.recordScopeId(CALLS_SERVICE_TOKEN_ENV_SCOPE_KEY, tokenMarker);
|
|
3099
2493
|
return { resourceId: app.appId };
|
|
3100
2494
|
}
|
|
2495
|
+
/**
|
|
2496
|
+
* The seven ZITADEL token env vars the calls service needs to accept
|
|
2497
|
+
* Agora's sign-in token (`calls/docs/member-auth.md`). Only called with all
|
|
2498
|
+
* inputs present, so the set is whole or absent.
|
|
2499
|
+
*
|
|
2500
|
+
* The introspection client is an API app in the messaging project (the
|
|
2501
|
+
* audience the token carries), minted once the way `platform-providers.ts`
|
|
2502
|
+
* mints the files one: its id is recorded under an undelivered key and
|
|
2503
|
+
* persisted before the secret leaves this method, the secret goes to
|
|
2504
|
+
* `deliverSecret` with retries and to the app's env, never to a log, error
|
|
2505
|
+
* or return value, and the guard is recorded by the caller after the env
|
|
2506
|
+
* write. A re-run reads the client id and secret back from the app's own
|
|
2507
|
+
* env and mints nothing.
|
|
2508
|
+
*/
|
|
2509
|
+
async callsTokenEnvs(org, appId, host, context) {
|
|
2510
|
+
const recorded = context.recordedIds();
|
|
2511
|
+
const zitadelOrgId = recorded[ZITADEL_ORG_SCOPE_KEY];
|
|
2512
|
+
const scope = (clientId, clientSecret) => [
|
|
2513
|
+
{ key: 'CALLS_ISSUER', value: host.issuer },
|
|
2514
|
+
{ key: 'CALLS_AUDIENCE', value: host.messagingProjectId },
|
|
2515
|
+
{ key: 'CALLS_ORGANISATION_ID', value: zitadelOrgId },
|
|
2516
|
+
// Chat's own scope (`apps/chat-service`): the audience, and `estate`.
|
|
2517
|
+
{ key: 'CALLS_APPLICATION_ID', value: host.messagingProjectId },
|
|
2518
|
+
{ key: 'CALLS_PLATFORM_ID', value: 'estate' },
|
|
2519
|
+
{ key: 'CALLS_INTROSPECTION_CLIENT_ID', value: clientId },
|
|
2520
|
+
{ key: 'CALLS_INTROSPECTION_CLIENT_SECRET', value: clientSecret },
|
|
2521
|
+
];
|
|
2522
|
+
const done = recorded[CALLS_INTROSPECTION_SCOPE_KEY];
|
|
2523
|
+
const undelivered = recorded[CALLS_INTROSPECTION_UNDELIVERED_SCOPE_KEY];
|
|
2524
|
+
if (undelivered && !done) {
|
|
2525
|
+
throw new Error(`coolify:calls-service for ${org.slug}: the introspection client secret (ZITADEL app ${undelivered}) was minted but never written; remove the recorded key ${CALLS_INTROSPECTION_UNDELIVERED_SCOPE_KEY} to mint a new one`);
|
|
2526
|
+
}
|
|
2527
|
+
// A changed project or org is never replaced here (see the message); an
|
|
2528
|
+
// issuer-only change keeps the client and rewrites the env below.
|
|
2529
|
+
const moved = callsTokenEnvMovedMessage(org, host, recorded);
|
|
2530
|
+
if (moved)
|
|
2531
|
+
throw new Error(`coolify:calls-service: ${moved}`);
|
|
2532
|
+
if (done) {
|
|
2533
|
+
const envs = await this.ports.coolify.listEnvs(appId);
|
|
2534
|
+
const read = (key) => envs.find((e) => e.key === key && !e.is_preview)?.value;
|
|
2535
|
+
const clientId = read('CALLS_INTROSPECTION_CLIENT_ID');
|
|
2536
|
+
const clientSecret = read('CALLS_INTROSPECTION_CLIENT_SECRET');
|
|
2537
|
+
if (!clientId || !clientSecret) {
|
|
2538
|
+
throw new Error(`coolify:calls-service for ${org.slug}: the introspection client (ZITADEL app ${done}) is recorded but its id or secret is no longer on the app; remove the recorded keys ${CALLS_INTROSPECTION_SCOPE_KEY} and ${CALLS_INTROSPECTION_UNDELIVERED_SCOPE_KEY}, and delete the ZITADEL client, to mint a new one`);
|
|
2539
|
+
}
|
|
2540
|
+
return { envs: scope(clientId, clientSecret), appId: done };
|
|
2541
|
+
}
|
|
2542
|
+
// An app by this name with no recorded secret is a crash between create
|
|
2543
|
+
// and record: nothing was delivered, so it is deleted and made again.
|
|
2544
|
+
const stale = (await this.ports.zitadel.listApps(zitadelOrgId, host.messagingProjectId)).apps.filter((app) => app.name === callsIntrospectionAppName(org) && app.state !== 'APP_STATE_REMOVED');
|
|
2545
|
+
for (const app of stale) {
|
|
2546
|
+
try {
|
|
2547
|
+
await this.ports.zitadel.deleteApp(zitadelOrgId, host.messagingProjectId, app.id);
|
|
2548
|
+
}
|
|
2549
|
+
catch (error) {
|
|
2550
|
+
if (!isNotFoundError(error))
|
|
2551
|
+
throw error;
|
|
2552
|
+
}
|
|
2553
|
+
}
|
|
2554
|
+
const client = await this.ports.zitadel.createApiApp(zitadelOrgId, host.messagingProjectId, {
|
|
2555
|
+
name: callsIntrospectionAppName(org),
|
|
2556
|
+
});
|
|
2557
|
+
context.recordScopeId(CALLS_INTROSPECTION_UNDELIVERED_SCOPE_KEY, client.appId);
|
|
2558
|
+
await context.persistScopeIds();
|
|
2559
|
+
if (!(await this.deliverWithRetry(`zitadel:calls:${org.slug}:introspection`, client.clientSecret))) {
|
|
2560
|
+
throw new Error(`coolify:calls-service for ${org.slug}: the introspection client secret (ZITADEL app ${client.appId}) could not be delivered after ${JOBS_DELIVERY_ATTEMPTS} attempts`);
|
|
2561
|
+
}
|
|
2562
|
+
return {
|
|
2563
|
+
envs: scope(client.clientId, client.clientSecret),
|
|
2564
|
+
appId: client.appId,
|
|
2565
|
+
};
|
|
2566
|
+
}
|
|
3101
2567
|
/**
|
|
3102
2568
|
* Reads back the calls Matrix stack's MAS client id/secret from the MAS
|
|
3103
2569
|
* app's own envs ({@link CALLS_SECRET_ENV_KEYS}) -- never mints: minting
|
|
@@ -3155,7 +2621,10 @@ export class FoundryStepExecutor {
|
|
|
3155
2621
|
* `agora` port overrides it) -- absent otherwise. The chat upstreams are
|
|
3156
2622
|
* still never pushed: chat has no admin API yet
|
|
3157
2623
|
* (https://github.com/wtfalch/chat/issues/40). Agora answers 502 on those
|
|
3158
|
-
* routes rather than serving anything wrong.
|
|
2624
|
+
* routes rather than serving anything wrong. `AGORA_CALLS_UPSTREAM` is
|
|
2625
|
+
* pushed once calls are on and this org's calls service app carries its
|
|
2626
|
+
* internal name (`desiredAgoraEnv` reads that marker from the recorded ids,
|
|
2627
|
+
* not the host values).
|
|
3159
2628
|
*/
|
|
3160
2629
|
async coolifyAgora(action, org, context) {
|
|
3161
2630
|
// The host values the plan read, carried in the step: never read again
|
|
@@ -3283,17 +2752,11 @@ export class FoundryStepExecutor {
|
|
|
3283
2752
|
}
|
|
3284
2753
|
// ---- cloudflare --------------------------------------------------------
|
|
3285
2754
|
async cloudflareTunnel(action, org, context) {
|
|
3286
|
-
const adoptedTunnel = org.hosting.adopt?.tunnel;
|
|
3287
|
-
const connectorApp = org.hosting.adopt?.connectorApp;
|
|
3288
2755
|
if (action.desired.enabled === false) {
|
|
3289
2756
|
const tunnelId = context.recordedIds()[`cloudflare:tunnel:${action.key}`];
|
|
3290
2757
|
if (!tunnelId) {
|
|
3291
2758
|
throw new Error(`cloudflare:tunnel for ${org.slug}: no live tunnel recorded, nothing to park`);
|
|
3292
2759
|
}
|
|
3293
|
-
// Parking clears the ingress and stops the connector: never for what was adopted (#312).
|
|
3294
|
-
if (await this.isAdopted(org, adoptedTunnelKey(org), tunnelId)) {
|
|
3295
|
-
throw new Error(`cloudflare:tunnel ${action.key}: adopted, not stopped`);
|
|
3296
|
-
}
|
|
3297
2760
|
// Cleared, not deleted (#7 corrections 1): an ingress with only the
|
|
3298
2761
|
// mandatory catch-all routes nothing.
|
|
3299
2762
|
await this.ports.cloudflare.putTunnelConfig(tunnelId, {
|
|
@@ -3314,17 +2777,6 @@ export class FoundryStepExecutor {
|
|
|
3314
2777
|
}
|
|
3315
2778
|
return { resourceId: tunnelId };
|
|
3316
2779
|
}
|
|
3317
|
-
// An adopted tunnel and connector are written to only under an active
|
|
3318
|
-
// snapshot; one that was adopted and is no longer declared is not taken as
|
|
3319
|
-
// foundry's own (#312).
|
|
3320
|
-
if (adoptedTunnel)
|
|
3321
|
-
await this.requireSnapshot(org, adoptedTunnelKey(org), adoptedTunnel);
|
|
3322
|
-
else
|
|
3323
|
-
await this.refuseTakeover(org, adoptedTunnelKey(org));
|
|
3324
|
-
if (connectorApp)
|
|
3325
|
-
await this.requireSnapshot(org, adoptedConnectorKey(org), connectorApp);
|
|
3326
|
-
else
|
|
3327
|
-
await this.refuseTakeover(org, adoptedConnectorKey(org));
|
|
3328
2780
|
const tunnelName = `${org.slug}-tunnel`;
|
|
3329
2781
|
// #236 round 5 finding 2 / round 6 finding 2: read, and (if it is about
|
|
3330
2782
|
// to change) added to the accumulated replaced-id set, before
|
|
@@ -3333,11 +2785,8 @@ export class FoundryStepExecutor {
|
|
|
3333
2785
|
// domain declared still leaves `destroy` a way to recognise the old
|
|
3334
2786
|
// id's CNAMEs as this tunnel's own rather than a foreign record.
|
|
3335
2787
|
const recordedTunnelId = context.recordedIds()[`cloudflare:tunnel:${action.key}`];
|
|
3336
|
-
|
|
3337
|
-
const tunnel =
|
|
3338
|
-
? { id: adoptedTunnel }
|
|
3339
|
-
: ((await this.ports.cloudflare.findTunnelByName(tunnelName)) ??
|
|
3340
|
-
(await this.ports.cloudflare.createTunnel(tunnelName)));
|
|
2788
|
+
const existing = await this.ports.cloudflare.findTunnelByName(tunnelName);
|
|
2789
|
+
const tunnel = existing ?? (await this.ports.cloudflare.createTunnel(tunnelName));
|
|
3341
2790
|
const previousIds = parseTunnelPreviousIds(context.recordedIds());
|
|
3342
2791
|
if (recordedTunnelId && recordedTunnelId !== tunnel.id) {
|
|
3343
2792
|
previousIds.add(recordedTunnelId);
|
|
@@ -3360,18 +2809,13 @@ export class FoundryStepExecutor {
|
|
|
3360
2809
|
// (#115) gets one ingress entry per hostname `tunnelIngressHostnames`
|
|
3361
2810
|
// returns, all routed to the same proxy -- Traefik itself, not this list,
|
|
3362
2811
|
// is what tells the public site apart from the cms studio.
|
|
3363
|
-
|
|
3364
|
-
|
|
3365
|
-
|
|
3366
|
-
|
|
3367
|
-
|
|
3368
|
-
|
|
3369
|
-
|
|
3370
|
-
}));
|
|
3371
|
-
await this.ports.cloudflare.putTunnelConfig(tunnel.id, {
|
|
3372
|
-
ingress: [...ingress, { service: 'http_status:404' }],
|
|
3373
|
-
});
|
|
3374
|
-
}
|
|
2812
|
+
const ingress = tunnelIngressHostnames(org).map((hostname) => ({
|
|
2813
|
+
hostname,
|
|
2814
|
+
service: COOLIFY_PROXY_ORIGIN,
|
|
2815
|
+
}));
|
|
2816
|
+
await this.ports.cloudflare.putTunnelConfig(tunnel.id, {
|
|
2817
|
+
ingress: [...ingress, { service: 'http_status:404' }],
|
|
2818
|
+
});
|
|
3375
2819
|
// #235: a connector for this org's own tunnel, the same call every
|
|
3376
2820
|
// platform tunnel step already makes (Decided, William, 2026-10-01:
|
|
3377
2821
|
// one connector per org tunnel, never a shared tunnel with appended
|
|
@@ -3379,40 +2823,22 @@ export class FoundryStepExecutor {
|
|
|
3379
2823
|
// nothing is dialed into it, so `hostname` answers with a Cloudflare
|
|
3380
2824
|
// error -- #197's own gap, for an org's tunnel rather than a platform
|
|
3381
2825
|
// one.
|
|
3382
|
-
|
|
3383
|
-
|
|
3384
|
-
|
|
3385
|
-
if (!connectorApp) {
|
|
3386
|
-
const projectUuid = await this.ensureCoolifyProject(org, context);
|
|
3387
|
-
const { environment } = await this.ports.coolify.findOrCreateEnvironment(projectUuid, 'production');
|
|
3388
|
-
place = { projectUuid, environment };
|
|
3389
|
-
}
|
|
3390
|
-
else {
|
|
3391
|
-
context.recordScopeId(CLOUDFLARE_TUNNEL_CONNECTOR_APP_SCOPE_KEY, connectorApp);
|
|
3392
|
-
}
|
|
3393
|
-
await this.guarded(Boolean(adoptedTunnel), `cloudflare:tunnel for ${org.slug}: updating the connector of tunnel ${tunnel.id}`, () => ensureCloudflaredConnector({
|
|
2826
|
+
const projectUuid = await this.ensureCoolifyProject(org, context);
|
|
2827
|
+
const { environment } = await this.ports.coolify.findOrCreateEnvironment(projectUuid, 'production');
|
|
2828
|
+
await ensureCloudflaredConnector({
|
|
3394
2829
|
coolify: this.ports.coolify,
|
|
3395
2830
|
cloudflare: this.ports.cloudflare,
|
|
3396
2831
|
tunnelId: tunnel.id,
|
|
3397
2832
|
appName: `${org.slug}-cloudflared`,
|
|
3398
|
-
|
|
3399
|
-
|
|
3400
|
-
// Restore redeploys a connector whose token foundry rewrote, so this is recorded first.
|
|
3401
|
-
// The digest tells restore the live token is foundry's; the token itself is never stored.
|
|
3402
|
-
beforeTokenWrite: connectorApp
|
|
3403
|
-
? (token) => this.updateAdoptionSettings(org, adoptedConnectorKey(org), (settings) => ({
|
|
3404
|
-
...settings,
|
|
3405
|
-
tokenWritten: true,
|
|
3406
|
-
tokenDigest: sha256Hex(token),
|
|
3407
|
-
}))
|
|
3408
|
-
: undefined,
|
|
2833
|
+
projectUuid,
|
|
2834
|
+
environment,
|
|
3409
2835
|
resolveServer: () => this.resolveServer(),
|
|
3410
2836
|
recordedConnectorAppId: context.recordedIds()[CLOUDFLARE_TUNNEL_CONNECTOR_APP_SCOPE_KEY] ?? null,
|
|
3411
2837
|
recordAppId: async (appId) => {
|
|
3412
2838
|
context.recordScopeId(CLOUDFLARE_TUNNEL_CONNECTOR_APP_SCOPE_KEY, appId);
|
|
3413
2839
|
await context.persistScopeIds();
|
|
3414
2840
|
},
|
|
3415
|
-
})
|
|
2841
|
+
});
|
|
3416
2842
|
// #236: an org with no domain (a level-1 org whose app still serves off
|
|
3417
2843
|
// the shared `*.oikos.host` wildcard tunnel, `scripts/shared-ingress.mjs`)
|
|
3418
2844
|
// has no zone of its own and nothing here to publish -- `org.domain` is
|
|
@@ -3445,14 +2871,12 @@ export class FoundryStepExecutor {
|
|
|
3445
2871
|
// failure leaves the *old* domain recorded, so a retry sees the same
|
|
3446
2872
|
// domain change again and tries cleanup again.
|
|
3447
2873
|
if (org.domain) {
|
|
3448
|
-
const { domain } = org;
|
|
3449
2874
|
const recordedBefore = context.recordedIds();
|
|
3450
2875
|
const previousDomain = recordedBefore[CLOUDFLARE_TUNNEL_DNS_DOMAIN_SCOPE_KEY];
|
|
3451
2876
|
const previousHostnamesRaw = recordedBefore[CLOUDFLARE_TUNNEL_DNS_HOSTNAMES_SCOPE_KEY];
|
|
3452
2877
|
const previousHostnames = previousHostnamesRaw ? previousHostnamesRaw.split(',') : [];
|
|
3453
2878
|
const domainChanged = previousDomain !== undefined && previousDomain !== org.domain;
|
|
3454
|
-
const
|
|
3455
|
-
const publishedHostnames = await this.guarded(Boolean(adoptedTunnel), dnsFailed, () => this.publishTunnelDnsRecords(org, domain, tunnel.id,
|
|
2879
|
+
const publishedHostnames = await this.publishTunnelDnsRecords(org, org.domain, tunnel.id,
|
|
3456
2880
|
// Round 6 finding 2: every id this step has ever replaced, not only
|
|
3457
2881
|
// the one `recordedTunnelId` captured above -- see the scope key's
|
|
3458
2882
|
// own doc comment.
|
|
@@ -3462,13 +2886,9 @@ export class FoundryStepExecutor {
|
|
|
3462
2886
|
// needs the domain's own previous hostname list for that. A domain
|
|
3463
2887
|
// change instead cleans the *whole* old domain below, so it gets an
|
|
3464
2888
|
// empty list here rather than a second, redundant pass over it.
|
|
3465
|
-
domainChanged ? [] : previousHostnames, context)
|
|
2889
|
+
domainChanged ? [] : previousHostnames, context);
|
|
3466
2890
|
if (domainChanged) {
|
|
3467
|
-
await this.
|
|
3468
|
-
// An adopted tunnel's CNAME that foundry did not create is never deleted.
|
|
3469
|
-
adoptedTunnel
|
|
3470
|
-
? new Set(createdDnsRecords((await this.ports.adoptionStore?.get(org.id, adoptedTunnelKey(org)))?.settings).map((r) => r.id))
|
|
3471
|
-
: undefined));
|
|
2891
|
+
await this.deleteStaleTunnelDnsRecords(tunnel.id, previousIds, previousDomain, previousHostnames, context);
|
|
3472
2892
|
}
|
|
3473
2893
|
context.recordScopeId(CLOUDFLARE_TUNNEL_DNS_DOMAIN_SCOPE_KEY, org.domain);
|
|
3474
2894
|
context.recordScopeId(CLOUDFLARE_TUNNEL_DNS_HOSTNAMES_SCOPE_KEY, publishedHostnames.join(','));
|
|
@@ -3496,80 +2916,6 @@ export class FoundryStepExecutor {
|
|
|
3496
2916
|
}
|
|
3497
2917
|
return { resourceId: tunnel.id };
|
|
3498
2918
|
}
|
|
3499
|
-
/**
|
|
3500
|
-
* Runs `call`; for an adopted resource a failure that is not one of this
|
|
3501
|
-
* file's own messages (which name hostnames and record types only) is
|
|
3502
|
-
* replaced by `label`, because a client's error text carries response
|
|
3503
|
-
* bodies (rule 6, #312).
|
|
3504
|
-
*/
|
|
3505
|
-
async guarded(adopted, label, call) {
|
|
3506
|
-
if (!adopted)
|
|
3507
|
-
return call();
|
|
3508
|
-
try {
|
|
3509
|
-
return await call();
|
|
3510
|
-
}
|
|
3511
|
-
catch (error) {
|
|
3512
|
-
if (error instanceof Error && error.message.startsWith('cloudflare:tunnel for '))
|
|
3513
|
-
throw error;
|
|
3514
|
-
throw new Error(`${label} failed`);
|
|
3515
|
-
}
|
|
3516
|
-
}
|
|
3517
|
-
/**
|
|
3518
|
-
* #312: writes the org's routes into the adopted tunnel's LIVE ingress list
|
|
3519
|
-
* (`mergeIngress`), not the snapshot, so a route a person added since is
|
|
3520
|
-
* kept too. The declared names go into the tunnel row's `written` only after
|
|
3521
|
-
* the write that carried them succeeded, so a crash in between leaves a
|
|
3522
|
-
* route taken as foreign and kept. Writes nothing when the merged list is the
|
|
3523
|
-
* live one. Errors name the tunnel id and the operation only.
|
|
3524
|
-
*/
|
|
3525
|
-
async mergeAdoptedIngress(org, tunnelId) {
|
|
3526
|
-
const cf = this.ports.cloudflare;
|
|
3527
|
-
const failed = (operation) => new Error(`cloudflare:tunnel for ${org.slug}: ${operation} of tunnel ${tunnelId} failed`);
|
|
3528
|
-
let live;
|
|
3529
|
-
try {
|
|
3530
|
-
live = (await cf.getTunnelConfig(tunnelId)).config;
|
|
3531
|
-
if (live && !Array.isArray(live.ingress))
|
|
3532
|
-
throw new Error('not a list');
|
|
3533
|
-
}
|
|
3534
|
-
catch {
|
|
3535
|
-
throw failed('reading the ingress');
|
|
3536
|
-
}
|
|
3537
|
-
const declared = tunnelIngressHostnames(org);
|
|
3538
|
-
const row = await this.ports.adoptionStore?.get(org.id, adoptedTunnelKey(org));
|
|
3539
|
-
// A name in `writing` may have been carried by a put whose row write never landed.
|
|
3540
|
-
const written = new Set([...writtenIngressNames(row), ...writingIngressNames(row)]);
|
|
3541
|
-
const snapshot = Array.isArray(row?.settings.ingress) ? row.settings.ingress : [];
|
|
3542
|
-
const merged = mergeIngress(live?.ingress ?? [], declared, COOLIFY_PROXY_ORIGIN, written, snapshot);
|
|
3543
|
-
try {
|
|
3544
|
-
const put = !isDeepStrictEqual(merged, live?.ingress ?? []);
|
|
3545
|
-
if (put) {
|
|
3546
|
-
// Recorded before the put: a failed row write after it must not strand foundry's routes.
|
|
3547
|
-
await this.updateAdoptionSettings(org, adoptedTunnelKey(org), (settings) => ({
|
|
3548
|
-
...settings,
|
|
3549
|
-
writing: [...new Set([...writingIngressNames(row), ...declared])],
|
|
3550
|
-
}));
|
|
3551
|
-
await cf.putTunnelConfig(tunnelId, { ...live, ingress: merged });
|
|
3552
|
-
}
|
|
3553
|
-
if (put || declared.some((hostname) => !written.has(hostname))) {
|
|
3554
|
-
await this.updateAdoptionSettings(org, adoptedTunnelKey(org), (settings) => ({
|
|
3555
|
-
...settings,
|
|
3556
|
-
written: [...new Set([...written, ...declared])],
|
|
3557
|
-
writing: [],
|
|
3558
|
-
}));
|
|
3559
|
-
}
|
|
3560
|
-
}
|
|
3561
|
-
catch {
|
|
3562
|
-
throw failed('writing the ingress');
|
|
3563
|
-
}
|
|
3564
|
-
}
|
|
3565
|
-
/** #312: replaces the `settings` of an adoption row, read fresh so no other key is lost. The state and `takenAt` stay. */
|
|
3566
|
-
async updateAdoptionSettings(org, resourceKey, update) {
|
|
3567
|
-
const { adoptionStore: store } = this.ports;
|
|
3568
|
-
const row = await store?.get(org.id, resourceKey);
|
|
3569
|
-
if (!store || !row)
|
|
3570
|
-
throw new Error('no adoption row to record in');
|
|
3571
|
-
await store.setSettings(org.id, resourceKey, update(row.settings));
|
|
3572
|
-
}
|
|
3573
2919
|
/**
|
|
3574
2920
|
* #236: when this tunnel's DNS was last published under a domain that
|
|
3575
2921
|
* differs from the one `org` now declares, deletes every CNAME in the
|
|
@@ -3611,7 +2957,7 @@ export class FoundryStepExecutor {
|
|
|
3611
2957
|
* hostnames) never added entries of its own to begin with, so there is
|
|
3612
2958
|
* nothing of this domain's left in that set either way.
|
|
3613
2959
|
*/
|
|
3614
|
-
async deleteStaleTunnelDnsRecords(tunnelId, previousIds, previousDomain, previousHostnames, context
|
|
2960
|
+
async deleteStaleTunnelDnsRecords(tunnelId, previousIds, previousDomain, previousHostnames, context) {
|
|
3615
2961
|
const zone = await this.ports.cloudflare.findZoneByApex(previousDomain);
|
|
3616
2962
|
if (!zone)
|
|
3617
2963
|
return;
|
|
@@ -3625,7 +2971,7 @@ export class FoundryStepExecutor {
|
|
|
3625
2971
|
? (await Promise.all(previousHostnames.map((hostname) => cf.findDnsRecord(zone.id, hostname, 'CNAME')))).flat()
|
|
3626
2972
|
: await cf.listDnsRecords(zone.id, { type: 'CNAME' });
|
|
3627
2973
|
for (const record of records) {
|
|
3628
|
-
if (dnsTargets.has(record.content)
|
|
2974
|
+
if (dnsTargets.has(record.content)) {
|
|
3629
2975
|
await cf.deleteDnsRecord(zone.id, record.id);
|
|
3630
2976
|
}
|
|
3631
2977
|
}
|
|
@@ -3699,15 +3045,12 @@ export class FoundryStepExecutor {
|
|
|
3699
3045
|
if (!zone) {
|
|
3700
3046
|
throw new Error(`cloudflare:tunnel for ${org.slug}: no Cloudflare zone found for ${domain} to point its hostnames at -- cloudflare:zone must provision it first`);
|
|
3701
3047
|
}
|
|
3702
|
-
const adopted = Boolean(org.hosting.adopt?.tunnel);
|
|
3703
|
-
const tunnelRow = () => this.ports.adoptionStore?.get(org.id, adoptedTunnelKey(org));
|
|
3704
3048
|
const dnsTarget = `${tunnelId}.cfargotunnel.com`;
|
|
3705
3049
|
const previousTargets = new Set([...previousIds].filter((id) => id !== tunnelId).map((id) => `${id}.cfargotunnel.com`));
|
|
3706
3050
|
const hostnames = tunnelDnsHostnames(org, domain);
|
|
3707
3051
|
const droppedHostnames = previousHostnames.filter((hostname) => !hostnames.includes(hostname));
|
|
3708
3052
|
const conflicts = [];
|
|
3709
3053
|
const toCreate = [];
|
|
3710
|
-
const toClaim = [];
|
|
3711
3054
|
const toRepoint = [];
|
|
3712
3055
|
for (const hostname of hostnames) {
|
|
3713
3056
|
const [a] = await cf.findDnsRecord(zone.id, hostname, 'A');
|
|
@@ -3728,11 +3071,6 @@ export class FoundryStepExecutor {
|
|
|
3728
3071
|
}
|
|
3729
3072
|
if (!cname)
|
|
3730
3073
|
toCreate.push(hostname);
|
|
3731
|
-
// A crash after the create left a CNAME to the tunnel that is foundry's.
|
|
3732
|
-
else if (adopted &&
|
|
3733
|
-
dnsCreatingNames((await tunnelRow())?.settings).some((n) => normalizeHostname(n) === normalizeHostname(hostname))) {
|
|
3734
|
-
toClaim.push({ hostname, recordId: cname.id });
|
|
3735
|
-
}
|
|
3736
3074
|
}
|
|
3737
3075
|
if (conflicts.length > 0) {
|
|
3738
3076
|
throw new Error(`cloudflare:tunnel for ${org.slug}: will not point its tunnel at every hostname -- ${conflicts.join('; ')}; remove the conflicting record first`);
|
|
@@ -3753,43 +3091,13 @@ export class FoundryStepExecutor {
|
|
|
3753
3091
|
};
|
|
3754
3092
|
for (const hostname of settled)
|
|
3755
3093
|
recordTracked(hostname);
|
|
3756
|
-
// The row learns of a record foundry made: `dnsCreating` first, then `dnsRecords` with its id.
|
|
3757
|
-
const recordCreated = (hostname, recordId) => this.updateAdoptionSettings(org, adoptedTunnelKey(org), (settings) => ({
|
|
3758
|
-
...settings,
|
|
3759
|
-
dnsRecords: [...createdDnsRecords(settings), { zoneId: zone.id, id: recordId, hostname }],
|
|
3760
|
-
dnsCreating: dnsCreatingNames(settings).filter((n) => normalizeHostname(n) !== normalizeHostname(hostname)),
|
|
3761
|
-
}));
|
|
3762
|
-
for (const { hostname, recordId } of toClaim)
|
|
3763
|
-
await recordCreated(hostname, recordId);
|
|
3764
3094
|
for (const hostname of toCreate) {
|
|
3765
|
-
|
|
3766
|
-
|
|
3767
|
-
|
|
3768
|
-
|
|
3769
|
-
|
|
3770
|
-
}
|
|
3771
|
-
let created;
|
|
3772
|
-
try {
|
|
3773
|
-
created = await cf.createDnsRecord(zone.id, {
|
|
3774
|
-
type: 'CNAME',
|
|
3775
|
-
name: hostname,
|
|
3776
|
-
content: dnsTarget,
|
|
3777
|
-
proxied: true,
|
|
3778
|
-
});
|
|
3779
|
-
}
|
|
3780
|
-
catch (error) {
|
|
3781
|
-
// The call answered with an error: no record of foundry's, so a later one is a person's.
|
|
3782
|
-
// A timeout or network failure may have created it: `dnsCreating` stays for the claim.
|
|
3783
|
-
if (adopted && isApiError(error)) {
|
|
3784
|
-
await this.updateAdoptionSettings(org, adoptedTunnelKey(org), (settings) => ({
|
|
3785
|
-
...settings,
|
|
3786
|
-
dnsCreating: dnsCreatingNames(settings).filter((n) => normalizeHostname(n) !== normalizeHostname(hostname)),
|
|
3787
|
-
})).catch(() => { });
|
|
3788
|
-
}
|
|
3789
|
-
throw error;
|
|
3790
|
-
}
|
|
3791
|
-
if (adopted)
|
|
3792
|
-
await recordCreated(hostname, created.id);
|
|
3095
|
+
await cf.createDnsRecord(zone.id, {
|
|
3096
|
+
type: 'CNAME',
|
|
3097
|
+
name: hostname,
|
|
3098
|
+
content: dnsTarget,
|
|
3099
|
+
proxied: true,
|
|
3100
|
+
});
|
|
3793
3101
|
recordTracked(hostname);
|
|
3794
3102
|
}
|
|
3795
3103
|
for (const { hostname, recordId } of toRepoint) {
|
|
@@ -3813,10 +3121,7 @@ export class FoundryStepExecutor {
|
|
|
3813
3121
|
// way.
|
|
3814
3122
|
for (const hostname of droppedHostnames) {
|
|
3815
3123
|
const [cname] = await cf.findDnsRecord(zone.id, hostname, 'CNAME');
|
|
3816
|
-
|
|
3817
|
-
const mine = !adopted ||
|
|
3818
|
-
createdDnsRecords((await tunnelRow())?.settings).some((r) => r.id === cname?.id);
|
|
3819
|
-
if (cname && mine && (cname.content === dnsTarget || previousTargets.has(cname.content))) {
|
|
3124
|
+
if (cname && (cname.content === dnsTarget || previousTargets.has(cname.content))) {
|
|
3820
3125
|
await cf.deleteDnsRecord(zone.id, cname.id);
|
|
3821
3126
|
}
|
|
3822
3127
|
tracked.delete(trackedTunnelDnsEntry(domain, hostname));
|
|
@@ -4258,16 +3563,12 @@ export class FoundryStepExecutor {
|
|
|
4258
3563
|
return;
|
|
4259
3564
|
}
|
|
4260
3565
|
case 'cloudflare:tunnel': {
|
|
4261
|
-
// An adopted tunnel and connector are never deleted (#312).
|
|
4262
|
-
if (await this.isAdopted(org, adoptedTunnelKey(org), target.id))
|
|
4263
|
-
return;
|
|
4264
3566
|
// #235: the connector app first, its own 404 handled locally --
|
|
4265
3567
|
// a retry where this already succeeded must still reach
|
|
4266
3568
|
// `deleteTunnel` below, which the outer catch's own 404 handling
|
|
4267
3569
|
// would otherwise short-circuit before it ran.
|
|
4268
3570
|
const connectorAppId = recordedIds[CLOUDFLARE_TUNNEL_CONNECTOR_APP_SCOPE_KEY];
|
|
4269
|
-
if (connectorAppId
|
|
4270
|
-
!(await this.isAdopted(org, adoptedConnectorKey(org), connectorAppId))) {
|
|
3571
|
+
if (connectorAppId) {
|
|
4271
3572
|
try {
|
|
4272
3573
|
await this.ports.coolify.deleteApplication(connectorAppId);
|
|
4273
3574
|
}
|
|
@@ -4352,13 +3653,9 @@ export class FoundryStepExecutor {
|
|
|
4352
3653
|
await this.ports.cloudflare.deleteAccessApp(target.id);
|
|
4353
3654
|
return;
|
|
4354
3655
|
case 'coolify:app':
|
|
4355
|
-
if (declaredAdoptUuids(org).has(target.id))
|
|
4356
|
-
return;
|
|
4357
3656
|
await this.ports.coolify.deleteApplication(target.id);
|
|
4358
3657
|
return;
|
|
4359
3658
|
case 'coolify:database':
|
|
4360
|
-
if (declaredAdoptUuids(org).has(target.id))
|
|
4361
|
-
return;
|
|
4362
3659
|
await this.ports.coolify.deleteDatabase(target.id);
|
|
4363
3660
|
return;
|
|
4364
3661
|
case 'zitadel:app': {
|
|
@@ -4419,10 +3716,3 @@ export function isNotFoundError(error) {
|
|
|
4419
3716
|
const message = error instanceof Error ? error.message : String(error);
|
|
4420
3717
|
return /^\S+ \S+ \S+ → 404:/.test(message);
|
|
4421
3718
|
}
|
|
4422
|
-
/** Whether the API answered with an error (a status), as opposed to a timeout or network failure. */
|
|
4423
|
-
function isApiError(error) {
|
|
4424
|
-
if (typeof error?.status === 'number')
|
|
4425
|
-
return true;
|
|
4426
|
-
const message = error instanceof Error ? error.message : String(error);
|
|
4427
|
-
return /^\S+ \S+ \S+ → \d{3}:/.test(message);
|
|
4428
|
-
}
|