fix first online resevation

This commit is contained in:
root
2026-05-09 20:01:51 -04:00
parent c4a45c8b21
commit 09b0e3b55f
75 changed files with 6394 additions and 2190 deletions
+467
View File
@@ -0,0 +1,467 @@
import { execFile } from 'child_process'
import { promisify } from 'util'
import * as fs from 'fs/promises'
import * as path from 'path'
import { prisma } from '../lib/prisma'
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<string, ServiceDef> // 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<void> {
await fs.mkdir(COMPOSE_DIR, { recursive: true })
}
async function readServices(): Promise<ServicesMap> {
try {
const raw = await fs.readFile(SERVICES_FILE, 'utf8')
return JSON.parse(raw)
} catch {
return {}
}
}
async function writeServices(services: ServicesMap): Promise<void> {
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<boolean> {
try {
await fs.access(COMPOSE_FILE)
await fs.access(SERVICES_FILE)
return true
} catch {
return false
}
}
async function rebuildServicesFromDatabase(): Promise<ServicesMap> {
const records = await prisma.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(): Promise<void> {
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<void> {
await prisma.companyContainer.update({
where: { companyId },
data: { status: 'ERROR', errorMessage: message },
}).catch(() => null)
}
async function runContainerAction(
companyId: string,
slug: string,
command: 'start' | 'stop' | 'restart',
successStatus: Exclude<ServiceStatus, 'ERROR'>,
preStatus?: Exclude<ServiceStatus, 'ERROR'>,
): Promise<void> {
if (preStatus) {
await prisma.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 prisma.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<number> {
const last = await prisma.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<string | null> {
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<void> {
const name = serviceName(company.slug)
const port = await allocatePort()
const record = await prisma.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 prisma.companyContainer.update({
where: { id: record.id },
data: { dockerId, status: 'RUNNING' },
})
} catch (err) {
await prisma.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<void> {
const record = await prisma.companyContainer.findUniqueOrThrow({
where: { companyId },
include: { company: { select: { slug: true } } },
})
await runContainerAction(companyId, record.company.slug, 'start', 'RUNNING')
}
export async function stopCompanyContainer(companyId: string): Promise<void> {
const record = await prisma.companyContainer.findUniqueOrThrow({
where: { companyId },
include: { company: { select: { slug: true } } },
})
await runContainerAction(companyId, record.company.slug, 'stop', 'STOPPED')
}
export async function restartCompanyContainer(companyId: string): Promise<void> {
const record = await prisma.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<void> {
const record = await prisma.companyContainer.findUniqueOrThrow({
where: { companyId },
include: { company: { select: { slug: true } } },
})
const name = serviceName(record.company.slug)
await prisma.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 prisma.companyContainer.delete({ where: { companyId } })
}
export async function redeployCompanyContainer(company: {
id: string
slug: string
name: string
}): Promise<void> {
const name = serviceName(company.slug)
const existing = await prisma.companyContainer.findUnique({ where: { companyId: company.id } })
if (existing) {
try { await compose('stop', name) } catch { /* ok */ }
try { await compose('rm', '-f', name) } catch { /* ok */ }
await prisma.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<string> {
const record = await prisma.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<void> {
const records = await prisma.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) => {
const name = serviceName(rec.company.slug)
const dockerState = stateByService[name]
const status = dockerState ? mapDockerState(dockerState) : 'STOPPED'
if (rec.status !== status) {
await prisma.companyContainer.update({ where: { id: rec.id }, data: { status } })
}
}),
)
} catch {
// Docker daemon unreachable — leave statuses as-is
}
}