@wtfalch/foundry 0.6.0 → 0.8.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.d.ts +3 -1
- package/dist/bootstrap/declare.js +6 -2
- package/dist/bootstrap/versions.d.ts +25 -0
- package/dist/bootstrap/versions.js +43 -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 +8 -6
- package/dist/calls-sfu.js +9 -6
- package/dist/index.d.ts +3 -4
- package/dist/index.js +5 -2
- package/dist/migrations/0022_chat_enabled.sql +6 -0
- package/dist/migrations/0023_hosting_app_env.sql +8 -0
- package/dist/migrations/0024_audit_events.sql +211 -0
- package/dist/migrations/0025_audit_display.sql +147 -0
- package/dist/migrations/0026_audit_erase_email_guard.sql +78 -0
- package/dist/migrations/0027_audit_chain.sql +170 -0
- package/dist/migrations/0028_audit_rls.sql +111 -0
- package/dist/migrations-dir.d.ts +9 -0
- package/dist/migrations-dir.js +10 -0
- package/dist/org.d.ts +24 -0
- package/dist/org.js +13 -0
- package/dist/plan/agora.d.ts +21 -0
- package/dist/plan/agora.js +23 -0
- package/dist/plan/calls-matrix.d.ts +4 -2
- package/dist/plan/calls-matrix.js +5 -2
- package/dist/plan/cloudflared-connector.d.ts +3 -2
- package/dist/plan/cloudflared-connector.js +5 -3
- package/dist/plan/diff.d.ts +18 -3
- package/dist/plan/diff.js +369 -42
- package/dist/plan/executor.d.ts +378 -44
- package/dist/plan/executor.js +816 -57
- package/dist/plan/index.d.ts +2 -0
- package/dist/plan/index.js +1 -0
- package/dist/plan/login-host.d.ts +88 -0
- package/dist/plan/login-host.js +73 -0
- package/dist/plan/plan-apply.d.ts +23 -8
- package/dist/plan/plan-apply.js +30 -10
- package/dist/plan/platform-diff.d.ts +102 -2
- package/dist/plan/platform-diff.js +232 -1
- package/dist/plan/platform-providers.d.ts +37 -2
- package/dist/plan/platform-providers.js +223 -36
- package/dist/plan/providers-readers.js +44 -1
- package/dist/plan.d.ts +43 -0
- package/dist/plan.js +16 -1
- package/dist/store/audit.d.ts +36 -4
- package/dist/store/audit.js +110 -29
- package/dist/store/postgres.d.ts +14 -0
- package/dist/store/postgres.js +17 -9
- package/dist/store/schema.d.ts +37 -1
- package/dist/store/schema.js +7 -0
- package/package.json +10 -5
- package/versions.json +8 -0
- package/dist/migrate.d.ts +0 -38
- package/dist/migrate.js +0 -47
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { generateKeyPairSync, randomBytes } from 'node:crypto';
|
|
2
|
+
import { VERSIONS } from '../bootstrap/versions.js';
|
|
2
3
|
/**
|
|
3
4
|
* The calls Matrix stack (#128, part 1 of 2 -- see that issue's "If this
|
|
4
5
|
* passes about 800 changed lines, split it" note): pure config generation
|
|
@@ -46,8 +47,10 @@ import { generateKeyPairSync, randomBytes } from 'node:crypto';
|
|
|
46
47
|
* (`executor.ts`'s `callsHostnameFor`).
|
|
47
48
|
*/
|
|
48
49
|
export const SYNAPSE_IMAGE = 'ghcr.io/element-hq/synapse';
|
|
49
|
-
export const SYNAPSE_TAG =
|
|
50
|
-
export const MAS_IMAGE =
|
|
50
|
+
export const SYNAPSE_TAG = VERSIONS.synapse;
|
|
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;
|
|
51
54
|
/**
|
|
52
55
|
* Never resolved by DNS and never federates (`callsSynapseHomeserverYaml`'s
|
|
53
56
|
* `federation_domain_whitelist: []`) -- purely a logical identifier MAS and
|
|
@@ -2,9 +2,10 @@ import type { CloudflarePort, CoolifyPort } from './executor.js';
|
|
|
2
2
|
/**
|
|
3
3
|
* #197: the pinned `cloudflared` image, the same release tag as
|
|
4
4
|
* `scripts/shared-ingress.mjs`'s and `calls-sfu.ts`'s own
|
|
5
|
-
* `CLOUDFLARED_IMAGE_REF`. A test in each keeps the three equal.
|
|
5
|
+
* `CLOUDFLARED_IMAGE_REF`. A test in each keeps the three equal. Pinned in
|
|
6
|
+
* `versions.json` (#247).
|
|
6
7
|
*/
|
|
7
|
-
export declare const CLOUDFLARED_IMAGE_REF
|
|
8
|
+
export declare const CLOUDFLARED_IMAGE_REF: string;
|
|
8
9
|
/**
|
|
9
10
|
* The inline Dockerfile a docker-image `cloudflared` app needs, copied from
|
|
10
11
|
* `scripts/shared-ingress.mjs`'s `cloudflaredDockerfile()`: the published
|
|
@@ -1,10 +1,12 @@
|
|
|
1
|
+
import { VERSIONS } from '../bootstrap/versions.js';
|
|
1
2
|
import { isNotFoundError } from './executor.js';
|
|
2
3
|
/**
|
|
3
4
|
* #197: the pinned `cloudflared` image, the same release tag as
|
|
4
5
|
* `scripts/shared-ingress.mjs`'s and `calls-sfu.ts`'s own
|
|
5
|
-
* `CLOUDFLARED_IMAGE_REF`. A test in each keeps the three equal.
|
|
6
|
+
* `CLOUDFLARED_IMAGE_REF`. A test in each keeps the three equal. Pinned in
|
|
7
|
+
* `versions.json` (#247).
|
|
6
8
|
*/
|
|
7
|
-
export const CLOUDFLARED_IMAGE_REF =
|
|
9
|
+
export const CLOUDFLARED_IMAGE_REF = `cloudflare/cloudflared:${VERSIONS.cloudflared}`;
|
|
8
10
|
/**
|
|
9
11
|
* The inline Dockerfile a docker-image `cloudflared` app needs, copied from
|
|
10
12
|
* `scripts/shared-ingress.mjs`'s `cloudflaredDockerfile()`: the published
|
|
@@ -72,7 +74,7 @@ export async function ensureCloudflaredConnector(input) {
|
|
|
72
74
|
environment_uuid: input.environment.uuid,
|
|
73
75
|
dockerfile: cloudflaredDockerfile(),
|
|
74
76
|
name: input.appName,
|
|
75
|
-
description: 'cloudflared connector for
|
|
77
|
+
description: 'cloudflared connector for a Cloudflare tunnel',
|
|
76
78
|
instant_deploy: false,
|
|
77
79
|
});
|
|
78
80
|
appId = created.uuid;
|
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';
|
|
@@ -26,10 +26,21 @@ export interface PlannedStep {
|
|
|
26
26
|
step: PlanStep;
|
|
27
27
|
action: StepAction;
|
|
28
28
|
}
|
|
29
|
+
/**
|
|
30
|
+
* Facts about the host's executor ports that an org's plan needs, so a step
|
|
31
|
+
* whose port is not wired is reported blocked instead of planned (rule 2).
|
|
32
|
+
* New ones go here, not in new positional arguments (rule 7).
|
|
33
|
+
*/
|
|
34
|
+
export interface PlanOrgOptions {
|
|
35
|
+
/** #267: the executor was built with a `loginHostBindings` port. */
|
|
36
|
+
loginHostBindings?: boolean;
|
|
37
|
+
}
|
|
29
38
|
export interface PlanDetail {
|
|
30
39
|
orgId: OrgId;
|
|
31
40
|
plannedSteps: PlannedStep[];
|
|
32
41
|
unmanaged: UnmanagedItem[];
|
|
42
|
+
/** #251: see `VersionChangeItem` (`../plan.ts`). Never folded into `plannedSteps`. */
|
|
43
|
+
versionChanges: VersionChangeItem[];
|
|
33
44
|
}
|
|
34
45
|
/** The `Plan` (#19's public contract) a `PlanDetail` carries. */
|
|
35
46
|
export declare function toPlan(detail: PlanDetail): Plan;
|
|
@@ -73,7 +84,11 @@ export declare function toPlan(detail: PlanDetail): Plan;
|
|
|
73
84
|
* app with the platform's jobs scheduler, idempotent on the *recorded*
|
|
74
85
|
* resource id like {@link planOwner} rather than a live resource, since
|
|
75
86
|
* nothing in `live` can report a scheduler registration (no reader exists for
|
|
76
|
-
* it).
|
|
87
|
+
* it). #267 adds one more, right after {@link planOwner} and before every
|
|
88
|
+
* app-creating step below: {@link planLoginHost}, the org's own company
|
|
89
|
+
* login host -- also idempotent on the *recorded* resource id, since
|
|
90
|
+
* `live` has no notion of a ZITADEL trusted domain or a login binding
|
|
91
|
+
* either. Everything
|
|
77
92
|
* else in `live` that none of these eighteen singletons and however many
|
|
78
93
|
* declared issuer apps claim is unmanaged. The full field-by-field provisioning surface (SSO,
|
|
79
94
|
* guests, calls on the org's own domain, adopted/bespoke apps, the actual
|
|
@@ -81,7 +96,7 @@ export declare function toPlan(detail: PlanDetail): Plan;
|
|
|
81
96
|
* Company Factory end to end", which wires real readers and executors
|
|
82
97
|
* against this seam.
|
|
83
98
|
*/
|
|
84
|
-
export declare function computePlanDetail(org: DeclaredOrg, live: LiveResource[], recordedIds?: Readonly<Record<string, string>>, agoraHost?: AgoraHostReading, jobsAvailable?: boolean, platformReadiness?: OrgPlatformReadiness): PlanDetail;
|
|
99
|
+
export declare function computePlanDetail(org: DeclaredOrg, live: LiveResource[], recordedIds?: Readonly<Record<string, string>>, agoraHost?: AgoraHostReading, jobsAvailable?: boolean, platformReadiness?: OrgPlatformReadiness, options?: PlanOrgOptions): PlanDetail;
|
|
85
100
|
/** Restated from `executor.ts`, the same rule: the id of an ai key minted but not delivered. */
|
|
86
101
|
export declare const AI_KEY_UNDELIVERED_SCOPE_KEY = "ai_key_undelivered";
|
|
87
102
|
/** Restated from `executor.ts`, the same rule: the id of a jobs project registered but not delivered. */
|
package/dist/plan/diff.js
CHANGED
|
@@ -1,6 +1,8 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { VERSIONS, floatingTagBlocked } from '../bootstrap/versions.js';
|
|
2
|
+
import { NO_AGORA_HOST_VALUES, ZITADEL_ORG_SCOPE_KEY, agoraEnvDrift, agoraHostMissing, desiredAgoraEnv, } from './agora.js';
|
|
3
|
+
import { LOGIN_BINDING_SCOPE_KEY, loginBindingState, loginHostClientIds, loginHostnameFor, pendingRemovalHosts, } from './login-host.js';
|
|
2
4
|
import { MAIL_COPY_COMPLETED_SCOPE_KEY, MAIL_COPY_DONE_SCOPE_KEY, parseMailCopyCompleted, } from './mail-copy.js';
|
|
3
|
-
import { ORG_MAIL_APPLIED_APEX_SCOPE_KEY, ORG_MAIL_APPLIED_HOST_SCOPE_KEY, } from './platform-diff.js';
|
|
5
|
+
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, resolveAppEnv, versionNotAppliedLine, } from './platform-diff.js';
|
|
4
6
|
import { DEFAULT_ORG_PLATFORM_READINESS } from './platform-org.js';
|
|
5
7
|
/** The `Plan` (#19's public contract) a `PlanDetail` carries. */
|
|
6
8
|
export function toPlan(detail) {
|
|
@@ -8,6 +10,7 @@ export function toPlan(detail) {
|
|
|
8
10
|
orgId: detail.orgId,
|
|
9
11
|
steps: detail.plannedSteps.map((p) => p.step),
|
|
10
12
|
unmanaged: detail.unmanaged,
|
|
13
|
+
versionChanges: detail.versionChanges,
|
|
11
14
|
};
|
|
12
15
|
}
|
|
13
16
|
function resourceKey(system, kind, key) {
|
|
@@ -53,7 +56,11 @@ function resourceKey(system, kind, key) {
|
|
|
53
56
|
* app with the platform's jobs scheduler, idempotent on the *recorded*
|
|
54
57
|
* resource id like {@link planOwner} rather than a live resource, since
|
|
55
58
|
* nothing in `live` can report a scheduler registration (no reader exists for
|
|
56
|
-
* it).
|
|
59
|
+
* it). #267 adds one more, right after {@link planOwner} and before every
|
|
60
|
+
* app-creating step below: {@link planLoginHost}, the org's own company
|
|
61
|
+
* login host -- also idempotent on the *recorded* resource id, since
|
|
62
|
+
* `live` has no notion of a ZITADEL trusted domain or a login binding
|
|
63
|
+
* either. Everything
|
|
57
64
|
* else in `live` that none of these eighteen singletons and however many
|
|
58
65
|
* declared issuer apps claim is unmanaged. The full field-by-field provisioning surface (SSO,
|
|
59
66
|
* guests, calls on the org's own domain, adopted/bespoke apps, the actual
|
|
@@ -61,7 +68,7 @@ function resourceKey(system, kind, key) {
|
|
|
61
68
|
* Company Factory end to end", which wires real readers and executors
|
|
62
69
|
* against this seam.
|
|
63
70
|
*/
|
|
64
|
-
export function computePlanDetail(org, live, recordedIds = {}, agoraHost = NO_AGORA_HOST_VALUES, jobsAvailable = false, platformReadiness = DEFAULT_ORG_PLATFORM_READINESS) {
|
|
71
|
+
export function computePlanDetail(org, live, recordedIds = {}, agoraHost = NO_AGORA_HOST_VALUES, jobsAvailable = false, platformReadiness = DEFAULT_ORG_PLATFORM_READINESS, options = {}) {
|
|
65
72
|
const byKey = new Map(live.map((r) => [resourceKey(r.system, r.kind, r.key), r]));
|
|
66
73
|
const claimed = new Set();
|
|
67
74
|
const plannedSteps = [];
|
|
@@ -71,9 +78,29 @@ export function computePlanDetail(org, live, recordedIds = {}, agoraHost = NO_AG
|
|
|
71
78
|
const owner = planOwner(org, recordedIds, platformReadiness.authReady);
|
|
72
79
|
if (owner)
|
|
73
80
|
plannedSteps.push(owner);
|
|
81
|
+
// #267: after the ZITADEL org step, before any app's own issuer
|
|
82
|
+
// application. Apps created later in this run are not in the binding yet;
|
|
83
|
+
// the next run replans this step and repoints them.
|
|
84
|
+
const loginHost = planLoginHost(org, recordedIds, platformReadiness.authReady, issuer.step !== null, options.loginHostBindings ?? false);
|
|
85
|
+
if (loginHost.step)
|
|
86
|
+
plannedSteps.push(loginHost.step);
|
|
87
|
+
// Planned ahead of everything that needs the zone live (#238); its own step
|
|
88
|
+
// is still pushed in its usual place below.
|
|
89
|
+
const zone = planZone(org, byKey, claimed);
|
|
90
|
+
const zoneWaiting = zone.blocked !== null;
|
|
91
|
+
const zoneBlocked = [];
|
|
74
92
|
const mail = planMail(org, byKey, claimed, recordedIds, platformReadiness.mailHost);
|
|
75
|
-
|
|
93
|
+
// #238: own-domain mail publishes DNS records into the zone, so it waits for
|
|
94
|
+
// an active zone -- blocked, not planned (rule 2).
|
|
95
|
+
if (mail.step && zoneWaiting && org.mail.usesDomain && org.domain) {
|
|
96
|
+
zoneBlocked.push({
|
|
97
|
+
description: `${org.slug}'s mail is blocked, not planned: the zone for ${org.domain} is not active yet`,
|
|
98
|
+
key: 'coolify:mail:zone',
|
|
99
|
+
});
|
|
100
|
+
}
|
|
101
|
+
else if (mail.step) {
|
|
76
102
|
plannedSteps.push(mail.step);
|
|
103
|
+
}
|
|
77
104
|
const mailCopy = planMailCopy(org, byKey, recordedIds);
|
|
78
105
|
if (mailCopy)
|
|
79
106
|
plannedSteps.push(mailCopy);
|
|
@@ -90,6 +117,22 @@ export function computePlanDetail(org, live, recordedIds = {}, agoraHost = NO_AG
|
|
|
90
117
|
const callsService = planCallsService(org, byKey, claimed, platformReadiness.callsReady);
|
|
91
118
|
if (callsService.step)
|
|
92
119
|
plannedSteps.push(callsService.step);
|
|
120
|
+
// #251: same mechanism as the platform's own version-change lines -- see
|
|
121
|
+
// `planPlatform`'s own block in `platform-diff.ts`. Synapse and MAS never
|
|
122
|
+
// patch a running app's image tag once created, so this is
|
|
123
|
+
// `versionNotAppliedLine`, the same honest wording zitadel's own line
|
|
124
|
+
// uses, never `versionChangeLine`'s "X -> Y (redeploy)" -- nothing here
|
|
125
|
+
// ever performs that redeploy.
|
|
126
|
+
const versionChanges = [];
|
|
127
|
+
if (org.calls.enabled && !calls.blocked) {
|
|
128
|
+
const callsAppLive = Boolean(byKey.get(resourceKey('coolify', 'calls', org.id)));
|
|
129
|
+
const synapseChange = versionNotAppliedLine('synapse', callsAppLive, recordedIds[CALLS_SYNAPSE_APPLIED_IMAGE_TAG_SCOPE_KEY], VERSIONS.synapse);
|
|
130
|
+
if (synapseChange)
|
|
131
|
+
versionChanges.push(synapseChange);
|
|
132
|
+
const masChange = versionNotAppliedLine('mas', callsAppLive, recordedIds[CALLS_MAS_APPLIED_IMAGE_TAG_SCOPE_KEY], VERSIONS.mas);
|
|
133
|
+
if (masChange)
|
|
134
|
+
versionChanges.push(masChange);
|
|
135
|
+
}
|
|
93
136
|
const filesOrganisation = planFilesOrganisation(org, byKey, claimed, platformReadiness.filesReady, platformReadiness.missingKeys.files, platformReadiness.noKeyReader);
|
|
94
137
|
if (filesOrganisation.step)
|
|
95
138
|
plannedSteps.push(filesOrganisation.step);
|
|
@@ -135,21 +178,36 @@ export function computePlanDetail(org, live, recordedIds = {}, agoraHost = NO_AG
|
|
|
135
178
|
const database = planDatabase(org, byKey, claimed);
|
|
136
179
|
if (database)
|
|
137
180
|
plannedSteps.push(database);
|
|
138
|
-
const app = planApp(org, byKey, claimed);
|
|
139
|
-
if (app)
|
|
140
|
-
plannedSteps.push(app);
|
|
141
|
-
const tunnel = planTunnel(org, byKey, claimed);
|
|
142
|
-
|
|
181
|
+
const app = planApp(org, byKey, claimed, recordedIds, plannedSteps);
|
|
182
|
+
if (app.step)
|
|
183
|
+
plannedSteps.push(app.step);
|
|
184
|
+
const tunnel = planTunnel(org, byKey, claimed, recordedIds);
|
|
185
|
+
// #238: a tunnel that is being provisioned waits for an active zone; parking
|
|
186
|
+
// one never does.
|
|
187
|
+
if (tunnel && zoneWaiting && tunnel.action.desired.enabled !== false) {
|
|
188
|
+
zoneBlocked.push({
|
|
189
|
+
description: `${org.slug}'s Cloudflare tunnel is blocked, not planned: the zone for ${org.domain} is not active yet`,
|
|
190
|
+
key: 'cloudflare:tunnel',
|
|
191
|
+
});
|
|
192
|
+
}
|
|
193
|
+
else if (tunnel) {
|
|
143
194
|
plannedSteps.push(tunnel);
|
|
144
|
-
|
|
145
|
-
if (zone)
|
|
146
|
-
plannedSteps.push(zone);
|
|
195
|
+
}
|
|
196
|
+
if (zone.step)
|
|
197
|
+
plannedSteps.push(zone.step);
|
|
147
198
|
// Right after the zone (#43): `cloudflareRateLimit` resolves the zone by
|
|
148
199
|
// apex domain, so the zone step above must already have run (or the zone
|
|
149
200
|
// must already exist live) by the time this one's action executes.
|
|
150
201
|
const rateLimit = planRateLimit(org, recordedIds);
|
|
151
|
-
if (rateLimit)
|
|
202
|
+
if (rateLimit && zoneWaiting) {
|
|
203
|
+
zoneBlocked.push({
|
|
204
|
+
description: `${org.slug}'s sign-in rate limit is blocked, not planned: the zone for ${org.domain} is not active yet`,
|
|
205
|
+
key: 'cloudflare:ratelimit',
|
|
206
|
+
});
|
|
207
|
+
}
|
|
208
|
+
else if (rateLimit) {
|
|
152
209
|
plannedSteps.push(rateLimit);
|
|
210
|
+
}
|
|
153
211
|
// After the apps: the customers org is granted the project the first app
|
|
154
212
|
// step creates.
|
|
155
213
|
const customers = planCustomers(org, live, claimed, recordedIds);
|
|
@@ -199,6 +257,10 @@ export function computePlanDetail(org, live, recordedIds = {}, agoraHost = NO_AG
|
|
|
199
257
|
// budget, key) would otherwise also have nothing to act on.
|
|
200
258
|
if (issuer.blocked)
|
|
201
259
|
unmanaged.push({ description: issuer.blocked, key: 'zitadel:issuer' });
|
|
260
|
+
// #267 (rule 2): a login host with no ZITADEL org to trust it in is blocked, not planned to throw.
|
|
261
|
+
if (loginHost.blocked) {
|
|
262
|
+
unmanaged.push({ description: loginHost.blocked, key: 'zitadel:login-host' });
|
|
263
|
+
}
|
|
202
264
|
if (mail.blocked)
|
|
203
265
|
unmanaged.push({ description: mail.blocked, key: 'coolify:mail' });
|
|
204
266
|
// #162: reported separately from `mail.blocked` -- missing an owner
|
|
@@ -218,7 +280,14 @@ export function computePlanDetail(org, live, recordedIds = {}, agoraHost = NO_AG
|
|
|
218
280
|
unmanaged.push({ description: storage.blocked, key: 'files:storage' });
|
|
219
281
|
if (aiBudget.blocked)
|
|
220
282
|
unmanaged.push({ description: aiBudget.blocked, key: 'ai:budget' });
|
|
221
|
-
|
|
283
|
+
// #238: a pending zone, reported here with its nameservers rather than
|
|
284
|
+
// silently treated as converged.
|
|
285
|
+
if (zone.blocked)
|
|
286
|
+
unmanaged.push({ description: zone.blocked, key: 'cloudflare:zone' });
|
|
287
|
+
unmanaged.push(...zoneBlocked);
|
|
288
|
+
// #243: an appEnv source nothing will record is reported, not silently skipped.
|
|
289
|
+
unmanaged.push(...app.blocked);
|
|
290
|
+
return { orgId: org.id, plannedSteps, unmanaged, versionChanges };
|
|
222
291
|
}
|
|
223
292
|
/**
|
|
224
293
|
* The org's own ZITADEL org (issuer), a per-org singleton like every other
|
|
@@ -355,6 +424,92 @@ function planOwner(org, recordedIds, authReady) {
|
|
|
355
424
|
},
|
|
356
425
|
};
|
|
357
426
|
}
|
|
427
|
+
/**
|
|
428
|
+
* #267: the org's own company login host (`login-host.ts`'s
|
|
429
|
+
* `loginHostnameFor`) -- `auth.<domain>` once the org has one,
|
|
430
|
+
* `<slug>-auth.oikos.host` for tier 1. Idempotent on the *recorded*
|
|
431
|
+
* resource id, the same shape `planOwner` above uses and for the same
|
|
432
|
+
* reason: `ZitadelReader.listResources` has no notion of a trusted domain
|
|
433
|
+
* or a login binding, so there is nothing in `live` this step could ever
|
|
434
|
+
* claim or compare against.
|
|
435
|
+
*
|
|
436
|
+
* Unlike `planOwner`, a re-run is not simply "already done or not": the
|
|
437
|
+
* recorded value is compared against {@link loginHostnameFor}'s own value
|
|
438
|
+
* for *this* apply, so an org moving from tier 1 onto its own domain (or
|
|
439
|
+
* losing one) replans this step rather than staying silent forever once
|
|
440
|
+
* first applied -- `executor.ts`'s `zitadelLoginHost` then also moves every
|
|
441
|
+
* already-live app onto the new base URI, and (#274) removes the *old* host
|
|
442
|
+
* from ZITADEL's trusted domains and zitadel-login's binding, reading it
|
|
443
|
+
* back from this same recorded value before overwriting it -- no separate
|
|
444
|
+
* action field or planning step of its own needed for that half. Mints
|
|
445
|
+
* nothing either way: adding a trusted domain is not a one-time secret, so
|
|
446
|
+
* there is no "undelivered"
|
|
447
|
+
* guard to check the way `planJobsRegistration`'s own does.
|
|
448
|
+
*
|
|
449
|
+
* Ordered right after `planOwner` (not after mail, chat, or any hosting
|
|
450
|
+
* step), for the same reason `planOwner`'s own doc comment gives: on a
|
|
451
|
+
* first-ever apply the ZITADEL org this step's executor needs
|
|
452
|
+
* (`context.recordedIds().zitadel_org`) has already been recorded by the
|
|
453
|
+
* issuer step two steps before it in the same run, and this step itself
|
|
454
|
+
* runs well before `planIssuerApps`/`planCmsClient`/`planAgoraClient`
|
|
455
|
+
* create any app under the org's project -- #267's own "before any app's
|
|
456
|
+
* issuer application".
|
|
457
|
+
*/
|
|
458
|
+
function planLoginHost(org, recordedIds, authReady, issuerPlanned, bindingsWired) {
|
|
459
|
+
const none = { step: null, blocked: null };
|
|
460
|
+
const key = resourceKey('zitadel', 'login-host', org.id);
|
|
461
|
+
const host = loginHostnameFor(org);
|
|
462
|
+
// Already applied -- a re-run plans nothing unless the host changed, an app
|
|
463
|
+
// client id appeared since the binding was last written (#267, rule 4), or
|
|
464
|
+
// an earlier host change still has a removal owed (#274 fix round 2):
|
|
465
|
+
// `zitadelLoginHost` always advances the recorded host to the current one,
|
|
466
|
+
// so a pending removal is the only way to know one is still owed, and
|
|
467
|
+
// nothing else here would replan the step to retry it.
|
|
468
|
+
if (recordedIds[key] === host &&
|
|
469
|
+
recordedIds[LOGIN_BINDING_SCOPE_KEY] ===
|
|
470
|
+
loginBindingState(host, loginHostClientIds(recordedIds)) &&
|
|
471
|
+
pendingRemovalHosts(recordedIds).length === 0) {
|
|
472
|
+
return none;
|
|
473
|
+
}
|
|
474
|
+
// #177: nothing to add a trusted domain to yet -- `planIssuer` already
|
|
475
|
+
// reports the platform's sign-in as blocked, so this step stays silent
|
|
476
|
+
// rather than repeating it, the same rule `planOwner` follows.
|
|
477
|
+
if (!authReady)
|
|
478
|
+
return none;
|
|
479
|
+
// Isolation between orgs rests on zitadel-login's binding, and repointing
|
|
480
|
+
// an app at a host with no binding breaks its sign-in: trust nothing and
|
|
481
|
+
// repoint nothing until the port that writes the binding is wired.
|
|
482
|
+
if (!bindingsWired) {
|
|
483
|
+
return {
|
|
484
|
+
step: null,
|
|
485
|
+
blocked: `${org.slug}'s login host ${host} waits for the loginHostBindings port, which the host has not wired`,
|
|
486
|
+
};
|
|
487
|
+
}
|
|
488
|
+
// No ZITADEL org to trust the host in: wait for it, unless the issuer step
|
|
489
|
+
// in this very plan creates it first (it runs before this one).
|
|
490
|
+
if (!recordedIds[ZITADEL_ORG_SCOPE_KEY] && !issuerPlanned) {
|
|
491
|
+
return {
|
|
492
|
+
step: null,
|
|
493
|
+
blocked: `${org.slug}'s login host ${host} waits for its ZITADEL org, which is not recorded yet`,
|
|
494
|
+
};
|
|
495
|
+
}
|
|
496
|
+
return {
|
|
497
|
+
step: {
|
|
498
|
+
action: {
|
|
499
|
+
system: 'zitadel',
|
|
500
|
+
kind: 'login-host',
|
|
501
|
+
key: org.id,
|
|
502
|
+
desired: { host },
|
|
503
|
+
},
|
|
504
|
+
step: {
|
|
505
|
+
description: `set ${org.slug}'s login host to ${host}`,
|
|
506
|
+
destroys: false,
|
|
507
|
+
migrates: false,
|
|
508
|
+
},
|
|
509
|
+
},
|
|
510
|
+
blocked: null,
|
|
511
|
+
};
|
|
512
|
+
}
|
|
358
513
|
/**
|
|
359
514
|
* #162: the flat scope id `executor.ts`'s `ensureOwnerMailbox` records once
|
|
360
515
|
* it has given the org's owner a mailbox (`MAIL_OWNER_MAILBOX_SCOPE_KEY`
|
|
@@ -364,6 +519,10 @@ function planOwner(org, recordedIds, authReady) {
|
|
|
364
519
|
const MAIL_OWNER_MAILBOX_SCOPE_KEY = 'mail_owner_mailbox';
|
|
365
520
|
/** #230: restated from `executor.ts`'s `MAIL_ROLE_MAILBOXES_SCOPE_KEY`, same rule: comma-joined local parts already created. */
|
|
366
521
|
const MAIL_ROLE_MAILBOXES_SCOPE_KEY = 'mail_role_mailboxes';
|
|
522
|
+
/** Restated from `executor.ts`, the same rule: the app id of the org tunnel's cloudflared connector. */
|
|
523
|
+
const CLOUDFLARE_TUNNEL_CONNECTOR_APP_SCOPE_KEY = 'cloudflare_tunnel_connector_app';
|
|
524
|
+
/** #236: restated from `executor.ts`, the same rule: the domain the org tunnel's DNS was last published under. */
|
|
525
|
+
const CLOUDFLARE_TUNNEL_DNS_DOMAIN_SCOPE_KEY = 'cloudflare_tunnel_dns_domain';
|
|
367
526
|
/** Restated from `executor.ts`, the same rule: the mail domain the last apply resolved. */
|
|
368
527
|
const MAIL_DOMAIN_SCOPE_KEY = 'mail_domain';
|
|
369
528
|
/** Restated from `executor.ts`, the same rule: the id of an ai key minted but not delivered. */
|
|
@@ -546,6 +705,10 @@ function planMailCopy(org, byKey, recordedIds) {
|
|
|
546
705
|
};
|
|
547
706
|
}
|
|
548
707
|
function planChat(org, byKey, claimed) {
|
|
708
|
+
// Chat off (not in v1): plan nothing, and leave any live chat unclaimed so
|
|
709
|
+
// it is reported in `unmanaged` and never adopted or destroyed.
|
|
710
|
+
if (!org.chat.enabled)
|
|
711
|
+
return null;
|
|
549
712
|
const key = resourceKey('coolify', 'chat', org.id);
|
|
550
713
|
const live = byKey.get(key);
|
|
551
714
|
if (live)
|
|
@@ -620,6 +783,26 @@ function planCalls(org, byKey, claimed, callsReady) {
|
|
|
620
783
|
blocked: `${org.slug}'s calls Matrix stack is blocked, not planned: the platform's shared calls SFU is off`,
|
|
621
784
|
};
|
|
622
785
|
}
|
|
786
|
+
// #248: the two images this stack's own apply would run, checked the same
|
|
787
|
+
// way the platform's SFU checks LiveKit/cloudflared and ZITADEL checks its
|
|
788
|
+
// own tag -- a re-apply of the same declaration must not silently run a
|
|
789
|
+
// different version with no diff shown anywhere.
|
|
790
|
+
if (org.calls.enabled && !byKey.get(key)) {
|
|
791
|
+
const synapseBlocked = floatingTagBlocked('Synapse', VERSIONS.synapse, 'v1.161.0');
|
|
792
|
+
if (synapseBlocked) {
|
|
793
|
+
return {
|
|
794
|
+
step: null,
|
|
795
|
+
blocked: `${org.slug}'s calls Matrix stack is blocked, not planned: ${synapseBlocked}`,
|
|
796
|
+
};
|
|
797
|
+
}
|
|
798
|
+
const masBlocked = floatingTagBlocked('matrix-authentication-service', VERSIONS.mas, '1.24.0');
|
|
799
|
+
if (masBlocked) {
|
|
800
|
+
return {
|
|
801
|
+
step: null,
|
|
802
|
+
blocked: `${org.slug}'s calls Matrix stack is blocked, not planned: ${masBlocked}`,
|
|
803
|
+
};
|
|
804
|
+
}
|
|
805
|
+
}
|
|
623
806
|
return {
|
|
624
807
|
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)`),
|
|
625
808
|
blocked: null,
|
|
@@ -1203,23 +1386,103 @@ function planHostingToggle(system, kind, desiredEnabled, org, byKey, claimed, de
|
|
|
1203
1386
|
* resource's `enabled` field -- a live resource nothing has actually
|
|
1204
1387
|
* checked yet is assumed fine rather than replanned every time.
|
|
1205
1388
|
*/
|
|
1206
|
-
function planApp(org, byKey, claimed) {
|
|
1389
|
+
function planApp(org, byKey, claimed, recordedIds, plannedSteps) {
|
|
1390
|
+
// #243: an `appEnv` source with no recorded value and no step planned to
|
|
1391
|
+
// record one this run has nothing to push, now or after this apply.
|
|
1392
|
+
const sourceStep = {
|
|
1393
|
+
filesOrganisationId: ['files', 'organisation'],
|
|
1394
|
+
aiOrganisationId: ['ai', 'organisation'],
|
|
1395
|
+
mailDomain: ['coolify', 'mail'],
|
|
1396
|
+
};
|
|
1397
|
+
const { values: wantedEnv, unresolved } = resolveAppEnv(org, recordedIds);
|
|
1398
|
+
const blocked = org.hosting.enabled
|
|
1399
|
+
? unresolved
|
|
1400
|
+
.filter(([, source]) => !plannedSteps.some((p) => p.action.system === sourceStep[source][0] &&
|
|
1401
|
+
p.action.kind === sourceStep[source][1]))
|
|
1402
|
+
.map(([name, source]) => ({
|
|
1403
|
+
description: `${org.slug}'s app env ${name} is blocked, not planned: its source ${source} has no recorded value yet`,
|
|
1404
|
+
key: `coolify:app:env:${name}`,
|
|
1405
|
+
}))
|
|
1406
|
+
: [];
|
|
1207
1407
|
const toggled = planHostingToggle('coolify', 'app', org.hosting.enabled, org, byKey, claimed, () => `provision ${org.slug}'s app`, () => `park ${org.slug}'s app (stops serving; app data is retained, not deleted -- #7 corrections 1)`);
|
|
1208
1408
|
if (toggled)
|
|
1209
|
-
return toggled;
|
|
1409
|
+
return { step: toggled, blocked };
|
|
1210
1410
|
if (!org.hosting.enabled)
|
|
1211
|
-
return null;
|
|
1411
|
+
return { step: null, blocked };
|
|
1212
1412
|
const live = byKey.get(resourceKey('coolify', 'app', org.id));
|
|
1213
|
-
if (!live
|
|
1214
|
-
return null;
|
|
1215
|
-
|
|
1216
|
-
|
|
1217
|
-
|
|
1218
|
-
|
|
1219
|
-
|
|
1220
|
-
migrates: false,
|
|
1221
|
-
},
|
|
1413
|
+
if (!live)
|
|
1414
|
+
return { step: null, blocked };
|
|
1415
|
+
const action = {
|
|
1416
|
+
system: 'coolify',
|
|
1417
|
+
kind: 'app',
|
|
1418
|
+
key: org.id,
|
|
1419
|
+
desired: { enabled: true },
|
|
1222
1420
|
};
|
|
1421
|
+
if (live.config.hasLogVolume === false) {
|
|
1422
|
+
return {
|
|
1423
|
+
step: {
|
|
1424
|
+
action,
|
|
1425
|
+
step: {
|
|
1426
|
+
description: `add a durable log directory to ${org.slug}'s app (persistent volume at /app/logs + LOG_DIR) so a database outage does not erase the evidence (#75)`,
|
|
1427
|
+
destroys: false,
|
|
1428
|
+
migrates: false,
|
|
1429
|
+
},
|
|
1430
|
+
},
|
|
1431
|
+
blocked,
|
|
1432
|
+
};
|
|
1433
|
+
}
|
|
1434
|
+
// #239 (rule 4), from live state: an app with a database but no
|
|
1435
|
+
// `DATABASE_URL_OWNER` env still has the pre-split superuser URL, or never
|
|
1436
|
+
// got the push. Names only, never values. A reader that did not list env
|
|
1437
|
+
// names (`envNames` absent) reads as compliant, like `hasLogVolume`.
|
|
1438
|
+
const envNames = live.config.envNames;
|
|
1439
|
+
const dbLive = byKey.get(resourceKey('coolify', 'database', org.id));
|
|
1440
|
+
const dbReady = org.hosting.database && Boolean(recordedIds[`coolify:database:${org.id}`]);
|
|
1441
|
+
if (dbReady && dbLive?.config.hasInternalUrl === false) {
|
|
1442
|
+
// Rule 2: the push cannot succeed yet, so report it rather than replan
|
|
1443
|
+
// forever.
|
|
1444
|
+
blocked.push({
|
|
1445
|
+
description: `${org.slug}'s app database URLs are blocked, not planned: the database reports no internal connection URL yet`,
|
|
1446
|
+
key: 'coolify:app:database-url',
|
|
1447
|
+
});
|
|
1448
|
+
}
|
|
1449
|
+
else if (dbReady &&
|
|
1450
|
+
// Only a read that saw the URL replans: an unread database (a failed
|
|
1451
|
+
// read) would plan a step that pushes nothing, forever.
|
|
1452
|
+
dbLive?.config.hasInternalUrl === true &&
|
|
1453
|
+
((Array.isArray(envNames) && !envNames.includes('DATABASE_URL_OWNER')) ||
|
|
1454
|
+
// A crash between the push and the restart leaves the env right and no
|
|
1455
|
+
// marker: replan once more to restart and set it.
|
|
1456
|
+
!recordedIds[DB_ENV_APPLIED_SCOPE_KEY])) {
|
|
1457
|
+
return {
|
|
1458
|
+
step: {
|
|
1459
|
+
action,
|
|
1460
|
+
step: {
|
|
1461
|
+
description: `give ${org.slug}'s app its owner and runtime database URLs and restart it (#239)`,
|
|
1462
|
+
destroys: false,
|
|
1463
|
+
migrates: false,
|
|
1464
|
+
},
|
|
1465
|
+
},
|
|
1466
|
+
blocked,
|
|
1467
|
+
};
|
|
1468
|
+
}
|
|
1469
|
+
// #243 (rule 3): a changed `hosting.appEnv` reaches an app that is already
|
|
1470
|
+
// live. `pushAppEnv` records what it pushed, so this plans the same step
|
|
1471
|
+
// again, with a redeploy, only once the resolved map differs from that.
|
|
1472
|
+
if (JSON.stringify(wantedEnv) !== (recordedIds[APP_ENV_APPLIED_SCOPE_KEY] ?? '{}')) {
|
|
1473
|
+
return {
|
|
1474
|
+
step: {
|
|
1475
|
+
action,
|
|
1476
|
+
step: {
|
|
1477
|
+
description: `push ${org.slug}'s changed app env (appEnv) and redeploy the app`,
|
|
1478
|
+
destroys: false,
|
|
1479
|
+
migrates: false,
|
|
1480
|
+
},
|
|
1481
|
+
},
|
|
1482
|
+
blocked,
|
|
1483
|
+
};
|
|
1484
|
+
}
|
|
1485
|
+
return { step: null, blocked };
|
|
1223
1486
|
}
|
|
1224
1487
|
/**
|
|
1225
1488
|
* #173: register the org's own app with the platform's jobs scheduler
|
|
@@ -1327,12 +1590,61 @@ function planDatabase(org, byKey, claimed) {
|
|
|
1327
1590
|
* domain) plans no per-org tunnel for Agora: `<slug>-agora.oikos.host` rides
|
|
1328
1591
|
* the shared `*.oikos.host` wildcard tunnel (`scripts/shared-ingress.mjs`).
|
|
1329
1592
|
*/
|
|
1330
|
-
function planTunnel(org, byKey, claimed) {
|
|
1593
|
+
function planTunnel(org, byKey, claimed, recordedIds) {
|
|
1331
1594
|
const desired = (org.hosting.tunnel && org.hosting.usesDomain && Boolean(org.domain)) ||
|
|
1332
1595
|
(org.site.enabled && Boolean(org.domain)) ||
|
|
1333
1596
|
(org.calls.enabled && org.calls.usesDomain && Boolean(org.domain)) ||
|
|
1334
1597
|
Boolean(org.domain);
|
|
1335
|
-
|
|
1598
|
+
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)`);
|
|
1599
|
+
if (toggled || !desired)
|
|
1600
|
+
return toggled;
|
|
1601
|
+
// A live, enabled tunnel re-plans the same step again when either:
|
|
1602
|
+
// #235, no connector recorded -- a first connector create that failed, or
|
|
1603
|
+
// a tunnel that predates connectors; or #236, its DNS was last published
|
|
1604
|
+
// under a domain other than the one now declared -- never published at
|
|
1605
|
+
// all (unset), or the org's domain has since changed. `org.domain` is set
|
|
1606
|
+
// whenever `desired` is true above, in every branch. The step itself
|
|
1607
|
+
// (`executor.ts`'s `cloudflareTunnel`) finds or creates the connector by
|
|
1608
|
+
// name and upserts DNS idempotently (and, on a domain change, cleans up
|
|
1609
|
+
// the old one once the new one is published -- round 3: publishing first
|
|
1610
|
+
// means a conflict on the new domain leaves the old one still live rather
|
|
1611
|
+
// than taking the org dark), so re-running it never duplicates either.
|
|
1612
|
+
const live = byKey.get(resourceKey('cloudflare', 'tunnel', org.id));
|
|
1613
|
+
if (!live)
|
|
1614
|
+
return null;
|
|
1615
|
+
const connectorMissing = !recordedIds[CLOUDFLARE_TUNNEL_CONNECTOR_APP_SCOPE_KEY];
|
|
1616
|
+
const dnsStale = recordedIds[CLOUDFLARE_TUNNEL_DNS_DOMAIN_SCOPE_KEY] !== org.domain;
|
|
1617
|
+
if (!connectorMissing && !dnsStale)
|
|
1618
|
+
return null;
|
|
1619
|
+
return {
|
|
1620
|
+
action: { system: 'cloudflare', kind: 'tunnel', key: org.id, desired: { enabled: true } },
|
|
1621
|
+
step: {
|
|
1622
|
+
description: connectorMissing
|
|
1623
|
+
? `run a cloudflared connector for ${org.slug}'s Cloudflare tunnel`
|
|
1624
|
+
: `point ${org.slug}'s hostnames at its Cloudflare tunnel`,
|
|
1625
|
+
destroys: false,
|
|
1626
|
+
migrates: false,
|
|
1627
|
+
},
|
|
1628
|
+
};
|
|
1629
|
+
}
|
|
1630
|
+
/**
|
|
1631
|
+
* #238: the org's own zone's live `status`/`name_servers`, read from the
|
|
1632
|
+
* same `kind: 'zone'` {@link LiveResource} `planZone` already matches by key
|
|
1633
|
+
* -- `@wtfalch/providers/cloudflare`'s `CloudflareClient.listResources`
|
|
1634
|
+
* already returns both (`config: { id, status, name_servers }`), so nothing
|
|
1635
|
+
* new has to be fetched; this just reads what was already there. `null`
|
|
1636
|
+
* means no zone is live yet at all (not a readiness problem `planZone`
|
|
1637
|
+
* itself reports -- it is still planning the zone's own creation).
|
|
1638
|
+
*/
|
|
1639
|
+
function zoneReadiness(domain, byKey) {
|
|
1640
|
+
const zone = byKey.get(resourceKey('cloudflare', 'zone', domain));
|
|
1641
|
+
if (!zone)
|
|
1642
|
+
return null;
|
|
1643
|
+
const config = zone.config;
|
|
1644
|
+
return {
|
|
1645
|
+
active: config.status === 'active',
|
|
1646
|
+
nameServers: config.name_servers ?? [],
|
|
1647
|
+
};
|
|
1336
1648
|
}
|
|
1337
1649
|
/**
|
|
1338
1650
|
* The org's Cloudflare zone for its own domain (`org.ts`'s
|
|
@@ -1364,25 +1676,40 @@ function planZone(org, byKey, claimed) {
|
|
|
1364
1676
|
(org.calls.enabled && org.calls.usesDomain) ||
|
|
1365
1677
|
Boolean(org.domain);
|
|
1366
1678
|
if (!usesDomain || !org.domain)
|
|
1367
|
-
return null;
|
|
1679
|
+
return { step: null, blocked: null };
|
|
1368
1680
|
const key = resourceKey('cloudflare', 'zone', org.domain);
|
|
1369
1681
|
const live = byKey.get(key);
|
|
1370
|
-
if (live)
|
|
1682
|
+
if (live) {
|
|
1371
1683
|
claimed.add(key);
|
|
1372
|
-
|
|
1373
|
-
|
|
1684
|
+
const readiness = zoneReadiness(org.domain, byKey);
|
|
1685
|
+
// #238: a zone stays pending until the customer changes nameservers at
|
|
1686
|
+
// their registrar -- reported here, not silently treated as converged,
|
|
1687
|
+
// so an apply says what it is waiting for. A later apply, once
|
|
1688
|
+
// Cloudflare marks the zone active, reports nothing and the DNS- and
|
|
1689
|
+
// mail-dependent steps below (`planMail`) resume.
|
|
1690
|
+
if (readiness && !readiness.active) {
|
|
1691
|
+
return {
|
|
1692
|
+
step: null,
|
|
1693
|
+
blocked: `${org.slug}'s zone for ${org.domain} is waiting for nameservers: ${readiness.nameServers.join(', ')}`,
|
|
1694
|
+
};
|
|
1695
|
+
}
|
|
1696
|
+
return { step: null, blocked: null };
|
|
1697
|
+
}
|
|
1374
1698
|
return {
|
|
1375
|
-
action: {
|
|
1376
|
-
system: 'cloudflare',
|
|
1377
|
-
kind: 'zone',
|
|
1378
|
-
key: org.domain,
|
|
1379
|
-
desired: { domain: org.domain },
|
|
1380
|
-
},
|
|
1381
1699
|
step: {
|
|
1382
|
-
|
|
1383
|
-
|
|
1384
|
-
|
|
1700
|
+
action: {
|
|
1701
|
+
system: 'cloudflare',
|
|
1702
|
+
kind: 'zone',
|
|
1703
|
+
key: org.domain,
|
|
1704
|
+
desired: { domain: org.domain },
|
|
1705
|
+
},
|
|
1706
|
+
step: {
|
|
1707
|
+
description: `provision a Cloudflare zone for ${org.domain}`,
|
|
1708
|
+
destroys: false,
|
|
1709
|
+
migrates: false,
|
|
1710
|
+
},
|
|
1385
1711
|
},
|
|
1712
|
+
blocked: null,
|
|
1386
1713
|
};
|
|
1387
1714
|
}
|
|
1388
1715
|
/**
|