Companion to apps.logs. SSH to the Coolify host as vibn-logs, resolve the target container by app uuid + service, and run the caller's command through `docker exec ... sh -lc`. No TTY, no stdin — this is the write-path sibling of apps.logs, purpose-built for migrations, seeds, CLI invocations, and ad-hoc debugging. - lib/coolify-containers.ts extracts container enumeration + service resolution into a shared helper used by both logs and exec. - lib/coolify-exec.ts wraps docker exec with timeout (60s default, 10-min cap), output byte cap (1 MB default, 5 MB cap), optional --user / --workdir, and structured audit logging of the command + target (never the output). - app/api/mcp/route.ts wires `apps.exec` into the dispatcher and advertises it in the capabilities manifest. - app/api/workspaces/[slug]/apps/[uuid]/exec/route.ts exposes the same tool over REST for session-cookie callers. Tenant safety: every entrypoint runs getApplicationInProject before touching SSH, so an agent can only exec in apps belonging to their workspace. Made-with: Cursor
193 lines
6.8 KiB
TypeScript
193 lines
6.8 KiB
TypeScript
/**
|
|
* 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<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 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<string, ServiceLogs> = {};
|
|
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,
|
|
};
|
|
}
|