Precheck deploy networking before container create
Release rolling / release (push) Successful in 7m47s
Release rolling / release (push) Successful in 7m47s
Validate port mappings for empty host ports, in-form duplicates, privileged low ports, and peer-side host port conflicts. Runs on template deploy, deploy view, and add-container before create RPC.
This commit is contained in:
@@ -8282,6 +8282,27 @@ function attachFormHandler(form) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Async networking precheck before deploy (empty host ports, peer conflicts)
|
||||
try {
|
||||
const { precheckDeployNetworking, formatNetworkingPrecheckMessage } =
|
||||
await import('./client/deployNetworkPrecheck.js');
|
||||
const netCheck = await precheckDeployNetworking(formData);
|
||||
if (!netCheck.ok) {
|
||||
showAlert(
|
||||
'danger',
|
||||
formatNetworkingPrecheckMessage(netCheck) ||
|
||||
'Networking configuration is invalid.'
|
||||
);
|
||||
return false;
|
||||
}
|
||||
if (netCheck.warnings?.length) {
|
||||
showAlert('warning', netCheck.warnings.join(' '), { toast: true, tray: false });
|
||||
}
|
||||
formData._networkingPrechecked = true;
|
||||
} catch (netErr) {
|
||||
console.warn('[deploy] networking precheck failed', netErr);
|
||||
}
|
||||
|
||||
const containerName = formData.containerName;
|
||||
|
||||
// Live job drawer handles progress — no top toast / tray spam
|
||||
|
||||
@@ -0,0 +1,286 @@
|
||||
/**
|
||||
* Pre-deploy networking checks for container creates (template deploy + add container).
|
||||
*
|
||||
* Catches incomplete port rows (container port set, host empty), invalid ranges,
|
||||
* in-form duplicates, privileged host ports, and peer-side host port conflicts.
|
||||
*/
|
||||
import { manager, Methods } from './manager.js'
|
||||
|
||||
/**
|
||||
* @typedef {{
|
||||
* raw: string,
|
||||
* hostPort: number|null,
|
||||
* containerPort: number|null,
|
||||
* protocol: string,
|
||||
* incomplete: boolean,
|
||||
* index: number,
|
||||
* }} ParsedPortMapping
|
||||
*/
|
||||
|
||||
/**
|
||||
* Parse a port mapping string produced by collectPortMappings / collectPorts.
|
||||
* Formats: "host:container/proto", "container/proto", "host:container", "container"
|
||||
*
|
||||
* @param {string} portStr
|
||||
* @param {number} [index=0]
|
||||
* @returns {ParsedPortMapping|null}
|
||||
*/
|
||||
export function parsePortMapping(portStr, index = 0) {
|
||||
const raw = String(portStr ?? '').trim()
|
||||
if (!raw) return null
|
||||
|
||||
let hostPort = null
|
||||
let containerPort = null
|
||||
let protocol = 'tcp'
|
||||
|
||||
if (raw.includes(':')) {
|
||||
const [hostPart, rest] = raw.split(':')
|
||||
const hostTrim = String(hostPart || '').trim()
|
||||
if (hostTrim !== '') {
|
||||
const hp = Number(hostTrim)
|
||||
hostPort = Number.isFinite(hp) ? hp : null
|
||||
}
|
||||
const restTrim = String(rest || '').trim()
|
||||
if (restTrim.includes('/')) {
|
||||
const [cPort, proto] = restTrim.split('/')
|
||||
const cp = Number(String(cPort || '').trim())
|
||||
containerPort = Number.isFinite(cp) ? cp : null
|
||||
protocol = String(proto || 'tcp').trim().toLowerCase() === 'udp' ? 'udp' : 'tcp'
|
||||
} else {
|
||||
const cp = Number(restTrim)
|
||||
containerPort = Number.isFinite(cp) ? cp : null
|
||||
}
|
||||
} else if (raw.includes('/')) {
|
||||
const [cPort, proto] = raw.split('/')
|
||||
const cp = Number(String(cPort || '').trim())
|
||||
containerPort = Number.isFinite(cp) ? cp : null
|
||||
protocol = String(proto || 'tcp').trim().toLowerCase() === 'udp' ? 'udp' : 'tcp'
|
||||
} else {
|
||||
const cp = Number(raw)
|
||||
containerPort = Number.isFinite(cp) ? cp : null
|
||||
}
|
||||
|
||||
const incomplete =
|
||||
containerPort != null &&
|
||||
Number.isFinite(containerPort) &&
|
||||
containerPort >= 1 &&
|
||||
(hostPort == null || !Number.isFinite(hostPort))
|
||||
|
||||
return {
|
||||
raw,
|
||||
hostPort: hostPort != null && Number.isFinite(hostPort) ? hostPort : null,
|
||||
containerPort:
|
||||
containerPort != null && Number.isFinite(containerPort) ? containerPort : null,
|
||||
protocol,
|
||||
incomplete: Boolean(incomplete),
|
||||
index,
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {number|null|undefined} port
|
||||
* @returns {boolean}
|
||||
*/
|
||||
function isValidPortNumber(port) {
|
||||
return port != null && Number.isFinite(port) && port >= 1 && port <= 65535
|
||||
}
|
||||
|
||||
/**
|
||||
* Synchronous networking validation from collected deploy payload fields.
|
||||
*
|
||||
* @param {{
|
||||
* ports?: string[],
|
||||
* networkMode?: string,
|
||||
* publishAllPorts?: boolean,
|
||||
* customNetwork?: string|null,
|
||||
* }} data
|
||||
* @returns {{ errors: string[], warnings: string[], parsed: ParsedPortMapping[] }}
|
||||
*/
|
||||
export function validateNetworkingSync(data = {}) {
|
||||
/** @type {string[]} */
|
||||
const errors = []
|
||||
/** @type {string[]} */
|
||||
const warnings = []
|
||||
const networkMode = String(data.networkMode || 'bridge')
|
||||
const publishAll = data.publishAllPorts === true
|
||||
const portsIn = Array.isArray(data.ports) ? data.ports : []
|
||||
|
||||
/** @type {ParsedPortMapping[]} */
|
||||
const parsed = []
|
||||
portsIn.forEach((p, i) => {
|
||||
const row = parsePortMapping(p, i)
|
||||
if (row) parsed.push(row)
|
||||
})
|
||||
|
||||
if (networkMode === 'host') {
|
||||
if (parsed.length > 0) {
|
||||
warnings.push(
|
||||
'Network mode is "host" — published port mappings are ignored (the container shares the host network namespace).'
|
||||
)
|
||||
}
|
||||
if (data.customNetwork) {
|
||||
warnings.push(
|
||||
'Network mode is "host" — "attach to network" is ignored for this mode.'
|
||||
)
|
||||
}
|
||||
return { errors, warnings, parsed }
|
||||
}
|
||||
|
||||
if (networkMode === 'none' && parsed.length > 0) {
|
||||
warnings.push(
|
||||
'Network mode is "none" — published ports will not be reachable from outside the container.'
|
||||
)
|
||||
}
|
||||
|
||||
if (networkMode === 'container' && !String(data.customNetwork || '').trim()) {
|
||||
errors.push(
|
||||
'Network mode is "container" but no peer container name/id was set — How to fix: enter the container to share the network stack with.'
|
||||
)
|
||||
}
|
||||
|
||||
/** @type {Map<string, number[]>} */
|
||||
const hostKeys = new Map()
|
||||
|
||||
parsed.forEach((row) => {
|
||||
const n = row.index + 1
|
||||
|
||||
if (row.containerPort == null || !isValidPortNumber(row.containerPort)) {
|
||||
errors.push(
|
||||
`Port mapping ${n}: container port is missing or invalid (must be 1–65535).`
|
||||
)
|
||||
return
|
||||
}
|
||||
|
||||
// Explicit publish row with only container side filled (common template pitfall)
|
||||
if (row.incomplete && !publishAll) {
|
||||
errors.push(
|
||||
`Port mapping ${n}: host port is empty while container port ${row.containerPort}/${row.protocol} is set — How to fix: enter a host port to publish, or remove this mapping.`
|
||||
)
|
||||
}
|
||||
|
||||
if (row.incomplete && publishAll) {
|
||||
warnings.push(
|
||||
`Port mapping ${n}: host port is empty (Publish all ports is on). Prefer an explicit host port so the published address is predictable.`
|
||||
)
|
||||
}
|
||||
|
||||
if (row.hostPort != null) {
|
||||
if (!isValidPortNumber(row.hostPort)) {
|
||||
errors.push(`Port mapping ${n}: host port must be between 1 and 65535.`)
|
||||
} else {
|
||||
if (row.hostPort < 1024) {
|
||||
warnings.push(
|
||||
`Port mapping ${n}: host port ${row.hostPort} is privileged (< 1024). Binding may fail unless the Docker host allows low ports (typically root / CAP_NET_BIND_SERVICE).`
|
||||
)
|
||||
}
|
||||
const key = `${row.hostPort}/${row.protocol}`
|
||||
const list = hostKeys.get(key) || []
|
||||
list.push(n)
|
||||
hostKeys.set(key, list)
|
||||
}
|
||||
}
|
||||
|
||||
// container/protocol only — server currently maps HostPort = containerPort (surprise bind)
|
||||
if (
|
||||
!row.incomplete &&
|
||||
row.hostPort == null &&
|
||||
isValidPortNumber(row.containerPort) &&
|
||||
!publishAll
|
||||
) {
|
||||
// Already covered by incomplete for empty host; keep for string forms without host:
|
||||
// "80/tcp" is incomplete by our definition (host null + container set)
|
||||
}
|
||||
})
|
||||
|
||||
for (const [key, idxs] of hostKeys) {
|
||||
if (idxs.length > 1) {
|
||||
errors.push(
|
||||
`Host port ${key} is used more than once in this form (mappings ${idxs.join(', ')}) — How to fix: use unique host ports.`
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
return { errors, warnings, parsed }
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch host ports published by containers on the active peer.
|
||||
* @returns {Promise<number[]>}
|
||||
*/
|
||||
export async function fetchUsedHostPorts() {
|
||||
if (!manager.active?.connected) return []
|
||||
try {
|
||||
const res = await manager.request(Methods.listUsedHostPorts, {})
|
||||
const ports = res?.ports || res?.data?.ports || []
|
||||
return Array.isArray(ports)
|
||||
? ports.map((p) => Number(p)).filter((n) => Number.isFinite(n))
|
||||
: []
|
||||
} catch {
|
||||
return []
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Full precheck including live host-port conflicts on the connected peer.
|
||||
*
|
||||
* @param {object} payload - deploy form / add-container payload
|
||||
* @param {{ usedPorts?: number[], skipRemote?: boolean }} [opts]
|
||||
* @returns {Promise<{ ok: boolean, errors: string[], warnings: string[], parsed: ParsedPortMapping[] }>}
|
||||
*/
|
||||
export async function precheckDeployNetworking(payload = {}, opts = {}) {
|
||||
const sync = validateNetworkingSync(payload)
|
||||
const errors = [...sync.errors]
|
||||
const warnings = [...sync.warnings]
|
||||
const networkMode = String(payload.networkMode || 'bridge')
|
||||
|
||||
if (networkMode !== 'host' && !opts.skipRemote) {
|
||||
const used =
|
||||
Array.isArray(opts.usedPorts) && opts.usedPorts.length
|
||||
? opts.usedPorts
|
||||
: await fetchUsedHostPorts()
|
||||
const usedSet = new Set(used.map((n) => Number(n)))
|
||||
|
||||
for (const row of sync.parsed) {
|
||||
if (row.hostPort == null || !isValidPortNumber(row.hostPort)) continue
|
||||
if (usedSet.has(row.hostPort)) {
|
||||
errors.push(
|
||||
`Host port ${row.hostPort}/${row.protocol} is already published by another container on this peer — How to fix: pick a free host port or stop the container using ${row.hostPort}.`
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Custom network attach: empty when mode expects a named network is soft unless container mode
|
||||
// (container mode hard-error already above)
|
||||
|
||||
return {
|
||||
ok: errors.length === 0,
|
||||
errors,
|
||||
warnings,
|
||||
parsed: sync.parsed,
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Format precheck result for showAlert / job logs.
|
||||
* @param {{ errors?: string[], warnings?: string[] }} result
|
||||
* @returns {string}
|
||||
*/
|
||||
export function formatNetworkingPrecheckMessage(result) {
|
||||
const parts = []
|
||||
if (result.errors?.length) {
|
||||
parts.push(result.errors.join(' '))
|
||||
}
|
||||
if (result.warnings?.length && !result.errors?.length) {
|
||||
parts.push(result.warnings.join(' '))
|
||||
}
|
||||
return parts.join(' ').trim()
|
||||
}
|
||||
|
||||
export default {
|
||||
parsePortMapping,
|
||||
validateNetworkingSync,
|
||||
fetchUsedHostPorts,
|
||||
precheckDeployNetworking,
|
||||
formatNetworkingPrecheckMessage,
|
||||
}
|
||||
@@ -8,6 +8,10 @@ import {
|
||||
showAlert,
|
||||
} from './uiUtils.js'
|
||||
import { presentError } from '../client/errors.js'
|
||||
import {
|
||||
precheckDeployNetworking,
|
||||
formatNetworkingPrecheckMessage,
|
||||
} from '../client/deployNetworkPrecheck.js'
|
||||
|
||||
let portSeq = 0
|
||||
let volSeq = 0
|
||||
@@ -423,6 +427,23 @@ export async function submitAddContainer() {
|
||||
)
|
||||
}
|
||||
|
||||
// Networking precheck before any RPC (empty host ports, conflicts, etc.)
|
||||
const netCheck = await precheckDeployNetworking(payload)
|
||||
if (!netCheck.ok) {
|
||||
throw new Error(
|
||||
formatNetworkingPrecheckMessage(netCheck) ||
|
||||
'Networking configuration is invalid.'
|
||||
)
|
||||
}
|
||||
if (netCheck.warnings?.length) {
|
||||
try {
|
||||
showAlert('warning', netCheck.warnings.join(' '), { toast: true, tray: false })
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
}
|
||||
payload._networkingPrechecked = true
|
||||
|
||||
// Reuse template deploy replace helper when available
|
||||
if (typeof window.deployDockerContainer === 'function') {
|
||||
// Prefer shared path for name-conflict handling + job tray
|
||||
|
||||
+104
-53
@@ -13,6 +13,11 @@ import {
|
||||
resolveTemplateForDeploy,
|
||||
buildStackDeployPayload,
|
||||
} from '../client/templateResolve.js';
|
||||
import {
|
||||
precheckDeployNetworking,
|
||||
validateNetworkingSync,
|
||||
formatNetworkingPrecheckMessage,
|
||||
} from '../client/deployNetworkPrecheck.js';
|
||||
|
||||
// DOM Elements - Lazy loaded (initialized when modal opens)
|
||||
let templateList = null;
|
||||
@@ -221,6 +226,27 @@ function setupFormSubmitListener() {
|
||||
return;
|
||||
}
|
||||
|
||||
// Async networking precheck (host-port conflicts on peer) before closing UI
|
||||
try {
|
||||
const netCheck = await precheckDeployNetworking(formData);
|
||||
if (!netCheck.ok) {
|
||||
showAlert(
|
||||
'danger',
|
||||
formatNetworkingPrecheckMessage(netCheck) ||
|
||||
'Networking configuration is invalid.'
|
||||
);
|
||||
return;
|
||||
}
|
||||
if (netCheck.warnings?.length) {
|
||||
showAlert('warning', netCheck.warnings.join(' '), { toast: true, tray: false });
|
||||
}
|
||||
// Mark so deployDockerContainer can skip a second remote conflict scan
|
||||
formData._networkingPrechecked = true;
|
||||
} catch (netErr) {
|
||||
console.warn('[deploy] networking precheck failed', netErr);
|
||||
// Continue — deployDockerContainer will re-run checks
|
||||
}
|
||||
|
||||
// Safely get container name with fallback
|
||||
const containerName = (formData && formData.containerName) ? String(formData.containerName) : 'container';
|
||||
|
||||
@@ -453,28 +479,7 @@ function validatePortMapping(portId) {
|
||||
|
||||
let isValid = true;
|
||||
|
||||
// Validate host port (optional)
|
||||
if (hostInput && hostInput.value) {
|
||||
const hostPort = parseInt(hostInput.value, 10);
|
||||
if (isNaN(hostPort) || hostPort < 1 || hostPort > 65535) {
|
||||
if (hostError) {
|
||||
hostError.textContent = 'Port must be between 1 and 65535';
|
||||
hostError.style.display = 'block';
|
||||
hostInput.classList.add('is-invalid');
|
||||
}
|
||||
isValid = false;
|
||||
} else {
|
||||
if (hostError) {
|
||||
hostError.style.display = 'none';
|
||||
hostInput.classList.remove('is-invalid');
|
||||
}
|
||||
}
|
||||
} else if (hostError) {
|
||||
hostError.style.display = 'none';
|
||||
if (hostInput) hostInput.classList.remove('is-invalid');
|
||||
}
|
||||
|
||||
// Validate container port (required)
|
||||
// Container port required
|
||||
if (containerInput) {
|
||||
const containerPort = parseInt(containerInput.value, 10);
|
||||
if (!containerInput.value || isNaN(containerPort) || containerPort < 1 || containerPort > 65535) {
|
||||
@@ -492,9 +497,53 @@ function validatePortMapping(portId) {
|
||||
}
|
||||
}
|
||||
|
||||
// Check for duplicate host ports
|
||||
if (hostInput && hostInput.value) {
|
||||
const hostPort = hostInput.value;
|
||||
const hasContainer =
|
||||
containerInput?.value &&
|
||||
!isNaN(parseInt(containerInput.value, 10)) &&
|
||||
parseInt(containerInput.value, 10) >= 1;
|
||||
const hostVal = hostInput?.value?.trim() || '';
|
||||
|
||||
// Host port required when container port is set (prevents empty host-side publish)
|
||||
if (hostInput) {
|
||||
if (hasContainer && !hostVal) {
|
||||
if (hostError) {
|
||||
hostError.textContent = 'Host port required when container port is set';
|
||||
hostError.style.display = 'block';
|
||||
hostInput.classList.add('is-invalid');
|
||||
}
|
||||
isValid = false;
|
||||
} else if (hostVal) {
|
||||
const hostPort = parseInt(hostVal, 10);
|
||||
if (isNaN(hostPort) || hostPort < 1 || hostPort > 65535) {
|
||||
if (hostError) {
|
||||
hostError.textContent = 'Port must be between 1 and 65535';
|
||||
hostError.style.display = 'block';
|
||||
hostInput.classList.add('is-invalid');
|
||||
}
|
||||
isValid = false;
|
||||
} else if (hostPort < 1024) {
|
||||
if (hostError) {
|
||||
hostError.textContent = 'Privileged port (<1024) — may need elevated host permissions';
|
||||
hostError.style.display = 'block';
|
||||
hostInput.classList.remove('is-invalid');
|
||||
hostInput.classList.add('is-warning');
|
||||
}
|
||||
// warning only — still valid for submit path with warning banner
|
||||
} else {
|
||||
if (hostError) {
|
||||
hostError.style.display = 'none';
|
||||
hostInput.classList.remove('is-invalid', 'is-warning');
|
||||
}
|
||||
}
|
||||
} else if (hostError) {
|
||||
hostError.style.display = 'none';
|
||||
hostInput.classList.remove('is-invalid', 'is-warning');
|
||||
}
|
||||
}
|
||||
|
||||
// Check for duplicate host ports in this form
|
||||
if (hostInput && hostVal) {
|
||||
const hostPort = hostVal;
|
||||
const allHostInputs = document.querySelectorAll('.port-host-input');
|
||||
let duplicateCount = 0;
|
||||
allHostInputs.forEach(input => {
|
||||
@@ -505,7 +554,7 @@ function validatePortMapping(portId) {
|
||||
|
||||
if (duplicateCount > 0) {
|
||||
if (hostError) {
|
||||
hostError.textContent = 'This host port is already in use';
|
||||
hostError.textContent = 'This host port is already used in another mapping';
|
||||
hostError.style.display = 'block';
|
||||
hostInput.classList.add('is-invalid');
|
||||
}
|
||||
@@ -2589,33 +2638,11 @@ function validateFormData(data) {
|
||||
}
|
||||
}
|
||||
|
||||
// Validate ports
|
||||
if (data.ports && Array.isArray(data.ports)) {
|
||||
data.ports.forEach((port, idx) => {
|
||||
const portStr = String(port).trim();
|
||||
// Support both "host:container/protocol" and "container/protocol" formats
|
||||
const portPattern1 = /^(\d+):(\d+)\/(tcp|udp)$/; // host:container/protocol
|
||||
const portPattern2 = /^(\d+)\/(tcp|udp)$/; // container/protocol
|
||||
|
||||
if (!portPattern1.test(portStr) && !portPattern2.test(portStr)) {
|
||||
errors.push(`Port ${idx + 1} has invalid format. Use "host:container/protocol" or "container/protocol".`);
|
||||
} else {
|
||||
// Validate port numbers are in valid range
|
||||
const match = portStr.match(portPattern1) || portStr.match(portPattern2);
|
||||
if (match) {
|
||||
const hostPort = match[1] ? parseInt(match[1], 10) : null;
|
||||
const containerPort = parseInt(match[2] || match[1], 10);
|
||||
|
||||
if (hostPort && (hostPort < 1 || hostPort > 65535)) {
|
||||
errors.push(`Port ${idx + 1}: Host port must be between 1 and 65535.`);
|
||||
}
|
||||
if (containerPort < 1 || containerPort > 65535) {
|
||||
errors.push(`Port ${idx + 1}: Container port must be between 1 and 65535.`);
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
// Networking / ports (empty host side, ranges, in-form duplicates)
|
||||
const net = validateNetworkingSync(data);
|
||||
errors.push(...net.errors);
|
||||
// Surface warnings as soft errors only when there are no hard errors yet —
|
||||
// warnings are re-checked async with peer port conflicts before deploy.
|
||||
|
||||
// Validate volumes
|
||||
if (data.volumes && Array.isArray(data.volumes)) {
|
||||
@@ -2862,6 +2889,30 @@ async function deployDockerContainer(payload) {
|
||||
);
|
||||
}
|
||||
|
||||
// Pre-deploy networking: empty host ports, conflicts, privileged ports, network mode
|
||||
// (may already be done by form submit; re-run unless marked prechecked this turn)
|
||||
if (!payload._networkingPrechecked) {
|
||||
const netCheck = await precheckDeployNetworking(payload);
|
||||
if (!netCheck.ok) {
|
||||
const msg =
|
||||
formatNetworkingPrecheckMessage(netCheck) ||
|
||||
'Networking configuration is invalid.';
|
||||
throw new Error(msg);
|
||||
}
|
||||
if (netCheck.warnings?.length) {
|
||||
console.warn('[deploy] networking warnings:', netCheck.warnings.join(' | '));
|
||||
try {
|
||||
showAlert('warning', netCheck.warnings.join(' '), { toast: true, tray: false });
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
}
|
||||
}
|
||||
// Never send internal flag to the server
|
||||
if (payload && typeof payload === 'object') {
|
||||
delete payload._networkingPrechecked;
|
||||
}
|
||||
|
||||
// Offer replace when name collides (style)
|
||||
payload = await confirmReplaceExistingContainer(payload)
|
||||
|
||||
|
||||
@@ -0,0 +1,110 @@
|
||||
import test from 'brittle'
|
||||
import {
|
||||
parsePortMapping,
|
||||
validateNetworkingSync,
|
||||
precheckDeployNetworking,
|
||||
formatNetworkingPrecheckMessage,
|
||||
} from '../client/deployNetworkPrecheck.js'
|
||||
|
||||
test('parsePortMapping host:container/proto', (t) => {
|
||||
const p = parsePortMapping('8080:80/tcp', 0)
|
||||
t.ok(p)
|
||||
t.is(p.hostPort, 8080)
|
||||
t.is(p.containerPort, 80)
|
||||
t.is(p.protocol, 'tcp')
|
||||
t.is(p.incomplete, false)
|
||||
})
|
||||
|
||||
test('parsePortMapping container-only is incomplete', (t) => {
|
||||
const p = parsePortMapping('80/tcp', 1)
|
||||
t.ok(p)
|
||||
t.is(p.hostPort, null)
|
||||
t.is(p.containerPort, 80)
|
||||
t.is(p.incomplete, true)
|
||||
})
|
||||
|
||||
test('parsePortMapping empty host with colon is incomplete', (t) => {
|
||||
// Unusual but guard against blank host field serialization
|
||||
const p = parsePortMapping(':443/tcp', 0)
|
||||
t.ok(p)
|
||||
t.is(p.containerPort, 443)
|
||||
t.is(p.incomplete, true)
|
||||
})
|
||||
|
||||
test('validateNetworkingSync rejects empty host ports', (t) => {
|
||||
const r = validateNetworkingSync({
|
||||
networkMode: 'bridge',
|
||||
ports: ['80/tcp', '443/udp'],
|
||||
})
|
||||
t.not(r.ok === true && r.errors.length === 0)
|
||||
t.ok(r.errors.length >= 2)
|
||||
t.ok(r.errors.some((e) => /host port is empty/i.test(e)))
|
||||
})
|
||||
|
||||
test('validateNetworkingSync accepts full mappings', (t) => {
|
||||
const r = validateNetworkingSync({
|
||||
networkMode: 'bridge',
|
||||
ports: ['8080:80/tcp', '8443:443/tcp'],
|
||||
})
|
||||
t.is(r.errors.length, 0)
|
||||
t.is(r.parsed.length, 2)
|
||||
})
|
||||
|
||||
test('validateNetworkingSync detects duplicate host ports', (t) => {
|
||||
const r = validateNetworkingSync({
|
||||
ports: ['8080:80/tcp', '8080:81/tcp'],
|
||||
})
|
||||
t.ok(r.errors.some((e) => /more than once/i.test(e)))
|
||||
})
|
||||
|
||||
test('validateNetworkingSync warns on privileged ports', (t) => {
|
||||
const r = validateNetworkingSync({
|
||||
ports: ['80:80/tcp'],
|
||||
})
|
||||
t.is(r.errors.length, 0)
|
||||
t.ok(r.warnings.some((e) => /privileged/i.test(e)))
|
||||
})
|
||||
|
||||
test('validateNetworkingSync host mode warns about ignored mappings', (t) => {
|
||||
const r = validateNetworkingSync({
|
||||
networkMode: 'host',
|
||||
ports: ['8080:80/tcp'],
|
||||
})
|
||||
t.is(r.errors.length, 0)
|
||||
t.ok(r.warnings.some((e) => /host/i.test(e) && /ignored/i.test(e)))
|
||||
})
|
||||
|
||||
test('validateNetworkingSync container mode requires peer container', (t) => {
|
||||
const r = validateNetworkingSync({
|
||||
networkMode: 'container',
|
||||
customNetwork: '',
|
||||
ports: [],
|
||||
})
|
||||
t.ok(r.errors.some((e) => /peer container/i.test(e)))
|
||||
})
|
||||
|
||||
test('precheckDeployNetworking flags used host ports', async (t) => {
|
||||
const r = await precheckDeployNetworking(
|
||||
{ networkMode: 'bridge', ports: ['8080:80/tcp'] },
|
||||
{ usedPorts: [8080, 9000] }
|
||||
)
|
||||
t.not(r.ok)
|
||||
t.ok(r.errors.some((e) => /already published/i.test(e)))
|
||||
})
|
||||
|
||||
test('precheckDeployNetworking ok when free', async (t) => {
|
||||
const r = await precheckDeployNetworking(
|
||||
{ networkMode: 'bridge', ports: ['8080:80/tcp'] },
|
||||
{ usedPorts: [9000] }
|
||||
)
|
||||
t.ok(r.ok)
|
||||
})
|
||||
|
||||
test('formatNetworkingPrecheckMessage joins errors', (t) => {
|
||||
const msg = formatNetworkingPrecheckMessage({
|
||||
errors: ['a', 'b'],
|
||||
warnings: ['c'],
|
||||
})
|
||||
t.ok(msg.includes('a'))
|
||||
t.ok(msg.includes('b'))
|
||||
})
|
||||
Reference in New Issue
Block a user