#!/usr/bin/env node const [, , command, ...args] = process.argv; const apiUrl = (process.env.SELECTED_COOLIFY_API_URL || process.env.COOLIFY_STAGING_API_URL || process.env.COOLIFY_API_URL || "").replace(/\/+$/, ""); const token = process.env.SELECTED_COOLIFY_TOKEN || process.env.COOLIFY_STAGING_TOKEN || process.env.COOLIFY_TOKEN || ""; // Poll faster without reducing the existing 450-second deployment failure budget. const delayMs = Number(process.env.COOLIFY_DEPLOYMENT_POLL_MS || "2000"); const attempts = Number(process.env.COOLIFY_DEPLOYMENT_WAIT_ATTEMPTS || "225"); const apiTimeoutMs = Number(process.env.COOLIFY_API_TIMEOUT_MS || "20000"); const FULL_GIT_SHA_PATTERN = /^[0-9a-f]{40}$/; const OCI_DIGEST_PATTERN = /^sha256:[0-9a-f]{64}$/; const WORKER_GROUP_NAMES = Object.freeze(["general", "mail", "import", "webhook", "plugin"]); const WORKER_GROUPS = new Set(WORKER_GROUP_NAMES); const RUNTIME_DATABASE_ROLES = new Set(["hivegrid_app", "hivegrid_worker", "hivegrid_portal"]); const RUNTIME_DATABASE_SURFACES = new Set(["web", "admin", "portal", "service"]); const WEB_SURFACE_IDENTITIES = Object.freeze({ core: "hivegrid-web", admin: "hivegrid-admin", portal: "hivegrid-portal", }); const PORTAL_DOMAIN_CONTROLLER_IDENTITY = "hivegrid-portal-domain-controller"; const AUTHENTIK_ADMIN_ENV_KEYS = Object.freeze([ "AUTHENTIK_ISSUER_URL", "AUTHENTIK_ADMIN_CLIENT_ID", "AUTHENTIK_ADMIN_CLIENT_SECRET", "AUTHENTIK_ADMIN_REDIRECT_URI", "AUTHENTIK_ADMIN_JIT_DATABASE_URL", ]); const CORE_PUBLIC_HOST_ENV_KEYS = Object.freeze(["APP_BASE_DOMAIN", "WEB_APP_HOST", "ADMIN_APP_HOST", "API_HOST"]); const WEB_PUSH_VAPID_WORKER_ENV_KEYS = Object.freeze(["WEB_PUSH_VAPID_SUBJECT", "WEB_PUSH_VAPID_PUBLIC_KEY", "WEB_PUSH_VAPID_PRIVATE_KEY"]); const GDPR_EXPORT_STORAGE_ENV_KEYS = Object.freeze([ "STORAGE_ENDPOINT", "STORAGE_REGION", "STORAGE_BUCKET", "STORAGE_ACCESS_KEY_ID", "STORAGE_SECRET_ACCESS_KEY", "GDPR_EXPORT_SSE_C_KEY_BASE64", ]); const GDPR_EXPORT_RETENTION_STORAGE_ENV_KEYS = Object.freeze(GDPR_EXPORT_STORAGE_ENV_KEYS.filter((key) => key !== "GDPR_EXPORT_SSE_C_KEY_BASE64")); const SAFE_BASE64URL_PATTERN = /^[A-Za-z0-9_-]+$/; const CANONICAL_BASE64_32_BYTES_PATTERN = /^[A-Za-z0-9+/]{43}=$/; const COOLIFY_CUID_PATTERN = /^[a-z][a-z0-9]{23}$/; function fail(message) { throw new Error(message); } async function coolify(path, options = {}) { if (!apiUrl || !token) { fail("Coolify API URL and token are required"); } if (!Number.isInteger(apiTimeoutMs) || apiTimeoutMs < 10 || apiTimeoutMs > 120_000) { fail("COOLIFY_API_TIMEOUT_MS must be an integer between 10 and 120000"); } let response; try { response = await fetch(`${apiUrl}${path}`, { ...options, signal: options.signal ?? AbortSignal.timeout(apiTimeoutMs), headers: { Authorization: `Bearer ${token}`, Accept: "application/json", ...(options.body ? { "Content-Type": "application/json" } : {}), ...options.headers, }, }); } catch { fail("Coolify API request failed or timed out"); } 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 deploymentStatus(payload) { return String(payload.status || payload.deployment?.status || payload.data?.status || "unknown").toLowerCase(); } function deploymentUuid(payload, resourceUuid = "") { if (!Array.isArray(payload.deployments)) return ""; const queuedDeployment = resourceUuid ? payload.deployments.find((deployment) => deployment?.resource_uuid === resourceUuid) : payload.deployments[0]; const value = queuedDeployment?.deployment_uuid || ""; return typeof value === "string" && COOLIFY_CUID_PATTERN.test(value) ? value : ""; } function requireCoolifyCuid(value, label) { if (!COOLIFY_CUID_PATTERN.test(value)) { fail(`${label} must be a valid Coolify CUID`); } } function applicationEnvsPath(resourceUuid) { return `/applications/${encodeURIComponent(resourceUuid)}/envs`; } async function readApplicationEnvs(resourceUuid) { const path = applicationEnvsPath(resourceUuid); try { return await coolify(path); } catch (error) { if (!(error instanceof Error) || error.message !== "Coolify API request failed with HTTP 404") { throw error; } // Coolify 4.0 exposes this read route only when the decrypt query is explicit. return coolify(`${path}?decrypt=true`); } } function envEntries(payload) { if (Array.isArray(payload)) return payload; if (Array.isArray(payload?.data)) return payload.data; fail("Coolify environment response was malformed"); } function runtimeEnvEntries(payload) { const runtime = []; const keys = new Set(); for (const entry of envEntries(payload)) { if (normalizeCoolifyBoolean(entry?.is_preview) === "true") continue; const key = envKey(entry); if (!key) continue; if (keys.has(key)) { fail("Coolify runtime environment response contained duplicate non-preview keys"); } keys.add(key); runtime.push(entry); } return runtime; } function applicationEntries(payload) { if (Array.isArray(payload)) return payload; if (Array.isArray(payload?.data)) return payload.data; fail("Coolify applications response was malformed"); } function allowedResourceSet(allowedCsv) { const allowedValues = allowedCsv .split(",") .map((value) => value.trim()) .filter(Boolean); if (allowedValues.length === 0) { fail("Coolify inventory validation requires allowed resources"); } const allowed = new Set(allowedValues); if (allowed.size !== allowedValues.length) { fail("Coolify inventory allowlist must not contain duplicate resources"); } for (const uuid of allowed) { requireCoolifyCuid(uuid, "Coolify inventory allowlist entry"); } return allowed; } function normalizedImage(value) { return normalizeCoolifyValue(value).replace(/\/+$/, ""); } function isPositiveNumericLimit(value) { const normalized = normalizeCoolifyValue(value); if (!/^(?:\d+(?:\.\d*)?|\.\d+)$/.test(normalized)) return false; const numeric = Number(normalized); return Number.isFinite(numeric) && numeric > 0; } function hasPositiveMemoryLimit(value) { const normalized = normalizeCoolifyValue(value); const match = /^(\d+)[bkmg]$/i.exec(normalized); if (!match) return false; return BigInt(match[1]) > 0n; } function isPositiveInteger(value) { const normalized = normalizeCoolifyValue(value); return /^[1-9]\d*$/.test(normalized) && BigInt(normalized) > 0n; } function normalizeHttpsConfiguredOrigin(value) { const normalized = normalizeCoolifyValue(value); if (!normalized) return ""; try { const withProtocol = /^[a-z][a-z\d+\-.]*:\/\//i.test(normalized) ? normalized : `https://${normalized}`; const url = new URL(withProtocol); return url.protocol === "https:" ? url.origin.toLowerCase() : ""; } catch { return ""; } } function assertWebSurfaceResourceLimits(app) { if (!isPositiveNumericLimit(app?.limits_cpus) || !hasPositiveMemoryLimit(app?.limits_memory)) { fail("Each Coolify web surface must set positive limits_cpus and unit-suffixed limits_memory hard caps"); } } function assertRuntimeResourceLimits(app, label) { if (!isPositiveNumericLimit(app?.limits_cpus) || !hasPositiveMemoryLimit(app?.limits_memory)) { fail(`Coolify ${label} must set positive limits_cpus and unit-suffixed limits_memory hard caps`); } } function selectWebSurfacePair(apps, allowed, expectedImage) { const image = normalizedImage(expectedImage); if (!image) fail("Coolify web surface image is required"); const allowedWebImageApps = apps.filter((app) => allowed.has(app?.uuid) && normalizedImage(app?.docker_registry_image_name) === image); const coreMatches = allowedWebImageApps.filter((app) => normalizeCoolifyValue(app?.name) === WEB_SURFACE_IDENTITIES.core); const adminMatches = allowedWebImageApps.filter((app) => normalizeCoolifyValue(app?.name) === WEB_SURFACE_IDENTITIES.admin); const portalMatches = allowedWebImageApps.filter((app) => normalizeCoolifyValue(app?.name) === WEB_SURFACE_IDENTITIES.portal); if (allowedWebImageApps.length !== 3 || coreMatches.length !== 1 || adminMatches.length !== 1 || portalMatches.length !== 1) { fail("Coolify must contain exactly the explicit hivegrid-web, hivegrid-admin, and hivegrid-portal resources for the web image"); } const core = coreMatches[0]; const admin = adminMatches[0]; const portal = portalMatches[0]; requireCoolifyCuid(core?.uuid || "", "hivegrid-web resource UUID"); requireCoolifyCuid(admin?.uuid || "", "hivegrid-admin resource UUID"); requireCoolifyCuid(portal?.uuid || "", "hivegrid-portal resource UUID"); if (new Set([core.uuid, admin.uuid, portal.uuid]).size !== 3) { fail("hivegrid-web, hivegrid-admin, and hivegrid-portal must use distinct Coolify resources"); } if ([core, admin, portal].some((app) => normalizeCoolifyValue(app?.build_pack) !== "dockerimage")) { fail("hivegrid-web, hivegrid-admin, and hivegrid-portal must use Coolify dockerimage mode for OCI digest pinning"); } assertWebSurfaceResourceLimits(core); assertWebSurfaceResourceLimits(admin); assertWebSurfaceResourceLimits(portal); return { core, admin, portal, image }; } function assertWebSurfacePairByUuid(core, admin, portal, coreResourceUuid, adminResourceUuid, portalResourceUuid, expectedImage) { if (new Set([coreResourceUuid, adminResourceUuid, portalResourceUuid]).size !== 3) { fail("hivegrid-web, hivegrid-admin, and hivegrid-portal must use distinct Coolify resources"); } const expected = [ [core, coreResourceUuid, WEB_SURFACE_IDENTITIES.core], [admin, adminResourceUuid, WEB_SURFACE_IDENTITIES.admin], [portal, portalResourceUuid, WEB_SURFACE_IDENTITIES.portal], ]; for (const [app, resourceUuid, name] of expected) { assertWebSurfaceResourceLimits(app); if ( app?.uuid !== resourceUuid || normalizeCoolifyValue(app?.name) !== name || normalizedImage(app?.docker_registry_image_name) !== expectedImage || normalizeCoolifyValue(app?.build_pack) !== "dockerimage" ) { fail("Coolify web surface release target identity does not match its explicit name, UUID, and image"); } } } async function resolveWebSurfaces(allowedCsv, expectedImage) { const allowed = allowedResourceSet(allowedCsv); const pair = selectWebSurfacePair(applicationEntries(await coolify("/applications")), allowed, expectedImage); const runtimeEnvs = await Promise.all([pair.core, pair.admin, pair.portal].map((app) => readApplicationEnvs(app.uuid).then(runtimeEnvEntries))); if (runtimeEnvs.some((envs) => envs.some((entry) => envKey(entry) === "APP_REVISION"))) { fail("Coolify web surface APP_REVISION overrides must be removed before deployment"); } process.stdout.write(`${pair.core.uuid},${pair.admin.uuid},${pair.portal.uuid}`); } function coolifyDigestTag(digest) { if (!OCI_DIGEST_PATTERN.test(digest)) { fail("Coolify web surface release requires a sha256 registry digest"); } // Coolify's dockerimage build pack treats sha256- as an OCI digest // reference (image@sha256:) instead of a mutable Docker tag. return digest.replace(":", "-"); } function webSurfaceReleasePayload(digest) { return { pre_deployment_command: "", post_deployment_command: "", docker_registry_image_tag: coolifyDigestTag(digest), }; } function previousWebSurfaceReleasePayload(app) { return { pre_deployment_command: normalizeCoolifyValue(app?.pre_deployment_command), post_deployment_command: normalizeCoolifyValue(app?.post_deployment_command), docker_registry_image_tag: normalizeCoolifyValue(app?.docker_registry_image_tag), }; } async function restoreWebSurfaceReleases(resources) { let failed = false; for (const { path, previous } of [...resources].reverse()) { try { await coolify(path, { method: "PATCH", body: JSON.stringify(previous) }); } catch { failed = true; } } return !failed; } async function setWebSurfaceRelease(coreResourceUuid, adminResourceUuid, portalResourceUuid, expectedImage, revision, digest) { requireCoolifyCuid(coreResourceUuid, "hivegrid-web resource UUID"); requireCoolifyCuid(adminResourceUuid, "hivegrid-admin resource UUID"); requireCoolifyCuid(portalResourceUuid, "hivegrid-portal resource UUID"); if (new Set([coreResourceUuid, adminResourceUuid, portalResourceUuid]).size !== 3) { fail("hivegrid-web, hivegrid-admin, and hivegrid-portal must use distinct Coolify resources"); } const image = normalizedImage(expectedImage); if (!image) fail("Coolify web surface image is required"); if (!FULL_GIT_SHA_PATTERN.test(revision)) { fail("Coolify web surface release requires a full lowercase Git SHA"); } if (!OCI_DIGEST_PATTERN.test(digest)) { fail("Coolify web surface release requires a sha256 registry digest"); } const resources = [ { uuid: coreResourceUuid, name: WEB_SURFACE_IDENTITIES.core }, { uuid: adminResourceUuid, name: WEB_SURFACE_IDENTITIES.admin }, { uuid: portalResourceUuid, name: WEB_SURFACE_IDENTITIES.portal }, ].map((resource) => ({ ...resource, path: `/applications/${encodeURIComponent(resource.uuid)}` })); const current = await Promise.all(resources.map((resource) => coolify(resource.path))); assertWebSurfacePairByUuid(current[0], current[1], current[2], coreResourceUuid, adminResourceUuid, portalResourceUuid, image); const mutations = resources.map((resource, index) => ({ ...resource, previous: previousWebSurfaceReleasePayload(current[index]), })); const patched = []; try { for (const resource of mutations) { await coolify(resource.path, { method: "PATCH", body: JSON.stringify(webSurfaceReleasePayload(digest)) }); patched.push(resource); } const updated = await Promise.all(resources.map((resource) => coolify(resource.path))); assertWebSurfacePairByUuid(updated[0], updated[1], updated[2], coreResourceUuid, adminResourceUuid, portalResourceUuid, image); const digestTag = coolifyDigestTag(digest); if (updated.some((app) => normalizeCoolifyValue(app?.docker_registry_image_tag) !== digestTag)) { fail("Coolify web surface release digest was not persisted for all three resources"); } } catch { const restored = await restoreWebSurfaceReleases(patched); fail( restored ? "Coolify web surface pair mutation failed and previous resource configuration was restored" : "Coolify web surface pair mutation failed and compensation was incomplete" ); } process.stdout.write(`Web surface trio configured for ${image}:${revision} as ${image}@${digest}\n`); } function assertCompletedWebSurfaceDeployment(payload, deploymentUuidValue, app, expectedName) { if (normalizeCoolifyValue(payload?.deployment_uuid) !== deploymentUuidValue) { fail("Coolify web surface deployment identity does not match the triggered deployment"); } if (!["finished", "success", "succeeded", "completed", "done"].includes(deploymentStatus(payload))) { fail("Coolify web surface deployment is not completed"); } if (normalizeCoolifyValue(payload?.application_name) !== expectedName) { fail("Coolify web surface deployment does not belong to the expected named application"); } const applicationId = normalizeCoolifyValue(app?.id); const deploymentApplicationId = normalizeCoolifyValue(payload?.application_id); if (applicationId && deploymentApplicationId && applicationId !== deploymentApplicationId) { fail("Coolify web surface deployment application identity does not match the live resource"); } } async function assertWebSurfaceRelease( coreResourceUuid, adminResourceUuid, portalResourceUuid, expectedImage, revision, digest, coreDeploymentUuid, adminDeploymentUuid, portalDeploymentUuid ) { requireCoolifyCuid(coreResourceUuid, "hivegrid-web resource UUID"); requireCoolifyCuid(adminResourceUuid, "hivegrid-admin resource UUID"); requireCoolifyCuid(portalResourceUuid, "hivegrid-portal resource UUID"); requireCoolifyCuid(coreDeploymentUuid, "hivegrid-web deployment UUID"); requireCoolifyCuid(adminDeploymentUuid, "hivegrid-admin deployment UUID"); requireCoolifyCuid(portalDeploymentUuid, "hivegrid-portal deployment UUID"); if ( new Set([coreResourceUuid, adminResourceUuid, portalResourceUuid]).size !== 3 || new Set([coreDeploymentUuid, adminDeploymentUuid, portalDeploymentUuid]).size !== 3 ) { fail("hivegrid-web, hivegrid-admin, and hivegrid-portal require distinct resources and deployments"); } const image = normalizedImage(expectedImage); if (!image) fail("Coolify web surface image is required"); if (!FULL_GIT_SHA_PATTERN.test(revision)) { fail("Coolify web surface release proof requires a full lowercase Git SHA"); } const digestTag = coolifyDigestTag(digest); const resources = [ { uuid: coreResourceUuid, deploymentUuid: coreDeploymentUuid, name: WEB_SURFACE_IDENTITIES.core }, { uuid: adminResourceUuid, deploymentUuid: adminDeploymentUuid, name: WEB_SURFACE_IDENTITIES.admin }, { uuid: portalResourceUuid, deploymentUuid: portalDeploymentUuid, name: WEB_SURFACE_IDENTITIES.portal }, ]; const applications = await Promise.all(resources.map((resource) => coolify(`/applications/${encodeURIComponent(resource.uuid)}`))); assertWebSurfacePairByUuid(applications[0], applications[1], applications[2], coreResourceUuid, adminResourceUuid, portalResourceUuid, image); for (const app of applications) { if (normalizeCoolifyValue(app?.docker_registry_image_tag) !== digestTag) { fail("Coolify web surface is not pinned to the expected registry digest"); } if (normalizeCoolifyValue(app?.status).toLowerCase() !== "running:healthy") { fail("Coolify web surface is not running healthy at the expected registry digest"); } } const deployments = await Promise.all(resources.map((resource) => coolify(`/deployments/${encodeURIComponent(resource.deploymentUuid)}`))); for (let index = 0; index < resources.length; index += 1) { assertCompletedWebSurfaceDeployment(deployments[index], resources[index].deploymentUuid, applications[index], resources[index].name); } process.stdout.write(`Web surface trio verified for ${image}:${revision} at ${image}@${digest}\n`); } async function validateRequiredImages(allowedCsv, expectedImages) { if (expectedImages.length === 0) { fail("Coolify inventory validation requires allowed resources and expected images"); } const allowed = allowedResourceSet(allowedCsv); const normalizedImages = expectedImages.map(normalizedImage); if (normalizedImages.some((image) => !image) || new Set(normalizedImages).size !== normalizedImages.length) { fail("Coolify inventory expected images must be non-empty and unique"); } const apps = applicationEntries(await coolify("/applications")); const requiredApps = []; for (const expectedImage of normalizedImages) { const matches = apps.filter((app) => allowed.has(app?.uuid) && normalizedImage(app?.docker_registry_image_name) === expectedImage); if (matches.length !== 1 || !COOLIFY_CUID_PATTERN.test(matches[0]?.uuid || "")) { fail("Coolify required application inventory is missing, ambiguous, or invalid"); } requiredApps.push(matches[0]); } const requiredEnvs = await Promise.all(requiredApps.map((app) => readApplicationEnvs(app.uuid).then(runtimeEnvEntries))); if (requiredEnvs.some((envs) => envs.some((entry) => envKey(entry) === "APP_REVISION"))) { fail("Coolify core application APP_REVISION overrides must be removed before deployment"); } process.stdout.write("Required Coolify image inventory validated\n"); } function selectUniqueAllowedImageApplication(apps, allowed, expectedImage, label) { const image = normalizedImage(expectedImage); if (!image) fail(`${label} image is required`); const matches = apps.filter((app) => allowed.has(app?.uuid) && normalizedImage(app?.docker_registry_image_name) === image); if (matches.length !== 1 || !COOLIFY_CUID_PATTERN.test(matches[0]?.uuid || "")) { fail(`Coolify ${label} runtime is missing, ambiguous, or outside the resource allowlist`); } return matches[0]; } function selectUniqueAllowedNamedApplication(apps, allowed, expectedName, label) { const matches = apps.filter((app) => allowed.has(app?.uuid) && normalizeCoolifyValue(app?.name) === expectedName); if (matches.length !== 1 || !COOLIFY_CUID_PATTERN.test(matches[0]?.uuid || "")) { fail(`Coolify ${label} runtime is missing, ambiguous, or outside the resource allowlist`); } return matches[0]; } async function assertNoAppRevisionOverride(resourceUuid) { if (!resourceUuid) { fail("Coolify resource UUID is required"); } requireCoolifyCuid(resourceUuid, "Coolify resource UUID"); const envs = runtimeEnvEntries(await readApplicationEnvs(resourceUuid)); if (envs.some((entry) => envKey(entry) === "APP_REVISION")) { fail("Coolify APP_REVISION override must be removed before deployment"); } process.stdout.write("Application revision override absent\n"); } function isPrivilegedDatabaseEnvKey(key) { return ( key === "DIRECT_URL" || key === "MIGRATE_ON_BOOT" || key === "SEED_ON_BOOT" || key === "SEED_ONCE" || key.startsWith("MIGRATOR_") || key.startsWith("SEED_") ); } function isPortalForbiddenEnvKey(key) { return ( key === "SESSION_SECRET" || key === "ADMIN_SESSION_SECRET" || key === "CORE_SESSION_SECRET" || key.startsWith("AUTHENTIK_") || /^(?:OAUTH|GOOGLE|MICROSOFT|APPLE)(?:_|$)/.test(key) || /(?:^|_)OAUTH(?:_|$)/.test(key) || /^(?:BILLING|LAGO|MOLLIE|STRIPE)(?:_|$)/.test(key) || /^(?:COOLIFY|SSH|TRAEFIK|INGRESS|CLOUDFLARE)(?:_|$)/.test(key) || key.startsWith("PORTAL_SERVER_") || /(?:^|_)DOMAIN_CONTROLLER(?:_|$)/.test(key) ); } function isAllowedCaptchaInstanceUrl(value) { try { const url = new URL(value); const isLoopback = url.hostname === "localhost" || url.hostname === "::1" || url.hostname === "[::1]" || url.hostname.startsWith("127."); return ( (url.protocol === "https:" || (url.protocol === "http:" && isLoopback)) && Boolean(url.hostname) && !url.username && !url.password && !url.search && !url.hash ); } catch { return false; } } function isValidRedisConnectionUrl(value) { try { const url = new URL(value); const database = url.pathname.replace(/^\//u, ""); return (url.protocol === "redis:" || url.protocol === "rediss:") && Boolean(url.hostname) && (!database || /^\d+$/u.test(database)); } catch { return false; } } function normalizeRedisConnectionTarget(value) { if (!isValidRedisConnectionUrl(value)) return ""; const url = new URL(value); const database = url.pathname.replace(/^\//u, "") || "0"; return `${url.protocol}//${url.hostname.toLowerCase()}:${url.port || "6379"}/${database}`; } function resolveWorkerSharedStoreRedisUrl(values) { return values?.get("REDIS_URL")?.trim() || values?.get("BULLMQ_REDIS_URL")?.trim() || ""; } function assertPortalAuthDependencies(values) { const captchaProvider = values.get("CAPTCHA_PROVIDER")?.trim().toLowerCase(); if (captchaProvider !== "cap" && captchaProvider !== "mcaptcha") { fail("Coolify hivegrid-portal runtime requires CAPTCHA_PROVIDER=cap or CAPTCHA_PROVIDER=mcaptcha"); } if (captchaProvider === "cap") { if (!isAllowedCaptchaInstanceUrl(values.get("CAP_INSTANCE_URL")?.trim() || "")) { fail("Coolify hivegrid-portal runtime requires a valid CAP_INSTANCE_URL"); } if (!values.get("CAP_SITEKEY_LOGIN")?.trim()) { fail("Coolify hivegrid-portal runtime requires CAP_SITEKEY_LOGIN"); } if (!values.get("CAP_SITE_SECRET_LOGIN")?.trim() && !values.get("CAP_SITE_SECRET")?.trim()) { fail("Coolify hivegrid-portal runtime requires CAP_SITE_SECRET_LOGIN or CAP_SITE_SECRET"); } } else { if (!isAllowedCaptchaInstanceUrl(values.get("MCAPTCHA_INSTANCE_URL")?.trim() || "")) { fail("Coolify hivegrid-portal runtime requires a valid MCAPTCHA_INSTANCE_URL"); } if (!values.get("MCAPTCHA_SITEKEY_LOGIN")?.trim()) { fail("Coolify hivegrid-portal runtime requires MCAPTCHA_SITEKEY_LOGIN"); } if (!values.get("MCAPTCHA_ACCOUNT_SECRET")?.trim()) { fail("Coolify hivegrid-portal runtime requires MCAPTCHA_ACCOUNT_SECRET"); } } const redisUrl = values.get("REDIS_URL")?.trim() || ""; if (!isValidRedisConnectionUrl(redisUrl)) { fail("Coolify hivegrid-portal runtime requires a valid REDIS_URL for rate limits and the BullMQ fallback connection"); } const bullmqRedisUrl = values.get("BULLMQ_REDIS_URL")?.trim(); if (bullmqRedisUrl && !isValidRedisConnectionUrl(bullmqRedisUrl)) { fail("Coolify hivegrid-portal runtime BULLMQ_REDIS_URL must be a valid Redis connection URL when configured"); } } function databaseUrlRole(value) { try { const url = new URL(value); if (url.protocol !== "postgres:" && url.protocol !== "postgresql:") return ""; return decodeURIComponent(url.username); } catch { return ""; } } function databaseUrlPort(value) { try { const url = new URL(value); if (url.protocol !== "postgres:" && url.protocol !== "postgresql:") return ""; return url.port; } catch { return ""; } } function assertRuntimeDatabaseEntries(entries, expectedRole, surface) { if (!RUNTIME_DATABASE_ROLES.has(expectedRole)) { fail("Runtime database environment requires hivegrid_app, hivegrid_worker, or hivegrid_portal"); } if (!RUNTIME_DATABASE_SURFACES.has(surface)) { fail("Runtime database environment requires a web, portal, or service surface"); } if ( ((surface === "web" || surface === "admin") && expectedRole !== "hivegrid_app") || (surface === "portal" && expectedRole !== "hivegrid_portal") || (surface !== "portal" && expectedRole === "hivegrid_portal") ) { fail("Runtime web surface does not match its dedicated database role"); } const values = new Map(entries.map((entry) => [envKey(entry), normalizeEnvValue(entry)])); if (values.has("APP_REVISION")) { fail("Coolify runtime APP_REVISION overrides must be removed before migration"); } const forbidden = [...values.keys()].filter(isPrivilegedDatabaseEnvKey); if (forbidden.length > 0) { fail("Coolify runtime contains migration or seed environment keys"); } if (values.get("RLS_RUNTIME_MODE") !== "required") { fail("Coolify runtime must set RLS_RUNTIME_MODE=required"); } if (values.get("DATABASE_RUNTIME_ROLE") !== expectedRole || databaseUrlRole(values.get("DATABASE_URL") || "") !== expectedRole) { fail("Coolify runtime database credential does not match DATABASE_RUNTIME_ROLE"); } const databaseReadUrl = values.get("DATABASE_READ_URL")?.trim() || ""; if (databaseReadUrl && databaseUrlRole(databaseReadUrl) !== expectedRole) { fail("Coolify runtime read database credential does not match DATABASE_RUNTIME_ROLE"); } if (surface === "web" && values.get("WEB_RUNTIME_SURFACE") !== "core") { fail("Coolify hivegrid-web runtime must set WEB_RUNTIME_SURFACE=core"); } if (surface === "web" && values.has("PORTAL_SESSION_SECRET")) { fail("Coolify hivegrid-web runtime must not contain PORTAL_SESSION_SECRET"); } if (surface === "admin") { if (values.get("WEB_RUNTIME_SURFACE") !== "admin") { fail("Coolify hivegrid-admin runtime must set WEB_RUNTIME_SURFACE=admin"); } if (!values.get("SESSION_SECRET")?.trim()) { fail("Coolify hivegrid-admin runtime requires SESSION_SECRET"); } if (values.has("PORTAL_SESSION_SECRET")) { fail("Coolify hivegrid-admin runtime must not contain PORTAL_SESSION_SECRET"); } if ((values.get("ADMIN_FALLBACK_LOGIN_ENABLED") || "false") !== "false") { fail("Coolify hivegrid-admin runtime must set ADMIN_FALLBACK_LOGIN_ENABLED=false"); } } if (surface === "portal") { if (values.get("WEB_RUNTIME_SURFACE") !== "portal") { fail("Coolify hivegrid-portal runtime must set WEB_RUNTIME_SURFACE=portal"); } if (!values.get("PORTAL_SESSION_SECRET")?.trim()) { fail("Coolify hivegrid-portal runtime requires its dedicated PORTAL_SESSION_SECRET"); } assertPortalAuthDependencies(values); const portalForbidden = [...values.keys()].filter(isPortalForbiddenEnvKey); if (portalForbidden.length > 0) { fail("Coolify hivegrid-portal runtime contains forbidden core session, OAuth, billing, or ingress credentials"); } } const authentikKeysPresent = AUTHENTIK_ADMIN_ENV_KEYS.filter((key) => values.has(key)); if (surface !== "admin" && authentikKeysPresent.length > 0) { fail("Authentik Admin SSO environment is restricted to the admin resource"); } if (surface === "admin") { const missing = AUTHENTIK_ADMIN_ENV_KEYS.filter((key) => !values.get(key)?.trim()); if (missing.length > 0) { fail("Coolify admin Authentik SSO environment is incomplete"); } if (databaseUrlRole(values.get("AUTHENTIK_ADMIN_JIT_DATABASE_URL") || "") !== "hivegrid_authentik_jit") { fail("Coolify web Authentik Admin JIT credential must use hivegrid_authentik_jit"); } } if (!isPositiveInteger(values.get("DATABASE_POOL_MAX"))) { fail("Coolify runtime must set an explicit positive DATABASE_POOL_MAX"); } if (databaseUrlPort(values.get("DATABASE_URL") || "") !== "6432") { fail("Coolify runtime DATABASE_URL must use the PgBouncer port 6432"); } return values; } async function assertRuntimeDatabaseEnv(resourceUuid, expectedRole, surface) { if (!resourceUuid) fail("Coolify resource UUID is required"); requireCoolifyCuid(resourceUuid, "Coolify resource UUID"); const entries = runtimeEnvEntries(await readApplicationEnvs(resourceUuid)); assertRuntimeDatabaseEntries(entries, expectedRole, surface); process.stdout.write(`Runtime database environment verified for ${expectedRole}\n`); } async function assertRuntimePreflight(allowedCsv, webImage, apiImage, mcpImage, hocuspocusImage, workerImage) { const allowed = allowedResourceSet(allowedCsv); const expectedImages = [webImage, apiImage, mcpImage, hocuspocusImage, workerImage].map(normalizedImage); if (expectedImages.some((image) => !image) || new Set(expectedImages).size !== expectedImages.length) { fail("Coolify runtime preflight requires five non-empty, distinct runtime images"); } const apps = applicationEntries(await coolify("/applications")); const webPair = selectWebSurfacePair(apps, allowed, webImage); const api = selectUniqueAllowedImageApplication(apps, allowed, apiImage, "API"); const mcp = selectUniqueAllowedImageApplication(apps, allowed, mcpImage, "MCP"); const hocuspocus = selectUniqueAllowedImageApplication(apps, allowed, hocuspocusImage, "Hocuspocus"); const portalDomainController = selectUniqueAllowedNamedApplication( apps, allowed, PORTAL_DOMAIN_CONTROLLER_IDENTITY, "portal-domain-controller" ); const workerCandidates = apps.filter((app) => allowed.has(app?.uuid) && normalizedImage(app?.docker_registry_image_name) === normalizedImage(workerImage)); const workerCandidatesWithEnv = await Promise.all( workerCandidates.map(async (app) => { requireCoolifyCuid(app?.uuid || "", "Coolify worker resource UUID"); const entries = runtimeEnvEntries(await readApplicationEnvs(app.uuid)); const group = normalizeEnvValue(entries.find((entry) => envKey(entry) === "WORKER_QUEUE_GROUP")); return { app, entries, group }; }) ); const workers = WORKER_GROUP_NAMES.map((group) => { const matches = workerCandidatesWithEnv.filter((candidate) => candidate.group === group); if (matches.length !== 1) { fail(`Coolify worker-${group} runtime is missing, ambiguous, or outside the resource allowlist`); } return matches[0]; }); for (const [app, label] of [ [api, "API runtime"], [mcp, "MCP runtime"], [hocuspocus, "Hocuspocus runtime"], ]) { assertRuntimeResourceLimits(app, label); } for (const worker of workers) { assertRuntimeResourceLimits(worker.app, `worker-${worker.group} runtime`); } assertRuntimeResourceLimits(portalDomainController, "portal-domain-controller runtime"); const fixedResources = [ { app: webPair.core, role: "hivegrid_app", surface: "web" }, { app: webPair.admin, role: "hivegrid_app", surface: "admin" }, { app: webPair.portal, role: "hivegrid_portal", surface: "portal" }, { app: api, role: "hivegrid_app", surface: "service" }, { app: mcp, role: "hivegrid_app", surface: "service" }, { app: hocuspocus, role: "hivegrid_app", surface: "service" }, ]; const fixedResourcesWithEnv = await Promise.all( fixedResources.map(async (resource) => ({ ...resource, entries: runtimeEnvEntries(await readApplicationEnvs(resource.app.uuid)), })) ); const runtimeResources = [ ...fixedResourcesWithEnv, ...workers.map((worker) => ({ app: worker.app, entries: worker.entries, role: "hivegrid_worker", surface: "service" })), ]; const allResourceUuids = [...runtimeResources.map(({ app }) => app.uuid), portalDomainController.uuid]; if (new Set(allResourceUuids).size !== allResourceUuids.length) { fail("Coolify runtime preflight requires twelve distinct allowlisted resources"); } const valuesByUuid = new Map(); for (const resource of runtimeResources) { const values = assertRuntimeDatabaseEntries(resource.entries, resource.role, resource.surface); valuesByUuid.set(resource.app.uuid, values); } const coreValues = valuesByUuid.get(webPair.core.uuid); const adminValues = valuesByUuid.get(webPair.admin.uuid); const portalValues = valuesByUuid.get(webPair.portal.uuid); const apiValues = valuesByUuid.get(api.uuid); const mailWorker = workers.find((worker) => worker.group === "mail"); const mailValues = valuesByUuid.get(mailWorker?.app.uuid); const importWorker = workers.find((worker) => worker.group === "import"); const importValues = valuesByUuid.get(importWorker?.app.uuid); const generalWorker = workers.find((worker) => worker.group === "general"); const generalValues = valuesByUuid.get(generalWorker?.app.uuid); const importSharedStoreTarget = normalizeRedisConnectionTarget(resolveWorkerSharedStoreRedisUrl(importValues)); const generalSharedStoreTarget = normalizeRedisConnectionTarget(resolveWorkerSharedStoreRedisUrl(generalValues)); if (!importSharedStoreTarget) { fail("Coolify worker-import runtime requires a valid REDIS_URL or BULLMQ_REDIS_URL for the tenant-export barrier"); } if (!generalSharedStoreTarget) { fail("Coolify worker-general runtime requires a valid REDIS_URL or BULLMQ_REDIS_URL for the tenant-export barrier"); } if (importSharedStoreTarget !== generalSharedStoreTarget) { fail("Coolify worker-import and worker-general must use the same Redis endpoint and database for the tenant-export barrier"); } const coreRedisUrl = coreValues?.get("REDIS_URL")?.trim() || ""; if (!isValidRedisConnectionUrl(coreRedisUrl)) { fail("Coolify hivegrid-web runtime requires a valid REDIS_URL for distributed GDPR export download gates"); } const missingTenantHardDeleteSearch = ["MEILI_URL", "MEILI_API_KEY"].filter((key) => !generalValues?.get(key)?.trim()); if (missingTenantHardDeleteSearch.length > 0) { fail(`Coolify worker-general runtime is missing required tenant hard-delete search environment keys: ${missingTenantHardDeleteSearch.join(", ")}`); } assertAdminConnectionMasterKeyPreflight( [ ["hivegrid-web", coreValues], ["hivegrid-admin", adminValues], ["hivegrid-api", apiValues], ...workers.map((worker) => [`worker-${worker.group}`, valuesByUuid.get(worker.app.uuid)]), ], portalValues ); assertGdprExportStoragePreflight(coreValues, adminValues, portalValues, importValues, generalValues); const coreCryptoSecret = coreValues?.get("CRYPTO_SECRET")?.trim() || ""; const mailCryptoSecret = mailValues?.get("CRYPTO_SECRET")?.trim() || ""; if (!coreCryptoSecret || !mailCryptoSecret || coreCryptoSecret !== mailCryptoSecret) { fail("Coolify hivegrid-web and worker-mail must share the same non-empty CRYPTO_SECRET for portal setup envelope delivery"); } const coreHost = normalizeHttpsConfiguredOrigin(coreValues?.get("PORTAL_APP_HOST")); const adminHost = normalizeHttpsConfiguredOrigin(adminValues?.get("PORTAL_APP_HOST")); const portalHost = normalizeHttpsConfiguredOrigin(portalValues?.get("PORTAL_APP_HOST")); const mailHost = normalizeHttpsConfiguredOrigin(mailValues?.get("PORTAL_APP_HOST")); if (!coreHost || !adminHost || !portalHost || !mailHost || coreHost !== adminHost || adminHost !== portalHost || portalHost !== mailHost) { fail("Coolify hivegrid-web, hivegrid-admin, hivegrid-portal, and worker-mail must use the same normalized PORTAL_APP_HOST"); } if (portalValues?.get("APP_PROTOCOL") !== "https" || mailValues?.get("APP_PROTOCOL") !== "https") { fail("Coolify hivegrid-portal and worker-mail must set APP_PROTOCOL=https"); } const coreBoundaryOrigins = CORE_PUBLIC_HOST_ENV_KEYS.map((key) => { const origin = normalizeHttpsConfiguredOrigin(coreValues?.get(key)); if (!origin) { fail(`Coolify hivegrid-web runtime must set a valid ${key} for portal host isolation`); } return { key, origin }; }); if (coreBoundaryOrigins.some(({ origin }) => origin === portalHost)) { fail("Coolify PORTAL_APP_HOST must be distinct from core APP_BASE_DOMAIN, WEB_APP_HOST, ADMIN_APP_HOST, and API_HOST"); } for (const key of CORE_PUBLIC_HOST_ENV_KEYS) { if (normalizeHttpsConfiguredOrigin(adminValues?.get(key)) !== normalizeHttpsConfiguredOrigin(coreValues?.get(key))) { fail(`Coolify hivegrid-web and hivegrid-admin must use the same normalized ${key}`); } } const webhookWorker = workers.find((worker) => worker.group === "webhook"); const coreRuntime = fixedResourcesWithEnv.find((resource) => resource.app.uuid === webPair.core.uuid); assertWebPushEntries(coreRuntime?.entries || [], webhookWorker?.entries || []); process.stdout.write("Coolify read-only runtime preflight verified\n"); } function normalizeHttpsStorageEndpoint(value) { try { const url = new URL(value); if (url.protocol !== "https:" || !url.hostname || url.username || url.password) return ""; const pathname = url.pathname.replace(/\/+$/, ""); return `${url.protocol}//${url.host.toLowerCase()}${pathname}${url.search}`; } catch { return ""; } } function normalizeStorageForcePathStyle(value) { const normalized = value?.trim().toLowerCase() || "false"; return normalized === "true" || normalized === "false" ? normalized : ""; } function isCanonicalBase64Key(value) { if (!CANONICAL_BASE64_32_BYTES_PATTERN.test(value)) return false; try { const decoded = Buffer.from(value, "base64"); return decoded.byteLength === 32 && decoded.toString("base64") === value; } catch { return false; } } function readOptionalBooleanGate(values, key) { const value = values?.get(key); if (value === undefined || value === "") return false; if (value !== "true" && value !== "false") { fail(`Coolify worker-import ${key} must be exactly true or false when configured`); } return value === "true"; } function assertAdminConnectionMasterKeyPreflight(targets, portalValues) { const keyName = "ADMIN_CONNECTION_MASTER_KEY"; if (portalValues?.get(keyName)?.trim()) { fail(`Coolify hivegrid-portal must not contain ${keyName}`); } const invalidTargets = targets.filter(([, values]) => Buffer.byteLength(values?.get(keyName)?.trim() || "", "utf8") < 32).map(([label]) => label); if (invalidTargets.length > 0) { fail(`Coolify production runtimes require ${keyName} with at least 32 bytes on: ${invalidTargets.join(", ")}`); } const values = targets.map(([, entries]) => entries.get(keyName).trim()); if (new Set(values).size !== 1) { fail(`Coolify production web, API, and worker runtimes must share the same ${keyName}`); } const masterKey = values[0]; const reusedBy = targets .filter(([, entries]) => ["SESSION_SECRET", "CRYPTO_SECRET"].some((secretName) => entries.get(secretName)?.trim() === masterKey)) .map(([label]) => label); if (reusedBy.length > 0) { fail(`${keyName} must be independent from SESSION_SECRET and CRYPTO_SECRET on: ${reusedBy.join(", ")}`); } } function assertGdprExportStoragePreflight(coreValues, adminValues, portalValues, importValues, generalValues) { const importEnabled = readOptionalBooleanGate(importValues, "IMPORT_QUEUE_ENABLED"); const importRuntimeReady = readOptionalBooleanGate(importValues, "IMPORT_QUEUE_RUNTIME_READY"); const portalGdprExportEnabled = readOptionalBooleanGate(importValues, "PORTAL_GDPR_EXPORT_QUEUE_ENABLED"); const fullMode = importEnabled && importRuntimeReady && !portalGdprExportEnabled; const dedicatedMode = !importEnabled && !importRuntimeReady && portalGdprExportEnabled; const idleMode = !importEnabled && !importRuntimeReady && !portalGdprExportEnabled; if (!fullMode && !dedicatedMode && !idleMode) { fail( "Coolify worker-import must use idle mode, full mode with both IMPORT_QUEUE gates enabled, or dedicated GDPR export mode with only PORTAL_GDPR_EXPORT_QUEUE_ENABLED enabled" ); } const webResources = [ ["hivegrid-web", coreValues], ["hivegrid-admin", adminValues], ["hivegrid-portal", portalValues], ]; const workerStorageConfigured = GDPR_EXPORT_STORAGE_ENV_KEYS.some((key) => Boolean(importValues?.get(key)?.trim())); const resources = fullMode || dedicatedMode || workerStorageConfigured ? [...webResources, ["worker-import", importValues]] : webResources; const normalizedStorage = []; for (const [label, values] of resources) { const missing = GDPR_EXPORT_STORAGE_ENV_KEYS.filter((key) => !values?.get(key)?.trim()); if (missing.length > 0) { fail(`Coolify ${label} runtime is missing required encrypted GDPR export environment keys: ${missing.join(", ")}`); } const endpoint = normalizeHttpsStorageEndpoint(values.get("STORAGE_ENDPOINT")); if (!endpoint) { fail(`Coolify ${label} runtime must set STORAGE_ENDPOINT to a valid HTTPS URL for encrypted GDPR exports`); } if (!isCanonicalBase64Key(values.get("GDPR_EXPORT_SSE_C_KEY_BASE64"))) { fail(`Coolify ${label} runtime must set GDPR_EXPORT_SSE_C_KEY_BASE64 to canonical base64 for exactly 32 bytes`); } const forcePathStyle = normalizeStorageForcePathStyle(values.get("STORAGE_FORCE_PATH_STYLE")); if (!forcePathStyle) { fail(`Coolify ${label} runtime must set STORAGE_FORCE_PATH_STYLE to true or false when configured`); } normalizedStorage.push({ label, STORAGE_ENDPOINT: endpoint, STORAGE_REGION: values.get("STORAGE_REGION").toLowerCase(), STORAGE_BUCKET: values.get("STORAGE_BUCKET").toLowerCase(), STORAGE_ACCESS_KEY_ID: values.get("STORAGE_ACCESS_KEY_ID"), STORAGE_FORCE_PATH_STYLE: forcePathStyle, GDPR_EXPORT_SSE_C_KEY_BASE64: values.get("GDPR_EXPORT_SSE_C_KEY_BASE64"), }); } const equalityKeys = ["STORAGE_ENDPOINT", "STORAGE_REGION", "STORAGE_BUCKET", "STORAGE_FORCE_PATH_STYLE", "GDPR_EXPORT_SSE_C_KEY_BASE64"]; const mismatched = equalityKeys.filter((key) => new Set(normalizedStorage.map((storage) => storage[key])).size !== 1); if (mismatched.length > 0) { fail(`Coolify encrypted GDPR export runtime values must match after normalization for: ${mismatched.join(", ")}`); } if (generalValues?.get("EXPORT_RETENTION_QUEUE_ENABLED") !== "true") { fail("Coolify worker-general must set EXPORT_RETENTION_QUEUE_ENABLED=true for automatic GDPR export hard-delete"); } if (generalValues?.get("GDPR_EXPORT_SSE_C_KEY_BASE64")?.trim()) { fail("Coolify worker-general must not contain GDPR_EXPORT_SSE_C_KEY_BASE64 because it is delete-only"); } const missingRetentionStorage = GDPR_EXPORT_RETENTION_STORAGE_ENV_KEYS.filter((key) => !generalValues?.get(key)?.trim()); if (missingRetentionStorage.length > 0) { fail(`Coolify worker-general runtime is missing required GDPR export-retention environment keys: ${missingRetentionStorage.join(", ")}`); } const retentionEndpoint = normalizeHttpsStorageEndpoint(generalValues.get("STORAGE_ENDPOINT")); if (!retentionEndpoint) { fail("Coolify worker-general runtime must set STORAGE_ENDPOINT to a valid HTTPS URL for GDPR export retention"); } const retentionForcePathStyle = normalizeStorageForcePathStyle(generalValues.get("STORAGE_FORCE_PATH_STYLE")); if (!retentionForcePathStyle) { fail("Coolify worker-general runtime must set STORAGE_FORCE_PATH_STYLE to true or false when configured"); } const retentionStorage = { label: "worker-general", STORAGE_ENDPOINT: retentionEndpoint, STORAGE_REGION: generalValues.get("STORAGE_REGION").toLowerCase(), STORAGE_BUCKET: generalValues.get("STORAGE_BUCKET").toLowerCase(), STORAGE_ACCESS_KEY_ID: generalValues.get("STORAGE_ACCESS_KEY_ID"), STORAGE_FORCE_PATH_STYLE: retentionForcePathStyle, }; const writerStorage = normalizedStorage[0]; const retentionMismatched = ["STORAGE_ENDPOINT", "STORAGE_REGION", "STORAGE_BUCKET", "STORAGE_FORCE_PATH_STYLE"].filter( (key) => retentionStorage[key] !== writerStorage[key] ); if (retentionMismatched.length > 0) { fail(`Coolify GDPR export-retention storage values must match the writer/readers after normalization for: ${retentionMismatched.join(", ")}`); } } function isValidVapidSubject(subject) { if (subject.length > 320 || /[\u0000-\u001F\u007F]/.test(subject)) return false; if (subject.startsWith("mailto:")) { return /^mailto:[^\s@]+@[^\s@]+\.[^\s@]+$/i.test(subject); } try { const url = new URL(subject); return url.protocol === "https:" && Boolean(url.hostname) && !url.username && !url.password && !url.hash; } catch { return false; } } function isCanonicalBase64Url(value, expectedBytes, expectedFirstByte) { if (!SAFE_BASE64URL_PATTERN.test(value)) return false; try { const decoded = Buffer.from(value, "base64url"); return decoded.length === expectedBytes && decoded.toString("base64url") === value && (expectedFirstByte === undefined || decoded[0] === expectedFirstByte); } catch { return false; } } function assertWebPushEntries(webEntries, workerEntries) { const webValues = new Map(webEntries.map((entry) => [envKey(entry), normalizeEnvValue(entry)])); const workerValues = new Map(workerEntries.map((entry) => [envKey(entry), normalizeEnvValue(entry)])); const webPublicKey = webValues.get("WEB_PUSH_VAPID_PUBLIC_KEY") || ""; const missingWorkerKeys = WEB_PUSH_VAPID_WORKER_ENV_KEYS.filter((key) => !workerValues.get(key)); if (!webPublicKey) { fail("Coolify web runtime is missing WEB_PUSH_VAPID_PUBLIC_KEY"); } if (missingWorkerKeys.length > 0) { fail(`Coolify webhook-worker runtime is missing required Web Push environment keys: ${missingWorkerKeys.join(", ")}`); } const workerSubject = workerValues.get("WEB_PUSH_VAPID_SUBJECT") || ""; const workerPublicKey = workerValues.get("WEB_PUSH_VAPID_PUBLIC_KEY") || ""; const workerPrivateKey = workerValues.get("WEB_PUSH_VAPID_PRIVATE_KEY") || ""; const invalidKeys = []; if (!isValidVapidSubject(workerSubject)) invalidKeys.push("WEB_PUSH_VAPID_SUBJECT"); if (!isCanonicalBase64Url(webPublicKey, 65, 0x04)) invalidKeys.push("WEB_PUSH_VAPID_PUBLIC_KEY (web)"); if (!isCanonicalBase64Url(workerPublicKey, 65, 0x04)) invalidKeys.push("WEB_PUSH_VAPID_PUBLIC_KEY (webhook-worker)"); if (!isCanonicalBase64Url(workerPrivateKey, 32)) invalidKeys.push("WEB_PUSH_VAPID_PRIVATE_KEY"); if (invalidKeys.length > 0) { fail(`Coolify Web Push runtime configuration is invalid for: ${invalidKeys.join(", ")}`); } if (webPublicKey !== workerPublicKey) { fail("Coolify web and webhook-worker WEB_PUSH_VAPID_PUBLIC_KEY values must match"); } } async function assertWebPushEnv(webResourceUuid, webhookWorkerResourceUuid) { if (!webResourceUuid || !webhookWorkerResourceUuid) { fail("Web and webhook-worker Coolify resource UUIDs are required"); } requireCoolifyCuid(webResourceUuid, "Web Coolify resource UUID"); requireCoolifyCuid(webhookWorkerResourceUuid, "Webhook-worker Coolify resource UUID"); const [webEntries, workerEntries] = await Promise.all([ readApplicationEnvs(webResourceUuid).then(runtimeEnvEntries), readApplicationEnvs(webhookWorkerResourceUuid).then(runtimeEnvEntries), ]); assertWebPushEntries(webEntries, workerEntries); process.stdout.write("Web Push runtime environment verified\n"); } async function trigger(resourceUuid) { if (!resourceUuid) { fail("Coolify resource UUID is required"); } requireCoolifyCuid(resourceUuid, "Coolify resource UUID"); const payload = await coolify(`/deploy?uuid=${encodeURIComponent(resourceUuid)}&force=true`, { method: "POST", }); const uuid = deploymentUuid(payload, resourceUuid); if (!uuid) { fail("Coolify deployment UUID was not returned by the deploy request"); } process.stdout.write(uuid); } function normalizeCoolifyBoolean(value) { return value === true || value === "true" || value === 1 || value === "1" ? "true" : value === false || value === "false" || value === 0 || value === "0" ? "false" : ""; } function normalizeCoolifyValue(value) { return value === null || value === undefined ? "" : String(value).trim(); } function normalizeEnvValue(entry) { let value = envValue(entry).trim(); while (value.length >= 2 && ((value.startsWith("'") && value.endsWith("'")) || (value.startsWith('"') && value.endsWith('"')))) { value = value.slice(1, -1).trim(); } return value; } function desiredMcpHealthConfig(expectedRevision) { return { docker_registry_image_tag: expectedRevision, health_check_enabled: true, health_check_type: "cmd", health_check_command: `node apps/mcp/dist/deploy-health.js ${expectedRevision}`, health_check_interval: 30, health_check_timeout: 10, health_check_retries: 3, health_check_start_period: 20, }; } function mcpHealthConfigDrift(app, expectedRevision) { const desired = desiredMcpHealthConfig(expectedRevision); const expected = { ...desired, health_check_enabled: "true", custom_healthcheck_found: "false" }; const actual = { docker_registry_image_tag: normalizeCoolifyValue(app?.docker_registry_image_tag), health_check_enabled: normalizeCoolifyBoolean(app?.health_check_enabled), health_check_type: normalizeCoolifyValue(app?.health_check_type), health_check_command: normalizeCoolifyValue(app?.health_check_command), health_check_interval: Number(app?.health_check_interval), health_check_timeout: Number(app?.health_check_timeout), health_check_retries: Number(app?.health_check_retries), health_check_start_period: Number(app?.health_check_start_period), custom_healthcheck_found: normalizeCoolifyBoolean(app?.custom_healthcheck_found), }; return Object.keys(expected).filter((key) => actual[key] !== expected[key]); } function expectedRuntimeImageName(target, envName) { const configured = normalizeCoolifyValue(process.env[envName]).replace(/\/+$/, ""); if (configured) return configured; const registry = normalizeCoolifyValue(process.env.REGISTRY).replace(/\/+$/, ""); const namespace = normalizeCoolifyValue(process.env.IMAGE_NAMESPACE).replace(/^\/+|\/+$/g, ""); const prefix = normalizeCoolifyValue(process.env.IMAGE_PREFIX).replace(/^\/+|\/+$/g, ""); return registry && namespace && prefix ? `${registry}/${namespace}/${prefix}/${target}` : ""; } function expectedMcpImageName() { return expectedRuntimeImageName("mcp", "MCP_IMAGE"); } function expectedWorkerImageName() { return expectedRuntimeImageName("worker", "WORKER_IMAGE"); } function isExpectedMcpImage(app) { const actual = normalizeCoolifyValue(app?.docker_registry_image_name).replace(/\/+$/, ""); const expected = expectedMcpImageName(); return Boolean(expected) && actual === expected; } async function configureMcpHealthcheck(resourceUuid, expectedRevision) { if (!resourceUuid) { fail("Coolify resource UUID is required"); } requireCoolifyCuid(resourceUuid, "Coolify resource UUID"); if (!FULL_GIT_SHA_PATTERN.test(expectedRevision)) { fail("MCP deployment health requires a full lowercase Git SHA"); } const resourcePath = `/applications/${encodeURIComponent(resourceUuid)}`; const envPayload = await readApplicationEnvs(resourceUuid); const envs = runtimeEnvEntries(envPayload); if (envs.some((entry) => envKey(entry) === "APP_REVISION")) { fail("Coolify MCP APP_REVISION override must be removed before deployment"); } const current = await coolify(resourcePath); if (!isExpectedMcpImage(current)) { fail("Coolify MCP health configuration target does not use the expected MCP image"); } if (normalizeCoolifyBoolean(current?.custom_healthcheck_found) !== "false") { fail("Coolify MCP image healthcheck must be absent so command health can be authoritative"); } if (mcpHealthConfigDrift(current, expectedRevision).length === 0) { process.stdout.write("MCP deployment health already configured\n"); return; } await coolify(resourcePath, { method: "PATCH", body: JSON.stringify(desiredMcpHealthConfig(expectedRevision)), }); const updated = await coolify(resourcePath); if (mcpHealthConfigDrift(updated, expectedRevision).length > 0) { fail("Coolify MCP deployment health configuration was not persisted"); } process.stdout.write("MCP deployment health configured\n"); } function legacyRollbackHealthConfigDrift(app, expectedRevision) { const actual = { docker_registry_image_tag: normalizeCoolifyValue(app?.docker_registry_image_tag), health_check_enabled: normalizeCoolifyBoolean(app?.health_check_enabled), health_check_command: normalizeCoolifyValue(app?.health_check_command), }; const expected = { docker_registry_image_tag: expectedRevision, health_check_enabled: "false", health_check_command: "", }; return Object.keys(expected).filter((key) => actual[key] !== expected[key]); } function legacyRollbackResourceKind(app) { const image = normalizeCoolifyValue(app?.docker_registry_image_name).replace(/\/+$/, ""); const expectedWorkerImage = expectedWorkerImageName(); const expectedMcpImage = expectedMcpImageName(); if (expectedWorkerImage && expectedWorkerImage !== expectedMcpImage && image === expectedWorkerImage) return "worker"; if (expectedMcpImage && expectedMcpImage !== expectedWorkerImage && image === expectedMcpImage) return "mcp"; return ""; } async function configureLegacyRollbackHealthcheck(resourceUuid, expectedRevision, expectedWorkerGroup = "") { if (!resourceUuid) { fail("Coolify resource UUID is required"); } requireCoolifyCuid(resourceUuid, "Coolify resource UUID"); if (!FULL_GIT_SHA_PATTERN.test(expectedRevision)) { fail("Legacy rollback health configuration requires a full lowercase Git SHA"); } if (expectedWorkerGroup && !WORKER_GROUPS.has(expectedWorkerGroup)) { fail("Legacy rollback health configuration requires a valid worker group"); } const resourcePath = `/applications/${encodeURIComponent(resourceUuid)}`; const envPayload = await readApplicationEnvs(resourceUuid); const envs = runtimeEnvEntries(envPayload); if (envs.some((entry) => envKey(entry) === "APP_REVISION")) { fail("Coolify APP_REVISION override must be removed before legacy rollback"); } const current = await coolify(resourcePath); const resourceKind = legacyRollbackResourceKind(current); if (!resourceKind) { fail("Legacy rollback health configuration is restricted to MCP and worker resources"); } if (resourceKind === "worker" && !expectedWorkerGroup) { fail("Legacy worker rollback requires the exact worker group"); } if (resourceKind === "mcp" && expectedWorkerGroup) { fail("Legacy MCP rollback must not include a worker group"); } if (resourceKind === "worker") { const groupEntry = envs.find((entry) => envKey(entry) === "WORKER_QUEUE_GROUP"); if (!groupEntry || normalizeEnvValue(groupEntry) !== expectedWorkerGroup) { fail("Coolify worker group does not match the legacy rollback target"); } } await coolify(resourcePath, { method: "PATCH", body: JSON.stringify({ docker_registry_image_tag: expectedRevision, health_check_enabled: false, health_check_command: "", }), }); const updated = await coolify(resourcePath); if (legacyRollbackHealthConfigDrift(updated, expectedRevision).length > 0) { fail("Coolify legacy rollback health configuration was not persisted"); } process.stdout.write("Legacy rollback image health configured\n"); } function envKey(entry) { return typeof entry?.key === "string" ? entry.key : ""; } function envValue(entry) { const value = entry?.value ?? entry?.real_value; return typeof value === "string" ? value : ""; } async function setEnvs(resourceUuid, pairs) { if (!resourceUuid) { fail("Coolify resource UUID is required"); } requireCoolifyCuid(resourceUuid, "Coolify resource UUID"); if (pairs.length === 0) { fail("At least one KEY=VALUE pair is required"); } const parsed = pairs.map((pair) => { const separator = pair.indexOf("="); if (separator <= 0) { fail(`Invalid KEY=VALUE pair: ${pair}`); } return [pair.slice(0, separator), pair.slice(separator + 1)]; }); const existingPayload = await readApplicationEnvs(resourceUuid); const existing = runtimeEnvEntries(existingPayload); const byKey = new Map(existing.map((entry) => [envKey(entry), entry]).filter(([key]) => key)); for (const [key, value] of parsed) { const current = byKey.get(key); if (current && envValue(current) === value) { process.stdout.write(`env ${key} unchanged\n`); continue; } await coolify(`/applications/${encodeURIComponent(resourceUuid)}/envs`, { method: current ? "PATCH" : "POST", body: JSON.stringify({ key, value, is_literal: true, is_preview: false, is_multiline: false, is_shown_once: false, }), }); process.stdout.write(`env ${key} ${current ? "updated" : "created"}\n`); } } async function waitForDeployment(uuid, label) { if (!uuid) { fail("Coolify deployment UUID is required"); } requireCoolifyCuid(uuid, "Coolify deployment UUID"); for (let attempt = 1; attempt <= attempts; attempt += 1) { const payload = await coolify(`/deployments/${encodeURIComponent(uuid)}`); const status = deploymentStatus(payload); process.stdout.write(`${label} deployment status: ${status}\n`); if (["finished", "success", "succeeded", "completed", "done"].includes(status)) { return; } if (["failed", "error", "cancelled", "canceled", "cancelled-by-user"].includes(status)) { fail(`${label} deployment failed`); } await new Promise((resolve) => setTimeout(resolve, delayMs)); } fail(`${label} deployment did not finish in time`); } try { if (command === "trigger") { await trigger(args[0] || ""); } else if (command === "wait") { await waitForDeployment(args[0] || "", args[1] || "Coolify"); } else if (command === "set-envs") { await setEnvs(args[0] || "", args.slice(1)); } else if (command === "resolve-web-surfaces") { await resolveWebSurfaces(args[0] || "", args[1] || ""); } else if (command === "set-web-surface-release") { await setWebSurfaceRelease(args[0] || "", args[1] || "", args[2] || "", args[3] || "", args[4] || "", args[5] || ""); } else if (command === "assert-web-surface-release") { await assertWebSurfaceRelease( args[0] || "", args[1] || "", args[2] || "", args[3] || "", args[4] || "", args[5] || "", args[6] || "", args[7] || "", args[8] || "" ); } else if (command === "validate-required-images") { await validateRequiredImages(args[0] || "", args.slice(1)); } else if (command === "assert-no-app-revision-override") { await assertNoAppRevisionOverride(args[0] || ""); } else if (command === "assert-runtime-db-env") { await assertRuntimeDatabaseEnv(args[0] || "", args[1] || "", args[2] || ""); } else if (command === "assert-runtime-preflight") { await assertRuntimePreflight(args[0] || "", args[1] || "", args[2] || "", args[3] || "", args[4] || "", args[5] || ""); } else if (command === "assert-web-push-env") { await assertWebPushEnv(args[0] || "", args[1] || ""); } else if (command === "configure-mcp-healthcheck") { await configureMcpHealthcheck(args[0] || "", args[1] || ""); } else if (command === "configure-legacy-rollback-healthcheck") { await configureLegacyRollbackHealthcheck(args[0] || "", args[1] || "", args[2] || ""); } else { fail( "Usage: coolify-deploy.mjs trigger | wait