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