@wtfalch/foundry 0.17.1 → 0.19.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/bootstrap/declare.d.ts +11 -2
- package/dist/bootstrap/declare.js +20 -11
- package/dist/index.d.ts +4 -2
- package/dist/index.js +2 -1
- package/dist/migrations/0045_names_to_edge.sql +107 -0
- package/dist/org.d.ts +17 -17
- package/dist/org.js +48 -58
- package/dist/plan/agora.d.ts +25 -0
- package/dist/plan/agora.js +25 -0
- package/dist/plan/boule.d.ts +8 -0
- package/dist/plan/boule.js +8 -0
- package/dist/plan/diff.d.ts +3 -1
- package/dist/plan/diff.js +115 -52
- package/dist/plan/entry-edge-key.d.ts +79 -0
- package/dist/plan/{entry-names-key.js → entry-edge-key.js} +90 -66
- package/dist/plan/executor.d.ts +87 -42
- package/dist/plan/executor.js +277 -120
- package/dist/plan/index.d.ts +2 -2
- package/dist/plan/index.js +1 -1
- package/dist/plan/live.d.ts +3 -3
- package/dist/plan/login-host.d.ts +15 -11
- package/dist/plan/login-host.js +16 -16
- package/dist/plan/plan-apply.d.ts +6 -5
- package/dist/plan/plan-apply.js +40 -38
- package/dist/plan/platform-diff.d.ts +22 -22
- package/dist/plan/platform-diff.js +45 -45
- package/dist/plan/platform-org-ports.d.ts +1 -0
- package/dist/plan/platform-org-ports.js +3 -0
- package/dist/plan/platform-org.d.ts +8 -8
- package/dist/plan/platform-org.js +7 -7
- package/dist/plan/platform-providers.d.ts +17 -16
- package/dist/plan/platform-providers.js +136 -118
- package/dist/plan/providers-readers.d.ts +34 -11
- package/dist/plan/providers-readers.js +72 -15
- package/dist/plan/step-system.js +1 -1
- package/dist/plan/{edge-worker.d.ts → worker.d.ts} +53 -33
- package/dist/plan/{edge-worker.js → worker.js} +92 -54
- package/dist/platform.d.ts +3 -3
- package/dist/platform.js +1 -1
- package/dist/store/postgres.js +7 -7
- package/dist/store/schema.d.ts +6 -6
- package/dist/store/schema.js +4 -4
- package/package.json +1 -1
- package/dist/plan/entry-names-key.d.ts +0 -73
package/dist/plan/diff.d.ts
CHANGED
|
@@ -51,8 +51,10 @@ export interface PlanOrgOptions {
|
|
|
51
51
|
now?: () => number;
|
|
52
52
|
/** #374: the Archon that bills this org (`plan-apply.ts`'s `orgArchonUrl`), written into Boule's env as `ARCHON_URL`. `null` with no platform wired. */
|
|
53
53
|
archonUrl?: string | null;
|
|
54
|
-
/** #318: the executor was built with a `WorkersPort`, read by `
|
|
54
|
+
/** #318: the executor was built with a `WorkersPort`, read by `planWorkers`. */
|
|
55
55
|
edgeWorkers?: boolean;
|
|
56
|
+
/** The platform's own service hostnames: `edgeReservedLabels` reserves them under the org's domain, and a wildcard Worker route (`planWorkers`) keeps them out. */
|
|
57
|
+
platformHostnames?: readonly string[];
|
|
56
58
|
}
|
|
57
59
|
export interface PlanDetail {
|
|
58
60
|
orgId: OrgId;
|
package/dist/plan/diff.js
CHANGED
|
@@ -1,12 +1,11 @@
|
|
|
1
1
|
import { VERSIONS, floatingTagBlocked } from '../bootstrap/versions.js';
|
|
2
2
|
import { APP_ENTRY_KEY, ownerMailboxRecorded, ownerMailboxes } from '../org.js';
|
|
3
|
-
import { AGORA_ACTIVATED_ENV_SCOPE_KEY, CALLS_INTROSPECTION_SCOPE_KEY, CALLS_INTROSPECTION_UNDELIVERED_SCOPE_KEY, NO_AGORA_HOST_VALUES, ZITADEL_ORG_SCOPE_KEY, agoraEnvDrift, agoraEnvRevision, agoraHostMissing, agoraHostnameFor, callsServiceHasName, callsServiceHasTokenEnv, callsTokenEnvMovedMessage, callsTokenHostFrom, desiredAgoraEnv, withRecordedMessagingProject, } from './agora.js';
|
|
3
|
+
import { AGORA_ACTIVATED_ENV_SCOPE_KEY, AGORA_APP_PORTS_EXPOSES, CALLS_INTROSPECTION_SCOPE_KEY, CALLS_INTROSPECTION_UNDELIVERED_SCOPE_KEY, NO_AGORA_HOST_VALUES, ZITADEL_ORG_SCOPE_KEY, agoraEnvDrift, agoraEnvRevision, agoraHostMissing, agoraHostnameFor, callsServiceHasName, callsServiceHasTokenEnv, callsTokenEnvMovedMessage, callsTokenHostFrom, desiredAgoraEnv, withRecordedMessagingProject, } from './agora.js';
|
|
4
4
|
import { BOULE_BROKER_ENTRY_KEY, bouleEnvDrift, desiredBouleEnv, wantedBouleBrokerEntry, } from './boule.js';
|
|
5
5
|
import { brokerEntryAppliedScopeKey, brokerEntryWarningsScopeKey, entryAuthEnv, entryAuthEnvAppliedScopeKey, wantedBrokerEntry, } from './broker-entries.js';
|
|
6
|
-
import { planEdgeWorkers } from './edge-worker.js';
|
|
7
6
|
import { planEntryAiKeys } from './entry-ai-key.js';
|
|
7
|
+
import { planEdgeReservedLabels, planEntryEdgeKeys } from './entry-edge-key.js';
|
|
8
8
|
import { planFilesKeys } from './entry-files-key.js';
|
|
9
|
-
import { planEntryNamesKeys, planNamesReservedLabels } from './entry-names-key.js';
|
|
10
9
|
import { appHostname, entryHostnames, tunnelDnsHostnames } from './hostnames.js';
|
|
11
10
|
import { LOGIN_BINDING_SCOPE_KEY, loginBindingState, loginHostClientIds, loginHostnameFor, pendingRemovalHosts, } from './login-host.js';
|
|
12
11
|
import { MAIL_COPY_COMPLETED_SCOPE_KEY, MAIL_COPY_DONE_SCOPE_KEY, parseMailCopyCompleted, } from './mail-copy.js';
|
|
@@ -15,6 +14,7 @@ import { DEFAULT_ORG_PLATFORM_READINESS } from './platform-org.js';
|
|
|
15
14
|
import { PLATFORM_AUTH_BROKER_MAIL_KEY_REF } from './platform-providers.js';
|
|
16
15
|
import { appLabelsAppliedScopeKey, entryLabelHash, entryLabelSpec } from './proxy-labels.js';
|
|
17
16
|
import { withStepSystem } from './step-system.js';
|
|
17
|
+
import { planWorkers } from './worker.js';
|
|
18
18
|
/** The `Plan` (#19's public contract) a `PlanDetail` carries. */
|
|
19
19
|
export function toPlan(detail) {
|
|
20
20
|
return {
|
|
@@ -82,7 +82,11 @@ export function computePlanDetail(org, live, recordedIds = {}, agoraHost = NO_AG
|
|
|
82
82
|
const byKey = new Map(live.map((r) => [resourceKey(r.system, r.kind, r.key), r]));
|
|
83
83
|
const claimed = new Set();
|
|
84
84
|
const plannedSteps = [];
|
|
85
|
-
|
|
85
|
+
// The platform's own org: its domain is the platform apex (the same fact
|
|
86
|
+
// #491 reads for mail). It signs in on the shared instance's own host, so
|
|
87
|
+
// `issuer.usesDomain` never moves it.
|
|
88
|
+
const isPlatformOrg = isPlatformOwnOrg(org, platformReadiness.platformApex);
|
|
89
|
+
const issuer = planIssuer(org, byKey, claimed, platformReadiness.authReady, isPlatformOrg);
|
|
86
90
|
if (issuer.step)
|
|
87
91
|
plannedSteps.push(issuer.step);
|
|
88
92
|
plannedSteps.push(...planOwners(org, recordedIds, platformReadiness.authReady, 'error' in agoraHost ? null : agoraHost.issuer));
|
|
@@ -94,7 +98,7 @@ export function computePlanDetail(org, live, recordedIds = {}, agoraHost = NO_AG
|
|
|
94
98
|
// URL (`https://<platform.auth.hostname>`), the same value every org's
|
|
95
99
|
// sign-in already reads through `planOwners` above -- no separate port
|
|
96
100
|
// or plumbing needed to know the platform's own auth hostname.
|
|
97
|
-
platformAuthHostnameFrom('error' in agoraHost ? null : agoraHost.issuer));
|
|
101
|
+
platformAuthHostnameFrom('error' in agoraHost ? null : agoraHost.issuer), isPlatformOrg);
|
|
98
102
|
if (loginHost.step)
|
|
99
103
|
plannedSteps.push(loginHost.step);
|
|
100
104
|
// Planned ahead of everything that needs the zone live (#238); its own step
|
|
@@ -206,14 +210,14 @@ export function computePlanDetail(org, live, recordedIds = {}, agoraHost = NO_AG
|
|
|
206
210
|
plannedSteps.push(...filesKeys.steps);
|
|
207
211
|
// F7 (#317): reserved labels before any entry's own key, so a label is
|
|
208
212
|
// never claimable by a customer in the gap between an entry's key going
|
|
209
|
-
// live and its siblings' labels reaching
|
|
210
|
-
const
|
|
211
|
-
if (
|
|
212
|
-
plannedSteps.push(
|
|
213
|
-
// After the apps -- a
|
|
213
|
+
// live and its siblings' labels reaching edge.
|
|
214
|
+
const edgeReservedLabels = planEdgeReservedLabels(org, recordedIds, platformReadiness, options.platformHostnames ?? []);
|
|
215
|
+
if (edgeReservedLabels.step)
|
|
216
|
+
plannedSteps.push(edgeReservedLabels.step);
|
|
217
|
+
// After the apps -- a edge key needs the entry's app recorded, the same
|
|
214
218
|
// dependency `planEntryAiKeys`/`planFilesKeys` have.
|
|
215
|
-
const
|
|
216
|
-
plannedSteps.push(...
|
|
219
|
+
const entryEdgeKeys = planEntryEdgeKeys(org, recordedIds, plannedSteps, platformReadiness, (options.now ?? Date.now)());
|
|
220
|
+
plannedSteps.push(...entryEdgeKeys.steps);
|
|
217
221
|
const tunnel = planTunnel(org, byKey, claimed, recordedIds);
|
|
218
222
|
// #238: a tunnel that is being provisioned waits for an active zone; parking
|
|
219
223
|
// one never does.
|
|
@@ -274,16 +278,16 @@ export function computePlanDetail(org, live, recordedIds = {}, agoraHost = NO_AG
|
|
|
274
278
|
// F8 (#318), after Agora and Boule, not right after the zone: a route
|
|
275
279
|
// already bound by a hand-made entry, another entry's route, a taken
|
|
276
280
|
// script name, or no Workers token all throw at apply, and `runSteps`
|
|
277
|
-
// stops at the first failure -- planned this early, a failing
|
|
281
|
+
// stops at the first failure -- planned this early, a failing worker step
|
|
278
282
|
// stopped Agora (and everything after it) from provisioning at all
|
|
279
283
|
// (#318 re-review). `cloudflareWorker` still resolves the zone by apex
|
|
280
284
|
// domain the same way `planRateLimit` does, so a deploy (never a
|
|
281
285
|
// retire, which needs no new zone) still waits for it.
|
|
282
|
-
const edgeWorkers =
|
|
286
|
+
const edgeWorkers = planWorkers(org, recordedIds, options.edgeWorkers ?? false, options.platformHostnames ?? []);
|
|
283
287
|
for (const step of edgeWorkers.steps) {
|
|
284
288
|
if (zoneWaiting && step.action.desired.mode === 'apply') {
|
|
285
289
|
zoneBlocked.push({
|
|
286
|
-
description: `${org.slug}'s ${step.action.key.slice(org.id.length + 1)}
|
|
290
|
+
description: `${org.slug}'s ${step.action.key.slice(org.id.length + 1)} Worker is blocked, not planned: the zone for ${org.domain} is not active yet`,
|
|
287
291
|
key: step.action.key,
|
|
288
292
|
});
|
|
289
293
|
}
|
|
@@ -312,8 +316,8 @@ export function computePlanDetail(org, live, recordedIds = {}, agoraHost = NO_AG
|
|
|
312
316
|
unmanaged.push(...filesKeys.blocked);
|
|
313
317
|
unmanaged.push(...entryAiKeys.blocked);
|
|
314
318
|
unmanaged.push(...edgeWorkers.blocked);
|
|
315
|
-
unmanaged.push(...
|
|
316
|
-
unmanaged.push(...
|
|
319
|
+
unmanaged.push(...entryEdgeKeys.blocked);
|
|
320
|
+
unmanaged.push(...edgeReservedLabels.blocked);
|
|
317
321
|
// Same for a jobs registration that cannot run (no scheduler wired) or must
|
|
318
322
|
// not run again (minted, never delivered).
|
|
319
323
|
if (jobsRegistration.blocked) {
|
|
@@ -391,7 +395,7 @@ export function computePlanDetail(org, live, recordedIds = {}, agoraHost = NO_AG
|
|
|
391
395
|
* already exists live is unaffected: its own domain-move logic runs
|
|
392
396
|
* regardless, since that never re-provisions the ZITADEL org itself.
|
|
393
397
|
*/
|
|
394
|
-
function planIssuer(org, byKey, claimed, authReady) {
|
|
398
|
+
function planIssuer(org, byKey, claimed, authReady, isPlatformOrg = false) {
|
|
395
399
|
const key = resourceKey('zitadel', 'issuer', org.id);
|
|
396
400
|
const live = byKey.get(key);
|
|
397
401
|
const desiredUsesDomain = org.issuer.usesDomain;
|
|
@@ -420,9 +424,14 @@ function planIssuer(org, byKey, claimed, authReady) {
|
|
|
420
424
|
};
|
|
421
425
|
}
|
|
422
426
|
claimed.add(key);
|
|
423
|
-
|
|
424
|
-
|
|
427
|
+
// The platform's own org already signs in on the shared host: no move. A
|
|
428
|
+
// live issuer that does not say (the reader never does: foundry v1 never
|
|
429
|
+
// moves one) is not read as a different one either.
|
|
430
|
+
if (isPlatformOrg ||
|
|
431
|
+
live.config.usesDomain === undefined ||
|
|
432
|
+
Boolean(live.config.usesDomain) === desiredUsesDomain) {
|
|
425
433
|
return { step: null, blocked: null };
|
|
434
|
+
}
|
|
426
435
|
const domain = org.domain ?? 'the org domain';
|
|
427
436
|
return {
|
|
428
437
|
step: {
|
|
@@ -519,6 +528,9 @@ function planOwners(org, recordedIds, authReady, authIssuer) {
|
|
|
519
528
|
* computed `auth.<domain>` against, so the platform's own org never plans
|
|
520
529
|
* its login host equal to the shared ZITADEL instance's own.
|
|
521
530
|
*/
|
|
531
|
+
function isPlatformOwnOrg(org, platformApex) {
|
|
532
|
+
return Boolean(org.domain && platformApex && org.domain.toLowerCase() === platformApex.toLowerCase());
|
|
533
|
+
}
|
|
522
534
|
function platformAuthHostnameFrom(issuer) {
|
|
523
535
|
if (!issuer)
|
|
524
536
|
return null;
|
|
@@ -565,16 +577,58 @@ function platformAuthHostnameFrom(issuer) {
|
|
|
565
577
|
* platform's own domain, never plans the shared ZITADEL instance's own login
|
|
566
578
|
* host as its own -- `auth.companyincluded.com` colliding with itself.
|
|
567
579
|
*/
|
|
568
|
-
function planLoginHost(org, recordedIds, authReady, issuerPlanned, bindingsWired, platformAuthHostname) {
|
|
580
|
+
function planLoginHost(org, recordedIds, authReady, issuerPlanned, bindingsWired, platformAuthHostname, isPlatformOrg) {
|
|
569
581
|
const none = { step: null, blocked: null };
|
|
570
582
|
const key = resourceKey('zitadel', 'login-host', org.id);
|
|
571
|
-
|
|
583
|
+
// The platform's own org signs in on the shared instance's host whatever
|
|
584
|
+
// `issuer.usesDomain` says (turning it on would move the issuer).
|
|
585
|
+
// Before that host is known, plan nothing: a guessed host would delete the
|
|
586
|
+
// working one, and a normal step would trust a domain `destroy` later deletes.
|
|
587
|
+
if (isPlatformOrg && platformAuthHostname === null) {
|
|
588
|
+
return {
|
|
589
|
+
step: null,
|
|
590
|
+
blocked: `${org.slug}'s login host is waiting for the platform auth host`,
|
|
591
|
+
};
|
|
592
|
+
}
|
|
593
|
+
const sharedInstanceHost = isPlatformOrg && platformAuthHostname !== null;
|
|
594
|
+
const host = sharedInstanceHost ? platformAuthHostname : loginHostnameFor(org);
|
|
572
595
|
// #488: the guard in `loginHostnameFor` fired -- the org's own domain host
|
|
573
596
|
// would have been the platform's shared ZITADEL instance host.
|
|
574
|
-
|
|
575
|
-
|
|
576
|
-
|
|
577
|
-
host
|
|
597
|
+
if (sharedInstanceHost) {
|
|
598
|
+
// The host is the shared instance's own: nothing to trust or bind, and an
|
|
599
|
+
// org with no recorded host already signs in there. Only an org recorded
|
|
600
|
+
// on another host (the old `<slug>-auth.oikos.host`) plans one step, to
|
|
601
|
+
// move its apps over and remove the old host; then it plans nothing.
|
|
602
|
+
const recordedHost = recordedIds[key];
|
|
603
|
+
const moving = recordedHost !== undefined && recordedHost !== host;
|
|
604
|
+
const removalOwed = pendingRemovalHosts(recordedIds).length > 0;
|
|
605
|
+
if (!authReady || !(moving || removalOwed))
|
|
606
|
+
return none;
|
|
607
|
+
// The same gates as the normal branch: the executor's shared step also
|
|
608
|
+
// needs the binding port and the ZITADEL org.
|
|
609
|
+
const waiting = loginHostWaiting(org, host, recordedIds, bindingsWired, issuerPlanned);
|
|
610
|
+
return waiting ?? stepFor(true);
|
|
611
|
+
}
|
|
612
|
+
function stepFor(shared) {
|
|
613
|
+
return {
|
|
614
|
+
step: {
|
|
615
|
+
action: {
|
|
616
|
+
system: 'zitadel',
|
|
617
|
+
kind: 'login-host',
|
|
618
|
+
key: org.id,
|
|
619
|
+
desired: shared ? { host, shared: true } : { host },
|
|
620
|
+
},
|
|
621
|
+
step: {
|
|
622
|
+
description: shared
|
|
623
|
+
? `move ${org.slug}'s apps onto the platform's own sign-in host ${host}`
|
|
624
|
+
: `set ${org.slug}'s login host to ${host}`,
|
|
625
|
+
destroys: false,
|
|
626
|
+
migrates: false,
|
|
627
|
+
},
|
|
628
|
+
},
|
|
629
|
+
blocked: null,
|
|
630
|
+
};
|
|
631
|
+
}
|
|
578
632
|
// Already applied -- a re-run plans nothing unless the host changed, an app
|
|
579
633
|
// client id appeared since the binding was last written (#267, rule 4), or
|
|
580
634
|
// an earlier host change still has a removal owed (#274 fix round 2):
|
|
@@ -595,6 +649,10 @@ function planLoginHost(org, recordedIds, authReady, issuerPlanned, bindingsWired
|
|
|
595
649
|
// Isolation between orgs rests on zitadel-login's binding, and repointing
|
|
596
650
|
// an app at a host with no binding breaks its sign-in: trust nothing and
|
|
597
651
|
// repoint nothing until the port that writes the binding is wired.
|
|
652
|
+
return loginHostWaiting(org, host, recordedIds, bindingsWired, issuerPlanned) ?? stepFor(false);
|
|
653
|
+
}
|
|
654
|
+
/** Why a login-host step cannot run yet, or `null` when nothing is missing. */
|
|
655
|
+
function loginHostWaiting(org, host, recordedIds, bindingsWired, issuerPlanned) {
|
|
598
656
|
if (!bindingsWired) {
|
|
599
657
|
return {
|
|
600
658
|
step: null,
|
|
@@ -609,24 +667,7 @@ function planLoginHost(org, recordedIds, authReady, issuerPlanned, bindingsWired
|
|
|
609
667
|
blocked: `${org.slug}'s login host ${host} waits for its ZITADEL org, which is not recorded yet`,
|
|
610
668
|
};
|
|
611
669
|
}
|
|
612
|
-
return
|
|
613
|
-
step: {
|
|
614
|
-
action: {
|
|
615
|
-
system: 'zitadel',
|
|
616
|
-
kind: 'login-host',
|
|
617
|
-
key: org.id,
|
|
618
|
-
desired: { host },
|
|
619
|
-
},
|
|
620
|
-
step: {
|
|
621
|
-
description: sharedInstanceHost
|
|
622
|
-
? `set ${org.slug}'s login host to ${host}: its domain host (auth.${org.domain}) is the platform's own shared ZITADEL instance host, so it stays on oikos.host`
|
|
623
|
-
: `set ${org.slug}'s login host to ${host}`,
|
|
624
|
-
destroys: false,
|
|
625
|
-
migrates: false,
|
|
626
|
-
},
|
|
627
|
-
},
|
|
628
|
-
blocked: null,
|
|
629
|
-
};
|
|
670
|
+
return null;
|
|
630
671
|
}
|
|
631
672
|
/** #230: restated from `executor.ts`'s `MAIL_ROLE_MAILBOXES_SCOPE_KEY`, same rule: comma-joined local parts already created. */
|
|
632
673
|
const MAIL_ROLE_MAILBOXES_SCOPE_KEY = 'mail_role_mailboxes';
|
|
@@ -1594,6 +1635,13 @@ function planAgora(org, byKey, claimed, recordedIds, reading) {
|
|
|
1594
1635
|
if (liveEnv.AGORA_CONFIG === undefined && !drift.includes('AGORA_CONFIG')) {
|
|
1595
1636
|
drift.push('AGORA_CONFIG');
|
|
1596
1637
|
}
|
|
1638
|
+
// #506: an app Coolify never got `ports_exposes` on (or got the wrong one)
|
|
1639
|
+
// answers 502 behind the tunnel even though its env already matches --
|
|
1640
|
+
// exactly the Company Included incident this issue names. Compared
|
|
1641
|
+
// separately from `drift` above: it is a Coolify application setting, not
|
|
1642
|
+
// an env var, and `providers-readers.ts`'s `withAgoraEnvConfig` reads it
|
|
1643
|
+
// from `getApplication`, never `listEnvs`.
|
|
1644
|
+
const portDrift = live.config.ports_exposes !== AGORA_APP_PORTS_EXPOSES;
|
|
1597
1645
|
// #442: `drift` alone, once this step has ever run, is comparing `env`
|
|
1598
1646
|
// against Coolify's own env store -- which the executor's `bulkUpsertEnvs`
|
|
1599
1647
|
// leaves matching `env` the moment the write succeeds, restarted or not.
|
|
@@ -1604,14 +1652,18 @@ function planAgora(org, byKey, claimed, recordedIds, reading) {
|
|
|
1604
1652
|
// actually serving this exact `env`; while it disagrees, the step stays
|
|
1605
1653
|
// planned no matter what Coolify's env store already shows.
|
|
1606
1654
|
const activated = recordedIds[AGORA_ACTIVATED_ENV_SCOPE_KEY] === agoraEnvRevision(env);
|
|
1607
|
-
if (drift.length === 0 && activated)
|
|
1655
|
+
if (drift.length === 0 && activated && !portDrift)
|
|
1608
1656
|
return { step: null, blocked: null };
|
|
1657
|
+
const reasons = [
|
|
1658
|
+
...(drift.length > 0 ? [`${drift.join(', ')} missing or different`] : []),
|
|
1659
|
+
...(portDrift ? ["ports_exposes wrong -- Traefik can't reach it"] : []),
|
|
1660
|
+
];
|
|
1609
1661
|
return {
|
|
1610
1662
|
step: {
|
|
1611
1663
|
action,
|
|
1612
1664
|
step: {
|
|
1613
|
-
description:
|
|
1614
|
-
? `update ${org.slug}'s Agora config (${
|
|
1665
|
+
description: reasons.length > 0
|
|
1666
|
+
? `update ${org.slug}'s Agora config (${reasons.join('; ')})`
|
|
1615
1667
|
: `activate ${org.slug}'s Agora config -- written but not yet confirmed restarted`,
|
|
1616
1668
|
destroys: false,
|
|
1617
1669
|
migrates: false,
|
|
@@ -1735,13 +1787,13 @@ function planBoule(org, byKey, claimed, recordedIds, issuer, otherSlugs, archonU
|
|
|
1735
1787
|
* park-not-delete rule (#7 corrections 1) -- turning it off never deletes
|
|
1736
1788
|
* the org's own app, database or tunnel configuration, it parks it.
|
|
1737
1789
|
*/
|
|
1738
|
-
function planHostingToggle(system, kind, desiredEnabled, org, byKey, claimed, describeProvision, describePark, stepKey = org.id) {
|
|
1790
|
+
function planHostingToggle(system, kind, desiredEnabled, org, byKey, claimed, describeProvision, describePark, stepKey = org.id, isOn = (live) => live.config.enabled !== false) {
|
|
1739
1791
|
const key = resourceKey(system, kind, stepKey);
|
|
1740
1792
|
const live = byKey.get(key);
|
|
1741
1793
|
// A live resource is on unless it says it is parked. Providers' own
|
|
1742
1794
|
// configs carry no `enabled` field at all, so only a park step's
|
|
1743
1795
|
// explicit `enabled: false` reads as off.
|
|
1744
|
-
const liveEnabled = live !== undefined && live
|
|
1796
|
+
const liveEnabled = live !== undefined && isOn(live);
|
|
1745
1797
|
if (!desiredEnabled) {
|
|
1746
1798
|
// Nothing declared and nothing live: there is nothing to park.
|
|
1747
1799
|
if (!live)
|
|
@@ -2192,7 +2244,18 @@ function planTunnel(org, byKey, claimed, recordedIds) {
|
|
|
2192
2244
|
(org.site.enabled && Boolean(org.domain)) ||
|
|
2193
2245
|
(org.calls.enabled && org.calls.usesDomain && Boolean(org.domain)) ||
|
|
2194
2246
|
Boolean(org.domain);
|
|
2195
|
-
const toggled = planHostingToggle('cloudflare', 'tunnel', desired, org, byKey, claimed, () => `provision a Cloudflare tunnel for ${org.slug}'s app`, () => `park ${org.slug}'s Cloudflare tunnel (configuration is retained, not deleted -- #7 corrections 1)
|
|
2247
|
+
const toggled = planHostingToggle('cloudflare', 'tunnel', desired, org, byKey, claimed, () => `provision a Cloudflare tunnel for ${org.slug}'s app`, () => `park ${org.slug}'s Cloudflare tunnel (configuration is retained, not deleted -- #7 corrections 1)`, org.id,
|
|
2248
|
+
// Cloudflare's status, read against what is declared: a park cannot change
|
|
2249
|
+
// it, so judging on/off by status alone would never let the plan empty.
|
|
2250
|
+
// Wanted on: only `down` needs a repair (`inactive` is a fresh tunnel
|
|
2251
|
+
// whose connector has not dialled in yet). Wanted off: only a connected
|
|
2252
|
+
// tunnel needs parking; `inactive` and `down` are already parked.
|
|
2253
|
+
(live) => {
|
|
2254
|
+
const status = live.config.status;
|
|
2255
|
+
if (typeof status !== 'string')
|
|
2256
|
+
return live.config.enabled !== false;
|
|
2257
|
+
return desired ? status !== 'down' : status === 'healthy' || status === 'degraded';
|
|
2258
|
+
});
|
|
2196
2259
|
if (toggled || !desired)
|
|
2197
2260
|
return toggled;
|
|
2198
2261
|
// A live, enabled tunnel re-plans the same step again when either:
|
|
@@ -2214,10 +2277,10 @@ function planTunnel(org, byKey, claimed, recordedIds) {
|
|
|
2214
2277
|
// differ from the names now declared. An org that never recorded a list
|
|
2215
2278
|
// has nothing to compare, so only the domain check above applies to it.
|
|
2216
2279
|
const published = recordedIds[CLOUDFLARE_TUNNEL_DNS_HOSTNAMES_SCOPE_KEY];
|
|
2217
|
-
const
|
|
2280
|
+
const edgeChanged = published !== undefined &&
|
|
2218
2281
|
org.domain !== null &&
|
|
2219
2282
|
sortedNames(published.split(',')) !== sortedNames(tunnelDnsHostnames(org, org.domain));
|
|
2220
|
-
const dnsStale = recordedIds[CLOUDFLARE_TUNNEL_DNS_DOMAIN_SCOPE_KEY] !== org.domain ||
|
|
2283
|
+
const dnsStale = recordedIds[CLOUDFLARE_TUNNEL_DNS_DOMAIN_SCOPE_KEY] !== org.domain || edgeChanged;
|
|
2221
2284
|
if (!connectorMissing && !dnsStale)
|
|
2222
2285
|
return null;
|
|
2223
2286
|
return {
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
import type { DeclaredOrg, HostingEntry } from '../org.js';
|
|
2
|
+
import type { DraftStep } from './diff.js';
|
|
3
|
+
import type { OrgPlatformReadiness } from './platform-org.js';
|
|
4
|
+
/**
|
|
5
|
+
* F7 (#317): one edge key per entry with `uses.edge`, minted directly
|
|
6
|
+
* below the org with the edge service (`EdgePort.mintKey({
|
|
7
|
+
* organisationId, ... })` -- no org-level root key the way files/ai have
|
|
8
|
+
* one, since the edge service takes the org id itself). `EDGE_URL` and
|
|
9
|
+
* `EDGE_API_KEY` go to the entry's env and nowhere else. A edge key has no
|
|
10
|
+
* declared renewal window in the issue, so unlike `planEntryAiKeys`/
|
|
11
|
+
* `planFilesKeys` this mints once and never renews.
|
|
12
|
+
*
|
|
13
|
+
* `EDGE_*` is written in this step, after the app's own `coolify:app`
|
|
14
|
+
* create (which never knows the key yet), so the app's first boot has
|
|
15
|
+
* neither and its first deploy restarts once this step's env write lands --
|
|
16
|
+
* the same one-extra-restart shape `planEntryAiKeys`'s `AI_*`/
|
|
17
|
+
* `planFilesKeys`'s `FILES_*` already have, not a names-specific gap.
|
|
18
|
+
*
|
|
19
|
+
* A domain change after mint is not detected (#317 re-review): the key is
|
|
20
|
+
* bound to the domain passed at mint time, but nothing here records that
|
|
21
|
+
* domain to compare against a later `org.domain`, so an org that changes
|
|
22
|
+
* domains keeps a edge key still bound to the old one. Not built --
|
|
23
|
+
* resolving it needs a manual revoke-and-re-mint (drop `uses.edge`, apply,
|
|
24
|
+
* re-add it) until this is.
|
|
25
|
+
*/
|
|
26
|
+
export declare const entryEdgeUndeliveredScopeKey: (entryKey: string) => string;
|
|
27
|
+
/** The id of a key that was live when its entry stopped declaring `uses.edge`, once handled. */
|
|
28
|
+
export declare const entryEdgeRetiredScopeKey: (entryKey: string) => string;
|
|
29
|
+
export declare const ENTRY_EDGE_KEY_DELIVERY_ATTEMPTS = 3;
|
|
30
|
+
/** How long an entry's edge key lives. The issue names no renewal window, so this mints once and never renews. */
|
|
31
|
+
export declare const EDGE_ENTRY_KEY_DAYS = 365;
|
|
32
|
+
/** The ISO time a delivered key's mint succeeded -- the only trace of its age, since there is no renewal to track against. */
|
|
33
|
+
export declare const entryEdgeMintedAtScopeKey: (entryKey: string) => string;
|
|
34
|
+
/** A key with fewer days left than this is surfaced as a blocked warning row (#317 review): still live, but nothing here renews it. */
|
|
35
|
+
export declare const EDGE_ENTRY_KEY_WARN_WINDOW_DAYS = 30;
|
|
36
|
+
/** The reserved labels last sent to the edge service, as that sorted list's own JSON. */
|
|
37
|
+
export declare const EDGE_RESERVED_LABELS_APPLIED_SCOPE_KEY = "edge_reserved_labels_applied";
|
|
38
|
+
export declare const entryEdgeKeyName: (org: DeclaredOrg, entry: HostingEntry) => string;
|
|
39
|
+
/**
|
|
40
|
+
* Every label under `org.domain` that foundry provisions or the platform
|
|
41
|
+
* uses, so edge never lets a company claim one: the hostnames foundry routes
|
|
42
|
+
* (`tunnelIngressHostnames`: entries, site, cms, calls, Agora, Boule, share),
|
|
43
|
+
* the names it locks (`lockedHostnames`: also the sign-in host and the
|
|
44
|
+
* platform's service hostnames), each entry's own labels, the mail labels
|
|
45
|
+
* and `www`. The entry's `*` is the space users claim in, so it reserves
|
|
46
|
+
* nothing, and neither does the bare domain.
|
|
47
|
+
*
|
|
48
|
+
* An org without a domain has no hostnames edge sees (`uses.edge` needs a
|
|
49
|
+
* domain), so it reserves `www` only.
|
|
50
|
+
*/
|
|
51
|
+
export declare function edgeReservedLabels(org: DeclaredOrg, platformHostnames?: readonly string[]): string[];
|
|
52
|
+
type Blocked = Array<{
|
|
53
|
+
description: string;
|
|
54
|
+
key: string;
|
|
55
|
+
}>;
|
|
56
|
+
/**
|
|
57
|
+
* One step per entry whose edge key is missing, plus one retire step per
|
|
58
|
+
* entry whose `uses.edge` was removed but still has a recorded key.
|
|
59
|
+
* Blocked, not planned (rule 2), while the platform has no edge service
|
|
60
|
+
* wired, the entry's app is not recorded, or a previous mint's delivery
|
|
61
|
+
* failed.
|
|
62
|
+
*/
|
|
63
|
+
export declare function planEntryEdgeKeys(org: DeclaredOrg, recordedIds: Readonly<Record<string, string>>, plannedSteps: readonly DraftStep[], readiness: OrgPlatformReadiness, now?: number): {
|
|
64
|
+
steps: DraftStep[];
|
|
65
|
+
blocked: Blocked;
|
|
66
|
+
};
|
|
67
|
+
/**
|
|
68
|
+
* The reserved-labels step: resent whenever the org's own label set
|
|
69
|
+
* (`edgeReservedLabels`) differs from what was last sent. While the org
|
|
70
|
+
* uses edge at all (at least one entry with `uses.edge`) that set is
|
|
71
|
+
* `edgeReservedLabels(org)`; once the last entry drops `uses.edge`, this
|
|
72
|
+
* clears whatever was last sent (an empty list) rather than leaving it
|
|
73
|
+
* stuck in names forever -- a one-time step, same as any other change.
|
|
74
|
+
*/
|
|
75
|
+
export declare function planEdgeReservedLabels(org: DeclaredOrg, recordedIds: Readonly<Record<string, string>>, readiness: OrgPlatformReadiness, platformHostnames?: readonly string[]): {
|
|
76
|
+
step: DraftStep | null;
|
|
77
|
+
blocked: Blocked;
|
|
78
|
+
};
|
|
79
|
+
export {};
|