@wtfalch/foundry 0.22.0 → 0.22.1
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/plan/diff.js +32 -14
- package/dist/plan/executor.d.ts +12 -24
- package/dist/plan/executor.js +72 -17
- package/dist/plan/hostnames.d.ts +5 -3
- package/dist/plan/hostnames.js +8 -4
- package/dist/plan/login-host.d.ts +17 -2
- package/dist/plan/login-host.js +24 -3
- package/dist/plan/platform-providers.d.ts +2 -0
- package/dist/plan/platform-providers.js +2 -2
- package/package.json +1 -1
package/dist/plan/diff.js
CHANGED
|
@@ -7,7 +7,7 @@ import { planEntryAiKeys } from './entry-ai-key.js';
|
|
|
7
7
|
import { planEdgeReservedLabels, planEntryEdgeKeys } from './entry-edge-key.js';
|
|
8
8
|
import { planFilesKeys } from './entry-files-key.js';
|
|
9
9
|
import { appHostname, entryHostnames, tunnelDnsHostnames } from './hostnames.js';
|
|
10
|
-
import { LOGIN_BINDING_SCOPE_KEY, loginBindingState, loginHostClientIds, loginHostnameFor, pendingRemovalHosts, } from './login-host.js';
|
|
10
|
+
import { LOGIN_BINDING_SCOPE_KEY, companyLoginTunnelHost, loginBindingState, loginHostClientIds, loginHostnameFor, pendingRemovalHosts, } from './login-host.js';
|
|
11
11
|
import { MAIL_COPY_COMPLETED_SCOPE_KEY, MAIL_COPY_DONE_SCOPE_KEY, parseMailCopyCompleted, } from './mail-copy.js';
|
|
12
12
|
import { CALLS_MAS_APPLIED_IMAGE_TAG_SCOPE_KEY, CALLS_SYNAPSE_APPLIED_IMAGE_TAG_SCOPE_KEY, ORG_MAIL_APPLIED_APEX_SCOPE_KEY, ORG_MAIL_APPLIED_HOST_SCOPE_KEY, ORG_MAIL_APPLIED_MODE_SCOPE_KEY, ORG_MAIL_APPLIED_USES_DOMAIN_SCOPE_KEY, appEnvAppliedScopeKey, appSettingsOf, appStepKey, appliedAppDomains, appliedAppSettings, dbEnvAppliedScopeKey, resolveAppEnv, serviceDbEnvAppliedScopeKey, versionNotAppliedLine, } from './platform-diff.js';
|
|
13
13
|
import { DEFAULT_ORG_PLATFORM_READINESS, } from './platform-org.js';
|
|
@@ -102,7 +102,7 @@ export function computePlanDetail(org, live, recordedIds = {}, agoraHost = NO_AG
|
|
|
102
102
|
if (loginHost.step)
|
|
103
103
|
plannedSteps.push(loginHost.step);
|
|
104
104
|
// Planned ahead of everything that needs the zone live (#238); its own step
|
|
105
|
-
// is
|
|
105
|
+
// is pushed just below, before mail.
|
|
106
106
|
const zone = planZone(org, byKey, claimed);
|
|
107
107
|
const zoneWaiting = zone.blocked !== null;
|
|
108
108
|
const zoneBlocked = [];
|
|
@@ -110,6 +110,15 @@ export function computePlanDetail(org, live, recordedIds = {}, agoraHost = NO_AG
|
|
|
110
110
|
const dnsBlocked = [];
|
|
111
111
|
// #553: a ZITADEL app provision whose app id is recorded needs the typed confirmation.
|
|
112
112
|
const guardApp = (step) => guardAppProvision(step, org, recordedIds);
|
|
113
|
+
// #567: before the first step that reads the zone (mail, the tunnel, the
|
|
114
|
+
// rate limit), so a domain with no zone yet gets it provisioned first.
|
|
115
|
+
const zoneReason = zone.step ? edgeNeeded(platformReadiness, ['zones']) : null;
|
|
116
|
+
if (zone.step && zoneReason) {
|
|
117
|
+
dnsBlocked.push(edgeBlockedItem(org, 'Cloudflare zone', 'cloudflare:zone', zoneReason));
|
|
118
|
+
}
|
|
119
|
+
else if (zone.step) {
|
|
120
|
+
plannedSteps.push(zone.step);
|
|
121
|
+
}
|
|
113
122
|
const mail = planMail(org, byKey, claimed, recordedIds, platformReadiness.mailHost, platformReadiness.platformApex, platformReadiness.platformLettermintProjectId);
|
|
114
123
|
// #238: own-domain mail publishes DNS records into the zone, so it waits for
|
|
115
124
|
// an active zone -- blocked, not planned (rule 2).
|
|
@@ -241,7 +250,7 @@ export function computePlanDetail(org, live, recordedIds = {}, agoraHost = NO_AG
|
|
|
241
250
|
const edgeReservedLabels = planEdgeReservedLabels(org, recordedIds, platformReadiness, options.platformHostnames ?? []);
|
|
242
251
|
if (edgeReservedLabels.step)
|
|
243
252
|
plannedSteps.push(edgeReservedLabels.step);
|
|
244
|
-
const tunnel = planTunnel(org, byKey, claimed, recordedIds);
|
|
253
|
+
const tunnel = planTunnel(org, byKey, claimed, recordedIds, isPlatformOrg, platformReadiness.platformApex);
|
|
245
254
|
// #238: a tunnel that is being provisioned waits for an active zone; parking
|
|
246
255
|
// one never does.
|
|
247
256
|
// #555: provisioning writes the tunnel config, the zone lookup and DNS; a
|
|
@@ -267,13 +276,6 @@ export function computePlanDetail(org, live, recordedIds = {}, agoraHost = NO_AG
|
|
|
267
276
|
else if (tunnel) {
|
|
268
277
|
plannedSteps.push(tunnel);
|
|
269
278
|
}
|
|
270
|
-
const zoneReason = zone.step ? edgeNeeded(platformReadiness, ['zones']) : null;
|
|
271
|
-
if (zone.step && zoneReason) {
|
|
272
|
-
dnsBlocked.push(edgeBlockedItem(org, 'Cloudflare zone', 'cloudflare:zone', zoneReason));
|
|
273
|
-
}
|
|
274
|
-
else if (zone.step) {
|
|
275
|
-
plannedSteps.push(zone.step);
|
|
276
|
-
}
|
|
277
279
|
// Right after the zone (#43): `cloudflareRateLimit` resolves the zone by
|
|
278
280
|
// apex domain, so the zone step above must already have run (or the zone
|
|
279
281
|
// must already exist live) by the time this one's action executes.
|
|
@@ -741,7 +743,7 @@ function planLoginHost(org, recordedIds, authReady, issuerPlanned, bindingsWired
|
|
|
741
743
|
// nothing else here would replan the step to retry it.
|
|
742
744
|
if (recordedIds[key] === host &&
|
|
743
745
|
recordedIds[LOGIN_BINDING_SCOPE_KEY] ===
|
|
744
|
-
loginBindingState(host, loginHostClientIds(recordedIds)) &&
|
|
746
|
+
loginBindingState(host, loginHostClientIds(org, recordedIds)) &&
|
|
745
747
|
pendingRemovalHosts(recordedIds).length === 0) {
|
|
746
748
|
return none;
|
|
747
749
|
}
|
|
@@ -2432,7 +2434,7 @@ function sortedNames(names) {
|
|
|
2432
2434
|
* domain) plans no per-org tunnel for Agora: `<slug>-agora.oikos.host` rides
|
|
2433
2435
|
* the shared `*.oikos.host` wildcard tunnel (`scripts/shared-ingress.mjs`).
|
|
2434
2436
|
*/
|
|
2435
|
-
function planTunnel(org, byKey, claimed, recordedIds) {
|
|
2437
|
+
function planTunnel(org, byKey, claimed, recordedIds, isPlatformOrg, platformApex) {
|
|
2436
2438
|
const desired = (org.hosting.tunnel && org.hosting.usesDomain && Boolean(org.domain)) ||
|
|
2437
2439
|
(org.site.enabled && Boolean(org.domain)) ||
|
|
2438
2440
|
(org.calls.enabled && org.calls.usesDomain && Boolean(org.domain)) ||
|
|
@@ -2449,6 +2451,16 @@ function planTunnel(org, byKey, claimed, recordedIds) {
|
|
|
2449
2451
|
return live.config.enabled !== false;
|
|
2450
2452
|
return desired ? status !== 'down' : status === 'healthy' || status === 'degraded';
|
|
2451
2453
|
});
|
|
2454
|
+
// #568: the company's own login host is routed by the step, so a first
|
|
2455
|
+
// provision carries it too; a park does not. Without a known platform apex
|
|
2456
|
+
// the platform's own org cannot be told apart, so none is planned.
|
|
2457
|
+
const loginHost = platformApex ? companyLoginTunnelHost(org, isPlatformOrg) : null;
|
|
2458
|
+
if (toggled && toggled.action.desired.enabled !== false && loginHost) {
|
|
2459
|
+
return {
|
|
2460
|
+
...toggled,
|
|
2461
|
+
action: { ...toggled.action, desired: { ...toggled.action.desired, loginHost } },
|
|
2462
|
+
};
|
|
2463
|
+
}
|
|
2452
2464
|
if (toggled || !desired)
|
|
2453
2465
|
return toggled;
|
|
2454
2466
|
// A live, enabled tunnel re-plans the same step again when either:
|
|
@@ -2472,12 +2484,18 @@ function planTunnel(org, byKey, claimed, recordedIds) {
|
|
|
2472
2484
|
const published = recordedIds[CLOUDFLARE_TUNNEL_DNS_HOSTNAMES_SCOPE_KEY];
|
|
2473
2485
|
const edgeChanged = published !== undefined &&
|
|
2474
2486
|
org.domain !== null &&
|
|
2475
|
-
sortedNames(published.split(',')) !==
|
|
2487
|
+
sortedNames(published.split(',')) !==
|
|
2488
|
+
sortedNames(tunnelDnsHostnames(org, org.domain, loginHost));
|
|
2476
2489
|
const dnsStale = recordedIds[CLOUDFLARE_TUNNEL_DNS_DOMAIN_SCOPE_KEY] !== org.domain || edgeChanged;
|
|
2477
2490
|
if (!connectorMissing && !dnsStale)
|
|
2478
2491
|
return null;
|
|
2479
2492
|
return {
|
|
2480
|
-
action: {
|
|
2493
|
+
action: {
|
|
2494
|
+
system: 'cloudflare',
|
|
2495
|
+
kind: 'tunnel',
|
|
2496
|
+
key: org.id,
|
|
2497
|
+
desired: loginHost ? { enabled: true, loginHost } : { enabled: true },
|
|
2498
|
+
},
|
|
2481
2499
|
step: {
|
|
2482
2500
|
description: connectorMissing
|
|
2483
2501
|
? `run a cloudflared connector for ${org.slug}'s Cloudflare tunnel`
|
package/dist/plan/executor.d.ts
CHANGED
|
@@ -13,27 +13,6 @@ import { type LoginHostBindingsPort } from './login-host.js';
|
|
|
13
13
|
import { type MailCopyPort } from './mail-copy.js';
|
|
14
14
|
import type { DestroyTarget, StepDestroyer, StepExecutionContext, StepExecutor } from './plan-apply.js';
|
|
15
15
|
import type { EdgePortName } from './platform-org.js';
|
|
16
|
-
/**
|
|
17
|
-
* The real {@link StepExecutor} (#20 "provision Company Factory end to
|
|
18
|
-
* end"): makes each {@link StepAction} `diff.ts` plans true against a real
|
|
19
|
-
* Coolify, Cloudflare and ZITADEL, plus per-org organisations, spend limits
|
|
20
|
-
* and (for AI) offers on Files and AI (#80). Scope and design are exactly
|
|
21
|
-
* what `.claude/decisions-2026-09-24-executor.md`'s "Executor decisions"
|
|
22
|
-
* section settled, referenced by name at each dispatch branch below.
|
|
23
|
-
*
|
|
24
|
-
* Built entirely from narrow ports (interfaces), never a concrete
|
|
25
|
-
* `@wtfalch/providers` client: this package holds no credential and never
|
|
26
|
-
* will (executor decisions: "the executor is constructed with narrow ports
|
|
27
|
-
* ... a host [archon] supplies real clients. How the host gets provider
|
|
28
|
-
* tokens from the keys Worker is the host's job, not this campaign's").
|
|
29
|
-
* Every port method here is named and shaped to match a real method on
|
|
30
|
-
* `@wtfalch/providers`' `CoolifyClient`/`CloudflareClient`/`ZitadelClient`
|
|
31
|
-
* (checked against `providers` `origin/main`, 2026-09-24), or on
|
|
32
|
-
* `@wtfalch/files`'/`@wtfalch/ai`'s own SDK clients (#80, checked against
|
|
33
|
-
* each repo's own `main`, 2026-09-25), so a real client satisfies the port
|
|
34
|
-
* structurally, with no adapter needed — see each port's own doc comment for
|
|
35
|
-
* the one real method it does not yet have.
|
|
36
|
-
*/
|
|
37
16
|
/**
|
|
38
17
|
* The Coolify calls this executor makes, shaped to match
|
|
39
18
|
* `@wtfalch/providers/coolify`'s `CoolifyClient` method-for-method — a real
|
|
@@ -54,10 +33,19 @@ export interface CoolifyPort {
|
|
|
54
33
|
findProjectByName(name: string): Promise<{
|
|
55
34
|
uuid: string;
|
|
56
35
|
name: string;
|
|
36
|
+
description?: string | null;
|
|
57
37
|
} | null>;
|
|
58
38
|
createProject(name: string, description?: string): Promise<{
|
|
59
39
|
uuid: string;
|
|
60
40
|
}>;
|
|
41
|
+
/**
|
|
42
|
+
* #569: what a project holds (`CoolifyClient.listResources`: its
|
|
43
|
+
* environments, applications and databases). Optional: without it an
|
|
44
|
+
* unmarked project cannot be shown empty, so it is refused.
|
|
45
|
+
*/
|
|
46
|
+
listResources?(projectUuid: string): Promise<Array<{
|
|
47
|
+
kind: string;
|
|
48
|
+
}>>;
|
|
61
49
|
findOrCreateEnvironment(projectUuid: string, name: string): Promise<{
|
|
62
50
|
environment: {
|
|
63
51
|
id: number;
|
|
@@ -1801,9 +1789,9 @@ export declare class FoundryStepExecutor implements StepExecutor, StepDestroyer
|
|
|
1801
1789
|
* container itself still writing nowhere). `wasAlreadyRunning` tells this
|
|
1802
1790
|
* method whether `appId` names an app `coolifyApp` found already there
|
|
1803
1791
|
* (restart it once the volume is created) or one it just created fresh
|
|
1804
|
-
* (
|
|
1805
|
-
*
|
|
1806
|
-
*
|
|
1792
|
+
* (`coolifyApp` deploys it after this call, #566, and that first start
|
|
1793
|
+
* carries the storage, so a restart here would only be a redundant extra
|
|
1794
|
+
* one).
|
|
1807
1795
|
*/
|
|
1808
1796
|
private ensureLogVolume;
|
|
1809
1797
|
private coolifyDatabase;
|
package/dist/plan/executor.js
CHANGED
|
@@ -21,12 +21,38 @@ import { BOULE_EDGE_ENTRY_KEY, EDGE_ENTRY_KEY_DAYS, EDGE_RESERVED_LABELS_APPLIED
|
|
|
21
21
|
import { FILES_AGORA_KEY_SCOPE_KEY, FILES_AGORA_UNDELIVERED_SCOPE_KEY, FILES_KEY_DAYS, FILES_KEY_RENEW_WINDOW_DAYS, FILES_ORG_OWNER_SCOPE_KEY, FILES_ROOT_EXPIRES_SCOPE_KEY, FILES_ROOT_PREVIOUS_SCOPE_KEY, FILES_ROOT_UNDELIVERED_SCOPE_KEY, FILES_ROOT_UNREVOKED_SCOPE_KEY, entryFilesAppliedScopeKey, entryFilesExpiresScopeKey, entryFilesMinterScopeKey, entryFilesPendingExpiresScopeKey, entryFilesRetiredScopeKey, entryFilesUndeliveredScopeKey, entryFilesUnrevokedScopeKey, entryFilesWrittenScopeKey, filesOwnerUserId, parseIdList as parseFilesIdList, } from './entry-files-key.js';
|
|
22
22
|
import { healthCheckSettings } from './health-check-settings.js';
|
|
23
23
|
import { appHostname, coolifyDomains, entryHostnames, tunnelDnsHostnames, tunnelIngressHostnames, } from './hostnames.js';
|
|
24
|
-
import { LOGIN_BINDING_SCOPE_KEY, LOGIN_HOST_PENDING_REMOVAL_SCOPE_KEY, LOGIN_HOST_SHARED_SCOPE_KEY, loginBindingState, loginHostClientIds, pendingRemovalHosts, pendingRemovalState, } from './login-host.js';
|
|
24
|
+
import { LOGIN_BINDING_SCOPE_KEY, LOGIN_HOST_PENDING_REMOVAL_SCOPE_KEY, LOGIN_HOST_SHARED_SCOPE_KEY, customersEntryKeys, loginBindingState, loginHostClientIds, pendingRemovalHosts, pendingRemovalState, } from './login-host.js';
|
|
25
25
|
import { MAIL_COPY_COMPLETED_SCOPE_KEY, MAIL_COPY_DONE_SCOPE_KEY, parseMailCopyCompleted, runMailCopyStep, } from './mail-copy.js';
|
|
26
26
|
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, ORG_MAIL_APPLIED_MODE_SCOPE_KEY, ORG_MAIL_APPLIED_USES_DOMAIN_SCOPE_KEY, appDomainsAppliedScopeKey, appEnvAppliedScopeKey, appNameFor, appSettingsAppliedScopeKey, appSettingsOf, appStepKey, appliedAppDomains, appliedAppSettings, dbEnvAppliedScopeKey, resolveAppEnv, serviceDbEnvAppliedScopeKey, } from './platform-diff.js';
|
|
27
|
-
import { PLATFORM_AUTH_BROKER_MAIL_KEY_REF } from './platform-providers.js';
|
|
27
|
+
import { AUTH_LOGIN_PORT, PLATFORM_AUTH_BROKER_MAIL_KEY_REF, PLATFORM_AUTH_LOGIN_INTERNAL_NAME, } from './platform-providers.js';
|
|
28
28
|
import { appLabelsAppliedScopeKey, entryLabelHash, entryLabelSpec, entryProxyLabels, sameLabelSets, } from './proxy-labels.js';
|
|
29
29
|
import { entryWorkerAppliedScopeKey, entryWorkerExclusionsScopeKey, entryWorkerRetiredScopeKey, entryWorkerRouteIdScopeKey, entryWorkerRoutePatternScopeKey, entryWorkerRoutePendingScopeKey, entryWorkerRouteZoneScopeKey, entryWorkerUploadedScopeKey, } from './worker.js';
|
|
30
|
+
/**
|
|
31
|
+
* The real {@link StepExecutor} (#20 "provision Company Factory end to
|
|
32
|
+
* end"): makes each {@link StepAction} `diff.ts` plans true against a real
|
|
33
|
+
* Coolify, Cloudflare and ZITADEL, plus per-org organisations, spend limits
|
|
34
|
+
* and (for AI) offers on Files and AI (#80). Scope and design are exactly
|
|
35
|
+
* what `.claude/decisions-2026-09-24-executor.md`'s "Executor decisions"
|
|
36
|
+
* section settled, referenced by name at each dispatch branch below.
|
|
37
|
+
*
|
|
38
|
+
* Built entirely from narrow ports (interfaces), never a concrete
|
|
39
|
+
* `@wtfalch/providers` client: this package holds no credential and never
|
|
40
|
+
* will (executor decisions: "the executor is constructed with narrow ports
|
|
41
|
+
* ... a host [archon] supplies real clients. How the host gets provider
|
|
42
|
+
* tokens from the keys Worker is the host's job, not this campaign's").
|
|
43
|
+
* Every port method here is named and shaped to match a real method on
|
|
44
|
+
* `@wtfalch/providers`' `CoolifyClient`/`CloudflareClient`/`ZitadelClient`
|
|
45
|
+
* (checked against `providers` `origin/main`, 2026-09-24), or on
|
|
46
|
+
* `@wtfalch/files`'/`@wtfalch/ai`'s own SDK clients (#80, checked against
|
|
47
|
+
* each repo's own `main`, 2026-09-25), so a real client satisfies the port
|
|
48
|
+
* structurally, with no adapter needed — see each port's own doc comment for
|
|
49
|
+
* the one real method it does not yet have.
|
|
50
|
+
*/
|
|
51
|
+
/** #569: the description foundry gives the Coolify project it creates for an org. */
|
|
52
|
+
const COOLIFY_PROJECT_MARK_PREFIX = 'foundry org ';
|
|
53
|
+
function coolifyProjectMark(org) {
|
|
54
|
+
return `${COOLIFY_PROJECT_MARK_PREFIX}${org.id}`;
|
|
55
|
+
}
|
|
30
56
|
/**
|
|
31
57
|
* #274 fix round 4: bridges `@wtfalch/providers`' real `ZitadelClient.
|
|
32
58
|
* listApps` -- a plain array, with no `loginBaseUri` or completeness signal
|
|
@@ -1330,7 +1356,7 @@ export class FoundryStepExecutor {
|
|
|
1330
1356
|
// The platform's own shared sign-in host: already trusted and routed by
|
|
1331
1357
|
// the shared instance, so it gets no trusted domain or binding here.
|
|
1332
1358
|
const shared = action.desired.shared === true;
|
|
1333
|
-
const clientIds = loginHostClientIds(recorded);
|
|
1359
|
+
const clientIds = loginHostClientIds(org, recorded);
|
|
1334
1360
|
if (!shared) {
|
|
1335
1361
|
await this.ports.zitadel.ensureTrustedDomain(host);
|
|
1336
1362
|
// zitadel-login's own routing table, written before any app is repointed
|
|
@@ -1340,13 +1366,16 @@ export class FoundryStepExecutor {
|
|
|
1340
1366
|
// Only apps whose client id the binding now holds: any other app in the
|
|
1341
1367
|
// project would lose sign-in. The rest move on a later run, once the
|
|
1342
1368
|
// recorded client ids differ from the binding and replan this step.
|
|
1369
|
+
const customersKeys = customersEntryKeys(org);
|
|
1343
1370
|
const boundAppIds = new Set([
|
|
1344
1371
|
...(recorded[APP_CLIENT_ID_SCOPE_KEY]
|
|
1345
1372
|
? org.hosting.issuerApps.filter((a) => a.kind === 'web').map((a) => a.name)
|
|
1346
1373
|
: []),
|
|
1347
1374
|
...Object.keys(recorded)
|
|
1348
1375
|
.filter((key) => key.startsWith(ENTRY_CLIENT_ID_SCOPE_PREFIX))
|
|
1349
|
-
.map((key) => key.slice(ENTRY_CLIENT_ID_SCOPE_PREFIX.length))
|
|
1376
|
+
.map((key) => key.slice(ENTRY_CLIENT_ID_SCOPE_PREFIX.length))
|
|
1377
|
+
// #573: a customers entry signs in on the shared host.
|
|
1378
|
+
.filter((name) => !customersKeys.has(name)),
|
|
1350
1379
|
...(recorded[CMS_CLIENT_ID_SCOPE_KEY] ? ['cms'] : []),
|
|
1351
1380
|
...(recorded[AGORA_CLIENT_ID_SCOPE_KEY] ? ['agora'] : []),
|
|
1352
1381
|
...(recorded[BOULE_CLIENT_ID_SCOPE_KEY] ? ['boule'] : []),
|
|
@@ -1669,9 +1698,19 @@ export class FoundryStepExecutor {
|
|
|
1669
1698
|
if (existing)
|
|
1670
1699
|
return existing;
|
|
1671
1700
|
const found = await this.ports.coolify.findProjectByName(org.slug);
|
|
1701
|
+
// #569: a project foundry did not create is never adopted. With no id
|
|
1702
|
+
// recorded, a found project is reused only when it carries this org's
|
|
1703
|
+
// mark, or holds no application or database (a crash between create and
|
|
1704
|
+
// record). Refused before any write otherwise.
|
|
1705
|
+
if (found && found.description !== coolifyProjectMark(org)) {
|
|
1706
|
+
const resources = await this.ports.coolify.listResources?.(found.uuid);
|
|
1707
|
+
if (!resources || resources.some((r) => r.kind !== 'environment')) {
|
|
1708
|
+
throw new Error(`coolify project ${org.slug} (${found.uuid}) was not created by foundry for this org and may hold other resources: rename or delete it, then apply again`);
|
|
1709
|
+
}
|
|
1710
|
+
}
|
|
1672
1711
|
const projectUuid = found
|
|
1673
1712
|
? found.uuid
|
|
1674
|
-
: (await this.ports.coolify.createProject(org.slug)).uuid;
|
|
1713
|
+
: (await this.ports.coolify.createProject(org.slug, coolifyProjectMark(org))).uuid;
|
|
1675
1714
|
context.recordScopeId('coolify_project', projectUuid);
|
|
1676
1715
|
return projectUuid;
|
|
1677
1716
|
}
|
|
@@ -1755,6 +1794,10 @@ export class FoundryStepExecutor {
|
|
|
1755
1794
|
let haveNames;
|
|
1756
1795
|
let have;
|
|
1757
1796
|
let appId;
|
|
1797
|
+
// A stopped app with no settings marker was never deployed successfully
|
|
1798
|
+
// (the marker is recorded only after the redeploy), e.g. a first deploy
|
|
1799
|
+
// that failed: starting it builds nothing. A parked app has its marker.
|
|
1800
|
+
let neverDeployed = false;
|
|
1758
1801
|
if (existing) {
|
|
1759
1802
|
appId = existing.uuid;
|
|
1760
1803
|
// Found by name (rather than just created) is started back up if not
|
|
@@ -1766,6 +1809,7 @@ export class FoundryStepExecutor {
|
|
|
1766
1809
|
live ??= await this.ports.coolify.getApplication(existing.uuid);
|
|
1767
1810
|
if (!isRunningOrStarting(live.status)) {
|
|
1768
1811
|
await this.ports.coolify.raw('POST', `/applications/${existing.uuid}/start`);
|
|
1812
|
+
neverDeployed = context.recordedIds()[settingsKey] === undefined;
|
|
1769
1813
|
}
|
|
1770
1814
|
have = appliedAppSettings(context.recordedIds(), entry.key);
|
|
1771
1815
|
haveNames = appliedAppDomains(org, context.recordedIds(), entry.key);
|
|
@@ -1900,8 +1944,10 @@ export class FoundryStepExecutor {
|
|
|
1900
1944
|
// that was already there is restarted, once, when its database URLs or its
|
|
1901
1945
|
// declared appEnv changed.
|
|
1902
1946
|
// A new folder needs a build, which a restart does not do, so a settings
|
|
1903
|
-
// change deploys instead (rule 3), and that carries the env too.
|
|
1904
|
-
|
|
1947
|
+
// change deploys instead (rule 3), and that carries the env too. A
|
|
1948
|
+
// created app is never deployed by Coolify itself (create sends no
|
|
1949
|
+
// `instant_deploy`), so it deploys here too (#566).
|
|
1950
|
+
if (settingsNeedDeploy || !existing || neverDeployed) {
|
|
1905
1951
|
// #543: every deploy waits for health through the shared helper, never
|
|
1906
1952
|
// a fire-and-forget `deploy` call -- `names[0]` is the same hostname
|
|
1907
1953
|
// `healthUrls` probes for this entry; an internal entry (no hostname)
|
|
@@ -1940,7 +1986,10 @@ export class FoundryStepExecutor {
|
|
|
1940
1986
|
// One Lettermint project, `<slug>-app`, belongs to the `app` entry; a
|
|
1941
1987
|
// second entry sharing the name would delete the first's live token.
|
|
1942
1988
|
if (entry.key === APP_ENTRY_KEY) {
|
|
1943
|
-
await this.ensureAppLettermintMail(appId, org, context,
|
|
1989
|
+
await this.ensureAppLettermintMail(appId, org, context,
|
|
1990
|
+
// Deployed or started above on every path, a created app included
|
|
1991
|
+
// (#566), so it has read its env before the mail env is written.
|
|
1992
|
+
true,
|
|
1944
1993
|
// #491 (review round 2, finding 3): `diff.ts`'s `planApps` always
|
|
1945
1994
|
// sets `platformApex` on this step's `desired` once it's planned at
|
|
1946
1995
|
// all -- `null` when no platform is declared, never omitted. The key
|
|
@@ -1969,9 +2018,9 @@ export class FoundryStepExecutor {
|
|
|
1969
2018
|
* container itself still writing nowhere). `wasAlreadyRunning` tells this
|
|
1970
2019
|
* method whether `appId` names an app `coolifyApp` found already there
|
|
1971
2020
|
* (restart it once the volume is created) or one it just created fresh
|
|
1972
|
-
* (
|
|
1973
|
-
*
|
|
1974
|
-
*
|
|
2021
|
+
* (`coolifyApp` deploys it after this call, #566, and that first start
|
|
2022
|
+
* carries the storage, so a restart here would only be a redundant extra
|
|
2023
|
+
* one).
|
|
1975
2024
|
*/
|
|
1976
2025
|
async ensureLogVolume(appId, org, wasAlreadyRunning, appName = org.slug) {
|
|
1977
2026
|
const storages = await this.ports.coolify.listStorages(appId);
|
|
@@ -2481,7 +2530,7 @@ export class FoundryStepExecutor {
|
|
|
2481
2530
|
await this.ports.coolify.bulkUpsertEnvs(appId, envUpdates);
|
|
2482
2531
|
// A running container read its env at start and still holds the old
|
|
2483
2532
|
// token, so a fresh one only reaches it through a restart -- before the
|
|
2484
|
-
// old project is deleted below. A just-created app
|
|
2533
|
+
// old project is deleted below. A just-created app is deployed first (#566).
|
|
2485
2534
|
if (!healthy && running)
|
|
2486
2535
|
await this.ports.coolify.restart(appId);
|
|
2487
2536
|
await this.ensureLettermintDomainRestriction(org, mailDomain, allProjects, platformApex, platformLettermintProjectId);
|
|
@@ -4360,9 +4409,15 @@ export class FoundryStepExecutor {
|
|
|
4360
4409
|
// (#115) gets one ingress entry per hostname `tunnelIngressHostnames`
|
|
4361
4410
|
// returns, all routed to the same proxy -- Traefik itself, not this list,
|
|
4362
4411
|
// is what tells the public site apart from the cms studio.
|
|
4363
|
-
|
|
4412
|
+
// #568: the company's own login host goes straight to the platform's
|
|
4413
|
+
// login app (the same container the platform auth tunnel routes to), not
|
|
4414
|
+
// through Coolify's proxy, which has no route for it.
|
|
4415
|
+
const loginHost = action.desired.loginHost ?? null;
|
|
4416
|
+
const ingress = tunnelIngressHostnames(org, loginHost).map((hostname) => ({
|
|
4364
4417
|
hostname,
|
|
4365
|
-
service:
|
|
4418
|
+
service: hostname === loginHost
|
|
4419
|
+
? `http://${PLATFORM_AUTH_LOGIN_INTERNAL_NAME}:${AUTH_LOGIN_PORT}`
|
|
4420
|
+
: COOLIFY_PROXY_ORIGIN,
|
|
4366
4421
|
}));
|
|
4367
4422
|
await this.tunnelPort().putTunnelConfig(tunnel.id, {
|
|
4368
4423
|
ingress: [...ingress, { service: 'http_status:404' }],
|
|
@@ -4427,7 +4482,7 @@ export class FoundryStepExecutor {
|
|
|
4427
4482
|
const previousHostnamesRaw = recordedBefore[CLOUDFLARE_TUNNEL_DNS_HOSTNAMES_SCOPE_KEY];
|
|
4428
4483
|
const previousHostnames = previousHostnamesRaw ? previousHostnamesRaw.split(',') : [];
|
|
4429
4484
|
const domainChanged = previousDomain !== undefined && previousDomain !== org.domain;
|
|
4430
|
-
const publishedHostnames = await this.publishTunnelDnsRecords(org, org.domain, tunnel.id,
|
|
4485
|
+
const publishedHostnames = await this.publishTunnelDnsRecords(org, org.domain, loginHost, tunnel.id,
|
|
4431
4486
|
// Round 6 finding 2: every id this step has ever replaced, not only
|
|
4432
4487
|
// the one `recordedTunnelId` captured above -- see the scope key's
|
|
4433
4488
|
// own doc comment.
|
|
@@ -4590,7 +4645,7 @@ export class FoundryStepExecutor {
|
|
|
4590
4645
|
* overwriting them. `destroy` and {@link deleteStaleTunnelDnsRecords}
|
|
4591
4646
|
* both read this set.
|
|
4592
4647
|
*/
|
|
4593
|
-
async publishTunnelDnsRecords(org, domain, tunnelId, previousIds, previousHostnames, context) {
|
|
4648
|
+
async publishTunnelDnsRecords(org, domain, loginHost, tunnelId, previousIds, previousHostnames, context) {
|
|
4594
4649
|
const dns = this.dnsPort();
|
|
4595
4650
|
const zone = await this.zonePort().findZoneByApex(domain);
|
|
4596
4651
|
if (!zone) {
|
|
@@ -4598,7 +4653,7 @@ export class FoundryStepExecutor {
|
|
|
4598
4653
|
}
|
|
4599
4654
|
const dnsTarget = `${tunnelId}.cfargotunnel.com`;
|
|
4600
4655
|
const previousTargets = new Set([...previousIds].filter((id) => id !== tunnelId).map((id) => `${id}.cfargotunnel.com`));
|
|
4601
|
-
const hostnames = tunnelDnsHostnames(org, domain);
|
|
4656
|
+
const hostnames = tunnelDnsHostnames(org, domain, loginHost);
|
|
4602
4657
|
const droppedHostnames = previousHostnames.filter((hostname) => !hostnames.includes(hostname));
|
|
4603
4658
|
const conflicts = [];
|
|
4604
4659
|
const toCreate = [];
|
package/dist/plan/hostnames.d.ts
CHANGED
|
@@ -36,8 +36,10 @@ export declare function filesShareHostname(org: DeclaredOrg): string | null;
|
|
|
36
36
|
* The hostnames `cloudflareTunnel` routes to Coolify's proxy (#115, #145,
|
|
37
37
|
* #164): a site's two names, every entry's names, an own-domain calls name,
|
|
38
38
|
* Agora's and Boule's, then the wildcard last. An org that does not host keeps the single
|
|
39
|
-
* name it always had.
|
|
39
|
+
* name it always had. `loginHost` (#568) is the company's own `auth.<domain>`
|
|
40
|
+
* when it has one; it is listed before the wildcard, and the tunnel step routes
|
|
41
|
+
* it to the platform's login app, not to Coolify's proxy.
|
|
40
42
|
*/
|
|
41
|
-
export declare function tunnelIngressHostnames(org: DeclaredOrg): string[];
|
|
43
|
+
export declare function tunnelIngressHostnames(org: DeclaredOrg, loginHost?: string | null): string[];
|
|
42
44
|
/** The ingress names that get a DNS record: those under the org's own domain. */
|
|
43
|
-
export declare function tunnelDnsHostnames(org: DeclaredOrg, domain: string): string[];
|
|
45
|
+
export declare function tunnelDnsHostnames(org: DeclaredOrg, domain: string, loginHost?: string | null): string[];
|
package/dist/plan/hostnames.js
CHANGED
|
@@ -61,9 +61,11 @@ export function filesShareHostname(org) {
|
|
|
61
61
|
* The hostnames `cloudflareTunnel` routes to Coolify's proxy (#115, #145,
|
|
62
62
|
* #164): a site's two names, every entry's names, an own-domain calls name,
|
|
63
63
|
* Agora's and Boule's, then the wildcard last. An org that does not host keeps the single
|
|
64
|
-
* name it always had.
|
|
64
|
+
* name it always had. `loginHost` (#568) is the company's own `auth.<domain>`
|
|
65
|
+
* when it has one; it is listed before the wildcard, and the tunnel step routes
|
|
66
|
+
* it to the platform's login app, not to Coolify's proxy.
|
|
65
67
|
*/
|
|
66
|
-
export function tunnelIngressHostnames(org) {
|
|
68
|
+
export function tunnelIngressHostnames(org, loginHost = null) {
|
|
67
69
|
const hostnames = org.site.enabled && org.domain ? [org.domain, `cms.${org.domain}`] : [];
|
|
68
70
|
if (org.hosting.enabled) {
|
|
69
71
|
for (const entry of org.hosting.entries)
|
|
@@ -84,6 +86,8 @@ export function tunnelIngressHostnames(org) {
|
|
|
84
86
|
const share = filesShareHostname(org);
|
|
85
87
|
if (share)
|
|
86
88
|
hostnames.push(share);
|
|
89
|
+
if (loginHost)
|
|
90
|
+
hostnames.push(loginHost);
|
|
87
91
|
// The wildcard goes last: Cloudflare takes the first ingress rule that
|
|
88
92
|
// matches, so every exact name above must come before `*.<domain>`.
|
|
89
93
|
const wildcard = wildcardHostname(org);
|
|
@@ -92,6 +96,6 @@ export function tunnelIngressHostnames(org) {
|
|
|
92
96
|
return [...new Set(hostnames)];
|
|
93
97
|
}
|
|
94
98
|
/** The ingress names that get a DNS record: those under the org's own domain. */
|
|
95
|
-
export function tunnelDnsHostnames(org, domain) {
|
|
96
|
-
return tunnelIngressHostnames(org).filter((hostname) => hostname === domain || hostname.endsWith(`.${domain}`));
|
|
99
|
+
export function tunnelDnsHostnames(org, domain, loginHost = null) {
|
|
100
|
+
return tunnelIngressHostnames(org, loginHost).filter((hostname) => hostname === domain || hostname.endsWith(`.${domain}`));
|
|
97
101
|
}
|
|
@@ -81,8 +81,15 @@ export interface LoginHostBindingsPort {
|
|
|
81
81
|
* on the next run.
|
|
82
82
|
*/
|
|
83
83
|
export declare const LOGIN_BINDING_SCOPE_KEY = "login_host_binding";
|
|
84
|
-
/**
|
|
85
|
-
|
|
84
|
+
/**
|
|
85
|
+
* #573: keys of the entries that sign in the customers organisation. The login
|
|
86
|
+
* host's binding admits the staff organisation only, so these entries stay
|
|
87
|
+
* out of it and sign in on the shared host. A recorded client id whose entry
|
|
88
|
+
* is no longer declared is not here, so it stays bound as before.
|
|
89
|
+
*/
|
|
90
|
+
export declare function customersEntryKeys(org: DeclaredOrg): Set<string>;
|
|
91
|
+
/** The org's recorded app client ids (hosted app, each entry's own bar a customers entry, cms, agora, boule), deduplicated and sorted. */
|
|
92
|
+
export declare function loginHostClientIds(org: DeclaredOrg, recorded: Readonly<Record<string, string>>): string[];
|
|
86
93
|
/** The host plus its sorted client ids, as recorded under {@link LOGIN_BINDING_SCOPE_KEY}. */
|
|
87
94
|
export declare function loginBindingState(host: string, clientIds: readonly string[]): string;
|
|
88
95
|
/**
|
|
@@ -101,3 +108,11 @@ export declare const LOGIN_HOST_PENDING_REMOVAL_SCOPE_KEY = "zitadel_login_host_
|
|
|
101
108
|
export declare function pendingRemovalHosts(recorded: Readonly<Record<string, string>>): string[];
|
|
102
109
|
/** {@link pendingRemovalHosts}' own serialisation, sorted so the recorded value is stable regardless of check order. */
|
|
103
110
|
export declare function pendingRemovalState(hosts: readonly string[]): string;
|
|
111
|
+
/**
|
|
112
|
+
* #568: the company's own login host when the company tunnel has to publish
|
|
113
|
+
* it: `auth.<domain>`, only when it is under the org's own domain
|
|
114
|
+
* (`issuer.usesDomain`) and the org is not the platform's own, whose host is
|
|
115
|
+
* the shared instance's and is routed by the platform auth tunnel. Otherwise
|
|
116
|
+
* `null`. The tier 1 host rides the shared `*.oikos.host` tunnel.
|
|
117
|
+
*/
|
|
118
|
+
export declare function companyLoginTunnelHost(org: DeclaredOrg, isPlatformOrg: boolean): string | null;
|
package/dist/plan/login-host.js
CHANGED
|
@@ -55,15 +55,26 @@ export const LOGIN_HOST_SHARED_SCOPE_KEY = 'zitadel_login_host_shared';
|
|
|
55
55
|
* on the next run.
|
|
56
56
|
*/
|
|
57
57
|
export const LOGIN_BINDING_SCOPE_KEY = 'login_host_binding';
|
|
58
|
-
/**
|
|
59
|
-
|
|
58
|
+
/**
|
|
59
|
+
* #573: keys of the entries that sign in the customers organisation. The login
|
|
60
|
+
* host's binding admits the staff organisation only, so these entries stay
|
|
61
|
+
* out of it and sign in on the shared host. A recorded client id whose entry
|
|
62
|
+
* is no longer declared is not here, so it stays bound as before.
|
|
63
|
+
*/
|
|
64
|
+
export function customersEntryKeys(org) {
|
|
65
|
+
return new Set(org.hosting.entries.filter((e) => e.uses?.auth?.users === 'customers').map((e) => e.key));
|
|
66
|
+
}
|
|
67
|
+
/** The org's recorded app client ids (hosted app, each entry's own bar a customers entry, cms, agora, boule), deduplicated and sorted. */
|
|
68
|
+
export function loginHostClientIds(org, recorded) {
|
|
69
|
+
const customers = customersEntryKeys(org);
|
|
60
70
|
const ids = [
|
|
61
71
|
recorded[APP_CLIENT_ID_SCOPE_KEY],
|
|
62
72
|
recorded[CMS_CLIENT_ID_SCOPE_KEY],
|
|
63
73
|
recorded[AGORA_CLIENT_ID_SCOPE_KEY],
|
|
64
74
|
recorded[BOULE_CLIENT_ID_SCOPE_KEY],
|
|
65
75
|
...Object.entries(recorded)
|
|
66
|
-
.filter(([key]) => key.startsWith(ENTRY_CLIENT_ID_SCOPE_PREFIX)
|
|
76
|
+
.filter(([key]) => key.startsWith(ENTRY_CLIENT_ID_SCOPE_PREFIX) &&
|
|
77
|
+
!customers.has(key.slice(ENTRY_CLIENT_ID_SCOPE_PREFIX.length)))
|
|
67
78
|
.map(([, id]) => id),
|
|
68
79
|
].filter((id) => Boolean(id));
|
|
69
80
|
return [...new Set(ids)].sort();
|
|
@@ -93,3 +104,13 @@ export function pendingRemovalHosts(recorded) {
|
|
|
93
104
|
export function pendingRemovalState(hosts) {
|
|
94
105
|
return [...hosts].sort().join(',');
|
|
95
106
|
}
|
|
107
|
+
/**
|
|
108
|
+
* #568: the company's own login host when the company tunnel has to publish
|
|
109
|
+
* it: `auth.<domain>`, only when it is under the org's own domain
|
|
110
|
+
* (`issuer.usesDomain`) and the org is not the platform's own, whose host is
|
|
111
|
+
* the shared instance's and is routed by the platform auth tunnel. Otherwise
|
|
112
|
+
* `null`. The tier 1 host rides the shared `*.oikos.host` tunnel.
|
|
113
|
+
*/
|
|
114
|
+
export function companyLoginTunnelHost(org, isPlatformOrg) {
|
|
115
|
+
return org.domain && org.issuer.usesDomain && !isPlatformOrg ? loginHostnameFor(org) : null;
|
|
116
|
+
}
|
|
@@ -330,6 +330,8 @@ export declare class FoundryPlatformStepExecutor implements PlatformStepExecutor
|
|
|
330
330
|
private executeSfu;
|
|
331
331
|
private coolifyJobs;
|
|
332
332
|
}
|
|
333
|
+
export declare const PLATFORM_AUTH_LOGIN_INTERNAL_NAME = "platform-auth-login";
|
|
334
|
+
export declare const AUTH_LOGIN_PORT = "3000";
|
|
333
335
|
/**
|
|
334
336
|
* Every ZITADEL OIDC/management/console/asset path -- `deploy/proxy/Caddyfile`'s
|
|
335
337
|
* own explicit list, restated here as one regex since the tunnel routes by a
|
|
@@ -695,7 +695,7 @@ const PLATFORM_AUTH_BROKER_APP_NAME = 'platform-auth-broker';
|
|
|
695
695
|
* header and so cannot give two different apps the same public hostname.
|
|
696
696
|
*/
|
|
697
697
|
const PLATFORM_AUTH_ZITADEL_INTERNAL_NAME = 'platform-auth-zitadel';
|
|
698
|
-
const PLATFORM_AUTH_LOGIN_INTERNAL_NAME = 'platform-auth-login';
|
|
698
|
+
export const PLATFORM_AUTH_LOGIN_INTERNAL_NAME = 'platform-auth-login';
|
|
699
699
|
const PLATFORM_AUTH_BROKER_INTERNAL_NAME = 'platform-auth-broker';
|
|
700
700
|
const PLATFORM_AUTH_TUNNEL_NAME = 'platform-auth-tunnel';
|
|
701
701
|
/** #197: the one Coolify app that runs `cloudflared` for {@link PLATFORM_AUTH_TUNNEL_NAME}. */
|
|
@@ -714,7 +714,7 @@ const AUTH_BROKER_DOCKERFILE = '/broker/Dockerfile';
|
|
|
714
714
|
* own `ZITADEL_PORT: 8080` and the login image's own `EXPOSE`.
|
|
715
715
|
*/
|
|
716
716
|
const AUTH_ZITADEL_PORT = '8080';
|
|
717
|
-
const AUTH_LOGIN_PORT = '3000';
|
|
717
|
+
export const AUTH_LOGIN_PORT = '3000';
|
|
718
718
|
/** `broker/Dockerfile`'s own `ENV PORT=3000` / `EXPOSE 3000`. */
|
|
719
719
|
const AUTH_BROKER_PORT = '3000';
|
|
720
720
|
/**
|