@wtfalch/foundry 0.32.0 → 0.34.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 +18 -1
- package/dist/bootstrap/bootstrap.js +167 -28
- package/dist/bootstrap/declare.d.ts +5 -1
- package/dist/bootstrap/declare.js +3 -1
- package/dist/bootstrap/index.d.ts +1 -1
- package/dist/bootstrap/versions.d.ts +4 -0
- package/dist/calls-sfu.d.ts +10 -0
- package/dist/calls-sfu.js +41 -0
- package/dist/index.d.ts +18 -8
- package/dist/index.js +8 -3
- package/dist/migrations/0051_org_services.sql +13 -0
- package/dist/migrations/0052_platform_mail_relay.sql +8 -0
- package/dist/org.d.ts +24 -2
- package/dist/org.js +42 -0
- package/dist/plan/boule.d.ts +24 -1
- package/dist/plan/boule.js +29 -1
- package/dist/plan/calls-matrix.d.ts +9 -0
- package/dist/plan/calls-matrix.js +17 -0
- package/dist/plan/cloudflared-connector.d.ts +7 -1
- package/dist/plan/cloudflared-connector.js +14 -1
- package/dist/plan/diff.d.ts +32 -1
- package/dist/plan/diff.js +383 -26
- package/dist/plan/executor.d.ts +177 -7
- package/dist/plan/executor.js +738 -51
- package/dist/plan/health-check-settings.d.ts +28 -0
- package/dist/plan/health-check-settings.js +36 -0
- package/dist/plan/health-gaps.d.ts +33 -0
- package/dist/plan/health-gaps.js +89 -0
- package/dist/plan/health-targets.js +1 -1
- package/dist/plan/held-key-refs.d.ts +10 -0
- package/dist/plan/held-key-refs.js +10 -0
- package/dist/plan/index.d.ts +16 -6
- package/dist/plan/index.js +8 -3
- package/dist/plan/live.d.ts +5 -2
- package/dist/plan/live.js +15 -2
- package/dist/plan/locked-hostnames.d.ts +5 -2
- package/dist/plan/locked-hostnames.js +9 -1
- package/dist/plan/login-host.d.ts +62 -2
- package/dist/plan/login-host.js +104 -1
- package/dist/plan/mail-relay.d.ts +120 -0
- package/dist/plan/mail-relay.js +323 -0
- package/dist/plan/org-names.d.ts +24 -0
- package/dist/plan/org-names.js +52 -0
- package/dist/plan/own-diff.d.ts +169 -0
- package/dist/plan/own-diff.js +432 -0
- package/dist/plan/own-dns.d.ts +6 -0
- package/dist/plan/own-dns.js +15 -0
- package/dist/plan/own-limits.d.ts +22 -0
- package/dist/plan/own-limits.js +101 -0
- package/dist/plan/own-services.d.ts +193 -0
- package/dist/plan/own-services.js +167 -0
- package/dist/plan/own-sfu.d.ts +69 -0
- package/dist/plan/own-sfu.js +255 -0
- package/dist/plan/plan-apply.d.ts +101 -7
- package/dist/plan/plan-apply.js +216 -12
- package/dist/plan/platform-diff.d.ts +61 -12
- package/dist/plan/platform-diff.js +140 -34
- package/dist/plan/platform-org.d.ts +36 -0
- package/dist/plan/platform-org.js +96 -2
- package/dist/plan/platform-providers.d.ts +407 -12
- package/dist/plan/platform-providers.js +947 -422
- package/dist/plan/sign-in-org.d.ts +99 -0
- package/dist/plan/sign-in-org.js +151 -0
- package/dist/plan/zitadel-login-policy.js +1 -1
- package/dist/plan/zitadel-system-api.d.ts +22 -1
- package/dist/plan/zitadel-system-api.js +49 -8
- package/dist/plan.d.ts +18 -0
- package/dist/platform.d.ts +8 -0
- package/dist/slug.js +1 -0
- package/dist/store/postgres.js +51 -2
- package/dist/store/schema.d.ts +51 -0
- package/dist/store/schema.js +5 -0
- package/package.json +1 -1
- package/versions.json +3 -1
|
@@ -0,0 +1,255 @@
|
|
|
1
|
+
import { mintLivekitCredentials } from '../calls-sfu-keys.js';
|
|
2
|
+
import { LIVEKIT_SIGNALLING_PORT, sfuSlotPorts } from '../calls-sfu.js';
|
|
3
|
+
import { ensureCloudflaredConnector } from './cloudflared-connector.js';
|
|
4
|
+
import { waitForNewDeployment } from './deploy-health.js';
|
|
5
|
+
import { healthCheckSettings } from './health-check-settings.js';
|
|
6
|
+
import { recordOwnDnsHostname } from './own-dns.js';
|
|
7
|
+
/** The LiveKit image, the same repository the shared SFU runs. */
|
|
8
|
+
export const OWN_SFU_IMAGE = 'livekit/livekit-server';
|
|
9
|
+
export const OWN_SFU_APP_SCOPE_KEY = 'coolify_own_sfu_app';
|
|
10
|
+
export const OWN_SFU_TUNNEL_SCOPE_KEY = 'cloudflare_own_sfu_tunnel';
|
|
11
|
+
export const OWN_SFU_CONNECTOR_APP_SCOPE_KEY = 'coolify_own_sfu_connector_app';
|
|
12
|
+
/** Set to the app id before the key pair leaves, cleared after the pair is delivered and written to the app. */
|
|
13
|
+
export const OWN_SFU_KEYS_UNDELIVERED_SCOPE_KEY = 'own_sfu_keys_undelivered';
|
|
14
|
+
/** The app id once the company's key pair is delivered and in the app's env. */
|
|
15
|
+
export const OWN_SFU_KEYS_SCOPE_KEY = 'own_sfu_keys_set';
|
|
16
|
+
export const OWN_SFU_DEPLOY_PENDING_SCOPE_KEY = 'own_sfu_deploy_pending';
|
|
17
|
+
export const OWN_SFU_APPLIED_VERSION_SCOPE_KEY = 'own_sfu_applied_version';
|
|
18
|
+
export const OWN_SFU_APPLIED_SLOT_SCOPE_KEY = 'own_sfu_applied_slot';
|
|
19
|
+
export const OWN_SFU_APPLIED_HOSTNAME_SCOPE_KEY = 'own_sfu_applied_hostname';
|
|
20
|
+
export const SFU_INTERNAL_URL_SCOPE_KEY = 'sfu_internal_url';
|
|
21
|
+
export const SFU_PUBLIC_URL_SCOPE_KEY = 'sfu_public_url';
|
|
22
|
+
const KEYS_DELIVERY_ATTEMPTS = 3;
|
|
23
|
+
const CONFIG_ENV = 'LIVEKIT_CONFIG';
|
|
24
|
+
/** The Docker `ports_mappings` string for a slot: the UDP range and the TCP fallback port, one to one. */
|
|
25
|
+
export function sfuPortsMappings(slot) {
|
|
26
|
+
const { udpStart, udpEnd, tcp } = sfuSlotPorts(slot);
|
|
27
|
+
return `${udpStart}-${udpEnd}:${udpStart}-${udpEnd}/udp,${tcp}:${tcp}`;
|
|
28
|
+
}
|
|
29
|
+
/**
|
|
30
|
+
* LiveKit takes its whole config from one env value. `node_ip` is the
|
|
31
|
+
* server's address: the app is not on the host network, so the media ports
|
|
32
|
+
* reach it through the published mappings.
|
|
33
|
+
*/
|
|
34
|
+
export function sfuConfig(input) {
|
|
35
|
+
const { udpStart, udpEnd, tcp } = sfuSlotPorts(input.slot);
|
|
36
|
+
return [
|
|
37
|
+
`port: ${LIVEKIT_SIGNALLING_PORT}`,
|
|
38
|
+
'rtc:',
|
|
39
|
+
` tcp_port: ${tcp}`,
|
|
40
|
+
` port_range_start: ${udpStart}`,
|
|
41
|
+
` port_range_end: ${udpEnd}`,
|
|
42
|
+
' use_external_ip: false',
|
|
43
|
+
` node_ip: ${input.nodeIp}`,
|
|
44
|
+
'keys:',
|
|
45
|
+
` ${input.keys.apiKey}: ${input.keys.apiSecret}`,
|
|
46
|
+
'',
|
|
47
|
+
].join('\n');
|
|
48
|
+
}
|
|
49
|
+
function heldPairOf(value) {
|
|
50
|
+
if (!value)
|
|
51
|
+
return null;
|
|
52
|
+
let parsed;
|
|
53
|
+
try {
|
|
54
|
+
parsed = JSON.parse(value);
|
|
55
|
+
}
|
|
56
|
+
catch {
|
|
57
|
+
return null;
|
|
58
|
+
}
|
|
59
|
+
const { apiKey, apiSecret } = (parsed ?? {});
|
|
60
|
+
return typeof apiKey === 'string' &&
|
|
61
|
+
typeof apiSecret === 'string' &&
|
|
62
|
+
KEY_PAIR_SHAPE.test(apiKey) &&
|
|
63
|
+
KEY_PAIR_SHAPE.test(apiSecret)
|
|
64
|
+
? { apiKey, apiSecret }
|
|
65
|
+
: null;
|
|
66
|
+
}
|
|
67
|
+
function keysOf(config) {
|
|
68
|
+
const match = /^keys:\n {2}([A-Za-z0-9]+): ([A-Za-z0-9]+)$/m.exec(config ?? '');
|
|
69
|
+
return match ? { apiKey: match[1], apiSecret: match[2] } : null;
|
|
70
|
+
}
|
|
71
|
+
const KEY_PAIR_SHAPE = /^[A-Za-z0-9]{8,128}$/;
|
|
72
|
+
/**
|
|
73
|
+
* One company's LiveKit media server as a Coolify app: the image, its slot's
|
|
74
|
+
* ports published to the server, and a tunnel for signalling at `sfu.<domain>`.
|
|
75
|
+
* The company's single key pair is minted once, delivered to
|
|
76
|
+
* `target.refs.livekitKeys` as JSON, and written to the app's env; it is never
|
|
77
|
+
* recorded. Rules 1 to 6 of `CLAUDE.md` "Platform steps".
|
|
78
|
+
*/
|
|
79
|
+
export async function provisionSfuApp(scope, deps, target, input) {
|
|
80
|
+
const { coolify, network, deliverSecret, runtime } = deps;
|
|
81
|
+
const label = `own step coolify:own-sfu for ${target.id}`;
|
|
82
|
+
sfuSlotPorts(input.slot);
|
|
83
|
+
const zoneId = await network.zoneId(input.apex);
|
|
84
|
+
if (!zoneId)
|
|
85
|
+
throw new Error(`${label}: no Cloudflare zone for ${input.apex}`);
|
|
86
|
+
const projectUuid = await input.project.ensure(coolify, scope);
|
|
87
|
+
const { environment } = await coolify.findOrCreateEnvironment(projectUuid, 'production');
|
|
88
|
+
const servers = await coolify.listServers();
|
|
89
|
+
const server = servers.find((s) => s.is_usable && s.is_reachable) ?? servers[0];
|
|
90
|
+
if (!server)
|
|
91
|
+
throw new Error(`${label}: no usable Coolify server is registered`);
|
|
92
|
+
if (!server.ip)
|
|
93
|
+
throw new Error(`${label}: the Coolify server has no address to give LiveKit`);
|
|
94
|
+
const existingApp = await coolify.findApplicationByName(target.names.sfuApp, {
|
|
95
|
+
environmentIds: [environment.id],
|
|
96
|
+
});
|
|
97
|
+
let appId;
|
|
98
|
+
if (existingApp) {
|
|
99
|
+
appId = existingApp.uuid;
|
|
100
|
+
}
|
|
101
|
+
else {
|
|
102
|
+
appId = (await coolify.createDockerImageApplication({
|
|
103
|
+
project_uuid: projectUuid,
|
|
104
|
+
server_uuid: server.uuid,
|
|
105
|
+
environment_name: environment.name,
|
|
106
|
+
environment_uuid: environment.uuid,
|
|
107
|
+
docker_registry_image_name: OWN_SFU_IMAGE,
|
|
108
|
+
docker_registry_image_tag: input.version,
|
|
109
|
+
ports_exposes: String(LIVEKIT_SIGNALLING_PORT),
|
|
110
|
+
ports_mappings: sfuPortsMappings(input.slot),
|
|
111
|
+
name: target.names.sfuApp,
|
|
112
|
+
description: `LiveKit media server for ${target.id}`,
|
|
113
|
+
instant_deploy: false,
|
|
114
|
+
})).uuid;
|
|
115
|
+
}
|
|
116
|
+
scope.recordScopeId(OWN_SFU_APP_SCOPE_KEY, appId);
|
|
117
|
+
const recorded = scope.recordedIds();
|
|
118
|
+
const changed = recorded[OWN_SFU_APPLIED_VERSION_SCOPE_KEY] !== input.version ||
|
|
119
|
+
recorded[OWN_SFU_APPLIED_SLOT_SCOPE_KEY] !== String(input.slot) ||
|
|
120
|
+
recorded[OWN_SFU_APPLIED_HOSTNAME_SCOPE_KEY] !== input.hostname;
|
|
121
|
+
// Set on every run: a declared version or slot reaches an existing app here.
|
|
122
|
+
await coolify.updateApplication(appId, {
|
|
123
|
+
custom_internal_name: target.internal.sfu,
|
|
124
|
+
docker_registry_image_tag: input.version,
|
|
125
|
+
ports_exposes: String(LIVEKIT_SIGNALLING_PORT),
|
|
126
|
+
ports_mappings: sfuPortsMappings(input.slot),
|
|
127
|
+
...healthCheckSettings('/', LIVEKIT_SIGNALLING_PORT),
|
|
128
|
+
});
|
|
129
|
+
const liveConfig = (await coolify.listEnvs(appId)).find((e) => e.key === CONFIG_ENV && !e.is_preview)?.value;
|
|
130
|
+
let keys;
|
|
131
|
+
let keysJustSet = false;
|
|
132
|
+
if (scope.recordedIds()[OWN_SFU_KEYS_SCOPE_KEY]) {
|
|
133
|
+
const held = keysOf(liveConfig);
|
|
134
|
+
if (!held) {
|
|
135
|
+
throw new Error(`${label}: the app's ${CONFIG_ENV} no longer holds the company's key`);
|
|
136
|
+
}
|
|
137
|
+
keys = held;
|
|
138
|
+
deps.onSecret?.(keys.apiSecret);
|
|
139
|
+
}
|
|
140
|
+
else {
|
|
141
|
+
const lost = scope.recordedIds()[OWN_SFU_KEYS_UNDELIVERED_SCOPE_KEY];
|
|
142
|
+
const resumed = lost
|
|
143
|
+
? heldPairOf((await deps.readSecret?.(target.refs.livekitKeys)) ?? null)
|
|
144
|
+
: null;
|
|
145
|
+
if (lost && !resumed) {
|
|
146
|
+
throw new Error(`${label}: a LiveKit key pair (${lost}) was minted but never confirmed delivered; remove the recorded key ${OWN_SFU_KEYS_UNDELIVERED_SCOPE_KEY} to mint a new one`);
|
|
147
|
+
}
|
|
148
|
+
if (resumed) {
|
|
149
|
+
keys = resumed;
|
|
150
|
+
deps.onSecret?.(keys.apiSecret);
|
|
151
|
+
keysJustSet = true;
|
|
152
|
+
}
|
|
153
|
+
else {
|
|
154
|
+
keys = (deps.mintLivekitCredentials ?? mintLivekitCredentials)();
|
|
155
|
+
if (!KEY_PAIR_SHAPE.test(keys.apiKey) || !KEY_PAIR_SHAPE.test(keys.apiSecret)) {
|
|
156
|
+
throw new Error(`${label}: the minted LiveKit key has characters the config cannot hold`);
|
|
157
|
+
}
|
|
158
|
+
deps.onSecret?.(keys.apiSecret);
|
|
159
|
+
scope.recordScopeId(OWN_SFU_KEYS_UNDELIVERED_SCOPE_KEY, appId);
|
|
160
|
+
await scope.persist();
|
|
161
|
+
let delivered = false;
|
|
162
|
+
const payload = JSON.stringify({ apiKey: keys.apiKey, apiSecret: keys.apiSecret });
|
|
163
|
+
for (let attempt = 1; attempt <= KEYS_DELIVERY_ATTEMPTS && !delivered; attempt++) {
|
|
164
|
+
try {
|
|
165
|
+
await deliverSecret(target.refs.livekitKeys, payload);
|
|
166
|
+
delivered = true;
|
|
167
|
+
}
|
|
168
|
+
catch {
|
|
169
|
+
// Retried; the error is dropped in case it echoes the key.
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
if (!delivered) {
|
|
173
|
+
throw new Error(`${label}: the LiveKit key pair could not be delivered after ${KEYS_DELIVERY_ATTEMPTS} attempts`);
|
|
174
|
+
}
|
|
175
|
+
keysJustSet = true;
|
|
176
|
+
}
|
|
177
|
+
}
|
|
178
|
+
const config = sfuConfig({ slot: input.slot, nodeIp: server.ip, keys });
|
|
179
|
+
deps.onSecret?.(config);
|
|
180
|
+
const envChanged = liveConfig !== config;
|
|
181
|
+
const pending = Boolean(scope.recordedIds()[OWN_SFU_DEPLOY_PENDING_SCOPE_KEY]) || changed || envChanged;
|
|
182
|
+
if (pending) {
|
|
183
|
+
scope.recordScopeId(OWN_SFU_DEPLOY_PENDING_SCOPE_KEY, '1');
|
|
184
|
+
await scope.persist();
|
|
185
|
+
}
|
|
186
|
+
if (envChanged) {
|
|
187
|
+
await coolify.bulkUpsertEnvs(appId, [{ key: CONFIG_ENV, value: config, literal: true }]);
|
|
188
|
+
}
|
|
189
|
+
if (keysJustSet) {
|
|
190
|
+
scope.recordScopeId(OWN_SFU_KEYS_SCOPE_KEY, appId);
|
|
191
|
+
scope.recordScopeId(OWN_SFU_KEYS_UNDELIVERED_SCOPE_KEY, '');
|
|
192
|
+
await scope.persist();
|
|
193
|
+
}
|
|
194
|
+
const running = (await coolify.getApplication(appId)).status?.startsWith('running') ?? false;
|
|
195
|
+
if (!existingApp || pending || !running) {
|
|
196
|
+
await waitForNewDeployment(coolify, appId, { kind: 'deployment-only' }, runtime, runtime.sleep ?? ((ms) => new Promise((resolve) => setTimeout(resolve, ms))), label, 'the media server keeps its old version');
|
|
197
|
+
}
|
|
198
|
+
if (pending) {
|
|
199
|
+
scope.recordScopeId(OWN_SFU_DEPLOY_PENDING_SCOPE_KEY, '');
|
|
200
|
+
await scope.persist();
|
|
201
|
+
}
|
|
202
|
+
const tunnels = await network.tunnels();
|
|
203
|
+
const tunnel = (await tunnels.findTunnelByName(target.names.sfuTunnel)) ??
|
|
204
|
+
(await tunnels.createTunnel(target.names.sfuTunnel));
|
|
205
|
+
scope.recordScopeId(OWN_SFU_TUNNEL_SCOPE_KEY, tunnel.id);
|
|
206
|
+
await tunnels.putTunnelConfig(tunnel.id, {
|
|
207
|
+
ingress: [
|
|
208
|
+
{
|
|
209
|
+
hostname: input.hostname,
|
|
210
|
+
service: `http://${target.internal.sfu}:${LIVEKIT_SIGNALLING_PORT}`,
|
|
211
|
+
},
|
|
212
|
+
{ service: 'http_status:404' },
|
|
213
|
+
],
|
|
214
|
+
});
|
|
215
|
+
await ensureCloudflaredConnector({
|
|
216
|
+
coolify,
|
|
217
|
+
tunnels,
|
|
218
|
+
tunnelId: tunnel.id,
|
|
219
|
+
appName: target.names.sfuCloudflared,
|
|
220
|
+
projectUuid,
|
|
221
|
+
environment,
|
|
222
|
+
resolveServer: async () => server,
|
|
223
|
+
recordedConnectorAppId: scope.recordedIds()[OWN_SFU_CONNECTOR_APP_SCOPE_KEY] ?? null,
|
|
224
|
+
recordAppId: async (id) => {
|
|
225
|
+
scope.recordScopeId(OWN_SFU_CONNECTOR_APP_SCOPE_KEY, id);
|
|
226
|
+
await scope.persist();
|
|
227
|
+
},
|
|
228
|
+
});
|
|
229
|
+
const dns = await network.dns();
|
|
230
|
+
const dnsTarget = `${tunnel.id}.cfargotunnel.com`;
|
|
231
|
+
const [record] = await dns.findDnsRecord(input.apex, input.hostname, 'CNAME');
|
|
232
|
+
const cname = { type: 'CNAME', name: input.hostname, content: dnsTarget, proxied: true };
|
|
233
|
+
if (!record)
|
|
234
|
+
await dns.createDnsRecord(input.apex, cname);
|
|
235
|
+
else if (record.content !== dnsTarget)
|
|
236
|
+
await dns.updateDnsRecord(input.apex, record.id, cname);
|
|
237
|
+
await recordOwnDnsHostname(scope, input.hostname);
|
|
238
|
+
scope.recordScopeId(OWN_SFU_APPLIED_VERSION_SCOPE_KEY, input.version);
|
|
239
|
+
scope.recordScopeId(OWN_SFU_APPLIED_SLOT_SCOPE_KEY, String(input.slot));
|
|
240
|
+
scope.recordScopeId(OWN_SFU_APPLIED_HOSTNAME_SCOPE_KEY, input.hostname);
|
|
241
|
+
scope.recordScopeId(SFU_INTERNAL_URL_SCOPE_KEY, `http://${target.internal.sfu}:${LIVEKIT_SIGNALLING_PORT}`);
|
|
242
|
+
scope.recordScopeId(SFU_PUBLIC_URL_SCOPE_KEY, `https://${input.hostname}`);
|
|
243
|
+
return { resourceId: appId };
|
|
244
|
+
}
|
|
245
|
+
/**
|
|
246
|
+
* The addresses of the company's media server, from its recorded ids. Before
|
|
247
|
+
* the step is recorded the pair is empty, which `resolveCallsSfuUrls` refuses:
|
|
248
|
+
* an own org's calls service never falls back to the shared SFU's addresses.
|
|
249
|
+
*/
|
|
250
|
+
export function ownSfuUrls(ids) {
|
|
251
|
+
return {
|
|
252
|
+
privateUrl: ids[SFU_INTERNAL_URL_SCOPE_KEY] ?? '',
|
|
253
|
+
publicUrl: ids[SFU_PUBLIC_URL_SCOPE_KEY] ?? '',
|
|
254
|
+
};
|
|
255
|
+
}
|
|
@@ -10,6 +10,7 @@ import { type AgoraHostPort } from './agora.js';
|
|
|
10
10
|
import { type UnconfirmedUpgrade } from './confirm-upgrade.js';
|
|
11
11
|
import { type StepAction } from './diff.js';
|
|
12
12
|
import { type LiveReaders, type LiveResource, type PlatformLiveReaders } from './live.js';
|
|
13
|
+
import { type LoginHostDiscoveryPort } from './login-host.js';
|
|
13
14
|
import { type EdgePortName, type HeldKeyResolver } from './platform-org.js';
|
|
14
15
|
import type { CallsSfuConnectionPort } from './platform-sfu.js';
|
|
15
16
|
/** Who did it, for `@wtfalch/audit`'s "who, when, the plan, the result" (#9, #19). */
|
|
@@ -172,6 +173,38 @@ export interface StepExecutor {
|
|
|
172
173
|
* an executor without it supplies none.
|
|
173
174
|
*/
|
|
174
175
|
ownEdgePorts?(): Partial<Record<EdgePortName, boolean>>;
|
|
176
|
+
/**
|
|
177
|
+
* What the executor's own files dependencies supply (`ownServices.provision.files`):
|
|
178
|
+
* valid keys worker settings, and a bucket port. The planner blocks an own
|
|
179
|
+
* org's files host until both are true. Optional: an executor without it supplies neither.
|
|
180
|
+
*/
|
|
181
|
+
ownFilesDeps?(): {
|
|
182
|
+
keys: boolean;
|
|
183
|
+
bucket: boolean;
|
|
184
|
+
};
|
|
185
|
+
/**
|
|
186
|
+
* Which ports an own org's files storage key step needs this executor has: the R2 key port, and
|
|
187
|
+
* `storeR2Credentials` on the org's files-organisation port. Optional: an executor without it
|
|
188
|
+
* leaves the step unplanned.
|
|
189
|
+
*/
|
|
190
|
+
ownFilesR2Ports?(org: DeclaredOrg, recordedIds: Readonly<Record<string, string>>): Promise<{
|
|
191
|
+
r2Keys: boolean;
|
|
192
|
+
storeR2Credentials: boolean;
|
|
193
|
+
}>;
|
|
194
|
+
/** Whether the executor can mint an own org's Lettermint key: a Lettermint port and a reader for the held ref. */
|
|
195
|
+
ownMailKeyMintable?(): boolean;
|
|
196
|
+
/**
|
|
197
|
+
* Whether the host's scoped ports for this own org include a files-organisation
|
|
198
|
+
* port. The planner plans no `files:organisation` step for an own org while
|
|
199
|
+
* this says false. Optional: an executor without it is taken to have the port.
|
|
200
|
+
*/
|
|
201
|
+
ownFilesOrganisationPort?(org: DeclaredOrg, recordedIds: Readonly<Record<string, string>>): Promise<boolean>;
|
|
202
|
+
/**
|
|
203
|
+
* Every secret this executor minted or read so far (peer password, bridge
|
|
204
|
+
* secret, invite code and link). The host's run adds them to its redactor
|
|
205
|
+
* after each step, so an error or progress line that quotes one is masked.
|
|
206
|
+
*/
|
|
207
|
+
secretsHandled?(): readonly string[];
|
|
175
208
|
}
|
|
176
209
|
/**
|
|
177
210
|
* #167: {@link StepExecutionContext}'s platform-scoped counterpart -- what
|
|
@@ -232,6 +265,8 @@ export interface PlatformStepExecutor {
|
|
|
232
265
|
ai?: string;
|
|
233
266
|
archon?: string;
|
|
234
267
|
mail?: string;
|
|
268
|
+
/** See `PlanPlatformOptions.blocked.relay` (`platform-diff.ts`). */
|
|
269
|
+
relay?: string;
|
|
235
270
|
/** #604: see `PlanPlatformOptions.blocked.accessProvider` (`platform-diff.ts`). */
|
|
236
271
|
accessProvider?: string;
|
|
237
272
|
/** #257: see `PlanPlatformOptions.blocked.backupExecutorMissing` (`platform-diff.ts`). */
|
|
@@ -376,6 +411,14 @@ export interface FoundryPlanApplyOptions {
|
|
|
376
411
|
* before zitadel-login's binding can be written.
|
|
377
412
|
*/
|
|
378
413
|
loginHostBindings?: boolean;
|
|
414
|
+
/**
|
|
415
|
+
* Reads `GET https://<login host>/.well-known/openid-configuration` for a
|
|
416
|
+
* company's login host (5 s limit). Optional, and without it every company's
|
|
417
|
+
* Agora keeps the platform issuer and the plan carries a blocked line for the
|
|
418
|
+
* switch. The host must route the company's login host to a login app that
|
|
419
|
+
* serves discovery (https://github.com/wtfalch/auth/pull/233).
|
|
420
|
+
*/
|
|
421
|
+
loginHostDiscovery?: LoginHostDiscoveryPort;
|
|
379
422
|
/**
|
|
380
423
|
* True when the host's ZITADEL client has `updateOidcAppIdTokenUserinfoAssertion`
|
|
381
424
|
* (`@wtfalch/providers` at the version that has it). Optional, and false
|
|
@@ -395,6 +438,12 @@ export interface FoundryPlanApplyOptions {
|
|
|
395
438
|
* reported blocked instead of planned.
|
|
396
439
|
*/
|
|
397
440
|
edgeWorkers?: boolean;
|
|
441
|
+
/**
|
|
442
|
+
* True when the host's executor was built with `ownerInviteMail`. Optional,
|
|
443
|
+
* and false without it: each owner's invite is then reported blocked
|
|
444
|
+
* instead of planned.
|
|
445
|
+
*/
|
|
446
|
+
ownerInviteMail?: boolean;
|
|
398
447
|
/**
|
|
399
448
|
* #177: true when the host's executor was built with its own `filesOrganisation`
|
|
400
449
|
* / `aiOrganisation` port. Without one, the org step needs the platform's
|
|
@@ -408,6 +457,32 @@ export interface FoundryPlanApplyOptions {
|
|
|
408
457
|
* reported blocked instead of planned to fail.
|
|
409
458
|
*/
|
|
410
459
|
edge?: boolean;
|
|
460
|
+
/**
|
|
461
|
+
* The executor was built with an `ownServices` port. Without it, an org
|
|
462
|
+
* that runs its own services (`services.own`) is reported blocked and none of
|
|
463
|
+
* its steps run.
|
|
464
|
+
*/
|
|
465
|
+
ownServices?: boolean;
|
|
466
|
+
/**
|
|
467
|
+
* Says whether an own org's media server slot has its ports open on the
|
|
468
|
+
* Coolify server. Foundry opens none, so without this the plan keeps a
|
|
469
|
+
* fixed line naming them. A call that fails or takes over five seconds
|
|
470
|
+
* counts as not open.
|
|
471
|
+
*/
|
|
472
|
+
sfuPortsOpen?: (range: {
|
|
473
|
+
udpStart: number;
|
|
474
|
+
udpEnd: number;
|
|
475
|
+
tcp: number;
|
|
476
|
+
}) => Promise<boolean>;
|
|
477
|
+
/**
|
|
478
|
+
* Narrows every org plan to the steps `step` accepts, and its blocked items
|
|
479
|
+
* to the keys `blocked` accepts, so a plan, its stale check and its apply
|
|
480
|
+
* all see the same slice. Absent: the whole plan.
|
|
481
|
+
*/
|
|
482
|
+
onlySteps?: {
|
|
483
|
+
step(action: StepAction): boolean;
|
|
484
|
+
blocked(key: string): boolean;
|
|
485
|
+
};
|
|
411
486
|
/** Overridable for tests; defaults to `() => new Date()`. */
|
|
412
487
|
now?: () => Date;
|
|
413
488
|
/** #441: how long a mutation lease lasts before another caller may take it over. Defaults to 30 minutes. */
|
|
@@ -466,12 +541,6 @@ export interface FoundryPlanApplyOptions {
|
|
|
466
541
|
heldKeys?: HeldKeyResolver;
|
|
467
542
|
};
|
|
468
543
|
}
|
|
469
|
-
/**
|
|
470
|
-
* The #19 implementation of {@link PlanApply}: computes a plan against live
|
|
471
|
-
* state, and refuses to apply one that has gone stale, one that touches a
|
|
472
|
-
* destructive or migrating step without the org's name typed, or one the
|
|
473
|
-
* actor is not authorized to run.
|
|
474
|
-
*/
|
|
475
544
|
export declare class FoundryPlanApply implements PlanApply {
|
|
476
545
|
private readonly store;
|
|
477
546
|
private readonly readers;
|
|
@@ -483,12 +552,17 @@ export declare class FoundryPlanApply implements PlanApply {
|
|
|
483
552
|
private readonly agora;
|
|
484
553
|
private readonly jobsScheduler;
|
|
485
554
|
private readonly loginHostBindings;
|
|
555
|
+
private readonly loginHostDiscovery;
|
|
486
556
|
private readonly zitadelIdTokenUserinfo;
|
|
487
557
|
private readonly broker;
|
|
488
558
|
private readonly edgeWorkers;
|
|
559
|
+
private readonly ownerInviteMail;
|
|
489
560
|
private readonly hostFiles;
|
|
490
561
|
private readonly hostAi;
|
|
491
562
|
private readonly hostEdge;
|
|
563
|
+
private readonly hostOwnServices;
|
|
564
|
+
private readonly sfuPortsOpen;
|
|
565
|
+
private readonly onlySteps;
|
|
492
566
|
private readonly now;
|
|
493
567
|
private readonly leaseTtlMs;
|
|
494
568
|
private readonly appliedVersions;
|
|
@@ -630,6 +704,8 @@ export declare class FoundryPlanApply implements PlanApply {
|
|
|
630
704
|
private refusePlatform;
|
|
631
705
|
private refuseDestroy;
|
|
632
706
|
private runSteps;
|
|
707
|
+
/** An own org with calls on: the host's answer about its slot's ports. A failure or a slow answer is "not open". */
|
|
708
|
+
private readSfuPortsOpen;
|
|
633
709
|
private computeFreshPlan;
|
|
634
710
|
/**
|
|
635
711
|
* An entry may not take a name foundry owns (`locked-hostnames.ts`). The
|
|
@@ -646,12 +722,20 @@ export declare class FoundryPlanApply implements PlanApply {
|
|
|
646
722
|
* org step that needs one is blocked.
|
|
647
723
|
*/
|
|
648
724
|
private orgPlatformReadiness;
|
|
725
|
+
/** An own org's Lettermint key is held. A resolver that throws counts as not held. */
|
|
726
|
+
private ownMailKeyHeld;
|
|
727
|
+
/** Which of an own org's undelivered-secret refs hold a value now, so a rerun resumes instead of blocking. */
|
|
728
|
+
private ownResumableRefs;
|
|
729
|
+
/** The relay is declared and has recorded the routes it last applied: port 25 reaches the mail servers. */
|
|
730
|
+
private mailRelayApplied;
|
|
731
|
+
private sharedPlatformReadiness;
|
|
649
732
|
/**
|
|
650
733
|
* #177: `AgoraHostValues` from the applied platform, the same
|
|
651
734
|
* `platformDeps` {@link orgPlatformReadiness} reads -- but only when the
|
|
652
735
|
* host supplies no `agora` port of its own. An explicit `agora` (#164)
|
|
653
736
|
* always wins, unchanged, so a host that already has its own values
|
|
654
|
-
* (perhaps not yet wired to this platform) keeps them.
|
|
737
|
+
* (perhaps not yet wired to this platform) keeps them. An org that owns its
|
|
738
|
+
* services never takes the host's values: they belong to the shared services.
|
|
655
739
|
*/
|
|
656
740
|
private orgAgoraHost;
|
|
657
741
|
/**
|
|
@@ -667,6 +751,16 @@ export declare class FoundryPlanApply implements PlanApply {
|
|
|
667
751
|
/** #167: `FoundryPlanApplyOptions.platform`, or a plain `Error` naming what's missing. */
|
|
668
752
|
private requirePlatformDeps;
|
|
669
753
|
private computeFreshPlatformPlan;
|
|
754
|
+
/**
|
|
755
|
+
* What the shared mail relay needs from the orgs. A route exists for an org
|
|
756
|
+
* that owns its services, serves mail on its own domain and has recorded its
|
|
757
|
+
* mail server's SMTP address (`mail_smtp_internal`, written by the company
|
|
758
|
+
* mail step). Every other org with mail on its own domain, the platform
|
|
759
|
+
* apex and the shared no-domain apex are platform domains: the platform's Stalwart receives them. An org
|
|
760
|
+
* that owns its services but has no mail server yet appears in neither list,
|
|
761
|
+
* so the relay refuses its mail until the server exists.
|
|
762
|
+
*/
|
|
763
|
+
private mailRelayInputs;
|
|
670
764
|
private sfuBlockedReason;
|
|
671
765
|
/**
|
|
672
766
|
* {@link runSteps}, for the platform. The ids the steps return are recorded
|