@wtfalch/foundry 0.39.4 → 0.39.6

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 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, sfuPortsMappings, } from './plan/index.js';
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, sfuPortsMappings, } from './plan/index.js';
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,66 @@
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`, then `PATCH` with
32
+ * `connect_to_docker_network`, which makes the container join the
33
+ * destination's network under the alias `<service key>-<service uuid>`
34
+ * (`StartService.php`), which the tunnel dials. The create cannot carry the
35
+ * flag: Coolify 4.4.2 checks its fields against the list for every create
36
+ * first, which lacks it (`ServicesController.php:391`), and answers 422.
37
+ * A PATCH that fails leaves the service without the flag; the next run finds
38
+ * it and `updateComposeService` sets it. Nothing starts: `instant_deploy` is false.
39
+ */
40
+ export declare function createComposeService(coolify: Raw, input: CreateComposeServiceInput): Promise<string>;
41
+ /** `PATCH /services/{uuid}`: the compose file and the network flag. Coolify parses the file again; nothing restarts. */
42
+ export declare function updateComposeService(coolify: Raw, uuid: string, compose: string): Promise<void>;
43
+ /** `GET /services/{uuid}`: its `status` is `running:healthy`, `exited:unhealthy`, ... (the model appends it). */
44
+ export declare function composeServiceStatus(coolify: Raw, uuid: string): Promise<string>;
45
+ /** Coolify refuses `start` of a service whose status contains "running" (`action_deploy`), so the same test decides. */
46
+ export declare const composeServiceRuns: (status: string) => boolean;
47
+ /** One env value of the service's production rows, or undefined. */
48
+ export declare function composeServiceEnv(coolify: Raw, uuid: string, key: string): Promise<string | undefined>;
49
+ /**
50
+ * `PATCH /services/{uuid}/envs/bulk`. `is_literal` and `is_multiline` both
51
+ * single-quote the value in the service's `.env` (`EnvironmentVariable.php`
52
+ * `real_value`), so `$` is never interpolated and a newline stays in it.
53
+ */
54
+ export declare function upsertComposeServiceEnv(coolify: Raw, uuid: string, env: {
55
+ key: string;
56
+ value: string;
57
+ }): Promise<void>;
58
+ /**
59
+ * Runs the service's current compose file and env. A service has no
60
+ * deployment record to wait on, so a running service is stopped first and
61
+ * the wait for "not running" comes before `start`: the old status can never
62
+ * be read as the new one (rule 5). Then `start` and the wait for "running".
63
+ * A service that is not running is only started.
64
+ */
65
+ export declare function redeployComposeService(coolify: Raw, uuid: string, runtime: DeployHealthRuntime, label: string): Promise<void>;
66
+ export {};
@@ -0,0 +1,126 @@
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`, then `PATCH` with
36
+ * `connect_to_docker_network`, which makes the container join the
37
+ * destination's network under the alias `<service key>-<service uuid>`
38
+ * (`StartService.php`), which the tunnel dials. The create cannot carry the
39
+ * flag: Coolify 4.4.2 checks its fields against the list for every create
40
+ * first, which lacks it (`ServicesController.php:391`), and answers 422.
41
+ * A PATCH that fails leaves the service without the flag; the next run finds
42
+ * it and `updateComposeService` sets it. Nothing starts: `instant_deploy` is false.
43
+ */
44
+ export async function createComposeService(coolify, input) {
45
+ const { compose, ...rest } = input;
46
+ const created = await call(coolify, `creating service ${input.name}`, 'POST', '/services', {
47
+ ...rest,
48
+ docker_compose_raw: Buffer.from(compose, 'utf8').toString('base64'),
49
+ instant_deploy: false,
50
+ });
51
+ const uuid = isRecord(created) ? created.uuid : undefined;
52
+ if (typeof uuid !== 'string' || uuid === '') {
53
+ throw new Error(`creating service ${input.name}: Coolify answered with no uuid`);
54
+ }
55
+ await call(coolify, `updating service ${uuid}`, 'PATCH', `/services/${uuid}`, {
56
+ connect_to_docker_network: true,
57
+ });
58
+ return uuid;
59
+ }
60
+ /** `PATCH /services/{uuid}`: the compose file and the network flag. Coolify parses the file again; nothing restarts. */
61
+ export async function updateComposeService(coolify, uuid, compose) {
62
+ await call(coolify, `updating service ${uuid}`, 'PATCH', `/services/${uuid}`, {
63
+ docker_compose_raw: Buffer.from(compose, 'utf8').toString('base64'),
64
+ connect_to_docker_network: true,
65
+ });
66
+ }
67
+ /** `GET /services/{uuid}`: its `status` is `running:healthy`, `exited:unhealthy`, ... (the model appends it). */
68
+ export async function composeServiceStatus(coolify, uuid) {
69
+ const row = await call(coolify, `reading service ${uuid}`, 'GET', `/services/${uuid}`);
70
+ const status = isRecord(row) ? row.status : undefined;
71
+ return typeof status === 'string' ? status : '';
72
+ }
73
+ /** Coolify refuses `start` of a service whose status contains "running" (`action_deploy`), so the same test decides. */
74
+ export const composeServiceRuns = (status) => status.includes('running');
75
+ /** One env value of the service's production rows, or undefined. */
76
+ export async function composeServiceEnv(coolify, uuid, key) {
77
+ const rows = await call(coolify, `reading the envs of service ${uuid}`, 'GET', `/services/${uuid}/envs`);
78
+ if (!Array.isArray(rows))
79
+ throw new Error(`reading the envs of service ${uuid}: no list`);
80
+ for (const row of rows) {
81
+ if (isRecord(row) && row.key === key && row.is_preview !== true) {
82
+ return typeof row.value === 'string' ? row.value : undefined;
83
+ }
84
+ }
85
+ return undefined;
86
+ }
87
+ /**
88
+ * `PATCH /services/{uuid}/envs/bulk`. `is_literal` and `is_multiline` both
89
+ * single-quote the value in the service's `.env` (`EnvironmentVariable.php`
90
+ * `real_value`), so `$` is never interpolated and a newline stays in it.
91
+ */
92
+ export async function upsertComposeServiceEnv(coolify, uuid, env) {
93
+ await call(coolify, `writing the envs of service ${uuid}`, 'PATCH', `/services/${uuid}/envs/bulk`, {
94
+ data: [{ key: env.key, value: env.value, is_literal: true, is_multiline: true }],
95
+ });
96
+ }
97
+ async function waitFor(coolify, uuid, want, runtime, label, what) {
98
+ const sleep = runtime.sleep ?? ((ms) => new Promise((r) => setTimeout(r, ms)));
99
+ const now = runtime.now ?? Date.now;
100
+ const timeout = runtime.readyTimeoutMs ?? SERVICE_WAIT_MS;
101
+ const interval = runtime.readyIntervalMs ?? SERVICE_POLL_INTERVAL_MS;
102
+ const deadline = now() + timeout;
103
+ for (;;) {
104
+ if (want(await composeServiceStatus(coolify, uuid)))
105
+ return;
106
+ if (now() >= deadline) {
107
+ throw new Error(`${label}: service ${uuid} did not ${what} within ${Math.round(timeout / 1000)}s. Apply again`);
108
+ }
109
+ await sleep(interval);
110
+ }
111
+ }
112
+ /**
113
+ * Runs the service's current compose file and env. A service has no
114
+ * deployment record to wait on, so a running service is stopped first and
115
+ * the wait for "not running" comes before `start`: the old status can never
116
+ * be read as the new one (rule 5). Then `start` and the wait for "running".
117
+ * A service that is not running is only started.
118
+ */
119
+ export async function redeployComposeService(coolify, uuid, runtime, label) {
120
+ if (composeServiceRuns(await composeServiceStatus(coolify, uuid))) {
121
+ await call(coolify, `stopping service ${uuid}`, 'POST', `/services/${uuid}/stop`);
122
+ await waitFor(coolify, uuid, (s) => !composeServiceRuns(s), runtime, label, 'stop');
123
+ }
124
+ await call(coolify, `starting service ${uuid}`, 'POST', `/services/${uuid}/start`);
125
+ await waitFor(coolify, uuid, (s) => composeServiceRuns(s) && !s.includes('unhealthy'), runtime, label, 'run');
126
+ }
@@ -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,
@@ -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, sfuConfig, sfuPortsMappings } from './own-sfu.js';
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';
@@ -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, sfuConfig, sfuPortsMappings } from './own-sfu.js';
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';
@@ -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 app ${undelivered} was minted but not confirmed delivered; remove the recorded key ${OWN_SFU_KEYS_UNDELIVERED_SCOPE_KEY} to mint a new one`);
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`);
@@ -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 { type DeployHealthRuntime } from './deploy-health.js';
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
- export declare const OWN_SFU_APP_SCOPE_KEY = "coolify_own_sfu_app";
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 app id before the key pair leaves, cleared after the pair is delivered and written to the app. */
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 app id once the company's key pair is delivered and in the app's env. */
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 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;
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. `node_ip` is the
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 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".
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;
@@ -1,18 +1,22 @@
1
- import { coolifyImageFor } from '../bootstrap/versions.js';
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 { waitForNewDeployment } from './deploy-health.js';
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
- export const OWN_SFU_APP_SCOPE_KEY = 'coolify_own_sfu_app';
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 app id before the key pair leaves, cleared after the pair is delivered and written to the app. */
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 app id once the company's key pair is delivered and in the app's env. */
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 Docker `ports_mappings` string for a slot: the UDP range and the TCP fallback port, one to one. */
26
- export function sfuPortsMappings(slot) {
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,${tcp}:${tcp}`;
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
- * LiveKit takes its whole config from one env value. `node_ip` is the
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 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".
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 existingApp = await coolify.findApplicationByName(target.names.sfuApp, {
96
- environmentIds: [environment.id],
137
+ const compose = sfuCompose({
138
+ serviceName: target.internal.sfu,
139
+ slot: input.slot,
140
+ version: input.version,
97
141
  });
98
- let appId;
99
- if (existingApp) {
100
- appId = existingApp.uuid;
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
- appId = (await coolify.createDockerImageApplication({
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
- ...coolifyImageFor('livekit', OWN_SFU_IMAGE, input.version),
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
- instant_deploy: false,
114
- })).uuid;
159
+ compose,
160
+ });
115
161
  }
116
- scope.recordScopeId(OWN_SFU_APP_SCOPE_KEY, appId);
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
- // 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
- ...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 app's ${CONFIG_ENV} no longer holds the company's key`);
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, appId);
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.bulkUpsertEnvs(appId, [{ key: CONFIG_ENV, value: config, literal: true }]);
225
+ await upsertComposeServiceEnv(coolify, serviceId, { key: CONFIG_ENV, value: config });
188
226
  }
189
227
  if (keysJustSet) {
190
- scope.recordScopeId(OWN_SFU_KEYS_SCOPE_KEY, appId);
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.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');
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: `http://${target.internal.sfu}:${LIVEKIT_SIGNALLING_PORT}`,
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, `http://${target.internal.sfu}:${LIVEKIT_SIGNALLING_PORT}`);
281
+ scope.recordScopeId(SFU_INTERNAL_URL_SCOPE_KEY, signalling);
242
282
  scope.recordScopeId(SFU_PUBLIC_URL_SCOPE_KEY, `https://${input.hostname}`);
243
- return { resourceId: appId };
283
+ return { resourceId: serviceId };
244
284
  }
245
285
  /**
246
286
  * The addresses of the company's media server, from its recorded ids. Before
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@wtfalch/foundry",
3
- "version": "0.39.4",
3
+ "version": "0.39.6",
4
4
  "description": "Foundry's SDK: the declared org's types, the plan and apply contracts, and the state store's interface.",
5
5
  "repository": {
6
6
  "type": "git",