@wtfalch/foundry 0.9.0 → 0.11.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 +16 -0
- package/dist/index.d.ts +4 -3
- package/dist/index.js +1 -1
- package/dist/migrations/0030_hosting_entry_hostnames.sql +24 -0
- package/dist/migrations/0031_adoptions.sql +37 -0
- package/dist/org.d.ts +49 -4
- package/dist/org.js +134 -1
- package/dist/plan/adopt.d.ts +112 -0
- package/dist/plan/adopt.js +252 -0
- package/dist/plan/adoption-vault.d.ts +9 -0
- package/dist/plan/adoption-vault.js +1 -0
- package/dist/plan/agora.d.ts +7 -0
- package/dist/plan/agora.js +9 -0
- package/dist/plan/broker-entries.d.ts +108 -0
- package/dist/plan/broker-entries.js +214 -0
- package/dist/plan/cloudflared-connector.d.ts +10 -2
- package/dist/plan/cloudflared-connector.js +24 -16
- package/dist/plan/diff.d.ts +14 -0
- package/dist/plan/diff.js +488 -47
- package/dist/plan/executor.d.ts +141 -3
- package/dist/plan/executor.js +1100 -149
- package/dist/plan/hostnames.d.ts +24 -0
- package/dist/plan/hostnames.js +56 -0
- package/dist/plan/index.d.ts +2 -0
- package/dist/plan/index.js +1 -0
- package/dist/plan/locked-hostnames.d.ts +8 -0
- package/dist/plan/locked-hostnames.js +26 -0
- package/dist/plan/login-host.d.ts +1 -1
- package/dist/plan/login-host.js +5 -2
- package/dist/plan/plan-apply.d.ts +63 -3
- package/dist/plan/plan-apply.js +163 -5
- package/dist/plan/platform-diff.d.ts +66 -13
- package/dist/plan/platform-diff.js +117 -15
- package/dist/plan/platform-providers.d.ts +95 -2
- package/dist/plan/platform-providers.js +393 -27
- package/dist/plan/providers-readers.d.ts +8 -1
- package/dist/plan/providers-readers.js +58 -4
- package/dist/plan.d.ts +6 -0
- package/dist/store/postgres.d.ts +6 -1
- package/dist/store/postgres.js +80 -5
- package/dist/store/schema.d.ts +162 -0
- package/dist/store/schema.js +19 -1
- package/dist/store.d.ts +37 -0
- package/package.json +1 -1
package/dist/plan/executor.js
CHANGED
|
@@ -1,13 +1,17 @@
|
|
|
1
|
-
import { randomBytes } from 'node:crypto';
|
|
1
|
+
import { createHash, randomBytes } from 'node:crypto';
|
|
2
2
|
import { URL } from 'node:url';
|
|
3
|
+
import { isDeepStrictEqual } from 'node:util';
|
|
3
4
|
import { CALLS_SFU_PRIVATE_URL, CALLS_SFU_PUBLIC_URL } from '../calls-sfu.js';
|
|
4
5
|
import { APP_ENTRY_KEY, appEntry } from '../org.js';
|
|
5
|
-
import {
|
|
6
|
+
import { adoptedAppPatch, adoptedConnectorKey, adoptedPatchDetails, adoptedResources, adoptedTunnelKey, adoptionVaultRef, declaredAdoptUuids, foreignRoutes, isActiveSnapshot, mergeIngress, normalizeHostname, protectsKey, removedNames, replacedNames, restoreAppPatch, restoreIngress, snapshotAppSettings, } from './adopt.js';
|
|
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';
|
|
8
|
+
import { brokerEntryAppliedScopeKey, brokerEntryWarningsScopeKey, entryAuthEnv, entryAuthEnvAppliedScopeKey, wantedBrokerEntry, writeBrokerEntry, } from './broker-entries.js';
|
|
6
9
|
import { MAS_IMAGE, MAS_TAG, SYNAPSE_IMAGE, SYNAPSE_TAG, callsMasConfigYaml, callsServerName, callsSynapseHomeserverYaml, mintCallsMatrixSecrets, parseCallsDbUrl, } from './calls-matrix.js';
|
|
7
|
-
import { ensureCloudflaredConnector } from './cloudflared-connector.js';
|
|
10
|
+
import { ensureCloudflaredConnector, } from './cloudflared-connector.js';
|
|
11
|
+
import { appHostname, entryHostnames, tunnelDnsHostnames, tunnelIngressHostnames, } from './hostnames.js';
|
|
8
12
|
import { LOGIN_BINDING_SCOPE_KEY, LOGIN_HOST_PENDING_REMOVAL_SCOPE_KEY, loginBindingState, loginHostClientIds, pendingRemovalHosts, pendingRemovalState, } from './login-host.js';
|
|
9
13
|
import { MAIL_COPY_COMPLETED_SCOPE_KEY, MAIL_COPY_DONE_SCOPE_KEY, parseMailCopyCompleted, runMailCopyStep, } from './mail-copy.js';
|
|
10
|
-
import {
|
|
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, appDeployPendingScopeKey, appDomainsAppliedScopeKey, appEnvAppliedScopeKey, appNameFor, appSettingsAppliedScopeKey, appSettingsOf, appStepKey, appliedAppDomains, appliedAppSettings, dbEnvAppliedScopeKey, resolveAppEnv, } from './platform-diff.js';
|
|
11
15
|
/**
|
|
12
16
|
* #274 fix round 4: bridges `@wtfalch/providers`' real `ZitadelClient.
|
|
13
17
|
* listApps` -- a plain array, with no `loginBaseUri` or completeness signal
|
|
@@ -48,25 +52,6 @@ export class NotBuiltError extends Error {
|
|
|
48
52
|
// ---------------------------------------------------------------------------
|
|
49
53
|
// Hostnames
|
|
50
54
|
// ---------------------------------------------------------------------------
|
|
51
|
-
/**
|
|
52
|
-
* The Coolify name of a hosting entry's app: `<slug>` for the `app` entry, so
|
|
53
|
-
* the move to a list finds the app that runs today, `<slug>-<key>` for any
|
|
54
|
-
* other.
|
|
55
|
-
*/
|
|
56
|
-
function appNameFor(org, entry) {
|
|
57
|
-
return entry.key === APP_ENTRY_KEY ? org.slug : `${org.slug}-${entry.key}`;
|
|
58
|
-
}
|
|
59
|
-
/**
|
|
60
|
-
* `<slug>.oikos.host` (`../slug.ts`), or `DeclaredOrg.domain` once
|
|
61
|
-
* `hosting.usesDomain` is true — the same rule `diff.ts`'s `planApp` and
|
|
62
|
-
* `planSite` document, reused here for the org's own hosted app, its
|
|
63
|
-
* Cloudflare tunnel ingress and its ZITADEL issuer apps' redirect URIs
|
|
64
|
-
* (task brief: "Hostnames: `<slug>.oikos.host` ... or `DeclaredOrg.domain`
|
|
65
|
-
* when `hosting.usesDomain`").
|
|
66
|
-
*/
|
|
67
|
-
function hostnameFor(org) {
|
|
68
|
-
return org.hosting.usesDomain && org.domain ? org.domain : `${org.slug}.oikos.host`;
|
|
69
|
-
}
|
|
70
55
|
/**
|
|
71
56
|
* #274 fix round 3 point 4: `zitadelLoginHost`'s own comparison between a
|
|
72
57
|
* live app's ZITADEL-reported login base URI and the one this step itself
|
|
@@ -104,36 +89,6 @@ function callsHostnameFor(org) {
|
|
|
104
89
|
? `calls.${org.domain}`
|
|
105
90
|
: `${org.slug}-calls.oikos.host`;
|
|
106
91
|
}
|
|
107
|
-
/**
|
|
108
|
-
* The hostnames `cloudflareTunnel` routes to `COOLIFY_PROXY_ORIGIN`
|
|
109
|
-
* (https://github.com/wtfalch/foundry/issues/115). A site-enabled org
|
|
110
|
-
* (`org.ts`'s `SiteConfig.enabled`) has no single app hostname to reuse -- it
|
|
111
|
-
* needs both its public site at `org.domain` and its cms studio (the sign-in
|
|
112
|
-
* and editing surface, `@wtfalch/cms`) at `cms.<domain>`, so both are named
|
|
113
|
-
* here; `org.domain` is non-null in that branch, guaranteed by `org.ts`'s
|
|
114
|
-
* `validateSite` ("site.enabled requires domain"). Every other org --
|
|
115
|
-
* concretely a hosting org, whose one app hostname this served before #115
|
|
116
|
-
* ever existed -- keeps exactly the prior single-hostname behavior
|
|
117
|
-
* ({@link hostnameFor}), unchanged.
|
|
118
|
-
*
|
|
119
|
-
* #145: an own-domain calls service adds `calls.<domain>` on top of whichever
|
|
120
|
-
* of those a site/hosting org already gets, since calls can run alongside
|
|
121
|
-
* either (or neither).
|
|
122
|
-
*/
|
|
123
|
-
function tunnelIngressHostnames(org) {
|
|
124
|
-
const hostnames = org.site.enabled && org.domain ? [org.domain, `cms.${org.domain}`] : [hostnameFor(org)];
|
|
125
|
-
if (org.calls.enabled && org.calls.usesDomain && org.domain) {
|
|
126
|
-
hostnames.push(`calls.${org.domain}`);
|
|
127
|
-
}
|
|
128
|
-
// #164: Agora always moves onto `agora.<domain>` once `org.domain` is set
|
|
129
|
-
// -- no toggle to gate this on. A level-1 org (no domain) adds nothing
|
|
130
|
-
// here: `<slug>-agora.oikos.host` rides the shared `*.oikos.host` wildcard
|
|
131
|
-
// tunnel (`scripts/shared-ingress.mjs`), not a per-org tunnel.
|
|
132
|
-
if (org.domain) {
|
|
133
|
-
hostnames.push(agoraHostnameFor(org));
|
|
134
|
-
}
|
|
135
|
-
return hostnames;
|
|
136
|
-
}
|
|
137
92
|
/**
|
|
138
93
|
* The health URLs an outside prober should call for this org (#43): one per
|
|
139
94
|
* app foundry runs for it. A hosting org's app answers at its own hostname,
|
|
@@ -143,8 +98,17 @@ function tunnelIngressHostnames(org) {
|
|
|
143
98
|
* this file's hostname rules.
|
|
144
99
|
*/
|
|
145
100
|
export function healthUrls(org) {
|
|
146
|
-
|
|
147
|
-
|
|
101
|
+
// One URL per entry that has a public name; an internal entry has none.
|
|
102
|
+
if (org.hosting.enabled && appEntry(org.hosting)) {
|
|
103
|
+
return org.hosting.entries.flatMap((entry) => {
|
|
104
|
+
const [hostname] = entryHostnames(org, entry);
|
|
105
|
+
if (!hostname)
|
|
106
|
+
return [];
|
|
107
|
+
return [
|
|
108
|
+
`https://${hostname}${entry.healthPath ?? (entry.buildPack === 'static' ? '/' : '/api/health')}`,
|
|
109
|
+
];
|
|
110
|
+
});
|
|
111
|
+
}
|
|
148
112
|
if (org.site.enabled && org.domain) {
|
|
149
113
|
return [`https://${org.domain}/api/health`, `https://${cmsHostnameFor(org)}/api/health`];
|
|
150
114
|
}
|
|
@@ -166,7 +130,7 @@ function mailOrgFor(org, usesDomain, mode) {
|
|
|
166
130
|
/**
|
|
167
131
|
* Every app on the Coolify box shares one Traefik reverse proxy, and Coolify
|
|
168
132
|
* itself points that proxy's routing at whatever `domains` an application
|
|
169
|
-
* declares (`coolifyApp`'s `
|
|
133
|
+
* declares (`coolifyApp`'s `appHostname(org)`) -- so a tunnel's ingress only
|
|
170
134
|
* has to reach the proxy, never a specific app container.
|
|
171
135
|
* `wtfalch/app-template`'s bootstrap (`bootstrap/steps/cf-tunnel.ts`, prior
|
|
172
136
|
* art for the exact org this file's `cloudflareTunnel` implements, cited by
|
|
@@ -331,6 +295,34 @@ const CLOUDFLARE_TUNNEL_CONNECTOR_APP_SCOPE_KEY = 'cloudflare_tunnel_connector_a
|
|
|
331
295
|
* already follows.
|
|
332
296
|
*/
|
|
333
297
|
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);
|
|
334
326
|
/**
|
|
335
327
|
* #236 round 3: every hostname {@link FoundryStepExecutor.publishTunnelDnsRecords}
|
|
336
328
|
* last wrote a CNAME for, joined by `,` (hostnames carry no `,`, the same
|
|
@@ -564,6 +556,100 @@ export function isRunningOrStarting(status) {
|
|
|
564
556
|
return true;
|
|
565
557
|
return !status.startsWith('exited') && !status.startsWith('stopped');
|
|
566
558
|
}
|
|
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
|
+
}
|
|
567
653
|
// ---------------------------------------------------------------------------
|
|
568
654
|
// Executor
|
|
569
655
|
// ---------------------------------------------------------------------------
|
|
@@ -585,8 +671,12 @@ export class FoundryStepExecutor {
|
|
|
585
671
|
return this.zitadelApp(action, org, context);
|
|
586
672
|
case 'zitadel:customers':
|
|
587
673
|
return this.zitadelCustomers(org, context);
|
|
674
|
+
case 'coolify:adopt':
|
|
675
|
+
return this.coolifyAdopt(action, org, context.recordedIds());
|
|
588
676
|
case 'coolify:app':
|
|
589
677
|
return this.coolifyApp(action, org, context);
|
|
678
|
+
case 'coolify:broker-entry':
|
|
679
|
+
return this.coolifyBrokerEntry(action, org, context);
|
|
590
680
|
case 'coolify:database':
|
|
591
681
|
return this.coolifyDatabase(action, org, context);
|
|
592
682
|
case 'coolify:calls':
|
|
@@ -637,7 +727,12 @@ export class FoundryStepExecutor {
|
|
|
637
727
|
* any policy but foundry's, since that policy could let others in.
|
|
638
728
|
*/
|
|
639
729
|
async cloudflareAccess(org) {
|
|
640
|
-
|
|
730
|
+
// Only the `app` entry has a public name (`planAccess`); per-entry
|
|
731
|
+
// hostnames are issue 309.
|
|
732
|
+
const entry = appEntry(org.hosting);
|
|
733
|
+
if (!entry)
|
|
734
|
+
throw new Error(`cloudflare:access for ${org.slug}: no '${APP_ENTRY_KEY}' hosting entry`);
|
|
735
|
+
const domain = appHostname(org);
|
|
641
736
|
const name = `foundry:${org.id}`;
|
|
642
737
|
const cf = this.ports.cloudflare;
|
|
643
738
|
const onDomain = (await cf.listAccessApps()).filter((a) => a.domain === domain);
|
|
@@ -655,7 +750,7 @@ export class FoundryStepExecutor {
|
|
|
655
750
|
await cf.createAccessPolicy(app.id, {
|
|
656
751
|
name: ACCESS_POLICY_NAME,
|
|
657
752
|
decision: 'allow',
|
|
658
|
-
include:
|
|
753
|
+
include: entry.accessEmails.map((email) => ({ email: { email } })),
|
|
659
754
|
});
|
|
660
755
|
}
|
|
661
756
|
return { resourceId: app.id };
|
|
@@ -842,6 +937,9 @@ export class FoundryStepExecutor {
|
|
|
842
937
|
...(recorded[APP_CLIENT_ID_SCOPE_KEY]
|
|
843
938
|
? org.hosting.issuerApps.filter((a) => a.kind === 'web').map((a) => a.name)
|
|
844
939
|
: []),
|
|
940
|
+
...Object.keys(recorded)
|
|
941
|
+
.filter((key) => key.startsWith(ENTRY_CLIENT_ID_SCOPE_PREFIX))
|
|
942
|
+
.map((key) => key.slice(ENTRY_CLIENT_ID_SCOPE_PREFIX.length)),
|
|
845
943
|
...(recorded[CMS_CLIENT_ID_SCOPE_KEY] ? ['cms'] : []),
|
|
846
944
|
...(recorded[AGORA_CLIENT_ID_SCOPE_KEY] ? ['agora'] : []),
|
|
847
945
|
].map((name) => recorded[`zitadel:app:${name}`]));
|
|
@@ -1025,7 +1123,7 @@ export class FoundryStepExecutor {
|
|
|
1025
1123
|
? cmsHostnameFor(org)
|
|
1026
1124
|
: isAgoraClient
|
|
1027
1125
|
? agoraHostnameFor(org)
|
|
1028
|
-
:
|
|
1126
|
+
: appHostname(org);
|
|
1029
1127
|
// Native apps sign in via device code, with no redirect of their own
|
|
1030
1128
|
// (auth/scripts/provision.mjs:475-497, prior art cited in the decisions
|
|
1031
1129
|
// file) -- empty arrays rather than a web app's callback/logout URLs.
|
|
@@ -1037,11 +1135,23 @@ export class FoundryStepExecutor {
|
|
|
1037
1135
|
const redirectUri = isAgoraClient
|
|
1038
1136
|
? `https://${hostname}/callback`
|
|
1039
1137
|
: `https://${hostname}/auth/callback`;
|
|
1138
|
+
// An entry with `uses.auth` signs in at its own exact hostnames, one pair
|
|
1139
|
+
// of addresses each; a `*` adds none (`entryHostnames`).
|
|
1140
|
+
const authEntry = org.hosting.entries.find((e) => e.key === action.key && e.uses?.auth);
|
|
1141
|
+
const hosts = authEntry ? entryHostnames(org, authEntry) : null;
|
|
1040
1142
|
const created = await this.ports.zitadel.createOidcApp(zitadelOrgId, projectId, {
|
|
1041
1143
|
name: action.key,
|
|
1042
1144
|
appType,
|
|
1043
|
-
redirectUris: appType === 'native'
|
|
1044
|
-
|
|
1145
|
+
redirectUris: appType === 'native'
|
|
1146
|
+
? []
|
|
1147
|
+
: hosts
|
|
1148
|
+
? hosts.map((host) => `https://${host}/auth/callback`)
|
|
1149
|
+
: [redirectUri],
|
|
1150
|
+
postLogoutRedirectUris: appType === 'native'
|
|
1151
|
+
? []
|
|
1152
|
+
: hosts
|
|
1153
|
+
? hosts.map((host) => `https://${host}/`)
|
|
1154
|
+
: [`https://${hostname}/`],
|
|
1045
1155
|
});
|
|
1046
1156
|
if (appType === 'web') {
|
|
1047
1157
|
// `coolifyApp`'s own `pushAppEnv` reads `APP_CLIENT_ID_SCOPE_KEY` to
|
|
@@ -1056,7 +1166,9 @@ export class FoundryStepExecutor {
|
|
|
1056
1166
|
? CMS_CLIENT_ID_SCOPE_KEY
|
|
1057
1167
|
: isAgoraClient
|
|
1058
1168
|
? AGORA_CLIENT_ID_SCOPE_KEY
|
|
1059
|
-
:
|
|
1169
|
+
: authEntry
|
|
1170
|
+
? entryClientIdScopeKey(action.key)
|
|
1171
|
+
: APP_CLIENT_ID_SCOPE_KEY, created.clientId);
|
|
1060
1172
|
}
|
|
1061
1173
|
return { resourceId: created.appId };
|
|
1062
1174
|
}
|
|
@@ -1102,29 +1214,521 @@ export class FoundryStepExecutor {
|
|
|
1102
1214
|
throw new Error('no usable Coolify server is registered on this instance');
|
|
1103
1215
|
return server;
|
|
1104
1216
|
}
|
|
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
|
+
}
|
|
1105
1688
|
async coolifyApp(action, org, context) {
|
|
1106
1689
|
const recordedAppId = context.recordedIds()[`coolify:app:${action.key}`];
|
|
1107
1690
|
if (action.desired.enabled === false) {
|
|
1108
1691
|
if (!recordedAppId) {
|
|
1109
1692
|
throw new Error(`coolify:app for ${org.slug}: no live app recorded, nothing to park`);
|
|
1110
1693
|
}
|
|
1694
|
+
if (await this.isAdopted(org, `coolify:app:${action.key}`, recordedAppId)) {
|
|
1695
|
+
throw new Error(`coolify:app ${action.key}: adopted, not stopped`);
|
|
1696
|
+
}
|
|
1111
1697
|
await this.ports.coolify.raw('POST', `/applications/${recordedAppId}/stop`);
|
|
1112
1698
|
return { resourceId: recordedAppId };
|
|
1113
1699
|
}
|
|
1114
|
-
const entry =
|
|
1700
|
+
const entry = org.hosting.entries.find((e) => appStepKey(org, e.key) === action.key);
|
|
1115
1701
|
if (!entry)
|
|
1116
|
-
throw new Error(`coolify:app for ${org.slug}: no
|
|
1702
|
+
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}`);
|
|
1117
1707
|
// A level-1 org with no bespoke app of its own gets the shared standard
|
|
1118
1708
|
// app instead (gap P1, `.plans/2026-09-26-level-1-org-map.md`): an entry
|
|
1119
1709
|
// with no `repo`.
|
|
1120
1710
|
const appRepo = entry.repo ?? DEFAULT_APP_REPO;
|
|
1121
|
-
|
|
1122
|
-
|
|
1711
|
+
// Read only by the name lookup and by create, so an adopted entry, which
|
|
1712
|
+
// does neither, makes no project (#311).
|
|
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
|
+
};
|
|
1123
1722
|
// The recorded id first; the name when nothing is recorded, or when the
|
|
1124
1723
|
// recorded app is gone.
|
|
1125
1724
|
let existing = null;
|
|
1126
1725
|
let live;
|
|
1127
|
-
|
|
1726
|
+
// An adopted entry is the app it declares by uuid: never found by name,
|
|
1727
|
+
// never created (#311).
|
|
1728
|
+
if (entry.adopt) {
|
|
1729
|
+
existing = { uuid: entry.adopt.app };
|
|
1730
|
+
}
|
|
1731
|
+
else if (recordedAppId) {
|
|
1128
1732
|
try {
|
|
1129
1733
|
live = await this.ports.coolify.getApplication(recordedAppId);
|
|
1130
1734
|
existing = { uuid: recordedAppId };
|
|
@@ -1134,11 +1738,21 @@ export class FoundryStepExecutor {
|
|
|
1134
1738
|
throw error;
|
|
1135
1739
|
}
|
|
1136
1740
|
}
|
|
1137
|
-
if (!existing) {
|
|
1741
|
+
if (!existing && !entry.adopt) {
|
|
1138
1742
|
existing = await this.ports.coolify.findApplicationByName(appNameFor(org, entry), {
|
|
1139
|
-
environmentIds: [environment.id],
|
|
1743
|
+
environmentIds: [(await scope()).environment.id],
|
|
1140
1744
|
});
|
|
1141
1745
|
}
|
|
1746
|
+
// An entry with no names is internal: the other entries reach it as
|
|
1747
|
+
// `<slug>-<key>`, and it gets no domain, route or DNS record.
|
|
1748
|
+
const names = entryHostnames(org, entry);
|
|
1749
|
+
const isStatic = entry.buildPack === 'static';
|
|
1750
|
+
const folderField = isStatic ? 'publish_directory' : 'base_directory';
|
|
1751
|
+
const settingsKey = appSettingsAppliedScopeKey(entry.key);
|
|
1752
|
+
const domainsKey = appDomainsAppliedScopeKey(entry.key);
|
|
1753
|
+
const pendingKey = appDeployPendingScopeKey(entry.key);
|
|
1754
|
+
let haveNames;
|
|
1755
|
+
let have;
|
|
1142
1756
|
let appId;
|
|
1143
1757
|
if (existing) {
|
|
1144
1758
|
appId = existing.uuid;
|
|
@@ -1148,12 +1762,18 @@ export class FoundryStepExecutor {
|
|
|
1148
1762
|
// stops the app without deleting it, so re-enabling found it here,
|
|
1149
1763
|
// still stopped, and it stayed stopped forever despite `apply`
|
|
1150
1764
|
// reporting success.
|
|
1151
|
-
|
|
1152
|
-
if (!
|
|
1153
|
-
await this.ports.coolify.
|
|
1765
|
+
// An adopted app is left as it runs: starting it is not declared.
|
|
1766
|
+
if (!entry.adopt) {
|
|
1767
|
+
live ??= await this.ports.coolify.getApplication(existing.uuid);
|
|
1768
|
+
if (!isRunningOrStarting(live.status)) {
|
|
1769
|
+
await this.ports.coolify.raw('POST', `/applications/${existing.uuid}/start`);
|
|
1770
|
+
}
|
|
1154
1771
|
}
|
|
1772
|
+
have = appliedAppSettings(context.recordedIds(), entry.key);
|
|
1773
|
+
haveNames = appliedAppDomains(org, context.recordedIds(), entry.key);
|
|
1155
1774
|
}
|
|
1156
1775
|
else {
|
|
1776
|
+
const { projectUuid, environment } = await scope();
|
|
1157
1777
|
const server = await this.resolveServer();
|
|
1158
1778
|
const githubApp = await this.ports.coolify.requireGithubApp();
|
|
1159
1779
|
const created = await this.ports.coolify.createGithubAppApplication({
|
|
@@ -1166,20 +1786,70 @@ export class FoundryStepExecutor {
|
|
|
1166
1786
|
git_branch: entry.branch,
|
|
1167
1787
|
build_pack: entry.buildPack,
|
|
1168
1788
|
name: appNameFor(org, entry),
|
|
1169
|
-
domains:
|
|
1789
|
+
domains: names.length > 0 ? names.join(',') : undefined,
|
|
1790
|
+
autogenerate_domain: names.length > 0 ? undefined : false,
|
|
1791
|
+
// A static site is served by Coolify's own web server on port 80.
|
|
1792
|
+
ports_exposes: isStatic ? '80' : entry.port === null ? undefined : String(entry.port),
|
|
1793
|
+
is_auto_deploy_enabled: entry.autoDeploy,
|
|
1794
|
+
[folderField]: entry.folder === null ? undefined : `/${entry.folder}`,
|
|
1170
1795
|
// Only the shared standard app builds from this repo; a bespoke
|
|
1171
1796
|
// app's Dockerfile is at its own repo's root.
|
|
1172
1797
|
dockerfile_location: entry.repo ? undefined : DEFAULT_APP_DOCKERFILE,
|
|
1173
1798
|
});
|
|
1174
1799
|
appId = created.uuid;
|
|
1800
|
+
// Create sent every setting, so nothing is left to patch.
|
|
1801
|
+
have = appSettingsOf(entry);
|
|
1802
|
+
haveNames = names;
|
|
1803
|
+
}
|
|
1804
|
+
// Patch what differs from what the app has. A crash before the marker is
|
|
1805
|
+
// recorded below leaves the old one, so the retry patches the same
|
|
1806
|
+
// difference again.
|
|
1807
|
+
const want = appSettingsOf(entry);
|
|
1808
|
+
let patch = {};
|
|
1809
|
+
if (entry.adopt) {
|
|
1810
|
+
patch = adoptedAppPatch(entry, names, await this.readLiveApp(entry.adopt.app));
|
|
1811
|
+
}
|
|
1812
|
+
else {
|
|
1813
|
+
if (want.folder !== have.folder)
|
|
1814
|
+
patch[folderField] = want.folder ? `/${want.folder}` : '/';
|
|
1815
|
+
if (want.port !== have.port && want.port !== null && !isStatic) {
|
|
1816
|
+
patch.ports_exposes = String(want.port);
|
|
1817
|
+
}
|
|
1818
|
+
if (want.autoDeploy !== have.autoDeploy)
|
|
1819
|
+
patch.is_auto_deploy_enabled = want.autoDeploy;
|
|
1820
|
+
// Create names the app after `domains`; a changed name reaches a live app
|
|
1821
|
+
// here (rule 3). The internal name is set after create, as the calls
|
|
1822
|
+
// stack's apps do.
|
|
1823
|
+
if (JSON.stringify(names) !== JSON.stringify(haveNames))
|
|
1824
|
+
patch.domains = names.join(',');
|
|
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
|
+
}
|
|
1836
|
+
await this.ports.coolify.updateApplication(appId, patch);
|
|
1175
1837
|
}
|
|
1838
|
+
// The container builds and listens from these only at its next deploy.
|
|
1839
|
+
const settingsNeedDeploy = Boolean((entry.adopt && context.recordedIds()[pendingKey]) ||
|
|
1840
|
+
patch.base_directory ||
|
|
1841
|
+
patch.publish_directory ||
|
|
1842
|
+
patch.ports_exposes ||
|
|
1843
|
+
patch.domains !== undefined ||
|
|
1844
|
+
patch.custom_internal_name);
|
|
1176
1845
|
// The database plans before the app (`diff.ts`'s `computePlanDetail`,
|
|
1177
1846
|
// hosting-wiring campaign) so that, on the same apply, the database's
|
|
1178
1847
|
// resource id is already recorded by the time this runs -- but this also
|
|
1179
1848
|
// covers the database being turned on after the app already exists (the
|
|
1180
1849
|
// app step still ran this apply, found the app by name, and lands here).
|
|
1181
1850
|
let dbPush = { pushed: false, changed: false };
|
|
1182
|
-
|
|
1851
|
+
// An adopted app keeps the database addresses it has (#311).
|
|
1852
|
+
if (entry.database && !entry.adopt && !org.hosting.adopt?.database) {
|
|
1183
1853
|
const dbId = context.recordedIds()[`coolify:database:${org.id}`];
|
|
1184
1854
|
if (dbId) {
|
|
1185
1855
|
dbPush = await this.pushDatabaseUrl(appId, dbId, org.slug.replace(/-/g, '_'));
|
|
@@ -1188,7 +1858,7 @@ export class FoundryStepExecutor {
|
|
|
1188
1858
|
// Restart when the push changed something, or when no earlier run got as
|
|
1189
1859
|
// far as restarting after one (marker missing): a crash between the push
|
|
1190
1860
|
// and the restart leaves the env right and the container stale.
|
|
1191
|
-
const dbNeedsRestart = dbPush.pushed && (dbPush.changed || !context.recordedIds()[
|
|
1861
|
+
const dbNeedsRestart = dbPush.pushed && (dbPush.changed || !context.recordedIds()[dbEnvAppliedScopeKey(entry.key)]);
|
|
1192
1862
|
// Every app keeps its own logs across a redeploy (#75). Unconditional,
|
|
1193
1863
|
// like the env push below: found by mount path before create, so a
|
|
1194
1864
|
// re-run (or the drift step `diff.ts`'s `planApp` plans for an app that
|
|
@@ -1196,27 +1866,47 @@ export class FoundryStepExecutor {
|
|
|
1196
1866
|
// `ensureLogVolume` whether this app was already running before this
|
|
1197
1867
|
// call -- see its own doc comment for why that decides whether to
|
|
1198
1868
|
// restart it.
|
|
1199
|
-
|
|
1869
|
+
// An adopted app gets no volume: the snapshot cannot undo one (#311).
|
|
1870
|
+
if (!entry.adopt) {
|
|
1871
|
+
await this.ensureLogVolume(appId, org, Boolean(existing), appNameFor(org, entry));
|
|
1872
|
+
}
|
|
1200
1873
|
// The app receives whatever it needs to sign in (gap P2). Unconditional,
|
|
1201
1874
|
// unlike the database push above: every app has a hostname, and an
|
|
1202
1875
|
// upsert of the same value on a later apply is a no-op, not a change.
|
|
1203
|
-
const appEnv = await this.pushAppEnv(appId, org, entry, context);
|
|
1876
|
+
const appEnv = await this.pushAppEnv(appId, org, entry, context, action.desired.authIssuer ?? null);
|
|
1204
1877
|
// Rule 4 (#239, #243): a container reads its env only at start, so an app
|
|
1205
1878
|
// that was already there is restarted, once, when its database URLs or its
|
|
1206
1879
|
// declared appEnv changed.
|
|
1207
|
-
|
|
1880
|
+
// A new folder needs a build, which a restart does not do, so a settings
|
|
1881
|
+
// change deploys instead (rule 3), and that carries the env too.
|
|
1882
|
+
if (settingsNeedDeploy)
|
|
1883
|
+
await this.ports.coolify.deploy(appId);
|
|
1884
|
+
else if ((appEnv.changed || dbNeedsRestart) && existing)
|
|
1208
1885
|
await this.ports.coolify.restart(appId);
|
|
1209
|
-
// Recorded only after the
|
|
1210
|
-
//
|
|
1211
|
-
|
|
1886
|
+
// Recorded only after the redeploy succeeded: a failed one leaves the old
|
|
1887
|
+
// value, so the next plan replans (rule 3). The db marker is not a URL.
|
|
1888
|
+
// A port taken back to null was not patched, so the marker keeps the old
|
|
1889
|
+
// one and `planApp` reports it instead of planning it.
|
|
1890
|
+
context.recordScopeId(settingsKey, JSON.stringify({ ...want, port: want.port ?? have.port }));
|
|
1891
|
+
context.recordScopeId(domainsKey, JSON.stringify(names));
|
|
1892
|
+
if (entry.adopt)
|
|
1893
|
+
context.recordScopeId(pendingKey, '');
|
|
1894
|
+
context.recordScopeId(appEnvAppliedScopeKey(entry.key), appEnv.applied);
|
|
1895
|
+
if (entry.uses?.auth) {
|
|
1896
|
+
context.recordScopeId(entryAuthEnvAppliedScopeKey(entry.key), appEnv.authApplied);
|
|
1897
|
+
}
|
|
1212
1898
|
if (dbPush.pushed)
|
|
1213
|
-
context.recordScopeId(
|
|
1899
|
+
context.recordScopeId(dbEnvAppliedScopeKey(entry.key), '1');
|
|
1214
1900
|
// #140 rework (2026-09-28 lettermint-org-mail swap): the hosted app's own
|
|
1215
1901
|
// Lettermint project, domain restriction and `MAIL_FROM` (only the app
|
|
1216
1902
|
// started by this same step, per the design's "only apps that send mail
|
|
1217
1903
|
// get it" -- cms and calls do not). A no-op until `coolify:mail` has
|
|
1218
1904
|
// actually resolved a mail domain for this org.
|
|
1219
|
-
|
|
1905
|
+
// One Lettermint project, `<slug>-app`, belongs to the `app` entry; a
|
|
1906
|
+
// second entry sharing the name would delete the first's live token.
|
|
1907
|
+
if (entry.key === APP_ENTRY_KEY && !entry.adopt) {
|
|
1908
|
+
await this.ensureAppLettermintMail(appId, org, context, Boolean(existing));
|
|
1909
|
+
}
|
|
1220
1910
|
return { resourceId: appId };
|
|
1221
1911
|
}
|
|
1222
1912
|
/**
|
|
@@ -1240,13 +1930,13 @@ export class FoundryStepExecutor {
|
|
|
1240
1930
|
* `createGithubAppApplication`'s own instant-deploy path, so a restart
|
|
1241
1931
|
* here would only be a redundant extra one).
|
|
1242
1932
|
*/
|
|
1243
|
-
async ensureLogVolume(appId, org, wasAlreadyRunning) {
|
|
1933
|
+
async ensureLogVolume(appId, org, wasAlreadyRunning, appName = org.slug) {
|
|
1244
1934
|
const storages = await this.ports.coolify.listStorages(appId);
|
|
1245
1935
|
const already = (storages.persistent_storages ?? []).some((s) => s.mount_path === LOG_DIR);
|
|
1246
1936
|
if (already)
|
|
1247
1937
|
return;
|
|
1248
1938
|
await this.ports.coolify.createPersistentStorage(appId, {
|
|
1249
|
-
name: `${
|
|
1939
|
+
name: `${appName}-logs`,
|
|
1250
1940
|
mount_path: LOG_DIR,
|
|
1251
1941
|
});
|
|
1252
1942
|
if (wasAlreadyRunning) {
|
|
@@ -1259,12 +1949,24 @@ export class FoundryStepExecutor {
|
|
|
1259
1949
|
if (!dbId) {
|
|
1260
1950
|
throw new Error(`coolify:database for ${org.slug}: no live database recorded, nothing to park`);
|
|
1261
1951
|
}
|
|
1952
|
+
if (await this.isAdopted(org, `coolify:database:${action.key}`, dbId)) {
|
|
1953
|
+
throw new Error(`coolify:database ${action.key}: adopted, not stopped`);
|
|
1954
|
+
}
|
|
1262
1955
|
await this.ports.coolify.raw('POST', `/databases/${dbId}/stop`);
|
|
1263
1956
|
return { resourceId: dbId };
|
|
1264
1957
|
}
|
|
1265
1958
|
const existing = context.recordedIds()[`coolify:database:${action.key}`];
|
|
1266
1959
|
let dbId;
|
|
1267
|
-
if (
|
|
1960
|
+
if (!org.hosting.adopt?.database) {
|
|
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) {
|
|
1268
1970
|
dbId = existing;
|
|
1269
1971
|
}
|
|
1270
1972
|
else {
|
|
@@ -1293,14 +1995,16 @@ export class FoundryStepExecutor {
|
|
|
1293
1995
|
// Handles the database being provisioned after the app already exists
|
|
1294
1996
|
// (`coolifyApp`'s own push handles the more common order, database
|
|
1295
1997
|
// before app on the same apply -- `diff.ts`'s `computePlanDetail`).
|
|
1296
|
-
const
|
|
1297
|
-
|
|
1998
|
+
const dbEntry = org.hosting.entries.find((e) => e.database);
|
|
1999
|
+
const appId = dbEntry && context.recordedIds()[`coolify:app:${appStepKey(org, dbEntry.key)}`];
|
|
2000
|
+
if (dbEntry && appId && !dbEntry.adopt && !org.hosting.adopt?.database) {
|
|
2001
|
+
const markerKey = dbEnvAppliedScopeKey(dbEntry.key);
|
|
1298
2002
|
const push = await this.pushDatabaseUrl(appId, dbId, org.slug.replace(/-/g, '_'));
|
|
1299
2003
|
if (push.pushed) {
|
|
1300
|
-
if (push.changed || !context.recordedIds()[
|
|
2004
|
+
if (push.changed || !context.recordedIds()[markerKey]) {
|
|
1301
2005
|
await this.ports.coolify.restart(appId);
|
|
1302
2006
|
}
|
|
1303
|
-
context.recordScopeId(
|
|
2007
|
+
context.recordScopeId(markerKey, '1');
|
|
1304
2008
|
}
|
|
1305
2009
|
}
|
|
1306
2010
|
return { resourceId: dbId };
|
|
@@ -1390,28 +2094,22 @@ export class FoundryStepExecutor {
|
|
|
1390
2094
|
* that call runs only once the `app` entry's `database` is declared and
|
|
1391
2095
|
* provisioned, where this one is unconditional (#239).
|
|
1392
2096
|
*/
|
|
1393
|
-
async pushAppEnv(appId, org, entry, context) {
|
|
2097
|
+
async pushAppEnv(appId, org, entry, context, authIssuer) {
|
|
1394
2098
|
const recorded = context.recordedIds();
|
|
1395
|
-
const envs =
|
|
1396
|
-
|
|
1397
|
-
|
|
1398
|
-
|
|
1399
|
-
|
|
1400
|
-
|
|
1401
|
-
|
|
1402
|
-
|
|
1403
|
-
|
|
1404
|
-
|
|
1405
|
-
|
|
1406
|
-
|
|
1407
|
-
|
|
1408
|
-
|
|
1409
|
-
// them its own owner in authz on first sign-in. `runSteps` records this
|
|
1410
|
-
// under the same `system:kind:key` shape every step's `resourceId` uses
|
|
1411
|
-
// (`zitadelOwner`'s own doc comment), not a scope id.
|
|
1412
|
-
const ownerUserId = recorded[`zitadel:owner:${org.id}`];
|
|
1413
|
-
if (ownerUserId)
|
|
1414
|
-
envs.push({ key: 'OWNER_USER_ID', value: ownerUserId });
|
|
2099
|
+
const envs = ownedAppEnv(org, entry, recorded, authIssuer);
|
|
2100
|
+
// An entry with `uses.auth` signs in one organisation, with its own client
|
|
2101
|
+
// and the issuer; any other entry keeps the org's staff organisation and
|
|
2102
|
+
// the one hosted-app client.
|
|
2103
|
+
const authEnv = entryAuthEnv(org, entry, recorded, authIssuer);
|
|
2104
|
+
if (entry.uses?.auth) {
|
|
2105
|
+
// A customers entry never falls back to the staff organisation's id.
|
|
2106
|
+
if (!authEnv.AUTH_ORGANIZATION_ID) {
|
|
2107
|
+
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
|
+
}
|
|
2109
|
+
const appKey = await this.ensureAuthAppKey(appId);
|
|
2110
|
+
if (appKey)
|
|
2111
|
+
envs.push({ key: 'AUTH_APP_KEY', value: appKey });
|
|
2112
|
+
}
|
|
1415
2113
|
// Idempotent the same way `pushDatabaseUrl`'s credential is treated:
|
|
1416
2114
|
// generated once, handed straight to `bulkUpsertEnvs` and nowhere else in
|
|
1417
2115
|
// this class -- never returned, recorded, logged or interpolated into a
|
|
@@ -1428,12 +2126,86 @@ export class FoundryStepExecutor {
|
|
|
1428
2126
|
const appEnv = resolveAppEnv(org, entry, recorded).values;
|
|
1429
2127
|
for (const [key, value] of Object.entries(appEnv))
|
|
1430
2128
|
envs.push({ key, value });
|
|
1431
|
-
|
|
2129
|
+
// An omitted flag is written as false, so on an adopted app a name that
|
|
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
|
+
}));
|
|
1432
2145
|
// `coolifyApp` records `applied` once the restart succeeded, so `planApp`
|
|
1433
2146
|
// replans this step when the declared map changes (rule 3). Ids and a
|
|
1434
2147
|
// domain, never a secret.
|
|
1435
2148
|
const applied = JSON.stringify(appEnv);
|
|
1436
|
-
|
|
2149
|
+
// The sign-in values, recorded by `coolifyApp` the same way; the key is
|
|
2150
|
+
// never part of them.
|
|
2151
|
+
const authApplied = JSON.stringify(authEnv);
|
|
2152
|
+
return {
|
|
2153
|
+
changed: applied !== (recorded[appEnvAppliedScopeKey(entry.key)] ?? '{}') ||
|
|
2154
|
+
(Boolean(entry.uses?.auth) &&
|
|
2155
|
+
authApplied !== (recorded[entryAuthEnvAppliedScopeKey(entry.key)] ?? '{}')),
|
|
2156
|
+
applied,
|
|
2157
|
+
authApplied,
|
|
2158
|
+
};
|
|
2159
|
+
}
|
|
2160
|
+
/**
|
|
2161
|
+
* F5: the app's `AUTH_APP_KEY`, minted once and only when the app has none
|
|
2162
|
+
* of at least 32 characters (the broker refuses a shorter one). `null` once
|
|
2163
|
+
* it has one: the app's env is where the key lives, and
|
|
2164
|
+
* `coolifyBrokerEntry` reads it back from there. It goes to this env and
|
|
2165
|
+
* the broker's, nowhere else.
|
|
2166
|
+
*/
|
|
2167
|
+
async ensureAuthAppKey(appId) {
|
|
2168
|
+
const envs = await this.ports.coolify.listEnvs(appId);
|
|
2169
|
+
const already = envs.some((e) => e.key === 'AUTH_APP_KEY' && !e.is_preview && (e.value ?? '').length >= 32);
|
|
2170
|
+
return already ? null : randomBytes(32).toString('base64url');
|
|
2171
|
+
}
|
|
2172
|
+
/**
|
|
2173
|
+
* `plan/diff.ts`'s `planBrokerEntries` (F5): the entry's `AUTH_APPS` entry,
|
|
2174
|
+
* written by `writeBrokerEntry` with the key `pushAppEnv` put in the app's
|
|
2175
|
+
* env. Records what it wrote (never the key) and any foreign entry the
|
|
2176
|
+
* broker would drop.
|
|
2177
|
+
*/
|
|
2178
|
+
async coolifyBrokerEntry(action, org, context) {
|
|
2179
|
+
const broker = this.ports.broker;
|
|
2180
|
+
if (!broker)
|
|
2181
|
+
throw new Error(`coolify:broker-entry for ${org.slug}: the broker port is not wired`);
|
|
2182
|
+
const recorded = context.recordedIds();
|
|
2183
|
+
const entry = org.hosting.entries.find((e) => appStepKey(org, e.key) === action.key);
|
|
2184
|
+
if (!entry?.uses?.auth) {
|
|
2185
|
+
throw new Error(`coolify:broker-entry for ${org.slug}: no uses.auth entry for ${action.key}`);
|
|
2186
|
+
}
|
|
2187
|
+
const wanted = wantedBrokerEntry(org, entry, recorded);
|
|
2188
|
+
if ('missing' in wanted) {
|
|
2189
|
+
throw new Error(`coolify:broker-entry for ${org.slug}: ${wanted.missing.map((m) => m.what).join(', ')} not recorded yet`);
|
|
2190
|
+
}
|
|
2191
|
+
const appId = recorded[`coolify:app:${action.key}`];
|
|
2192
|
+
if (!appId) {
|
|
2193
|
+
throw new Error(`coolify:broker-entry for ${org.slug}: app ${action.key} is not recorded yet`);
|
|
2194
|
+
}
|
|
2195
|
+
const appKey = (await this.ports.coolify.listEnvs(appId)).find((e) => e.key === 'AUTH_APP_KEY' && !e.is_preview)?.value;
|
|
2196
|
+
if (!appKey || appKey.length < 32) {
|
|
2197
|
+
throw new Error(`coolify:broker-entry for ${org.slug}: app ${appId} has no AUTH_APP_KEY yet`);
|
|
2198
|
+
}
|
|
2199
|
+
const previousRaw = recorded[brokerEntryAppliedScopeKey(entry.key)];
|
|
2200
|
+
const result = await writeBrokerEntry(broker, {
|
|
2201
|
+
want: wanted.want,
|
|
2202
|
+
appKey,
|
|
2203
|
+
previous: previousRaw ? JSON.parse(previousRaw) : null,
|
|
2204
|
+
});
|
|
2205
|
+
context.recordScopeId(brokerEntryWarningsScopeKey(entry.key), JSON.stringify(result.invalidForeign));
|
|
2206
|
+
// Last: a run that failed above plans the step again.
|
|
2207
|
+
context.recordScopeId(brokerEntryAppliedScopeKey(entry.key), JSON.stringify(wanted.want));
|
|
2208
|
+
return {};
|
|
1437
2209
|
}
|
|
1438
2210
|
/**
|
|
1439
2211
|
* `null` once the app already has one -- `AUTH_COOKIE_SECRET` is generated
|
|
@@ -2507,11 +3279,17 @@ export class FoundryStepExecutor {
|
|
|
2507
3279
|
}
|
|
2508
3280
|
// ---- cloudflare --------------------------------------------------------
|
|
2509
3281
|
async cloudflareTunnel(action, org, context) {
|
|
3282
|
+
const adoptedTunnel = org.hosting.adopt?.tunnel;
|
|
3283
|
+
const connectorApp = org.hosting.adopt?.connectorApp;
|
|
2510
3284
|
if (action.desired.enabled === false) {
|
|
2511
3285
|
const tunnelId = context.recordedIds()[`cloudflare:tunnel:${action.key}`];
|
|
2512
3286
|
if (!tunnelId) {
|
|
2513
3287
|
throw new Error(`cloudflare:tunnel for ${org.slug}: no live tunnel recorded, nothing to park`);
|
|
2514
3288
|
}
|
|
3289
|
+
// Parking clears the ingress and stops the connector: never for what was adopted (#312).
|
|
3290
|
+
if (await this.isAdopted(org, adoptedTunnelKey(org), tunnelId)) {
|
|
3291
|
+
throw new Error(`cloudflare:tunnel ${action.key}: adopted, not stopped`);
|
|
3292
|
+
}
|
|
2515
3293
|
// Cleared, not deleted (#7 corrections 1): an ingress with only the
|
|
2516
3294
|
// mandatory catch-all routes nothing.
|
|
2517
3295
|
await this.ports.cloudflare.putTunnelConfig(tunnelId, {
|
|
@@ -2532,6 +3310,17 @@ export class FoundryStepExecutor {
|
|
|
2532
3310
|
}
|
|
2533
3311
|
return { resourceId: tunnelId };
|
|
2534
3312
|
}
|
|
3313
|
+
// An adopted tunnel and connector are written to only under an active
|
|
3314
|
+
// snapshot; one that was adopted and is no longer declared is not taken as
|
|
3315
|
+
// foundry's own (#312).
|
|
3316
|
+
if (adoptedTunnel)
|
|
3317
|
+
await this.requireSnapshot(org, adoptedTunnelKey(org), adoptedTunnel);
|
|
3318
|
+
else
|
|
3319
|
+
await this.refuseTakeover(org, adoptedTunnelKey(org));
|
|
3320
|
+
if (connectorApp)
|
|
3321
|
+
await this.requireSnapshot(org, adoptedConnectorKey(org), connectorApp);
|
|
3322
|
+
else
|
|
3323
|
+
await this.refuseTakeover(org, adoptedConnectorKey(org));
|
|
2535
3324
|
const tunnelName = `${org.slug}-tunnel`;
|
|
2536
3325
|
// #236 round 5 finding 2 / round 6 finding 2: read, and (if it is about
|
|
2537
3326
|
// to change) added to the accumulated replaced-id set, before
|
|
@@ -2540,8 +3329,11 @@ export class FoundryStepExecutor {
|
|
|
2540
3329
|
// domain declared still leaves `destroy` a way to recognise the old
|
|
2541
3330
|
// id's CNAMEs as this tunnel's own rather than a foreign record.
|
|
2542
3331
|
const recordedTunnelId = context.recordedIds()[`cloudflare:tunnel:${action.key}`];
|
|
2543
|
-
|
|
2544
|
-
const tunnel =
|
|
3332
|
+
// An adopted tunnel is the one declared by id: never found by name, never created (#312).
|
|
3333
|
+
const tunnel = adoptedTunnel
|
|
3334
|
+
? { id: adoptedTunnel }
|
|
3335
|
+
: ((await this.ports.cloudflare.findTunnelByName(tunnelName)) ??
|
|
3336
|
+
(await this.ports.cloudflare.createTunnel(tunnelName)));
|
|
2545
3337
|
const previousIds = parseTunnelPreviousIds(context.recordedIds());
|
|
2546
3338
|
if (recordedTunnelId && recordedTunnelId !== tunnel.id) {
|
|
2547
3339
|
previousIds.add(recordedTunnelId);
|
|
@@ -2554,7 +3346,7 @@ export class FoundryStepExecutor {
|
|
|
2554
3346
|
// offline.tf's bootstrap, cited by
|
|
2555
3347
|
// https://github.com/wtfalch/foundry/issues/71): every app on the
|
|
2556
3348
|
// Coolify box already gets a Traefik route from the `domains` its
|
|
2557
|
-
// `coolify:app` step declared (`
|
|
3349
|
+
// `coolify:app` step declared (`appHostname(org)`, this file's
|
|
2558
3350
|
// `coolifyApp`), so a cloudflared connector joined to the same Docker
|
|
2559
3351
|
// network only has to reach Traefik and let it route by Host header --
|
|
2560
3352
|
// the connector never needs to know the app's own container name or
|
|
@@ -2564,13 +3356,18 @@ export class FoundryStepExecutor {
|
|
|
2564
3356
|
// (#115) gets one ingress entry per hostname `tunnelIngressHostnames`
|
|
2565
3357
|
// returns, all routed to the same proxy -- Traefik itself, not this list,
|
|
2566
3358
|
// is what tells the public site apart from the cms studio.
|
|
2567
|
-
|
|
2568
|
-
|
|
2569
|
-
|
|
2570
|
-
|
|
2571
|
-
|
|
2572
|
-
|
|
2573
|
-
|
|
3359
|
+
if (adoptedTunnel) {
|
|
3360
|
+
await this.mergeAdoptedIngress(org, tunnel.id);
|
|
3361
|
+
}
|
|
3362
|
+
else {
|
|
3363
|
+
const ingress = tunnelIngressHostnames(org).map((hostname) => ({
|
|
3364
|
+
hostname,
|
|
3365
|
+
service: COOLIFY_PROXY_ORIGIN,
|
|
3366
|
+
}));
|
|
3367
|
+
await this.ports.cloudflare.putTunnelConfig(tunnel.id, {
|
|
3368
|
+
ingress: [...ingress, { service: 'http_status:404' }],
|
|
3369
|
+
});
|
|
3370
|
+
}
|
|
2574
3371
|
// #235: a connector for this org's own tunnel, the same call every
|
|
2575
3372
|
// platform tunnel step already makes (Decided, William, 2026-10-01:
|
|
2576
3373
|
// one connector per org tunnel, never a shared tunnel with appended
|
|
@@ -2578,22 +3375,40 @@ export class FoundryStepExecutor {
|
|
|
2578
3375
|
// nothing is dialed into it, so `hostname` answers with a Cloudflare
|
|
2579
3376
|
// error -- #197's own gap, for an org's tunnel rather than a platform
|
|
2580
3377
|
// one.
|
|
2581
|
-
|
|
2582
|
-
|
|
2583
|
-
|
|
3378
|
+
// An adopted connector is the app declared by uuid: it needs no project to
|
|
3379
|
+
// be found in or created under (#312).
|
|
3380
|
+
let place;
|
|
3381
|
+
if (!connectorApp) {
|
|
3382
|
+
const projectUuid = await this.ensureCoolifyProject(org, context);
|
|
3383
|
+
const { environment } = await this.ports.coolify.findOrCreateEnvironment(projectUuid, 'production');
|
|
3384
|
+
place = { projectUuid, environment };
|
|
3385
|
+
}
|
|
3386
|
+
else {
|
|
3387
|
+
context.recordScopeId(CLOUDFLARE_TUNNEL_CONNECTOR_APP_SCOPE_KEY, connectorApp);
|
|
3388
|
+
}
|
|
3389
|
+
await this.guarded(Boolean(adoptedTunnel), `cloudflare:tunnel for ${org.slug}: updating the connector of tunnel ${tunnel.id}`, () => ensureCloudflaredConnector({
|
|
2584
3390
|
coolify: this.ports.coolify,
|
|
2585
3391
|
cloudflare: this.ports.cloudflare,
|
|
2586
3392
|
tunnelId: tunnel.id,
|
|
2587
3393
|
appName: `${org.slug}-cloudflared`,
|
|
2588
|
-
|
|
2589
|
-
|
|
3394
|
+
...place,
|
|
3395
|
+
adopted: Boolean(connectorApp),
|
|
3396
|
+
// Restore redeploys a connector whose token foundry rewrote, so this is recorded first.
|
|
3397
|
+
// The digest tells restore the live token is foundry's; the token itself is never stored.
|
|
3398
|
+
beforeTokenWrite: connectorApp
|
|
3399
|
+
? (token) => this.updateAdoptionSettings(org, adoptedConnectorKey(org), (settings) => ({
|
|
3400
|
+
...settings,
|
|
3401
|
+
tokenWritten: true,
|
|
3402
|
+
tokenDigest: sha256Hex(token),
|
|
3403
|
+
}))
|
|
3404
|
+
: undefined,
|
|
2590
3405
|
resolveServer: () => this.resolveServer(),
|
|
2591
3406
|
recordedConnectorAppId: context.recordedIds()[CLOUDFLARE_TUNNEL_CONNECTOR_APP_SCOPE_KEY] ?? null,
|
|
2592
3407
|
recordAppId: async (appId) => {
|
|
2593
3408
|
context.recordScopeId(CLOUDFLARE_TUNNEL_CONNECTOR_APP_SCOPE_KEY, appId);
|
|
2594
3409
|
await context.persistScopeIds();
|
|
2595
3410
|
},
|
|
2596
|
-
});
|
|
3411
|
+
}));
|
|
2597
3412
|
// #236: an org with no domain (a level-1 org whose app still serves off
|
|
2598
3413
|
// the shared `*.oikos.host` wildcard tunnel, `scripts/shared-ingress.mjs`)
|
|
2599
3414
|
// has no zone of its own and nothing here to publish -- `org.domain` is
|
|
@@ -2626,12 +3441,14 @@ export class FoundryStepExecutor {
|
|
|
2626
3441
|
// failure leaves the *old* domain recorded, so a retry sees the same
|
|
2627
3442
|
// domain change again and tries cleanup again.
|
|
2628
3443
|
if (org.domain) {
|
|
3444
|
+
const { domain } = org;
|
|
2629
3445
|
const recordedBefore = context.recordedIds();
|
|
2630
3446
|
const previousDomain = recordedBefore[CLOUDFLARE_TUNNEL_DNS_DOMAIN_SCOPE_KEY];
|
|
2631
3447
|
const previousHostnamesRaw = recordedBefore[CLOUDFLARE_TUNNEL_DNS_HOSTNAMES_SCOPE_KEY];
|
|
2632
3448
|
const previousHostnames = previousHostnamesRaw ? previousHostnamesRaw.split(',') : [];
|
|
2633
3449
|
const domainChanged = previousDomain !== undefined && previousDomain !== org.domain;
|
|
2634
|
-
const
|
|
3450
|
+
const dnsFailed = `cloudflare:tunnel for ${org.slug}: publishing DNS for tunnel ${tunnel.id}`;
|
|
3451
|
+
const publishedHostnames = await this.guarded(Boolean(adoptedTunnel), dnsFailed, () => this.publishTunnelDnsRecords(org, domain, tunnel.id,
|
|
2635
3452
|
// Round 6 finding 2: every id this step has ever replaced, not only
|
|
2636
3453
|
// the one `recordedTunnelId` captured above -- see the scope key's
|
|
2637
3454
|
// own doc comment.
|
|
@@ -2641,9 +3458,13 @@ export class FoundryStepExecutor {
|
|
|
2641
3458
|
// needs the domain's own previous hostname list for that. A domain
|
|
2642
3459
|
// change instead cleans the *whole* old domain below, so it gets an
|
|
2643
3460
|
// empty list here rather than a second, redundant pass over it.
|
|
2644
|
-
domainChanged ? [] : previousHostnames, context);
|
|
3461
|
+
domainChanged ? [] : previousHostnames, context));
|
|
2645
3462
|
if (domainChanged) {
|
|
2646
|
-
await this.deleteStaleTunnelDnsRecords(tunnel.id, previousIds, previousDomain, previousHostnames, context
|
|
3463
|
+
await this.guarded(Boolean(adoptedTunnel), dnsFailed, async () => this.deleteStaleTunnelDnsRecords(tunnel.id, previousIds, previousDomain, previousHostnames, context,
|
|
3464
|
+
// An adopted tunnel's CNAME that foundry did not create is never deleted.
|
|
3465
|
+
adoptedTunnel
|
|
3466
|
+
? new Set(createdDnsRecords((await this.ports.adoptionStore?.get(org.id, adoptedTunnelKey(org)))?.settings).map((r) => r.id))
|
|
3467
|
+
: undefined));
|
|
2647
3468
|
}
|
|
2648
3469
|
context.recordScopeId(CLOUDFLARE_TUNNEL_DNS_DOMAIN_SCOPE_KEY, org.domain);
|
|
2649
3470
|
context.recordScopeId(CLOUDFLARE_TUNNEL_DNS_HOSTNAMES_SCOPE_KEY, publishedHostnames.join(','));
|
|
@@ -2671,6 +3492,80 @@ export class FoundryStepExecutor {
|
|
|
2671
3492
|
}
|
|
2672
3493
|
return { resourceId: tunnel.id };
|
|
2673
3494
|
}
|
|
3495
|
+
/**
|
|
3496
|
+
* Runs `call`; for an adopted resource a failure that is not one of this
|
|
3497
|
+
* file's own messages (which name hostnames and record types only) is
|
|
3498
|
+
* replaced by `label`, because a client's error text carries response
|
|
3499
|
+
* bodies (rule 6, #312).
|
|
3500
|
+
*/
|
|
3501
|
+
async guarded(adopted, label, call) {
|
|
3502
|
+
if (!adopted)
|
|
3503
|
+
return call();
|
|
3504
|
+
try {
|
|
3505
|
+
return await call();
|
|
3506
|
+
}
|
|
3507
|
+
catch (error) {
|
|
3508
|
+
if (error instanceof Error && error.message.startsWith('cloudflare:tunnel for '))
|
|
3509
|
+
throw error;
|
|
3510
|
+
throw new Error(`${label} failed`);
|
|
3511
|
+
}
|
|
3512
|
+
}
|
|
3513
|
+
/**
|
|
3514
|
+
* #312: writes the org's routes into the adopted tunnel's LIVE ingress list
|
|
3515
|
+
* (`mergeIngress`), not the snapshot, so a route a person added since is
|
|
3516
|
+
* kept too. The declared names go into the tunnel row's `written` only after
|
|
3517
|
+
* the write that carried them succeeded, so a crash in between leaves a
|
|
3518
|
+
* route taken as foreign and kept. Writes nothing when the merged list is the
|
|
3519
|
+
* live one. Errors name the tunnel id and the operation only.
|
|
3520
|
+
*/
|
|
3521
|
+
async mergeAdoptedIngress(org, tunnelId) {
|
|
3522
|
+
const cf = this.ports.cloudflare;
|
|
3523
|
+
const failed = (operation) => new Error(`cloudflare:tunnel for ${org.slug}: ${operation} of tunnel ${tunnelId} failed`);
|
|
3524
|
+
let live;
|
|
3525
|
+
try {
|
|
3526
|
+
live = (await cf.getTunnelConfig(tunnelId)).config;
|
|
3527
|
+
if (live && !Array.isArray(live.ingress))
|
|
3528
|
+
throw new Error('not a list');
|
|
3529
|
+
}
|
|
3530
|
+
catch {
|
|
3531
|
+
throw failed('reading the ingress');
|
|
3532
|
+
}
|
|
3533
|
+
const declared = tunnelIngressHostnames(org);
|
|
3534
|
+
const row = await this.ports.adoptionStore?.get(org.id, adoptedTunnelKey(org));
|
|
3535
|
+
// A name in `writing` may have been carried by a put whose row write never landed.
|
|
3536
|
+
const written = new Set([...writtenIngressNames(row), ...writingIngressNames(row)]);
|
|
3537
|
+
const snapshot = Array.isArray(row?.settings.ingress) ? row.settings.ingress : [];
|
|
3538
|
+
const merged = mergeIngress(live?.ingress ?? [], declared, COOLIFY_PROXY_ORIGIN, written, snapshot);
|
|
3539
|
+
try {
|
|
3540
|
+
const put = !isDeepStrictEqual(merged, live?.ingress ?? []);
|
|
3541
|
+
if (put) {
|
|
3542
|
+
// Recorded before the put: a failed row write after it must not strand foundry's routes.
|
|
3543
|
+
await this.updateAdoptionSettings(org, adoptedTunnelKey(org), (settings) => ({
|
|
3544
|
+
...settings,
|
|
3545
|
+
writing: [...new Set([...writingIngressNames(row), ...declared])],
|
|
3546
|
+
}));
|
|
3547
|
+
await cf.putTunnelConfig(tunnelId, { ...live, ingress: merged });
|
|
3548
|
+
}
|
|
3549
|
+
if (put || declared.some((hostname) => !written.has(hostname))) {
|
|
3550
|
+
await this.updateAdoptionSettings(org, adoptedTunnelKey(org), (settings) => ({
|
|
3551
|
+
...settings,
|
|
3552
|
+
written: [...new Set([...written, ...declared])],
|
|
3553
|
+
writing: [],
|
|
3554
|
+
}));
|
|
3555
|
+
}
|
|
3556
|
+
}
|
|
3557
|
+
catch {
|
|
3558
|
+
throw failed('writing the ingress');
|
|
3559
|
+
}
|
|
3560
|
+
}
|
|
3561
|
+
/** #312: replaces the `settings` of an adoption row, read fresh so no other key is lost. The state and `takenAt` stay. */
|
|
3562
|
+
async updateAdoptionSettings(org, resourceKey, update) {
|
|
3563
|
+
const { adoptionStore: store } = this.ports;
|
|
3564
|
+
const row = await store?.get(org.id, resourceKey);
|
|
3565
|
+
if (!store || !row)
|
|
3566
|
+
throw new Error('no adoption row to record in');
|
|
3567
|
+
await store.setSettings(org.id, resourceKey, update(row.settings));
|
|
3568
|
+
}
|
|
2674
3569
|
/**
|
|
2675
3570
|
* #236: when this tunnel's DNS was last published under a domain that
|
|
2676
3571
|
* differs from the one `org` now declares, deletes every CNAME in the
|
|
@@ -2712,7 +3607,7 @@ export class FoundryStepExecutor {
|
|
|
2712
3607
|
* hostnames) never added entries of its own to begin with, so there is
|
|
2713
3608
|
* nothing of this domain's left in that set either way.
|
|
2714
3609
|
*/
|
|
2715
|
-
async deleteStaleTunnelDnsRecords(tunnelId, previousIds, previousDomain, previousHostnames, context) {
|
|
3610
|
+
async deleteStaleTunnelDnsRecords(tunnelId, previousIds, previousDomain, previousHostnames, context, onlyIds) {
|
|
2716
3611
|
const zone = await this.ports.cloudflare.findZoneByApex(previousDomain);
|
|
2717
3612
|
if (!zone)
|
|
2718
3613
|
return;
|
|
@@ -2726,7 +3621,7 @@ export class FoundryStepExecutor {
|
|
|
2726
3621
|
? (await Promise.all(previousHostnames.map((hostname) => cf.findDnsRecord(zone.id, hostname, 'CNAME')))).flat()
|
|
2727
3622
|
: await cf.listDnsRecords(zone.id, { type: 'CNAME' });
|
|
2728
3623
|
for (const record of records) {
|
|
2729
|
-
if (dnsTargets.has(record.content)) {
|
|
3624
|
+
if (dnsTargets.has(record.content) && (!onlyIds || onlyIds.has(record.id))) {
|
|
2730
3625
|
await cf.deleteDnsRecord(zone.id, record.id);
|
|
2731
3626
|
}
|
|
2732
3627
|
}
|
|
@@ -2800,12 +3695,15 @@ export class FoundryStepExecutor {
|
|
|
2800
3695
|
if (!zone) {
|
|
2801
3696
|
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`);
|
|
2802
3697
|
}
|
|
3698
|
+
const adopted = Boolean(org.hosting.adopt?.tunnel);
|
|
3699
|
+
const tunnelRow = () => this.ports.adoptionStore?.get(org.id, adoptedTunnelKey(org));
|
|
2803
3700
|
const dnsTarget = `${tunnelId}.cfargotunnel.com`;
|
|
2804
3701
|
const previousTargets = new Set([...previousIds].filter((id) => id !== tunnelId).map((id) => `${id}.cfargotunnel.com`));
|
|
2805
|
-
const hostnames =
|
|
3702
|
+
const hostnames = tunnelDnsHostnames(org, domain);
|
|
2806
3703
|
const droppedHostnames = previousHostnames.filter((hostname) => !hostnames.includes(hostname));
|
|
2807
3704
|
const conflicts = [];
|
|
2808
3705
|
const toCreate = [];
|
|
3706
|
+
const toClaim = [];
|
|
2809
3707
|
const toRepoint = [];
|
|
2810
3708
|
for (const hostname of hostnames) {
|
|
2811
3709
|
const [a] = await cf.findDnsRecord(zone.id, hostname, 'A');
|
|
@@ -2826,6 +3724,11 @@ export class FoundryStepExecutor {
|
|
|
2826
3724
|
}
|
|
2827
3725
|
if (!cname)
|
|
2828
3726
|
toCreate.push(hostname);
|
|
3727
|
+
// A crash after the create left a CNAME to the tunnel that is foundry's.
|
|
3728
|
+
else if (adopted &&
|
|
3729
|
+
dnsCreatingNames((await tunnelRow())?.settings).some((n) => normalizeHostname(n) === normalizeHostname(hostname))) {
|
|
3730
|
+
toClaim.push({ hostname, recordId: cname.id });
|
|
3731
|
+
}
|
|
2829
3732
|
}
|
|
2830
3733
|
if (conflicts.length > 0) {
|
|
2831
3734
|
throw new Error(`cloudflare:tunnel for ${org.slug}: will not point its tunnel at every hostname -- ${conflicts.join('; ')}; remove the conflicting record first`);
|
|
@@ -2846,13 +3749,43 @@ export class FoundryStepExecutor {
|
|
|
2846
3749
|
};
|
|
2847
3750
|
for (const hostname of settled)
|
|
2848
3751
|
recordTracked(hostname);
|
|
3752
|
+
// The row learns of a record foundry made: `dnsCreating` first, then `dnsRecords` with its id.
|
|
3753
|
+
const recordCreated = (hostname, recordId) => this.updateAdoptionSettings(org, adoptedTunnelKey(org), (settings) => ({
|
|
3754
|
+
...settings,
|
|
3755
|
+
dnsRecords: [...createdDnsRecords(settings), { zoneId: zone.id, id: recordId, hostname }],
|
|
3756
|
+
dnsCreating: dnsCreatingNames(settings).filter((n) => normalizeHostname(n) !== normalizeHostname(hostname)),
|
|
3757
|
+
}));
|
|
3758
|
+
for (const { hostname, recordId } of toClaim)
|
|
3759
|
+
await recordCreated(hostname, recordId);
|
|
2849
3760
|
for (const hostname of toCreate) {
|
|
2850
|
-
|
|
2851
|
-
|
|
2852
|
-
|
|
2853
|
-
|
|
2854
|
-
|
|
2855
|
-
}
|
|
3761
|
+
if (adopted) {
|
|
3762
|
+
await this.updateAdoptionSettings(org, adoptedTunnelKey(org), (settings) => ({
|
|
3763
|
+
...settings,
|
|
3764
|
+
dnsCreating: [...new Set([...dnsCreatingNames(settings), hostname])],
|
|
3765
|
+
}));
|
|
3766
|
+
}
|
|
3767
|
+
let created;
|
|
3768
|
+
try {
|
|
3769
|
+
created = await cf.createDnsRecord(zone.id, {
|
|
3770
|
+
type: 'CNAME',
|
|
3771
|
+
name: hostname,
|
|
3772
|
+
content: dnsTarget,
|
|
3773
|
+
proxied: true,
|
|
3774
|
+
});
|
|
3775
|
+
}
|
|
3776
|
+
catch (error) {
|
|
3777
|
+
// The call answered with an error: no record of foundry's, so a later one is a person's.
|
|
3778
|
+
// A timeout or network failure may have created it: `dnsCreating` stays for the claim.
|
|
3779
|
+
if (adopted && isApiError(error)) {
|
|
3780
|
+
await this.updateAdoptionSettings(org, adoptedTunnelKey(org), (settings) => ({
|
|
3781
|
+
...settings,
|
|
3782
|
+
dnsCreating: dnsCreatingNames(settings).filter((n) => normalizeHostname(n) !== normalizeHostname(hostname)),
|
|
3783
|
+
})).catch(() => { });
|
|
3784
|
+
}
|
|
3785
|
+
throw error;
|
|
3786
|
+
}
|
|
3787
|
+
if (adopted)
|
|
3788
|
+
await recordCreated(hostname, created.id);
|
|
2856
3789
|
recordTracked(hostname);
|
|
2857
3790
|
}
|
|
2858
3791
|
for (const { hostname, recordId } of toRepoint) {
|
|
@@ -2876,7 +3809,10 @@ export class FoundryStepExecutor {
|
|
|
2876
3809
|
// way.
|
|
2877
3810
|
for (const hostname of droppedHostnames) {
|
|
2878
3811
|
const [cname] = await cf.findDnsRecord(zone.id, hostname, 'CNAME');
|
|
2879
|
-
|
|
3812
|
+
// An adopted tunnel's CNAME is deleted only if foundry created it.
|
|
3813
|
+
const mine = !adopted ||
|
|
3814
|
+
createdDnsRecords((await tunnelRow())?.settings).some((r) => r.id === cname?.id);
|
|
3815
|
+
if (cname && mine && (cname.content === dnsTarget || previousTargets.has(cname.content))) {
|
|
2880
3816
|
await cf.deleteDnsRecord(zone.id, cname.id);
|
|
2881
3817
|
}
|
|
2882
3818
|
tracked.delete(trackedTunnelDnsEntry(domain, hostname));
|
|
@@ -2975,9 +3911,9 @@ export class FoundryStepExecutor {
|
|
|
2975
3911
|
// #177: what this step wrote from the platform, so `planMail` can see a later change.
|
|
2976
3912
|
context.recordScopeId(ORG_MAIL_APPLIED_APEX_SCOPE_KEY, options.apex);
|
|
2977
3913
|
context.recordScopeId(ORG_MAIL_APPLIED_HOST_SCOPE_KEY, options.mailHost);
|
|
2978
|
-
await this.ensureOwnerMailbox(org, mailOrg, context);
|
|
3914
|
+
await this.ensureOwnerMailbox(org, mailOrg, options, context);
|
|
2979
3915
|
await this.ensureOrgLettermintRelay(org, mailOrg, result.domain, context);
|
|
2980
|
-
await this.ensureRoleMailboxes(org, mailOrg, result.domain, context);
|
|
3916
|
+
await this.ensureRoleMailboxes(org, mailOrg, result.domain, options, context);
|
|
2981
3917
|
return { resourceId: result.domain };
|
|
2982
3918
|
}
|
|
2983
3919
|
/**
|
|
@@ -3018,7 +3954,7 @@ export class FoundryStepExecutor {
|
|
|
3018
3954
|
* so a second apply never calls `MailPort.createMailbox` again, rather
|
|
3019
3955
|
* than relying on that port method's own duplicate refusal.
|
|
3020
3956
|
*/
|
|
3021
|
-
async ensureOwnerMailbox(org, mailOrg, context) {
|
|
3957
|
+
async ensureOwnerMailbox(org, mailOrg, options, context) {
|
|
3022
3958
|
if (!org.owner)
|
|
3023
3959
|
return;
|
|
3024
3960
|
const address = org.mail.ownerMailboxAddress ?? null;
|
|
@@ -3029,7 +3965,7 @@ export class FoundryStepExecutor {
|
|
|
3029
3965
|
await this.ports.mail.createMailbox(mailOrg, {
|
|
3030
3966
|
address,
|
|
3031
3967
|
name: `${org.owner.givenName} ${org.owner.familyName}`,
|
|
3032
|
-
});
|
|
3968
|
+
}, options);
|
|
3033
3969
|
context.recordScopeId(MAIL_OWNER_MAILBOX_SCOPE_KEY, address);
|
|
3034
3970
|
}
|
|
3035
3971
|
/**
|
|
@@ -3042,7 +3978,7 @@ export class FoundryStepExecutor {
|
|
|
3042
3978
|
* apply (or a retry after a crash) skips what exists rather than relying
|
|
3043
3979
|
* on the mail server's own duplicate refusal.
|
|
3044
3980
|
*/
|
|
3045
|
-
async ensureRoleMailboxes(org, mailOrg, domain, context) {
|
|
3981
|
+
async ensureRoleMailboxes(org, mailOrg, domain, options, context) {
|
|
3046
3982
|
const declared = org.mail.roleMailboxes ?? [];
|
|
3047
3983
|
if (declared.length === 0)
|
|
3048
3984
|
return;
|
|
@@ -3054,7 +3990,7 @@ export class FoundryStepExecutor {
|
|
|
3054
3990
|
if (created.has(entry))
|
|
3055
3991
|
continue;
|
|
3056
3992
|
try {
|
|
3057
|
-
await this.ports.mail.createMailbox(mailOrg, { address, name: local });
|
|
3993
|
+
await this.ports.mail.createMailbox(mailOrg, { address, name: local }, options);
|
|
3058
3994
|
}
|
|
3059
3995
|
catch (error) {
|
|
3060
3996
|
// A crash between create and record leaves the mailbox made but
|
|
@@ -3316,12 +4252,16 @@ export class FoundryStepExecutor {
|
|
|
3316
4252
|
return;
|
|
3317
4253
|
}
|
|
3318
4254
|
case 'cloudflare:tunnel': {
|
|
4255
|
+
// An adopted tunnel and connector are never deleted (#312).
|
|
4256
|
+
if (await this.isAdopted(org, adoptedTunnelKey(org), target.id))
|
|
4257
|
+
return;
|
|
3319
4258
|
// #235: the connector app first, its own 404 handled locally --
|
|
3320
4259
|
// a retry where this already succeeded must still reach
|
|
3321
4260
|
// `deleteTunnel` below, which the outer catch's own 404 handling
|
|
3322
4261
|
// would otherwise short-circuit before it ran.
|
|
3323
4262
|
const connectorAppId = recordedIds[CLOUDFLARE_TUNNEL_CONNECTOR_APP_SCOPE_KEY];
|
|
3324
|
-
if (connectorAppId
|
|
4263
|
+
if (connectorAppId &&
|
|
4264
|
+
!(await this.isAdopted(org, adoptedConnectorKey(org), connectorAppId))) {
|
|
3325
4265
|
try {
|
|
3326
4266
|
await this.ports.coolify.deleteApplication(connectorAppId);
|
|
3327
4267
|
}
|
|
@@ -3406,9 +4346,13 @@ export class FoundryStepExecutor {
|
|
|
3406
4346
|
await this.ports.cloudflare.deleteAccessApp(target.id);
|
|
3407
4347
|
return;
|
|
3408
4348
|
case 'coolify:app':
|
|
4349
|
+
if (declaredAdoptUuids(org).has(target.id))
|
|
4350
|
+
return;
|
|
3409
4351
|
await this.ports.coolify.deleteApplication(target.id);
|
|
3410
4352
|
return;
|
|
3411
4353
|
case 'coolify:database':
|
|
4354
|
+
if (declaredAdoptUuids(org).has(target.id))
|
|
4355
|
+
return;
|
|
3412
4356
|
await this.ports.coolify.deleteDatabase(target.id);
|
|
3413
4357
|
return;
|
|
3414
4358
|
case 'zitadel:app': {
|
|
@@ -3469,3 +4413,10 @@ export function isNotFoundError(error) {
|
|
|
3469
4413
|
const message = error instanceof Error ? error.message : String(error);
|
|
3470
4414
|
return /^\S+ \S+ \S+ → 404:/.test(message);
|
|
3471
4415
|
}
|
|
4416
|
+
/** Whether the API answered with an error (a status), as opposed to a timeout or network failure. */
|
|
4417
|
+
function isApiError(error) {
|
|
4418
|
+
if (typeof error?.status === 'number')
|
|
4419
|
+
return true;
|
|
4420
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
4421
|
+
return /^\S+ \S+ \S+ → \d{3}:/.test(message);
|
|
4422
|
+
}
|