@wtfalch/foundry 0.7.1 → 0.9.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/bootstrap.d.ts +12 -8
- package/dist/bootstrap/bootstrap.js +4 -12
- package/dist/bootstrap/declare.js +1 -5
- package/dist/bootstrap/versions.d.ts +8 -0
- package/dist/bootstrap/versions.js +31 -0
- package/dist/calls-sfu-deploy-support.d.ts +1 -1
- package/dist/calls-sfu-deploy-support.js +1 -1
- package/dist/calls-sfu-ssh-adapter.d.ts +1 -1
- package/dist/calls-sfu-ssh-adapter.js +1 -1
- package/dist/calls-sfu.d.ts +5 -4
- package/dist/calls-sfu.js +5 -4
- package/dist/index.d.ts +4 -5
- package/dist/index.js +6 -3
- package/dist/migrations/0024_audit_events.sql +1 -1
- package/dist/migrations/0025_audit_display.sql +1 -1
- package/dist/migrations/0026_audit_erase_email_guard.sql +1 -1
- package/dist/migrations/0027_audit_chain.sql +1 -1
- package/dist/migrations/0028_audit_rls.sql +2 -2
- package/dist/migrations/0029_hosting_entries.sql +109 -0
- package/dist/migrations-dir.d.ts +9 -0
- package/dist/migrations-dir.js +10 -0
- package/dist/org.d.ts +59 -27
- package/dist/org.js +98 -26
- package/dist/plan/calls-matrix.d.ts +2 -0
- package/dist/plan/calls-matrix.js +2 -0
- package/dist/plan/diff.d.ts +3 -1
- package/dist/plan/diff.js +100 -32
- package/dist/plan/executor.d.ts +324 -19
- package/dist/plan/executor.js +764 -34
- package/dist/plan/index.d.ts +1 -1
- package/dist/plan/index.js +1 -1
- package/dist/plan/login-host.d.ts +24 -0
- package/dist/plan/login-host.js +21 -0
- package/dist/plan/plan-apply.d.ts +2 -0
- package/dist/plan/plan-apply.js +26 -10
- package/dist/plan/platform-diff.d.ts +102 -12
- package/dist/plan/platform-diff.js +236 -15
- package/dist/plan/platform-providers.d.ts +43 -1
- package/dist/plan/platform-providers.js +245 -33
- package/dist/plan/zitadel-system-api.d.ts +64 -0
- package/dist/plan/zitadel-system-api.js +125 -0
- package/dist/plan.d.ts +43 -0
- package/dist/plan.js +16 -1
- package/dist/platform.d.ts +1 -1
- package/dist/store/audit.d.ts +13 -17
- package/dist/store/audit.js +14 -18
- package/dist/store/postgres.d.ts +14 -0
- package/dist/store/postgres.js +15 -18
- package/dist/store/schema.d.ts +20 -1
- package/dist/store/schema.js +7 -1
- package/package.json +6 -3
- package/versions.json +1 -1
- package/dist/migrate.d.ts +0 -38
- package/dist/migrate.js +0 -47
package/dist/org.js
CHANGED
|
@@ -2,6 +2,12 @@
|
|
|
2
2
|
export const DEFAULT_MAIL_APPS = ['agora', 'webmail'];
|
|
3
3
|
/** {@link MailConfig.noReplyLocalPart}'s own default, restated wherever it is read rather than imported (this file's `LOG_DIR`-style convention, named in `plan/executor.ts`'s own doc comments). */
|
|
4
4
|
export const DEFAULT_NO_REPLY_LOCAL_PART = 'no-reply';
|
|
5
|
+
/** The key of the entry that is today's one app: the Coolify app named `<slug>`. */
|
|
6
|
+
export const APP_ENTRY_KEY = 'app';
|
|
7
|
+
/** The org's `app` entry: today's one app. */
|
|
8
|
+
export function appEntry(hosting) {
|
|
9
|
+
return hosting.entries.find((entry) => entry.key === APP_ENTRY_KEY);
|
|
10
|
+
}
|
|
5
11
|
export const APP_ENV_SOURCES = new Set([
|
|
6
12
|
'filesOrganisationId',
|
|
7
13
|
'aiOrganisationId',
|
|
@@ -18,34 +24,18 @@ const HOSTING_BUILD_PACKS = new Set([
|
|
|
18
24
|
* `null` when `hosting` is valid; otherwise the reason it is not (#71), same
|
|
19
25
|
* shape as `validateBuyerIdentity` below.
|
|
20
26
|
*
|
|
21
|
-
* - `
|
|
22
|
-
* without the app itself running.
|
|
23
|
-
* - `gitBranch` is non-empty and `buildPack` is one of `HostingBuildPack`'s
|
|
24
|
-
* four values.
|
|
27
|
+
* - `tunnel` requires `enabled` -- it means nothing without the apps running.
|
|
25
28
|
* - Every `issuerApps[]` entry has a non-empty `name`, unique within the
|
|
26
29
|
* list, and a `kind` that is one of `IssuerAppKind`'s three values.
|
|
30
|
+
* - `enabled` requires an entry keyed {@link APP_ENTRY_KEY}.
|
|
31
|
+
* - Every `entries[]` entry follows {@link validateHostingEntry}; keys are
|
|
32
|
+
* unique, and at most one entry has a `database`, because the one
|
|
33
|
+
* `<slug>_rt` runtime role cannot serve two apps.
|
|
27
34
|
*/
|
|
28
35
|
export function validateHosting(hosting) {
|
|
29
|
-
if (hosting.database && !hosting.enabled) {
|
|
30
|
-
return 'hosting.database requires hosting.enabled';
|
|
31
|
-
}
|
|
32
36
|
if (hosting.tunnel && !hosting.enabled) {
|
|
33
37
|
return 'hosting.tunnel requires hosting.enabled';
|
|
34
38
|
}
|
|
35
|
-
if (!hosting.gitBranch) {
|
|
36
|
-
return 'hosting.gitBranch must be non-empty';
|
|
37
|
-
}
|
|
38
|
-
if (!HOSTING_BUILD_PACKS.has(hosting.buildPack)) {
|
|
39
|
-
return `hosting.buildPack has an unknown value '${hosting.buildPack}'`;
|
|
40
|
-
}
|
|
41
|
-
if (hosting.accessEmails.length > 0 && !hosting.enabled) {
|
|
42
|
-
return 'hosting.accessEmails requires hosting.enabled';
|
|
43
|
-
}
|
|
44
|
-
for (const email of hosting.accessEmails) {
|
|
45
|
-
if (!/^[^@\s]+@[^@\s]+$/.test(email)) {
|
|
46
|
-
return `hosting.accessEmails has an invalid email '${email}'`;
|
|
47
|
-
}
|
|
48
|
-
}
|
|
49
39
|
const seenNames = new Set();
|
|
50
40
|
for (const app of hosting.issuerApps) {
|
|
51
41
|
if (!app.name) {
|
|
@@ -67,16 +57,98 @@ export function validateHosting(hosting) {
|
|
|
67
57
|
return `issuerApps '${app.name}' is reserved for the always-on Agora client every org gets`;
|
|
68
58
|
}
|
|
69
59
|
}
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
60
|
+
if (hosting.enabled && !appEntry(hosting)) {
|
|
61
|
+
return `hosting.enabled requires an entry keyed '${APP_ENTRY_KEY}'`;
|
|
62
|
+
}
|
|
63
|
+
const seenKeys = new Set();
|
|
64
|
+
for (const entry of hosting.entries) {
|
|
65
|
+
if (seenKeys.has(entry.key)) {
|
|
66
|
+
return `hosting.entries key '${entry.key}' is not unique`;
|
|
67
|
+
}
|
|
68
|
+
seenKeys.add(entry.key);
|
|
69
|
+
const invalid = validateHostingEntry(entry, hosting.enabled);
|
|
70
|
+
if (invalid)
|
|
71
|
+
return invalid;
|
|
72
|
+
}
|
|
73
|
+
if (hosting.entries.filter((entry) => entry.database).length > 1) {
|
|
74
|
+
return 'at most one hosting entry may set database: the one runtime database role cannot serve two apps';
|
|
75
|
+
}
|
|
76
|
+
return null;
|
|
77
|
+
}
|
|
78
|
+
const ENTRY_KEY = /^[a-z0-9]([a-z0-9-]{0,61}[a-z0-9])?$/;
|
|
79
|
+
const REPO = /^wtfalch\/[A-Za-z0-9._-]+$/;
|
|
80
|
+
// The env names `pushAppEnv` and `pushDatabaseUrl` set themselves.
|
|
81
|
+
const FOUNDRY_ENV_NAME = /^(APP_URL|LOG_DIR|OWNER_USER_ID|AUTH_.*|DATABASE_URL.*)$/;
|
|
82
|
+
const SECRET_ENV_NAME = /_(KEY|SECRET|TOKEN|PASSWORD)$/;
|
|
83
|
+
const HELD_SECRETS = 'held secrets are not available yet';
|
|
84
|
+
/** The rules for one {@link HostingEntry}; `null` when it is valid. */
|
|
85
|
+
function validateHostingEntry(entry, enabled) {
|
|
86
|
+
const at = `hosting.entries '${entry.key}'`;
|
|
87
|
+
if (!ENTRY_KEY.test(entry.key)) {
|
|
88
|
+
return `hosting.entries key '${entry.key}' must be a DNS label (lowercase letters, digits and hyphens, 1-63 characters)`;
|
|
89
|
+
}
|
|
90
|
+
if (entry.repo !== null && !REPO.test(entry.repo)) {
|
|
91
|
+
return `${at} repo must be a repo under wtfalch, or null`;
|
|
92
|
+
}
|
|
93
|
+
if (entry.folder !== null && (entry.folder.startsWith('/') || entry.folder.includes('..'))) {
|
|
94
|
+
return `${at} folder must be a relative path inside the repo`;
|
|
95
|
+
}
|
|
96
|
+
if (!entry.branch) {
|
|
97
|
+
return `${at} branch must be non-empty`;
|
|
98
|
+
}
|
|
99
|
+
if (!HOSTING_BUILD_PACKS.has(entry.buildPack)) {
|
|
100
|
+
return `${at} buildPack has an unknown value '${entry.buildPack}'`;
|
|
101
|
+
}
|
|
102
|
+
if (entry.buildPack === 'dockercompose') {
|
|
103
|
+
return `${at} buildPack dockercompose is not available for an entry`;
|
|
104
|
+
}
|
|
105
|
+
if (entry.port !== null &&
|
|
106
|
+
!(Number.isInteger(entry.port) && entry.port >= 1 && entry.port <= 65535)) {
|
|
107
|
+
return `${at} port must be a whole number from 1 to 65535, or null`;
|
|
108
|
+
}
|
|
109
|
+
if (entry.healthPath !== null && !entry.healthPath.startsWith('/')) {
|
|
110
|
+
return `${at} healthPath must start with '/', or be null`;
|
|
111
|
+
}
|
|
112
|
+
if (entry.database && !enabled) {
|
|
113
|
+
return `${at} database requires hosting.enabled`;
|
|
114
|
+
}
|
|
115
|
+
if (entry.accessEmails.length > 0 && !enabled) {
|
|
116
|
+
return `${at} accessEmails requires hosting.enabled`;
|
|
117
|
+
}
|
|
118
|
+
if (entry.accessEmails.length > 0 && entry.key !== APP_ENTRY_KEY) {
|
|
119
|
+
return `${at} accessEmails is only available on the '${APP_ENTRY_KEY}' entry for now`;
|
|
120
|
+
}
|
|
121
|
+
for (const email of entry.accessEmails) {
|
|
122
|
+
if (!/^[^@\s]+@[^@\s]+$/.test(email)) {
|
|
123
|
+
return `${at} accessEmails has an invalid email '${email}'`;
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
for (const [name, value] of Object.entries(entry.env)) {
|
|
127
|
+
if (!name) {
|
|
128
|
+
return `${at} env needs a non-empty env var name`;
|
|
129
|
+
}
|
|
130
|
+
if (FOUNDRY_ENV_NAME.test(name)) {
|
|
131
|
+
return `${at} env '${name}' is set by foundry and cannot be declared`;
|
|
132
|
+
}
|
|
133
|
+
if (typeof value !== 'string') {
|
|
134
|
+
if (!APP_ENV_SOURCES.has(value?.from)) {
|
|
135
|
+
return `${at} env '${name}' has an unknown source '${value?.from}'`;
|
|
136
|
+
}
|
|
73
137
|
}
|
|
74
|
-
if (
|
|
75
|
-
return
|
|
138
|
+
else if (SECRET_ENV_NAME.test(name) || (name.endsWith('_URL') && urlHasPassword(value))) {
|
|
139
|
+
return `${at} env '${name}' looks like a secret: ${HELD_SECRETS}`;
|
|
76
140
|
}
|
|
77
141
|
}
|
|
78
142
|
return null;
|
|
79
143
|
}
|
|
144
|
+
function urlHasPassword(value) {
|
|
145
|
+
try {
|
|
146
|
+
return new URL(value).password !== '';
|
|
147
|
+
}
|
|
148
|
+
catch {
|
|
149
|
+
return false;
|
|
150
|
+
}
|
|
151
|
+
}
|
|
80
152
|
/**
|
|
81
153
|
* A DNS-label-per-segment format check for `DeclaredOrg.domain` (#131): every
|
|
82
154
|
* dot-separated segment follows the exact rule `../slug.ts`'s `DNS_LABEL`
|
|
@@ -47,6 +47,8 @@
|
|
|
47
47
|
export declare const SYNAPSE_IMAGE = "ghcr.io/element-hq/synapse";
|
|
48
48
|
export declare const SYNAPSE_TAG: string;
|
|
49
49
|
export declare const MAS_IMAGE: string;
|
|
50
|
+
/** #251: the version baked into {@link MAS_IMAGE}'s `FROM` line, named on its own so a version-change line can read it without re-parsing the image string. */
|
|
51
|
+
export declare const MAS_TAG: string;
|
|
50
52
|
/**
|
|
51
53
|
* Never resolved by DNS and never federates (`callsSynapseHomeserverYaml`'s
|
|
52
54
|
* `federation_domain_whitelist: []`) -- purely a logical identifier MAS and
|
|
@@ -49,6 +49,8 @@ import { VERSIONS } from '../bootstrap/versions.js';
|
|
|
49
49
|
export const SYNAPSE_IMAGE = 'ghcr.io/element-hq/synapse';
|
|
50
50
|
export const SYNAPSE_TAG = VERSIONS.synapse;
|
|
51
51
|
export const MAS_IMAGE = `ghcr.io/element-hq/matrix-authentication-service:${VERSIONS.mas}`;
|
|
52
|
+
/** #251: the version baked into {@link MAS_IMAGE}'s `FROM` line, named on its own so a version-change line can read it without re-parsing the image string. */
|
|
53
|
+
export const MAS_TAG = VERSIONS.mas;
|
|
52
54
|
/**
|
|
53
55
|
* Never resolved by DNS and never federates (`callsSynapseHomeserverYaml`'s
|
|
54
56
|
* `federation_domain_whitelist: []`) -- purely a logical identifier MAS and
|
package/dist/plan/diff.d.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import type { DeclaredOrg, OrgId } from '../org.js';
|
|
2
|
-
import type { Plan, PlanStep, UnmanagedItem } from '../plan.js';
|
|
2
|
+
import type { Plan, PlanStep, UnmanagedItem, VersionChangeItem } from '../plan.js';
|
|
3
3
|
import { type AgoraHostReading } from './agora.js';
|
|
4
4
|
import type { LiveResource } from './live.js';
|
|
5
5
|
import { type OrgPlatformReadiness } from './platform-org.js';
|
|
@@ -39,6 +39,8 @@ export interface PlanDetail {
|
|
|
39
39
|
orgId: OrgId;
|
|
40
40
|
plannedSteps: PlannedStep[];
|
|
41
41
|
unmanaged: UnmanagedItem[];
|
|
42
|
+
/** #251: see `VersionChangeItem` (`../plan.ts`). Never folded into `plannedSteps`. */
|
|
43
|
+
versionChanges: VersionChangeItem[];
|
|
42
44
|
}
|
|
43
45
|
/** The `Plan` (#19's public contract) a `PlanDetail` carries. */
|
|
44
46
|
export declare function toPlan(detail: PlanDetail): Plan;
|
package/dist/plan/diff.js
CHANGED
|
@@ -1,7 +1,9 @@
|
|
|
1
|
+
import { VERSIONS, floatingTagBlocked } from '../bootstrap/versions.js';
|
|
2
|
+
import { appEntry } from '../org.js';
|
|
1
3
|
import { NO_AGORA_HOST_VALUES, ZITADEL_ORG_SCOPE_KEY, agoraEnvDrift, agoraHostMissing, desiredAgoraEnv, } from './agora.js';
|
|
2
|
-
import { LOGIN_BINDING_SCOPE_KEY, loginBindingState, loginHostClientIds, loginHostnameFor, } from './login-host.js';
|
|
4
|
+
import { LOGIN_BINDING_SCOPE_KEY, loginBindingState, loginHostClientIds, loginHostnameFor, pendingRemovalHosts, } from './login-host.js';
|
|
3
5
|
import { MAIL_COPY_COMPLETED_SCOPE_KEY, MAIL_COPY_DONE_SCOPE_KEY, parseMailCopyCompleted, } from './mail-copy.js';
|
|
4
|
-
import { APP_ENV_APPLIED_SCOPE_KEY, DB_ENV_APPLIED_SCOPE_KEY, ORG_MAIL_APPLIED_APEX_SCOPE_KEY, ORG_MAIL_APPLIED_HOST_SCOPE_KEY, resolveAppEnv, } from './platform-diff.js';
|
|
6
|
+
import { APP_ENV_APPLIED_SCOPE_KEY, CALLS_MAS_APPLIED_IMAGE_TAG_SCOPE_KEY, CALLS_SYNAPSE_APPLIED_IMAGE_TAG_SCOPE_KEY, DB_ENV_APPLIED_SCOPE_KEY, ORG_MAIL_APPLIED_APEX_SCOPE_KEY, ORG_MAIL_APPLIED_HOST_SCOPE_KEY, appStepKey, resolveAppEnv, versionNotAppliedLine, } from './platform-diff.js';
|
|
5
7
|
import { DEFAULT_ORG_PLATFORM_READINESS } from './platform-org.js';
|
|
6
8
|
/** The `Plan` (#19's public contract) a `PlanDetail` carries. */
|
|
7
9
|
export function toPlan(detail) {
|
|
@@ -9,6 +11,7 @@ export function toPlan(detail) {
|
|
|
9
11
|
orgId: detail.orgId,
|
|
10
12
|
steps: detail.plannedSteps.map((p) => p.step),
|
|
11
13
|
unmanaged: detail.unmanaged,
|
|
14
|
+
versionChanges: detail.versionChanges,
|
|
12
15
|
};
|
|
13
16
|
}
|
|
14
17
|
function resourceKey(system, kind, key) {
|
|
@@ -115,6 +118,22 @@ export function computePlanDetail(org, live, recordedIds = {}, agoraHost = NO_AG
|
|
|
115
118
|
const callsService = planCallsService(org, byKey, claimed, platformReadiness.callsReady);
|
|
116
119
|
if (callsService.step)
|
|
117
120
|
plannedSteps.push(callsService.step);
|
|
121
|
+
// #251: same mechanism as the platform's own version-change lines -- see
|
|
122
|
+
// `planPlatform`'s own block in `platform-diff.ts`. Synapse and MAS never
|
|
123
|
+
// patch a running app's image tag once created, so this is
|
|
124
|
+
// `versionNotAppliedLine`, the same honest wording zitadel's own line
|
|
125
|
+
// uses, never `versionChangeLine`'s "X -> Y (redeploy)" -- nothing here
|
|
126
|
+
// ever performs that redeploy.
|
|
127
|
+
const versionChanges = [];
|
|
128
|
+
if (org.calls.enabled && !calls.blocked) {
|
|
129
|
+
const callsAppLive = Boolean(byKey.get(resourceKey('coolify', 'calls', org.id)));
|
|
130
|
+
const synapseChange = versionNotAppliedLine('synapse', callsAppLive, recordedIds[CALLS_SYNAPSE_APPLIED_IMAGE_TAG_SCOPE_KEY], VERSIONS.synapse);
|
|
131
|
+
if (synapseChange)
|
|
132
|
+
versionChanges.push(synapseChange);
|
|
133
|
+
const masChange = versionNotAppliedLine('mas', callsAppLive, recordedIds[CALLS_MAS_APPLIED_IMAGE_TAG_SCOPE_KEY], VERSIONS.mas);
|
|
134
|
+
if (masChange)
|
|
135
|
+
versionChanges.push(masChange);
|
|
136
|
+
}
|
|
118
137
|
const filesOrganisation = planFilesOrganisation(org, byKey, claimed, platformReadiness.filesReady, platformReadiness.missingKeys.files, platformReadiness.noKeyReader);
|
|
119
138
|
if (filesOrganisation.step)
|
|
120
139
|
plannedSteps.push(filesOrganisation.step);
|
|
@@ -269,7 +288,7 @@ export function computePlanDetail(org, live, recordedIds = {}, agoraHost = NO_AG
|
|
|
269
288
|
unmanaged.push(...zoneBlocked);
|
|
270
289
|
// #243: an appEnv source nothing will record is reported, not silently skipped.
|
|
271
290
|
unmanaged.push(...app.blocked);
|
|
272
|
-
return { orgId: org.id, plannedSteps, unmanaged };
|
|
291
|
+
return { orgId: org.id, plannedSteps, unmanaged, versionChanges };
|
|
273
292
|
}
|
|
274
293
|
/**
|
|
275
294
|
* The org's own ZITADEL org (issuer), a per-org singleton like every other
|
|
@@ -420,8 +439,12 @@ function planOwner(org, recordedIds, authReady) {
|
|
|
420
439
|
* for *this* apply, so an org moving from tier 1 onto its own domain (or
|
|
421
440
|
* losing one) replans this step rather than staying silent forever once
|
|
422
441
|
* first applied -- `executor.ts`'s `zitadelLoginHost` then also moves every
|
|
423
|
-
* already-live app onto the new base URI
|
|
424
|
-
*
|
|
442
|
+
* already-live app onto the new base URI, and (#274) removes the *old* host
|
|
443
|
+
* from ZITADEL's trusted domains and zitadel-login's binding, reading it
|
|
444
|
+
* back from this same recorded value before overwriting it -- no separate
|
|
445
|
+
* action field or planning step of its own needed for that half. Mints
|
|
446
|
+
* nothing either way: adding a trusted domain is not a one-time secret, so
|
|
447
|
+
* there is no "undelivered"
|
|
425
448
|
* guard to check the way `planJobsRegistration`'s own does.
|
|
426
449
|
*
|
|
427
450
|
* Ordered right after `planOwner` (not after mail, chat, or any hosting
|
|
@@ -437,11 +460,16 @@ function planLoginHost(org, recordedIds, authReady, issuerPlanned, bindingsWired
|
|
|
437
460
|
const none = { step: null, blocked: null };
|
|
438
461
|
const key = resourceKey('zitadel', 'login-host', org.id);
|
|
439
462
|
const host = loginHostnameFor(org);
|
|
440
|
-
// Already applied -- a re-run plans nothing unless the host changed
|
|
441
|
-
// client id appeared since the binding was last written (#267, rule 4)
|
|
463
|
+
// Already applied -- a re-run plans nothing unless the host changed, an app
|
|
464
|
+
// client id appeared since the binding was last written (#267, rule 4), or
|
|
465
|
+
// an earlier host change still has a removal owed (#274 fix round 2):
|
|
466
|
+
// `zitadelLoginHost` always advances the recorded host to the current one,
|
|
467
|
+
// so a pending removal is the only way to know one is still owed, and
|
|
468
|
+
// nothing else here would replan the step to retry it.
|
|
442
469
|
if (recordedIds[key] === host &&
|
|
443
470
|
recordedIds[LOGIN_BINDING_SCOPE_KEY] ===
|
|
444
|
-
loginBindingState(host, loginHostClientIds(recordedIds))
|
|
471
|
+
loginBindingState(host, loginHostClientIds(recordedIds)) &&
|
|
472
|
+
pendingRemovalHosts(recordedIds).length === 0) {
|
|
445
473
|
return none;
|
|
446
474
|
}
|
|
447
475
|
// #177: nothing to add a trusted domain to yet -- `planIssuer` already
|
|
@@ -494,6 +522,8 @@ const MAIL_OWNER_MAILBOX_SCOPE_KEY = 'mail_owner_mailbox';
|
|
|
494
522
|
const MAIL_ROLE_MAILBOXES_SCOPE_KEY = 'mail_role_mailboxes';
|
|
495
523
|
/** Restated from `executor.ts`, the same rule: the app id of the org tunnel's cloudflared connector. */
|
|
496
524
|
const CLOUDFLARE_TUNNEL_CONNECTOR_APP_SCOPE_KEY = 'cloudflare_tunnel_connector_app';
|
|
525
|
+
/** #236: restated from `executor.ts`, the same rule: the domain the org tunnel's DNS was last published under. */
|
|
526
|
+
const CLOUDFLARE_TUNNEL_DNS_DOMAIN_SCOPE_KEY = 'cloudflare_tunnel_dns_domain';
|
|
497
527
|
/** Restated from `executor.ts`, the same rule: the mail domain the last apply resolved. */
|
|
498
528
|
const MAIL_DOMAIN_SCOPE_KEY = 'mail_domain';
|
|
499
529
|
/** Restated from `executor.ts`, the same rule: the id of an ai key minted but not delivered. */
|
|
@@ -754,6 +784,26 @@ function planCalls(org, byKey, claimed, callsReady) {
|
|
|
754
784
|
blocked: `${org.slug}'s calls Matrix stack is blocked, not planned: the platform's shared calls SFU is off`,
|
|
755
785
|
};
|
|
756
786
|
}
|
|
787
|
+
// #248: the two images this stack's own apply would run, checked the same
|
|
788
|
+
// way the platform's SFU checks LiveKit/cloudflared and ZITADEL checks its
|
|
789
|
+
// own tag -- a re-apply of the same declaration must not silently run a
|
|
790
|
+
// different version with no diff shown anywhere.
|
|
791
|
+
if (org.calls.enabled && !byKey.get(key)) {
|
|
792
|
+
const synapseBlocked = floatingTagBlocked('Synapse', VERSIONS.synapse, 'v1.161.0');
|
|
793
|
+
if (synapseBlocked) {
|
|
794
|
+
return {
|
|
795
|
+
step: null,
|
|
796
|
+
blocked: `${org.slug}'s calls Matrix stack is blocked, not planned: ${synapseBlocked}`,
|
|
797
|
+
};
|
|
798
|
+
}
|
|
799
|
+
const masBlocked = floatingTagBlocked('matrix-authentication-service', VERSIONS.mas, '1.24.0');
|
|
800
|
+
if (masBlocked) {
|
|
801
|
+
return {
|
|
802
|
+
step: null,
|
|
803
|
+
blocked: `${org.slug}'s calls Matrix stack is blocked, not planned: ${masBlocked}`,
|
|
804
|
+
};
|
|
805
|
+
}
|
|
806
|
+
}
|
|
757
807
|
return {
|
|
758
808
|
step: planHostingToggle('coolify', 'calls', org.calls.enabled, org, byKey, claimed, () => `provision ${org.slug}'s calls Matrix stack (Synapse + matrix-authentication-service)`, () => `park ${org.slug}'s calls Matrix stack (stops serving; data is retained, not deleted -- #7 corrections 1)`),
|
|
759
809
|
blocked: null,
|
|
@@ -1282,8 +1332,8 @@ function planAgora(org, byKey, claimed, recordedIds, reading) {
|
|
|
1282
1332
|
* park-not-delete rule (#7 corrections 1) -- turning it off never deletes
|
|
1283
1333
|
* the org's own app, database or tunnel configuration, it parks it.
|
|
1284
1334
|
*/
|
|
1285
|
-
function planHostingToggle(system, kind, desiredEnabled, org, byKey, claimed, describeProvision, describePark) {
|
|
1286
|
-
const key = resourceKey(system, kind,
|
|
1335
|
+
function planHostingToggle(system, kind, desiredEnabled, org, byKey, claimed, describeProvision, describePark, stepKey = org.id) {
|
|
1336
|
+
const key = resourceKey(system, kind, stepKey);
|
|
1287
1337
|
const live = byKey.get(key);
|
|
1288
1338
|
// A live resource is on unless it says it is parked. Providers' own
|
|
1289
1339
|
// configs carry no `enabled` field at all, so only a park step's
|
|
@@ -1299,7 +1349,7 @@ function planHostingToggle(system, kind, desiredEnabled, org, byKey, claimed, de
|
|
|
1299
1349
|
if (!liveEnabled)
|
|
1300
1350
|
return null;
|
|
1301
1351
|
return {
|
|
1302
|
-
action: { system, kind, key:
|
|
1352
|
+
action: { system, kind, key: stepKey, desired: { enabled: false } },
|
|
1303
1353
|
step: { description: describePark(), destroys: true, migrates: false },
|
|
1304
1354
|
};
|
|
1305
1355
|
}
|
|
@@ -1310,7 +1360,7 @@ function planHostingToggle(system, kind, desiredEnabled, org, byKey, claimed, de
|
|
|
1310
1360
|
if (liveEnabled)
|
|
1311
1361
|
return null;
|
|
1312
1362
|
return {
|
|
1313
|
-
action: { system, kind, key:
|
|
1363
|
+
action: { system, kind, key: stepKey, desired: { enabled: true } },
|
|
1314
1364
|
step: { description: describeProvision(), destroys: false, migrates: false },
|
|
1315
1365
|
};
|
|
1316
1366
|
}
|
|
@@ -1345,8 +1395,12 @@ function planApp(org, byKey, claimed, recordedIds, plannedSteps) {
|
|
|
1345
1395
|
aiOrganisationId: ['ai', 'organisation'],
|
|
1346
1396
|
mailDomain: ['coolify', 'mail'],
|
|
1347
1397
|
};
|
|
1348
|
-
const
|
|
1349
|
-
const
|
|
1398
|
+
const entry = org.hosting.enabled ? appEntry(org.hosting) : undefined;
|
|
1399
|
+
const { values: wantedEnv, unresolved } = entry
|
|
1400
|
+
? resolveAppEnv(org, entry, recordedIds)
|
|
1401
|
+
: { values: {}, unresolved: [] };
|
|
1402
|
+
const stepKey = appStepKey(org);
|
|
1403
|
+
const blocked = entry
|
|
1350
1404
|
? unresolved
|
|
1351
1405
|
.filter(([, source]) => !plannedSteps.some((p) => p.action.system === sourceStep[source][0] &&
|
|
1352
1406
|
p.action.kind === sourceStep[source][1]))
|
|
@@ -1355,18 +1409,18 @@ function planApp(org, byKey, claimed, recordedIds, plannedSteps) {
|
|
|
1355
1409
|
key: `coolify:app:env:${name}`,
|
|
1356
1410
|
}))
|
|
1357
1411
|
: [];
|
|
1358
|
-
const toggled = planHostingToggle('coolify', 'app',
|
|
1412
|
+
const toggled = planHostingToggle('coolify', 'app', entry !== undefined, org, byKey, claimed, () => `provision ${org.slug}'s app`, () => `park ${org.slug}'s app (stops serving; app data is retained, not deleted -- #7 corrections 1)`, stepKey);
|
|
1359
1413
|
if (toggled)
|
|
1360
1414
|
return { step: toggled, blocked };
|
|
1361
|
-
if (!
|
|
1415
|
+
if (!entry)
|
|
1362
1416
|
return { step: null, blocked };
|
|
1363
|
-
const live = byKey.get(resourceKey('coolify', 'app',
|
|
1417
|
+
const live = byKey.get(resourceKey('coolify', 'app', stepKey));
|
|
1364
1418
|
if (!live)
|
|
1365
1419
|
return { step: null, blocked };
|
|
1366
1420
|
const action = {
|
|
1367
1421
|
system: 'coolify',
|
|
1368
1422
|
kind: 'app',
|
|
1369
|
-
key:
|
|
1423
|
+
key: stepKey,
|
|
1370
1424
|
desired: { enabled: true },
|
|
1371
1425
|
};
|
|
1372
1426
|
if (live.config.hasLogVolume === false) {
|
|
@@ -1388,7 +1442,7 @@ function planApp(org, byKey, claimed, recordedIds, plannedSteps) {
|
|
|
1388
1442
|
// names (`envNames` absent) reads as compliant, like `hasLogVolume`.
|
|
1389
1443
|
const envNames = live.config.envNames;
|
|
1390
1444
|
const dbLive = byKey.get(resourceKey('coolify', 'database', org.id));
|
|
1391
|
-
const dbReady =
|
|
1445
|
+
const dbReady = entry.database && Boolean(recordedIds[`coolify:database:${org.id}`]);
|
|
1392
1446
|
if (dbReady && dbLive?.config.hasInternalUrl === false) {
|
|
1393
1447
|
// Rule 2: the push cannot succeed yet, so report it rather than replan
|
|
1394
1448
|
// forever.
|
|
@@ -1417,7 +1471,7 @@ function planApp(org, byKey, claimed, recordedIds, plannedSteps) {
|
|
|
1417
1471
|
blocked,
|
|
1418
1472
|
};
|
|
1419
1473
|
}
|
|
1420
|
-
// #243 (rule 3): a changed `
|
|
1474
|
+
// #243 (rule 3): a changed entry `env` reaches an app that is already
|
|
1421
1475
|
// live. `pushAppEnv` records what it pushed, so this plans the same step
|
|
1422
1476
|
// again, with a redeploy, only once the resolved map differs from that.
|
|
1423
1477
|
if (JSON.stringify(wantedEnv) !== (recordedIds[APP_ENV_APPLIED_SCOPE_KEY] ?? '{}')) {
|
|
@@ -1503,12 +1557,12 @@ function planJobsRegistration(org, recordedIds, jobsAvailable, missingKeyRef = n
|
|
|
1503
1557
|
}
|
|
1504
1558
|
/**
|
|
1505
1559
|
* A Postgres database beside the org's app (`org.ts`'s
|
|
1506
|
-
* `
|
|
1560
|
+
* `HostingEntry.database`, #71) -- `validateHosting` requires
|
|
1507
1561
|
* `hosting.enabled` before this can be true. Same park-not-delete shape as
|
|
1508
1562
|
* {@link planApp}.
|
|
1509
1563
|
*/
|
|
1510
1564
|
function planDatabase(org, byKey, claimed) {
|
|
1511
|
-
return planHostingToggle('coolify', 'database', org.hosting
|
|
1565
|
+
return planHostingToggle('coolify', 'database', appEntry(org.hosting)?.database ?? false, org, byKey, claimed, () => `provision a database for ${org.slug}'s app`, () => `park ${org.slug}'s database (data is retained, not deleted -- #7 corrections 1)`);
|
|
1512
1566
|
}
|
|
1513
1567
|
/**
|
|
1514
1568
|
* A Cloudflare tunnel reaching the org's app (`org.ts`'s
|
|
@@ -1549,17 +1603,30 @@ function planTunnel(org, byKey, claimed, recordedIds) {
|
|
|
1549
1603
|
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)`);
|
|
1550
1604
|
if (toggled || !desired)
|
|
1551
1605
|
return toggled;
|
|
1552
|
-
//
|
|
1553
|
-
//
|
|
1554
|
-
//
|
|
1555
|
-
//
|
|
1606
|
+
// A live, enabled tunnel re-plans the same step again when either:
|
|
1607
|
+
// #235, no connector recorded -- a first connector create that failed, or
|
|
1608
|
+
// a tunnel that predates connectors; or #236, its DNS was last published
|
|
1609
|
+
// under a domain other than the one now declared -- never published at
|
|
1610
|
+
// all (unset), or the org's domain has since changed. `org.domain` is set
|
|
1611
|
+
// whenever `desired` is true above, in every branch. The step itself
|
|
1612
|
+
// (`executor.ts`'s `cloudflareTunnel`) finds or creates the connector by
|
|
1613
|
+
// name and upserts DNS idempotently (and, on a domain change, cleans up
|
|
1614
|
+
// the old one once the new one is published -- round 3: publishing first
|
|
1615
|
+
// means a conflict on the new domain leaves the old one still live rather
|
|
1616
|
+
// than taking the org dark), so re-running it never duplicates either.
|
|
1556
1617
|
const live = byKey.get(resourceKey('cloudflare', 'tunnel', org.id));
|
|
1557
|
-
if (!live
|
|
1618
|
+
if (!live)
|
|
1619
|
+
return null;
|
|
1620
|
+
const connectorMissing = !recordedIds[CLOUDFLARE_TUNNEL_CONNECTOR_APP_SCOPE_KEY];
|
|
1621
|
+
const dnsStale = recordedIds[CLOUDFLARE_TUNNEL_DNS_DOMAIN_SCOPE_KEY] !== org.domain;
|
|
1622
|
+
if (!connectorMissing && !dnsStale)
|
|
1558
1623
|
return null;
|
|
1559
1624
|
return {
|
|
1560
1625
|
action: { system: 'cloudflare', kind: 'tunnel', key: org.id, desired: { enabled: true } },
|
|
1561
1626
|
step: {
|
|
1562
|
-
description:
|
|
1627
|
+
description: connectorMissing
|
|
1628
|
+
? `run a cloudflared connector for ${org.slug}'s Cloudflare tunnel`
|
|
1629
|
+
: `point ${org.slug}'s hostnames at its Cloudflare tunnel`,
|
|
1563
1630
|
destroys: false,
|
|
1564
1631
|
migrates: false,
|
|
1565
1632
|
},
|
|
@@ -1728,17 +1795,18 @@ function planCustomers(org, live, claimed, recordedIds) {
|
|
|
1728
1795
|
}
|
|
1729
1796
|
/**
|
|
1730
1797
|
* #84: a Cloudflare Access gate on the org's app hostname, letting only
|
|
1731
|
-
* `
|
|
1798
|
+
* the `app` entry's `accessEmails` through. Idempotent on the recorded Access app id,
|
|
1732
1799
|
* like `planOwner`: Cloudflare's live listing has no Access apps. A later
|
|
1733
1800
|
* change to the email list is not reconciled yet.
|
|
1734
1801
|
*/
|
|
1735
1802
|
function planAccess(org, recordedIds) {
|
|
1736
|
-
|
|
1803
|
+
const entry = org.hosting.enabled ? appEntry(org.hosting) : undefined;
|
|
1804
|
+
if (!entry || entry.accessEmails.length === 0)
|
|
1737
1805
|
return null;
|
|
1738
|
-
if (recordedIds[resourceKey('cloudflare', 'access', org
|
|
1806
|
+
if (recordedIds[resourceKey('cloudflare', 'access', appStepKey(org))])
|
|
1739
1807
|
return null;
|
|
1740
1808
|
return {
|
|
1741
|
-
action: { system: 'cloudflare', kind: 'access', key: org
|
|
1809
|
+
action: { system: 'cloudflare', kind: 'access', key: appStepKey(org), desired: {} },
|
|
1742
1810
|
step: {
|
|
1743
1811
|
description: `put ${org.slug}'s app behind Cloudflare Access`,
|
|
1744
1812
|
destroys: false,
|