/** * Unified runtime-logs fetcher for Coolify apps. * * Coolify's `/applications/{uuid}/logs` REST endpoint works well for * single-container build packs (dockerfile, nixpacks, static) but * returns empty for `dockercompose` — it has no way to pick which of * N services to tail. * * For compose apps we SSH into the Coolify host and call `docker logs` * directly against each compose-managed container (Coolify names them * `{service}-{appUuid}-{shortHash}`). The SSH user is dedicated to this * purpose — a member of the `docker` group, no sudo, single ed25519 * key. * * The agent-facing shape is always `{ services: {...}, source, warnings }` * regardless of which path was taken. That way the MCP tool response is * stable whether Coolify's API grows per-service support later or not. */ import { getApplication, getApplicationRuntimeLogsFromApi, type CoolifyApplication } from '@/lib/coolify'; import { isCoolifySshConfigured, runOnCoolifyHost } from '@/lib/coolify-ssh'; import { listContainersForApp } from '@/lib/coolify-containers'; export type LogsSource = 'coolify_api' | 'ssh_docker' | 'empty'; export interface ApplicationRuntimeLogs { uuid: string; buildPack: string; source: LogsSource; /** * Per-service logs. For non-compose apps there's a single entry keyed * by the application name (or "app"). For compose apps, one entry per * service (`server`, `db`, `redis`, etc.). */ services: Record; warnings: string[]; truncated: boolean; } export interface ServiceLogs { container: string | null; lines: number; bytes: number; logs: string; /** Raw `docker ps` status string when we fetched over SSH. */ status?: string; } const DEFAULT_LINES = 200; const MAX_LINES = 5000; export interface GetLogsOpts { /** Limit to a specific compose service (ignored for non-compose apps). */ service?: string; /** Number of tail lines per container. Clamped to [1, 5000]. */ lines?: number; } /** * Shell-escape a single token for bash. We never pass user input through * here — values are derived from Coolify state (uuids, service names) or * our own integer line counts — but defense-in-depth is cheap. */ function sq(s: string): string { return `'${s.replace(/'/g, `'\\''`)}'`; } export async function getApplicationRuntimeLogs( uuid: string, opts: GetLogsOpts = {}, ): Promise { const lines = Math.max(1, Math.min(opts.lines ?? DEFAULT_LINES, MAX_LINES)); const app = await getApplication(uuid); const buildPack = (app.build_pack ?? 'unknown') as string; const warnings: string[] = []; // ── Compose path (needs SSH) ───────────────────────────────────── if (buildPack === 'dockercompose') { if (!isCoolifySshConfigured()) { warnings.push( 'COOLIFY_SSH_* not configured — cannot tail per-service logs for compose apps. ' + 'Set COOLIFY_SSH_HOST / COOLIFY_SSH_PRIVATE_KEY_B64 / COOLIFY_SSH_USER to enable.', ); return { uuid, buildPack, source: 'empty', services: {}, warnings, truncated: false, }; } return await fetchComposeLogsViaSsh(app, lines, opts.service, warnings); } // ── Single-container path (Coolify API) ───────────────────────── try { const res = await getApplicationRuntimeLogsFromApi(uuid, lines); const raw = (res?.logs ?? '').toString(); const serviceName = (app.name as string) || 'app'; return { uuid, buildPack, source: raw ? 'coolify_api' : 'empty', services: { [serviceName]: { container: null, lines: raw ? raw.split('\n').length : 0, bytes: Buffer.byteLength(raw, 'utf8'), logs: raw, }, }, warnings: raw ? warnings : [...warnings, 'Coolify returned empty logs. App may not be running yet, or this is a known Coolify limitation for this runtime.'], truncated: false, }; } catch (err) { warnings.push(`coolify_api error: ${err instanceof Error ? err.message : String(err)}`); // Fall through to SSH if we have it — handy for dockerfile apps too if (!isCoolifySshConfigured()) { return { uuid, buildPack, source: 'empty', services: {}, warnings, truncated: false }; } return await fetchComposeLogsViaSsh(app, lines, opts.service, warnings); } } async function fetchComposeLogsViaSsh( app: CoolifyApplication, lines: number, filterService: string | undefined, warnings: string[], ): Promise { const uuid = app.uuid as string; const buildPack = (app.build_pack ?? 'dockercompose') as string; // 1) Enumerate containers that belong to this app via the shared // helper. Coolify's naming convention always includes the app // uuid (suffix for compose, embedded for single-container). let containers: Array<{ name: string; status: string; service: string }>; try { const raw = await listContainersForApp(uuid); containers = raw.map(c => ({ name: c.name, status: c.status, service: c.service })); } catch (err) { warnings.push(err instanceof Error ? err.message : String(err)); return { uuid, buildPack, source: 'empty', services: {}, warnings, truncated: false }; } if (containers.length === 0) { warnings.push('No containers found on Coolify host for this app uuid. Deployment may not have started yet.'); return { uuid, buildPack, source: 'ssh_docker', services: {}, warnings, truncated: false }; } // 2) Tail logs per container. listContainersForApp already derives // the service name from each container; we just need to filter // by the caller's requested service (if any). const services: Record = {}; let anyTruncated = false; const targets = containers.filter(c => !filterService || c.service === filterService); if (filterService && targets.length === 0) { warnings.push( `No container matched service=${filterService}. Available: ${containers.map(c => c.service).join(', ')}`, ); } for (const c of targets) { const key = services[c.service] ? c.name : c.service; const logsRes = await runOnCoolifyHost( `docker logs --tail ${Math.floor(lines)} --timestamps ${sq(c.name)} 2>&1`, { timeoutMs: 10_000, maxBytes: 1_000_000 }, ); if (logsRes.truncated) anyTruncated = true; const text = logsRes.stdout; // stderr merged via `2>&1` services[key] = { container: c.name, lines: text ? text.split('\n').length : 0, bytes: Buffer.byteLength(text, 'utf8'), logs: text, status: c.status, }; } return { uuid, buildPack, source: 'ssh_docker', services, warnings, truncated: anyTruncated, }; }