feat(mcp): apps.logs — compose-aware runtime logs
Adds apps.logs MCP tool + session REST endpoint for tailing runtime
container logs. Unblocks cold-start debugging for agent-deployed
compose apps (Twenty, Cal.com, Plane, etc.) where Coolify's own
/applications/{uuid}/logs endpoint returns empty.
Architecture:
- dockerfile / nixpacks / static apps → Coolify's REST logs API
- dockercompose apps → SSH into Coolify host,
`docker logs` per service
New SSH path uses a dedicated `vibn-logs` user (docker group, no
sudo, no pty, no port-forwarding, single ed25519 key). Private key
lives in COOLIFY_SSH_PRIVATE_KEY_B64 on the vibn-frontend Coolify
app; authorized_key is installed by scripts/setup-vibn-logs-user.sh
on the Coolify host.
Tool shape:
params: { uuid, service?, lines? (default 200, max 5000) }
returns: { uuid, buildPack, source: 'coolify_api'|'ssh_docker'|'empty',
services: { [name]: { container, lines, bytes, logs, status? } },
warnings: string[], truncated: boolean }
Made-with: Cursor
This commit is contained in:
133
lib/coolify-ssh.ts
Normal file
133
lib/coolify-ssh.ts
Normal file
@@ -0,0 +1,133 @@
|
||||
/**
|
||||
* One-shot SSH-exec helper into the Coolify host.
|
||||
*
|
||||
* Used to pull docker runtime data that the Coolify REST API doesn't
|
||||
* surface cleanly (per-service logs for compose apps, container state,
|
||||
* etc.). Strictly command-in / string-out — we never hold a persistent
|
||||
* channel and we never expose an interactive shell to callers.
|
||||
*
|
||||
* Required env (injected into vibn-frontend at deploy time):
|
||||
* COOLIFY_SSH_HOST e.g. 34.19.250.135 or coolify.vibnai.com
|
||||
* COOLIFY_SSH_PORT default 22
|
||||
* COOLIFY_SSH_USER default "vibn-logs"
|
||||
* COOLIFY_SSH_PRIVATE_KEY_B64 base64-encoded PEM private key
|
||||
*
|
||||
* The authorised key lives in ~vibn-logs/.ssh/authorized_keys on the
|
||||
* Coolify host. That user must be a member of the `docker` group so
|
||||
* `docker logs`/`docker ps` work without sudo; it should NOT have
|
||||
* sudo rights.
|
||||
*/
|
||||
|
||||
import { Client, type ConnectConfig } from 'ssh2';
|
||||
|
||||
const DEFAULT_EXEC_TIMEOUT_MS = 10_000;
|
||||
const DEFAULT_MAX_BYTES = 1_000_000; // 1 MB – more than enough for `docker logs --tail 2000`
|
||||
|
||||
export interface CoolifySshResult {
|
||||
code: number | null;
|
||||
stdout: string;
|
||||
stderr: string;
|
||||
truncated: boolean;
|
||||
}
|
||||
|
||||
export interface CoolifySshOptions {
|
||||
/** Millis to wait for the command to finish before killing the channel. */
|
||||
timeoutMs?: number;
|
||||
/** Cap combined stdout+stderr. Extra bytes are dropped and `truncated=true`. */
|
||||
maxBytes?: number;
|
||||
}
|
||||
|
||||
function loadConnectConfig(): ConnectConfig {
|
||||
const host = process.env.COOLIFY_SSH_HOST;
|
||||
const keyB64 = process.env.COOLIFY_SSH_PRIVATE_KEY_B64;
|
||||
if (!host) throw new Error('COOLIFY_SSH_HOST is not set');
|
||||
if (!keyB64) throw new Error('COOLIFY_SSH_PRIVATE_KEY_B64 is not set');
|
||||
const privateKey = Buffer.from(keyB64, 'base64').toString('utf8');
|
||||
const port = Number(process.env.COOLIFY_SSH_PORT ?? 22);
|
||||
const username = process.env.COOLIFY_SSH_USER ?? 'vibn-logs';
|
||||
return {
|
||||
host,
|
||||
port,
|
||||
username,
|
||||
privateKey,
|
||||
readyTimeout: 8_000,
|
||||
// We accept the host key on first connect; we're always connecting
|
||||
// to infra we own, but a future hardening pass should pin the host
|
||||
// key fingerprint via `hostVerifier`.
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Run a single command on the Coolify host and collect its output.
|
||||
*
|
||||
* The helper enforces:
|
||||
* - a wall-clock timeout (default 10s)
|
||||
* - a combined-output byte cap (default 1 MB)
|
||||
* - always-close semantics (connection is destroyed on return / throw)
|
||||
*/
|
||||
export function runOnCoolifyHost(
|
||||
command: string,
|
||||
opts: CoolifySshOptions = {},
|
||||
): Promise<CoolifySshResult> {
|
||||
const cfg = loadConnectConfig();
|
||||
const timeoutMs = opts.timeoutMs ?? DEFAULT_EXEC_TIMEOUT_MS;
|
||||
const maxBytes = opts.maxBytes ?? DEFAULT_MAX_BYTES;
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
const conn = new Client();
|
||||
let settled = false;
|
||||
let stdout = '';
|
||||
let stderr = '';
|
||||
let truncated = false;
|
||||
const settle = (fn: () => void) => {
|
||||
if (settled) return;
|
||||
settled = true;
|
||||
clearTimeout(timer);
|
||||
try { conn.end(); } catch { /* noop */ }
|
||||
fn();
|
||||
};
|
||||
const timer = setTimeout(() => {
|
||||
settle(() => reject(new Error(`[coolify-ssh] timeout after ${timeoutMs}ms running: ${command}`)));
|
||||
}, timeoutMs);
|
||||
|
||||
conn
|
||||
.on('ready', () => {
|
||||
conn.exec(command, (err, stream) => {
|
||||
if (err) return settle(() => reject(err));
|
||||
stream
|
||||
.on('close', (code: number | null) => {
|
||||
settle(() => resolve({ code, stdout, stderr, truncated }));
|
||||
})
|
||||
.on('data', (chunk: Buffer) => {
|
||||
if (stdout.length + stderr.length + chunk.length > maxBytes) {
|
||||
truncated = true;
|
||||
const room = Math.max(0, maxBytes - stdout.length - stderr.length);
|
||||
stdout += chunk.slice(0, room).toString('utf8');
|
||||
} else {
|
||||
stdout += chunk.toString('utf8');
|
||||
}
|
||||
})
|
||||
.stderr.on('data', (chunk: Buffer) => {
|
||||
if (stdout.length + stderr.length + chunk.length > maxBytes) {
|
||||
truncated = true;
|
||||
const room = Math.max(0, maxBytes - stdout.length - stderr.length);
|
||||
stderr += chunk.slice(0, room).toString('utf8');
|
||||
} else {
|
||||
stderr += chunk.toString('utf8');
|
||||
}
|
||||
});
|
||||
});
|
||||
})
|
||||
.on('error', (err) => settle(() => reject(err)))
|
||||
.connect(cfg);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* True if the env is configured to talk to the Coolify host.
|
||||
* Callers use this to gracefully degrade when SSH isn't available
|
||||
* (e.g. local dev without the key pair mounted).
|
||||
*/
|
||||
export function isCoolifySshConfigured(): boolean {
|
||||
return !!(process.env.COOLIFY_SSH_HOST && process.env.COOLIFY_SSH_PRIVATE_KEY_B64);
|
||||
}
|
||||
Reference in New Issue
Block a user