#!/usr/bin/env node import path from "node:path"; import { fileURLToPath } from "node:url"; export const GROUPS = [ { name: "general", healthPort: "9101", adminPort: "9201", concurrencyKey: "WORKER_CONCURRENCY_GENERAL", concurrency: "10" }, { name: "mail", healthPort: "9102", adminPort: "9202", concurrencyKey: "WORKER_CONCURRENCY_MAIL", concurrency: "5" }, { name: "import", healthPort: "9103", adminPort: "9203", concurrencyKey: "WORKER_CONCURRENCY_IMPORT", concurrency: "1" }, { name: "webhook", healthPort: "9104", adminPort: "9204", concurrencyKey: "WORKER_CONCURRENCY_WEBHOOK", concurrency: "20" }, { name: "plugin", healthPort: "9105", adminPort: "9205", concurrencyKey: "WORKER_CONCURRENCY_PLUGIN", concurrency: "5" }, ]; const [, , environment = "staging"] = process.argv; const prefix = environment === "production" ? "COOLIFY" : "COOLIFY_STAGING"; const apiUrl = (process.env.SELECTED_COOLIFY_API_URL || process.env[`${prefix}_API_URL`] || "").replace(/\/+$/, ""); const token = process.env.SELECTED_COOLIFY_TOKEN || process.env[`${prefix}_TOKEN`] || ""; const imageTag = process.env.IMAGE_TAG || (environment === "production" ? "main" : "staging"); const registry = process.env.REGISTRY || ""; const imageNamespace = process.env.IMAGE_NAMESPACE || ""; const imagePrefix = process.env.IMAGE_PREFIX || ""; const workerImage = process.env.WORKER_IMAGE || [registry, imageNamespace, imagePrefix, "worker"].filter(Boolean).join("/"); const existingResourceUuids = process.env[`${prefix}_RESOURCE_UUIDS`] || ""; const resolvedEnvName = process.env.COOLIFY_RESOLVED_RESOURCE_UUIDS_ENV_NAME || (environment === "production" ? "PRODUCTION_RESOLVED_RESOURCE_UUIDS" : "STAGING_RESOLVED_RESOURCE_UUIDS"); const provisionEnabled = process.env[`${prefix}_PROVISION_WORKER_GROUPS`] === "true"; const configuredWorkerEnvSourceUuid = process.env[`${prefix}_WORKER_ENV_SOURCE_UUID`] || process.env.WORKER_ENV_SOURCE_UUID || ""; export const WORKER_ENV_OVERRIDE_KEYS = new Set([ "WORKER_ENABLED", "WORKER_QUEUE_GROUP", "WORKER_CONCURRENCY", "WORKER_HEALTH_PORT", "WORKER_ADMIN_PORT", "WORKER_CONCURRENCY_GENERAL", "WORKER_CONCURRENCY_MAIL", "WORKER_CONCURRENCY_IMPORT", "WORKER_CONCURRENCY_WEBHOOK", "WORKER_CONCURRENCY_PLUGIN", "MARKETING_SEQUENCES_ENABLED", "TRANSACTIONAL_MAIL_OUTBOX_REQUIRED", "OUTBOX_DISPATCHER_ENABLED", "OUTBOX_DISPATCHER_REQUIRED", "PLUGIN_HOOK_FANOUT_CONCURRENCY", "PLUGIN_HOOK_FANOUT_ENABLED", "PLUGIN_HOOK_FANOUT_REQUIRED", "PLUGIN_RUNTIME_PRODUCTION_ENABLED", "WORKFLOW_TRIGGER_EXECUTOR_MODE", "WORKFLOW_TRIGGER_OUTBOX_ENABLED", "WORKFLOW_TRIGGER_OUTBOX_CONCURRENCY", "WORKFLOW_TRIGGER_OUTBOX_REQUIRED", ]); function fail(message) { process.stderr.write(`${message}\n`); process.exit(1); } function requireEnv(name) { const value = process.env[name] || ""; if (!value) { fail(`${name} is required to provision missing Coolify worker applications`); } return value; } async function coolify(path, options = {}) { if (!apiUrl || !token) { fail("Coolify API URL and token are required"); } const response = await fetch(`${apiUrl}${path}`, { ...options, headers: { Authorization: `Bearer ${token}`, Accept: "application/json", ...(options.body ? { "Content-Type": "application/json" } : {}), ...options.headers, }, }); const text = await response.text(); let payload = {}; if (text.trim()) { try { payload = JSON.parse(text); } catch { payload = { message: text.trim() }; } } if (!response.ok) { fail(`Coolify API request failed with HTTP ${response.status}`); } return payload; } function asArray(payload) { if (Array.isArray(payload)) return payload; if (Array.isArray(payload?.data)) return payload.data; return []; } function appText(app) { return [ app.name, app.description, app.docker_registry_image_name, app.fqdn, app.domain, app.domains, JSON.stringify(app.environment_variables || app.envs || app.environment || ""), ] .filter(Boolean) .join(" "); } function matchesWorkerGroup(app, group) { if (app.docker_registry_image_name !== workerImage) return false; const text = appText(app); const envPattern = new RegExp(`WORKER_QUEUE_GROUP\\s*(?:=|:)\\s*["']?${group}\\b`, "i"); const namePattern = new RegExp(`(?:^|[\\s/_-])worker[\\s_-]*${group}(?:$|[\\s:_-])`, "i"); return envPattern.test(text) || namePattern.test(text); } function isGroupWorker(app) { return GROUPS.some((group) => matchesWorkerGroup(app, group.name)); } function resolveWorkerEnvSourceUuid(apps, allowed) { if (configuredWorkerEnvSourceUuid) { const app = apps.find((candidate) => candidate.uuid === configuredWorkerEnvSourceUuid); if (!app) { fail(`${prefix}_WORKER_ENV_SOURCE_UUID does not match an existing Coolify application`); } return configuredWorkerEnvSourceUuid; } const candidates = apps .filter((candidate) => candidate.docker_registry_image_name === workerImage) .filter((candidate) => allowed.size === 0 || allowed.has(candidate.uuid)) .filter((candidate) => !isGroupWorker(candidate)); candidates.sort((a, b) => { const aName = String(a.name || ""); const bName = String(b.name || ""); if (aName === "boilerplate-worker") return -1; if (bName === "boilerplate-worker") return 1; if (/worker/i.test(aName) && !/worker/i.test(bName)) return -1; if (/worker/i.test(bName) && !/worker/i.test(aName)) return 1; return aName.localeCompare(bName); }); return candidates[0]?.uuid || ""; } function envKey(entry) { return typeof entry?.key === "string" ? entry.key : ""; } function envValue(entry) { let value = typeof entry?.value === "string" ? entry.value : ""; while (value.length >= 2 && ((value.startsWith("'") && value.endsWith("'")) || (value.startsWith('"') && value.endsWith('"')))) { value = value.slice(1, -1); } return value; } export function desiredWorkerEnv(group) { const env = { WORKER_ENABLED: "true", WORKER_QUEUE_GROUP: group.name, WORKER_HEALTH_PORT: group.healthPort, WORKER_ADMIN_PORT: group.adminPort, [group.concurrencyKey]: group.concurrency, }; if (group.name === "general") { Object.assign(env, { OUTBOX_DISPATCHER_ENABLED: "true", OUTBOX_DISPATCHER_REQUIRED: "true", WORKFLOW_TRIGGER_EXECUTOR_MODE: "http", WORKFLOW_TRIGGER_OUTBOX_ENABLED: "true", WORKFLOW_TRIGGER_OUTBOX_CONCURRENCY: "5", WORKFLOW_TRIGGER_OUTBOX_REQUIRED: "true", }); } if (group.name === "mail") { Object.assign(env, { MARKETING_SEQUENCES_ENABLED: "false", TRANSACTIONAL_MAIL_OUTBOX_REQUIRED: "true", }); } return env; } export function envEntriesToRecord(entries) { const record = {}; for (const entry of entries || []) { const key = envKey(entry); if (!key) continue; record[key] = envValue(entry); } return record; } export function normalizeWorkflowTriggerExecutorMode(value) { const normalized = typeof value === "string" ? value.trim().toLowerCase() : ""; return normalized === "http" || normalized === "hybrid" || normalized === "native" ? normalized : ""; } export function getWorkerEnvDrift(app, group, entries) { const actual = envEntriesToRecord(entries); const exactDrift = Object.entries(desiredWorkerEnv(group)) .filter(([key, expected]) => { if (key === "WORKFLOW_TRIGGER_EXECUTOR_MODE") { return !normalizeWorkflowTriggerExecutorMode(actual[key]); } return actual[key] !== expected; }) .map(([key, expected]) => ({ key, expected: key === "WORKFLOW_TRIGGER_EXECUTOR_MODE" ? "http|hybrid|native" : expected, actual: actual[key] ?? "", appUuid: app?.uuid ?? "", appName: app?.name ?? `worker-${group.name}`, group: group.name, })); const sharedRuntimeDrift = getSharedWorkerRuntimeDrift(app, group, actual); const groupRuntimeDrift = group.name === "general" ? getGeneralWorkerRuntimeDrift(app, group, actual) : []; return [...exactDrift, ...sharedRuntimeDrift, ...groupRuntimeDrift]; } export function getWorkerAppConfigDrift(app, group) { const appName = app?.name ?? `worker-${group.name}`; const appUuid = app?.uuid ?? ""; const item = (key, expected, actual) => ({ key, expected, actual, appUuid, appName, group: group.name, }); const expected = { ports_exposes: group.healthPort, health_check_enabled: "true", health_check_path: "/readyz", health_check_port: group.healthPort, health_check_host: "127.0.0.1", }; const actual = { ports_exposes: normalizeCoolifyConfigValue(app?.ports_exposes), health_check_enabled: normalizeCoolifyBoolean(app?.health_check_enabled), health_check_path: normalizeCoolifyConfigValue(app?.health_check_path), health_check_port: normalizeCoolifyConfigValue(app?.health_check_port), health_check_host: normalizeCoolifyConfigValue(app?.health_check_host), }; return Object.entries(expected) .filter(([key, expectedValue]) => actual[key] !== expectedValue) .map(([key, expectedValue]) => item(key, expectedValue, actual[key] || "")); } function normalizeCoolifyBoolean(value) { return value === true || value === "true" || value === 1 || value === "1" ? "true" : value === false || value === "false" || value === 0 || value === "0" ? "false" : ""; } function normalizeCoolifyConfigValue(value) { if (value === null || value === undefined) return ""; return String(value).trim(); } function hasEnvValue(actual, key) { return typeof actual[key] === "string" && actual[key].trim().length > 0; } function getSharedWorkerRuntimeDrift(app, group, actual) { const drift = []; const item = (key, expected, actualValue) => ({ key, expected, actual: actualValue, appUuid: app?.uuid ?? "", appName: app?.name ?? `worker-${group.name}`, group: group.name, }); if (!hasEnvValue(actual, "DATABASE_URL")) { drift.push(item("DATABASE_URL", "configured DATABASE_URL", "")); } if (!hasEnvValue(actual, "BULLMQ_REDIS_URL")) { drift.push(item("BULLMQ_REDIS_URL", "configured BULLMQ_REDIS_URL", actual.REDIS_URL ? "" : "")); } return drift; } function getGeneralWorkerRuntimeDrift(app, group, actual) { const drift = []; const item = (key, expected, actualValue) => ({ key, expected, actual: actualValue, appUuid: app?.uuid ?? "", appName: app?.name ?? `worker-${group.name}`, group: group.name, }); const mode = normalizeWorkflowTriggerExecutorMode(actual.WORKFLOW_TRIGGER_EXECUTOR_MODE); if (mode === "http" || mode === "hybrid") { if (!hasEnvValue(actual, "WORKFLOW_TRIGGER_EXECUTOR_URL") && !hasEnvValue(actual, "APP_URL") && !hasEnvValue(actual, "WEB_ORIGIN")) { drift.push(item("WORKFLOW_TRIGGER_EXECUTOR_URL", "configured WORKFLOW_TRIGGER_EXECUTOR_URL, APP_URL, or WEB_ORIGIN", "")); } if (!hasEnvValue(actual, "API_ACCESS_TOKEN")) { drift.push(item("API_ACCESS_TOKEN", "configured API_ACCESS_TOKEN matching the web runtime", "")); } else if (hasEnvValue(actual, "WORKFLOW_TRIGGER_EXECUTOR_TOKEN")) { drift.push(item("WORKFLOW_TRIGGER_EXECUTOR_TOKEN", "unset; API_ACCESS_TOKEN is the supported workflow bridge credential", "")); } } if (actual.PLUGIN_RUNTIME_PRODUCTION_ENABLED === "true") { if (actual.PLUGIN_HOOK_FANOUT_ENABLED === "false") { drift.push(item("PLUGIN_HOOK_FANOUT_ENABLED", "unset or true when PLUGIN_RUNTIME_PRODUCTION_ENABLED=true", "false")); } if (actual.PLUGIN_HOOK_FANOUT_REQUIRED !== "true") { drift.push(item("PLUGIN_HOOK_FANOUT_REQUIRED", "true when PLUGIN_RUNTIME_PRODUCTION_ENABLED=true", actual.PLUGIN_HOOK_FANOUT_REQUIRED ?? "")); } } return drift; } async function cloneWorkerEnv(sourceUuid, targetUuid) { if (!sourceUuid) return; const [sourceEnvs, targetEnvs] = await Promise.all([ coolify(`/applications/${encodeURIComponent(sourceUuid)}/envs`).then(asArray), coolify(`/applications/${encodeURIComponent(targetUuid)}/envs`).then(asArray), ]); const targetKeys = new Set(targetEnvs.map(envKey).filter(Boolean)); for (const entry of sourceEnvs) { const key = envKey(entry); if (!key || targetKeys.has(key) || WORKER_ENV_OVERRIDE_KEYS.has(key)) continue; await coolify(`/applications/${encodeURIComponent(targetUuid)}/envs`, { method: "POST", body: JSON.stringify({ key, value: envValue(entry), is_literal: entry.is_literal ?? true, is_preview: entry.is_preview ?? false, is_multiline: entry.is_multiline ?? false, is_shown_once: entry.is_shown_once ?? false, }), }); targetKeys.add(key); } } async function createWorkerApplication(group, workerEnvSourceUuid) { if (!provisionEnabled) { fail(`Missing Coolify worker-${group.name} application. Set ${prefix}_PROVISION_WORKER_GROUPS=true with project/server/destination variables to create it automatically.`); } const projectUuid = requireEnv(`${prefix}_PROJECT_UUID`); const serverUuid = requireEnv(`${prefix}_SERVER_UUID`); const destinationUuid = requireEnv(`${prefix}_DESTINATION_UUID`); const environmentUuid = process.env[`${prefix}_ENVIRONMENT_UUID`] || ""; const environmentName = process.env[`${prefix}_ENVIRONMENT_NAME`] || environment; const body = buildWorkerApplicationBody({ group, projectUuid, serverUuid, destinationUuid, environmentName, environmentUuid, workerImage, imageTag, }); const created = await coolify("/applications/dockerimage", { method: "POST", body: JSON.stringify(body), }); const uuid = typeof created.uuid === "string" ? created.uuid : ""; if (!uuid) { fail(`Coolify did not return a UUID for worker-${group.name}`); } await cloneWorkerEnv(workerEnvSourceUuid, uuid); await ensureWorkerEnv(uuid, group); process.stdout.write(`Created Coolify worker-${group.name} application\n`); return uuid; } export function buildWorkerApplicationBody({ group, projectUuid, serverUuid, destinationUuid, environmentName, environmentUuid = "", workerImage, imageTag }) { const body = { project_uuid: projectUuid, server_uuid: serverUuid, environment_name: environmentName, docker_registry_image_name: workerImage, docker_registry_image_tag: imageTag, destination_uuid: destinationUuid, name: `worker-${group.name}`, description: `Managed by deploy workflow. WORKER_QUEUE_GROUP=${group.name}`, ports_exposes: group.healthPort, health_check_enabled: true, health_check_path: "/readyz", health_check_port: group.healthPort, health_check_host: "127.0.0.1", health_check_method: "GET", health_check_return_code: 200, health_check_scheme: "http", health_check_interval: 30, health_check_timeout: 5, health_check_retries: 3, health_check_start_period: 20, instant_deploy: false, connect_to_docker_network: true, }; if (environmentUuid) { body.environment_uuid = environmentUuid; } return body; } async function ensureWorkerEnv(uuid, group) { for (const [key, value] of Object.entries(desiredWorkerEnv(group))) { await coolify(`/applications/${encodeURIComponent(uuid)}/envs`, { method: "POST", body: JSON.stringify({ key, value, is_literal: true, is_preview: false, is_multiline: false, is_shown_once: false, }), }); } } async function assertWorkerEnvMatches(uuid, app, group) { const envs = asArray(await coolify(`/applications/${encodeURIComponent(uuid)}/envs`)); const drift = [...getWorkerAppConfigDrift(app, group), ...getWorkerEnvDrift(app, group, envs)]; if (drift.length > 0) { const summary = drift.map((item) => `${item.key}: expected ${item.expected}, got ${item.actual}`).join("; "); fail(`Coolify worker-${group.name} drift detected for ${app?.name || uuid}: ${summary}`); } } async function main() { if (!["staging", "production"].includes(environment)) { fail("Usage: coolify-ensure-worker-groups.mjs staging|production"); } if (!workerImage || workerImage.split("/").length < 4) { fail("Worker image path could not be resolved from REGISTRY, IMAGE_NAMESPACE, IMAGE_PREFIX, and worker target"); } const allowed = new Set(existingResourceUuids.split(",").filter(Boolean)); const apps = await coolify("/applications"); const workerEnvSourceUuid = resolveWorkerEnvSourceUuid(apps, allowed); const resolvedWorkers = []; for (const group of GROUPS) { let app = apps.find((candidate) => matchesWorkerGroup(candidate, group.name) && (allowed.size === 0 || allowed.has(candidate.uuid))); if (!app && provisionEnabled) { const uuid = await createWorkerApplication(group, workerEnvSourceUuid); // Re-fetch the freshly created application so config drift checks // (ports_exposes/health_check_*) validate the persisted resource, not a // bare { uuid } stub that always reports those fields as . const created = await coolify(`/applications/${encodeURIComponent(uuid)}`); app = created && created.uuid ? created : { uuid }; } if (!app?.uuid) { fail(`Missing Coolify worker-${group.name} application`); } await assertWorkerEnvMatches(app.uuid, app, group); resolvedWorkers.push(app.uuid); } const resolved = [...allowed, ...resolvedWorkers].filter(Boolean).join(","); if (process.env.GITHUB_ENV) { const fs = await import("node:fs"); fs.appendFileSync(process.env.GITHUB_ENV, `${resolvedEnvName}=${resolved}\n`); } process.stdout.write(`Coolify ${environment} worker group applications are present\n`); } const invokedPath = process.argv[1] ? path.resolve(process.argv[1]) : ""; if (invokedPath && fileURLToPath(import.meta.url) === invokedPath) { await main(); }