Fix Duplicate/Edit cloning (volumes/settings) and initial image update check.
Release rolling / release (push) Successful in 9m49s
Release rolling / release (push) Successful in 9m49s
This commit is contained in:
@@ -1028,6 +1028,8 @@ function navigateToView(viewName, opts = {}) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
sendCommand('listContainers');
|
sendCommand('listContainers');
|
||||||
|
// First open of Containers for this peer: compare digests to registries
|
||||||
|
ensureInitialImageUpdateCheck();
|
||||||
}
|
}
|
||||||
} else if (viewName === 'images') {
|
} else if (viewName === 'images') {
|
||||||
loadImages();
|
loadImages();
|
||||||
@@ -2962,6 +2964,8 @@ const imageUpdateByImage = new Map();
|
|||||||
const imageUpdateByContainer = new Map();
|
const imageUpdateByContainer = new Map();
|
||||||
let imageUpdateCheckInFlight = false;
|
let imageUpdateCheckInFlight = false;
|
||||||
let imageUpdateCheckTimer = null;
|
let imageUpdateCheckTimer = null;
|
||||||
|
/** Peer id for which we already kicked off the first Containers-tab update check */
|
||||||
|
let imageUpdateInitialPeerId = '';
|
||||||
let currentImageFilter = 'all'; // Current filter: 'all', 'used', 'unused'
|
let currentImageFilter = 'all'; // Current filter: 'all', 'used', 'unused'
|
||||||
|
|
||||||
function loadImages() {
|
function loadImages() {
|
||||||
@@ -9318,6 +9322,10 @@ function clearContainerStore() {
|
|||||||
containerStore.gen = 0;
|
containerStore.gen = 0;
|
||||||
containerStore.topicId = '';
|
containerStore.topicId = '';
|
||||||
containerFilterState.allContainers = [];
|
containerFilterState.allContainers = [];
|
||||||
|
// New peer / reset — allow Containers tab to run an initial update check again
|
||||||
|
imageUpdateInitialPeerId = '';
|
||||||
|
imageUpdateByImage.clear();
|
||||||
|
imageUpdateByContainer.clear();
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Merge IP from inspect payload without reordering / wiping the table */
|
/** Merge IP from inspect payload without reordering / wiping the table */
|
||||||
@@ -9693,6 +9701,20 @@ function scheduleImageUpdateCheck(opts = {}) {
|
|||||||
}, delay);
|
}, delay);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Run image update check once when Containers tab first loads for the active peer.
|
||||||
|
* List reconcile often skips this (structFp unchanged / check deferred while off-tab).
|
||||||
|
*/
|
||||||
|
function ensureInitialImageUpdateCheck() {
|
||||||
|
if (!manager?.active?.connected) return;
|
||||||
|
if (currentView && currentView !== 'containers') return;
|
||||||
|
const peerId = manager.active.id || '';
|
||||||
|
if (!peerId) return;
|
||||||
|
if (imageUpdateInitialPeerId === peerId) return;
|
||||||
|
imageUpdateInitialPeerId = peerId;
|
||||||
|
scheduleImageUpdateCheck({ force: false, immediate: true, initial: true });
|
||||||
|
}
|
||||||
|
|
||||||
async function runImageUpdateCheck(opts = {}) {
|
async function runImageUpdateCheck(opts = {}) {
|
||||||
if (!manager.active?.connected) return;
|
if (!manager.active?.connected) return;
|
||||||
if (currentView && currentView !== 'containers' && !opts.force) return;
|
if (currentView && currentView !== 'containers' && !opts.force) return;
|
||||||
@@ -9706,14 +9728,15 @@ async function runImageUpdateCheck(opts = {}) {
|
|||||||
}
|
}
|
||||||
if (line) line.textContent = 'Checking image digests against registries…';
|
if (line) line.textContent = 'Checking image digests against registries…';
|
||||||
|
|
||||||
// Mark visible rows as checking when no cached status
|
// Mark visible rows as checking on force or first tab load (no client cache yet)
|
||||||
const listElement = domCache.containerList || containerList;
|
const listElement = domCache.containerList || containerList;
|
||||||
if (listElement && opts.force) {
|
const showChecking = Boolean(opts.force || opts.initial || imageUpdateByImage.size === 0);
|
||||||
|
if (listElement && showChecking) {
|
||||||
listElement.querySelectorAll('tr[data-container-id]').forEach((row) => {
|
listElement.querySelectorAll('tr[data-container-id]').forEach((row) => {
|
||||||
const c = row._pdContainer;
|
const c = row._pdContainer;
|
||||||
if (!c) return;
|
if (!c) return;
|
||||||
const existing = imageUpdateByContainer.get(c.Id) || imageUpdateByImage.get(c.Image);
|
const existing = imageUpdateByContainer.get(c.Id) || imageUpdateByImage.get(c.Image);
|
||||||
if (!existing || opts.force) {
|
if (!existing || opts.force || opts.initial) {
|
||||||
imageUpdateByContainer.set(c.Id, { status: 'checking', image: c.Image });
|
imageUpdateByContainer.set(c.Id, { status: 'checking', image: c.Image });
|
||||||
applyImageUpdateToRow(row, c);
|
applyImageUpdateToRow(row, c);
|
||||||
}
|
}
|
||||||
@@ -10384,6 +10407,11 @@ function renderContainers(containers, topicId) {
|
|||||||
applyRoleUI();
|
applyRoleUI();
|
||||||
// Digest check (cached server-side; cheap when warm)
|
// Digest check (cached server-side; cheap when warm)
|
||||||
scheduleImageUpdateCheck({ force: false });
|
scheduleImageUpdateCheck({ force: false });
|
||||||
|
// Ensure first Containers-tab open for this peer always kicks a check
|
||||||
|
// (schedule above is skipped when currentView was not containers when the timer fired)
|
||||||
|
if (currentView === 'containers') {
|
||||||
|
ensureInitialImageUpdateCheck();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
+330
-114
@@ -1127,16 +1127,28 @@ const activeVolumeHandlers = new Map();
|
|||||||
window.activeVolumeHandlers = activeVolumeHandlers;
|
window.activeVolumeHandlers = activeVolumeHandlers;
|
||||||
|
|
||||||
// Simplified load volumes for named volume select
|
// Simplified load volumes for named volume select
|
||||||
async function loadVolumesForSelect(volumeId) {
|
async function loadVolumesForSelect(volumeId, preferredName) {
|
||||||
const namedSelect = document.querySelector(`[data-volume-named="${volumeId}"]`);
|
const namedSelect = document.querySelector(`[data-volume-named="${volumeId}"]`);
|
||||||
if (!namedSelect) {
|
if (!namedSelect) {
|
||||||
console.error('[ERROR] Named volume select element not found for ID:', volumeId);
|
console.error('[ERROR] Named volume select element not found for ID:', volumeId);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (preferredName) {
|
||||||
|
namedSelect.dataset.preferredVolume = String(preferredName);
|
||||||
|
}
|
||||||
|
|
||||||
// Check if already has volumes loaded (more than just placeholder)
|
// Check if already has volumes loaded (more than just placeholder)
|
||||||
const hasVolumeOptions = Array.from(namedSelect.options).some(opt => opt.value && opt.value !== '');
|
const hasVolumeOptions = Array.from(namedSelect.options).some(opt => opt.value && opt.value !== '');
|
||||||
if (hasVolumeOptions && namedSelect.options.length > 1) {
|
if (hasVolumeOptions && namedSelect.options.length > 1) {
|
||||||
|
// Still restore preferred selection if needed
|
||||||
|
const preferred = namedSelect.dataset.preferredVolume || preferredName || namedSelect.value;
|
||||||
|
if (preferred) {
|
||||||
|
if (![...namedSelect.options].some((o) => o.value === preferred)) {
|
||||||
|
namedSelect.add(new Option(preferred, preferred, true, true));
|
||||||
|
}
|
||||||
|
namedSelect.value = preferred;
|
||||||
|
}
|
||||||
return; // Already loaded
|
return; // Already loaded
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1281,33 +1293,42 @@ function populateVolumeSelect(volumeId, volumesArray) {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const currentValue = namedSelect.value;
|
const preferred =
|
||||||
|
namedSelect.dataset.preferredVolume ||
|
||||||
|
namedSelect.value ||
|
||||||
|
'';
|
||||||
|
|
||||||
// Clear and rebuild options
|
// Clear and rebuild options
|
||||||
namedSelect.innerHTML = '';
|
namedSelect.innerHTML = '';
|
||||||
|
|
||||||
// Add placeholder option
|
// Add placeholder option
|
||||||
const placeholderOption = new Option('Select or create volume...', '', true, false);
|
const placeholderOption = new Option('Select or create volume...', '', !preferred, false);
|
||||||
namedSelect.add(placeholderOption);
|
namedSelect.add(placeholderOption);
|
||||||
|
|
||||||
// Add volumes
|
// Add volumes
|
||||||
if (!volumesArray || volumesArray.length === 0) {
|
const seen = new Set();
|
||||||
const option = new Option('No volumes available', '', false, true);
|
if (volumesArray && volumesArray.length > 0) {
|
||||||
option.disabled = true;
|
|
||||||
namedSelect.add(option);
|
|
||||||
} else {
|
|
||||||
volumesArray.forEach((volume) => {
|
volumesArray.forEach((volume) => {
|
||||||
const volumeName = volume.Name || volume.name || (typeof volume === 'string' ? volume : null);
|
const volumeName = volume.Name || volume.name || (typeof volume === 'string' ? volume : null);
|
||||||
if (volumeName) {
|
if (volumeName && !seen.has(volumeName)) {
|
||||||
const option = new Option(volumeName, volumeName, false, false);
|
seen.add(volumeName);
|
||||||
namedSelect.add(option);
|
namedSelect.add(new Option(volumeName, volumeName, false, false));
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
} else if (!preferred) {
|
||||||
|
const option = new Option('No volumes available', '', false, false);
|
||||||
|
option.disabled = true;
|
||||||
|
namedSelect.add(option);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Restore previous selection if it still exists
|
// Always keep the preferred/cloned volume selectable even if not in list yet
|
||||||
if (currentValue && Array.from(namedSelect.options).some(opt => opt.value === currentValue)) {
|
if (preferred && !seen.has(preferred)) {
|
||||||
namedSelect.value = currentValue;
|
namedSelect.add(new Option(preferred, preferred, true, true));
|
||||||
|
seen.add(preferred);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (preferred) {
|
||||||
|
namedSelect.value = preferred;
|
||||||
}
|
}
|
||||||
|
|
||||||
namedSelect.disabled = false;
|
namedSelect.disabled = false;
|
||||||
@@ -2738,6 +2759,157 @@ function addDuplicatePortMapping(portData = null) {
|
|||||||
container.appendChild(item);
|
container.appendChild(item);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Parse a Docker bind/volume string into form fields.
|
||||||
|
* Handles modes with commas (ro,Z) and named volumes.
|
||||||
|
* @param {string|null|undefined} volumeData
|
||||||
|
* @returns {{ volumeType: 'bind'|'named', hostPath: string, containerPath: string, mountMode: 'rw'|'ro' }}
|
||||||
|
*/
|
||||||
|
function parseVolumeMountSpec(volumeData) {
|
||||||
|
let volumeType = 'bind';
|
||||||
|
let hostPath = '';
|
||||||
|
let containerPath = '';
|
||||||
|
let mountMode = 'rw';
|
||||||
|
|
||||||
|
if (!volumeData || typeof volumeData !== 'string') {
|
||||||
|
return { volumeType, hostPath, containerPath, mountMode };
|
||||||
|
}
|
||||||
|
|
||||||
|
const raw = volumeData.trim();
|
||||||
|
const modeRe =
|
||||||
|
/^(?:ro|rw|z|Z|shared|rshared|slave|rslave|private|rprivate)(?:,(?:ro|rw|z|Z|shared|rshared|slave|rslave|private|rprivate))*$/i;
|
||||||
|
const parts = raw.split(':');
|
||||||
|
if (parts.length < 2) {
|
||||||
|
return { volumeType, hostPath, containerPath, mountMode };
|
||||||
|
}
|
||||||
|
|
||||||
|
let source;
|
||||||
|
let dest;
|
||||||
|
let modePart = '';
|
||||||
|
if (parts.length >= 3 && modeRe.test(parts[parts.length - 1])) {
|
||||||
|
modePart = parts[parts.length - 1];
|
||||||
|
dest = parts[parts.length - 2];
|
||||||
|
source = parts.slice(0, -2).join(':');
|
||||||
|
} else {
|
||||||
|
dest = parts[parts.length - 1];
|
||||||
|
source = parts.slice(0, -1).join(':');
|
||||||
|
}
|
||||||
|
|
||||||
|
hostPath = source || '';
|
||||||
|
containerPath = dest || '';
|
||||||
|
if (modePart) {
|
||||||
|
mountMode = /\bro\b/i.test(modePart) && !/\brw\b/i.test(modePart) ? 'ro' : 'rw';
|
||||||
|
}
|
||||||
|
|
||||||
|
const namedVol = /^[a-zA-Z0-9][a-zA-Z0-9_.-]*$/;
|
||||||
|
const looksNamed =
|
||||||
|
namedVol.test(hostPath) &&
|
||||||
|
!hostPath.startsWith('/') &&
|
||||||
|
!hostPath.startsWith('~') &&
|
||||||
|
!hostPath.startsWith('.') &&
|
||||||
|
!/^[A-Za-z]:[\\/]/.test(hostPath);
|
||||||
|
volumeType = looksNamed ? 'named' : 'bind';
|
||||||
|
|
||||||
|
return { volumeType, hostPath, containerPath, mountMode };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Escape a value for use inside double-quoted HTML attributes.
|
||||||
|
* @param {string} s
|
||||||
|
*/
|
||||||
|
function escapeAttrValue(s) {
|
||||||
|
return String(s ?? '')
|
||||||
|
.replace(/&/g, '&')
|
||||||
|
.replace(/"/g, '"')
|
||||||
|
.replace(/</g, '<')
|
||||||
|
.replace(/>/g, '>');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Build volume bind strings from a container inspect result.
|
||||||
|
* Merges Mounts + HostConfig.Binds + HostConfig.Mounts (deduped by destination).
|
||||||
|
* @param {object} config
|
||||||
|
* @returns {string[]}
|
||||||
|
*/
|
||||||
|
function extractVolumeSpecsFromInspect(config) {
|
||||||
|
/** @type {Map<string, string>} dest → bind string */
|
||||||
|
const byDest = new Map();
|
||||||
|
|
||||||
|
const pushSpec = (source, dest, mode = 'rw', typeHint = '') => {
|
||||||
|
if (!source || !dest) return;
|
||||||
|
const modeStr = mode === 'ro' ? 'ro' : 'rw';
|
||||||
|
const hint = String(typeHint || '').toLowerCase();
|
||||||
|
const namedVol = /^[a-zA-Z0-9][a-zA-Z0-9_.-]*$/;
|
||||||
|
const isNamed =
|
||||||
|
hint === 'volume' ||
|
||||||
|
(namedVol.test(source) &&
|
||||||
|
!source.startsWith('/') &&
|
||||||
|
!source.startsWith('~') &&
|
||||||
|
!source.startsWith('.'));
|
||||||
|
// Prefer first non-empty; later Binds can fill gaps only
|
||||||
|
if (byDest.has(dest)) return;
|
||||||
|
byDest.set(dest, `${source}:${dest}:${modeStr}`);
|
||||||
|
void isNamed;
|
||||||
|
};
|
||||||
|
|
||||||
|
const modeFromMount = (m) => {
|
||||||
|
if (m?.RW === false || m?.ReadOnly === true) return 'ro';
|
||||||
|
if (/\bro\b/i.test(String(m?.Mode || ''))) return 'ro';
|
||||||
|
return 'rw';
|
||||||
|
};
|
||||||
|
|
||||||
|
const considerMount = (m) => {
|
||||||
|
if (!m || typeof m !== 'object') return;
|
||||||
|
const type = String(m.Type || m.type || '').toLowerCase();
|
||||||
|
if (type === 'tmpfs') return; // handled in tmpfs section
|
||||||
|
const dest = m.Destination || m.Target || m.destination || m.target;
|
||||||
|
if (!dest) return;
|
||||||
|
|
||||||
|
const mode = modeFromMount(m);
|
||||||
|
|
||||||
|
if (type === 'volume' || m.Name || m.Driver) {
|
||||||
|
const name = m.Name || m.name;
|
||||||
|
if (name && !String(name).startsWith('/')) {
|
||||||
|
pushSpec(String(name), dest, mode, 'volume');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (type === 'bind' || m.Source || m.source) {
|
||||||
|
const src = m.Source || m.source;
|
||||||
|
if (src) pushSpec(String(src), dest, mode, type || 'bind');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
if (Array.isArray(config?.Mounts)) {
|
||||||
|
for (const m of config.Mounts) considerMount(m);
|
||||||
|
}
|
||||||
|
if (Array.isArray(config?.HostConfig?.Mounts)) {
|
||||||
|
for (const m of config.HostConfig.Mounts) considerMount(m);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Binds fill anything Mounts missed (or when Mounts empty)
|
||||||
|
if (Array.isArray(config?.HostConfig?.Binds)) {
|
||||||
|
for (const bind of config.HostConfig.Binds) {
|
||||||
|
if (!bind) continue;
|
||||||
|
const parsed = parseVolumeMountSpec(String(bind));
|
||||||
|
if (!parsed.containerPath || !parsed.hostPath) continue;
|
||||||
|
if (byDest.has(parsed.containerPath)) {
|
||||||
|
// Prefer Binds mode if we only had a weak entry — keep existing
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
byDest.set(
|
||||||
|
parsed.containerPath,
|
||||||
|
`${parsed.hostPath}:${parsed.containerPath}:${parsed.mountMode}`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Config.Volumes keys are container paths declared in the image (anonymous).
|
||||||
|
// Only include if we already have a real mount for that path — never invent host paths.
|
||||||
|
return [...byDest.values()];
|
||||||
|
}
|
||||||
|
|
||||||
function addDuplicateVolumeMount(volumeData = null) {
|
function addDuplicateVolumeMount(volumeData = null) {
|
||||||
const container = document.getElementById('duplicate-volumes-container');
|
const container = document.getElementById('duplicate-volumes-container');
|
||||||
if (!container) return;
|
if (!container) return;
|
||||||
@@ -2746,29 +2918,13 @@ function addDuplicateVolumeMount(volumeData = null) {
|
|||||||
item.className = 'array-item volume-mount-item';
|
item.className = 'array-item volume-mount-item';
|
||||||
item.id = id;
|
item.id = id;
|
||||||
|
|
||||||
// Parse existing volume data if provided
|
const parsed = parseVolumeMountSpec(volumeData);
|
||||||
let volumeType = 'bind';
|
const volumeType = parsed.volumeType;
|
||||||
let hostPath = '';
|
const hostPath = parsed.hostPath;
|
||||||
let containerPath = '';
|
const containerPath = parsed.containerPath;
|
||||||
let mountMode = 'rw';
|
const mountMode = parsed.mountMode;
|
||||||
|
const hostEsc = escapeAttrValue(hostPath);
|
||||||
if (volumeData) {
|
const destEsc = escapeAttrValue(containerPath);
|
||||||
const parts = volumeData.split(':');
|
|
||||||
if (parts.length >= 2) {
|
|
||||||
// Check if it's a named volume (starts with volume name, no leading slash)
|
|
||||||
if (!parts[0].startsWith('/') && !parts[0].startsWith('~')) {
|
|
||||||
volumeType = 'named';
|
|
||||||
hostPath = parts[0];
|
|
||||||
} else {
|
|
||||||
volumeType = 'bind';
|
|
||||||
hostPath = parts[0];
|
|
||||||
}
|
|
||||||
containerPath = parts[1];
|
|
||||||
if (parts.length === 3) {
|
|
||||||
mountMode = parts[2];
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
item.innerHTML = `
|
item.innerHTML = `
|
||||||
<div class="volume-mount-fields">
|
<div class="volume-mount-fields">
|
||||||
@@ -2788,7 +2944,7 @@ function addDuplicateVolumeMount(volumeData = null) {
|
|||||||
class="form-control bg-dark text-white volume-host-input"
|
class="form-control bg-dark text-white volume-host-input"
|
||||||
placeholder="/host/path"
|
placeholder="/host/path"
|
||||||
data-volume-host="${id}"
|
data-volume-host="${id}"
|
||||||
value="${hostPath}"
|
value="${hostEsc}"
|
||||||
oninput="validateVolumeMount('${id}')">
|
oninput="validateVolumeMount('${id}')">
|
||||||
<button type="button"
|
<button type="button"
|
||||||
class="btn btn-outline-secondary"
|
class="btn btn-outline-secondary"
|
||||||
@@ -2820,7 +2976,7 @@ function addDuplicateVolumeMount(volumeData = null) {
|
|||||||
placeholder="/container/path"
|
placeholder="/container/path"
|
||||||
required
|
required
|
||||||
data-volume-container="${id}"
|
data-volume-container="${id}"
|
||||||
value="${containerPath}"
|
value="${destEsc}"
|
||||||
oninput="validateVolumeMount('${id}')">
|
oninput="validateVolumeMount('${id}')">
|
||||||
<small class="volume-error-msg" data-volume-container-error="${id}" style="display: none;"></small>
|
<small class="volume-error-msg" data-volume-container-error="${id}" style="display: none;"></small>
|
||||||
</div>
|
</div>
|
||||||
@@ -2840,9 +2996,16 @@ function addDuplicateVolumeMount(volumeData = null) {
|
|||||||
`;
|
`;
|
||||||
container.appendChild(item);
|
container.appendChild(item);
|
||||||
|
|
||||||
// Load volumes if named volume is selected
|
// Named volumes: seed the select immediately so collect works before list loads
|
||||||
if (volumeType === 'named') {
|
if (volumeType === 'named' && hostPath) {
|
||||||
loadVolumesForSelect(id);
|
const namedSelect = item.querySelector('.volume-named-input');
|
||||||
|
if (namedSelect) {
|
||||||
|
namedSelect.dataset.preferredVolume = hostPath;
|
||||||
|
const seed = new Option(hostPath, hostPath, true, true);
|
||||||
|
namedSelect.add(seed);
|
||||||
|
namedSelect.value = hostPath;
|
||||||
|
}
|
||||||
|
loadVolumesForSelect(id, hostPath);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -3153,10 +3316,11 @@ function collectDuplicateLabels() {
|
|||||||
const items = collectArrayItems('duplicate-labels-container', 'data-label-id');
|
const items = collectArrayItems('duplicate-labels-container', 'data-label-id');
|
||||||
const labels = {};
|
const labels = {};
|
||||||
items.forEach(item => {
|
items.forEach(item => {
|
||||||
const [key, value] = item.split('=');
|
if (!item || !item.includes('=')) return;
|
||||||
if (key && value) {
|
const eq = item.indexOf('=');
|
||||||
labels[key.trim()] = value.trim();
|
const key = item.slice(0, eq).trim();
|
||||||
}
|
const value = item.slice(eq + 1); // preserve = inside values
|
||||||
|
if (key) labels[key] = value;
|
||||||
});
|
});
|
||||||
return Object.keys(labels).length > 0 ? labels : null;
|
return Object.keys(labels).length > 0 ? labels : null;
|
||||||
}
|
}
|
||||||
@@ -3180,10 +3344,11 @@ function collectDuplicateSysctls() {
|
|||||||
const items = collectArrayItems('duplicate-sysctls-container', 'data-sysctl-id');
|
const items = collectArrayItems('duplicate-sysctls-container', 'data-sysctl-id');
|
||||||
const sysctls = {};
|
const sysctls = {};
|
||||||
items.forEach(item => {
|
items.forEach(item => {
|
||||||
const [key, value] = item.split('=');
|
if (!item || !item.includes('=')) return;
|
||||||
if (key && value) {
|
const eq = item.indexOf('=');
|
||||||
sysctls[key.trim()] = value.trim();
|
const key = item.slice(0, eq).trim();
|
||||||
}
|
const value = item.slice(eq + 1).trim();
|
||||||
|
if (key) sysctls[key] = value;
|
||||||
});
|
});
|
||||||
return Object.keys(sysctls).length > 0 ? sysctls : null;
|
return Object.keys(sysctls).length > 0 ? sysctls : null;
|
||||||
}
|
}
|
||||||
@@ -3315,20 +3480,42 @@ function populateDuplicateForm(config, opts = {}) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const imageEl = document.getElementById('duplicate-image');
|
const imageEl = document.getElementById('duplicate-image');
|
||||||
if (imageEl) imageEl.value = config.Config?.Image || '';
|
if (imageEl) {
|
||||||
|
// Prefer Config.Image; fall back to top-level Image / Config.ImageID-less tag
|
||||||
|
imageEl.value =
|
||||||
|
config.Config?.Image ||
|
||||||
|
(typeof config.Image === 'string' && !config.Image.startsWith('sha256:')
|
||||||
|
? config.Image
|
||||||
|
: '') ||
|
||||||
|
'';
|
||||||
|
}
|
||||||
|
|
||||||
// Default on (match Add container); user can turn off to reuse a local image
|
// Default on (match Add container); user can turn off to reuse a local image
|
||||||
const alwaysPullEl = document.getElementById('duplicate-always-pull');
|
const alwaysPullEl = document.getElementById('duplicate-always-pull');
|
||||||
if (alwaysPullEl) alwaysPullEl.checked = true;
|
if (alwaysPullEl) alwaysPullEl.checked = true;
|
||||||
|
|
||||||
const commandEl = document.getElementById('duplicate-command');
|
const commandEl = document.getElementById('duplicate-command');
|
||||||
if (commandEl && config.Config?.Cmd) {
|
if (commandEl) {
|
||||||
commandEl.value = Array.isArray(config.Config.Cmd) ? config.Config.Cmd.join(' ') : config.Config.Cmd;
|
const cmd = config.Config?.Cmd;
|
||||||
|
if (Array.isArray(cmd) && cmd.length) {
|
||||||
|
commandEl.value = cmd.join(' ');
|
||||||
|
} else if (typeof cmd === 'string' && cmd) {
|
||||||
|
commandEl.value = cmd;
|
||||||
|
} else {
|
||||||
|
commandEl.value = '';
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const entrypointEl = document.getElementById('duplicate-entrypoint');
|
const entrypointEl = document.getElementById('duplicate-entrypoint');
|
||||||
if (entrypointEl && config.Config?.Entrypoint) {
|
if (entrypointEl) {
|
||||||
entrypointEl.value = Array.isArray(config.Config.Entrypoint) ? config.Config.Entrypoint.join(' ') : config.Config.Entrypoint;
|
const ep = config.Config?.Entrypoint;
|
||||||
|
if (Array.isArray(ep) && ep.length) {
|
||||||
|
entrypointEl.value = ep.join(' ');
|
||||||
|
} else if (typeof ep === 'string' && ep) {
|
||||||
|
entrypointEl.value = ep;
|
||||||
|
} else {
|
||||||
|
entrypointEl.value = '';
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const workdirEl = document.getElementById('duplicate-workdir');
|
const workdirEl = document.getElementById('duplicate-workdir');
|
||||||
@@ -3378,12 +3565,13 @@ function populateDuplicateForm(config, opts = {}) {
|
|||||||
const domainnameEl = document.getElementById('duplicate-domainname');
|
const domainnameEl = document.getElementById('duplicate-domainname');
|
||||||
if (domainnameEl) domainnameEl.value = config.Config?.Domainname || '';
|
if (domainnameEl) domainnameEl.value = config.Config?.Domainname || '';
|
||||||
|
|
||||||
// Port bindings (HostConfig + live NetworkSettings.Ports fallback)
|
// Port bindings (HostConfig + live NetworkSettings.Ports fallback) — all host mappings
|
||||||
const portBindings = config.HostConfig?.PortBindings || {};
|
const portBindings = config.HostConfig?.PortBindings || {};
|
||||||
const livePorts = config.NetworkSettings?.Ports || {};
|
const livePorts = config.NetworkSettings?.Ports || {};
|
||||||
const portKeys = new Set([
|
const portKeys = new Set([
|
||||||
...Object.keys(portBindings),
|
...Object.keys(portBindings),
|
||||||
...Object.keys(livePorts),
|
...Object.keys(livePorts),
|
||||||
|
...Object.keys(config.Config?.ExposedPorts || {}),
|
||||||
]);
|
]);
|
||||||
portKeys.forEach((containerPort) => {
|
portKeys.forEach((containerPort) => {
|
||||||
const bindings =
|
const bindings =
|
||||||
@@ -3394,14 +3582,16 @@ function populateDuplicateForm(config, opts = {}) {
|
|||||||
? livePorts[containerPort]
|
? livePorts[containerPort]
|
||||||
: null) ||
|
: null) ||
|
||||||
[null];
|
[null];
|
||||||
const binding = bindings[0];
|
|
||||||
const protocol = containerPort.split('/')[1] || 'tcp';
|
const protocol = containerPort.split('/')[1] || 'tcp';
|
||||||
const portNum = containerPort.split('/')[0];
|
const portNum = containerPort.split('/')[0];
|
||||||
|
// One form row per host binding (duplicate may publish the same container port multiple times)
|
||||||
|
for (const binding of bindings) {
|
||||||
const hostPort = binding?.HostPort || '';
|
const hostPort = binding?.HostPort || '';
|
||||||
const portStr = hostPort
|
const portStr = hostPort
|
||||||
? `${hostPort}:${portNum}/${protocol}`
|
? `${hostPort}:${portNum}/${protocol}`
|
||||||
: `${portNum}/${protocol}`;
|
: `${portNum}/${protocol}`;
|
||||||
addDuplicatePortMapping(portStr);
|
addDuplicatePortMapping(portStr);
|
||||||
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
// DNS
|
// DNS
|
||||||
@@ -3424,44 +3614,44 @@ function populateDuplicateForm(config, opts = {}) {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// Volumes — Binds preferred; fall back to Mounts (named volumes / binds)
|
// Volumes — Mounts + Binds + HostConfig.Mounts (named + bind), deduped by destination
|
||||||
const volumeSpecs = [];
|
const volumeSpecs = extractVolumeSpecsFromInspect(config);
|
||||||
if (config.HostConfig?.Binds && Array.isArray(config.HostConfig.Binds)) {
|
|
||||||
for (const bind of config.HostConfig.Binds) {
|
|
||||||
if (bind) volumeSpecs.push(bind);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if (!volumeSpecs.length && Array.isArray(config.Mounts)) {
|
|
||||||
for (const m of config.Mounts) {
|
|
||||||
if (!m?.Destination) continue;
|
|
||||||
const mode = m.RW === false || m.Mode === 'ro' ? 'ro' : 'rw';
|
|
||||||
if (m.Type === 'volume' && m.Name) {
|
|
||||||
volumeSpecs.push(`${m.Name}:${m.Destination}:${mode}`);
|
|
||||||
} else if (m.Source && m.Destination) {
|
|
||||||
volumeSpecs.push(`${m.Source}:${m.Destination}:${mode}`);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
volumeSpecs.forEach((bind) => addDuplicateVolumeMount(bind));
|
volumeSpecs.forEach((bind) => addDuplicateVolumeMount(bind));
|
||||||
|
|
||||||
// Tmpfs
|
// Tmpfs (HostConfig.Tmpfs + Mounts type=tmpfs)
|
||||||
if (config.HostConfig?.Tmpfs && typeof config.HostConfig.Tmpfs === 'object') {
|
const tmpfsMap = { ...(config.HostConfig?.Tmpfs || {}) };
|
||||||
Object.entries(config.HostConfig.Tmpfs).forEach(([path, opts]) => {
|
if (Array.isArray(config.Mounts)) {
|
||||||
|
for (const m of config.Mounts) {
|
||||||
|
if (String(m?.Type || '').toLowerCase() !== 'tmpfs') continue;
|
||||||
|
const dest = m.Destination || m.Target;
|
||||||
|
if (!dest || tmpfsMap[dest] !== undefined) continue;
|
||||||
|
tmpfsMap[dest] = m.Mode || m.TmpfsOptions
|
||||||
|
? Object.entries(m.TmpfsOptions || {})
|
||||||
|
.map(([k, v]) => (v === true ? k : `${k}=${v}`))
|
||||||
|
.join(',')
|
||||||
|
: '';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Object.entries(tmpfsMap).forEach(([path, opts]) => {
|
||||||
addDuplicateTmpfsMount();
|
addDuplicateTmpfsMount();
|
||||||
const container = document.getElementById('duplicate-tmpfs-container');
|
const container = document.getElementById('duplicate-tmpfs-container');
|
||||||
const lastInput = container?.lastElementChild?.querySelector('input');
|
const lastInput = container?.lastElementChild?.querySelector('input');
|
||||||
if (lastInput) lastInput.value = opts ? `${path}:${opts}` : path;
|
if (lastInput) lastInput.value = opts ? `${path}:${opts}` : path;
|
||||||
});
|
});
|
||||||
}
|
|
||||||
|
|
||||||
// Resources
|
// Resources
|
||||||
if (config.HostConfig?.NanoCpus) {
|
if (config.HostConfig?.NanoCpus) {
|
||||||
const cpuLimitEl = document.getElementById('duplicate-cpu-limit');
|
const cpuLimitEl = document.getElementById('duplicate-cpu-limit');
|
||||||
if (cpuLimitEl) cpuLimitEl.value = config.HostConfig.NanoCpus / 1000000000;
|
if (cpuLimitEl) cpuLimitEl.value = config.HostConfig.NanoCpus / 1e9;
|
||||||
}
|
}
|
||||||
if (config.HostConfig?.CpuQuota) {
|
// CpuQuota is microseconds per period (default period 100000), not nanoseconds
|
||||||
|
if (config.HostConfig?.CpuQuota > 0) {
|
||||||
const cpuReservationEl = document.getElementById('duplicate-cpu-reservation');
|
const cpuReservationEl = document.getElementById('duplicate-cpu-reservation');
|
||||||
if (cpuReservationEl) cpuReservationEl.value = config.HostConfig.CpuQuota / 1000000000;
|
if (cpuReservationEl) {
|
||||||
|
const period =
|
||||||
|
config.HostConfig.CpuPeriod > 0 ? config.HostConfig.CpuPeriod : 100000;
|
||||||
|
cpuReservationEl.value = config.HostConfig.CpuQuota / period;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
if (config.HostConfig?.CpuShares) {
|
if (config.HostConfig?.CpuShares) {
|
||||||
const cpuSharesEl = document.getElementById('duplicate-cpu-shares');
|
const cpuSharesEl = document.getElementById('duplicate-cpu-shares');
|
||||||
@@ -3475,9 +3665,14 @@ function populateDuplicateForm(config, opts = {}) {
|
|||||||
const memoryReservationEl = document.getElementById('duplicate-memory-reservation');
|
const memoryReservationEl = document.getElementById('duplicate-memory-reservation');
|
||||||
if (memoryReservationEl) memoryReservationEl.value = Math.round(config.HostConfig.MemoryReservation / (1024 * 1024));
|
if (memoryReservationEl) memoryReservationEl.value = Math.round(config.HostConfig.MemoryReservation / (1024 * 1024));
|
||||||
}
|
}
|
||||||
if (config.HostConfig?.MemorySwap !== undefined) {
|
if (config.HostConfig?.MemorySwap !== undefined && config.HostConfig.MemorySwap !== null) {
|
||||||
const memorySwapEl = document.getElementById('duplicate-memory-swap');
|
const memorySwapEl = document.getElementById('duplicate-memory-swap');
|
||||||
if (memorySwapEl) memorySwapEl.value = config.HostConfig.MemorySwap === -1 ? -1 : Math.round(config.HostConfig.MemorySwap / (1024 * 1024));
|
if (memorySwapEl) {
|
||||||
|
memorySwapEl.value =
|
||||||
|
config.HostConfig.MemorySwap === -1
|
||||||
|
? -1
|
||||||
|
: Math.round(config.HostConfig.MemorySwap / (1024 * 1024));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Devices
|
// Devices
|
||||||
@@ -3524,19 +3719,30 @@ function populateDuplicateForm(config, opts = {}) {
|
|||||||
if (userEl) userEl.value = config.Config?.User || '';
|
if (userEl) userEl.value = config.Config?.User || '';
|
||||||
|
|
||||||
const groupEl = document.getElementById('duplicate-group');
|
const groupEl = document.getElementById('duplicate-group');
|
||||||
if (groupEl && config.HostConfig?.GroupAdd && config.HostConfig.GroupAdd.length > 0) {
|
if (groupEl) {
|
||||||
groupEl.value = config.HostConfig.GroupAdd[0];
|
// Form has a single group field — join extra groups so they aren't dropped
|
||||||
|
const groups = config.HostConfig?.GroupAdd;
|
||||||
|
if (Array.isArray(groups) && groups.length) {
|
||||||
|
groupEl.value = groups.join(',');
|
||||||
|
} else {
|
||||||
|
groupEl.value = '';
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const privilegedEl = document.getElementById('duplicate-privileged');
|
const privilegedEl = document.getElementById('duplicate-privileged');
|
||||||
if (privilegedEl) privilegedEl.checked = config.HostConfig?.Privileged || false;
|
if (privilegedEl) privilegedEl.checked = Boolean(config.HostConfig?.Privileged);
|
||||||
|
|
||||||
const readonlyRootfsEl = document.getElementById('duplicate-readonly-rootfs');
|
const readonlyRootfsEl = document.getElementById('duplicate-readonly-rootfs');
|
||||||
if (readonlyRootfsEl) readonlyRootfsEl.checked = config.HostConfig?.ReadonlyRootfs || false;
|
if (readonlyRootfsEl) {
|
||||||
|
readonlyRootfsEl.checked = Boolean(
|
||||||
|
config.HostConfig?.ReadonlyRootfs ?? config.Config?.ReadonlyRootfs
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
// Capabilities
|
// Capabilities (CapAdd). CapDrop is not a form field — surface as security notes via CapAdd only.
|
||||||
if (config.HostConfig?.CapAdd && Array.isArray(config.HostConfig.CapAdd)) {
|
if (config.HostConfig?.CapAdd && Array.isArray(config.HostConfig.CapAdd)) {
|
||||||
config.HostConfig.CapAdd.forEach(cap => {
|
config.HostConfig.CapAdd.forEach(cap => {
|
||||||
|
if (!cap || cap === 'null') return;
|
||||||
addDuplicateCapability();
|
addDuplicateCapability();
|
||||||
const container = document.getElementById('duplicate-capabilities-container');
|
const container = document.getElementById('duplicate-capabilities-container');
|
||||||
const lastInput = container?.lastElementChild?.querySelector('input');
|
const lastInput = container?.lastElementChild?.querySelector('input');
|
||||||
@@ -3547,6 +3753,7 @@ function populateDuplicateForm(config, opts = {}) {
|
|||||||
// Security options
|
// Security options
|
||||||
if (config.HostConfig?.SecurityOpt && Array.isArray(config.HostConfig.SecurityOpt)) {
|
if (config.HostConfig?.SecurityOpt && Array.isArray(config.HostConfig.SecurityOpt)) {
|
||||||
config.HostConfig.SecurityOpt.forEach(opt => {
|
config.HostConfig.SecurityOpt.forEach(opt => {
|
||||||
|
if (!opt) return;
|
||||||
addDuplicateSecurityOpt();
|
addDuplicateSecurityOpt();
|
||||||
const container = document.getElementById('duplicate-security-opts-container');
|
const container = document.getElementById('duplicate-security-opts-container');
|
||||||
const lastInput = container?.lastElementChild?.querySelector('input');
|
const lastInput = container?.lastElementChild?.querySelector('input');
|
||||||
@@ -3557,55 +3764,64 @@ function populateDuplicateForm(config, opts = {}) {
|
|||||||
// Runtime
|
// Runtime
|
||||||
const restartPolicyEl = document.getElementById('duplicate-restart-policy');
|
const restartPolicyEl = document.getElementById('duplicate-restart-policy');
|
||||||
if (restartPolicyEl && config.HostConfig?.RestartPolicy) {
|
if (restartPolicyEl && config.HostConfig?.RestartPolicy) {
|
||||||
restartPolicyEl.value = config.HostConfig.RestartPolicy.Name || 'no';
|
const policyName = config.HostConfig.RestartPolicy.Name || 'no';
|
||||||
|
// Docker may return "" for no restart
|
||||||
|
restartPolicyEl.value = policyName === '' ? 'no' : policyName;
|
||||||
const restartMaxRetriesEl = document.getElementById('duplicate-restart-max-retries');
|
const restartMaxRetriesEl = document.getElementById('duplicate-restart-max-retries');
|
||||||
if (restartMaxRetriesEl && config.HostConfig.RestartPolicy.MaximumRetryCount) {
|
if (restartMaxRetriesEl) {
|
||||||
restartMaxRetriesEl.value = config.HostConfig.RestartPolicy.MaximumRetryCount;
|
const max = config.HostConfig.RestartPolicy.MaximumRetryCount;
|
||||||
|
restartMaxRetriesEl.value =
|
||||||
|
max !== undefined && max !== null && max !== 0 ? max : '';
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const autoRemoveEl = document.getElementById('duplicate-auto-remove');
|
const autoRemoveEl = document.getElementById('duplicate-auto-remove');
|
||||||
if (autoRemoveEl) autoRemoveEl.checked = config.HostConfig?.AutoRemove || false;
|
if (autoRemoveEl) autoRemoveEl.checked = Boolean(config.HostConfig?.AutoRemove);
|
||||||
|
|
||||||
const ttyEl = document.getElementById('duplicate-tty');
|
const ttyEl = document.getElementById('duplicate-tty');
|
||||||
if (ttyEl) ttyEl.checked = config.Config?.Tty || false;
|
if (ttyEl) ttyEl.checked = Boolean(config.Config?.Tty);
|
||||||
|
|
||||||
const stdinOpenEl = document.getElementById('duplicate-stdin-open');
|
const stdinOpenEl = document.getElementById('duplicate-stdin-open');
|
||||||
if (stdinOpenEl) stdinOpenEl.checked = config.Config?.OpenStdin || false;
|
if (stdinOpenEl) stdinOpenEl.checked = Boolean(config.Config?.OpenStdin);
|
||||||
|
|
||||||
const detachEl = document.getElementById('duplicate-detach');
|
const detachEl = document.getElementById('duplicate-detach');
|
||||||
if (detachEl) detachEl.checked = config.Config?.AttachStdin === false;
|
// Detach = not attaching stdin interactively in our UI; default true for cloned services
|
||||||
|
if (detachEl) {
|
||||||
|
detachEl.checked =
|
||||||
|
config.Config?.OpenStdin === true ? false : true;
|
||||||
|
}
|
||||||
|
|
||||||
const initEl = document.getElementById('duplicate-init');
|
const initEl = document.getElementById('duplicate-init');
|
||||||
if (initEl) initEl.checked = config.HostConfig?.Init || false;
|
if (initEl) initEl.checked = Boolean(config.HostConfig?.Init);
|
||||||
|
|
||||||
// Health check
|
// Health check — strip CMD / CMD-SHELL prefix so re-deploy wraps correctly
|
||||||
if (config.Config?.Healthcheck) {
|
if (config.Config?.Healthcheck) {
|
||||||
|
const hc = config.Config.Healthcheck;
|
||||||
const healthCmdEl = document.getElementById('duplicate-health-cmd');
|
const healthCmdEl = document.getElementById('duplicate-health-cmd');
|
||||||
if (healthCmdEl && config.Config.Healthcheck.Test) {
|
if (healthCmdEl && hc.Test) {
|
||||||
const test = config.Config.Healthcheck.Test;
|
const test = hc.Test;
|
||||||
if (Array.isArray(test)) {
|
if (Array.isArray(test)) {
|
||||||
healthCmdEl.value = test.join(' ');
|
if (test[0] === 'NONE' || test[0] === 'none') {
|
||||||
|
healthCmdEl.value = '';
|
||||||
|
} else if (test[0] === 'CMD-SHELL' || test[0] === 'CMD') {
|
||||||
|
healthCmdEl.value = test.slice(1).join(' ');
|
||||||
} else {
|
} else {
|
||||||
healthCmdEl.value = test;
|
healthCmdEl.value = test.join(' ');
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
healthCmdEl.value = String(test);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
const nsToSec = (ns) =>
|
||||||
|
ns && Number(ns) > 0 ? Math.round(Number(ns) / 1e9) : '';
|
||||||
const healthIntervalEl = document.getElementById('duplicate-health-interval');
|
const healthIntervalEl = document.getElementById('duplicate-health-interval');
|
||||||
if (healthIntervalEl && config.Config.Healthcheck.Interval) {
|
if (healthIntervalEl) healthIntervalEl.value = nsToSec(hc.Interval);
|
||||||
healthIntervalEl.value = Math.round(config.Config.Healthcheck.Interval / 1000000000);
|
|
||||||
}
|
|
||||||
const healthTimeoutEl = document.getElementById('duplicate-health-timeout');
|
const healthTimeoutEl = document.getElementById('duplicate-health-timeout');
|
||||||
if (healthTimeoutEl && config.Config.Healthcheck.Timeout) {
|
if (healthTimeoutEl) healthTimeoutEl.value = nsToSec(hc.Timeout);
|
||||||
healthTimeoutEl.value = Math.round(config.Config.Healthcheck.Timeout / 1000000000);
|
|
||||||
}
|
|
||||||
const healthRetriesEl = document.getElementById('duplicate-health-retries');
|
const healthRetriesEl = document.getElementById('duplicate-health-retries');
|
||||||
if (healthRetriesEl && config.Config.Healthcheck.Retries) {
|
if (healthRetriesEl && hc.Retries != null) healthRetriesEl.value = hc.Retries;
|
||||||
healthRetriesEl.value = config.Config.Healthcheck.Retries;
|
|
||||||
}
|
|
||||||
const healthStartPeriodEl = document.getElementById('duplicate-health-start-period');
|
const healthStartPeriodEl = document.getElementById('duplicate-health-start-period');
|
||||||
if (healthStartPeriodEl && config.Config.Healthcheck.StartPeriod) {
|
if (healthStartPeriodEl) healthStartPeriodEl.value = nsToSec(hc.StartPeriod);
|
||||||
healthStartPeriodEl.value = Math.round(config.Config.Healthcheck.StartPeriod / 1000000000);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Logging
|
// Logging
|
||||||
|
|||||||
@@ -159,6 +159,19 @@ export function registerDeployHandlers(session) {
|
|||||||
}
|
}
|
||||||
if (args.user) containerConfig.User = args.user
|
if (args.user) containerConfig.User = args.user
|
||||||
|
|
||||||
|
// Extra groups (comma/space separated from Duplicate form, or array)
|
||||||
|
let groupAdd = null
|
||||||
|
if (args.group || args.groupAdd) {
|
||||||
|
const raw = args.groupAdd || args.group
|
||||||
|
const groups = Array.isArray(raw)
|
||||||
|
? raw.map((g) => String(g).trim()).filter(Boolean)
|
||||||
|
: String(raw)
|
||||||
|
.split(/[,\s]+/)
|
||||||
|
.map((g) => g.trim())
|
||||||
|
.filter(Boolean)
|
||||||
|
if (groups.length) groupAdd = groups
|
||||||
|
}
|
||||||
|
|
||||||
if (args.healthCmd) {
|
if (args.healthCmd) {
|
||||||
containerConfig.Healthcheck = {
|
containerConfig.Healthcheck = {
|
||||||
Test: args.healthCmd.startsWith('CMD-SHELL')
|
Test: args.healthCmd.startsWith('CMD-SHELL')
|
||||||
@@ -258,6 +271,7 @@ export function registerDeployHandlers(session) {
|
|||||||
}
|
}
|
||||||
if (args.autoRemove === true) hostConfig.AutoRemove = true
|
if (args.autoRemove === true) hostConfig.AutoRemove = true
|
||||||
if (args.privileged === true) hostConfig.Privileged = true
|
if (args.privileged === true) hostConfig.Privileged = true
|
||||||
|
if (groupAdd?.length) hostConfig.GroupAdd = groupAdd
|
||||||
if (args.capabilities && Array.isArray(args.capabilities)) {
|
if (args.capabilities && Array.isArray(args.capabilities)) {
|
||||||
hostConfig.CapAdd = args.capabilities
|
hostConfig.CapAdd = args.capabilities
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -94,23 +94,70 @@ function isValidPortMapping(portMapping) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Validates volume mount format
|
* Mount option suffix (Docker bind mode flags).
|
||||||
* @param {string} volume - Volume mount string (e.g., "/host:/container:ro")
|
* @param {string} part
|
||||||
|
*/
|
||||||
|
function isMountModePart(part) {
|
||||||
|
if (!part || typeof part !== 'string') return false;
|
||||||
|
// e.g. ro, rw, ro,Z, rw,z, shared, rprivate, …
|
||||||
|
return /^(?:ro|rw|z|Z|shared|rshared|slave|rslave|private|rprivate)(?:,(?:ro|rw|z|Z|shared|rshared|slave|rslave|private|rprivate))*$/i.test(
|
||||||
|
part.trim()
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Validates volume mount format.
|
||||||
|
* Accepts bind mounts and named volumes:
|
||||||
|
* /host/path:/container/path
|
||||||
|
* /host/path:/container/path:ro
|
||||||
|
* volume-name:/container/path
|
||||||
|
* volume-name:/container/path:rw
|
||||||
|
* /host:/container:ro,Z
|
||||||
|
* @param {string} volume - Volume mount string
|
||||||
* @returns {boolean} - True if valid
|
* @returns {boolean} - True if valid
|
||||||
*/
|
*/
|
||||||
function isValidVolumeMount(volume) {
|
function isValidVolumeMount(volume) {
|
||||||
if (!volume || typeof volume !== 'string') return false;
|
if (!volume || typeof volume !== 'string') return false;
|
||||||
if (!volume.includes(':')) return false;
|
if (!volume.includes(':')) return false;
|
||||||
|
// Block path traversal in any segment
|
||||||
|
if (volume.includes('..')) return false;
|
||||||
|
|
||||||
const parts = volume.split(':');
|
const raw = volume.trim();
|
||||||
if (parts.length < 2 || parts.length > 3) return false;
|
const parts = raw.split(':');
|
||||||
|
if (parts.length < 2) return false;
|
||||||
|
|
||||||
// Check for path traversal attempts
|
let source;
|
||||||
if (parts.some(part => part.includes('..'))) return false;
|
let dest;
|
||||||
|
if (parts.length >= 3 && isMountModePart(parts[parts.length - 1])) {
|
||||||
|
dest = parts[parts.length - 2];
|
||||||
|
source = parts.slice(0, -2).join(':');
|
||||||
|
} else if (parts.length === 2) {
|
||||||
|
source = parts[0];
|
||||||
|
dest = parts[1];
|
||||||
|
} else if (parts.length > 2) {
|
||||||
|
// e.g. Windows drive or unusual source with colons: join all but last as source
|
||||||
|
dest = parts[parts.length - 1];
|
||||||
|
source = parts.slice(0, -1).join(':');
|
||||||
|
} else {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
// Basic path validation
|
if (!source || !dest) return false;
|
||||||
const pathPattern = /^(\/[^\/]+)*\/?$/;
|
|
||||||
return parts.slice(0, 2).every(part => pathPattern.test(part) || part.startsWith('/'));
|
// Container path must be absolute
|
||||||
|
if (!dest.startsWith('/')) return false;
|
||||||
|
|
||||||
|
// Named volume (Docker volume name) OR host path (absolute/relative/~ / Windows drive)
|
||||||
|
const namedVol = /^[a-zA-Z0-9][a-zA-Z0-9_.-]*$/;
|
||||||
|
const isNamed = namedVol.test(source);
|
||||||
|
const isHostPath =
|
||||||
|
source.startsWith('/') ||
|
||||||
|
source.startsWith('./') ||
|
||||||
|
source.startsWith('~/') ||
|
||||||
|
source === '~' ||
|
||||||
|
/^[A-Za-z]:[\\/]/.test(source);
|
||||||
|
|
||||||
|
return isNamed || isHostPath;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -42,12 +42,19 @@ test('isValidPortMapping - invalid ports', (t) => {
|
|||||||
test('isValidVolumeMount - valid volumes', (t) => {
|
test('isValidVolumeMount - valid volumes', (t) => {
|
||||||
t.ok(validation.isValidVolumeMount('/host:/container'));
|
t.ok(validation.isValidVolumeMount('/host:/container'));
|
||||||
t.ok(validation.isValidVolumeMount('/host:/container:ro'));
|
t.ok(validation.isValidVolumeMount('/host:/container:ro'));
|
||||||
|
t.ok(validation.isValidVolumeMount('/host/path:/container/path:rw'));
|
||||||
|
t.ok(validation.isValidVolumeMount('/host:/container:ro,Z'));
|
||||||
|
// Named volumes must be accepted (deploy filters with this helper)
|
||||||
|
t.ok(validation.isValidVolumeMount('mydata:/data'));
|
||||||
|
t.ok(validation.isValidVolumeMount('my-volume:/var/lib/app:rw'));
|
||||||
|
t.ok(validation.isValidVolumeMount('app_data:/app/data:ro'));
|
||||||
});
|
});
|
||||||
|
|
||||||
test('isValidVolumeMount - invalid volumes', (t) => {
|
test('isValidVolumeMount - invalid volumes', (t) => {
|
||||||
t.not(validation.isValidVolumeMount(''));
|
t.not(validation.isValidVolumeMount(''));
|
||||||
t.not(validation.isValidVolumeMount('no-colon'));
|
t.not(validation.isValidVolumeMount('no-colon'));
|
||||||
t.not(validation.isValidVolumeMount('/host/../container')); // Path traversal
|
t.not(validation.isValidVolumeMount('/host/../container')); // Path traversal
|
||||||
|
t.not(validation.isValidVolumeMount('myvol:relative-dest')); // dest must be absolute
|
||||||
});
|
});
|
||||||
|
|
||||||
test('sanitizeEnvVarName - valid names', (t) => {
|
test('sanitizeEnvVarName - valid names', (t) => {
|
||||||
|
|||||||
Reference in New Issue
Block a user