import Service from "#models/service"; import Docker from "dockerode"; import drive from '@adonisjs/drive/services/main' import axios from 'axios'; import logger from '@adonisjs/core/services/logger' import transmit from '@adonisjs/transmit/services/main' import { inject } from "@adonisjs/core"; import { ServiceStatus } from "../../types/services.js"; @inject() export class DockerService { private docker: Docker; public static KIWIX_SERVICE_NAME = 'nomad_kiwix_serve'; public static OPENSTREETMAP_SERVICE_NAME = 'nomad_openstreetmap'; public static OPENSTREETMAP_IMPORT_SERVICE_NAME = 'nomad_openstreetmap_import'; public static OLLAMA_SERVICE_NAME = 'nomad_ollama'; public static OPEN_WEBUI_SERVICE_NAME = 'nomad_open_webui'; constructor() { this.docker = new Docker({ socketPath: '/var/run/docker.sock' }); } async affectContainer(serviceName: string, action: 'start' | 'stop' | 'restart'): Promise<{ success: boolean; message: string }> { try { const service = await Service.query().where('service_name', serviceName).first(); if (!service || !service.installed) { return { success: false, message: `Service ${serviceName} not found or not installed`, }; } const containers = await this.docker.listContainers({ all: true }); const container = containers.find(c => c.Names.includes(`/${serviceName}`)); if (!container) { return { success: false, message: `Container for service ${serviceName} not found`, }; } const dockerContainer = this.docker.getContainer(container.Id); if (action === 'stop') { await dockerContainer.stop(); return { success: true, message: `Service ${serviceName} stopped successfully`, }; } if (action === 'restart') { await dockerContainer.restart(); return { success: true, message: `Service ${serviceName} restarted successfully`, }; } if (action === 'start') { if (container.State === 'running') { return { success: true, message: `Service ${serviceName} is already running`, }; } await dockerContainer.start(); } return { success: false, message: `Invalid action: ${action}. Use 'start', 'stop', or 'restart'.`, } } catch (error) { console.error(`Error starting service ${serviceName}: ${error.message}`); return { success: false, message: `Failed to start service ${serviceName}: ${error.message}`, }; } } async getServicesStatus(): Promise<{ service_name: string; status: ServiceStatus; }[]> { try { const services = await Service.query().where('installed', true); if (!services || services.length === 0) { return []; } const containers = await this.docker.listContainers({ all: true }); const containerMap = new Map(); containers.forEach(container => { const name = container.Names[0].replace('/', ''); if (name.startsWith('nomad_')) { containerMap.set(name, container); } }); const getStatus = (state: string): ServiceStatus => { switch (state) { case 'running': return 'running'; case 'exited': case 'created': case 'paused': return 'stopped'; default: return 'unknown'; } }; return Array.from(containerMap.entries()).map(([name, container]) => ({ service_name: name, status: getStatus(container.State), })); } catch (error) { console.error(`Error fetching services status: ${error.message}`); return []; } } async createContainerPreflight(serviceName: string): Promise<{ success: boolean; message: string }> { const service = await Service.query().where('service_name', serviceName).first(); if (!service) { return { success: false, message: `Service ${serviceName} not found`, }; } if (service.installed) { return { success: false, message: `Service ${serviceName} is already installed`, }; } // Check if a service wasn't marked as installed but has an existing container // This can happen if the service was created but not properly installed // or if the container was removed manually without updating the service status. // if (await this._checkIfServiceContainerExists(serviceName)) { // const removeResult = await this._removeServiceContainer(serviceName); // if (!removeResult.success) { // return { // success: false, // message: `Failed to remove existing container for service ${serviceName}: ${removeResult.message}`, // }; // } // } const containerConfig = this._parseContainerConfig(service.container_config); this._createContainer(service, containerConfig); // Don't await this method - we will use server-sent events to notify the client of progress return { success: true, message: `Service ${serviceName} installation initiated successfully. You can receive updates via server-sent events.`, } } /** * Handles the long-running process of creating a Docker container for a service. * NOTE: This method should not be called directly. Instead, use `createContainerPreflight` to check prerequisites first * and return an HTTP response to the client, if needed. This method will then transmit server-sent events to the client * to notify them of the progress. * @param serviceName * @returns */ async _createContainer(service: Service & { dependencies?: Service[] }, containerConfig: any): Promise { try { this._broadcastAndLog(service.service_name, 'initializing', ''); let dependencies = []; if (service.depends_on) { const dependency = await Service.query().where('service_name', service.depends_on).first(); if (dependency) { dependencies.push(dependency); } } // First, check if the service has any dependencies that need to be installed first if (dependencies && dependencies.length > 0) { this._broadcastAndLog(service.service_name, 'checking-dependencies', `Checking dependencies for service ${service.service_name}...`); for (const dependency of dependencies) { if (!dependency.installed) { this._broadcastAndLog(service.service_name, 'dependency-not-installed', `Dependency service ${dependency.service_name} is not installed. Installing it first...`); await this._createContainer(dependency, this._parseContainerConfig(dependency.container_config)); } else { this._broadcastAndLog(service.service_name, 'dependency-installed', `Dependency service ${dependency.service_name} is already installed.`); } } } // Start pulling the Docker image and wait for it to complete const pullStream = await this.docker.pull(service.container_image); this._broadcastAndLog(service.service_name, 'pulling', `Pulling Docker image ${service.container_image}...`); await new Promise(res => this.docker.modem.followProgress(pullStream, res)); this._broadcastAndLog(service.service_name, 'creating', `Creating Docker container for service ${service.service_name}...`); const container = await this.docker.createContainer({ Image: service.container_image, name: service.service_name, ...(containerConfig?.HostConfig && { HostConfig: containerConfig.HostConfig }), ...(containerConfig?.WorkingDir && { WorkingDir: containerConfig.WorkingDir }), ...(containerConfig?.ExposedPorts && { ExposedPorts: containerConfig.ExposedPorts }), ...(service.container_command ? { Cmd: service.container_command.split(' ') } : {}), ...(service.service_name === 'open-webui' ? { Env: ['WEBUI_AUTH=False', 'PORT=3000', 'OLLAMA_BASE_URL=http://127.0.0.1:11434'] } : {}), // Special case for Open WebUI }); if (service.service_name === DockerService.KIWIX_SERVICE_NAME) { await this._runPreinstallActions__KiwixServe(); this._broadcastAndLog(service.service_name, 'preinstall-complete', `Pre-install actions for Kiwix Serve completed successfully.`); } else if (service.service_name === DockerService.OPENSTREETMAP_SERVICE_NAME) { await this._runPreinstallActions__OpenStreetMap(service.container_image, containerConfig); this._broadcastAndLog(service.service_name, 'preinstall-complete', `Pre-install actions for OpenStreetMap completed successfully.`); } console.log("GOT HERE") this._broadcastAndLog(service.service_name, 'starting', `Starting Docker container for service ${service.service_name}...`); await container.start(); console.log("GOT HERE 2") this._broadcastAndLog(service.service_name, 'finalizing', `Finalizing installation of service ${service.service_name}...`); service.installed = true; await service.save(); console.log("GOT HERE 3") this._broadcastAndLog(service.service_name, 'completed', `Service ${service.service_name} installation completed successfully.`); } catch (error) { this._broadcastAndLog(service.service_name, 'error', `Error installing service ${service.service_name}: ${error.message}`); throw new Error(`Failed to install service ${service.service_name}: ${error.message}`); } } async _checkIfServiceContainerExists(serviceName: string): Promise { try { const containers = await this.docker.listContainers({ all: true }); return containers.some(container => container.Names.includes(`/${serviceName}`)); } catch (error) { console.error(`Error checking if service container exists: ${error.message}`); return false; } } async _removeServiceContainer(serviceName: string): Promise<{ success: boolean; message: string }> { try { const containers = await this.docker.listContainers({ all: true }); const container = containers.find(c => c.Names.includes(`/${serviceName}`)); if (!container) { return { success: false, message: `Container for service ${serviceName} not found` }; } const dockerContainer = this.docker.getContainer(container.Id); await dockerContainer.stop(); await dockerContainer.remove(); return { success: true, message: `Service ${serviceName} container removed successfully` }; } catch (error) { console.error(`Error removing service container: ${error.message}`); return { success: false, message: `Failed to remove service ${serviceName} container: ${error.message}` }; } } private async _runPreinstallActions__KiwixServe(): Promise { /** * At least one .zim file must be available before we can start the kiwix container. * We'll download the lightweight mini Wikipedia Top 100 zim file for this purpose. **/ const WIKIPEDIA_ZIM_URL = "https://download.kiwix.org/zim/wikipedia/wikipedia_en_100_mini_2025-06.zim" const PATH = '/zim/wikipedia_en_100_mini_2025-06.zim'; this._broadcastAndLog(DockerService.KIWIX_SERVICE_NAME, 'preinstall', `Running pre-install actions for Kiwix Serve...`); this._broadcastAndLog(DockerService.KIWIX_SERVICE_NAME, 'preinstall', `Downloading Wikipedia ZIM file from ${WIKIPEDIA_ZIM_URL}. This may take some time...`); const response = await axios.get(WIKIPEDIA_ZIM_URL, { responseType: 'stream', }); const stream = response.data; stream.on('error', (error: Error) => { logger.error(`Error downloading Wikipedia ZIM file: ${error.message}`); throw error; }); const disk = drive.use('fs'); await disk.putStream(PATH, stream); this._broadcastAndLog(DockerService.KIWIX_SERVICE_NAME, 'preinstall', `Downloaded Wikipedia ZIM file to ${PATH}`); } /** * Largely follows the install instructions here: https://github.com/Overv/openstreetmap-tile-server/blob/master/README.md */ private async _runPreinstallActions__OpenStreetMap(image: string, containerConfig: any): Promise { const FILE_NAME = 'us-pacific-latest.osm.pbf'; const OSM_PBF_URL = `https://download.geofabrik.de/north-america/${FILE_NAME}`; // Download a small subregion for initial import const PATH = `/osm/${FILE_NAME}`; const IMPORT_BIND = `${PATH}:/data/region.osm.pbf`; const disk = drive.use('fs'); this._broadcastAndLog(DockerService.OPENSTREETMAP_IMPORT_SERVICE_NAME, 'preinstall', `Running pre-install actions for OpenStreetMap Tile Server...`); const fileExists = await disk.exists(PATH); if (!fileExists) { this._broadcastAndLog(DockerService.OPENSTREETMAP_IMPORT_SERVICE_NAME, 'preinstall', `Downloading OpenStreetMap PBF file from ${OSM_PBF_URL}. This may take some time...`); const response = await axios.get(OSM_PBF_URL, { responseType: 'stream', }); const stream = response.data; stream.on('error', (error: Error) => { logger.error(`Error downloading OpenStreetMap PBF file: ${error.message}`); throw error; }); await disk.putStream(PATH, stream); this._broadcastAndLog(DockerService.OPENSTREETMAP_IMPORT_SERVICE_NAME, 'preinstall', `Downloaded OpenStreetMap PBF file to ${PATH}`); } else { this._broadcastAndLog(DockerService.OPENSTREETMAP_IMPORT_SERVICE_NAME, 'preinstall', `OpenStreetMap PBF file already exists at ${PATH}. Skipping download.`); } // Do initial import of OSM data into the tile server DB // We need to add the initial osm.pbf file as another volume bind so we can import it const configWithImportBind = containerConfig.HostConfig || {}; const bindsArray: string[] = [] if (Array.isArray(configWithImportBind.Binds)) { bindsArray.push(...configWithImportBind.Binds, IMPORT_BIND); } else { bindsArray.push(IMPORT_BIND); } configWithImportBind.Binds = bindsArray; this._broadcastAndLog(DockerService.OPENSTREETMAP_IMPORT_SERVICE_NAME, 'importing', `Processing initial import of OSM data. This may take some time...`); const result = await new Promise((resolve, reject) => { this.docker.run(image, ['import'], process.stdout, configWithImportBind, {}, {}, // @ts-ignore (err: any, data: any, container: Docker.Container) => { if (err) { logger.error(`Error running initial import for OpenStreetMap Tile Server: ${err.message}`); return reject(err); } resolve(data); }); }).catch((error) => { logger.error(`Error during OpenStreetMap data import: ${error.message}`); return null; }); logger.log('debug', `OpenStreetMap data import result: ${JSON.stringify(result)}`); const [output, container] = result ? result as [any, any] : [null, null]; if (output?.StatusCode === 0) { this._broadcastAndLog(DockerService.OPENSTREETMAP_IMPORT_SERVICE_NAME, 'imported', `OpenStreetMap data imported successfully.`); await container.remove(); } else { const errorMessage = `Failed to import OpenStreetMap data. Status code: ${output?.StatusCode}. Output: ${output?.Output || 'No output'}`; this._broadcastAndLog(DockerService.OPENSTREETMAP_IMPORT_SERVICE_NAME, 'error', errorMessage); logger.error(errorMessage); throw new Error(errorMessage); } } private _broadcastAndLog(service: string, status: string, message: string) { transmit.broadcast('service-installation', { service_name: service, timestamp: new Date().toISOString(), status, message, }); logger.info(`[DockerService] [${service}] ${status}: ${message}`); } private _parseContainerConfig(containerConfig: any): any { if (!containerConfig) { return {}; } try { // Handle the case where containerConfig is returned as an object by DB instead of a string let toParse = containerConfig; if (typeof containerConfig === 'object') { toParse = JSON.stringify(containerConfig); } return JSON.parse(toParse); } catch (error) { logger.error(`Failed to parse container configuration: ${error.message}`); throw new Error(`Invalid container configuration: ${error.message}`); } } }