import { execFile } from 'child_process' import { promisify } from 'util' import * as fs from 'fs/promises' import * as path from 'path' import { prisma as _prisma } from '../lib/prisma' const db = _prisma as any const execFileAsync = promisify(execFile) let composeCommand: 'docker-compose' | 'docker' | null = null // ── Config ──────────────────────────────────────────────────────────────────── const COMPOSE_DIR = process.env.COMPANIES_COMPOSE_DIR || '/opt/companies' const COMPOSE_FILE = path.join(COMPOSE_DIR, 'docker-compose.companies.yml') const SERVICES_FILE = path.join(COMPOSE_DIR, 'companies-services.json') // our source of truth const DASHBOARD_IMAGE = process.env.DASHBOARD_CONTAINER_IMAGE || 'rentaldrivego/dashboard:latest' const PORT_RANGE_START = parseInt(process.env.CONTAINER_PORT_START || '5100', 10) const PORT_RANGE_END = parseInt(process.env.CONTAINER_PORT_END || '5999', 10) const API_INTERNAL_URL = process.env.API_INTERNAL_URL || 'http://api:4000' const CONTAINER_NETWORK = process.env.CONTAINER_NETWORK || 'rentaldrivego_default' // ── Types ───────────────────────────────────────────────────────────────────── interface ServiceDef { companyId: string slug: string image: string port: number } type ServicesMap = Record // key = service name e.g. "company-slug" type ServiceStatus = 'RUNNING' | 'STOPPED' | 'RESTARTING' | 'ERROR' // ── Internal helpers ────────────────────────────────────────────────────────── function serviceName(slug: string) { return `company-${slug}` } function containerName(slug: string) { return `rdg-company-${slug}` } async function ensureDir(): Promise { await fs.mkdir(COMPOSE_DIR, { recursive: true }) } async function readServices(): Promise { try { const raw = await fs.readFile(SERVICES_FILE, 'utf8') return JSON.parse(raw) } catch { return {} } } async function writeServices(services: ServicesMap): Promise { await ensureDir() await fs.writeFile(SERVICES_FILE, JSON.stringify(services, null, 2), 'utf8') await fs.writeFile(COMPOSE_FILE, buildComposeYaml(services), 'utf8') } async function composeFilesExist(): Promise { try { await fs.access(COMPOSE_FILE) await fs.access(SERVICES_FILE) return true } catch { return false } } async function rebuildServicesFromDatabase(): Promise { const records = await db.companyContainer.findMany({ include: { company: { select: { slug: true } } }, }) return Object.fromEntries( records.map((record: any) => [ serviceName(record.company.slug), { companyId: record.companyId, slug: record.company.slug, image: record.image, port: record.port, }, ]), ) } async function ensureComposeFiles(): Promise { if (await composeFilesExist()) return const services = await rebuildServicesFromDatabase() await writeServices(services) } function buildComposeYaml(services: ServicesMap): string { const lines: string[] = ['services:'] for (const [name, svc] of Object.entries(services)) { lines.push(` ${name}:`) lines.push(` image: "${svc.image}"`) lines.push(` container_name: "${containerName(svc.slug)}"`) lines.push(` restart: unless-stopped`) lines.push(` environment:`) lines.push(` COMPANY_ID: "${svc.companyId}"`) lines.push(` COMPANY_SLUG: "${svc.slug}"`) lines.push(` API_URL: "${API_INTERNAL_URL}"`) lines.push(` NEXT_PUBLIC_API_URL: "${API_INTERNAL_URL}"`) lines.push(` PORT: "3000"`) lines.push(` NODE_ENV: "production"`) lines.push(` ports:`) lines.push(` - "${svc.port}:3000"`) lines.push(` networks:`) lines.push(` - ${CONTAINER_NETWORK}`) lines.push(` labels:`) lines.push(` rdg.managed: "true"`) lines.push(` rdg.company.id: "${svc.companyId}"`) lines.push(` rdg.company.slug: "${svc.slug}"`) } lines.push('') lines.push('networks:') lines.push(` ${CONTAINER_NETWORK}:`) lines.push(` external: true`) lines.push('') return lines.join('\n') } async function resolveComposeCommand(): Promise<'docker-compose' | 'docker'> { if (composeCommand) return composeCommand try { await execFileAsync('docker-compose', ['version']) composeCommand = 'docker-compose' return composeCommand } catch { try { await execFileAsync('docker', ['compose', 'version']) composeCommand = 'docker' return composeCommand } catch (err) { throw mapDockerError(err) } } } async function compose(...args: string[]): Promise<{ stdout: string; stderr: string }> { try { await ensureComposeFiles() const command = await resolveComposeCommand() if (command === 'docker-compose') { return await execFileAsync('docker-compose', ['-f', COMPOSE_FILE, ...args]) } return await execFileAsync('docker', ['compose', '-f', COMPOSE_FILE, ...args]) } catch (err) { throw mapDockerError(err) } } function dockerUnavailableError(message: string, details?: string) { const err = Object.assign(new Error(message), { statusCode: 503, code: 'docker_unavailable', }) if (details) { ;(err as Error & { details?: string }).details = details } return err } function mapDockerError(err: unknown) { if (err && typeof err === 'object') { const error = err as NodeJS.ErrnoException & { stderr?: string; stdout?: string } const stderr = error.stderr?.trim() if (error.code === 'ENOENT' && error.path === 'docker') { return dockerUnavailableError( 'Docker CLI is not available to the API service.', 'Install Docker in the API container or run the API on a host with Docker available in PATH.', ) } if (error.code === 'ENOENT' && error.path === 'docker-compose') { return dockerUnavailableError( 'Docker Compose is not available to the API service.', 'Install Docker Compose in the API container or switch the service to a runtime that supports `docker compose`.', ) } if ( stderr?.includes("docker: 'compose' is not a docker command.") || stderr?.includes("unknown shorthand flag: 'f' in -f") ) { return dockerUnavailableError( 'Docker Compose is not available to the API service.', 'Install Docker Compose in the API container or switch the service to a runtime that supports `docker compose`.', ) } if ( stderr?.includes('Cannot connect to the Docker daemon') || stderr?.includes('permission denied while trying to connect to the Docker daemon socket') || stderr?.includes('error during connect') ) { return dockerUnavailableError( 'Docker daemon is not reachable from the API service.', 'Mount `/var/run/docker.sock` into the API container and ensure the Docker daemon is running.', ) } // Catch-all: docker ran but exited non-zero — surface stderr as a readable 502 if (typeof error.code === 'number' && error.code !== 0) { const detail = stderr || error.stdout?.trim() || 'docker compose exited with a non-zero status' return Object.assign(new Error(detail), { statusCode: 502, code: 'docker_error' }) } } return err } async function setContainerError(companyId: string, message: string): Promise { await db.companyContainer.update({ where: { companyId }, data: { status: 'ERROR', errorMessage: message }, }).catch(() => null) } async function runContainerAction( companyId: string, slug: string, command: 'start' | 'stop' | 'restart', successStatus: Exclude, preStatus?: Exclude, ): Promise { if (preStatus) { await db.companyContainer.update({ where: { companyId }, data: { status: preStatus, errorMessage: null } }) } try { // Use `up -d --no-recreate` for start so it creates the container if it doesn't exist yet const args = command === 'start' ? ['up', '-d', '--no-recreate', serviceName(slug)] : [command, serviceName(slug)] await compose(...args) await db.companyContainer.update({ where: { companyId }, data: { status: successStatus, errorMessage: null }, }) } catch (err) { const message = err instanceof Error ? err.message : `Failed to ${command} container` await setContainerError(companyId, message) throw err } } async function allocatePort(): Promise { const last = await db.companyContainer.findFirst({ orderBy: { port: 'desc' }, select: { port: true }, }) const next = last ? last.port + 1 : PORT_RANGE_START if (next > PORT_RANGE_END) throw new Error('No available ports in container port range') return next } async function getDockerServiceId(slug: string): Promise { try { const name = serviceName(slug) const { stdout } = await compose('ps', '--format', 'json', name) const line = stdout.trim().split('\n')[0] if (!line) return null const info = JSON.parse(line) return info.ID ?? info.Id ?? null } catch { return null } } // Maps docker compose State strings to our DB enum function mapDockerState(state: string): ServiceStatus { switch (state.toLowerCase()) { case 'running': return 'RUNNING' case 'restarting': return 'RESTARTING' case 'exited': case 'created': case 'paused': return 'STOPPED' default: return 'ERROR' } } // ── Public API ──────────────────────────────────────────────────────────────── export async function createCompanyContainer(company: { id: string slug: string name: string }): Promise { const name = serviceName(company.slug) const port = await allocatePort() const record = await db.companyContainer.create({ data: { companyId: company.id, containerName: containerName(company.slug), status: 'CREATING', port, image: DASHBOARD_IMAGE, }, }) try { const services = await readServices() services[name] = { companyId: company.id, slug: company.slug, image: DASHBOARD_IMAGE, port } await writeServices(services) await compose('up', '-d', '--no-recreate', name) const dockerId = await getDockerServiceId(company.slug) await db.companyContainer.update({ where: { id: record.id }, data: { dockerId, status: 'RUNNING' }, }) } catch (err) { await db.companyContainer.update({ where: { id: record.id }, data: { status: 'ERROR', errorMessage: err instanceof Error ? err.message : 'Unknown error during service creation', }, }) throw err } } export async function startCompanyContainer(companyId: string): Promise { const record = await db.companyContainer.findUniqueOrThrow({ where: { companyId }, include: { company: { select: { slug: true } } }, }) await runContainerAction(companyId, record.company.slug, 'start', 'RUNNING') } export async function stopCompanyContainer(companyId: string): Promise { const record = await db.companyContainer.findUniqueOrThrow({ where: { companyId }, include: { company: { select: { slug: true } } }, }) await runContainerAction(companyId, record.company.slug, 'stop', 'STOPPED') } export async function restartCompanyContainer(companyId: string): Promise { const record = await db.companyContainer.findUniqueOrThrow({ where: { companyId }, include: { company: { select: { slug: true } } }, }) await runContainerAction(companyId, record.company.slug, 'restart', 'RUNNING', 'RESTARTING') } export async function removeCompanyContainer(companyId: string): Promise { const record = await db.companyContainer.findUniqueOrThrow({ where: { companyId }, include: { company: { select: { slug: true } } }, }) const name = serviceName(record.company.slug) await db.companyContainer.update({ where: { companyId }, data: { status: 'REMOVING' } }) try { await compose('stop', name) } catch { /* already stopped */ } try { await compose('rm', '-f', name) } catch { /* already removed */ } const services = await readServices() delete services[name] await writeServices(services) await db.companyContainer.delete({ where: { companyId } }) } export async function redeployCompanyContainer(company: { id: string slug: string name: string }): Promise { const name = serviceName(company.slug) const existing = await db.companyContainer.findUnique({ where: { companyId: company.id } }) if (existing) { try { await compose('stop', name) } catch { /* ok */ } try { await compose('rm', '-f', name) } catch { /* ok */ } await db.companyContainer.delete({ where: { companyId: company.id } }) } // Remove from compose file too, then recreate const services = await readServices() delete services[name] await writeServices(services) await createCompanyContainer(company) } export async function getContainerLogs(companyId: string, tail = 150): Promise { const record = await db.companyContainer.findUniqueOrThrow({ where: { companyId }, include: { company: { select: { slug: true } } }, }) try { const { stdout } = await compose( 'logs', '--no-log-prefix', `--tail=${tail}`, serviceName(record.company.slug), ) return stdout } catch { return '' } } export async function syncContainerStatuses(): Promise { const records = await db.companyContainer.findMany({ where: { status: { notIn: ['PENDING', 'CREATING', 'REMOVING'] } }, include: { company: { select: { slug: true } } }, }) try { // Get all compose service states in one shot const { stdout } = await compose('ps', '--format', 'json') const rows = stdout .trim() .split('\n') .filter(Boolean) .map((line) => { try { return JSON.parse(line) } catch { return null } }) .filter(Boolean) as Array<{ Service: string; State: string }> const stateByService = Object.fromEntries(rows.map((r) => [r.Service, r.State])) await Promise.allSettled( records.map(async (rec: any) => { const name = serviceName(rec.company.slug) const dockerState = stateByService[name] const status = dockerState ? mapDockerState(dockerState) : 'STOPPED' if (rec.status !== status) { await db.companyContainer.update({ where: { id: rec.id }, data: { status } }) } }), ) } catch { // Docker daemon unreachable — leave statuses as-is } }