Files
alrahma_sunday_school_api/apps/api/dist/services/containerService.js
T
2026-06-11 03:22:12 -04:00

421 lines
16 KiB
JavaScript

"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