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:
211
lib/coolify-logs.ts
Normal file
211
lib/coolify-logs.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user