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:
109
lib/coolify-containers.ts
Normal file
109
lib/coolify-containers.ts
Normal file
@@ -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<ComposeContainer[]> {
|
||||
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<ComposeContainer> {
|
||||
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;
|
||||
}
|
||||
Reference in New Issue
Block a user