@wtfalch/foundry 0.33.1 → 0.35.1

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.
Files changed (106) hide show
  1. package/dist/bootstrap/bootstrap.d.ts +1 -1
  2. package/dist/bootstrap/bootstrap.js +49 -11
  3. package/dist/bootstrap/declare.d.ts +12 -5
  4. package/dist/bootstrap/declare.js +33 -18
  5. package/dist/bootstrap/versions.d.ts +24 -0
  6. package/dist/bootstrap/versions.js +46 -0
  7. package/dist/calls-sfu.d.ts +10 -0
  8. package/dist/calls-sfu.js +44 -3
  9. package/dist/index.d.ts +21 -8
  10. package/dist/index.js +11 -4
  11. package/dist/migrations/0051_org_services.sql +13 -0
  12. package/dist/migrations/0052_platform_mail_relay.sql +8 -0
  13. package/dist/migrations/0053_heartbeats_provider.sql +9 -0
  14. package/dist/migrations/0053_org_version_pin.sql +6 -0
  15. package/dist/org.d.ts +76 -12
  16. package/dist/org.js +136 -20
  17. package/dist/plan/backup-gate.d.ts +93 -0
  18. package/dist/plan/backup-gate.js +261 -0
  19. package/dist/plan/broker-entries.d.ts +9 -1
  20. package/dist/plan/broker-entries.js +20 -3
  21. package/dist/plan/build-record.d.ts +56 -0
  22. package/dist/plan/build-record.js +109 -0
  23. package/dist/plan/calls-matrix.d.ts +9 -0
  24. package/dist/plan/calls-matrix.js +18 -1
  25. package/dist/plan/cloudflared-connector.d.ts +7 -1
  26. package/dist/plan/cloudflared-connector.js +15 -2
  27. package/dist/plan/company-backups.d.ts +31 -0
  28. package/dist/plan/company-backups.js +101 -0
  29. package/dist/plan/deploy-health.d.ts +12 -2
  30. package/dist/plan/deploy-health.js +3 -1
  31. package/dist/plan/destinations.d.ts +73 -0
  32. package/dist/plan/destinations.js +217 -0
  33. package/dist/plan/diff.d.ts +46 -8
  34. package/dist/plan/diff.js +367 -126
  35. package/dist/plan/edge-access.d.ts +15 -0
  36. package/dist/plan/edge-access.js +21 -2
  37. package/dist/plan/executor.d.ts +276 -5
  38. package/dist/plan/executor.js +1111 -109
  39. package/dist/plan/health-check-settings.d.ts +28 -0
  40. package/dist/plan/health-check-settings.js +36 -0
  41. package/dist/plan/health-gaps.d.ts +33 -0
  42. package/dist/plan/health-gaps.js +89 -0
  43. package/dist/plan/health-targets.d.ts +1 -3
  44. package/dist/plan/health-targets.js +36 -36
  45. package/dist/plan/held-key-refs.d.ts +15 -0
  46. package/dist/plan/held-key-refs.js +15 -0
  47. package/dist/plan/hostnames.d.ts +10 -0
  48. package/dist/plan/hostnames.js +14 -0
  49. package/dist/plan/index.d.ts +19 -6
  50. package/dist/plan/index.js +10 -3
  51. package/dist/plan/live.d.ts +11 -2
  52. package/dist/plan/live.js +16 -2
  53. package/dist/plan/locked-hostnames.d.ts +7 -3
  54. package/dist/plan/locked-hostnames.js +25 -4
  55. package/dist/plan/login-host.d.ts +61 -12
  56. package/dist/plan/login-host.js +99 -11
  57. package/dist/plan/mail-relay.d.ts +139 -0
  58. package/dist/plan/mail-relay.js +400 -0
  59. package/dist/plan/network-move.d.ts +20 -0
  60. package/dist/plan/network-move.js +41 -0
  61. package/dist/plan/oikos-ingress.d.ts +111 -0
  62. package/dist/plan/oikos-ingress.js +231 -0
  63. package/dist/plan/org-names.d.ts +30 -0
  64. package/dist/plan/org-names.js +108 -0
  65. package/dist/plan/own-diff.d.ts +170 -0
  66. package/dist/plan/own-diff.js +440 -0
  67. package/dist/plan/own-dns.d.ts +6 -0
  68. package/dist/plan/own-dns.js +15 -0
  69. package/dist/plan/own-hostnames.d.ts +27 -0
  70. package/dist/plan/own-hostnames.js +24 -0
  71. package/dist/plan/own-limits.d.ts +213 -0
  72. package/dist/plan/own-limits.js +232 -0
  73. package/dist/plan/own-services.d.ts +193 -0
  74. package/dist/plan/own-services.js +169 -0
  75. package/dist/plan/own-sfu.d.ts +69 -0
  76. package/dist/plan/own-sfu.js +256 -0
  77. package/dist/plan/plan-apply.d.ts +121 -10
  78. package/dist/plan/plan-apply.js +357 -18
  79. package/dist/plan/platform-diff.d.ts +155 -11
  80. package/dist/plan/platform-diff.js +271 -66
  81. package/dist/plan/platform-org.d.ts +107 -4
  82. package/dist/plan/platform-org.js +175 -3
  83. package/dist/plan/platform-providers.d.ts +474 -81
  84. package/dist/plan/platform-providers.js +1407 -560
  85. package/dist/plan/proxy-labels.d.ts +57 -11
  86. package/dist/plan/proxy-labels.js +94 -14
  87. package/dist/plan/run-options.d.ts +19 -0
  88. package/dist/plan/run-options.js +46 -0
  89. package/dist/plan/sign-in-org.d.ts +1 -1
  90. package/dist/plan/sign-in-org.js +20 -2
  91. package/dist/plan/zitadel-login-policy.d.ts +1 -1
  92. package/dist/plan/zitadel-login-policy.js +77 -9
  93. package/dist/plan/zitadel-system-api.d.ts +22 -1
  94. package/dist/plan/zitadel-system-api.js +49 -8
  95. package/dist/platform.d.ts +40 -0
  96. package/dist/platform.js +16 -0
  97. package/dist/provision.d.ts +2 -0
  98. package/dist/provision.js +3 -1
  99. package/dist/slug.d.ts +3 -1
  100. package/dist/slug.js +26 -3
  101. package/dist/store/postgres.js +93 -2
  102. package/dist/store/schema.d.ts +87 -0
  103. package/dist/store/schema.js +9 -0
  104. package/package.json +3 -3
  105. package/versions.digests.json +11 -0
  106. package/versions.json +3 -1
@@ -0,0 +1,169 @@
1
+ import { NO_DOMAIN_ZONE } from './own-hostnames.js';
2
+ import { apexZoneKey, exactZone, platformDns, platformTunnels } from './platform-providers.js';
3
+ /** The key the org's zone step records its Cloudflare zone id under. */
4
+ const ORG_ZONE_SCOPE_KEY = 'cloudflare_zone';
5
+ /** Applied-state key under which an own org's provisioned-services marker is recorded. */
6
+ export const OWN_SERVICES_APPLIED_SCOPE_KEY = 'own_services_applied';
7
+ /** The platform's shared services, with the literals the platform steps use today. */
8
+ export function platformTarget() {
9
+ return {
10
+ id: 'platform',
11
+ kind: 'platform',
12
+ names: {
13
+ authDb: 'platform-auth-db',
14
+ authZitadel: 'platform-auth-zitadel',
15
+ authLogin: 'platform-auth-login',
16
+ authBroker: 'platform-auth-broker',
17
+ authTunnel: 'platform-auth-tunnel',
18
+ authCloudflared: 'platform-auth-cloudflared',
19
+ mailApp: 'platform-mail-stalwart',
20
+ mailTunnel: 'platform-mail-tunnel',
21
+ mailCloudflared: 'platform-mail-cloudflared',
22
+ filesDb: 'platform-files-db',
23
+ filesMigrate: 'files-migrate',
24
+ filesRunner: 'files',
25
+ filesBucket: 'platform-files',
26
+ jobsApp: 'jobs',
27
+ jobsDb: 'jobs-scheduler-db',
28
+ sfuApp: 'platform-sfu',
29
+ sfuTunnel: 'platform-sfu-tunnel',
30
+ sfuCloudflared: 'platform-sfu-cloudflared',
31
+ },
32
+ internal: {
33
+ authZitadel: 'platform-auth-zitadel',
34
+ authLogin: 'platform-auth-login',
35
+ authBroker: 'platform-auth-broker',
36
+ mail: 'platform-mail-stalwart',
37
+ files: 'platform-files',
38
+ jobs: 'platform-jobs',
39
+ sfu: 'platform-sfu',
40
+ },
41
+ refs: {
42
+ instanceBearer: 'zitadel:platform:instanceBearer',
43
+ authAdmin: 'zitadel:platform:admin',
44
+ brokerMailKey: 'lettermint:platform:apiToken',
45
+ mailAdminKey: 'stalwart:platform:admin',
46
+ filesManagement: 'files:platform:managementKey',
47
+ filesIntrospection: 'zitadel:platform:files:introspection',
48
+ jobsAdminToken: 'jobs:scheduler:adminToken',
49
+ livekitKeys: 'livekit:platform:keys',
50
+ keysCredential: 'keys:platform:credential',
51
+ keysVerifyKeys: 'keys:platform:verifyKeys',
52
+ },
53
+ };
54
+ }
55
+ /** One company's own services: every name and ref carries the org's slug, so two orgs never clash. */
56
+ export function orgTarget(org) {
57
+ const s = org.slug;
58
+ return {
59
+ id: s,
60
+ kind: 'org',
61
+ names: {
62
+ authDb: `${s}-auth-db`,
63
+ authZitadel: `${s}-auth-zitadel`,
64
+ authLogin: `${s}-auth-login`,
65
+ authBroker: `${s}-auth-broker`,
66
+ authTunnel: `${s}-auth-tunnel`,
67
+ authCloudflared: `${s}-auth-cloudflared`,
68
+ mailApp: `${s}-mail-stalwart`,
69
+ mailTunnel: `${s}-mail-tunnel`,
70
+ mailCloudflared: `${s}-mail-cloudflared`,
71
+ filesDb: `${s}-files-db`,
72
+ filesMigrate: `${s}-files-migrate`,
73
+ filesRunner: `${s}-files`,
74
+ filesBucket: `${s}-files`,
75
+ jobsApp: `${s}-jobs`,
76
+ jobsDb: `${s}-jobs-db`,
77
+ sfuApp: `${s}-sfu`,
78
+ sfuTunnel: `${s}-sfu-tunnel`,
79
+ sfuCloudflared: `${s}-sfu-cloudflared`,
80
+ },
81
+ internal: {
82
+ authZitadel: `${s}-auth-zitadel`,
83
+ authLogin: `${s}-auth-login`,
84
+ authBroker: `${s}-auth-broker`,
85
+ mail: `${s}-mail-stalwart`,
86
+ files: `${s}-files`,
87
+ jobs: `${s}-jobs`,
88
+ sfu: `${s}-sfu`,
89
+ },
90
+ refs: {
91
+ instanceBearer: `zitadel:org:${s}:instanceBearer`,
92
+ authAdmin: `zitadel:org:${s}:admin`,
93
+ brokerMailKey: `lettermint:org:${s}:apiToken`,
94
+ mailAdminKey: `stalwart:org:${s}:admin`,
95
+ filesManagement: `files:org:${s}:managementKey`,
96
+ filesIntrospection: `zitadel:org:${s}:files:introspection`,
97
+ jobsAdminToken: `jobs:org:${s}:adminToken`,
98
+ livekitKeys: `livekit:org:${s}:keys`,
99
+ keysCredential: `keys:org:${s}:credential`,
100
+ keysVerifyKeys: `keys:org:${s}:verifyKeys`,
101
+ },
102
+ };
103
+ }
104
+ export function scopeFromPlatform(context, _platformIds) {
105
+ return {
106
+ recordedIds: () => context.recordedIds(),
107
+ recordScopeId: (key, id) => context.recordScopeId(key, id),
108
+ persist: async () => {
109
+ await context.persist?.();
110
+ },
111
+ platformIds: () => context.recordedIds(),
112
+ };
113
+ }
114
+ export function scopeFromOrg(context, platformIds) {
115
+ return {
116
+ recordedIds: () => context.recordedIds(),
117
+ recordScopeId: (key, id) => context.recordScopeId(key, id),
118
+ persist: () => context.persistScopeIds(),
119
+ platformIds: () => platformIds,
120
+ };
121
+ }
122
+ export function platformNetwork(deps, scope, step) {
123
+ return {
124
+ dns: () => platformDns(deps, scope.recordedIds(), step),
125
+ tunnels: () => platformTunnels(deps, scope.recordedIds(), step),
126
+ zoneId: async (apex) => scope.recordedIds()[apexZoneKey(apex)] ?? (await exactZone(deps.cloudflare, apex))?.id,
127
+ };
128
+ }
129
+ /**
130
+ * An own org's DNS and tunnels: the org's edge-routed ports, never the
131
+ * platform's scope, and the zone edge recorded for the org's domain. A port
132
+ * the host did not supply is named in the error, never reached around.
133
+ */
134
+ export function orgNetwork(ports, scope, step) {
135
+ return {
136
+ dns: async () => {
137
+ if (!ports.dns)
138
+ throw new Error(`${step}: the host supplies no DNS port for this org`);
139
+ return ports.dns;
140
+ },
141
+ tunnels: async () => {
142
+ if (!ports.tunnels)
143
+ throw new Error(`${step}: the host supplies no tunnel port for this org`);
144
+ return ports.tunnels;
145
+ },
146
+ zoneId: async (apex) => {
147
+ // The recorded zone is the company's own; `oikos.host` is never it.
148
+ const recorded = apex === NO_DOMAIN_ZONE ? undefined : scope.recordedIds()[ORG_ZONE_SCOPE_KEY];
149
+ if (recorded)
150
+ return recorded;
151
+ return ports.zones ? (await exactZone(ports.zones, apex))?.id : undefined;
152
+ },
153
+ };
154
+ }
155
+ /** The token id of the storage key delivered to an own files host. Its presence means the step is done. */
156
+ export const OWN_FILES_R2_SCOPE_KEY = 'files_r2_id';
157
+ /** The token id minted and not yet confirmed delivered: deleted before any new mint. */
158
+ export const OWN_FILES_R2_UNDELIVERED_SCOPE_KEY = 'files_r2_undelivered';
159
+ /** A held key an own service needs is missing. Names the service and the ref, never a value. */
160
+ export class OwnServicesBlockedError extends Error {
161
+ service;
162
+ ref;
163
+ constructor(service, ref) {
164
+ super(`own ${service} is blocked: held key ${ref} is missing`);
165
+ this.service = service;
166
+ this.ref = ref;
167
+ this.name = 'OwnServicesBlockedError';
168
+ }
169
+ }
@@ -0,0 +1,69 @@
1
+ import { type LivekitCredentials } from '../calls-sfu-keys.js';
2
+ import { type CallsSfuUrls } from '../calls-sfu.js';
3
+ import { type DeployHealthRuntime } from './deploy-health.js';
4
+ import type { FoundryStepExecutorPorts } from './executor.js';
5
+ import type { OwnSfuDeps, ProjectSource, ServiceNetwork, ServiceTarget, StepScope } from './own-services.js';
6
+ /** The LiveKit image, the same repository the shared SFU runs. */
7
+ export declare const OWN_SFU_IMAGE = "livekit/livekit-server";
8
+ export declare const OWN_SFU_APP_SCOPE_KEY = "coolify_own_sfu_app";
9
+ export declare const OWN_SFU_TUNNEL_SCOPE_KEY = "cloudflare_own_sfu_tunnel";
10
+ export declare const OWN_SFU_CONNECTOR_APP_SCOPE_KEY = "coolify_own_sfu_connector_app";
11
+ /** Set to the app id before the key pair leaves, cleared after the pair is delivered and written to the app. */
12
+ export declare const OWN_SFU_KEYS_UNDELIVERED_SCOPE_KEY = "own_sfu_keys_undelivered";
13
+ /** The app id once the company's key pair is delivered and in the app's env. */
14
+ export declare const OWN_SFU_KEYS_SCOPE_KEY = "own_sfu_keys_set";
15
+ export declare const OWN_SFU_DEPLOY_PENDING_SCOPE_KEY = "own_sfu_deploy_pending";
16
+ export declare const OWN_SFU_APPLIED_VERSION_SCOPE_KEY = "own_sfu_applied_version";
17
+ export declare const OWN_SFU_APPLIED_SLOT_SCOPE_KEY = "own_sfu_applied_slot";
18
+ export declare const OWN_SFU_APPLIED_HOSTNAME_SCOPE_KEY = "own_sfu_applied_hostname";
19
+ export declare const SFU_INTERNAL_URL_SCOPE_KEY = "sfu_internal_url";
20
+ export declare const SFU_PUBLIC_URL_SCOPE_KEY = "sfu_public_url";
21
+ export interface SfuAppDeps {
22
+ coolify: OwnSfuDeps['coolify'];
23
+ network: ServiceNetwork;
24
+ deliverSecret: FoundryStepExecutorPorts['deliverSecret'];
25
+ /** Reads a delivered secret back from the host. Without it, an undelivered marker cannot be resumed. */
26
+ readSecret?: (ref: string) => Promise<string | null>;
27
+ runtime: DeployHealthRuntime;
28
+ mintLivekitCredentials?: () => LivekitCredentials;
29
+ /** Called with every secret value this step handles, so the caller can scrub it from output. */
30
+ onSecret?: (value: string) => void;
31
+ }
32
+ export interface SfuAppInput {
33
+ slot: number;
34
+ /** `sfu.<domain>`. */
35
+ hostname: string;
36
+ /** The org's domain: the zone the hostname goes in. */
37
+ apex: string;
38
+ /** The pinned LiveKit release tag. */
39
+ version: string;
40
+ project: ProjectSource;
41
+ }
42
+ /** The Docker `ports_mappings` string for a slot: the UDP range and the TCP fallback port, one to one. */
43
+ export declare function sfuPortsMappings(slot: number): string;
44
+ /**
45
+ * LiveKit takes its whole config from one env value. `node_ip` is the
46
+ * server's address: the app is not on the host network, so the media ports
47
+ * reach it through the published mappings.
48
+ */
49
+ export declare function sfuConfig(input: {
50
+ slot: number;
51
+ nodeIp: string;
52
+ keys: LivekitCredentials;
53
+ }): string;
54
+ /**
55
+ * One company's LiveKit media server as a Coolify app: the image, its slot's
56
+ * ports published to the server, and a tunnel for signalling at `sfu.<domain>`.
57
+ * The company's single key pair is minted once, delivered to
58
+ * `target.refs.livekitKeys` as JSON, and written to the app's env; it is never
59
+ * recorded. Rules 1 to 6 of `CLAUDE.md` "Platform steps".
60
+ */
61
+ export declare function provisionSfuApp(scope: StepScope, deps: SfuAppDeps, target: ServiceTarget, input: SfuAppInput): Promise<{
62
+ resourceId?: string;
63
+ }>;
64
+ /**
65
+ * The addresses of the company's media server, from its recorded ids. Before
66
+ * the step is recorded the pair is empty, which `resolveCallsSfuUrls` refuses:
67
+ * an own org's calls service never falls back to the shared SFU's addresses.
68
+ */
69
+ export declare function ownSfuUrls(ids: Readonly<Record<string, string>>): CallsSfuUrls;
@@ -0,0 +1,256 @@
1
+ import { pinnedTagFor } from '../bootstrap/versions.js';
2
+ import { mintLivekitCredentials } from '../calls-sfu-keys.js';
3
+ import { LIVEKIT_SIGNALLING_PORT, sfuSlotPorts } from '../calls-sfu.js';
4
+ import { ensureCloudflaredConnector } from './cloudflared-connector.js';
5
+ import { waitForNewDeployment } from './deploy-health.js';
6
+ import { healthCheckSettings } from './health-check-settings.js';
7
+ import { recordOwnDnsHostname } from './own-dns.js';
8
+ /** The LiveKit image, the same repository the shared SFU runs. */
9
+ export const OWN_SFU_IMAGE = 'livekit/livekit-server';
10
+ export const OWN_SFU_APP_SCOPE_KEY = 'coolify_own_sfu_app';
11
+ export const OWN_SFU_TUNNEL_SCOPE_KEY = 'cloudflare_own_sfu_tunnel';
12
+ export const OWN_SFU_CONNECTOR_APP_SCOPE_KEY = 'coolify_own_sfu_connector_app';
13
+ /** Set to the app id before the key pair leaves, cleared after the pair is delivered and written to the app. */
14
+ export const OWN_SFU_KEYS_UNDELIVERED_SCOPE_KEY = 'own_sfu_keys_undelivered';
15
+ /** The app id once the company's key pair is delivered and in the app's env. */
16
+ export const OWN_SFU_KEYS_SCOPE_KEY = 'own_sfu_keys_set';
17
+ export const OWN_SFU_DEPLOY_PENDING_SCOPE_KEY = 'own_sfu_deploy_pending';
18
+ export const OWN_SFU_APPLIED_VERSION_SCOPE_KEY = 'own_sfu_applied_version';
19
+ export const OWN_SFU_APPLIED_SLOT_SCOPE_KEY = 'own_sfu_applied_slot';
20
+ export const OWN_SFU_APPLIED_HOSTNAME_SCOPE_KEY = 'own_sfu_applied_hostname';
21
+ export const SFU_INTERNAL_URL_SCOPE_KEY = 'sfu_internal_url';
22
+ export const SFU_PUBLIC_URL_SCOPE_KEY = 'sfu_public_url';
23
+ const KEYS_DELIVERY_ATTEMPTS = 3;
24
+ const CONFIG_ENV = 'LIVEKIT_CONFIG';
25
+ /** The Docker `ports_mappings` string for a slot: the UDP range and the TCP fallback port, one to one. */
26
+ export function sfuPortsMappings(slot) {
27
+ const { udpStart, udpEnd, tcp } = sfuSlotPorts(slot);
28
+ return `${udpStart}-${udpEnd}:${udpStart}-${udpEnd}/udp,${tcp}:${tcp}`;
29
+ }
30
+ /**
31
+ * LiveKit takes its whole config from one env value. `node_ip` is the
32
+ * server's address: the app is not on the host network, so the media ports
33
+ * reach it through the published mappings.
34
+ */
35
+ export function sfuConfig(input) {
36
+ const { udpStart, udpEnd, tcp } = sfuSlotPorts(input.slot);
37
+ return [
38
+ `port: ${LIVEKIT_SIGNALLING_PORT}`,
39
+ 'rtc:',
40
+ ` tcp_port: ${tcp}`,
41
+ ` port_range_start: ${udpStart}`,
42
+ ` port_range_end: ${udpEnd}`,
43
+ ' use_external_ip: false',
44
+ ` node_ip: ${input.nodeIp}`,
45
+ 'keys:',
46
+ ` ${input.keys.apiKey}: ${input.keys.apiSecret}`,
47
+ '',
48
+ ].join('\n');
49
+ }
50
+ function heldPairOf(value) {
51
+ if (!value)
52
+ return null;
53
+ let parsed;
54
+ try {
55
+ parsed = JSON.parse(value);
56
+ }
57
+ catch {
58
+ return null;
59
+ }
60
+ const { apiKey, apiSecret } = (parsed ?? {});
61
+ return typeof apiKey === 'string' &&
62
+ typeof apiSecret === 'string' &&
63
+ KEY_PAIR_SHAPE.test(apiKey) &&
64
+ KEY_PAIR_SHAPE.test(apiSecret)
65
+ ? { apiKey, apiSecret }
66
+ : null;
67
+ }
68
+ function keysOf(config) {
69
+ const match = /^keys:\n {2}([A-Za-z0-9]+): ([A-Za-z0-9]+)$/m.exec(config ?? '');
70
+ return match ? { apiKey: match[1], apiSecret: match[2] } : null;
71
+ }
72
+ const KEY_PAIR_SHAPE = /^[A-Za-z0-9]{8,128}$/;
73
+ /**
74
+ * One company's LiveKit media server as a Coolify app: the image, its slot's
75
+ * ports published to the server, and a tunnel for signalling at `sfu.<domain>`.
76
+ * The company's single key pair is minted once, delivered to
77
+ * `target.refs.livekitKeys` as JSON, and written to the app's env; it is never
78
+ * recorded. Rules 1 to 6 of `CLAUDE.md` "Platform steps".
79
+ */
80
+ export async function provisionSfuApp(scope, deps, target, input) {
81
+ const { coolify, network, deliverSecret, runtime } = deps;
82
+ const label = `own step coolify:own-sfu for ${target.id}`;
83
+ sfuSlotPorts(input.slot);
84
+ const zoneId = await network.zoneId(input.apex);
85
+ if (!zoneId)
86
+ throw new Error(`${label}: no Cloudflare zone for ${input.apex}`);
87
+ const projectUuid = await input.project.ensure(coolify, scope);
88
+ const { environment } = await coolify.findOrCreateEnvironment(projectUuid, 'production');
89
+ const servers = await coolify.listServers();
90
+ const server = servers.find((s) => s.is_usable && s.is_reachable) ?? servers[0];
91
+ if (!server)
92
+ throw new Error(`${label}: no usable Coolify server is registered`);
93
+ if (!server.ip)
94
+ throw new Error(`${label}: the Coolify server has no address to give LiveKit`);
95
+ const existingApp = await coolify.findApplicationByName(target.names.sfuApp, {
96
+ environmentIds: [environment.id],
97
+ });
98
+ let appId;
99
+ if (existingApp) {
100
+ appId = existingApp.uuid;
101
+ }
102
+ else {
103
+ appId = (await coolify.createDockerImageApplication({
104
+ project_uuid: projectUuid,
105
+ server_uuid: server.uuid,
106
+ environment_name: environment.name,
107
+ environment_uuid: environment.uuid,
108
+ docker_registry_image_name: OWN_SFU_IMAGE,
109
+ docker_registry_image_tag: pinnedTagFor('livekit', input.version),
110
+ ports_exposes: String(LIVEKIT_SIGNALLING_PORT),
111
+ ports_mappings: sfuPortsMappings(input.slot),
112
+ name: target.names.sfuApp,
113
+ description: `LiveKit media server for ${target.id}`,
114
+ instant_deploy: false,
115
+ })).uuid;
116
+ }
117
+ scope.recordScopeId(OWN_SFU_APP_SCOPE_KEY, appId);
118
+ const recorded = scope.recordedIds();
119
+ const changed = recorded[OWN_SFU_APPLIED_VERSION_SCOPE_KEY] !== input.version ||
120
+ recorded[OWN_SFU_APPLIED_SLOT_SCOPE_KEY] !== String(input.slot) ||
121
+ recorded[OWN_SFU_APPLIED_HOSTNAME_SCOPE_KEY] !== input.hostname;
122
+ // Set on every run: a declared version or slot reaches an existing app here.
123
+ await coolify.updateApplication(appId, {
124
+ custom_internal_name: target.internal.sfu,
125
+ docker_registry_image_tag: pinnedTagFor('livekit', input.version),
126
+ ports_exposes: String(LIVEKIT_SIGNALLING_PORT),
127
+ ports_mappings: sfuPortsMappings(input.slot),
128
+ ...healthCheckSettings('/', LIVEKIT_SIGNALLING_PORT),
129
+ });
130
+ const liveConfig = (await coolify.listEnvs(appId)).find((e) => e.key === CONFIG_ENV && !e.is_preview)?.value;
131
+ let keys;
132
+ let keysJustSet = false;
133
+ if (scope.recordedIds()[OWN_SFU_KEYS_SCOPE_KEY]) {
134
+ const held = keysOf(liveConfig);
135
+ if (!held) {
136
+ throw new Error(`${label}: the app's ${CONFIG_ENV} no longer holds the company's key`);
137
+ }
138
+ keys = held;
139
+ deps.onSecret?.(keys.apiSecret);
140
+ }
141
+ else {
142
+ const lost = scope.recordedIds()[OWN_SFU_KEYS_UNDELIVERED_SCOPE_KEY];
143
+ const resumed = lost
144
+ ? heldPairOf((await deps.readSecret?.(target.refs.livekitKeys)) ?? null)
145
+ : null;
146
+ if (lost && !resumed) {
147
+ 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`);
148
+ }
149
+ if (resumed) {
150
+ keys = resumed;
151
+ deps.onSecret?.(keys.apiSecret);
152
+ keysJustSet = true;
153
+ }
154
+ else {
155
+ keys = (deps.mintLivekitCredentials ?? mintLivekitCredentials)();
156
+ if (!KEY_PAIR_SHAPE.test(keys.apiKey) || !KEY_PAIR_SHAPE.test(keys.apiSecret)) {
157
+ throw new Error(`${label}: the minted LiveKit key has characters the config cannot hold`);
158
+ }
159
+ deps.onSecret?.(keys.apiSecret);
160
+ scope.recordScopeId(OWN_SFU_KEYS_UNDELIVERED_SCOPE_KEY, appId);
161
+ await scope.persist();
162
+ let delivered = false;
163
+ const payload = JSON.stringify({ apiKey: keys.apiKey, apiSecret: keys.apiSecret });
164
+ for (let attempt = 1; attempt <= KEYS_DELIVERY_ATTEMPTS && !delivered; attempt++) {
165
+ try {
166
+ await deliverSecret(target.refs.livekitKeys, payload);
167
+ delivered = true;
168
+ }
169
+ catch {
170
+ // Retried; the error is dropped in case it echoes the key.
171
+ }
172
+ }
173
+ if (!delivered) {
174
+ throw new Error(`${label}: the LiveKit key pair could not be delivered after ${KEYS_DELIVERY_ATTEMPTS} attempts`);
175
+ }
176
+ keysJustSet = true;
177
+ }
178
+ }
179
+ const config = sfuConfig({ slot: input.slot, nodeIp: server.ip, keys });
180
+ deps.onSecret?.(config);
181
+ const envChanged = liveConfig !== config;
182
+ const pending = Boolean(scope.recordedIds()[OWN_SFU_DEPLOY_PENDING_SCOPE_KEY]) || changed || envChanged;
183
+ if (pending) {
184
+ scope.recordScopeId(OWN_SFU_DEPLOY_PENDING_SCOPE_KEY, '1');
185
+ await scope.persist();
186
+ }
187
+ if (envChanged) {
188
+ await coolify.bulkUpsertEnvs(appId, [{ key: CONFIG_ENV, value: config, literal: true }]);
189
+ }
190
+ if (keysJustSet) {
191
+ scope.recordScopeId(OWN_SFU_KEYS_SCOPE_KEY, appId);
192
+ scope.recordScopeId(OWN_SFU_KEYS_UNDELIVERED_SCOPE_KEY, '');
193
+ await scope.persist();
194
+ }
195
+ const running = (await coolify.getApplication(appId)).status?.startsWith('running') ?? false;
196
+ if (!existingApp || pending || !running) {
197
+ 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');
198
+ }
199
+ if (pending) {
200
+ scope.recordScopeId(OWN_SFU_DEPLOY_PENDING_SCOPE_KEY, '');
201
+ await scope.persist();
202
+ }
203
+ const tunnels = await network.tunnels();
204
+ const tunnel = (await tunnels.findTunnelByName(target.names.sfuTunnel)) ??
205
+ (await tunnels.createTunnel(target.names.sfuTunnel));
206
+ scope.recordScopeId(OWN_SFU_TUNNEL_SCOPE_KEY, tunnel.id);
207
+ await tunnels.putTunnelConfig(tunnel.id, {
208
+ ingress: [
209
+ {
210
+ hostname: input.hostname,
211
+ service: `http://${target.internal.sfu}:${LIVEKIT_SIGNALLING_PORT}`,
212
+ },
213
+ { service: 'http_status:404' },
214
+ ],
215
+ });
216
+ await ensureCloudflaredConnector({
217
+ coolify,
218
+ tunnels,
219
+ tunnelId: tunnel.id,
220
+ appName: target.names.sfuCloudflared,
221
+ projectUuid,
222
+ environment,
223
+ resolveServer: async () => server,
224
+ recordedConnectorAppId: scope.recordedIds()[OWN_SFU_CONNECTOR_APP_SCOPE_KEY] ?? null,
225
+ recordAppId: async (id) => {
226
+ scope.recordScopeId(OWN_SFU_CONNECTOR_APP_SCOPE_KEY, id);
227
+ await scope.persist();
228
+ },
229
+ });
230
+ const dns = await network.dns();
231
+ const dnsTarget = `${tunnel.id}.cfargotunnel.com`;
232
+ const [record] = await dns.findDnsRecord(input.apex, input.hostname, 'CNAME');
233
+ const cname = { type: 'CNAME', name: input.hostname, content: dnsTarget, proxied: true };
234
+ if (!record)
235
+ await dns.createDnsRecord(input.apex, cname);
236
+ else if (record.content !== dnsTarget)
237
+ await dns.updateDnsRecord(input.apex, record.id, cname);
238
+ await recordOwnDnsHostname(scope, input.hostname);
239
+ scope.recordScopeId(OWN_SFU_APPLIED_VERSION_SCOPE_KEY, input.version);
240
+ scope.recordScopeId(OWN_SFU_APPLIED_SLOT_SCOPE_KEY, String(input.slot));
241
+ scope.recordScopeId(OWN_SFU_APPLIED_HOSTNAME_SCOPE_KEY, input.hostname);
242
+ scope.recordScopeId(SFU_INTERNAL_URL_SCOPE_KEY, `http://${target.internal.sfu}:${LIVEKIT_SIGNALLING_PORT}`);
243
+ scope.recordScopeId(SFU_PUBLIC_URL_SCOPE_KEY, `https://${input.hostname}`);
244
+ return { resourceId: appId };
245
+ }
246
+ /**
247
+ * The addresses of the company's media server, from its recorded ids. Before
248
+ * the step is recorded the pair is empty, which `resolveCallsSfuUrls` refuses:
249
+ * an own org's calls service never falls back to the shared SFU's addresses.
250
+ */
251
+ export function ownSfuUrls(ids) {
252
+ return {
253
+ privateUrl: ids[SFU_INTERNAL_URL_SCOPE_KEY] ?? '',
254
+ publicUrl: ids[SFU_PUBLIC_URL_SCOPE_KEY] ?? '',
255
+ };
256
+ }