Complete production roadmap: vault, peers, swarm, streams, ops
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.
This commit is contained in:
2026-07-10 20:50:55 -04:00
parent 0bb6ba1692
commit 0ee9b67834
56 changed files with 4430 additions and 376 deletions
+160
View File
@@ -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
}