Files
HiveGrid-Wireframes/03 Quellen und Referenzen/hivegrid-cloud/scripts/coolify-deploy.mjs
T

1390 lines
61 KiB
JavaScript
Executable File

#!/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-<hex> as an OCI digest
// reference (image@sha256:<hex>) 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 <resource-uuid> | wait <deployment-uuid> <label> | set-envs <resource-uuid> KEY=VALUE... | resolve-web-surfaces <allowed-resource-csv> <web-image> | set-web-surface-release <core-resource-uuid> <admin-resource-uuid> <portal-resource-uuid> <web-image> <full-git-sha> <sha256-digest> | assert-web-surface-release <core-resource-uuid> <admin-resource-uuid> <portal-resource-uuid> <web-image> <full-git-sha> <sha256-digest> <core-deployment-uuid> <admin-deployment-uuid> <portal-deployment-uuid> | validate-required-images <allowed-resource-csv> <expected-image>... | assert-no-app-revision-override <resource-uuid> | assert-runtime-db-env <resource-uuid> <hivegrid_app|hivegrid_worker|hivegrid_portal> <web|admin|portal|service> | assert-runtime-preflight <allowed-resource-csv> <web-image> <api-image> <mcp-image> <hocuspocus-image> <worker-image> | assert-web-push-env <web-resource-uuid> <webhook-worker-resource-uuid> | configure-mcp-healthcheck <resource-uuid> <full-git-sha> | configure-legacy-rollback-healthcheck <resource-uuid> <full-git-sha> [worker-group]"
);
}
} catch (error) {
process.stderr.write(`${error instanceof Error ? error.message : "Coolify deploy helper failed"}\n`);
process.exitCode = 1;
}