"use strict"; var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) { if (k2 === undefined) k2 = k; var desc = Object.getOwnPropertyDescriptor(m, k); if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) { desc = { enumerable: true, get: function() { return m[k]; } }; } Object.defineProperty(o, k2, desc); }) : (function(o, m, k, k2) { if (k2 === undefined) k2 = k; o[k2] = m[k]; })); var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) { Object.defineProperty(o, "default", { enumerable: true, value: v }); }) : function(o, v) { o["default"] = v; }); var __importStar = (this && this.__importStar) || (function () { var ownKeys = function(o) { ownKeys = Object.getOwnPropertyNames || function (o) { var ar = []; for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k; return ar; }; return ownKeys(o); }; return function (mod) { if (mod && mod.__esModule) return mod; var result = {}; if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]); __setModuleDefault(result, mod); return result; }; })(); Object.defineProperty(exports, "__esModule", { value: true }); exports.createCompanyContainer = createCompanyContainer; exports.startCompanyContainer = startCompanyContainer; exports.stopCompanyContainer = stopCompanyContainer; exports.restartCompanyContainer = restartCompanyContainer; exports.removeCompanyContainer = removeCompanyContainer; exports.redeployCompanyContainer = redeployCompanyContainer; exports.getContainerLogs = getContainerLogs; exports.syncContainerStatuses = syncContainerStatuses; const child_process_1 = require("child_process"); const util_1 = require("util"); const fs = __importStar(require("fs/promises")); const path = __importStar(require("path")); const prisma_1 = require("../lib/prisma"); const db = prisma_1.prisma; const execFileAsync = (0, util_1.promisify)(child_process_1.execFile); let composeCommand = 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'; // ── Internal helpers ────────────────────────────────────────────────────────── function serviceName(slug) { return `company-${slug}`; } function containerName(slug) { return `rdg-company-${slug}`; } async function ensureDir() { await fs.mkdir(COMPOSE_DIR, { recursive: true }); } async function readServices() { try { const raw = await fs.readFile(SERVICES_FILE, 'utf8'); return JSON.parse(raw); } catch { return {}; } } async function writeServices(services) { await ensureDir(); await fs.writeFile(SERVICES_FILE, JSON.stringify(services, null, 2), 'utf8'); await fs.writeFile(COMPOSE_FILE, buildComposeYaml(services), 'utf8'); } async function composeFilesExist() { try { await fs.access(COMPOSE_FILE); await fs.access(SERVICES_FILE); return true; } catch { return false; } } async function rebuildServicesFromDatabase() { const records = await db.companyContainer.findMany({ include: { company: { select: { slug: true } } }, }); return Object.fromEntries(records.map((record) => [ serviceName(record.company.slug), { companyId: record.companyId, slug: record.company.slug, image: record.image, port: record.port, }, ])); } async function ensureComposeFiles() { if (await composeFilesExist()) return; const services = await rebuildServicesFromDatabase(); await writeServices(services); } function buildComposeYaml(services) { const lines = ['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() { 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) { 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, details) { const err = Object.assign(new Error(message), { statusCode: 503, code: 'docker_unavailable', }); if (details) { ; err.details = details; } return err; } function mapDockerError(err) { if (err && typeof err === 'object') { const error = err; 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, message) { await db.companyContainer.update({ where: { companyId }, data: { status: 'ERROR', errorMessage: message }, }).catch(() => null); } async function runContainerAction(companyId, slug, command, successStatus, preStatus) { 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() { 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) { 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) { switch (state.toLowerCase()) { case 'running': return 'RUNNING'; case 'restarting': return 'RESTARTING'; case 'exited': case 'created': case 'paused': return 'STOPPED'; default: return 'ERROR'; } } // ── Public API ──────────────────────────────────────────────────────────────── async function createCompanyContainer(company) { 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; } } async function startCompanyContainer(companyId) { const record = await db.companyContainer.findUniqueOrThrow({ where: { companyId }, include: { company: { select: { slug: true } } }, }); await runContainerAction(companyId, record.company.slug, 'start', 'RUNNING'); } async function stopCompanyContainer(companyId) { const record = await db.companyContainer.findUniqueOrThrow({ where: { companyId }, include: { company: { select: { slug: true } } }, }); await runContainerAction(companyId, record.company.slug, 'stop', 'STOPPED'); } async function restartCompanyContainer(companyId) { const record = await db.companyContainer.findUniqueOrThrow({ where: { companyId }, include: { company: { select: { slug: true } } }, }); await runContainerAction(companyId, record.company.slug, 'restart', 'RUNNING', 'RESTARTING'); } async function removeCompanyContainer(companyId) { 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 } }); } async function redeployCompanyContainer(company) { 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); } async function getContainerLogs(companyId, tail = 150) { 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 ''; } } async function syncContainerStatuses() { 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); const stateByService = Object.fromEntries(rows.map((r) => [r.Service, r.State])); await Promise.allSettled(records.map(async (rec) => { 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 } } //# sourceMappingURL=containerService.js.map