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
110 lines
3.7 KiB
TypeScript
110 lines
3.7 KiB
TypeScript
/**
|
|
* 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;
|
|
}
|