diff --git a/app/api/mcp/route.ts b/app/api/mcp/route.ts index 27222ce..4be096f 100644 --- a/app/api/mcp/route.ts +++ b/app/api/mcp/route.ts @@ -27,6 +27,8 @@ import { } from '@/lib/workspace-gcs'; import { VIBN_GCS_LOCATION } from '@/lib/gcp/storage'; import { getApplicationRuntimeLogs } from '@/lib/coolify-logs'; +import { execInCoolifyApp } from '@/lib/coolify-exec'; +import { isCoolifySshConfigured } from '@/lib/coolify-ssh'; import { deployApplication, getApplicationInProject, @@ -99,6 +101,7 @@ export async function GET() { 'apps.domains.list', 'apps.domains.set', 'apps.logs', + 'apps.exec', 'apps.envs.list', 'apps.envs.upsert', 'apps.envs.delete', @@ -193,6 +196,8 @@ export async function POST(request: Request) { return await toolAppsDomainsSet(principal, params); case 'apps.logs': return await toolAppsLogs(principal, params); + case 'apps.exec': + return await toolAppsExec(principal, params); case 'databases.list': return await toolDatabasesList(principal); @@ -432,6 +437,78 @@ async function toolAppsLogs(principal: Principal, params: Record) { return NextResponse.json({ result }); } +/** + * apps.exec — run a one-shot command inside an app container. + * + * Requires COOLIFY_SSH_* env vars (same as apps.logs). The caller + * provides `uuid`, an optional `service` (required for compose apps + * with >1 container), and a `command` string. Output is capped at + * 1MB by default and 10-minute wall-clock timeout. + * + * Note: the command is NOT parsed or validated. It's executed as a + * single shell invocation inside the container via `sh -lc`. This is + * deliberate — this tool is the platform's trust-the-agent escape + * hatch for migrations, CLI invocations, and ad-hoc debugging. It's + * authenticated per-workspace (tenant check above) and rate-limited + * by the SSH session's timeout/byte caps. + */ +async function toolAppsExec(principal: Principal, params: Record) { + const projectUuid = requireCoolifyProject(principal); + if (projectUuid instanceof NextResponse) return projectUuid; + + if (!isCoolifySshConfigured()) { + return NextResponse.json( + { + error: + 'apps.exec requires SSH access to the Coolify host, which is not configured on this deployment.', + }, + { status: 501 }, + ); + } + + const appUuid = String(params.uuid ?? params.appUuid ?? '').trim(); + const command = typeof params.command === 'string' ? params.command : ''; + if (!appUuid || !command) { + return NextResponse.json( + { error: 'Params "uuid" and "command" are required' }, + { status: 400 }, + ); + } + await getApplicationInProject(appUuid, projectUuid); + + const service = typeof params.service === 'string' && params.service.trim() + ? params.service.trim() + : undefined; + const user = typeof params.user === 'string' && params.user.trim() + ? params.user.trim() + : undefined; + const workdir = typeof params.workdir === 'string' && params.workdir.trim() + ? params.workdir.trim() + : undefined; + const timeoutMs = Number.isFinite(Number(params.timeout_ms)) + ? Number(params.timeout_ms) + : undefined; + const maxBytes = Number.isFinite(Number(params.max_bytes)) + ? Number(params.max_bytes) + : undefined; + + try { + const result = await execInCoolifyApp({ + appUuid, + service, + command, + user, + workdir, + timeoutMs, + maxBytes, + }); + return NextResponse.json({ result }); + } catch (err) { + const msg = err instanceof Error ? err.message : String(err); + return NextResponse.json({ error: msg }, { status: 400 }); + } +} + async function toolAppsEnvsList(principal: Principal, params: Record) { const projectUuid = requireCoolifyProject(principal); if (projectUuid instanceof NextResponse) return projectUuid; diff --git a/app/api/workspaces/[slug]/apps/[uuid]/exec/route.ts b/app/api/workspaces/[slug]/apps/[uuid]/exec/route.ts new file mode 100644 index 0000000..c4f8695 --- /dev/null +++ b/app/api/workspaces/[slug]/apps/[uuid]/exec/route.ts @@ -0,0 +1,85 @@ +/** + * POST /api/workspaces/[slug]/apps/[uuid]/exec + * + * Run a one-shot command inside an app container via `docker exec` + * on the Coolify host (over SSH). The companion of `/logs` for the + * write path. + * + * Body (JSON): + * command string required. Passed through `sh -lc`. + * service string? compose service to target (required when + * the app has >1 container). + * user string? docker exec --user + * workdir string? docker exec --workdir + * timeout_ms number? default 60_000, max 600_000 + * max_bytes number? default 1_000_000, max 5_000_000 + */ + +import { NextResponse } from 'next/server'; +import { requireWorkspacePrincipal } from '@/lib/auth/workspace-auth'; +import { getApplicationInProject, TenantError } from '@/lib/coolify'; +import { execInCoolifyApp } from '@/lib/coolify-exec'; +import { isCoolifySshConfigured } from '@/lib/coolify-ssh'; + +export async function POST( + 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 }); + } + if (!isCoolifySshConfigured()) { + return NextResponse.json( + { error: 'apps.exec requires SSH to the Coolify host, not configured on this deployment.' }, + { status: 501 }, + ); + } + + let body: Record; + try { + body = await request.json(); + } catch { + return NextResponse.json({ error: 'Body must be JSON' }, { status: 400 }); + } + + const command = typeof body.command === 'string' ? body.command : ''; + if (!command) { + return NextResponse.json({ error: 'command is required' }, { status: 400 }); + } + const service = typeof body.service === 'string' && body.service.trim() + ? body.service.trim() + : undefined; + const user = typeof body.user === 'string' && body.user.trim() ? body.user.trim() : undefined; + const workdir = typeof body.workdir === 'string' && body.workdir.trim() + ? body.workdir.trim() + : undefined; + const timeoutMs = Number.isFinite(Number(body.timeout_ms)) ? Number(body.timeout_ms) : undefined; + const maxBytes = Number.isFinite(Number(body.max_bytes)) ? Number(body.max_bytes) : undefined; + + try { + await getApplicationInProject(uuid, ws.coolify_project_uuid); + const result = await execInCoolifyApp({ + appUuid: uuid, + command, + service, + user, + workdir, + timeoutMs, + maxBytes, + }); + return NextResponse.json(result); + } catch (err) { + if (err instanceof TenantError) { + return NextResponse.json({ error: err.message }, { status: 403 }); + } + return NextResponse.json( + { error: 'Failed to exec in container', details: err instanceof Error ? err.message : String(err) }, + { status: 502 }, + ); + } +} diff --git a/lib/coolify-containers.ts b/lib/coolify-containers.ts new file mode 100644 index 0000000..2feacae --- /dev/null +++ b/lib/coolify-containers.ts @@ -0,0 +1,109 @@ +/** + * Container enumeration for a Coolify-managed app. + * + * Coolify names compose containers as `{service}-{appUuid}-{shortHash}` + * and single-container apps as `{name}-{appUuid}` (or similar). To + * target a specific service we: + * 1. SSH to the Coolify host as the locked-down vibn-logs user + * 2. `docker ps --filter name={appUuid}` to list matching containers + * 3. Derive the service name by stripping the uuid suffix + * + * Shared between lib/coolify-logs.ts (read path) and + * lib/coolify-exec.ts (write path). Both pass through the same + * ssh2-based helper in lib/coolify-ssh.ts. + */ + +import { runOnCoolifyHost } from '@/lib/coolify-ssh'; + +export interface ComposeContainer { + /** Full docker container name, e.g. `server-flz0i740ql0a-20113077`. */ + name: string; + /** Derived compose service, e.g. `server`. */ + service: string; + /** `Up 5 minutes (healthy)`, `Exited (1) 2 minutes ago`, etc. */ + status: string; + /** `healthy` / `unhealthy` / `starting` / `none`. Parsed from status. */ + health: 'healthy' | 'unhealthy' | 'starting' | 'none'; +} + +/** + * Shell-escape a single token for bash. We only ever pass Coolify- + * derived uuids through here, but defence-in-depth is cheap. + */ +function sq(s: string): string { + return `'${s.replace(/'/g, `'\\''`)}'`; +} + +function deriveService(name: string, appUuid: string): string { + const idx = name.indexOf(`-${appUuid}`); + return idx > 0 ? name.slice(0, idx) : name; +} + +function parseHealth(status: string): ComposeContainer['health'] { + if (/\(healthy\)/i.test(status)) return 'healthy'; + if (/\(unhealthy\)/i.test(status)) return 'unhealthy'; + if (/\(health: starting\)/i.test(status) || /starting/i.test(status)) return 'starting'; + return 'none'; +} + +/** + * List all docker containers belonging to a Coolify app (compose or + * single-container). Returns an empty array when the deployment hasn't + * started yet. Throws only on transport errors. + */ +export async function listContainersForApp(appUuid: string): Promise { + const res = await runOnCoolifyHost( + `docker ps -a --filter ${sq('name=' + appUuid)} --format '{{.Names}}\t{{.Status}}'`, + { timeoutMs: 8_000 }, + ); + if (res.code !== 0) { + throw new Error(`docker ps exited ${res.code}: ${res.stderr.trim()}`); + } + return res.stdout + .split('\n') + .map(l => l.trim()) + .filter(Boolean) + .map(l => { + const [name, ...rest] = l.split('\t'); + const status = rest.join('\t'); + return { + name, + service: deriveService(name, appUuid), + status, + health: parseHealth(status), + }; + }); +} + +/** + * Resolve a single target container for an action that only makes + * sense on one service (e.g. `apps.exec`). Behaviour: + * - No containers → throws "no containers" + * - One container total → returns it (service param optional) + * - N containers and `service` given → match by derived service + * - N containers, no `service` → throws "must specify service" + */ +export async function resolveAppTargetContainer( + appUuid: string, + service: string | undefined, +): Promise { + const containers = await listContainersForApp(appUuid); + if (containers.length === 0) { + throw new Error(`No containers found for app ${appUuid}. Deploy may not have started.`); + } + if (containers.length === 1 && !service) { + return containers[0]; + } + if (!service) { + throw new Error( + `App has ${containers.length} containers; specify service. Available: ${containers.map(c => c.service).join(', ')}`, + ); + } + const match = containers.find(c => c.service === service); + if (!match) { + throw new Error( + `Service "${service}" not found. Available: ${containers.map(c => c.service).join(', ')}`, + ); + } + return match; +} diff --git a/lib/coolify-exec.ts b/lib/coolify-exec.ts new file mode 100644 index 0000000..a4dcc85 --- /dev/null +++ b/lib/coolify-exec.ts @@ -0,0 +1,118 @@ +/** + * Run a one-shot command inside a Coolify-managed app container. + * + * Same SSH backbone as lib/coolify-logs.ts: we connect to the Coolify + * host as the locked-down `vibn-logs` user, resolve the target + * container via listContainersForApp, and execute `docker exec` in + * non-interactive mode. No TTY, no stdin — this is purpose-built for + * "run a command, return the output" operator actions (running + * migrations, sanity checks, one-off CLI invocations). + * + * Tenant safety: every caller must verify the app uuid belongs to the + * calling workspace BEFORE invoking this helper (via + * getApplicationInProject). This file trusts that check has already + * happened. + */ + +import { runOnCoolifyHost } from '@/lib/coolify-ssh'; +import { resolveAppTargetContainer, type ComposeContainer } from '@/lib/coolify-containers'; + +const DEFAULT_TIMEOUT_MS = 60_000; +const MAX_TIMEOUT_MS = 600_000; // 10 min — enough for migrations / seeds +const DEFAULT_MAX_BYTES = 1_000_000; // 1 MB combined stdout+stderr +const MAX_BYTES_CAP = 5_000_000; // 5 MB hard ceiling + +export interface ExecInAppOptions { + appUuid: string; + /** Compose service name (`server`, `db`, `worker`, …). Required when the app has >1 container. */ + service?: string; + /** The command to run. Passed through `sh -lc`, so shell syntax (pipes, redirects, `&&`) works. */ + command: string; + /** Kill the channel after this many ms. Defaults to 60s, max 10 min. */ + timeoutMs?: number; + /** Cap combined stdout+stderr. Defaults to 1 MB, max 5 MB. */ + maxBytes?: number; + /** Optional `--user` passed to `docker exec`, e.g. `root` or `1000:1000`. */ + user?: string; + /** Optional `--workdir` passed to `docker exec`, e.g. `/app`. */ + workdir?: string; +} + +export interface ExecInAppResult { + container: string; + service: string; + code: number | null; + stdout: string; + stderr: string; + truncated: boolean; + durationMs: number; + /** Container health at time of exec (parsed from `docker ps`). */ + containerHealth: ComposeContainer['health']; + /** The command as it was actually executed (post-escape, for logs). */ + executedCommand: string; +} + +/** Shell-escape a single token for bash. */ +function sq(s: string): string { + return `'${s.replace(/'/g, `'\\''`)}'`; +} + +export async function execInCoolifyApp(opts: ExecInAppOptions): Promise { + if (!opts.command || typeof opts.command !== 'string') { + throw new Error('command is required'); + } + const timeoutMs = Math.min( + Math.max(opts.timeoutMs ?? DEFAULT_TIMEOUT_MS, 1_000), + MAX_TIMEOUT_MS, + ); + const maxBytes = Math.min( + Math.max(opts.maxBytes ?? DEFAULT_MAX_BYTES, 1_024), + MAX_BYTES_CAP, + ); + + const container = await resolveAppTargetContainer(opts.appUuid, opts.service); + + // Build the `docker exec` invocation. We run the payload through + // `sh -lc` so callers can use shell syntax naturally. The payload + // itself is passed as a single-quoted argv token — no interpolation + // happens outside of what the child shell does. + const flags: string[] = []; + if (opts.user) flags.push(`--user ${sq(opts.user)}`); + if (opts.workdir) flags.push(`--workdir ${sq(opts.workdir)}`); + + const executedCommand = `docker exec ${flags.join(' ')} ${sq(container.name)} sh -lc ${sq(opts.command)}`.replace(/\s+/g, ' ').trim(); + + const startedAt = Date.now(); + // Audit log: record the command + target, NOT the output (output + // may contain secrets). Structured so downstream log shipping can + // parse it. + console.log( + '[apps.exec]', + JSON.stringify({ + app_uuid: opts.appUuid, + container: container.name, + service: container.service, + timeout_ms: timeoutMs, + command: opts.command, + }), + ); + + const res = await runOnCoolifyHost(executedCommand, { timeoutMs, maxBytes }); + const durationMs = Date.now() - startedAt; + + // Our ssh helper merges stream metadata; we didn't redirect stderr + // to stdout in the command, so the ssh-layer separation is already + // correct. But docker-exec uses exit code 126/127 for "cannot exec" + // and >0 for the user's command failing; surface all of them. + return { + container: container.name, + service: container.service, + code: res.code, + stdout: res.stdout, + stderr: res.stderr, + truncated: res.truncated, + durationMs, + containerHealth: container.health, + executedCommand, + }; +} diff --git a/lib/coolify-logs.ts b/lib/coolify-logs.ts index b64ed98..e4c17a8 100644 --- a/lib/coolify-logs.ts +++ b/lib/coolify-logs.ts @@ -19,6 +19,7 @@ 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'; @@ -133,65 +134,45 @@ async function fetchComposeLogsViaSsh( 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 }; + // 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 }; } - 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. + // 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 => { - if (!filterService) return true; - return c.name.startsWith(filterService + '-') || c.name === filterService; - }); + 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.name).join(', ')}`); + warnings.push( + `No container matched service=${filterService}. Available: ${containers.map(c => c.service).join(', ')}`, + ); } for (const c of targets) { - // service name = name with -- 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 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 is merged via `2>&1` - services[service] = { + 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'),