feat(mcp): apps.exec — run one-shot commands in app containers
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
This commit is contained in:
@@ -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<string, ServiceLogs> = {};
|
||||
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 -<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 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'),
|
||||
|
||||
Reference in New Issue
Block a user