/** * 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 { 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, }; }