@wtfalch/foundry 0.22.0 → 0.22.2
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/index.d.ts +1 -1
- package/dist/index.js +1 -1
- package/dist/plan/diff.js +43 -23
- package/dist/plan/executor.d.ts +12 -24
- package/dist/plan/executor.js +89 -31
- package/dist/plan/hostnames.d.ts +18 -4
- package/dist/plan/hostnames.js +25 -5
- package/dist/plan/index.d.ts +1 -1
- package/dist/plan/index.js +1 -1
- package/dist/plan/login-host.d.ts +17 -2
- package/dist/plan/login-host.js +24 -3
- package/dist/plan/platform-diff.d.ts +17 -6
- package/dist/plan/platform-diff.js +20 -6
- package/dist/plan/platform-org-ports.js +3 -2
- package/dist/plan/platform-org.d.ts +7 -1
- package/dist/plan/platform-org.js +4 -2
- package/dist/plan/platform-providers.d.ts +7 -1
- package/dist/plan/platform-providers.js +41 -10
- package/package.json +1 -1
package/dist/index.d.ts
CHANGED
|
@@ -22,7 +22,7 @@ export type { LiveResource, ZitadelReader, CoolifyReader, CloudflareReader, File
|
|
|
22
22
|
export { AdoptRefusedError } from './plan/index.js';
|
|
23
23
|
export { createEdgeAccess, createEdgeDns, createEdgeRateLimits, createEdgeTunnels, createEdgeWorkers, createEdgeZones, } from './plan/index.js';
|
|
24
24
|
export type { AccessAppSummary, AccessDecision, AccessPolicyRule, AccessPort, DnsPort, EdgePlatformAccess, EdgePlatformDns, EdgePlatformRateLimits, EdgePlatformWorkers, EdgePlatformZones, EdgeZonesOptions, ZonePort, EdgeRateLimitsOptions, RateLimitPort, RateLimitRuleInput, EdgeWorkersOptions, EdgePlatformTunnels, TunnelPort, } from './plan/index.js';
|
|
25
|
-
export { readLiveResources, readLivePlatformResources, computePlanDetail, toPlan, planPlatform, toPlatformPlan, JOBS_SCHEDULER_KEY, PLATFORM_AI_KEY, PLATFORM_AI_INTERNAL_URL_SCOPE_KEY, PLATFORM_AI_MANAGEMENT_KEY_SCOPE_KEY, PLATFORM_AI_ORGANISATION_SCOPE_KEY, PLATFORM_FILES_KEY, PLATFORM_FILES_INTERNAL_URL_SCOPE_KEY, PLATFORM_FILES_ZITADEL_PROJECT_SCOPE_KEY, PLATFORM_MAIL_INTERNAL_URL_SCOPE_KEY, PLATFORM_MAIL_ZITADEL_PROJECT_SCOPE_KEY, PLATFORM_EDGE_KEY, PLATFORM_EDGE_INTERNAL_URL_SCOPE_KEY, PLATFORM_EDGE_KEY_REF, PLATFORM_EDGE_CLOUDFLARE_TOKEN_REF, FoundryPlanApply, DeclaredOrgNotFoundError, StalePlanError, ConfirmationRequiredError, NotAuthorizedError, NotATestOrgError, DeclaredPlatformNotFoundError, PlatformStalePlanError, PlatformConfirmationRequiredError, planNeedsConfirmation, } from './plan/index.js';
|
|
25
|
+
export { readLiveResources, readLivePlatformResources, computePlanDetail, toPlan, planPlatform, toPlatformPlan, JOBS_INTERNAL_URL_SCOPE_KEY, JOBS_SCHEDULER_KEY, PLATFORM_AI_KEY, PLATFORM_AI_INTERNAL_URL_SCOPE_KEY, PLATFORM_AI_MANAGEMENT_KEY_SCOPE_KEY, PLATFORM_AI_ORGANISATION_SCOPE_KEY, PLATFORM_FILES_KEY, PLATFORM_FILES_INTERNAL_URL_SCOPE_KEY, PLATFORM_FILES_ZITADEL_PROJECT_SCOPE_KEY, PLATFORM_MAIL_INTERNAL_URL_SCOPE_KEY, PLATFORM_MAIL_ZITADEL_PROJECT_SCOPE_KEY, PLATFORM_EDGE_KEY, PLATFORM_EDGE_INTERNAL_URL_SCOPE_KEY, PLATFORM_EDGE_KEY_REF, PLATFORM_EDGE_CLOUDFLARE_TOKEN_REF, FoundryPlanApply, DeclaredOrgNotFoundError, StalePlanError, ConfirmationRequiredError, NotAuthorizedError, NotATestOrgError, DeclaredPlatformNotFoundError, PlatformStalePlanError, PlatformConfirmationRequiredError, planNeedsConfirmation, } from './plan/index.js';
|
|
26
26
|
export type { HeldKeyResolver, OrgPlatformReadiness } from './plan/index.js';
|
|
27
27
|
export { DEFAULT_ORG_PLATFORM_READINESS, ORG_PLATFORM_KEY_REFS, agoraHostValuesFromPlatform, heldPlatformKeyRefs, orgPlatformReadinessFrom, } from './plan/index.js';
|
|
28
28
|
export type { PlatformOrgClientFactories } from './plan/index.js';
|
package/dist/index.js
CHANGED
|
@@ -13,7 +13,7 @@ export { createPostgresAuditLog } from './store/audit.js';
|
|
|
13
13
|
export { declaredOrgs, foundryAppliedState, foundryAppliedVersions, foundryAuditEvents, foundryDeclaredPlatform, foundryRequestedChanges, } from './store/schema.js';
|
|
14
14
|
export { AdoptRefusedError } from './plan/index.js';
|
|
15
15
|
export { createEdgeAccess, createEdgeDns, createEdgeRateLimits, createEdgeTunnels, createEdgeWorkers, createEdgeZones, } from './plan/index.js';
|
|
16
|
-
export { readLiveResources, readLivePlatformResources, computePlanDetail, toPlan, planPlatform, toPlatformPlan, JOBS_SCHEDULER_KEY, PLATFORM_AI_KEY, PLATFORM_AI_INTERNAL_URL_SCOPE_KEY, PLATFORM_AI_MANAGEMENT_KEY_SCOPE_KEY, PLATFORM_AI_ORGANISATION_SCOPE_KEY, PLATFORM_FILES_KEY, PLATFORM_FILES_INTERNAL_URL_SCOPE_KEY, PLATFORM_FILES_ZITADEL_PROJECT_SCOPE_KEY, PLATFORM_MAIL_INTERNAL_URL_SCOPE_KEY, PLATFORM_MAIL_ZITADEL_PROJECT_SCOPE_KEY, PLATFORM_EDGE_KEY, PLATFORM_EDGE_INTERNAL_URL_SCOPE_KEY, PLATFORM_EDGE_KEY_REF, PLATFORM_EDGE_CLOUDFLARE_TOKEN_REF, FoundryPlanApply, DeclaredOrgNotFoundError, StalePlanError, ConfirmationRequiredError, NotAuthorizedError, NotATestOrgError, DeclaredPlatformNotFoundError, PlatformStalePlanError, PlatformConfirmationRequiredError, planNeedsConfirmation, } from './plan/index.js';
|
|
16
|
+
export { readLiveResources, readLivePlatformResources, computePlanDetail, toPlan, planPlatform, toPlatformPlan, JOBS_INTERNAL_URL_SCOPE_KEY, JOBS_SCHEDULER_KEY, PLATFORM_AI_KEY, PLATFORM_AI_INTERNAL_URL_SCOPE_KEY, PLATFORM_AI_MANAGEMENT_KEY_SCOPE_KEY, PLATFORM_AI_ORGANISATION_SCOPE_KEY, PLATFORM_FILES_KEY, PLATFORM_FILES_INTERNAL_URL_SCOPE_KEY, PLATFORM_FILES_ZITADEL_PROJECT_SCOPE_KEY, PLATFORM_MAIL_INTERNAL_URL_SCOPE_KEY, PLATFORM_MAIL_ZITADEL_PROJECT_SCOPE_KEY, PLATFORM_EDGE_KEY, PLATFORM_EDGE_INTERNAL_URL_SCOPE_KEY, PLATFORM_EDGE_KEY_REF, PLATFORM_EDGE_CLOUDFLARE_TOKEN_REF, FoundryPlanApply, DeclaredOrgNotFoundError, StalePlanError, ConfirmationRequiredError, NotAuthorizedError, NotATestOrgError, DeclaredPlatformNotFoundError, PlatformStalePlanError, PlatformConfirmationRequiredError, planNeedsConfirmation, } from './plan/index.js';
|
|
17
17
|
export { DEFAULT_ORG_PLATFORM_READINESS, ORG_PLATFORM_KEY_REFS, agoraHostValuesFromPlatform, heldPlatformKeyRefs, orgPlatformReadinessFrom, } from './plan/index.js';
|
|
18
18
|
export { PlatformOrgBlockedError, PlatformOrgPorts } from './plan/index.js';
|
|
19
19
|
export { FoundryStepExecutor, NotBuiltError, healthUrls, loginHostnameFor } from './plan/index.js';
|
package/dist/plan/diff.js
CHANGED
|
@@ -6,8 +6,8 @@ import { brokerEntryAppliedScopeKey, brokerEntryWarningsScopeKey, entryAuthEnv,
|
|
|
6
6
|
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
|
-
import { appHostname, entryHostnames, tunnelDnsHostnames } from './hostnames.js';
|
|
10
|
-
import { LOGIN_BINDING_SCOPE_KEY, loginBindingState, loginHostClientIds, loginHostnameFor, pendingRemovalHosts, } from './login-host.js';
|
|
9
|
+
import { appHostname, entryHostnames, tunnelDnsHostnames, tunnelDomainList } from './hostnames.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.
|
|
@@ -324,7 +326,7 @@ export function computePlanDetail(org, live, recordedIds = {}, agoraHost = NO_AG
|
|
|
324
326
|
// #173: register the org's own app with the platform's jobs scheduler.
|
|
325
327
|
// After Agora, so a failing registration can never stop Agora from
|
|
326
328
|
// provisioning (`runSteps` stops at the first failing step).
|
|
327
|
-
const jobsRegistration = planJobsRegistration(org, recordedIds, jobsAvailable || platformReadiness.jobsReady, platformReadiness.missingKeys.jobs, platformReadiness.noKeyReader);
|
|
329
|
+
const jobsRegistration = planJobsRegistration(org, recordedIds, jobsAvailable || platformReadiness.jobsReady, platformReadiness.missingKeys.jobs, platformReadiness.noKeyReader, platformReadiness.jobsUrlMissing ?? false);
|
|
328
330
|
if (jobsRegistration.step)
|
|
329
331
|
plannedSteps.push(jobsRegistration.step);
|
|
330
332
|
// F8 (#318), after Agora and Boule, not right after the zone: a route
|
|
@@ -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
|
}
|
|
@@ -2184,7 +2186,7 @@ function planApp(org, entryKey, byKey, claimed, recordedIds, plannedSteps, authI
|
|
|
2184
2186
|
}
|
|
2185
2187
|
// A changed hostname reaches an app that is already live: its domains are
|
|
2186
2188
|
// patched and it is redeployed.
|
|
2187
|
-
if (JSON.stringify(entryHostnames(org, entry)) !==
|
|
2189
|
+
if (JSON.stringify(tunnelDomainList(entryHostnames(org, entry))) !==
|
|
2188
2190
|
JSON.stringify(appliedAppDomains(org, recordedIds, entryKey))) {
|
|
2189
2191
|
return {
|
|
2190
2192
|
step: {
|
|
@@ -2342,7 +2344,7 @@ function planApps(org, byKey, claimed, recordedIds, plannedSteps, authIssuer, re
|
|
|
2342
2344
|
* {@link planSite}'s own `hostname` local does: `DeclaredOrg.domain` once
|
|
2343
2345
|
* `hosting.usesDomain` is true, `<slug>.oikos.host` otherwise.
|
|
2344
2346
|
*/
|
|
2345
|
-
function planJobsRegistration(org, recordedIds, jobsAvailable, missingKeyRef = null, noKeyReader = false) {
|
|
2347
|
+
function planJobsRegistration(org, recordedIds, jobsAvailable, missingKeyRef = null, noKeyReader = false, addressMissing = false) {
|
|
2346
2348
|
if (!org.hosting.enabled)
|
|
2347
2349
|
return { step: null, blocked: null };
|
|
2348
2350
|
const key = resourceKey('jobs', 'registration', org.id);
|
|
@@ -2364,11 +2366,13 @@ function planJobsRegistration(org, recordedIds, jobsAvailable, missingKeyRef = n
|
|
|
2364
2366
|
if (!jobsAvailable) {
|
|
2365
2367
|
return {
|
|
2366
2368
|
step: null,
|
|
2367
|
-
blocked:
|
|
2368
|
-
? `${org.slug}'s jobs registration is blocked, not planned: the
|
|
2369
|
-
:
|
|
2370
|
-
?
|
|
2371
|
-
:
|
|
2369
|
+
blocked: addressMissing
|
|
2370
|
+
? `${org.slug}'s jobs registration is blocked, not planned: the platform's jobs scheduler has not recorded its internal address yet; apply the platform's jobs step first`
|
|
2371
|
+
: !missingKeyRef
|
|
2372
|
+
? `${org.slug}'s jobs registration is blocked, not planned: the host supplies no jobs scheduler`
|
|
2373
|
+
: `${org.slug}'s jobs registration is blocked, not planned: ${noKeyReader
|
|
2374
|
+
? `the host gave no held-keys reader and no jobs scheduler port, so the platform's jobs admin token ${missingKeyRef} cannot be checked`
|
|
2375
|
+
: `the platform's jobs admin token ${missingKeyRef} is not held yet`}`,
|
|
2372
2376
|
};
|
|
2373
2377
|
}
|
|
2374
2378
|
const hostname = appHostname(org);
|
|
@@ -2432,7 +2436,7 @@ function sortedNames(names) {
|
|
|
2432
2436
|
* domain) plans no per-org tunnel for Agora: `<slug>-agora.oikos.host` rides
|
|
2433
2437
|
* the shared `*.oikos.host` wildcard tunnel (`scripts/shared-ingress.mjs`).
|
|
2434
2438
|
*/
|
|
2435
|
-
function planTunnel(org, byKey, claimed, recordedIds) {
|
|
2439
|
+
function planTunnel(org, byKey, claimed, recordedIds, isPlatformOrg, platformApex) {
|
|
2436
2440
|
const desired = (org.hosting.tunnel && org.hosting.usesDomain && Boolean(org.domain)) ||
|
|
2437
2441
|
(org.site.enabled && Boolean(org.domain)) ||
|
|
2438
2442
|
(org.calls.enabled && org.calls.usesDomain && Boolean(org.domain)) ||
|
|
@@ -2449,6 +2453,16 @@ function planTunnel(org, byKey, claimed, recordedIds) {
|
|
|
2449
2453
|
return live.config.enabled !== false;
|
|
2450
2454
|
return desired ? status !== 'down' : status === 'healthy' || status === 'degraded';
|
|
2451
2455
|
});
|
|
2456
|
+
// #568: the company's own login host is routed by the step, so a first
|
|
2457
|
+
// provision carries it too; a park does not. Without a known platform apex
|
|
2458
|
+
// the platform's own org cannot be told apart, so none is planned.
|
|
2459
|
+
const loginHost = platformApex ? companyLoginTunnelHost(org, isPlatformOrg) : null;
|
|
2460
|
+
if (toggled && toggled.action.desired.enabled !== false && loginHost) {
|
|
2461
|
+
return {
|
|
2462
|
+
...toggled,
|
|
2463
|
+
action: { ...toggled.action, desired: { ...toggled.action.desired, loginHost } },
|
|
2464
|
+
};
|
|
2465
|
+
}
|
|
2452
2466
|
if (toggled || !desired)
|
|
2453
2467
|
return toggled;
|
|
2454
2468
|
// A live, enabled tunnel re-plans the same step again when either:
|
|
@@ -2472,12 +2486,18 @@ function planTunnel(org, byKey, claimed, recordedIds) {
|
|
|
2472
2486
|
const published = recordedIds[CLOUDFLARE_TUNNEL_DNS_HOSTNAMES_SCOPE_KEY];
|
|
2473
2487
|
const edgeChanged = published !== undefined &&
|
|
2474
2488
|
org.domain !== null &&
|
|
2475
|
-
sortedNames(published.split(',')) !==
|
|
2489
|
+
sortedNames(published.split(',')) !==
|
|
2490
|
+
sortedNames(tunnelDnsHostnames(org, org.domain, loginHost));
|
|
2476
2491
|
const dnsStale = recordedIds[CLOUDFLARE_TUNNEL_DNS_DOMAIN_SCOPE_KEY] !== org.domain || edgeChanged;
|
|
2477
2492
|
if (!connectorMissing && !dnsStale)
|
|
2478
2493
|
return null;
|
|
2479
2494
|
return {
|
|
2480
|
-
action: {
|
|
2495
|
+
action: {
|
|
2496
|
+
system: 'cloudflare',
|
|
2497
|
+
kind: 'tunnel',
|
|
2498
|
+
key: org.id,
|
|
2499
|
+
desired: loginHost ? { enabled: true, loginHost } : { enabled: true },
|
|
2500
|
+
},
|
|
2481
2501
|
step: {
|
|
2482
2502
|
description: connectorMissing
|
|
2483
2503
|
? `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
|
@@ -20,13 +20,39 @@ import { AI_ENTRY_RENEW_WINDOW_DAYS, entryAiAppliedScopeKey, entryAiClampedScope
|
|
|
20
20
|
import { BOULE_EDGE_ENTRY_KEY, EDGE_ENTRY_KEY_DAYS, EDGE_RESERVED_LABELS_APPLIED_SCOPE_KEY, ENTRY_EDGE_KEY_DELIVERY_ATTEMPTS, edgeKeyAppRecordKey, entryEdgeMintedAtScopeKey, entryEdgeRetiredScopeKey, entryEdgeUndeliveredScopeKey, } from './entry-edge-key.js';
|
|
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
|
-
import { appHostname,
|
|
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';
|
|
23
|
+
import { appHostname, coolifyTunnelDomains, entryHostnames, tunnelDnsHostnames, tunnelDomainList, tunnelIngressHostnames, } from './hostnames.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
|
}
|
|
@@ -1748,13 +1787,18 @@ export class FoundryStepExecutor {
|
|
|
1748
1787
|
// An entry with no names is internal: the other entries reach it as
|
|
1749
1788
|
// `<slug>-<key>`, and it gets no domain, route or DNS record.
|
|
1750
1789
|
const names = entryHostnames(org, entry);
|
|
1790
|
+
const wantDomains = tunnelDomainList(names);
|
|
1751
1791
|
const isStatic = entry.buildPack === 'static';
|
|
1752
1792
|
const folderField = isStatic ? 'publish_directory' : 'base_directory';
|
|
1753
1793
|
const settingsKey = appSettingsAppliedScopeKey(entry.key);
|
|
1754
1794
|
const domainsKey = appDomainsAppliedScopeKey(entry.key);
|
|
1755
|
-
let
|
|
1795
|
+
let haveDomains;
|
|
1756
1796
|
let have;
|
|
1757
1797
|
let appId;
|
|
1798
|
+
// A stopped app with no settings marker was never deployed successfully
|
|
1799
|
+
// (the marker is recorded only after the redeploy), e.g. a first deploy
|
|
1800
|
+
// that failed: starting it builds nothing. A parked app has its marker.
|
|
1801
|
+
let neverDeployed = false;
|
|
1758
1802
|
if (existing) {
|
|
1759
1803
|
appId = existing.uuid;
|
|
1760
1804
|
// Found by name (rather than just created) is started back up if not
|
|
@@ -1766,9 +1810,10 @@ export class FoundryStepExecutor {
|
|
|
1766
1810
|
live ??= await this.ports.coolify.getApplication(existing.uuid);
|
|
1767
1811
|
if (!isRunningOrStarting(live.status)) {
|
|
1768
1812
|
await this.ports.coolify.raw('POST', `/applications/${existing.uuid}/start`);
|
|
1813
|
+
neverDeployed = context.recordedIds()[settingsKey] === undefined;
|
|
1769
1814
|
}
|
|
1770
1815
|
have = appliedAppSettings(context.recordedIds(), entry.key);
|
|
1771
|
-
|
|
1816
|
+
haveDomains = appliedAppDomains(org, context.recordedIds(), entry.key);
|
|
1772
1817
|
}
|
|
1773
1818
|
else {
|
|
1774
1819
|
const server = await this.resolveServer(org, context);
|
|
@@ -1783,7 +1828,7 @@ export class FoundryStepExecutor {
|
|
|
1783
1828
|
git_branch: entry.branch,
|
|
1784
1829
|
build_pack: entry.buildPack,
|
|
1785
1830
|
name: appNameFor(org, entry),
|
|
1786
|
-
domains: names.length > 0 ?
|
|
1831
|
+
domains: names.length > 0 ? coolifyTunnelDomains(names) : undefined,
|
|
1787
1832
|
autogenerate_domain: names.length > 0 ? undefined : false,
|
|
1788
1833
|
// A static site is served by Coolify's own web server on port 80.
|
|
1789
1834
|
ports_exposes: isStatic ? '80' : entry.port === null ? undefined : String(entry.port),
|
|
@@ -1796,7 +1841,7 @@ export class FoundryStepExecutor {
|
|
|
1796
1841
|
appId = created.uuid;
|
|
1797
1842
|
// Create sent every setting, so nothing is left to patch.
|
|
1798
1843
|
have = appSettingsOf(entry);
|
|
1799
|
-
|
|
1844
|
+
haveDomains = wantDomains;
|
|
1800
1845
|
}
|
|
1801
1846
|
// Patch what differs from what the app has. A crash before the marker is
|
|
1802
1847
|
// recorded below leaves the old one, so the retry patches the same
|
|
@@ -1825,10 +1870,12 @@ export class FoundryStepExecutor {
|
|
|
1825
1870
|
Object.assign(patch, healthCheckSettings(entry.healthPath ?? (isStatic ? '/' : '/api/health'), healthCheckPort));
|
|
1826
1871
|
}
|
|
1827
1872
|
// Create names the app after `domains`; a changed name reaches a live app
|
|
1828
|
-
// here (rule 3)
|
|
1829
|
-
//
|
|
1830
|
-
|
|
1831
|
-
|
|
1873
|
+
// here (rule 3), and so does an app made with the `https://` form, whose
|
|
1874
|
+
// redirect loops behind the tunnel. The internal name is set after
|
|
1875
|
+
// create, as the calls stack's apps do.
|
|
1876
|
+
if (JSON.stringify(wantDomains) !== JSON.stringify(haveDomains)) {
|
|
1877
|
+
patch.domains = coolifyTunnelDomains(names);
|
|
1878
|
+
}
|
|
1832
1879
|
if (names.length === 0 && (!existing || patch.domains !== undefined)) {
|
|
1833
1880
|
patch.custom_internal_name = appNameFor(org, entry);
|
|
1834
1881
|
}
|
|
@@ -1900,8 +1947,10 @@ export class FoundryStepExecutor {
|
|
|
1900
1947
|
// that was already there is restarted, once, when its database URLs or its
|
|
1901
1948
|
// declared appEnv changed.
|
|
1902
1949
|
// 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
|
-
|
|
1950
|
+
// change deploys instead (rule 3), and that carries the env too. A
|
|
1951
|
+
// created app is never deployed by Coolify itself (create sends no
|
|
1952
|
+
// `instant_deploy`), so it deploys here too (#566).
|
|
1953
|
+
if (settingsNeedDeploy || !existing || neverDeployed) {
|
|
1905
1954
|
// #543: every deploy waits for health through the shared helper, never
|
|
1906
1955
|
// a fire-and-forget `deploy` call -- `names[0]` is the same hostname
|
|
1907
1956
|
// `healthUrls` probes for this entry; an internal entry (no hostname)
|
|
@@ -1923,7 +1972,7 @@ export class FoundryStepExecutor {
|
|
|
1923
1972
|
// A port taken back to null was not patched, so the marker keeps the old
|
|
1924
1973
|
// one and `planApp` reports it instead of planning it.
|
|
1925
1974
|
context.recordScopeId(settingsKey, JSON.stringify({ ...want, port: want.port ?? have.port }));
|
|
1926
|
-
context.recordScopeId(domainsKey, JSON.stringify(
|
|
1975
|
+
context.recordScopeId(domainsKey, JSON.stringify(wantDomains));
|
|
1927
1976
|
if (wantLabelsHash)
|
|
1928
1977
|
context.recordScopeId(labelsKey, wantLabelsHash);
|
|
1929
1978
|
context.recordScopeId(appEnvAppliedScopeKey(entry.key), appEnv.applied);
|
|
@@ -1940,7 +1989,10 @@ export class FoundryStepExecutor {
|
|
|
1940
1989
|
// One Lettermint project, `<slug>-app`, belongs to the `app` entry; a
|
|
1941
1990
|
// second entry sharing the name would delete the first's live token.
|
|
1942
1991
|
if (entry.key === APP_ENTRY_KEY) {
|
|
1943
|
-
await this.ensureAppLettermintMail(appId, org, context,
|
|
1992
|
+
await this.ensureAppLettermintMail(appId, org, context,
|
|
1993
|
+
// Deployed or started above on every path, a created app included
|
|
1994
|
+
// (#566), so it has read its env before the mail env is written.
|
|
1995
|
+
true,
|
|
1944
1996
|
// #491 (review round 2, finding 3): `diff.ts`'s `planApps` always
|
|
1945
1997
|
// sets `platformApex` on this step's `desired` once it's planned at
|
|
1946
1998
|
// all -- `null` when no platform is declared, never omitted. The key
|
|
@@ -1969,9 +2021,9 @@ export class FoundryStepExecutor {
|
|
|
1969
2021
|
* container itself still writing nowhere). `wasAlreadyRunning` tells this
|
|
1970
2022
|
* method whether `appId` names an app `coolifyApp` found already there
|
|
1971
2023
|
* (restart it once the volume is created) or one it just created fresh
|
|
1972
|
-
* (
|
|
1973
|
-
*
|
|
1974
|
-
*
|
|
2024
|
+
* (`coolifyApp` deploys it after this call, #566, and that first start
|
|
2025
|
+
* carries the storage, so a restart here would only be a redundant extra
|
|
2026
|
+
* one).
|
|
1975
2027
|
*/
|
|
1976
2028
|
async ensureLogVolume(appId, org, wasAlreadyRunning, appName = org.slug) {
|
|
1977
2029
|
const storages = await this.ports.coolify.listStorages(appId);
|
|
@@ -2481,7 +2533,7 @@ export class FoundryStepExecutor {
|
|
|
2481
2533
|
await this.ports.coolify.bulkUpsertEnvs(appId, envUpdates);
|
|
2482
2534
|
// A running container read its env at start and still holds the old
|
|
2483
2535
|
// token, so a fresh one only reaches it through a restart -- before the
|
|
2484
|
-
// old project is deleted below. A just-created app
|
|
2536
|
+
// old project is deleted below. A just-created app is deployed first (#566).
|
|
2485
2537
|
if (!healthy && running)
|
|
2486
2538
|
await this.ports.coolify.restart(appId);
|
|
2487
2539
|
await this.ensureLettermintDomainRestriction(org, mailDomain, allProjects, platformApex, platformLettermintProjectId);
|
|
@@ -2994,7 +3046,7 @@ export class FoundryStepExecutor {
|
|
|
2994
3046
|
git_branch: 'main',
|
|
2995
3047
|
build_pack: 'dockerfile',
|
|
2996
3048
|
name: input.name,
|
|
2997
|
-
domains:
|
|
3049
|
+
domains: coolifyTunnelDomains([input.domain]),
|
|
2998
3050
|
dockerfile_location: input.dockerfileLocation,
|
|
2999
3051
|
});
|
|
3000
3052
|
return { appId: created.uuid, existed: false };
|
|
@@ -3690,7 +3742,7 @@ export class FoundryStepExecutor {
|
|
|
3690
3742
|
git_branch: 'main',
|
|
3691
3743
|
build_pack: 'dockerfile',
|
|
3692
3744
|
name,
|
|
3693
|
-
domains:
|
|
3745
|
+
domains: coolifyTunnelDomains([callsHostnameFor(org)]),
|
|
3694
3746
|
dockerfile_location: CALLS_SERVICE_DOCKERFILE,
|
|
3695
3747
|
}));
|
|
3696
3748
|
// A fixed internal name, like Synapse and MAS above, so the org's Agora
|
|
@@ -3973,7 +4025,7 @@ export class FoundryStepExecutor {
|
|
|
3973
4025
|
git_branch: 'main',
|
|
3974
4026
|
build_pack: 'dockerfile',
|
|
3975
4027
|
name,
|
|
3976
|
-
domains:
|
|
4028
|
+
domains: coolifyTunnelDomains([agoraHostnameFor(org)]),
|
|
3977
4029
|
dockerfile_location: AGORA_APP_DOCKERFILE,
|
|
3978
4030
|
ports_exposes: AGORA_APP_PORTS_EXPOSES,
|
|
3979
4031
|
}));
|
|
@@ -4083,7 +4135,7 @@ export class FoundryStepExecutor {
|
|
|
4083
4135
|
git_branch: 'main',
|
|
4084
4136
|
build_pack: 'dockerfile',
|
|
4085
4137
|
name,
|
|
4086
|
-
domains:
|
|
4138
|
+
domains: coolifyTunnelDomains([bouleHostnameFor(org)]),
|
|
4087
4139
|
dockerfile_location: BOULE_APP_DOCKERFILE,
|
|
4088
4140
|
}));
|
|
4089
4141
|
// Every app foundry deploys keeps its own logs (#75), reused as-is here.
|
|
@@ -4360,9 +4412,15 @@ export class FoundryStepExecutor {
|
|
|
4360
4412
|
// (#115) gets one ingress entry per hostname `tunnelIngressHostnames`
|
|
4361
4413
|
// returns, all routed to the same proxy -- Traefik itself, not this list,
|
|
4362
4414
|
// is what tells the public site apart from the cms studio.
|
|
4363
|
-
|
|
4415
|
+
// #568: the company's own login host goes straight to the platform's
|
|
4416
|
+
// login app (the same container the platform auth tunnel routes to), not
|
|
4417
|
+
// through Coolify's proxy, which has no route for it.
|
|
4418
|
+
const loginHost = action.desired.loginHost ?? null;
|
|
4419
|
+
const ingress = tunnelIngressHostnames(org, loginHost).map((hostname) => ({
|
|
4364
4420
|
hostname,
|
|
4365
|
-
service:
|
|
4421
|
+
service: hostname === loginHost
|
|
4422
|
+
? `http://${PLATFORM_AUTH_LOGIN_INTERNAL_NAME}:${AUTH_LOGIN_PORT}`
|
|
4423
|
+
: COOLIFY_PROXY_ORIGIN,
|
|
4366
4424
|
}));
|
|
4367
4425
|
await this.tunnelPort().putTunnelConfig(tunnel.id, {
|
|
4368
4426
|
ingress: [...ingress, { service: 'http_status:404' }],
|
|
@@ -4427,7 +4485,7 @@ export class FoundryStepExecutor {
|
|
|
4427
4485
|
const previousHostnamesRaw = recordedBefore[CLOUDFLARE_TUNNEL_DNS_HOSTNAMES_SCOPE_KEY];
|
|
4428
4486
|
const previousHostnames = previousHostnamesRaw ? previousHostnamesRaw.split(',') : [];
|
|
4429
4487
|
const domainChanged = previousDomain !== undefined && previousDomain !== org.domain;
|
|
4430
|
-
const publishedHostnames = await this.publishTunnelDnsRecords(org, org.domain, tunnel.id,
|
|
4488
|
+
const publishedHostnames = await this.publishTunnelDnsRecords(org, org.domain, loginHost, tunnel.id,
|
|
4431
4489
|
// Round 6 finding 2: every id this step has ever replaced, not only
|
|
4432
4490
|
// the one `recordedTunnelId` captured above -- see the scope key's
|
|
4433
4491
|
// own doc comment.
|
|
@@ -4590,7 +4648,7 @@ export class FoundryStepExecutor {
|
|
|
4590
4648
|
* overwriting them. `destroy` and {@link deleteStaleTunnelDnsRecords}
|
|
4591
4649
|
* both read this set.
|
|
4592
4650
|
*/
|
|
4593
|
-
async publishTunnelDnsRecords(org, domain, tunnelId, previousIds, previousHostnames, context) {
|
|
4651
|
+
async publishTunnelDnsRecords(org, domain, loginHost, tunnelId, previousIds, previousHostnames, context) {
|
|
4594
4652
|
const dns = this.dnsPort();
|
|
4595
4653
|
const zone = await this.zonePort().findZoneByApex(domain);
|
|
4596
4654
|
if (!zone) {
|
|
@@ -4598,7 +4656,7 @@ export class FoundryStepExecutor {
|
|
|
4598
4656
|
}
|
|
4599
4657
|
const dnsTarget = `${tunnelId}.cfargotunnel.com`;
|
|
4600
4658
|
const previousTargets = new Set([...previousIds].filter((id) => id !== tunnelId).map((id) => `${id}.cfargotunnel.com`));
|
|
4601
|
-
const hostnames = tunnelDnsHostnames(org, domain);
|
|
4659
|
+
const hostnames = tunnelDnsHostnames(org, domain, loginHost);
|
|
4602
4660
|
const droppedHostnames = previousHostnames.filter((hostname) => !hostnames.includes(hostname));
|
|
4603
4661
|
const conflicts = [];
|
|
4604
4662
|
const toCreate = [];
|
package/dist/plan/hostnames.d.ts
CHANGED
|
@@ -20,10 +20,22 @@ export declare function wildcardHostname(org: DeclaredOrg): string | null;
|
|
|
20
20
|
export declare function appHostname(org: DeclaredOrg): string;
|
|
21
21
|
/**
|
|
22
22
|
* Coolify's `domains` field: comma-separated full URLs. A bare hostname is a
|
|
23
|
-
* 422 ("Invalid URL"), so every write names each host as
|
|
23
|
+
* 422 ("Invalid URL"), so every write names each host as a URL. This helper
|
|
24
|
+
* writes `https://<host>` for the platform's apps; a company's apps are reached
|
|
25
|
+
* through `coolify-proxy:80` and use {@link coolifyTunnelDomains} (`http://`).
|
|
24
26
|
* Foundry's own records and proxy labels keep the bare names.
|
|
25
27
|
*/
|
|
26
28
|
export declare function coolifyDomains(names: readonly string[]): string;
|
|
29
|
+
/**
|
|
30
|
+
* The `domains` entries of a company's app, one URL per host. Every company
|
|
31
|
+
* tunnel delivers plain http to Traefik (`COOLIFY_PROXY_ORIGIN`), and Coolify
|
|
32
|
+
* puts a redirect-to-https on the http entry point of an `https://` domain,
|
|
33
|
+
* so a request through the tunnel would be redirected to itself forever. The
|
|
34
|
+
* `http://` form is served as it is; Cloudflare terminates TLS in front.
|
|
35
|
+
*/
|
|
36
|
+
export declare function tunnelDomainList(names: readonly string[]): string[];
|
|
37
|
+
/** {@link tunnelDomainList} as Coolify's comma-separated `domains` field. */
|
|
38
|
+
export declare function coolifyTunnelDomains(names: readonly string[]): string;
|
|
27
39
|
/** What `domains` held on an `app` entry's Coolify app before entries had hostnames. */
|
|
28
40
|
export declare function legacyAppHostname(org: DeclaredOrg): string;
|
|
29
41
|
/**
|
|
@@ -36,8 +48,10 @@ export declare function filesShareHostname(org: DeclaredOrg): string | null;
|
|
|
36
48
|
* The hostnames `cloudflareTunnel` routes to Coolify's proxy (#115, #145,
|
|
37
49
|
* #164): a site's two names, every entry's names, an own-domain calls name,
|
|
38
50
|
* Agora's and Boule's, then the wildcard last. An org that does not host keeps the single
|
|
39
|
-
* name it always had.
|
|
51
|
+
* name it always had. `loginHost` (#568) is the company's own `auth.<domain>`
|
|
52
|
+
* when it has one; it is listed before the wildcard, and the tunnel step routes
|
|
53
|
+
* it to the platform's login app, not to Coolify's proxy.
|
|
40
54
|
*/
|
|
41
|
-
export declare function tunnelIngressHostnames(org: DeclaredOrg): string[];
|
|
55
|
+
export declare function tunnelIngressHostnames(org: DeclaredOrg, loginHost?: string | null): string[];
|
|
42
56
|
/** 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[];
|
|
57
|
+
export declare function tunnelDnsHostnames(org: DeclaredOrg, domain: string, loginHost?: string | null): string[];
|
package/dist/plan/hostnames.js
CHANGED
|
@@ -37,12 +37,28 @@ export function appHostname(org) {
|
|
|
37
37
|
}
|
|
38
38
|
/**
|
|
39
39
|
* Coolify's `domains` field: comma-separated full URLs. A bare hostname is a
|
|
40
|
-
* 422 ("Invalid URL"), so every write names each host as
|
|
40
|
+
* 422 ("Invalid URL"), so every write names each host as a URL. This helper
|
|
41
|
+
* writes `https://<host>` for the platform's apps; a company's apps are reached
|
|
42
|
+
* through `coolify-proxy:80` and use {@link coolifyTunnelDomains} (`http://`).
|
|
41
43
|
* Foundry's own records and proxy labels keep the bare names.
|
|
42
44
|
*/
|
|
43
45
|
export function coolifyDomains(names) {
|
|
44
46
|
return names.map((name) => `https://${name}`).join(',');
|
|
45
47
|
}
|
|
48
|
+
/**
|
|
49
|
+
* The `domains` entries of a company's app, one URL per host. Every company
|
|
50
|
+
* tunnel delivers plain http to Traefik (`COOLIFY_PROXY_ORIGIN`), and Coolify
|
|
51
|
+
* puts a redirect-to-https on the http entry point of an `https://` domain,
|
|
52
|
+
* so a request through the tunnel would be redirected to itself forever. The
|
|
53
|
+
* `http://` form is served as it is; Cloudflare terminates TLS in front.
|
|
54
|
+
*/
|
|
55
|
+
export function tunnelDomainList(names) {
|
|
56
|
+
return names.map((name) => `http://${name}`);
|
|
57
|
+
}
|
|
58
|
+
/** {@link tunnelDomainList} as Coolify's comma-separated `domains` field. */
|
|
59
|
+
export function coolifyTunnelDomains(names) {
|
|
60
|
+
return tunnelDomainList(names).join(',');
|
|
61
|
+
}
|
|
46
62
|
/** What `domains` held on an `app` entry's Coolify app before entries had hostnames. */
|
|
47
63
|
export function legacyAppHostname(org) {
|
|
48
64
|
return org.hosting.usesDomain && org.domain ? org.domain : `${org.slug}.oikos.host`;
|
|
@@ -61,9 +77,11 @@ export function filesShareHostname(org) {
|
|
|
61
77
|
* The hostnames `cloudflareTunnel` routes to Coolify's proxy (#115, #145,
|
|
62
78
|
* #164): a site's two names, every entry's names, an own-domain calls name,
|
|
63
79
|
* Agora's and Boule's, then the wildcard last. An org that does not host keeps the single
|
|
64
|
-
* name it always had.
|
|
80
|
+
* name it always had. `loginHost` (#568) is the company's own `auth.<domain>`
|
|
81
|
+
* when it has one; it is listed before the wildcard, and the tunnel step routes
|
|
82
|
+
* it to the platform's login app, not to Coolify's proxy.
|
|
65
83
|
*/
|
|
66
|
-
export function tunnelIngressHostnames(org) {
|
|
84
|
+
export function tunnelIngressHostnames(org, loginHost = null) {
|
|
67
85
|
const hostnames = org.site.enabled && org.domain ? [org.domain, `cms.${org.domain}`] : [];
|
|
68
86
|
if (org.hosting.enabled) {
|
|
69
87
|
for (const entry of org.hosting.entries)
|
|
@@ -84,6 +102,8 @@ export function tunnelIngressHostnames(org) {
|
|
|
84
102
|
const share = filesShareHostname(org);
|
|
85
103
|
if (share)
|
|
86
104
|
hostnames.push(share);
|
|
105
|
+
if (loginHost)
|
|
106
|
+
hostnames.push(loginHost);
|
|
87
107
|
// The wildcard goes last: Cloudflare takes the first ingress rule that
|
|
88
108
|
// matches, so every exact name above must come before `*.<domain>`.
|
|
89
109
|
const wildcard = wildcardHostname(org);
|
|
@@ -92,6 +112,6 @@ export function tunnelIngressHostnames(org) {
|
|
|
92
112
|
return [...new Set(hostnames)];
|
|
93
113
|
}
|
|
94
114
|
/** 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}`));
|
|
115
|
+
export function tunnelDnsHostnames(org, domain, loginHost = null) {
|
|
116
|
+
return tunnelIngressHostnames(org, loginHost).filter((hostname) => hostname === domain || hostname.endsWith(`.${domain}`));
|
|
97
117
|
}
|
package/dist/plan/index.d.ts
CHANGED
|
@@ -5,7 +5,7 @@ export { computePlanDetail, toPlan } from './diff.js';
|
|
|
5
5
|
export type { StepSystem } from './step-system.js';
|
|
6
6
|
export { stepSystemOf } from './step-system.js';
|
|
7
7
|
export type { PlatformPlanDetail } from './platform-diff.js';
|
|
8
|
-
export { planPlatform, toPlatformPlan, JOBS_SCHEDULER_KEY, PLATFORM_AI_KEY, PLATFORM_AI_INTERNAL_URL_SCOPE_KEY, PLATFORM_AI_MANAGEMENT_KEY_SCOPE_KEY, PLATFORM_AI_ORGANISATION_SCOPE_KEY, PLATFORM_AUTH_KEY, PLATFORM_FILES_KEY, PLATFORM_FILES_INTERNAL_URL_SCOPE_KEY, PLATFORM_FILES_ZITADEL_PROJECT_SCOPE_KEY, PLATFORM_MAIL_KEY, PLATFORM_MAIL_INTERNAL_URL_SCOPE_KEY, PLATFORM_MAIL_ZITADEL_PROJECT_SCOPE_KEY, PLATFORM_EDGE_KEY, PLATFORM_EDGE_INTERNAL_URL_SCOPE_KEY, PLATFORM_EDGE_KEY_REF, PLATFORM_EDGE_CLOUDFLARE_TOKEN_REF, } from './platform-diff.js';
|
|
8
|
+
export { planPlatform, toPlatformPlan, JOBS_INTERNAL_URL_SCOPE_KEY, JOBS_SCHEDULER_KEY, PLATFORM_AI_KEY, PLATFORM_AI_INTERNAL_URL_SCOPE_KEY, PLATFORM_AI_MANAGEMENT_KEY_SCOPE_KEY, PLATFORM_AI_ORGANISATION_SCOPE_KEY, PLATFORM_AUTH_KEY, PLATFORM_FILES_KEY, PLATFORM_FILES_INTERNAL_URL_SCOPE_KEY, PLATFORM_FILES_ZITADEL_PROJECT_SCOPE_KEY, PLATFORM_MAIL_KEY, PLATFORM_MAIL_INTERNAL_URL_SCOPE_KEY, PLATFORM_MAIL_ZITADEL_PROJECT_SCOPE_KEY, PLATFORM_EDGE_KEY, PLATFORM_EDGE_INTERNAL_URL_SCOPE_KEY, PLATFORM_EDGE_KEY_REF, PLATFORM_EDGE_CLOUDFLARE_TOKEN_REF, } from './platform-diff.js';
|
|
9
9
|
export type { HeldKeyResolver, OrgPlatformReadiness } from './platform-org.js';
|
|
10
10
|
export { DEFAULT_ORG_PLATFORM_READINESS, ORG_PLATFORM_KEY_REFS, agoraHostValuesFromPlatform, heldPlatformKeyRefs, orgPlatformReadinessFrom, } from './platform-org.js';
|
|
11
11
|
export type { PlatformOrgClientFactories } from './platform-org-ports.js';
|
package/dist/plan/index.js
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
export { readLiveResources, readLivePlatformResources } from './live.js';
|
|
2
2
|
export { computePlanDetail, toPlan } from './diff.js';
|
|
3
3
|
export { stepSystemOf } from './step-system.js';
|
|
4
|
-
export { planPlatform, toPlatformPlan, JOBS_SCHEDULER_KEY, PLATFORM_AI_KEY, PLATFORM_AI_INTERNAL_URL_SCOPE_KEY, PLATFORM_AI_MANAGEMENT_KEY_SCOPE_KEY, PLATFORM_AI_ORGANISATION_SCOPE_KEY, PLATFORM_AUTH_KEY, PLATFORM_FILES_KEY, PLATFORM_FILES_INTERNAL_URL_SCOPE_KEY, PLATFORM_FILES_ZITADEL_PROJECT_SCOPE_KEY, PLATFORM_MAIL_KEY, PLATFORM_MAIL_INTERNAL_URL_SCOPE_KEY, PLATFORM_MAIL_ZITADEL_PROJECT_SCOPE_KEY, PLATFORM_EDGE_KEY, PLATFORM_EDGE_INTERNAL_URL_SCOPE_KEY, PLATFORM_EDGE_KEY_REF, PLATFORM_EDGE_CLOUDFLARE_TOKEN_REF, } from './platform-diff.js';
|
|
4
|
+
export { planPlatform, toPlatformPlan, JOBS_INTERNAL_URL_SCOPE_KEY, JOBS_SCHEDULER_KEY, PLATFORM_AI_KEY, PLATFORM_AI_INTERNAL_URL_SCOPE_KEY, PLATFORM_AI_MANAGEMENT_KEY_SCOPE_KEY, PLATFORM_AI_ORGANISATION_SCOPE_KEY, PLATFORM_AUTH_KEY, PLATFORM_FILES_KEY, PLATFORM_FILES_INTERNAL_URL_SCOPE_KEY, PLATFORM_FILES_ZITADEL_PROJECT_SCOPE_KEY, PLATFORM_MAIL_KEY, PLATFORM_MAIL_INTERNAL_URL_SCOPE_KEY, PLATFORM_MAIL_ZITADEL_PROJECT_SCOPE_KEY, PLATFORM_EDGE_KEY, PLATFORM_EDGE_INTERNAL_URL_SCOPE_KEY, PLATFORM_EDGE_KEY_REF, PLATFORM_EDGE_CLOUDFLARE_TOKEN_REF, } from './platform-diff.js';
|
|
5
5
|
export { DEFAULT_ORG_PLATFORM_READINESS, ORG_PLATFORM_KEY_REFS, agoraHostValuesFromPlatform, heldPlatformKeyRefs, orgPlatformReadinessFrom, } from './platform-org.js';
|
|
6
6
|
export { PlatformOrgBlockedError, PlatformOrgPorts } from './platform-org-ports.js';
|
|
7
7
|
export { AdoptRefusedError } from './adopt.js';
|
|
@@ -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
|
+
}
|
|
@@ -490,15 +490,19 @@ export declare function appSettingsAppliedScopeKey(entryKey: string): string;
|
|
|
490
490
|
/** The settings the app has: the marker, or the defaults when there is none. */
|
|
491
491
|
export declare function appliedAppSettings(recorded: Readonly<Record<string, string>>, entryKey: string): AppSettings;
|
|
492
492
|
/**
|
|
493
|
-
* The
|
|
494
|
-
* array (`[]` for an internal entry). `planApp`
|
|
495
|
-
* redeploy, when
|
|
493
|
+
* The URLs `coolifyApp` last put in the entry's Coolify `domains`
|
|
494
|
+
* (`tunnelDomainList`), as a JSON array (`[]` for an internal entry). `planApp`
|
|
495
|
+
* plans the step again, with a redeploy, when the wanted URLs differ from it.
|
|
496
|
+
* A marker of bare names, written before the `http://` form, never equals
|
|
497
|
+
* them, so an app made with the `https://` form is patched once.
|
|
496
498
|
*/
|
|
497
499
|
export declare function appDomainsAppliedScopeKey(entryKey: string): string;
|
|
498
500
|
/**
|
|
499
|
-
* The
|
|
500
|
-
* name it was created with (the `app` entry's address, nothing for any
|
|
501
|
-
* entry)
|
|
501
|
+
* The domains the app has: the marker, or for an app with no marker the one
|
|
502
|
+
* bare name it was created with (the `app` entry's address, nothing for any
|
|
503
|
+
* other entry). A bare name is never a URL `tunnelDomainList` writes, so an
|
|
504
|
+
* app with no marker is patched once: the scheme it was created with is
|
|
505
|
+
* unknown, bare before 4b2380f and `https://` after it.
|
|
502
506
|
*/
|
|
503
507
|
export declare function appliedAppDomains(org: DeclaredOrg, recorded: Readonly<Record<string, string>>, entryKey: string): string[];
|
|
504
508
|
/** Whether the secret sources can be filled right now: the mail key is held, and a new shared secret can be held. */
|
|
@@ -935,6 +939,13 @@ export declare function planPlatform(platform: DeclaredPlatform, live: LiveResou
|
|
|
935
939
|
* it, so the planner and the executor/reader can never key it apart).
|
|
936
940
|
*/
|
|
937
941
|
export declare const JOBS_SCHEDULER_KEY = "jobs";
|
|
942
|
+
/**
|
|
943
|
+
* The scheduler's address on the Coolify network (`http://<custom_internal_name>:8080`),
|
|
944
|
+
* the value the org steps' jobs port and Archon's `JOBS_URL` are read from.
|
|
945
|
+
* Not a secret. The scheduler's public hostname has no tunnel and no DNS
|
|
946
|
+
* record, so nothing on the platform can reach it there.
|
|
947
|
+
*/
|
|
948
|
+
export declare const JOBS_INTERNAL_URL_SCOPE_KEY = "jobs_internal_url";
|
|
938
949
|
/**
|
|
939
950
|
* #257: the six platform databases foundry itself creates, each keyed by
|
|
940
951
|
* the scope id `platform-providers.ts`'s own `createPostgresDatabase` call
|
|
@@ -533,17 +533,21 @@ export function appliedAppSettings(recorded, entryKey) {
|
|
|
533
533
|
return marker ? JSON.parse(marker) : DEFAULT_APP_SETTINGS;
|
|
534
534
|
}
|
|
535
535
|
/**
|
|
536
|
-
* The
|
|
537
|
-
* array (`[]` for an internal entry). `planApp`
|
|
538
|
-
* redeploy, when
|
|
536
|
+
* The URLs `coolifyApp` last put in the entry's Coolify `domains`
|
|
537
|
+
* (`tunnelDomainList`), as a JSON array (`[]` for an internal entry). `planApp`
|
|
538
|
+
* plans the step again, with a redeploy, when the wanted URLs differ from it.
|
|
539
|
+
* A marker of bare names, written before the `http://` form, never equals
|
|
540
|
+
* them, so an app made with the `https://` form is patched once.
|
|
539
541
|
*/
|
|
540
542
|
export function appDomainsAppliedScopeKey(entryKey) {
|
|
541
543
|
return `app_domains_applied.${entryKey}`;
|
|
542
544
|
}
|
|
543
545
|
/**
|
|
544
|
-
* The
|
|
545
|
-
* name it was created with (the `app` entry's address, nothing for any
|
|
546
|
-
* entry)
|
|
546
|
+
* The domains the app has: the marker, or for an app with no marker the one
|
|
547
|
+
* bare name it was created with (the `app` entry's address, nothing for any
|
|
548
|
+
* other entry). A bare name is never a URL `tunnelDomainList` writes, so an
|
|
549
|
+
* app with no marker is patched once: the scheme it was created with is
|
|
550
|
+
* unknown, bare before 4b2380f and `https://` after it.
|
|
547
551
|
*/
|
|
548
552
|
export function appliedAppDomains(org, recorded, entryKey) {
|
|
549
553
|
const marker = recorded[appDomainsAppliedScopeKey(entryKey)];
|
|
@@ -1129,6 +1133,13 @@ function planApexZone(platform, byKey, claimed) {
|
|
|
1129
1133
|
* it, so the planner and the executor/reader can never key it apart).
|
|
1130
1134
|
*/
|
|
1131
1135
|
export const JOBS_SCHEDULER_KEY = 'jobs';
|
|
1136
|
+
/**
|
|
1137
|
+
* The scheduler's address on the Coolify network (`http://<custom_internal_name>:8080`),
|
|
1138
|
+
* the value the org steps' jobs port and Archon's `JOBS_URL` are read from.
|
|
1139
|
+
* Not a secret. The scheduler's public hostname has no tunnel and no DNS
|
|
1140
|
+
* record, so nothing on the platform can reach it there.
|
|
1141
|
+
*/
|
|
1142
|
+
export const JOBS_INTERNAL_URL_SCOPE_KEY = 'jobs_internal_url';
|
|
1132
1143
|
/**
|
|
1133
1144
|
* #173: the jobs scheduler (https://github.com/wtfalch/jobs) itself, as a
|
|
1134
1145
|
* platform-level Coolify application with its own database -- provision-only,
|
|
@@ -1159,8 +1170,11 @@ function planJobs(platform, byKey, claimed, observable, recordedIds) {
|
|
|
1159
1170
|
// So does a recorded pending deploy: the step wrote its env and died
|
|
1160
1171
|
// before the deploy finished.
|
|
1161
1172
|
const databaseStatus = live.config.databaseStatus;
|
|
1173
|
+
// So does a missing internal address: a scheduler applied before the
|
|
1174
|
+
// address was recorded gets it from the next apply.
|
|
1162
1175
|
if (live.config.ownerUrlMissing !== true &&
|
|
1163
1176
|
!recordedIds[JOBS_DEPLOY_PENDING_SCOPE_KEY] &&
|
|
1177
|
+
recordedIds[JOBS_INTERNAL_URL_SCOPE_KEY] &&
|
|
1164
1178
|
(typeof databaseStatus !== 'string' || databaseStatus.startsWith('running'))) {
|
|
1165
1179
|
return { step: null, blocked: null };
|
|
1166
1180
|
}
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { HttpJobsSchedulerClient } from './jobs-scheduler-http.js';
|
|
2
|
-
import { PLATFORM_AI_INTERNAL_URL_SCOPE_KEY, PLATFORM_FILES_INTERNAL_URL_SCOPE_KEY, } from './platform-diff.js';
|
|
2
|
+
import { JOBS_INTERNAL_URL_SCOPE_KEY, PLATFORM_AI_INTERNAL_URL_SCOPE_KEY, PLATFORM_FILES_INTERNAL_URL_SCOPE_KEY, } from './platform-diff.js';
|
|
3
3
|
import { FILES_MANAGEMENT_ISSUER, FILES_MANAGEMENT_KEY_ID, FILES_MANAGEMENT_KEY_REF, JOBS_ADMIN_TOKEN_REF, PLATFORM_AI_MANAGEMENT_KEY_REF, } from './platform-providers.js';
|
|
4
4
|
/**
|
|
5
5
|
* #177: what `platform-org.ts`'s readiness could not see between a plan and
|
|
@@ -44,8 +44,9 @@ export class PlatformOrgPorts {
|
|
|
44
44
|
if (!platform?.jobs.enabled) {
|
|
45
45
|
throw new PlatformOrgBlockedError('jobs', 'the platform does not declare a jobs scheduler');
|
|
46
46
|
}
|
|
47
|
+
const baseUrl = await this.recorded('jobs', JOBS_INTERNAL_URL_SCOPE_KEY);
|
|
47
48
|
const token = await this.held('jobs', JOBS_ADMIN_TOKEN_REF);
|
|
48
|
-
return new HttpJobsSchedulerClient(
|
|
49
|
+
return new HttpJobsSchedulerClient(baseUrl, token, this.options.fetch);
|
|
49
50
|
}
|
|
50
51
|
async authHostname() {
|
|
51
52
|
return (await this.options.store.get())?.auth.hostname ?? null;
|
|
@@ -87,8 +87,14 @@ export interface OrgPlatformReadiness {
|
|
|
87
87
|
callsReady: boolean;
|
|
88
88
|
/** The platform's own sign-in (#168), which every org's own ZITADEL org lives inside. */
|
|
89
89
|
authReady: boolean;
|
|
90
|
-
/** The jobs scheduler (#173) is enabled, applied, and its admin token is held. */
|
|
90
|
+
/** The jobs scheduler (#173) is enabled, applied, has recorded its internal address, and its admin token is held. */
|
|
91
91
|
jobsReady: boolean;
|
|
92
|
+
/**
|
|
93
|
+
* The scheduler is applied but has recorded no internal address, so the
|
|
94
|
+
* org's registration cannot reach it; `diff.ts` reports the registration
|
|
95
|
+
* blocked with that reason. Absent when false.
|
|
96
|
+
*/
|
|
97
|
+
jobsUrlMissing?: boolean;
|
|
92
98
|
/**
|
|
93
99
|
* #317 (F7) and #478: the edge service is declared `enabled`, its own
|
|
94
100
|
* `coolify:app` deploy step has applied (`provisionPlatformEdge`), and
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { JOBS_SCHEDULER_KEY, PLATFORM_AI_INTERNAL_URL_SCOPE_KEY, PLATFORM_AI_KEY, PLATFORM_AUTH_KEY, PLATFORM_EDGE_KEY, PLATFORM_FILES_INTERNAL_URL_SCOPE_KEY, PLATFORM_FILES_KEY, PLATFORM_MAIL_INTERNAL_URL_SCOPE_KEY, PLATFORM_MAIL_KEY, PLATFORM_MAIL_ZITADEL_PROJECT_SCOPE_KEY, platformMailMxHost, } from './platform-diff.js';
|
|
1
|
+
import { JOBS_INTERNAL_URL_SCOPE_KEY, JOBS_SCHEDULER_KEY, PLATFORM_AI_INTERNAL_URL_SCOPE_KEY, PLATFORM_AI_KEY, PLATFORM_AUTH_KEY, PLATFORM_EDGE_KEY, PLATFORM_FILES_INTERNAL_URL_SCOPE_KEY, PLATFORM_FILES_KEY, PLATFORM_MAIL_INTERNAL_URL_SCOPE_KEY, PLATFORM_MAIL_KEY, PLATFORM_MAIL_ZITADEL_PROJECT_SCOPE_KEY, platformMailMxHost, } from './platform-diff.js';
|
|
2
2
|
import { FILES_MANAGEMENT_KEY_REF, JOBS_ADMIN_TOKEN_REF, PLATFORM_AI_MANAGEMENT_KEY_REF, } from './platform-providers.js';
|
|
3
3
|
/**
|
|
4
4
|
* #443: the no-domain company mail apex -- `<slug>.oikos.host`, the same
|
|
@@ -103,6 +103,7 @@ edgePortPresent = false) {
|
|
|
103
103
|
const filesApplied = platform.files.enabled && coolifyAppApplied(platformRecordedIds, PLATFORM_FILES_KEY);
|
|
104
104
|
const aiApplied = platform.ai.enabled && coolifyAppApplied(platformRecordedIds, PLATFORM_AI_KEY);
|
|
105
105
|
const jobsApplied = platform.jobs.enabled && Boolean(platformRecordedIds[`coolify:jobs:${JOBS_SCHEDULER_KEY}`]);
|
|
106
|
+
const jobsUrl = platformRecordedIds[JOBS_INTERNAL_URL_SCOPE_KEY];
|
|
106
107
|
const edgeApplied = platform.edge.enabled && coolifyAppApplied(platformRecordedIds, PLATFORM_EDGE_KEY);
|
|
107
108
|
const missing = (applied, ref) => applied && heldRefs !== undefined && !heldRefs.has(ref) ? ref : null;
|
|
108
109
|
const missingKeys = {
|
|
@@ -129,7 +130,8 @@ edgePortPresent = false) {
|
|
|
129
130
|
aiUrl: aiApplied ? (platformRecordedIds[PLATFORM_AI_INTERNAL_URL_SCOPE_KEY] ?? null) : null,
|
|
130
131
|
callsReady: platform.sfu.enabled,
|
|
131
132
|
authReady: platform.auth.enabled && coolifyAppApplied(platformRecordedIds, PLATFORM_AUTH_KEY),
|
|
132
|
-
jobsReady: jobsApplied && missingKeys.jobs === null,
|
|
133
|
+
jobsReady: jobsApplied && jobsUrl !== undefined && missingKeys.jobs === null,
|
|
134
|
+
...(jobsApplied && jobsUrl === undefined ? { jobsUrlMissing: true } : {}),
|
|
133
135
|
edgeReady: edgeApplied && edgePortPresent,
|
|
134
136
|
edgeUrl: edgeApplied && edgePortPresent ? `https://${platform.edge.hostname}` : null,
|
|
135
137
|
missingKeys,
|
|
@@ -2,6 +2,7 @@ import type { CloudflareClient } from '@wtfalch/providers/cloudflare';
|
|
|
2
2
|
import type { DeclaredOrg } from '../org.js';
|
|
3
3
|
import type { DeclaredPlatform } from '../platform.js';
|
|
4
4
|
import type { DeclaredOrgStore, PlatformStore } from '../store.js';
|
|
5
|
+
import { type DeployHealthRuntime } from './deploy-health.js';
|
|
5
6
|
import type { StepAction } from './diff.js';
|
|
6
7
|
import type { AccessPort } from './edge-access.js';
|
|
7
8
|
import type { DnsPort } from './edge-dns.js';
|
|
@@ -117,7 +118,7 @@ export declare const JOBS_ADMIN_TOKEN_REF = "jobs:scheduler:adminToken";
|
|
|
117
118
|
* org's own app, reused here for the platform's one scheduler instead of once
|
|
118
119
|
* per org.
|
|
119
120
|
*/
|
|
120
|
-
type JobsCoolifyPort = Pick<CoolifyPort, 'findProjectByName' | 'createProject' | 'findOrCreateEnvironment' | 'listServers' | 'requireGithubApp' | 'findApplicationByName' | 'createGithubAppApplication' | 'listDatabases' | 'createPostgresDatabase' | 'getDatabase' | 'raw' | 'bulkUpsertEnvs' | 'listEnvs' | 'getApplication' | '
|
|
121
|
+
type JobsCoolifyPort = Pick<CoolifyPort, 'findProjectByName' | 'createProject' | 'findOrCreateEnvironment' | 'listServers' | 'requireGithubApp' | 'findApplicationByName' | 'createGithubAppApplication' | 'listDatabases' | 'createPostgresDatabase' | 'getDatabase' | 'raw' | 'bulkUpsertEnvs' | 'listEnvs' | 'getApplication' | 'updateApplication' | 'deployAndWait'>;
|
|
121
122
|
/**
|
|
122
123
|
* Options for {@link FoundryPlatformStepExecutor}'s constructor, replacing
|
|
123
124
|
* four positional arguments that shifted on every merge that added a
|
|
@@ -133,6 +134,8 @@ export interface FoundryPlatformStepExecutorOptions {
|
|
|
133
134
|
* an error). Needed by `coolify:jobs` for the scheduler's admin token.
|
|
134
135
|
*/
|
|
135
136
|
deliverSecret?: (ref: string, secret: string) => Promise<void>;
|
|
137
|
+
/** Clock, sleep and deployment wait for `coolify:jobs`; real ones by default. */
|
|
138
|
+
runtime?: DeployHealthRuntime;
|
|
136
139
|
sfu?: CallsSfuPlatformDeps;
|
|
137
140
|
/**
|
|
138
141
|
* #168: dependencies for `coolify:app` keyed {@link PLATFORM_AUTH_KEY} --
|
|
@@ -264,6 +267,7 @@ export declare class FoundryPlatformStepExecutor implements PlatformStepExecutor
|
|
|
264
267
|
private readonly declaredOrgStore;
|
|
265
268
|
private readonly coolify?;
|
|
266
269
|
private readonly deliverSecret?;
|
|
270
|
+
private readonly runtime;
|
|
267
271
|
private readonly sfu?;
|
|
268
272
|
private readonly auth?;
|
|
269
273
|
private readonly mail?;
|
|
@@ -330,6 +334,8 @@ export declare class FoundryPlatformStepExecutor implements PlatformStepExecutor
|
|
|
330
334
|
private executeSfu;
|
|
331
335
|
private coolifyJobs;
|
|
332
336
|
}
|
|
337
|
+
export declare const PLATFORM_AUTH_LOGIN_INTERNAL_NAME = "platform-auth-login";
|
|
338
|
+
export declare const AUTH_LOGIN_PORT = "3000";
|
|
333
339
|
/**
|
|
334
340
|
* Every ZITADEL OIDC/management/console/asset path -- `deploy/proxy/Caddyfile`'s
|
|
335
341
|
* own explicit list, restated here as one regex since the tunnel routes by a
|
|
@@ -7,8 +7,8 @@ import { ensureCloudflaredConnector } from './cloudflared-connector.js';
|
|
|
7
7
|
import { waitForDeploymentFinished, waitForHealthyVersion, waitForNewDeployment, } from './deploy-health.js';
|
|
8
8
|
import { ACCESS_POLICY_NAME, getDatabaseStarted, isNotFoundError, isRunningOrStarting, } from './executor.js';
|
|
9
9
|
import { healthCheckSettings } from './health-check-settings.js';
|
|
10
|
-
import { coolifyDomains, filesShareHostname } from './hostnames.js';
|
|
11
|
-
import { AUTH_ZITADEL_RUN_OPTIONS, JOBS_DEPLOY_PENDING_SCOPE_KEY, JOBS_SCHEDULER_KEY, PLATFORM_AI_APPLIED_GITREF_SCOPE_KEY, PLATFORM_AI_APPLIED_HOSTNAME_SCOPE_KEY, PLATFORM_AI_DEPLOY_PENDING_SCOPE_KEY, PLATFORM_AI_INTERNAL_URL_SCOPE_KEY, PLATFORM_AI_KEY, PLATFORM_AI_MANAGEMENT_KEY_CONFLICT_SCOPE_KEY, PLATFORM_AI_MANAGEMENT_KEY_SCOPE_KEY, PLATFORM_AI_MANAGEMENT_KEY_UNDELIVERED_SCOPE_KEY, PLATFORM_AI_MIGRATE_BLOCKED_SCOPE_KEY, PLATFORM_AI_ORGANISATION_SCOPE_KEY, PLATFORM_AI_SIGNING_KEY_SET_SCOPE_KEY, PLATFORM_AI_SIGNING_KEY_UNDELIVERED_SCOPE_KEY, PLATFORM_ARCHON_APPLIED_BROKER_SCOPE_KEY, PLATFORM_ARCHON_APPLIED_GIT_REF_SCOPE_KEY, PLATFORM_ARCHON_APPLIED_HOSTNAME_SCOPE_KEY, PLATFORM_ARCHON_APPLIED_OPERATOR_SCOPE_KEY, PLATFORM_ARCHON_APPLIED_ROLLING_UPDATE_SCOPE_KEY, PLATFORM_ARCHON_CONNECTOR_APP_SCOPE_KEY, PLATFORM_ARCHON_COOLIFY_READ_TOKEN_REF, PLATFORM_ARCHON_DEPLOYMENT_SCOPE_KEY, PLATFORM_ARCHON_ENVS_DEPLOYED_SCOPE_KEY, PLATFORM_ARCHON_GATE_CLOSED_SCOPE_KEY, PLATFORM_ARCHON_INTEGRATIONS_KEY_SYNCED_SCOPE_KEY, PLATFORM_ARCHON_KEY, PLATFORM_ARCHON_MASTER_CLOUDFLARE_TOKEN_REF, PLATFORM_ARCHON_MASTER_COOLIFY_TOKEN_REF, PLATFORM_ARCHON_MASTER_COOLIFY_URL_REF, PLATFORM_AUTH_ADMIN_UNDELIVERED_SCOPE_KEY, PLATFORM_AUTH_ADMIN_USER_SCOPE_KEY, PLATFORM_AUTH_BROKER_DEPLOYED_SCOPE_KEY, PLATFORM_AUTH_BROKER_SCOPE_KEY, PLATFORM_AUTH_CONNECTOR_APP_SCOPE_KEY, PLATFORM_AUTH_INSTANCE_SCOPE_KEY, PLATFORM_AUTH_INSTANCE_UNDELIVERED_SCOPE_KEY, PLATFORM_AUTH_KEY, PLATFORM_AUTH_LOGIN_CLIENT_UNDELIVERED_SCOPE_KEY, PLATFORM_AUTH_LOGIN_CLIENT_USER_SCOPE_KEY, PLATFORM_AUTH_OWNER_INVITE_SCOPE_KEY, PLATFORM_AUTH_SMTP_SCOPE_KEY, PLATFORM_AUTH_ZITADEL_APPLIED_IMAGE_TAG_SCOPE_KEY, PLATFORM_AUTH_ZITADEL_RUN_OPTIONS_SCOPE_KEY, PLATFORM_BACKUP_DATABASE_SCOPE_KEYS, PLATFORM_BACKUP_SERVICE_KEYS, PLATFORM_EDGE_APPLIED_GIT_REF_SCOPE_KEY, PLATFORM_EDGE_APPLIED_HOSTNAME_SCOPE_KEY, PLATFORM_EDGE_CLOUDFLARE_TOKEN_REF, PLATFORM_EDGE_INTERNAL_URL_SCOPE_KEY, PLATFORM_EDGE_KEY, PLATFORM_EDGE_KEY_REF, PLATFORM_EDGE_PLATFORM_KEY_SET_SCOPE_KEY, PLATFORM_EDGE_PLATFORM_KEY_UNDELIVERED_SCOPE_KEY, PLATFORM_FILES_APPLIED_GIT_REF_SCOPE_KEY, PLATFORM_FILES_APPLIED_HOSTNAME_SCOPE_KEY, PLATFORM_FILES_INTERNAL_URL_SCOPE_KEY, PLATFORM_FILES_INTROSPECTION_SCOPE_KEY, PLATFORM_FILES_INTROSPECTION_UNDELIVERED_SCOPE_KEY, PLATFORM_FILES_KEY, PLATFORM_FILES_MANAGEMENT_KEY_SCOPE_KEY, PLATFORM_FILES_MANAGEMENT_UNDELIVERED_SCOPE_KEY, PLATFORM_FILES_ZITADEL_PROJECT_SCOPE_KEY, PLATFORM_HC_API_KEY_REF, PLATFORM_HC_APPLIED_GIT_REF_SCOPE_KEY, PLATFORM_HC_APPLIED_HOSTNAME_SCOPE_KEY, PLATFORM_HC_KEY, PLATFORM_HEARTBEATS_APPLIED_SCOPE_KEY, PLATFORM_INTEGRATIONS_APPLIED_GITREF_SCOPE_KEY, PLATFORM_INTEGRATIONS_APPLIED_HOSTNAME_SCOPE_KEY, PLATFORM_INTEGRATIONS_KEY, PLATFORM_INTEGRATIONS_KEY_ID_SCOPE_KEY, PLATFORM_INTEGRATIONS_KEY_ROTATION_PREVIOUS_SCOPE_KEY, PLATFORM_INTEGRATIONS_KEY_ROTATION_REQUESTED_SCOPE_KEY, PLATFORM_INTEGRATIONS_KEY_ROTATION_UNDELIVERED_SCOPE_KEY, PLATFORM_INTEGRATIONS_KEY_SET_SCOPE_KEY, PLATFORM_INTEGRATIONS_KEY_UNDELIVERED_SCOPE_KEY, PLATFORM_INTEGRATIONS_RUNNER_KEYRING_DEPLOYED_SCOPE_KEY, PLATFORM_MAIL_ADMIN_KEY_SCOPE_KEY, PLATFORM_MAIL_ADMIN_UNDELIVERED_SCOPE_KEY, PLATFORM_MAIL_APPLIED_GIT_REF_SCOPE_KEY, PLATFORM_MAIL_APPLIED_HOSTNAME_SCOPE_KEY, PLATFORM_MAIL_CONNECTOR_APP_SCOPE_KEY, PLATFORM_MAIL_INTERNAL_URL_SCOPE_KEY, PLATFORM_MAIL_KEY, PLATFORM_MAIL_ZITADEL_PROJECT_SCOPE_KEY, PLATFORM_UPTIME_HEARTBEAT_CHECK_NAME, archonBootstrapOperator, desiredHeartbeatCheckNames, heartbeatCheckUrlRef, parseBrokerDeployRecord, parseLoginClientRecord, platformBackupHeartbeatCheckName, platformMailMxHost, } from './platform-diff.js';
|
|
10
|
+
import { coolifyDomains, coolifyTunnelDomains, filesShareHostname } from './hostnames.js';
|
|
11
|
+
import { AUTH_ZITADEL_RUN_OPTIONS, JOBS_DEPLOY_PENDING_SCOPE_KEY, JOBS_INTERNAL_URL_SCOPE_KEY, JOBS_SCHEDULER_KEY, PLATFORM_AI_APPLIED_GITREF_SCOPE_KEY, PLATFORM_AI_APPLIED_HOSTNAME_SCOPE_KEY, PLATFORM_AI_DEPLOY_PENDING_SCOPE_KEY, PLATFORM_AI_INTERNAL_URL_SCOPE_KEY, PLATFORM_AI_KEY, PLATFORM_AI_MANAGEMENT_KEY_CONFLICT_SCOPE_KEY, PLATFORM_AI_MANAGEMENT_KEY_SCOPE_KEY, PLATFORM_AI_MANAGEMENT_KEY_UNDELIVERED_SCOPE_KEY, PLATFORM_AI_MIGRATE_BLOCKED_SCOPE_KEY, PLATFORM_AI_ORGANISATION_SCOPE_KEY, PLATFORM_AI_SIGNING_KEY_SET_SCOPE_KEY, PLATFORM_AI_SIGNING_KEY_UNDELIVERED_SCOPE_KEY, PLATFORM_ARCHON_APPLIED_BROKER_SCOPE_KEY, PLATFORM_ARCHON_APPLIED_GIT_REF_SCOPE_KEY, PLATFORM_ARCHON_APPLIED_HOSTNAME_SCOPE_KEY, PLATFORM_ARCHON_APPLIED_OPERATOR_SCOPE_KEY, PLATFORM_ARCHON_APPLIED_ROLLING_UPDATE_SCOPE_KEY, PLATFORM_ARCHON_CONNECTOR_APP_SCOPE_KEY, PLATFORM_ARCHON_COOLIFY_READ_TOKEN_REF, PLATFORM_ARCHON_DEPLOYMENT_SCOPE_KEY, PLATFORM_ARCHON_ENVS_DEPLOYED_SCOPE_KEY, PLATFORM_ARCHON_GATE_CLOSED_SCOPE_KEY, PLATFORM_ARCHON_INTEGRATIONS_KEY_SYNCED_SCOPE_KEY, PLATFORM_ARCHON_KEY, PLATFORM_ARCHON_MASTER_CLOUDFLARE_TOKEN_REF, PLATFORM_ARCHON_MASTER_COOLIFY_TOKEN_REF, PLATFORM_ARCHON_MASTER_COOLIFY_URL_REF, PLATFORM_AUTH_ADMIN_UNDELIVERED_SCOPE_KEY, PLATFORM_AUTH_ADMIN_USER_SCOPE_KEY, PLATFORM_AUTH_BROKER_DEPLOYED_SCOPE_KEY, PLATFORM_AUTH_BROKER_SCOPE_KEY, PLATFORM_AUTH_CONNECTOR_APP_SCOPE_KEY, PLATFORM_AUTH_INSTANCE_SCOPE_KEY, PLATFORM_AUTH_INSTANCE_UNDELIVERED_SCOPE_KEY, PLATFORM_AUTH_KEY, PLATFORM_AUTH_LOGIN_CLIENT_UNDELIVERED_SCOPE_KEY, PLATFORM_AUTH_LOGIN_CLIENT_USER_SCOPE_KEY, PLATFORM_AUTH_OWNER_INVITE_SCOPE_KEY, PLATFORM_AUTH_SMTP_SCOPE_KEY, PLATFORM_AUTH_ZITADEL_APPLIED_IMAGE_TAG_SCOPE_KEY, PLATFORM_AUTH_ZITADEL_RUN_OPTIONS_SCOPE_KEY, PLATFORM_BACKUP_DATABASE_SCOPE_KEYS, PLATFORM_BACKUP_SERVICE_KEYS, PLATFORM_EDGE_APPLIED_GIT_REF_SCOPE_KEY, PLATFORM_EDGE_APPLIED_HOSTNAME_SCOPE_KEY, PLATFORM_EDGE_CLOUDFLARE_TOKEN_REF, PLATFORM_EDGE_INTERNAL_URL_SCOPE_KEY, PLATFORM_EDGE_KEY, PLATFORM_EDGE_KEY_REF, PLATFORM_EDGE_PLATFORM_KEY_SET_SCOPE_KEY, PLATFORM_EDGE_PLATFORM_KEY_UNDELIVERED_SCOPE_KEY, PLATFORM_FILES_APPLIED_GIT_REF_SCOPE_KEY, PLATFORM_FILES_APPLIED_HOSTNAME_SCOPE_KEY, PLATFORM_FILES_INTERNAL_URL_SCOPE_KEY, PLATFORM_FILES_INTROSPECTION_SCOPE_KEY, PLATFORM_FILES_INTROSPECTION_UNDELIVERED_SCOPE_KEY, PLATFORM_FILES_KEY, PLATFORM_FILES_MANAGEMENT_KEY_SCOPE_KEY, PLATFORM_FILES_MANAGEMENT_UNDELIVERED_SCOPE_KEY, PLATFORM_FILES_ZITADEL_PROJECT_SCOPE_KEY, PLATFORM_HC_API_KEY_REF, PLATFORM_HC_APPLIED_GIT_REF_SCOPE_KEY, PLATFORM_HC_APPLIED_HOSTNAME_SCOPE_KEY, PLATFORM_HC_KEY, PLATFORM_HEARTBEATS_APPLIED_SCOPE_KEY, PLATFORM_INTEGRATIONS_APPLIED_GITREF_SCOPE_KEY, PLATFORM_INTEGRATIONS_APPLIED_HOSTNAME_SCOPE_KEY, PLATFORM_INTEGRATIONS_KEY, PLATFORM_INTEGRATIONS_KEY_ID_SCOPE_KEY, PLATFORM_INTEGRATIONS_KEY_ROTATION_PREVIOUS_SCOPE_KEY, PLATFORM_INTEGRATIONS_KEY_ROTATION_REQUESTED_SCOPE_KEY, PLATFORM_INTEGRATIONS_KEY_ROTATION_UNDELIVERED_SCOPE_KEY, PLATFORM_INTEGRATIONS_KEY_SET_SCOPE_KEY, PLATFORM_INTEGRATIONS_KEY_UNDELIVERED_SCOPE_KEY, PLATFORM_INTEGRATIONS_RUNNER_KEYRING_DEPLOYED_SCOPE_KEY, PLATFORM_MAIL_ADMIN_KEY_SCOPE_KEY, PLATFORM_MAIL_ADMIN_UNDELIVERED_SCOPE_KEY, PLATFORM_MAIL_APPLIED_GIT_REF_SCOPE_KEY, PLATFORM_MAIL_APPLIED_HOSTNAME_SCOPE_KEY, PLATFORM_MAIL_CONNECTOR_APP_SCOPE_KEY, PLATFORM_MAIL_INTERNAL_URL_SCOPE_KEY, PLATFORM_MAIL_KEY, PLATFORM_MAIL_ZITADEL_PROJECT_SCOPE_KEY, PLATFORM_UPTIME_HEARTBEAT_CHECK_NAME, archonBootstrapOperator, desiredHeartbeatCheckNames, heartbeatCheckUrlRef, parseBrokerDeployRecord, parseLoginClientRecord, platformBackupHeartbeatCheckName, platformMailMxHost, } from './platform-diff.js';
|
|
12
12
|
import { applyCallsSfuPlatformStep } from './platform-sfu.js';
|
|
13
13
|
import { CreateInstanceError, createZitadelInstance, generateSystemApiKeypair, instanceWithoutBearerMessage, systemApiUsersEnv, } from './zitadel-system-api.js';
|
|
14
14
|
/** The key `runPlatformSteps` records the apex zone's id under (`system:kind:key`). */
|
|
@@ -71,6 +71,9 @@ export const JOBS_ADMIN_TOKEN_REF = 'jobs:scheduler:adminToken';
|
|
|
71
71
|
const PLATFORM_COOLIFY_PROJECT_NAME = 'platform';
|
|
72
72
|
/** https://github.com/wtfalch/jobs -- the scheduler's own repo, Dockerfile at its root (its own `Dockerfile`'s doc comment). */
|
|
73
73
|
const JOBS_SCHEDULER_REPO = 'wtfalch/jobs';
|
|
74
|
+
/** The scheduler's fixed `custom_internal_name` and the port it listens on (jobs' `PORT`, default 8080). */
|
|
75
|
+
const JOBS_INTERNAL_NAME = 'platform-jobs';
|
|
76
|
+
const JOBS_PORT = '8080';
|
|
74
77
|
/**
|
|
75
78
|
* The real {@link PlatformStepExecutor} for the two steps built so far:
|
|
76
79
|
* `cloudflare:zone` for the platform's apex (#167) and `coolify:jobs` for the
|
|
@@ -140,6 +143,7 @@ export class FoundryPlatformStepExecutor {
|
|
|
140
143
|
declaredOrgStore;
|
|
141
144
|
coolify;
|
|
142
145
|
deliverSecret;
|
|
146
|
+
runtime;
|
|
143
147
|
sfu;
|
|
144
148
|
auth;
|
|
145
149
|
mail;
|
|
@@ -156,6 +160,7 @@ export class FoundryPlatformStepExecutor {
|
|
|
156
160
|
this.declaredOrgStore = declaredOrgStore;
|
|
157
161
|
this.coolify = options.coolify;
|
|
158
162
|
this.deliverSecret = options.deliverSecret;
|
|
163
|
+
this.runtime = options.runtime ?? {};
|
|
159
164
|
this.sfu = options.sfu;
|
|
160
165
|
this.auth = options.auth;
|
|
161
166
|
this.mail = options.mail;
|
|
@@ -615,6 +620,12 @@ export class FoundryPlatformStepExecutor {
|
|
|
615
620
|
});
|
|
616
621
|
appId = created.uuid;
|
|
617
622
|
}
|
|
623
|
+
// Set on every run, like `platform:files`' runner: the name another
|
|
624
|
+
// container reaches the scheduler by. It only takes effect on a deploy.
|
|
625
|
+
await coolify.updateApplication(appId, {
|
|
626
|
+
custom_internal_name: JOBS_INTERNAL_NAME,
|
|
627
|
+
ports_exposes: JOBS_PORT,
|
|
628
|
+
});
|
|
618
629
|
const db = await getDatabaseStarted(coolify, dbId);
|
|
619
630
|
if (!db.internal_db_url) {
|
|
620
631
|
throw new Error(`platform step coolify:jobs: database ${dbId} has no internal_db_url yet; not deploying the scheduler without a database`);
|
|
@@ -670,12 +681,23 @@ export class FoundryPlatformStepExecutor {
|
|
|
670
681
|
// a redeploy, so deploy after a create or a new token. Also when it is not
|
|
671
682
|
// running, so a deploy that failed on an earlier run is retried.
|
|
672
683
|
const running = (await coolify.getApplication(appId)).status?.startsWith('running') ?? false;
|
|
673
|
-
|
|
674
|
-
|
|
684
|
+
// An app applied before its internal address was recorded has no fixed
|
|
685
|
+
// name yet, so it deploys once to get it; after that it deploys on the
|
|
686
|
+
// other conditions only.
|
|
687
|
+
const addressNew = !context.recordedIds()[JOBS_INTERNAL_URL_SCOPE_KEY];
|
|
688
|
+
// The address is recorded only once the deployment finished: the scheduler
|
|
689
|
+
// has no route that answers without its admin token (every other path is a
|
|
690
|
+
// 401, 404 or 405), so the deployment record is the whole check, as for
|
|
691
|
+
// the AI worker. A deploy that fails or times out throws here, records
|
|
692
|
+
// nothing and leaves the step planned.
|
|
693
|
+
if (!existingApp || tokenJustSet || pending || !running || addressNew) {
|
|
694
|
+
await waitForNewDeployment(coolify, appId, { kind: 'deployment-only' }, this.runtime, this.runtime.sleep ?? defaultSleep, 'platform step coolify:jobs', 'the scheduler keeps its old address');
|
|
695
|
+
}
|
|
675
696
|
if (pending) {
|
|
676
697
|
context.recordScopeId(JOBS_DEPLOY_PENDING_SCOPE_KEY, '');
|
|
677
698
|
await context.persist?.();
|
|
678
699
|
}
|
|
700
|
+
context.recordScopeId(JOBS_INTERNAL_URL_SCOPE_KEY, `http://${JOBS_INTERNAL_NAME}:${JOBS_PORT}`);
|
|
679
701
|
return { resourceId: appId };
|
|
680
702
|
}
|
|
681
703
|
}
|
|
@@ -695,7 +717,7 @@ const PLATFORM_AUTH_BROKER_APP_NAME = 'platform-auth-broker';
|
|
|
695
717
|
* header and so cannot give two different apps the same public hostname.
|
|
696
718
|
*/
|
|
697
719
|
const PLATFORM_AUTH_ZITADEL_INTERNAL_NAME = 'platform-auth-zitadel';
|
|
698
|
-
const PLATFORM_AUTH_LOGIN_INTERNAL_NAME = 'platform-auth-login';
|
|
720
|
+
export const PLATFORM_AUTH_LOGIN_INTERNAL_NAME = 'platform-auth-login';
|
|
699
721
|
const PLATFORM_AUTH_BROKER_INTERNAL_NAME = 'platform-auth-broker';
|
|
700
722
|
const PLATFORM_AUTH_TUNNEL_NAME = 'platform-auth-tunnel';
|
|
701
723
|
/** #197: the one Coolify app that runs `cloudflared` for {@link PLATFORM_AUTH_TUNNEL_NAME}. */
|
|
@@ -714,7 +736,7 @@ const AUTH_BROKER_DOCKERFILE = '/broker/Dockerfile';
|
|
|
714
736
|
* own `ZITADEL_PORT: 8080` and the login image's own `EXPOSE`.
|
|
715
737
|
*/
|
|
716
738
|
const AUTH_ZITADEL_PORT = '8080';
|
|
717
|
-
const AUTH_LOGIN_PORT = '3000';
|
|
739
|
+
export const AUTH_LOGIN_PORT = '3000';
|
|
718
740
|
/** `broker/Dockerfile`'s own `ENV PORT=3000` / `EXPOSE 3000`. */
|
|
719
741
|
const AUTH_BROKER_PORT = '3000';
|
|
720
742
|
/**
|
|
@@ -3698,7 +3720,7 @@ async function collectArchonServiceInputs(platform, deps, recorded) {
|
|
|
3698
3720
|
const needs = service === 'jobs'
|
|
3699
3721
|
? {
|
|
3700
3722
|
stepKey: null,
|
|
3701
|
-
scopeKeys: [],
|
|
3723
|
+
scopeKeys: [JOBS_INTERNAL_URL_SCOPE_KEY],
|
|
3702
3724
|
refs: [JOBS_ADMIN_TOKEN_REF],
|
|
3703
3725
|
}
|
|
3704
3726
|
: ARCHON_SERVICE_INPUTS[service];
|
|
@@ -3784,7 +3806,11 @@ async function collectArchonServiceInputs(platform, deps, recorded) {
|
|
|
3784
3806
|
}, { key: 'INTEGRATIONS_MANAGEMENT_AUDIENCE', value: url });
|
|
3785
3807
|
}
|
|
3786
3808
|
else {
|
|
3787
|
-
envs.push(
|
|
3809
|
+
envs.push(
|
|
3810
|
+
// Known gap: an Archon that is already applied keeps its old `JOBS_URL`
|
|
3811
|
+
// until its step runs again, because the planner does not compare it.
|
|
3812
|
+
// Tracked in a separate issue.
|
|
3813
|
+
{ key: 'JOBS_URL', value: recorded[JOBS_INTERNAL_URL_SCOPE_KEY] }, { key: 'JOBS_ADMIN_TOKEN', value: held(JOBS_ADMIN_TOKEN_REF) });
|
|
3788
3814
|
}
|
|
3789
3815
|
}
|
|
3790
3816
|
return { envs };
|
|
@@ -7326,10 +7352,15 @@ export const PLATFORM_FILES_SHARE_DOMAINS_SCOPE_KEY = 'platform_files_share_doma
|
|
|
7326
7352
|
function normalHosts(hosts) {
|
|
7327
7353
|
return [...new Set((hosts ?? []).map((h) => h.trim().toLowerCase()).filter(Boolean))].sort();
|
|
7328
7354
|
}
|
|
7329
|
-
/**
|
|
7355
|
+
/**
|
|
7356
|
+
* The runner's Coolify `domains`, written only when the share set changed (a
|
|
7357
|
+
* runner nobody gave a share host keeps none). The share host is in the
|
|
7358
|
+
* company tunnel, which dials the proxy over plain http, so it is written
|
|
7359
|
+
* `http://`.
|
|
7360
|
+
*/
|
|
7330
7361
|
function filesShareDomainsPatch(recorded, shareDomains) {
|
|
7331
7362
|
return shareDomainsChanged(recorded, shareDomains)
|
|
7332
|
-
? { domains:
|
|
7363
|
+
? { domains: coolifyTunnelDomains(normalHosts(shareDomains)) }
|
|
7333
7364
|
: {};
|
|
7334
7365
|
}
|
|
7335
7366
|
/**
|