CI / test (push) Successful in 9m54s
Ship remaining roadmap items: encrypted registry vault, peer invite/revoke, Swarm/plugins behind flags, binary streams, engine create validation, deploy rollback, schema validation, fleet/access UI, metrics, fuzz/load/soak tests, systemd packaging, and release tooling. Mark ROADMAP fully complete.
161 lines
5.0 KiB
JavaScript
161 lines
5.0 KiB
JavaScript
/**
|
|
* 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<string>}
|
|
*/
|
|
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
|
|
}
|