/** * Docker Engine version/capability cache and create-option validation. */ import { docker } from '../services/docker.js' import logger from './logger.js' /** @type {{ apiVersion: string, major: number, minor: number, os: string, experimental: boolean, fetchedAt: number }|null} */ let cached = null const CACHE_MS = 60_000 /** * @returns {Promise<{ apiVersion: string, major: number, minor: number, os: string, experimental: boolean }>} */ export async function getEngineCapabilities() { const now = Date.now() if (cached && now - cached.fetchedAt < CACHE_MS) { return cached } const version = await docker.version() const apiVersion = String(version.ApiVersion || version.apiVersion || '1.40') const [maj, min] = apiVersion.split('.').map((n) => Number(n) || 0) cached = { apiVersion, major: maj, minor: min, os: version.Os || version.os || 'linux', experimental: Boolean(version.Experimental || version.experimental), fetchedAt: now, } return cached } /** * Compare semver-ish API versions: returns true if a.b >= major.minor * @param {{ major: number, minor: number }} cap * @param {number} major * @param {number} minor */ export function apiAtLeast(cap, major, minor) { if (cap.major > major) return true if (cap.major < major) return false return cap.minor >= minor } /** * Features that require minimum Engine API versions (approx Docker docs). * Keys are HostConfig / Config field names (case-insensitive match). */ const FIELD_MIN_API = { // HostConfig Init: [1, 25], Runtime: [1, 25], NanoCpus: [1, 25], CpuCount: [1, 25], CpuPercent: [1, 25], IOMaximumIOps: [1, 25], IOMaximumBandwidth: [1, 25], DeviceCgroupRules: [1, 28], DeviceRequests: [1, 40], Sysctls: [1, 24], PidsLimit: [1, 25], Isolation: [1, 24], MaskedPaths: [1, 25], ReadonlyPaths: [1, 25], Mounts: [1, 30], ConsoleSize: [1, 25], Annotations: [1, 43], // Config StopTimeout: [1, 25], Shell: [1, 25], Healthcheck: [1, 24], // Networking EndpointSettings: [1, 22], // Windows / platform Platform: [1, 32], } /** * Validate create/update options against the connected Engine API version. * @param {object} opts - docker createContainer body (may include HostConfig, name, start) * @param {{ strict?: boolean }} [options] - strict throws; soft returns warnings * @returns {Promise<{ ok: boolean, warnings: string[], errors: string[], apiVersion: string }>} */ export async function validateCreateOptions(opts, options = {}) { const strict = options.strict !== false const warnings = [] const errors = [] let cap try { cap = await getEngineCapabilities() } catch (err) { logger.warn('engine capabilities unavailable', { error: err.message }) return { ok: true, warnings: ['Could not read Engine version; skipped capability check'], errors: [], apiVersion: 'unknown' } } const fields = collectFieldNames(opts) for (const field of fields) { const min = FIELD_MIN_API[field] if (!min) continue if (!apiAtLeast(cap, min[0], min[1])) { const msg = `Field "${field}" requires Docker API ≥ ${min[0]}.${min[1]} (engine reports ${cap.apiVersion})` if (strict) errors.push(msg) else warnings.push(msg) } } // Platform-specific checks if (opts.HostConfig?.Isolation && cap.os !== 'windows') { warnings.push('HostConfig.Isolation is primarily for Windows containers') } // Basic structural validation always hard-fails if (opts.Image != null && typeof opts.Image !== 'string') { errors.push('Image must be a string') } if (opts.Env != null && !Array.isArray(opts.Env)) { errors.push('Env must be an array of KEY=value strings') } if (opts.HostConfig?.PortBindings && typeof opts.HostConfig.PortBindings !== 'object') { errors.push('HostConfig.PortBindings must be an object') } if (opts.HostConfig?.Memory != null && Number(opts.HostConfig.Memory) < 0) { errors.push('HostConfig.Memory must be >= 0') } if (opts.HostConfig?.NanoCpus != null && Number(opts.HostConfig.NanoCpus) < 0) { errors.push('HostConfig.NanoCpus must be >= 0') } const ok = errors.length === 0 return { ok, warnings, errors, apiVersion: cap.apiVersion } } /** * @param {object} opts * @returns {Set} */ function collectFieldNames(opts) { const names = new Set() if (!opts || typeof opts !== 'object') return names for (const k of Object.keys(opts)) { if (k === 'name' || k === 'start' || k === 'update') continue names.add(k) } if (opts.HostConfig && typeof opts.HostConfig === 'object') { for (const k of Object.keys(opts.HostConfig)) names.add(k) } if (opts.Config && typeof opts.Config === 'object') { for (const k of Object.keys(opts.Config)) names.add(k) } if (opts.NetworkingConfig && typeof opts.NetworkingConfig === 'object') { names.add('EndpointSettings') } if (opts.Platform) names.add('Platform') if (opts.Healthcheck) names.add('Healthcheck') return names } export function clearEngineCapabilitiesCache() { cached = null }