@wtfalch/foundry 0.39.4 → 0.39.5
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/index.d.ts +1 -1
- package/dist/index.js +1 -1
- package/dist/plan/coolify-service.d.ts +62 -0
- package/dist/plan/coolify-service.js +120 -0
- package/dist/plan/executor.js +4 -0
- package/dist/plan/index.d.ts +1 -1
- package/dist/plan/index.js +1 -1
- package/dist/plan/own-diff.js +1 -1
- package/dist/plan/own-sfu.d.ts +44 -12
- package/dist/plan/own-sfu.js +87 -47
- package/package.json +1 -1
package/dist/index.d.ts
CHANGED
|
@@ -35,7 +35,7 @@ export { OWN_FILES_R2_SCOPE_KEY, OWN_FILES_R2_UNDELIVERED_SCOPE_KEY, OWN_SERVICE
|
|
|
35
35
|
export type { FilesHostInput, JobsHostDeps, JobsHostInput, OwnAuthInstance, PlanOwnAuthOptions, PlanOwnFilesOptions, PlanOwnFilesR2Options, PlanOwnJobsOptions, } from './plan/index.js';
|
|
36
36
|
export { OWN_AUTH_APPLIED_GIT_REF_SCOPE_KEY, OWN_AUTH_APPLIED_HOSTNAME_SCOPE_KEY, OWN_AUTH_BLOCKED_KEY, OWN_FILES_BLOCKED_KEY, OWN_FILES_R2_BLOCKED_KEY, OWN_JOBS_BLOCKED_KEY, OWN_MAIL_BLOCKED_KEY, planOwnAuth, planOwnFiles, planOwnFilesR2, planOwnJobs, planOwnMail, provisionFilesHost, provisionJobsHost, provisionMailServer, } from './plan/index.js';
|
|
37
37
|
export type { PlanOwnSfuOptions, SfuAppDeps, SfuAppInput } from './plan/index.js';
|
|
38
|
-
export { OWN_SFU_BLOCKED_KEY, OWN_SFU_FIREWALL_KEY, planOwnSfu, provisionSfuApp, sfuConfig,
|
|
38
|
+
export { OWN_SFU_BLOCKED_KEY, OWN_SFU_FIREWALL_KEY, planOwnSfu, provisionSfuApp, sfuConfig, sfuCompose, } from './plan/index.js';
|
|
39
39
|
export type { CoolifyPort, CloudflarePort, ZitadelPort, CallsSfuPort, JobsSchedulerPort, FilesOrganisationPort, AiOrganisationPort, FoundryStepExecutorPorts, PlatformOrgPortSource, AgoraHostPort, AgoraHostValues, LoginHostBinding, LoginHostBindingsPort, LoginHostDiscoveryPort, } from './plan/index.js';
|
|
40
40
|
export { FoundryStepExecutor, NotBuiltError, healthUrls, loginHostnameFor } from './plan/index.js';
|
|
41
41
|
export { NO_DOMAIN_ZONE, ownHostnames } from './plan/index.js';
|
package/dist/index.js
CHANGED
|
@@ -20,7 +20,7 @@ export { DEFAULT_ORG_PLATFORM_READINESS, ORG_PLATFORM_KEY_REFS, agoraHostValuesF
|
|
|
20
20
|
export { PlatformOrgBlockedError, PlatformOrgPorts } from './plan/index.js';
|
|
21
21
|
export { OWN_FILES_R2_SCOPE_KEY, OWN_FILES_R2_UNDELIVERED_SCOPE_KEY, OWN_SERVICES_APPLIED_SCOPE_KEY, OwnServicesBlockedError, orgTarget, } from './plan/index.js';
|
|
22
22
|
export { OWN_AUTH_APPLIED_GIT_REF_SCOPE_KEY, OWN_AUTH_APPLIED_HOSTNAME_SCOPE_KEY, OWN_AUTH_BLOCKED_KEY, OWN_FILES_BLOCKED_KEY, OWN_FILES_R2_BLOCKED_KEY, OWN_JOBS_BLOCKED_KEY, OWN_MAIL_BLOCKED_KEY, planOwnAuth, planOwnFiles, planOwnFilesR2, planOwnJobs, planOwnMail, provisionFilesHost, provisionJobsHost, provisionMailServer, } from './plan/index.js';
|
|
23
|
-
export { OWN_SFU_BLOCKED_KEY, OWN_SFU_FIREWALL_KEY, planOwnSfu, provisionSfuApp, sfuConfig,
|
|
23
|
+
export { OWN_SFU_BLOCKED_KEY, OWN_SFU_FIREWALL_KEY, planOwnSfu, provisionSfuApp, sfuConfig, sfuCompose, } from './plan/index.js';
|
|
24
24
|
export { FoundryStepExecutor, NotBuiltError, healthUrls, loginHostnameFor } from './plan/index.js';
|
|
25
25
|
export { NO_DOMAIN_ZONE, ownHostnames } from './plan/index.js';
|
|
26
26
|
export { ZITADEL_ORG_DOMAIN_SCOPE_KEY, ensureSignInOrg, registerSignInDomain, signInOrgDomain, } from './plan/index.js';
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
import type { DeployHealthRuntime } from './deploy-health.js';
|
|
2
|
+
import type { CoolifyPort } from './executor.js';
|
|
3
|
+
/**
|
|
4
|
+
* A Coolify "service": a Docker Compose file Coolify runs as one unit. Foundry
|
|
5
|
+
* uses it where an application cannot do the job. Coolify 4.4.2 takes
|
|
6
|
+
* `ports_mappings` on an application only as `^(\d+:\d+)(,\d+:\d+)*$`
|
|
7
|
+
* (`bootstrap/helpers/api.php:233`): no range and no `/udp`, so a media
|
|
8
|
+
* server's UDP range cannot be published that way. A service keeps the
|
|
9
|
+
* compose `ports` list as written (`bootstrap/helpers/parsers.php`,
|
|
10
|
+
* `serviceParser`: `$payload['ports'] = $ports`).
|
|
11
|
+
*
|
|
12
|
+
* Every call goes through `CoolifyPort.raw` with a time limit, and an error
|
|
13
|
+
* never carries a request body.
|
|
14
|
+
*/
|
|
15
|
+
type Raw = Pick<CoolifyPort, 'raw'>;
|
|
16
|
+
/** The uuid of the service named `name` in the environment, or null. `GET /services` lists a team's services with their `environment_id` (`ServicesController::services`). */
|
|
17
|
+
export declare function findComposeService(coolify: Raw, name: string, environmentId: number): Promise<string | null>;
|
|
18
|
+
export interface CreateComposeServiceInput {
|
|
19
|
+
project_uuid: string;
|
|
20
|
+
server_uuid: string;
|
|
21
|
+
environment_name: string;
|
|
22
|
+
environment_uuid: string;
|
|
23
|
+
/** Required by Coolify when the server has more than one destination (`ServicesController::create_service`). */
|
|
24
|
+
destination_uuid?: string;
|
|
25
|
+
name: string;
|
|
26
|
+
description: string;
|
|
27
|
+
/** The compose file as text; sent base64 encoded. */
|
|
28
|
+
compose: string;
|
|
29
|
+
}
|
|
30
|
+
/**
|
|
31
|
+
* `POST /services` with `docker_compose_raw`. `connect_to_docker_network` makes
|
|
32
|
+
* the container join the destination's network under the alias
|
|
33
|
+
* `<service key>-<service uuid>` (`StartService.php`), which the tunnel dials.
|
|
34
|
+
* Nothing starts: `instant_deploy` is false.
|
|
35
|
+
*/
|
|
36
|
+
export declare function createComposeService(coolify: Raw, input: CreateComposeServiceInput): Promise<string>;
|
|
37
|
+
/** `PATCH /services/{uuid}`: the compose file and the network flag. Coolify parses the file again; nothing restarts. */
|
|
38
|
+
export declare function updateComposeService(coolify: Raw, uuid: string, compose: string): Promise<void>;
|
|
39
|
+
/** `GET /services/{uuid}`: its `status` is `running:healthy`, `exited:unhealthy`, ... (the model appends it). */
|
|
40
|
+
export declare function composeServiceStatus(coolify: Raw, uuid: string): Promise<string>;
|
|
41
|
+
/** Coolify refuses `start` of a service whose status contains "running" (`action_deploy`), so the same test decides. */
|
|
42
|
+
export declare const composeServiceRuns: (status: string) => boolean;
|
|
43
|
+
/** One env value of the service's production rows, or undefined. */
|
|
44
|
+
export declare function composeServiceEnv(coolify: Raw, uuid: string, key: string): Promise<string | undefined>;
|
|
45
|
+
/**
|
|
46
|
+
* `PATCH /services/{uuid}/envs/bulk`. `is_literal` and `is_multiline` both
|
|
47
|
+
* single-quote the value in the service's `.env` (`EnvironmentVariable.php`
|
|
48
|
+
* `real_value`), so `$` is never interpolated and a newline stays in it.
|
|
49
|
+
*/
|
|
50
|
+
export declare function upsertComposeServiceEnv(coolify: Raw, uuid: string, env: {
|
|
51
|
+
key: string;
|
|
52
|
+
value: string;
|
|
53
|
+
}): Promise<void>;
|
|
54
|
+
/**
|
|
55
|
+
* Runs the service's current compose file and env. A service has no
|
|
56
|
+
* deployment record to wait on, so a running service is stopped first and
|
|
57
|
+
* the wait for "not running" comes before `start`: the old status can never
|
|
58
|
+
* be read as the new one (rule 5). Then `start` and the wait for "running".
|
|
59
|
+
* A service that is not running is only started.
|
|
60
|
+
*/
|
|
61
|
+
export declare function redeployComposeService(coolify: Raw, uuid: string, runtime: DeployHealthRuntime, label: string): Promise<void>;
|
|
62
|
+
export {};
|
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
import { OWN_LIMITS_TIMEOUT_MS } from './own-limits.js';
|
|
2
|
+
const SERVICE_WAIT_MS = 300_000;
|
|
3
|
+
const SERVICE_POLL_INTERVAL_MS = 5_000;
|
|
4
|
+
async function call(coolify, what, method, path, body) {
|
|
5
|
+
let timer;
|
|
6
|
+
try {
|
|
7
|
+
return await Promise.race([
|
|
8
|
+
coolify.raw(method, path, body),
|
|
9
|
+
new Promise((_, reject) => {
|
|
10
|
+
timer = setTimeout(() => reject(new Error(`${what}: Coolify did not answer within ${OWN_LIMITS_TIMEOUT_MS / 1000}s`)), OWN_LIMITS_TIMEOUT_MS);
|
|
11
|
+
}),
|
|
12
|
+
]);
|
|
13
|
+
}
|
|
14
|
+
finally {
|
|
15
|
+
clearTimeout(timer);
|
|
16
|
+
}
|
|
17
|
+
}
|
|
18
|
+
const isRecord = (v) => typeof v === 'object' && v !== null && !Array.isArray(v);
|
|
19
|
+
/** The uuid of the service named `name` in the environment, or null. `GET /services` lists a team's services with their `environment_id` (`ServicesController::services`). */
|
|
20
|
+
export async function findComposeService(coolify, name, environmentId) {
|
|
21
|
+
const rows = await call(coolify, 'listing services', 'GET', '/services');
|
|
22
|
+
if (!Array.isArray(rows))
|
|
23
|
+
throw new Error('listing services: Coolify answered with no list');
|
|
24
|
+
for (const row of rows) {
|
|
25
|
+
if (isRecord(row) &&
|
|
26
|
+
row.name === name &&
|
|
27
|
+
row.environment_id === environmentId &&
|
|
28
|
+
typeof row.uuid === 'string') {
|
|
29
|
+
return row.uuid;
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
return null;
|
|
33
|
+
}
|
|
34
|
+
/**
|
|
35
|
+
* `POST /services` with `docker_compose_raw`. `connect_to_docker_network` makes
|
|
36
|
+
* the container join the destination's network under the alias
|
|
37
|
+
* `<service key>-<service uuid>` (`StartService.php`), which the tunnel dials.
|
|
38
|
+
* Nothing starts: `instant_deploy` is false.
|
|
39
|
+
*/
|
|
40
|
+
export async function createComposeService(coolify, input) {
|
|
41
|
+
const { compose, ...rest } = input;
|
|
42
|
+
const created = await call(coolify, `creating service ${input.name}`, 'POST', '/services', {
|
|
43
|
+
...rest,
|
|
44
|
+
docker_compose_raw: Buffer.from(compose, 'utf8').toString('base64'),
|
|
45
|
+
connect_to_docker_network: true,
|
|
46
|
+
instant_deploy: false,
|
|
47
|
+
});
|
|
48
|
+
const uuid = isRecord(created) ? created.uuid : undefined;
|
|
49
|
+
if (typeof uuid !== 'string' || uuid === '') {
|
|
50
|
+
throw new Error(`creating service ${input.name}: Coolify answered with no uuid`);
|
|
51
|
+
}
|
|
52
|
+
return uuid;
|
|
53
|
+
}
|
|
54
|
+
/** `PATCH /services/{uuid}`: the compose file and the network flag. Coolify parses the file again; nothing restarts. */
|
|
55
|
+
export async function updateComposeService(coolify, uuid, compose) {
|
|
56
|
+
await call(coolify, `updating service ${uuid}`, 'PATCH', `/services/${uuid}`, {
|
|
57
|
+
docker_compose_raw: Buffer.from(compose, 'utf8').toString('base64'),
|
|
58
|
+
connect_to_docker_network: true,
|
|
59
|
+
});
|
|
60
|
+
}
|
|
61
|
+
/** `GET /services/{uuid}`: its `status` is `running:healthy`, `exited:unhealthy`, ... (the model appends it). */
|
|
62
|
+
export async function composeServiceStatus(coolify, uuid) {
|
|
63
|
+
const row = await call(coolify, `reading service ${uuid}`, 'GET', `/services/${uuid}`);
|
|
64
|
+
const status = isRecord(row) ? row.status : undefined;
|
|
65
|
+
return typeof status === 'string' ? status : '';
|
|
66
|
+
}
|
|
67
|
+
/** Coolify refuses `start` of a service whose status contains "running" (`action_deploy`), so the same test decides. */
|
|
68
|
+
export const composeServiceRuns = (status) => status.includes('running');
|
|
69
|
+
/** One env value of the service's production rows, or undefined. */
|
|
70
|
+
export async function composeServiceEnv(coolify, uuid, key) {
|
|
71
|
+
const rows = await call(coolify, `reading the envs of service ${uuid}`, 'GET', `/services/${uuid}/envs`);
|
|
72
|
+
if (!Array.isArray(rows))
|
|
73
|
+
throw new Error(`reading the envs of service ${uuid}: no list`);
|
|
74
|
+
for (const row of rows) {
|
|
75
|
+
if (isRecord(row) && row.key === key && row.is_preview !== true) {
|
|
76
|
+
return typeof row.value === 'string' ? row.value : undefined;
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
return undefined;
|
|
80
|
+
}
|
|
81
|
+
/**
|
|
82
|
+
* `PATCH /services/{uuid}/envs/bulk`. `is_literal` and `is_multiline` both
|
|
83
|
+
* single-quote the value in the service's `.env` (`EnvironmentVariable.php`
|
|
84
|
+
* `real_value`), so `$` is never interpolated and a newline stays in it.
|
|
85
|
+
*/
|
|
86
|
+
export async function upsertComposeServiceEnv(coolify, uuid, env) {
|
|
87
|
+
await call(coolify, `writing the envs of service ${uuid}`, 'PATCH', `/services/${uuid}/envs/bulk`, {
|
|
88
|
+
data: [{ key: env.key, value: env.value, is_literal: true, is_multiline: true }],
|
|
89
|
+
});
|
|
90
|
+
}
|
|
91
|
+
async function waitFor(coolify, uuid, want, runtime, label, what) {
|
|
92
|
+
const sleep = runtime.sleep ?? ((ms) => new Promise((r) => setTimeout(r, ms)));
|
|
93
|
+
const now = runtime.now ?? Date.now;
|
|
94
|
+
const timeout = runtime.readyTimeoutMs ?? SERVICE_WAIT_MS;
|
|
95
|
+
const interval = runtime.readyIntervalMs ?? SERVICE_POLL_INTERVAL_MS;
|
|
96
|
+
const deadline = now() + timeout;
|
|
97
|
+
for (;;) {
|
|
98
|
+
if (want(await composeServiceStatus(coolify, uuid)))
|
|
99
|
+
return;
|
|
100
|
+
if (now() >= deadline) {
|
|
101
|
+
throw new Error(`${label}: service ${uuid} did not ${what} within ${Math.round(timeout / 1000)}s. Apply again`);
|
|
102
|
+
}
|
|
103
|
+
await sleep(interval);
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
/**
|
|
107
|
+
* Runs the service's current compose file and env. A service has no
|
|
108
|
+
* deployment record to wait on, so a running service is stopped first and
|
|
109
|
+
* the wait for "not running" comes before `start`: the old status can never
|
|
110
|
+
* be read as the new one (rule 5). Then `start` and the wait for "running".
|
|
111
|
+
* A service that is not running is only started.
|
|
112
|
+
*/
|
|
113
|
+
export async function redeployComposeService(coolify, uuid, runtime, label) {
|
|
114
|
+
if (composeServiceRuns(await composeServiceStatus(coolify, uuid))) {
|
|
115
|
+
await call(coolify, `stopping service ${uuid}`, 'POST', `/services/${uuid}/stop`);
|
|
116
|
+
await waitFor(coolify, uuid, (s) => !composeServiceRuns(s), runtime, label, 'stop');
|
|
117
|
+
}
|
|
118
|
+
await call(coolify, `starting service ${uuid}`, 'POST', `/services/${uuid}/start`);
|
|
119
|
+
await waitFor(coolify, uuid, (s) => composeServiceRuns(s) && !s.includes('unhealthy'), runtime, label, 'run');
|
|
120
|
+
}
|
package/dist/plan/executor.js
CHANGED
|
@@ -5903,7 +5903,11 @@ export class FoundryStepExecutor {
|
|
|
5903
5903
|
throw new Error(OWN_SERVICES_PORT_ABSENT);
|
|
5904
5904
|
const scope = scopeFromOrg(context, {});
|
|
5905
5905
|
const { sfu } = own.provision;
|
|
5906
|
+
const sfuRaw = sfu.coolify;
|
|
5907
|
+
const listing = typeof sfuRaw.raw === 'function' ? sfuRaw : this.rawCoolify;
|
|
5906
5908
|
return provisionSfuApp(scope, {
|
|
5909
|
+
// The connector app goes through `placedForCompany` and `limited`; the media server is a service and names its destination itself.
|
|
5910
|
+
destinationFor: (serverUuid) => this.companyDestination(listing, org, context, serverUuid),
|
|
5907
5911
|
coolify: limited(this.placedForCompany(sfu.coolify, org, context), LIMITS.ownSfu),
|
|
5908
5912
|
network: orgNetwork({ dns: this.dnsPort(), tunnels: this.tunnelPort(), zones: this.zonePort() }, scope, `${org.slug}:sfu`),
|
|
5909
5913
|
deliverSecret: sfu.deliverSecret,
|
package/dist/plan/index.d.ts
CHANGED
|
@@ -13,7 +13,7 @@ export { OWN_AUTH_APPLIED_GIT_REF_SCOPE_KEY, OWN_AUTH_APPLIED_HOSTNAME_SCOPE_KEY
|
|
|
13
13
|
export type { PlanOwnSfuOptions } from './own-diff.js';
|
|
14
14
|
export { OWN_SFU_BLOCKED_KEY, OWN_SFU_FIREWALL_KEY, planOwnSfu } from './own-diff.js';
|
|
15
15
|
export type { SfuAppDeps, SfuAppInput } from './own-sfu.js';
|
|
16
|
-
export { provisionSfuApp,
|
|
16
|
+
export { provisionSfuApp, sfuCompose, sfuConfig } from './own-sfu.js';
|
|
17
17
|
export type { OwnAuthInstance, FilesKeysMint, FilesKeysMinted, OwnFilesDeps, OwnFilesR2Key, OwnFilesR2Keys, OwnServicePorts, OwnServicesPort, OwnServicesProvision, OwnSfuDeps, ProjectSource, ServiceNetwork, ServiceTarget, StepScope, } from './own-services.js';
|
|
18
18
|
export { OWN_FILES_R2_SCOPE_KEY, OWN_FILES_R2_UNDELIVERED_SCOPE_KEY, OWN_SERVICES_APPLIED_SCOPE_KEY, OwnServicesBlockedError, orgNetwork, orgTarget, platformNetwork, platformTarget, scopeFromOrg, scopeFromPlatform, } from './own-services.js';
|
|
19
19
|
export type { PlatformOrgClientFactories } from './platform-org-ports.js';
|
package/dist/plan/index.js
CHANGED
|
@@ -5,7 +5,7 @@ export { planPlatform, toPlatformPlan, JOBS_INTERNAL_URL_SCOPE_KEY, JOBS_SCHEDUL
|
|
|
5
5
|
export { DEFAULT_ORG_PLATFORM_READINESS, ORG_PLATFORM_KEY_REFS, agoraHostValuesForOwn, agoraHostValuesFromPlatform, heldOwnKeyRefs, heldPlatformKeyRefs, orgPlatformReadinessFor, orgPlatformReadinessFrom, } from './platform-org.js';
|
|
6
6
|
export { OWN_AUTH_APPLIED_GIT_REF_SCOPE_KEY, OWN_AUTH_APPLIED_HOSTNAME_SCOPE_KEY, OWN_AUTH_BLOCKED_KEY, OWN_FILES_BLOCKED_KEY, OWN_FILES_R2_BLOCKED_KEY, OWN_JOBS_BLOCKED_KEY, OWN_MAIL_BLOCKED_KEY, planOwnAuth, planOwnFiles, planOwnFilesR2, planOwnJobs, planOwnMail, } from './own-diff.js';
|
|
7
7
|
export { OWN_SFU_BLOCKED_KEY, OWN_SFU_FIREWALL_KEY, planOwnSfu } from './own-diff.js';
|
|
8
|
-
export { provisionSfuApp,
|
|
8
|
+
export { provisionSfuApp, sfuCompose, sfuConfig } from './own-sfu.js';
|
|
9
9
|
export { OWN_FILES_R2_SCOPE_KEY, OWN_FILES_R2_UNDELIVERED_SCOPE_KEY, OWN_SERVICES_APPLIED_SCOPE_KEY, OwnServicesBlockedError, orgNetwork, orgTarget, platformNetwork, platformTarget, scopeFromOrg, scopeFromPlatform, } from './own-services.js';
|
|
10
10
|
export { PlatformOrgBlockedError, PlatformOrgPorts } from './platform-org-ports.js';
|
|
11
11
|
export { AdoptRefusedError } from './adopt.js';
|
package/dist/plan/own-diff.js
CHANGED
|
@@ -338,7 +338,7 @@ export function planOwnSfu(options) {
|
|
|
338
338
|
});
|
|
339
339
|
const undelivered = recordedIds[OWN_SFU_KEYS_UNDELIVERED_SCOPE_KEY];
|
|
340
340
|
if (undelivered && !options.keysHeld) {
|
|
341
|
-
return result(null, `${org.slug}'s own media server LiveKit key for
|
|
341
|
+
return result(null, `${org.slug}'s own media server LiveKit key for service ${undelivered} was minted but not confirmed delivered; remove the recorded key ${OWN_SFU_KEYS_UNDELIVERED_SCOPE_KEY} to mint a new one`);
|
|
342
342
|
}
|
|
343
343
|
if (!ownServices) {
|
|
344
344
|
return result(null, `${org.slug}'s own media server is blocked, not planned: the host supplies no ownServices port`);
|
package/dist/plan/own-sfu.d.ts
CHANGED
|
@@ -1,16 +1,21 @@
|
|
|
1
1
|
import { type LivekitCredentials } from '../calls-sfu-keys.js';
|
|
2
2
|
import { type CallsSfuUrls } from '../calls-sfu.js';
|
|
3
|
-
import {
|
|
3
|
+
import type { DeployHealthRuntime } from './deploy-health.js';
|
|
4
4
|
import type { FoundryStepExecutorPorts } from './executor.js';
|
|
5
5
|
import type { OwnSfuDeps, ProjectSource, ServiceNetwork, ServiceTarget, StepScope } from './own-services.js';
|
|
6
6
|
/** The LiveKit image, the same repository the shared SFU runs. */
|
|
7
7
|
export declare const OWN_SFU_IMAGE = "livekit/livekit-server";
|
|
8
|
-
|
|
8
|
+
/**
|
|
9
|
+
* The Coolify service (not application) that runs the media server. The earlier
|
|
10
|
+
* `coolify_own_sfu_app` key named an application Coolify refused to create; it
|
|
11
|
+
* is no longer read or written, and a value left under it is ignored.
|
|
12
|
+
*/
|
|
13
|
+
export declare const OWN_SFU_SERVICE_SCOPE_KEY = "coolify_own_sfu_service";
|
|
9
14
|
export declare const OWN_SFU_TUNNEL_SCOPE_KEY = "cloudflare_own_sfu_tunnel";
|
|
10
15
|
export declare const OWN_SFU_CONNECTOR_APP_SCOPE_KEY = "coolify_own_sfu_connector_app";
|
|
11
|
-
/** Set to the
|
|
16
|
+
/** Set to the service uuid before the key pair leaves, cleared after the pair is delivered and written to the service. */
|
|
12
17
|
export declare const OWN_SFU_KEYS_UNDELIVERED_SCOPE_KEY = "own_sfu_keys_undelivered";
|
|
13
|
-
/** The
|
|
18
|
+
/** The service uuid once the company's key pair is delivered and in the service's env. */
|
|
14
19
|
export declare const OWN_SFU_KEYS_SCOPE_KEY = "own_sfu_keys_set";
|
|
15
20
|
export declare const OWN_SFU_DEPLOY_PENDING_SCOPE_KEY = "own_sfu_deploy_pending";
|
|
16
21
|
export declare const OWN_SFU_APPLIED_VERSION_SCOPE_KEY = "own_sfu_applied_version";
|
|
@@ -28,6 +33,8 @@ export interface SfuAppDeps {
|
|
|
28
33
|
mintLivekitCredentials?: () => LivekitCredentials;
|
|
29
34
|
/** Called with every secret value this step handles, so the caller can scrub it from output. */
|
|
30
35
|
onSecret?: (value: string) => void;
|
|
36
|
+
/** The company's Docker destination on a server. Without it the create names none. */
|
|
37
|
+
destinationFor?: (serverUuid: string) => Promise<string | undefined>;
|
|
31
38
|
}
|
|
32
39
|
export interface SfuAppInput {
|
|
33
40
|
slot: number;
|
|
@@ -39,10 +46,33 @@ export interface SfuAppInput {
|
|
|
39
46
|
version: string;
|
|
40
47
|
project: ProjectSource;
|
|
41
48
|
}
|
|
42
|
-
/** The
|
|
43
|
-
export declare function
|
|
49
|
+
/** The compose `ports` lines for a slot: the UDP range and the TCP fallback port, one to one. */
|
|
50
|
+
export declare function sfuComposePorts(slot: number): string[];
|
|
51
|
+
/** The image the service runs: the pinned tag carries the platform's digest, any other declared tag goes as declared. */
|
|
52
|
+
export declare function sfuImageRef(version: string): string;
|
|
53
|
+
/**
|
|
54
|
+
* The compose file of the media server. `serviceName` is the compose service
|
|
55
|
+
* key. Read from Coolify 4.4.2 (tag v4.4.2 source):
|
|
56
|
+
* - the container is named `<key>-<service uuid>` (`bootstrap/helpers/parsers.php:2711`);
|
|
57
|
+
* - with `connect_to_docker_network` set, `StartService` runs `docker network connect
|
|
58
|
+
* --alias <that name> <destination network> <that name>`
|
|
59
|
+
* (`app/Actions/Service/StartService.php:59-65`), so the tunnel on the destination
|
|
60
|
+
* network reaches port 7880 at that name;
|
|
61
|
+
* - the compose `ports` list is kept as written (`parsers.php`, `$payload['ports'] = $ports`),
|
|
62
|
+
* where an application refuses a port range and a protocol suffix in its mappings
|
|
63
|
+
* (`bootstrap/helpers/api.php:233`).
|
|
64
|
+
* The config reaches the container only as `${LIVEKIT_CONFIG}`, a service env.
|
|
65
|
+
*/
|
|
66
|
+
export declare function sfuCompose(input: {
|
|
67
|
+
serviceName: string;
|
|
68
|
+
slot: number;
|
|
69
|
+
version: string;
|
|
70
|
+
}): string;
|
|
44
71
|
/**
|
|
45
|
-
* LiveKit takes its whole config from one env value.
|
|
72
|
+
* LiveKit takes its whole config from one env value. It ends without a
|
|
73
|
+
* newline: Coolify trims every stored env value (EnvironmentVariable.php:462),
|
|
74
|
+
* so a trailing one would never match what is read back, and every apply
|
|
75
|
+
* would restart the media server. `node_ip` is the
|
|
46
76
|
* server's address: the app is not on the host network, so the media ports
|
|
47
77
|
* reach it through the published mappings.
|
|
48
78
|
*/
|
|
@@ -52,11 +82,13 @@ export declare function sfuConfig(input: {
|
|
|
52
82
|
keys: LivekitCredentials;
|
|
53
83
|
}): string;
|
|
54
84
|
/**
|
|
55
|
-
* One company's LiveKit media server as a Coolify
|
|
56
|
-
* ports published to the server, and a tunnel for signalling
|
|
57
|
-
*
|
|
58
|
-
* `
|
|
59
|
-
*
|
|
85
|
+
* One company's LiveKit media server as a Coolify service (a compose file): the
|
|
86
|
+
* image, its slot's ports published to the server, and a tunnel for signalling
|
|
87
|
+
* at `sfu.<domain>`. It is a service because an application cannot publish a
|
|
88
|
+
* UDP range (`coolify-service.ts`). The company's single key pair is minted
|
|
89
|
+
* once, delivered to `target.refs.livekitKeys` as JSON, and written to the
|
|
90
|
+
* service's env; it is never recorded. Rules 1 to 6 of `CLAUDE.md` "Platform
|
|
91
|
+
* steps". An application of the same name is never read or changed.
|
|
60
92
|
*/
|
|
61
93
|
export declare function provisionSfuApp(scope: StepScope, deps: SfuAppDeps, target: ServiceTarget, input: SfuAppInput): Promise<{
|
|
62
94
|
resourceId?: string;
|
package/dist/plan/own-sfu.js
CHANGED
|
@@ -1,18 +1,22 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { DIGESTS, VERSIONS } from '../bootstrap/versions.js';
|
|
2
2
|
import { mintLivekitCredentials } from '../calls-sfu-keys.js';
|
|
3
3
|
import { LIVEKIT_SIGNALLING_PORT, sfuSlotPorts } from '../calls-sfu.js';
|
|
4
4
|
import { ensureCloudflaredConnector } from './cloudflared-connector.js';
|
|
5
|
-
import {
|
|
6
|
-
import { healthCheckSettings } from './health-check-settings.js';
|
|
5
|
+
import { composeServiceEnv, composeServiceRuns, composeServiceStatus, createComposeService, findComposeService, redeployComposeService, updateComposeService, upsertComposeServiceEnv, } from './coolify-service.js';
|
|
7
6
|
import { recordOwnDnsHostname } from './own-dns.js';
|
|
8
7
|
/** The LiveKit image, the same repository the shared SFU runs. */
|
|
9
8
|
export const OWN_SFU_IMAGE = 'livekit/livekit-server';
|
|
10
|
-
|
|
9
|
+
/**
|
|
10
|
+
* The Coolify service (not application) that runs the media server. The earlier
|
|
11
|
+
* `coolify_own_sfu_app` key named an application Coolify refused to create; it
|
|
12
|
+
* is no longer read or written, and a value left under it is ignored.
|
|
13
|
+
*/
|
|
14
|
+
export const OWN_SFU_SERVICE_SCOPE_KEY = 'coolify_own_sfu_service';
|
|
11
15
|
export const OWN_SFU_TUNNEL_SCOPE_KEY = 'cloudflare_own_sfu_tunnel';
|
|
12
16
|
export const OWN_SFU_CONNECTOR_APP_SCOPE_KEY = 'coolify_own_sfu_connector_app';
|
|
13
|
-
/** Set to the
|
|
17
|
+
/** Set to the service uuid before the key pair leaves, cleared after the pair is delivered and written to the service. */
|
|
14
18
|
export const OWN_SFU_KEYS_UNDELIVERED_SCOPE_KEY = 'own_sfu_keys_undelivered';
|
|
15
|
-
/** The
|
|
19
|
+
/** The service uuid once the company's key pair is delivered and in the service's env. */
|
|
16
20
|
export const OWN_SFU_KEYS_SCOPE_KEY = 'own_sfu_keys_set';
|
|
17
21
|
export const OWN_SFU_DEPLOY_PENDING_SCOPE_KEY = 'own_sfu_deploy_pending';
|
|
18
22
|
export const OWN_SFU_APPLIED_VERSION_SCOPE_KEY = 'own_sfu_applied_version';
|
|
@@ -22,13 +26,50 @@ export const SFU_INTERNAL_URL_SCOPE_KEY = 'sfu_internal_url';
|
|
|
22
26
|
export const SFU_PUBLIC_URL_SCOPE_KEY = 'sfu_public_url';
|
|
23
27
|
const KEYS_DELIVERY_ATTEMPTS = 3;
|
|
24
28
|
const CONFIG_ENV = 'LIVEKIT_CONFIG';
|
|
25
|
-
/** The
|
|
26
|
-
export function
|
|
29
|
+
/** The compose `ports` lines for a slot: the UDP range and the TCP fallback port, one to one. */
|
|
30
|
+
export function sfuComposePorts(slot) {
|
|
27
31
|
const { udpStart, udpEnd, tcp } = sfuSlotPorts(slot);
|
|
28
|
-
return `${udpStart}-${udpEnd}:${udpStart}-${udpEnd}/udp
|
|
32
|
+
return [`${udpStart}-${udpEnd}:${udpStart}-${udpEnd}/udp`, `${tcp}:${tcp}/tcp`];
|
|
33
|
+
}
|
|
34
|
+
/** The image the service runs: the pinned tag carries the platform's digest, any other declared tag goes as declared. */
|
|
35
|
+
export function sfuImageRef(version) {
|
|
36
|
+
return version === VERSIONS.livekit
|
|
37
|
+
? `${OWN_SFU_IMAGE}:${version}@${DIGESTS.livekit}`
|
|
38
|
+
: `${OWN_SFU_IMAGE}:${version}`;
|
|
29
39
|
}
|
|
30
40
|
/**
|
|
31
|
-
*
|
|
41
|
+
* The compose file of the media server. `serviceName` is the compose service
|
|
42
|
+
* key. Read from Coolify 4.4.2 (tag v4.4.2 source):
|
|
43
|
+
* - the container is named `<key>-<service uuid>` (`bootstrap/helpers/parsers.php:2711`);
|
|
44
|
+
* - with `connect_to_docker_network` set, `StartService` runs `docker network connect
|
|
45
|
+
* --alias <that name> <destination network> <that name>`
|
|
46
|
+
* (`app/Actions/Service/StartService.php:59-65`), so the tunnel on the destination
|
|
47
|
+
* network reaches port 7880 at that name;
|
|
48
|
+
* - the compose `ports` list is kept as written (`parsers.php`, `$payload['ports'] = $ports`),
|
|
49
|
+
* where an application refuses a port range and a protocol suffix in its mappings
|
|
50
|
+
* (`bootstrap/helpers/api.php:233`).
|
|
51
|
+
* The config reaches the container only as `${LIVEKIT_CONFIG}`, a service env.
|
|
52
|
+
*/
|
|
53
|
+
export function sfuCompose(input) {
|
|
54
|
+
return [
|
|
55
|
+
'services:',
|
|
56
|
+
` ${input.serviceName}:`,
|
|
57
|
+
` image: "${sfuImageRef(input.version)}"`,
|
|
58
|
+
' restart: unless-stopped',
|
|
59
|
+
' environment:',
|
|
60
|
+
` - "${CONFIG_ENV}=\${${CONFIG_ENV}}"`,
|
|
61
|
+
' ports:',
|
|
62
|
+
...sfuComposePorts(input.slot).map((p) => ` - "${p}"`),
|
|
63
|
+
' mem_limit: 1g',
|
|
64
|
+
' cpus: 1',
|
|
65
|
+
'',
|
|
66
|
+
].join('\n');
|
|
67
|
+
}
|
|
68
|
+
/**
|
|
69
|
+
* LiveKit takes its whole config from one env value. It ends without a
|
|
70
|
+
* newline: Coolify trims every stored env value (EnvironmentVariable.php:462),
|
|
71
|
+
* so a trailing one would never match what is read back, and every apply
|
|
72
|
+
* would restart the media server. `node_ip` is the
|
|
32
73
|
* server's address: the app is not on the host network, so the media ports
|
|
33
74
|
* reach it through the published mappings.
|
|
34
75
|
*/
|
|
@@ -44,7 +85,6 @@ export function sfuConfig(input) {
|
|
|
44
85
|
` node_ip: ${input.nodeIp}`,
|
|
45
86
|
'keys:',
|
|
46
87
|
` ${input.keys.apiKey}: ${input.keys.apiSecret}`,
|
|
47
|
-
'',
|
|
48
88
|
].join('\n');
|
|
49
89
|
}
|
|
50
90
|
function heldPairOf(value) {
|
|
@@ -71,11 +111,13 @@ function keysOf(config) {
|
|
|
71
111
|
}
|
|
72
112
|
const KEY_PAIR_SHAPE = /^[A-Za-z0-9]{8,128}$/;
|
|
73
113
|
/**
|
|
74
|
-
* One company's LiveKit media server as a Coolify
|
|
75
|
-
* ports published to the server, and a tunnel for signalling
|
|
76
|
-
*
|
|
77
|
-
* `
|
|
78
|
-
*
|
|
114
|
+
* One company's LiveKit media server as a Coolify service (a compose file): the
|
|
115
|
+
* image, its slot's ports published to the server, and a tunnel for signalling
|
|
116
|
+
* at `sfu.<domain>`. It is a service because an application cannot publish a
|
|
117
|
+
* UDP range (`coolify-service.ts`). The company's single key pair is minted
|
|
118
|
+
* once, delivered to `target.refs.livekitKeys` as JSON, and written to the
|
|
119
|
+
* service's env; it is never recorded. Rules 1 to 6 of `CLAUDE.md` "Platform
|
|
120
|
+
* steps". An application of the same name is never read or changed.
|
|
79
121
|
*/
|
|
80
122
|
export async function provisionSfuApp(scope, deps, target, input) {
|
|
81
123
|
const { coolify, network, deliverSecret, runtime } = deps;
|
|
@@ -92,47 +134,43 @@ export async function provisionSfuApp(scope, deps, target, input) {
|
|
|
92
134
|
throw new Error(`${label}: no usable Coolify server is registered`);
|
|
93
135
|
if (!server.ip)
|
|
94
136
|
throw new Error(`${label}: the Coolify server has no address to give LiveKit`);
|
|
95
|
-
const
|
|
96
|
-
|
|
137
|
+
const compose = sfuCompose({
|
|
138
|
+
serviceName: target.internal.sfu,
|
|
139
|
+
slot: input.slot,
|
|
140
|
+
version: input.version,
|
|
97
141
|
});
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
142
|
+
const existingService = await findComposeService(coolify, target.names.sfuApp, environment.id);
|
|
143
|
+
let serviceId;
|
|
144
|
+
if (existingService) {
|
|
145
|
+
serviceId = existingService;
|
|
146
|
+
// Set on every run: a declared version or slot reaches an existing service here.
|
|
147
|
+
await updateComposeService(coolify, serviceId, compose);
|
|
101
148
|
}
|
|
102
149
|
else {
|
|
103
|
-
|
|
150
|
+
const destination = await deps.destinationFor?.(server.uuid);
|
|
151
|
+
serviceId = await createComposeService(coolify, {
|
|
104
152
|
project_uuid: projectUuid,
|
|
105
153
|
server_uuid: server.uuid,
|
|
106
154
|
environment_name: environment.name,
|
|
107
155
|
environment_uuid: environment.uuid,
|
|
108
|
-
...
|
|
109
|
-
ports_exposes: String(LIVEKIT_SIGNALLING_PORT),
|
|
110
|
-
ports_mappings: sfuPortsMappings(input.slot),
|
|
156
|
+
...(destination ? { destination_uuid: destination } : {}),
|
|
111
157
|
name: target.names.sfuApp,
|
|
112
158
|
description: `LiveKit media server for ${target.id}`,
|
|
113
|
-
|
|
114
|
-
})
|
|
159
|
+
compose,
|
|
160
|
+
});
|
|
115
161
|
}
|
|
116
|
-
scope.recordScopeId(
|
|
162
|
+
scope.recordScopeId(OWN_SFU_SERVICE_SCOPE_KEY, serviceId);
|
|
117
163
|
const recorded = scope.recordedIds();
|
|
118
164
|
const changed = recorded[OWN_SFU_APPLIED_VERSION_SCOPE_KEY] !== input.version ||
|
|
119
165
|
recorded[OWN_SFU_APPLIED_SLOT_SCOPE_KEY] !== String(input.slot) ||
|
|
120
166
|
recorded[OWN_SFU_APPLIED_HOSTNAME_SCOPE_KEY] !== input.hostname;
|
|
121
|
-
|
|
122
|
-
await coolify.updateApplication(appId, {
|
|
123
|
-
custom_internal_name: target.internal.sfu,
|
|
124
|
-
...coolifyImageFor('livekit', OWN_SFU_IMAGE, 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;
|
|
167
|
+
const liveConfig = await composeServiceEnv(coolify, serviceId, CONFIG_ENV);
|
|
130
168
|
let keys;
|
|
131
169
|
let keysJustSet = false;
|
|
132
170
|
if (scope.recordedIds()[OWN_SFU_KEYS_SCOPE_KEY]) {
|
|
133
171
|
const held = keysOf(liveConfig);
|
|
134
172
|
if (!held) {
|
|
135
|
-
throw new Error(`${label}: the
|
|
173
|
+
throw new Error(`${label}: the service's ${CONFIG_ENV} no longer holds the company's key`);
|
|
136
174
|
}
|
|
137
175
|
keys = held;
|
|
138
176
|
deps.onSecret?.(keys.apiSecret);
|
|
@@ -156,7 +194,7 @@ export async function provisionSfuApp(scope, deps, target, input) {
|
|
|
156
194
|
throw new Error(`${label}: the minted LiveKit key has characters the config cannot hold`);
|
|
157
195
|
}
|
|
158
196
|
deps.onSecret?.(keys.apiSecret);
|
|
159
|
-
scope.recordScopeId(OWN_SFU_KEYS_UNDELIVERED_SCOPE_KEY,
|
|
197
|
+
scope.recordScopeId(OWN_SFU_KEYS_UNDELIVERED_SCOPE_KEY, serviceId);
|
|
160
198
|
await scope.persist();
|
|
161
199
|
let delivered = false;
|
|
162
200
|
const payload = JSON.stringify({ apiKey: keys.apiKey, apiSecret: keys.apiSecret });
|
|
@@ -184,21 +222,23 @@ export async function provisionSfuApp(scope, deps, target, input) {
|
|
|
184
222
|
await scope.persist();
|
|
185
223
|
}
|
|
186
224
|
if (envChanged) {
|
|
187
|
-
await coolify
|
|
225
|
+
await upsertComposeServiceEnv(coolify, serviceId, { key: CONFIG_ENV, value: config });
|
|
188
226
|
}
|
|
189
227
|
if (keysJustSet) {
|
|
190
|
-
scope.recordScopeId(OWN_SFU_KEYS_SCOPE_KEY,
|
|
228
|
+
scope.recordScopeId(OWN_SFU_KEYS_SCOPE_KEY, serviceId);
|
|
191
229
|
scope.recordScopeId(OWN_SFU_KEYS_UNDELIVERED_SCOPE_KEY, '');
|
|
192
230
|
await scope.persist();
|
|
193
231
|
}
|
|
194
|
-
const running = (await coolify
|
|
195
|
-
if (!
|
|
196
|
-
await
|
|
232
|
+
const running = composeServiceRuns(await composeServiceStatus(coolify, serviceId));
|
|
233
|
+
if (!existingService || pending || !running) {
|
|
234
|
+
await redeployComposeService(coolify, serviceId, runtime, label);
|
|
197
235
|
}
|
|
198
236
|
if (pending) {
|
|
199
237
|
scope.recordScopeId(OWN_SFU_DEPLOY_PENDING_SCOPE_KEY, '');
|
|
200
238
|
await scope.persist();
|
|
201
239
|
}
|
|
240
|
+
// The container's alias on the destination network: `<service key>-<service uuid>`.
|
|
241
|
+
const signalling = `http://${target.internal.sfu}-${serviceId}:${LIVEKIT_SIGNALLING_PORT}`;
|
|
202
242
|
const tunnels = await network.tunnels();
|
|
203
243
|
const tunnel = (await tunnels.findTunnelByName(target.names.sfuTunnel)) ??
|
|
204
244
|
(await tunnels.createTunnel(target.names.sfuTunnel));
|
|
@@ -207,7 +247,7 @@ export async function provisionSfuApp(scope, deps, target, input) {
|
|
|
207
247
|
ingress: [
|
|
208
248
|
{
|
|
209
249
|
hostname: input.hostname,
|
|
210
|
-
service:
|
|
250
|
+
service: signalling,
|
|
211
251
|
},
|
|
212
252
|
{ service: 'http_status:404' },
|
|
213
253
|
],
|
|
@@ -238,9 +278,9 @@ export async function provisionSfuApp(scope, deps, target, input) {
|
|
|
238
278
|
scope.recordScopeId(OWN_SFU_APPLIED_VERSION_SCOPE_KEY, input.version);
|
|
239
279
|
scope.recordScopeId(OWN_SFU_APPLIED_SLOT_SCOPE_KEY, String(input.slot));
|
|
240
280
|
scope.recordScopeId(OWN_SFU_APPLIED_HOSTNAME_SCOPE_KEY, input.hostname);
|
|
241
|
-
scope.recordScopeId(SFU_INTERNAL_URL_SCOPE_KEY,
|
|
281
|
+
scope.recordScopeId(SFU_INTERNAL_URL_SCOPE_KEY, signalling);
|
|
242
282
|
scope.recordScopeId(SFU_PUBLIC_URL_SCOPE_KEY, `https://${input.hostname}`);
|
|
243
|
-
return { resourceId:
|
|
283
|
+
return { resourceId: serviceId };
|
|
244
284
|
}
|
|
245
285
|
/**
|
|
246
286
|
* The addresses of the company's media server, from its recorded ids. Before
|