Release rolling / release (push) Has been cancelled
When duplicating with the same host ports, stop+rename the origin, deploy and verify the new container, then remove the parked source. Client precheck ignores ports owned only by the origin to avoid false blocks.
336 lines
9.6 KiB
JavaScript
336 lines
9.6 KiB
JavaScript
/**
|
||
* 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<typeof parseCidr>} a
|
||
* @param {ReturnType<typeof parseCidr>} 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<string[]>}
|
||
*/
|
||
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<number>} */
|
||
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<string>} */
|
||
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 !== '<none>:<none>') 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,
|
||
}
|
||
}
|