feat(Docker): container URL resolution util and networking improvs

This commit is contained in:
Jake Turner 2026-01-24 23:26:17 +00:00 committed by Jake Turner
parent e31f956289
commit 64e6e11389
7 changed files with 86 additions and 29 deletions

View File

@ -3,6 +3,7 @@ import { QueueService } from '#services/queue_service'
import { BenchmarkService } from '#services/benchmark_service'
import type { RunBenchmarkJobParams } from '../../types/benchmark.js'
import logger from '@adonisjs/core/services/logger'
import { DockerService } from '#services/docker_service'
export class RunBenchmarkJob {
static get queue() {
@ -18,7 +19,8 @@ export class RunBenchmarkJob {
logger.info(`[RunBenchmarkJob] Starting benchmark ${benchmark_id} of type ${benchmark_type}`)
const benchmarkService = new BenchmarkService()
const dockerService = new DockerService()
const benchmarkService = new BenchmarkService(dockerService)
try {
let result

View File

@ -1,7 +1,6 @@
import { inject } from '@adonisjs/core'
import logger from '@adonisjs/core/services/logger'
import transmit from '@adonisjs/transmit/services/main'
import Docker from 'dockerode'
import si from 'systeminformation'
import axios from 'axios'
import { DateTime } from 'luxon'
@ -24,6 +23,7 @@ import type {
RepositoryStats,
} from '../../types/benchmark.js'
import { randomUUID } from 'node:crypto'
import { DockerService } from './docker_service.js'
// Re-export default weights for use in service
const SCORE_WEIGHTS = {
@ -44,10 +44,6 @@ const BENCHMARK_CHANNEL = 'benchmark-progress'
const AI_BENCHMARK_MODEL = 'llama3.2:1b'
const AI_BENCHMARK_PROMPT = 'Explain recursion in programming in exactly 100 words.'
// Ollama API URL - configurable for Docker environments where localhost doesn't reach the host
// In Docker, use host.docker.internal (Docker Desktop) or the host gateway IP (Linux)
const OLLAMA_API_URL = process.env.OLLAMA_API_URL || 'http://host.docker.internal:11434'
// Reference scores for normalization (calibrated to 0-100 scale)
// These represent "expected" scores for a mid-range system (score ~50)
const REFERENCE_SCORES = {
@ -61,18 +57,10 @@ const REFERENCE_SCORES = {
@inject()
export class BenchmarkService {
private docker: Docker
private currentBenchmarkId: string | null = null
private currentStatus: BenchmarkStatus = 'idle'
constructor() {
const isWindows = process.platform === 'win32'
if (isWindows) {
this.docker = new Docker({ socketPath: '//./pipe/docker_engine' })
} else {
this.docker = new Docker({ socketPath: '/var/run/docker.sock' })
}
}
constructor(private dockerService: DockerService) {}
/**
* Run a full benchmark suite
@ -366,18 +354,25 @@ export class BenchmarkService {
* Run AI benchmark using Ollama
*/
private async _runAIBenchmark(): Promise<AIScores> {
try {
this._updateStatus('running_ai', 'Running AI benchmark...')
const ollamaAPIURL = await this.dockerService.getServiceURL(DockerService.OLLAMA_SERVICE_NAME)
if (!ollamaAPIURL) {
throw new Error('AI Assistant service location could not be determined. Ensure AI Assistant is installed and running.')
}
// Check if Ollama is available
try {
await axios.get(`${OLLAMA_API_URL}/api/tags`, { timeout: 5000 })
await axios.get(`${ollamaAPIURL}/api/tags`, { timeout: 5000 })
} catch (error) {
const errorCode = error.code || error.response?.status || 'unknown'
throw new Error(`Ollama is not running or not accessible (${errorCode}). Ensure AI Assistant is installed and running.`)
}
// Check if the benchmark model is available, pull if not
const modelsResponse = await axios.get(`${OLLAMA_API_URL}/api/tags`)
const modelsResponse = await axios.get(`${ollamaAPIURL}/api/tags`)
const models = modelsResponse.data.models || []
const hasModel = models.some((m: any) => m.name === AI_BENCHMARK_MODEL || m.name.startsWith(AI_BENCHMARK_MODEL.split(':')[0]))
@ -387,7 +382,7 @@ export class BenchmarkService {
try {
// Model pull can take several minutes, use longer timeout
await axios.post(`${OLLAMA_API_URL}/api/pull`, { name: AI_BENCHMARK_MODEL }, { timeout: 600000 })
await axios.post(`${ollamaAPIURL}/api/pull`, { name: AI_BENCHMARK_MODEL }, { timeout: 600000 })
logger.info(`[BenchmarkService] Model ${AI_BENCHMARK_MODEL} downloaded successfully`)
} catch (pullError) {
throw new Error(`Failed to download AI benchmark model (${AI_BENCHMARK_MODEL}): ${pullError.message}`)
@ -397,9 +392,8 @@ export class BenchmarkService {
// Run inference benchmark
const startTime = Date.now()
try {
const response = await axios.post(
`${OLLAMA_API_URL}/api/generate`,
`${ollamaAPIURL}/api/generate`,
{
model: AI_BENCHMARK_MODEL,
prompt: AI_BENCHMARK_PROMPT,
@ -519,11 +513,11 @@ export class BenchmarkService {
*/
private async _ensureSysbenchImage(): Promise<void> {
try {
await this.docker.getImage(SYSBENCH_IMAGE).inspect()
await this.dockerService.docker.getImage(SYSBENCH_IMAGE).inspect()
} catch {
this._updateStatus('starting', `Pulling sysbench image...`)
const pullStream = await this.docker.pull(SYSBENCH_IMAGE)
await new Promise((resolve) => this.docker.modem.followProgress(pullStream, resolve))
const pullStream = await this.dockerService.docker.pull(SYSBENCH_IMAGE)
await new Promise((resolve) => this.dockerService.docker.modem.followProgress(pullStream, resolve))
}
}
@ -641,7 +635,7 @@ export class BenchmarkService {
private async _runSysbenchCommand(cmd: string[]): Promise<string> {
try {
// Create container with TTY to avoid multiplexed output
const container = await this.docker.createContainer({
const container = await this.dockerService.docker.createContainer({
Image: SYSBENCH_IMAGE,
Cmd: cmd,
name: `${SYSBENCH_CONTAINER_NAME}_${Date.now()}`,

View File

@ -18,6 +18,7 @@ export class DockerService {
public static FLATNOTES_SERVICE_NAME = 'nomad_flatnotes'
public static KOLIBRI_SERVICE_NAME = 'nomad_kolibri'
public static BENCHMARK_SERVICE_NAME = 'nomad_benchmark'
public static NOMAD_NETWORK = 'project-nomad_default'
constructor() {
// Support both Linux (production) and Windows (development with Docker Desktop)
@ -124,11 +125,58 @@ export class DockerService {
status: container.State,
}))
} catch (error) {
console.error(`Error fetching services status: ${error.message}`)
logger.error(`Error fetching services status: ${error.message}`)
return []
}
}
/**
* Get the URL to access a service based on its configuration.
* Attempts to return a docker-internal URL using the service name and exposed port.
* @param serviceName - The name of the service to get the URL for.
* @returns - The URL as a string, or null if it cannot be determined.
*/
async getServiceURL(serviceName: string): Promise<string | null> {
if (!serviceName || serviceName.trim() === '') {
return null
}
const service = await Service.query()
.where('service_name', serviceName)
.andWhere('installed', true)
.first()
if (!service) {
return null
}
const hostname = process.env.NODE_ENV === 'production' ? serviceName : 'localhost'
// First, check if ui_location is set and is a valid port number
if (service.ui_location && parseInt(service.ui_location, 10)) {
return `http://${hostname}:${service.ui_location}`
}
// Next, try to extract a host port from container_config
const parsedConfig = this._parseContainerConfig(service.container_config)
if (parsedConfig?.HostConfig?.PortBindings) {
const portBindings = parsedConfig.HostConfig.PortBindings
const hostPorts = Object.values(portBindings)
if (!hostPorts || !Array.isArray(hostPorts) || hostPorts.length === 0) {
return null
}
const hostPortsArray = hostPorts.flat() as { HostPort: string }[]
const hostPortsStrings = hostPortsArray.map((binding) => binding.HostPort)
if (hostPortsStrings.length > 0) {
return `http://${hostname}:${hostPortsStrings[0]}`
}
}
// Otherwise, return null if we can't determine a URL
return null
}
async createContainerPreflight(
serviceName: string
): Promise<{ success: boolean; message: string }> {
@ -411,6 +459,14 @@ export class DockerService {
...(containerConfig?.ExposedPorts && { ExposedPorts: containerConfig.ExposedPorts }),
...(containerConfig?.Env && { Env: containerConfig.Env }),
...(service.container_command ? { Cmd: service.container_command.split(' ') } : {}),
// Ensure container is attached to the Nomad docker network in production
...(process.env.NODE_ENV === 'production' && {
NetworkingConfig: {
EndpointsConfig: {
[DockerService.NOMAD_NETWORK]: {},
},
},
})
})
this._broadcast(

View File

@ -19,8 +19,10 @@ export default class BenchmarkResults extends BaseCommand {
}
async run() {
const { DockerService } = await import('#services/docker_service')
const { BenchmarkService } = await import('#services/benchmark_service')
const benchmarkService = new BenchmarkService()
const dockerService = new DockerService()
const benchmarkService = new BenchmarkService(dockerService)
try {
let results

View File

@ -19,8 +19,10 @@ export default class BenchmarkRun extends BaseCommand {
}
async run() {
const { DockerService } = await import('#services/docker_service')
const { BenchmarkService } = await import('#services/benchmark_service')
const benchmarkService = new BenchmarkService()
const dockerService = new DockerService()
const benchmarkService = new BenchmarkService(dockerService)
// Determine benchmark type
let benchmarkType: 'full' | 'system' | 'ai' = 'full'

View File

@ -16,8 +16,10 @@ export default class BenchmarkSubmit extends BaseCommand {
}
async run() {
const { DockerService } = await import('#services/docker_service')
const { BenchmarkService } = await import('#services/benchmark_service')
const benchmarkService = new BenchmarkService()
const dockerService = new DockerService()
const benchmarkService = new BenchmarkService(dockerService)
try {
// Get the result to submit

View File

@ -32,7 +32,6 @@ services:
- DB_SSL=false
- REDIS_HOST=redis
- REDIS_PORT=6379
- OLLAMA_API_URL=http://host.docker.internal:11434
depends_on:
mysql:
condition: service_healthy