diff --git a/app/api/mcp/route.ts b/app/api/mcp/route.ts index d731af23..7c1be317 100644 --- a/app/api/mcp/route.ts +++ b/app/api/mcp/route.ts @@ -92,7 +92,7 @@ const GITEA_API_URL = process.env.GITEA_API_URL ?? 'https://git.vibnai.com'; export async function GET() { return NextResponse.json({ name: 'vibn-mcp', - version: '2.4.7', + version: '2.4.8', authentication: { scheme: 'Bearer', tokenPrefix: 'vibn_sk_', @@ -837,6 +837,15 @@ async function toolAppsCreate(principal: Principal, params: Record) const fqdn = resolveFqdn(params.domain, ws.slug, appName); if (fqdn instanceof NextResponse) return fqdn; + // Pull the template's required upstream port from the catalog. + // Coolify's "Required Port" UI hint says: domains MUST be specified + // as host:port for the template engine to wire up the right + // SERVICE_FQDN__ magic env, the loadbalancer.server.port + // Traefik label, and the SERVICE_URL__ env. Without it + // we get the default sslip.io values everywhere and Traefik returns + // 503 because the routing rules have no port to forward to. + const templatePort = catalog[templateSlug]?.port ?? 3000; + const created = await createService({ projectUuid: commonOpts.projectUuid, serverUuid: commonOpts.serverUuid, @@ -851,22 +860,17 @@ async function toolAppsCreate(principal: Principal, params: Record) }); // Coolify auto-assigns sslip.io URLs. Replace them with the - // user's FQDN. We rebuild the urls array by reading the service - // back to learn the docker-compose service names (template-specific). + // user's FQDN, INCLUDING the required upstream port — see comment + // on `templatePort` above. The :port suffix is what makes Coolify + // generate the loadbalancer.server.port label and substitute the + // SERVICE_FQDN_ env to the user's host (no sslip.io leak). let urlsApplied = false; try { - // Brief settle so the service is fully committed await new Promise(r => setTimeout(r, 1500)); - const svc = await getService(created.uuid) as Record; - // Coolify stores per-service urls under different shapes across versions: - // - service.fqdn : "https://x.sslip.io,https://y.sslip.io" - // - service.urls : [{ name, url }] - // For simplicity, target the docker-compose service named after - // the template slug (covers ~90% of templates: twenty, n8n, ghost, - // wordpress, etc). Users can adjust later via apps.domains.set. - await setServiceDomains(created.uuid, [{ name: templateSlug, url: `https://${fqdn}` }]); + await setServiceDomains(created.uuid, [ + { name: templateSlug, url: `https://${fqdn}:${templatePort}` }, + ]); urlsApplied = true; - void svc; // reserved for future heuristic } catch (e) { console.warn('[mcp apps.create/template] setServiceDomains failed', e); } @@ -892,6 +896,7 @@ async function toolAppsCreate(principal: Principal, params: Record) uuid: created.uuid, fqdn, publicAppName: templateSlug, + port: templatePort, })); } diff --git a/lib/coolify-compose.ts b/lib/coolify-compose.ts index fc93d133..a67d383d 100644 --- a/lib/coolify-compose.ts +++ b/lib/coolify-compose.ts @@ -1,45 +1,33 @@ /** - * Surgical post-deploy fixes for Coolify-managed Services. + * Surgical post-deploy fix for Coolify-managed Services. * * Why this exists * --------------- - * Coolify's service-template deploy pipeline gets us 90% of the way: - * it generates a docker-compose.yml + .env, runs `docker compose up`, - * sets up volumes, and writes Traefik labels. But for many templates - * (including the popular twenty/n8n/ghost/etc.) it consistently fails - * to do three host-level things that the public REST API does NOT - * expose: + * Coolify's service-template deploy pipeline gets us 99% of the way + * — IF apps.create passes the upstream port in the URL it gives to + * `setServiceDomains` (e.g. `https://crm.mark.vibnai.com:3000`). + * With that port suffix Coolify auto-generates everything that + * matters: the loadbalancer.server.port Traefik label, the rewritten + * SERVICE_FQDN_ / SERVICE_URL_ env vars (no sslip.io + * leakage), and the correct routing rules. * - * 1. Rewrite the auto-generated `SERVICE_FQDN_*` / `SERVICE_URL_*` - * env vars from sslip.io defaults to the user's real FQDN. The - * user's domain is correctly stored on `service.applications[].fqdn` - * (so Traefik routing rules use it), but the env vars that the - * app embeds into its frontend bundle (e.g. Twenty's SERVER_URL) - * keep pointing at sslip.io. Result: SPA loads on real HTTPS - * then makes XHRs to insecure sslip.io URLs → "Mixed Content" - * errors and the app appears broken. + * The one thing Coolify still misses is connecting `coolify-proxy` + * to the resource's project Docker network. Coolify writes a + * `caddy_ingress_network=` hint label but never runs + * `docker network connect`, so Traefik discovers the right routing + * rules but cannot reach the upstream container — every request + * returns Traefik 503. * - * 2. Generate the `traefik.http.services..loadbalancer.server.port` - * label. Without it Traefik logs `error: port is missing` and - * returns 503 on every request. + * That's the entire purpose of this module: attach `coolify-proxy` + * to the project network, then nudge Traefik to re-discover. * - * 3. Connect `coolify-proxy` to the resource's project network. - * Coolify generates a label `caddy_ingress_network=` - * hinting that the proxy SHOULD live there, but never actually - * runs `docker network connect`. Result: even if Traefik - * discovers the right routing rules, it can't reach the upstream - * container. - * - * This module fixes all three after Coolify's queue finishes its work. - * - * Permissions model - * ----------------- - * The `vibn-logs` SSH user has docker-group membership but no shell - * sudo and no read access to `/data/coolify/services//` (Coolify - * chmods that to 0700 root). We work around both by running a one-shot - * `python:alpine` container that bind-mounts the path. The docker - * daemon runs as root so it can read the directory; vibn-logs only - * needs the docker socket. + * History + * ------- + * Versions 2.4.5 → 2.4.7 also rewrote `.env` and injected the + * loadbalancer port label via an embedded Python script run inside a + * `python:3-alpine` container. That code became unnecessary in 2.4.8 + * once we discovered the `:port` URL convention; it's been removed + * along with the `python:alpine` SSH dependency. */ import { runOnCoolifyHost, type CoolifySshResult } from './coolify-ssh'; @@ -142,9 +130,10 @@ export interface CoolifyPostDeployOptions { /** Compose service name of the user-facing app, e.g. "twenty". */ publicAppName: string; /** - * HTTP port the public app listens on inside the container. - * If omitted, we try to detect it from `.env` (looking for - * `SERVICE_FQDN__`). Falls back to 3000. + * HTTP port the public app listens on inside the container. Optional + * here — kept for back-compat and diagnostics; the actual port + * routing is wired by Coolify itself based on the URL passed to + * setServiceDomains, not by this helper. */ port?: number; } @@ -152,176 +141,44 @@ export interface CoolifyPostDeployOptions { export interface CoolifyPostDeployResult { ok: boolean; steps: { - envRewrite: { ok: boolean; detail: string }; - portLabel: { ok: boolean; detail: string }; proxyNetwork: { ok: boolean; detail: string }; - recreate: { ok: boolean; detail: string }; proxyRestart: { ok: boolean; detail: string }; }; } /** - * Embed a Python script (UTF-8 bytes, base64-encoded) as a here-doc - * arg to a docker-run that mounts the resource's compose dir at /work - * and exposes the inputs as env vars. We use base64 to sidestep all - * shell-escaping issues with python triple-quoted strings. - */ -function buildPythonRunner(script: string, env: Record, dir: string, networkAttach = false): string { - const b64 = Buffer.from(script, 'utf8').toString('base64'); - const envFlags = Object.entries(env) - .map(([k, v]) => `-e ${sq(`${k}=${v}`)}`) - .join(' '); - // We need a Python image with sed-style file editing. python:3-alpine - // is ~50MB and ships with regex + os out of the box. - return [ - `echo ${sq(b64)} | base64 -d |`, - 'docker run --rm -i', - `-v ${sq(`${dir}:/work`)}`, - networkAttach ? '-v /var/run/docker.sock:/var/run/docker.sock' : '', - envFlags, - 'python:3-alpine', - 'python -', - ].filter(Boolean).join(' '); -} - -/** - * Apply the three post-deploy fixes to a freshly-deployed Coolify - * service so the user-facing URL works on the very first hit. + * Apply the post-deploy fix to a freshly-deployed Coolify service so + * the user-facing URL works on the very first hit. * - * Idempotent. Safe to call multiple times — each step detects - * whether the change is already in place and no-ops if so. + * Idempotent. Safe to call multiple times. Coolify-version-tolerant — + * if a future Coolify already attaches the proxy network itself, both + * steps no-op cleanly. * * Sequencing: - * 1. Rewrite .env's SERVICE_FQDN_* / SERVICE_URL_* (cosmetic for - * Traefik but critical for any frontend that bakes the URL into - * its bundle from these env vars at startup). - * 2. Inject the missing `loadbalancer.server.port` label into the - * compose file. - * 3. Connect coolify-proxy to the project network so Traefik can - * reach the public container by its compose name. - * 4. `docker compose up -d --force-recreate ` — this - * applies the new env (step 1) and label (step 2) without - * touching internal services like postgres/redis (which would - * cause DNS collisions if their networks changed). - * 5. `docker restart coolify-proxy` so Traefik re-discovers the - * newly-attached network and the recreated container's labels. + * 1. `docker network connect coolify-proxy` so Traefik can + * reach the public container by its compose name. This is the + * ONE thing Coolify omits despite writing the + * `caddy_ingress_network=` hint label. + * 2. Background `docker restart coolify-proxy` (fired off via + * nohup) so Traefik re-discovers the newly-attached network. We + * can't restart it synchronously because coolify-proxy is the + * same gateway serving this very HTTP request — see step 2's + * comment for the gory detail. */ export async function applyCoolifyPostDeployFixes( opts: CoolifyPostDeployOptions, ): Promise { - const { uuid, fqdn, publicAppName, port = 3000 } = opts; - const dir = composeDir('service', uuid); + const { uuid } = opts; const result: CoolifyPostDeployResult = { ok: false, steps: { - envRewrite: { ok: false, detail: '' }, - portLabel: { ok: false, detail: '' }, proxyNetwork: { ok: false, detail: '' }, - recreate: { ok: false, detail: '' }, proxyRestart: { ok: false, detail: '' }, }, }; - // ── Step 1+2 fused: rewrite .env + inject port label in one Python pass - const editorScript = ` -import os, re, sys - -env_file = "/work/.env" -compose_file = "/work/docker-compose.yml" -fqdn = os.environ["NEW_FQDN"] -app = os.environ["APP"] # e.g. "twenty" -APP = app.upper() -uuid = os.environ["UUID"] -port = os.environ["PORT"] - -env_changes = [] -if os.path.exists(env_file): - with open(env_file, "r", encoding="utf-8") as f: - lines = f.readlines() - out = [] - for line in lines: - new = line - # SERVICE_FQDN_= - if re.match(rf"^SERVICE_FQDN_{re.escape(APP)}=", line): - new = f"SERVICE_FQDN_{APP}={fqdn}\\n" - # SERVICE_URL_= - elif re.match(rf"^SERVICE_URL_{re.escape(APP)}=", line): - new = f"SERVICE_URL_{APP}=https://{fqdn}\\n" - else: - m = re.match(rf"^SERVICE_FQDN_{re.escape(APP)}_(\\d+)=", line) - if m: - new = f"SERVICE_FQDN_{APP}_{m.group(1)}={fqdn}:{m.group(1)}\\n" - else: - m = re.match(rf"^SERVICE_URL_{re.escape(APP)}_(\\d+)=", line) - if m: - new = f"SERVICE_URL_{APP}_{m.group(1)}=https://{fqdn}:{m.group(1)}\\n" - if new != line: - env_changes.append(line.strip() + " => " + new.strip()) - out.append(new) - with open(env_file, "w", encoding="utf-8") as f: - f.writelines(out) - -# Inject port label into compose if missing. -label_changes = [] -svc_id = f"{app}-svc-{uuid}" -needed_router_svc = f"traefik.http.routers.https-0-{uuid}-{app}.service={svc_id}" -needed_loadbalance = f"traefik.http.services.{svc_id}.loadbalancer.server.port={port}" -http_router_svc = f"traefik.http.routers.http-0-{uuid}-{app}.service={svc_id}" - -with open(compose_file, "r", encoding="utf-8") as f: - s = f.read() - -if needed_loadbalance not in s: - # Anchor: the existing tls=true label for the https router. - anchor = f"traefik.http.routers.https-0-{uuid}-{app}.tls=true" - if anchor in s: - replacement = ( - anchor - + "\\n - " + http_router_svc - + "\\n - " + needed_router_svc - + "\\n - " + needed_loadbalance - ) - s = s.replace(anchor, replacement, 1) # only on the twenty service block - with open(compose_file, "w", encoding="utf-8") as f: - f.write(s) - label_changes.append(f"injected loadbalancer.server.port={port}") - else: - label_changes.append(f"WARN: anchor '{anchor}' not found; label NOT injected") -else: - label_changes.append("loadbalancer.server.port already present") - -print("ENV_CHANGES:" + str(len(env_changes))) -for c in env_changes: - print(" " + c) -print("LABEL_CHANGES:") -for c in label_changes: - print(" " + c) -`; - - try { - const cmd = buildPythonRunner( - editorScript, - { NEW_FQDN: fqdn, APP: publicAppName, UUID: uuid, PORT: String(port) }, - dir, - ); - const r = await runOnCoolifyHost(cmd, { timeoutMs: 60_000 }); - if (r.code === 0) { - const text = r.stdout.trim().slice(-1500); - result.steps.envRewrite = { ok: true, detail: text }; - result.steps.portLabel = { ok: !text.includes('WARN:'), detail: text }; - } else { - const detail = (r.stderr || r.stdout).trim().slice(-500); - result.steps.envRewrite = { ok: false, detail }; - result.steps.portLabel = { ok: false, detail }; - } - } catch (e) { - const detail = e instanceof Error ? e.message : String(e); - result.steps.envRewrite = { ok: false, detail }; - result.steps.portLabel = { ok: false, detail }; - } - - // ── Step 3: attach coolify-proxy to project network + // ── Step 1: attach coolify-proxy to project network try { // `|| true` swallows the "endpoint with name coolify-proxy already // exists in network" error which is the success-already-applied case. @@ -342,37 +199,7 @@ for c in label_changes: }; } - // ── Step 4: recreate ONLY the public app to apply env+label changes - // (not the whole stack — postgres/redis/worker stay where they are) - try { - const r = await composeRun('service', uuid, ['up', '-d', '--force-recreate', publicAppName], { - timeoutMs: 300_000, - }); - const detail = (r.stderr || r.stdout) - .replace(/\x1b\[[0-9;]*[a-zA-Z]/g, '') - .replace(/[\x00-\x08\x0B-\x1F]/g, '') - .trim() - .slice(-400); - // compose returns 0 on success, non-zero on partial failure; - // sidecar `depends_on` timeouts can produce a non-zero exit - // even though the public container started successfully. - const probe = await runOnCoolifyHost( - `docker ps --filter name=${publicAppName}-${uuid} --format '{{.Names}}'`, - { timeoutMs: 8_000 }, - ); - const running = probe.stdout.trim().length > 0; - result.steps.recreate = { - ok: running, - detail: running ? `${publicAppName}-${uuid} running` : detail, - }; - } catch (e) { - result.steps.recreate = { - ok: false, - detail: e instanceof Error ? e.message : String(e), - }; - } - - // ── Step 5: nudge Traefik to re-discover via proxy restart. + // ── Step 2: nudge Traefik to re-discover via proxy restart. // // CAUTION: coolify-proxy is the same gateway that's currently // serving this very HTTP request (the agent → vibnai.com call that diff --git a/lib/coolify.ts b/lib/coolify.ts index 533c353a..32f150d8 100644 --- a/lib/coolify.ts +++ b/lib/coolify.ts @@ -979,7 +979,13 @@ export async function listServiceTemplates(opts: { force?: boolean } = {}): Prom tags: Array.isArray(t.tags) ? t.tags.filter((x): x is string => typeof x === 'string') : undefined, category: typeof t.category === 'string' ? t.category : undefined, logo: typeof t.logo === 'string' ? t.logo : undefined, - port: typeof t.port === 'number' ? t.port : undefined, + // Coolify's catalog stores port as either a number (e.g. 3000) + // or a numeric string (e.g. "3000") — handle both. + port: typeof t.port === 'number' + ? t.port + : typeof t.port === 'string' && /^\d+$/.test(t.port.trim()) + ? Number(t.port.trim()) + : undefined, documentation: typeof t.documentation === 'string' ? t.documentation : undefined, minversion: typeof t.minversion === 'string' ? t.minversion : undefined, };