/** * Smart defaults / auto-populate helpers for dynamic UI. */ import { docker } from '../services/docker.js' import logger from './logger.js' /** * Parse "a.b.c.d/prefix" into numeric parts. * @param {string} cidr */ export function parseCidr(cidr) { if (!cidr || typeof cidr !== 'string') return null const m = cidr.trim().match(/^(\d+)\.(\d+)\.(\d+)\.(\d+)\/(\d+)$/) if (!m) return null const [, a, b, c, d, p] = m.map(Number) if ([a, b, c, d].some((x) => x < 0 || x > 255) || p < 0 || p > 32) return null const base = ((a << 24) | (b << 16) | (c << 8) | d) >>> 0 const mask = p === 0 ? 0 : (0xffffffff << (32 - p)) >>> 0 return { base: base & mask, mask, prefix: p, raw: cidr } } /** * @param {number} ip */ function ipToString(ip) { return [(ip >>> 24) & 255, (ip >>> 16) & 255, (ip >>> 8) & 255, ip & 255].join('.') } /** * @param {ReturnType} a * @param {ReturnType} b */ export function cidrsOverlap(a, b) { if (!a || !b) return false const aEnd = (a.base | (~a.mask >>> 0)) >>> 0 const bEnd = (b.base | (~b.mask >>> 0)) >>> 0 return a.base <= bEnd && b.base <= aEnd } /** * Collect used subnets from engine networks. * @returns {Promise} */ export async function listUsedSubnets() { const networks = await docker.listNetworks() const used = [] for (const n of networks) { try { const full = await docker.getNetwork(n.Id).inspect() const configs = full.IPAM?.Config || [] for (const c of configs) { if (c.Subnet) used.push(c.Subnet) } } catch { // ignore } } return used } /** * Suggest free bridge IPAM that does not collide with existing Docker networks. * @param {{ preferredPrefix?: number }} [opts] */ export async function suggestNetworkIPAM(opts = {}) { const usedRaw = await listUsedSubnets() const used = usedRaw.map(parseCidr).filter(Boolean) const candidates = [] // Prefer Docker-like 172.16–31.0.0/16 then 192.168.x.0/24 for (let n = 16; n <= 31; n++) { candidates.push(`172.${n}.0.0/16`) } for (let n = 0; n <= 250; n++) { candidates.push(`192.168.${n}.0/24`) } // Fallback denser 10.x for (let n = 0; n <= 50; n++) { candidates.push(`10.${n}.0.0/16`) } for (const cidr of candidates) { const parsed = parseCidr(cidr) if (!parsed) continue if (used.some((u) => cidrsOverlap(parsed, u))) continue const gateway = ipToString(parsed.base + 1) return { success: true, subnet: cidr, gateway, ipRange: null, driver: 'bridge', usedSubnets: usedRaw, collisions: [], note: 'Suggested free private range', } } return { success: false, subnet: null, gateway: null, usedSubnets: usedRaw, collisions: usedRaw, error: 'No free private subnet found in suggestion space', } } /** * Host ports published by containers. * Includes per-port owners so clients can ignore the source container when * duplicating (swap) without false host-port conflicts. */ export async function listUsedHostPorts() { const containers = await docker.listContainers({ all: true }) /** @type {Set} */ const ports = new Set() /** @type {{ port: number, protocol: string, id: string, name: string }[]} */ const owners = [] for (const c of containers) { const id = String(c.Id || '') const name = String(c.Names?.[0] || '') .replace(/^\//, '') || id.slice(0, 12) for (const p of c.Ports || []) { if (!p?.PublicPort) continue const port = Number(p.PublicPort) if (!Number.isFinite(port)) continue ports.add(port) owners.push({ port, protocol: String(p.Type || 'tcp').toLowerCase() === 'udp' ? 'udp' : 'tcp', id, name, }) } } return { success: true, ports: [...ports].sort((a, b) => a - b), owners, } } /** * Next free host port starting from preferred. * @param {number} preferred * @param {number[]} used */ export function nextFreePort(preferred, used) { const set = new Set(used) let p = Math.max(1, Math.min(65535, preferred || 8080)) for (let i = 0; i < 2000; i++) { if (!set.has(p)) return p p += 1 if (p > 65535) p = 1024 } return preferred } /** * Suggest resource name with numeric suffix if taken. * @param {'container'|'network'|'volume'|'stack'} kind * @param {string} base */ export async function suggestResourceName(kind, base) { const slug = String(base || 'resource') .toLowerCase() .replace(/[^a-z0-9._-]+/g, '-') .replace(/^-+|-+$/g, '') .slice(0, 50) || 'resource' /** @type {Set} */ const taken = new Set() if (kind === 'container') { const list = await docker.listContainers({ all: true }) for (const c of list) { for (const n of c.Names || []) taken.add(n.replace(/^\//, '').toLowerCase()) } } else if (kind === 'network') { const list = await docker.listNetworks() for (const n of list) taken.add(String(n.Name).toLowerCase()) } else if (kind === 'volume') { const list = await docker.listVolumes() for (const v of list.Volumes || []) taken.add(String(v.Name).toLowerCase()) } else if (kind === 'stack') { const list = await docker.listContainers({ all: true }) for (const c of list) { const p = c.Labels?.['com.docker.compose.project'] if (p) taken.add(String(p).toLowerCase()) } } if (!taken.has(slug)) return { success: true, name: slug, taken: false } for (let i = 2; i < 1000; i++) { const candidate = `${slug}-${i}` if (!taken.has(candidate)) return { success: true, name: candidate, taken: true } } return { success: true, name: `${slug}-${Date.now()}`, taken: true } } /** * One round-trip host snapshot for wizard warm-up. */ export async function getHostSnapshot() { const [containers, images, networks, volumesInfo, info, version, usedPorts, ipam] = await Promise.all([ docker.listContainers({ all: true }).catch(() => []), docker.listImages({ all: true }).catch(() => []), docker.listNetworks().catch(() => []), docker.listVolumes().catch(() => ({ Volumes: [] })), docker.info().catch(() => null), docker.version().catch(() => null), listUsedHostPorts().catch(() => ({ ports: [] })), suggestNetworkIPAM().catch(() => null), ]) const containerNames = [] for (const c of containers) { for (const n of c.Names || []) containerNames.push(n.replace(/^\//, '')) } const imageRefs = [] for (const img of images) { for (const t of img.RepoTags || []) { if (t && t !== ':') imageRefs.push(t) } } return { success: true, type: 'hostSnapshot', fetchedAt: Date.now(), engine: { info, version, ncpu: info?.NCPU ?? null, memTotal: info?.MemTotal ?? null, swarm: info?.Swarm?.LocalNodeState || 'inactive', name: info?.Name || null, operatingSystem: info?.OperatingSystem || null, architecture: info?.Architecture || null, }, counts: { containers: containers.length, running: containers.filter((c) => c.State === 'running').length, images: images.length, networks: networks.length, volumes: (volumesInfo.Volumes || []).length, }, names: { containers: containerNames, networks: networks.map((n) => n.Name), volumes: (volumesInfo.Volumes || []).map((v) => v.Name), images: imageRefs, }, usedHostPorts: usedPorts.ports || [], networkSuggestion: ipam, } } /** * Hydrate create-container defaults from local image inspect. * @param {string} imageRef */ export async function suggestFromImage(imageRef) { if (!imageRef) throw new Error('image required') let data try { data = await docker.getImage(imageRef).inspect() } catch (err) { logger.debug('suggestFromImage inspect failed', { imageRef, error: err.message }) return { success: false, error: err.message, image: imageRef } } const cfg = data.Config || {} const exposed = Object.keys(cfg.ExposedPorts || {}) const volumes = Object.keys(cfg.Volumes || {}) const env = (cfg.Env || []).map((e) => { const i = e.indexOf('=') return i >= 0 ? { name: e.slice(0, i), value: e.slice(i + 1) } : { name: e, value: '' } }) const usedPorts = (await listUsedHostPorts()).ports const ports = exposed.map((spec) => { const [port, protocol] = spec.split('/') const containerPort = Number(port) const hostPort = nextFreePort(containerPort < 1024 ? containerPort + 8000 : containerPort, usedPorts) usedPorts.push(hostPort) return { hostPort: String(hostPort), containerPort: String(containerPort), protocol: protocol || 'tcp', suggested: true, } }) const nameBase = String(imageRef) .split('/') .pop() .split(':')[0] .replace(/[^a-zA-Z0-9_.-]/g, '-') const nameSug = await suggestResourceName('container', nameBase) return { success: true, image: imageRef, name: nameSug.name, cmd: Array.isArray(cfg.Cmd) ? cfg.Cmd.join(' ') : cfg.Cmd || '', entrypoint: Array.isArray(cfg.Entrypoint) ? cfg.Entrypoint.join(' ') : cfg.Entrypoint || '', workingDir: cfg.WorkingDir || '', user: cfg.User || '', env, labels: cfg.Labels || {}, ports, volumes: volumes.map((path) => ({ containerPath: path, hostPath: '', type: 'volume', suggested: true, })), healthcheck: cfg.Healthcheck ? { test: cfg.Healthcheck.Test || [], interval: cfg.Healthcheck.Interval, timeout: cfg.Healthcheck.Timeout, retries: cfg.Healthcheck.Retries, startPeriod: cfg.Healthcheck.StartPeriod, } : null, exposedPorts: exposed, } }