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:
@@ -27,6 +27,8 @@ import {
|
|||||||
} from '@/lib/workspace-gcs';
|
} from '@/lib/workspace-gcs';
|
||||||
import { VIBN_GCS_LOCATION } from '@/lib/gcp/storage';
|
import { VIBN_GCS_LOCATION } from '@/lib/gcp/storage';
|
||||||
import { getApplicationRuntimeLogs } from '@/lib/coolify-logs';
|
import { getApplicationRuntimeLogs } from '@/lib/coolify-logs';
|
||||||
|
import { execInCoolifyApp } from '@/lib/coolify-exec';
|
||||||
|
import { isCoolifySshConfigured } from '@/lib/coolify-ssh';
|
||||||
import {
|
import {
|
||||||
deployApplication,
|
deployApplication,
|
||||||
getApplicationInProject,
|
getApplicationInProject,
|
||||||
@@ -99,6 +101,7 @@ export async function GET() {
|
|||||||
'apps.domains.list',
|
'apps.domains.list',
|
||||||
'apps.domains.set',
|
'apps.domains.set',
|
||||||
'apps.logs',
|
'apps.logs',
|
||||||
|
'apps.exec',
|
||||||
'apps.envs.list',
|
'apps.envs.list',
|
||||||
'apps.envs.upsert',
|
'apps.envs.upsert',
|
||||||
'apps.envs.delete',
|
'apps.envs.delete',
|
||||||
@@ -193,6 +196,8 @@ export async function POST(request: Request) {
|
|||||||
return await toolAppsDomainsSet(principal, params);
|
return await toolAppsDomainsSet(principal, params);
|
||||||
case 'apps.logs':
|
case 'apps.logs':
|
||||||
return await toolAppsLogs(principal, params);
|
return await toolAppsLogs(principal, params);
|
||||||
|
case 'apps.exec':
|
||||||
|
return await toolAppsExec(principal, params);
|
||||||
|
|
||||||
case 'databases.list':
|
case 'databases.list':
|
||||||
return await toolDatabasesList(principal);
|
return await toolDatabasesList(principal);
|
||||||
@@ -432,6 +437,78 @@ async function toolAppsLogs(principal: Principal, params: Record<string, any>) {
|
|||||||
return NextResponse.json({ result });
|
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<string, any>) {
|
||||||
|
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<string, any>) {
|
async function toolAppsEnvsList(principal: Principal, params: Record<string, any>) {
|
||||||
const projectUuid = requireCoolifyProject(principal);
|
const projectUuid = requireCoolifyProject(principal);
|
||||||
if (projectUuid instanceof NextResponse) return projectUuid;
|
if (projectUuid instanceof NextResponse) return projectUuid;
|
||||||
|
|||||||
85
app/api/workspaces/[slug]/apps/[uuid]/exec/route.ts
Normal file
85
app/api/workspaces/[slug]/apps/[uuid]/exec/route.ts
Normal file
@@ -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<string, unknown>;
|
||||||
|
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 },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
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;
|
||||||
|
}
|
||||||
118
lib/coolify-exec.ts
Normal file
118
lib/coolify-exec.ts
Normal file
@@ -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<ExecInAppResult> {
|
||||||
|
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,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -19,6 +19,7 @@
|
|||||||
|
|
||||||
import { getApplication, getApplicationRuntimeLogsFromApi, type CoolifyApplication } from '@/lib/coolify';
|
import { getApplication, getApplicationRuntimeLogsFromApi, type CoolifyApplication } from '@/lib/coolify';
|
||||||
import { isCoolifySshConfigured, runOnCoolifyHost } from '@/lib/coolify-ssh';
|
import { isCoolifySshConfigured, runOnCoolifyHost } from '@/lib/coolify-ssh';
|
||||||
|
import { listContainersForApp } from '@/lib/coolify-containers';
|
||||||
|
|
||||||
export type LogsSource = 'coolify_api' | 'ssh_docker' | 'empty';
|
export type LogsSource = 'coolify_api' | 'ssh_docker' | 'empty';
|
||||||
|
|
||||||
@@ -133,65 +134,45 @@ async function fetchComposeLogsViaSsh(
|
|||||||
const uuid = app.uuid as string;
|
const uuid = app.uuid as string;
|
||||||
const buildPack = (app.build_pack ?? 'dockercompose') as string;
|
const buildPack = (app.build_pack ?? 'dockercompose') as string;
|
||||||
|
|
||||||
// 1) Enumerate containers that belong to this app. Coolify's naming
|
// 1) Enumerate containers that belong to this app via the shared
|
||||||
// convention always includes the app uuid in the container name
|
// helper. Coolify's naming convention always includes the app
|
||||||
// (either as suffix for compose or as the name itself for single-
|
// uuid (suffix for compose, embedded for single-container).
|
||||||
// container apps).
|
let containers: Array<{ name: string; status: string; service: string }>;
|
||||||
const ps = await runOnCoolifyHost(
|
try {
|
||||||
`docker ps -a --filter ${sq('name=' + uuid)} --format '{{.Names}}\t{{.Status}}'`,
|
const raw = await listContainersForApp(uuid);
|
||||||
{ timeoutMs: 8_000 },
|
containers = raw.map(c => ({ name: c.name, status: c.status, service: c.service }));
|
||||||
);
|
} catch (err) {
|
||||||
if (ps.code !== 0) {
|
warnings.push(err instanceof Error ? err.message : String(err));
|
||||||
warnings.push(`docker ps exited ${ps.code}: ${ps.stderr.trim()}`);
|
return { uuid, buildPack, source: 'empty', services: {}, warnings, truncated: false };
|
||||||
return { uuid, buildPack, source: 'empty', services: {}, warnings, truncated: ps.truncated };
|
|
||||||
}
|
}
|
||||||
|
|
||||||
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) {
|
if (containers.length === 0) {
|
||||||
warnings.push('No containers found on Coolify host for this app uuid. Deployment may not have started yet.');
|
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 };
|
return { uuid, buildPack, source: 'ssh_docker', services: {}, warnings, truncated: false };
|
||||||
}
|
}
|
||||||
|
|
||||||
// 2) Derive a service name from each container.
|
// 2) Tail logs per container. listContainersForApp already derives
|
||||||
//
|
// the service name from each container; we just need to filter
|
||||||
// Coolify names compose containers like `{service}-{appUuid}-{short}`
|
// by the caller's requested service (if any).
|
||||||
// For single-container apps it's just `{appSlug}-{appUuid}` or similar.
|
|
||||||
// We strip the uuid to recover the service name.
|
|
||||||
const services: Record<string, ServiceLogs> = {};
|
const services: Record<string, ServiceLogs> = {};
|
||||||
let anyTruncated = false;
|
let anyTruncated = false;
|
||||||
|
|
||||||
const targets = containers.filter(c => {
|
const targets = containers.filter(c => !filterService || c.service === filterService);
|
||||||
if (!filterService) return true;
|
|
||||||
return c.name.startsWith(filterService + '-') || c.name === filterService;
|
|
||||||
});
|
|
||||||
if (filterService && targets.length === 0) {
|
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) {
|
for (const c of targets) {
|
||||||
// service name = name with -<uuid>-<hash> suffix stripped
|
const key = services[c.service] ? c.name : c.service;
|
||||||
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 logsRes = await runOnCoolifyHost(
|
const logsRes = await runOnCoolifyHost(
|
||||||
`docker logs --tail ${Math.floor(lines)} --timestamps ${sq(c.name)} 2>&1`,
|
`docker logs --tail ${Math.floor(lines)} --timestamps ${sq(c.name)} 2>&1`,
|
||||||
{ timeoutMs: 10_000, maxBytes: 1_000_000 },
|
{ timeoutMs: 10_000, maxBytes: 1_000_000 },
|
||||||
);
|
);
|
||||||
if (logsRes.truncated) anyTruncated = true;
|
if (logsRes.truncated) anyTruncated = true;
|
||||||
const text = logsRes.stdout; // stderr is merged via `2>&1`
|
const text = logsRes.stdout; // stderr merged via `2>&1`
|
||||||
services[service] = {
|
services[key] = {
|
||||||
container: c.name,
|
container: c.name,
|
||||||
lines: text ? text.split('\n').length : 0,
|
lines: text ? text.split('\n').length : 0,
|
||||||
bytes: Buffer.byteLength(text, 'utf8'),
|
bytes: Buffer.byteLength(text, 'utf8'),
|
||||||
|
|||||||
Reference in New Issue
Block a user