feat(mcp): apps.logs — compose-aware runtime logs

Adds apps.logs MCP tool + session REST endpoint for tailing runtime
container logs. Unblocks cold-start debugging for agent-deployed
compose apps (Twenty, Cal.com, Plane, etc.) where Coolify's own
/applications/{uuid}/logs endpoint returns empty.

Architecture:
  - dockerfile / nixpacks / static apps → Coolify's REST logs API
  - dockercompose apps                  → SSH into Coolify host,
                                          `docker logs` per service

New SSH path uses a dedicated `vibn-logs` user (docker group, no
sudo, no pty, no port-forwarding, single ed25519 key). Private key
lives in COOLIFY_SSH_PRIVATE_KEY_B64 on the vibn-frontend Coolify
app; authorized_key is installed by scripts/setup-vibn-logs-user.sh
on the Coolify host.

Tool shape:
  params:   { uuid, service?, lines? (default 200, max 5000) }
  returns:  { uuid, buildPack, source: 'coolify_api'|'ssh_docker'|'empty',
              services: { [name]: { container, lines, bytes, logs, status? } },
              warnings: string[], truncated: boolean }

Made-with: Cursor
This commit is contained in:
2026-04-23 13:21:52 -07:00
parent 9959eaeeaa
commit d86f2bea03
7 changed files with 541 additions and 0 deletions

211
lib/coolify-logs.ts Normal file
View File

@@ -0,0 +1,211 @@
/**
* 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';
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<string, ServiceLogs>;
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<ApplicationRuntimeLogs> {
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<ApplicationRuntimeLogs> {
const uuid = app.uuid as string;
const buildPack = (app.build_pack ?? 'dockercompose') as string;
// 1) Enumerate containers that belong to this app. Coolify's naming
// convention always includes the app uuid in the container name
// (either as suffix for compose or as the name itself for single-
// container apps).
const ps = await runOnCoolifyHost(
`docker ps -a --filter ${sq('name=' + uuid)} --format '{{.Names}}\t{{.Status}}'`,
{ timeoutMs: 8_000 },
);
if (ps.code !== 0) {
warnings.push(`docker ps exited ${ps.code}: ${ps.stderr.trim()}`);
return { uuid, buildPack, source: 'empty', services: {}, warnings, truncated: ps.truncated };
}
const containers = ps.stdout
.split('\n')
.map(l => l.trim())
.filter(Boolean)
.map(l => {
const [name, ...rest] = l.split('\t');
return { name, status: rest.join('\t') };
});
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) Derive a service name from each container.
//
// Coolify names compose containers like `{service}-{appUuid}-{short}`
// For single-container apps it's just `{appSlug}-{appUuid}` or similar.
// We strip the uuid to recover the service name.
const services: Record<string, ServiceLogs> = {};
let anyTruncated = false;
const targets = containers.filter(c => {
if (!filterService) return true;
return c.name.startsWith(filterService + '-') || c.name === filterService;
});
if (filterService && targets.length === 0) {
warnings.push(`No container matched service=${filterService}. Available: ${containers.map(c => c.name).join(', ')}`);
}
for (const c of targets) {
// service name = name with -<uuid>-<hash> suffix stripped
let service = c.name;
const idx = c.name.indexOf(`-${uuid}`);
if (idx > 0) service = c.name.slice(0, idx);
// Guard against empty or duplicate service keys
if (!service) service = c.name;
if (services[service]) service = c.name;
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 is merged via `2>&1`
services[service] = {
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,
};
}

133
lib/coolify-ssh.ts Normal file
View File

@@ -0,0 +1,133 @@
/**
* One-shot SSH-exec helper into the Coolify host.
*
* Used to pull docker runtime data that the Coolify REST API doesn't
* surface cleanly (per-service logs for compose apps, container state,
* etc.). Strictly command-in / string-out — we never hold a persistent
* channel and we never expose an interactive shell to callers.
*
* Required env (injected into vibn-frontend at deploy time):
* COOLIFY_SSH_HOST e.g. 34.19.250.135 or coolify.vibnai.com
* COOLIFY_SSH_PORT default 22
* COOLIFY_SSH_USER default "vibn-logs"
* COOLIFY_SSH_PRIVATE_KEY_B64 base64-encoded PEM private key
*
* The authorised key lives in ~vibn-logs/.ssh/authorized_keys on the
* Coolify host. That user must be a member of the `docker` group so
* `docker logs`/`docker ps` work without sudo; it should NOT have
* sudo rights.
*/
import { Client, type ConnectConfig } from 'ssh2';
const DEFAULT_EXEC_TIMEOUT_MS = 10_000;
const DEFAULT_MAX_BYTES = 1_000_000; // 1 MB more than enough for `docker logs --tail 2000`
export interface CoolifySshResult {
code: number | null;
stdout: string;
stderr: string;
truncated: boolean;
}
export interface CoolifySshOptions {
/** Millis to wait for the command to finish before killing the channel. */
timeoutMs?: number;
/** Cap combined stdout+stderr. Extra bytes are dropped and `truncated=true`. */
maxBytes?: number;
}
function loadConnectConfig(): ConnectConfig {
const host = process.env.COOLIFY_SSH_HOST;
const keyB64 = process.env.COOLIFY_SSH_PRIVATE_KEY_B64;
if (!host) throw new Error('COOLIFY_SSH_HOST is not set');
if (!keyB64) throw new Error('COOLIFY_SSH_PRIVATE_KEY_B64 is not set');
const privateKey = Buffer.from(keyB64, 'base64').toString('utf8');
const port = Number(process.env.COOLIFY_SSH_PORT ?? 22);
const username = process.env.COOLIFY_SSH_USER ?? 'vibn-logs';
return {
host,
port,
username,
privateKey,
readyTimeout: 8_000,
// We accept the host key on first connect; we're always connecting
// to infra we own, but a future hardening pass should pin the host
// key fingerprint via `hostVerifier`.
};
}
/**
* Run a single command on the Coolify host and collect its output.
*
* The helper enforces:
* - a wall-clock timeout (default 10s)
* - a combined-output byte cap (default 1 MB)
* - always-close semantics (connection is destroyed on return / throw)
*/
export function runOnCoolifyHost(
command: string,
opts: CoolifySshOptions = {},
): Promise<CoolifySshResult> {
const cfg = loadConnectConfig();
const timeoutMs = opts.timeoutMs ?? DEFAULT_EXEC_TIMEOUT_MS;
const maxBytes = opts.maxBytes ?? DEFAULT_MAX_BYTES;
return new Promise((resolve, reject) => {
const conn = new Client();
let settled = false;
let stdout = '';
let stderr = '';
let truncated = false;
const settle = (fn: () => void) => {
if (settled) return;
settled = true;
clearTimeout(timer);
try { conn.end(); } catch { /* noop */ }
fn();
};
const timer = setTimeout(() => {
settle(() => reject(new Error(`[coolify-ssh] timeout after ${timeoutMs}ms running: ${command}`)));
}, timeoutMs);
conn
.on('ready', () => {
conn.exec(command, (err, stream) => {
if (err) return settle(() => reject(err));
stream
.on('close', (code: number | null) => {
settle(() => resolve({ code, stdout, stderr, truncated }));
})
.on('data', (chunk: Buffer) => {
if (stdout.length + stderr.length + chunk.length > maxBytes) {
truncated = true;
const room = Math.max(0, maxBytes - stdout.length - stderr.length);
stdout += chunk.slice(0, room).toString('utf8');
} else {
stdout += chunk.toString('utf8');
}
})
.stderr.on('data', (chunk: Buffer) => {
if (stdout.length + stderr.length + chunk.length > maxBytes) {
truncated = true;
const room = Math.max(0, maxBytes - stdout.length - stderr.length);
stderr += chunk.slice(0, room).toString('utf8');
} else {
stderr += chunk.toString('utf8');
}
});
});
})
.on('error', (err) => settle(() => reject(err)))
.connect(cfg);
});
}
/**
* True if the env is configured to talk to the Coolify host.
* Callers use this to gracefully degrade when SSH isn't available
* (e.g. local dev without the key pair mounted).
*/
export function isCoolifySshConfigured(): boolean {
return !!(process.env.COOLIFY_SSH_HOST && process.env.COOLIFY_SSH_PRIVATE_KEY_B64);
}

View File

@@ -59,6 +59,7 @@ export interface CoolifyApplication {
environment_id?: number;
environment_name?: string;
environment?: { id?: number; project_uuid?: string; project?: { uuid?: string } };
build_pack?: string;
}
/**
@@ -548,6 +549,19 @@ export async function getDeploymentLogs(deploymentUuid: string): Promise<{ logs:
return coolifyFetch(`/deployments/${deploymentUuid}/logs`);
}
/**
* Coolify's "runtime logs" endpoint. Returns `{ logs: "…" }` for simple
* Dockerfile/nixpacks apps; returns an empty string for `dockercompose`
* apps (Coolify v4 doesn't know which of the compose services to tail).
* Use coolify-logs.getApplicationRuntimeLogs for the compose-aware path.
*/
export async function getApplicationRuntimeLogsFromApi(
uuid: string,
lines = 200,
): Promise<{ logs: string }> {
return coolifyFetch(`/applications/${uuid}/logs?lines=${Math.max(1, Math.min(lines, 5000))}`);
}
export async function listApplicationDeployments(uuid: string): Promise<CoolifyDeployment[]> {
// Coolify v4 nests this under /deployments/applications/{uuid}
// and returns { count, deployments }. Normalize to a flat array.