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

View File

@@ -0,0 +1,49 @@
/**
* GET /api/workspaces/[slug]/apps/[uuid]/logs
*
* Runtime logs for a Coolify app. Compose-aware: Coolify's REST API
* for non-compose build packs, SSH into the Coolify host for per-
* service `docker logs` on compose apps.
*
* Query params:
* lines tail lines per container (default 200, max 5000)
* service limit to one compose service (optional)
*/
import { NextResponse } from 'next/server';
import { requireWorkspacePrincipal } from '@/lib/auth/workspace-auth';
import { getApplicationInProject, TenantError } from '@/lib/coolify';
import { getApplicationRuntimeLogs } from '@/lib/coolify-logs';
export async function GET(
request: Request,
{ params }: { params: Promise<{ slug: string; uuid: string }> }
) {
const { slug, uuid } = await params;
const principal = await requireWorkspacePrincipal(request, { targetSlug: slug });
if (principal instanceof NextResponse) return principal;
const ws = principal.workspace;
if (!ws.coolify_project_uuid) {
return NextResponse.json({ error: 'Workspace has no Coolify project yet' }, { status: 503 });
}
const url = new URL(request.url);
const linesRaw = Number(url.searchParams.get('lines') ?? '200');
const lines = Number.isFinite(linesRaw) ? linesRaw : 200;
const service = url.searchParams.get('service') ?? undefined;
try {
await getApplicationInProject(uuid, ws.coolify_project_uuid);
const result = await getApplicationRuntimeLogs(uuid, { lines, service });
return NextResponse.json(result);
} catch (err) {
if (err instanceof TenantError) {
return NextResponse.json({ error: err.message }, { status: 403 });
}
return NextResponse.json(
{ error: 'Failed to fetch runtime logs', details: err instanceof Error ? err.message : String(err) },
{ status: 502 }
);
}
}