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.
This commit is contained in:
@@ -0,0 +1,160 @@
|
||||
/**
|
||||
* 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
|
||||
}
|
||||
Reference in New Issue
Block a user