import fs from 'fs/promises' import Docker from 'dockerode' import type { QuestLocale } from '@odyssey/constants' import config, { getQuestImage } from '../config.js' import logger from '../utils/logger.js' const engine = new Docker() const newContainerOptions = ( name: string, imageId: string, options: { binds?: string[] } = {} ): Docker.ContainerCreateOptions => ({ name, Image: getQuestImage(imageId), HostConfig: { NetworkMode: config.docker.network, Binds: options.binds, ExtraHosts: ['host.docker.internal:host-gateway'], }, ExposedPorts: { '9090/tcp': {}, }, Env: [`BACKEND_URL=${config.backendUrl}`], Labels: { 'traefik.enable': 'true', [`traefik.http.routers.terminal-${name}.rule`]: `PathPrefix(\`/terminal/${name}\`)`, [`traefik.http.middlewares.terminal-${name}-stripprefix.stripprefix.prefixes`]: `/terminal/${name}`, [`traefik.http.routers.terminal-${name}.middlewares`]: `terminal-${name}-stripprefix`, [`traefik.http.services.terminal-${name}.loadbalancer.server.port`]: '9090', [`traefik.http.routers.terminal-${name}.entrypoints`]: 'web', }, }) export async function createContainer( name: string, questId: string, locale: QuestLocale, imageId: string ): Promise { const binds: string[] = [] if (await questHomeExists(questId, locale)) { logger.info('Mounting quest home', questId) binds.push( `${config.docker.hostProjectRoot}/quests/${questId}/${locale}/home:/etc/skel:ro` ) } if (config.docker.mountQuest && imageId !== 'base') { logger.info('Mounting quest directory', questId) binds.push( `${config.docker.hostProjectRoot}/quests/${questId}/${locale}/home:/home/commander` ) } if (config.docker.mountCLI) { logger.info('Mounting CLI', questId) binds.push( `${config.docker.hostProjectRoot}/services/cmd-hook/bin:/usr/local/lib/cmd-hook:ro`, `${config.docker.hostProjectRoot}/services/terminal-service/bin:/usr/local/lib/terminal-service:ro` ) } const option = newContainerOptions(name, imageId, { binds }) await createNetworkIfNotExists(config.docker.network) return engine.createContainer(option) } async function createNetworkIfNotExists(network: string) { const dockerNetworks = await engine.listNetworks({ filters: { name: [network], }, }) if (dockerNetworks.length === 0) { logger.log('Creating player network', network) await engine.createNetwork({ Name: network }) } } async function questHomeExists(imageId: string, locale: QuestLocale) { try { const stat = await fs.stat( `${config.projectRoot}/quests/${imageId}/${locale}/home` ) return stat.isDirectory() } catch { return false } } export async function getAndStartContainer( id: string ): Promise { logger.debug(`Getting container: ${id}`) const container = engine.getContainer(id) if (!container) { throw new Error(`Container ${id} not found`) } if (!(await container.inspect()).State.Running) { await container.start() } return container } export async function deleteContainer(id: string) { const container = engine.getContainer(id) if (!container) { return } try { await container.stop() } catch { logger.warn('Failed to stop container', id) } await container.remove() } function parseJSONOutput(data: string) { const { stream, error } = JSON.parse(data) if (stream) { // eslint-disable-next-line no-console console.log(stream) } if (error) { throw error } } export function buildQuestImage(questPath: string, questId: string) { return new Promise((resolve, reject) => { engine.buildImage( { context: questPath, src: ['Dockerfile', 'home'], }, { t: getQuestImage(questId), networkmode: 'none', memory: 10 * 1e6, }, (err, response) => { if (err) { reject(err) } if (response) { response.on('data', (data) => { // Process the data (this could be Docker build output) try { data .toString() .split('\n') .map((line: string) => line.trim()) .filter((line: string) => line !== '') .forEach((line: string) => { parseJSONOutput(line) }) } catch (error) { reject(error) } }) response.on('end', () => { logger.info(`Build completed for ${questId}`) resolve(questId) }) response.on('error', (error) => { logger.error(`Build failed for ${questId}:`, error) reject(error) }) } } ) }) }