From 9d328fdecd48b546df5fbe2bf369f55e77bb5dee Mon Sep 17 00:00:00 2001 From: Nick Szittai Date: Sat, 7 Mar 2026 13:32:13 -0500 Subject: [PATCH 01/12] feat: add ContainerInfo and StackInfo standard classes Introduce centralized ContainerInfo and StackInfo PHP classes in util.php that normalize container identity and stack metadata across the codebase. PHP changes: - ContainerInfo: PascalCase->camelCase normalization, fromDockerInspect(), fromUpdateResponse(), mergeUpdateStatus(), toArray(), toUpdateArray(), automatic isPinned derivation from @sha256: in image reference - StackInfo: eager identity (project, sanitizedName, path, composeSource, composeFilePath, isIndirect, overrideInfo) with lazy metadata getters (getName, getDescription, getEnvFilePath, getIconUrl, getWebUIUrl, getDefaultProfiles, getAutostart, getStartedAt, getProfiles), getDefinedServices(), buildComposeArgs(), pruneOrphanOverrideServices() Migrated PHP callers: - exec.php: getStackContainers, checkStackUpdates, checkAllStacksUpdates - exec_functions.php: buildComposeArgs() now thin deprecated wrapper - compose_util_functions.php: echoComposeCommand, echoComposeCommandMultiple - compose_list.php: all inline metadata reads replaced with StackInfo - dashboard_stacks.php: metadata reads replaced with StackInfo JS changes: - Added createContainerInfo(), createStackInfo(), mergeStackUpdateStatus() factory functions for consistent client-side normalization - Migrated buildStackInfoFromCache() to use createStackInfo() - Migrated checkStackUpdates() inline construction to createStackInfo() - Migrated updateParentStackFromContainers() to mergeStackUpdateStatus() - Replaced both PascalCase normalization blocks with createContainerInfo() - Updated mergeUpdateStatus() to use createContainerInfo() for name matching Tests: - ContainerInfoTest: 22 tests covering both factories, merge, serialization - StackInfoTest: 33 tests covering identity, compose resolution, metadata, caching, and buildComposeArgs - All 304 existing tests pass (0 failures, 7 pre-existing skips) --- source/compose.manager/php/compose_list.php | 99 +-- .../php/compose_manager_main.php | 234 +++--- .../php/compose_util_functions.php | 84 +- .../compose.manager/php/dashboard_stacks.php | 36 +- source/compose.manager/php/exec.php | 88 ++- source/compose.manager/php/exec_functions.php | 29 +- source/compose.manager/php/util.php | 725 ++++++++++++++++++ tests/unit/ContainerInfoTest.php | 341 ++++++++ tests/unit/OverrideInfoTest.php | 114 +++ tests/unit/StackInfoTest.php | 467 +++++++++++ tests/unit/UtilTest.php | 46 ++ 11 files changed, 1935 insertions(+), 328 deletions(-) create mode 100644 tests/unit/ContainerInfoTest.php create mode 100644 tests/unit/StackInfoTest.php diff --git a/source/compose.manager/php/compose_list.php b/source/compose.manager/php/compose_list.php index b8695dc..64f1e20 100755 --- a/source/compose.manager/php/compose_list.php +++ b/source/compose.manager/php/compose_list.php @@ -54,47 +54,20 @@ $stackCount++; - $projectName = $project; - if (is_file("$compose_root/$project/name")) { - $projectName = trim(file_get_contents("$compose_root/$project/name")); - } + // Resolve stack identity and metadata via StackInfo + $stackInfo = StackInfo::fromProject($compose_root, $project); + + $projectName = $stackInfo->getName(); $id = str_replace(".", "-", $project); $id = str_replace(" ", "", $id); - // Get the compose file path - $basePath = is_file("$compose_root/$project/indirect") - ? trim(file_get_contents("$compose_root/$project/indirect")) - : "$compose_root/$project"; - $composeFile = findComposeFile($basePath) ?: "$basePath/" . COMPOSE_FILE_NAMES[0]; - // Resolve override via centralized helper (prefer correctly-named indirect override) - $overridePath = OverrideInfo::fromStack($compose_root, $project)->getOverridePath(); - - // Use docker compose config --services to get accurate service count - // This properly parses YAML, handles overrides, extends, etc. - $definedServices = 0; - if (is_file($composeFile)) { - $files = "-f " . escapeshellarg($composeFile); - if (is_file($overridePath)) { - $files .= " -f " . escapeshellarg($overridePath); - } + // Get the compose file path and override via StackInfo + $composeFile = $stackInfo->composeFilePath ?? ($stackInfo->composeSource . '/' . COMPOSE_FILE_NAMES[0]); + $overridePath = $stackInfo->getOverridePath(); - // Get env file if specified - $envFile = ""; - if (is_file("$compose_root/$project/envpath")) { - $envPath = trim(file_get_contents("$compose_root/$project/envpath")); - if (is_file($envPath)) { - $envFile = "--env-file " . escapeshellarg($envPath); - } - } - - // Use docker compose config --services to list all service names - $cmd = "docker compose $files $envFile config --services 2>/dev/null"; - $output = shell_exec($cmd); - if ($output) { - $services = array_filter(explode("\n", trim($output))); - $definedServices = count($services); - } - } + // Use StackInfo's getDefinedServices for accurate service count + $definedServicesList = $stackInfo->getDefinedServices(); + $definedServices = count($definedServicesList); // Get running container info from $containersByProject // Use directory basename (sanitized) as project key — this matches the -p flag in echoComposeCommand @@ -137,48 +110,23 @@ $isrestarting = $restartingCount > 0; $isup = $actualContainerCount > 0; - if (is_file("$compose_root/$project/description")) { - $description = @file_get_contents("$compose_root/$project/description"); - $description = str_replace("\r", "", $description); - // Escape HTML first to prevent XSS, then convert newlines to
- $description = htmlspecialchars($description, ENT_QUOTES, 'UTF-8'); + // Read metadata via StackInfo lazy getters + $descriptionRaw = $stackInfo->getDescription(); + if ($descriptionRaw) { + $descriptionRaw = str_replace("\r", "", $descriptionRaw); + $description = htmlspecialchars($descriptionRaw, ENT_QUOTES, 'UTF-8'); $description = str_replace("\n", "
", $description); } else { $description = ""; } - $autostart = ''; - if (is_file("$compose_root/$project/autostart")) { - $autostarttext = @file_get_contents("$compose_root/$project/autostart"); - if (strpos($autostarttext, 'true') !== false) { - $autostart = 'checked'; - } - } - - // Check for custom project icon (URL-based only via icon_url file) - $projectIcon = ''; - if (is_file("$compose_root/$project/icon_url")) { - $iconUrl = trim(@file_get_contents("$compose_root/$project/icon_url")); - if (filter_var($iconUrl, FILTER_VALIDATE_URL) && (strpos($iconUrl, 'http://') === 0 || strpos($iconUrl, 'https://') === 0)) { - $projectIcon = $iconUrl; - } - } + $autostart = $stackInfo->getAutostart() ? 'checked' : ''; - // Check for stack-level WebUI URL - $webuiUrl = ''; - if (is_file("$compose_root/$project/webui_url")) { - $webuiUrlTmp = trim(@file_get_contents("$compose_root/$project/webui_url")); - if (filter_var($webuiUrlTmp, FILTER_VALIDATE_URL) && (strpos($webuiUrlTmp, 'http://') === 0 || strpos($webuiUrlTmp, 'https://') === 0)) { - $webuiUrl = $webuiUrlTmp; - } - } + $projectIcon = $stackInfo->getIconUrl(); + $webuiUrl = $stackInfo->getWebUIUrl(); - $profiles = array(); - if (is_file("$compose_root/$project/profiles")) { - $profilestext = @file_get_contents("$compose_root/$project/profiles"); - $profiles = json_decode($profilestext, false); - } - $profilesJson = htmlspecialchars(json_encode($profiles ? $profiles : []), ENT_QUOTES, 'UTF-8'); + $profiles = $stackInfo->getProfiles(); + $profilesJson = htmlspecialchars(json_encode($profiles ?: []), ENT_QUOTES, 'UTF-8'); // Determine status text and class for badge $statusText = "Stopped"; @@ -231,11 +179,8 @@ $statusLabel = "partial ($runningCount/$containerCount)"; } - // Get stack started_at timestamp from file for uptime calculation - $stackStartedAt = ''; - if (is_file("$compose_root/$project/started_at")) { - $stackStartedAt = trim(file_get_contents("$compose_root/$project/started_at")); - } + // Get stack started_at timestamp via StackInfo + $stackStartedAt = $stackInfo->getStartedAt(); // Calculate uptime display from started_at timestamp $stackUptime = ''; diff --git a/source/compose.manager/php/compose_manager_main.php b/source/compose.manager/php/compose_manager_main.php index e7689c5..e7a495a 100755 --- a/source/compose.manager/php/compose_manager_main.php +++ b/source/compose.manager/php/compose_manager_main.php @@ -158,6 +158,113 @@ var hideComposeFromDocker = ; var composeCliVersion = ; + // ═══════════════════════════════════════════════════════════════════ + // Standard factory functions for container and stack identity objects + // ═══════════════════════════════════════════════════════════════════ + + /** + * Create a normalized container info object from any raw source. + * Handles PascalCase→camelCase, resolves name from multiple field aliases, + * and derives hasUpdate/isPinned when not explicitly set. + * + * @param {Object} raw - Raw container object (from server, cache, or update response) + * @returns {Object} Normalized container info + */ + function createContainerInfo(raw) { + if (!raw) return null; + var name = raw.name || raw.Name || raw.container || raw.Service || raw.service || ''; + var service = raw.service || raw.Service || name; + var updateStatus = raw.updateStatus || raw.UpdateStatus || ''; + var hasUpdate = (raw.hasUpdate !== undefined) ? !!raw.hasUpdate : (updateStatus === 'update-available'); + + return { + name: name, + service: service, + image: raw.image || raw.Image || '', + state: raw.state || raw.State || '', + isRunning: (raw.state || raw.State || '') === 'running', + hasUpdate: hasUpdate, + updateStatus: updateStatus, + localSha: raw.localSha || raw.LocalSha || '', + remoteSha: raw.remoteSha || raw.RemoteSha || '', + isPinned: (raw.isPinned !== undefined) ? !!raw.isPinned : false, + pinnedDigest: raw.pinnedDigest || raw.PinnedDigest || '', + icon: raw.icon || raw.Icon || '', + shell: raw.shell || raw.Shell || '', + webUI: raw.webUI || raw.WebUI || '', + ports: raw.ports || raw.Ports || '', + networks: raw.networks || raw.Networks || '', + volumes: raw.volumes || raw.Volumes || '', + id: raw.id || raw.Id || '', + created: raw.created || raw.Created || '', + startedAt: raw.startedAt || raw.StartedAt || '' + }; + } + + /** + * Create a normalized stack info object. + * + * @param {string} project - The project/stack folder name + * @param {Array} containers - Array of raw container objects (will be normalized) + * @param {Object} [opts] - Optional overrides (totalServices, lastChecked, etc.) + * @returns {Object} Normalized stack info + */ + function createStackInfo(project, containers, opts) { + opts = opts || {}; + var normalized = (containers || []).map(createContainerInfo).filter(Boolean); + var isRunning = normalized.some(function(c) { return c.isRunning; }); + var hasUpdate = normalized.some(function(c) { return c.hasUpdate; }); + + return { + projectName: opts.projectName || project, + containers: normalized, + isRunning: (opts.isRunning !== undefined) ? opts.isRunning : isRunning, + hasUpdate: (opts.hasUpdate !== undefined) ? opts.hasUpdate : hasUpdate, + totalServices: opts.totalServices || normalized.length, + lastChecked: opts.lastChecked || null + }; + } + + /** + * Merge update status info from a previous stackInfo into a new one. + * Matches containers by name and copies update fields. + * + * @param {Object} stackInfo - The target stack info (mutated in place) + * @param {Object} prevStatus - Previously saved stack update status + * @returns {Object} The mutated stackInfo + */ + function mergeStackUpdateStatus(stackInfo, prevStatus) { + if (!prevStatus) return stackInfo; + + // Copy stack-level fields + ['lastChecked', 'updateAvailable', 'checking', 'checked'].forEach(function(k) { + if (typeof prevStatus[k] !== 'undefined') stackInfo[k] = prevStatus[k]; + }); + + // Merge container-level update data + if (prevStatus.containers && stackInfo.containers) { + stackInfo.containers.forEach(function(c) { + var cName = c.name; + prevStatus.containers.forEach(function(pc) { + var prev = (typeof pc.name === 'string') ? pc : createContainerInfo(pc); + if (cName === prev.name) { + if (prev.hasUpdate && !c.hasUpdate) c.hasUpdate = prev.hasUpdate; + if (prev.updateStatus && !c.updateStatus) c.updateStatus = prev.updateStatus; + if (prev.localSha && !c.localSha) c.localSha = prev.localSha; + if (prev.remoteSha && !c.remoteSha) c.remoteSha = prev.remoteSha; + if (prev.isPinned !== undefined) c.isPinned = prev.isPinned; + } + }); + }); + // Recompute stack-level hasUpdate from merged containers + stackInfo.hasUpdate = stackInfo.containers.some(function(c) { return c.hasUpdate; }); + } + + return stackInfo; + } + + // ═══════════════════════════════════════════════════════════════════ + // Timers for async operations (plugin-specific to avoid collision with Unraid's global timers) var composeTimers = {}; @@ -1090,14 +1197,9 @@ function checkStackUpdates(stackName) { try { var response = JSON.parse(data); if (response.result === 'success') { - var stackInfo = { + var stackInfo = createStackInfo(stackName, response.updates, { projectName: response.projectName, - hasUpdate: false, - containers: response.updates, - isRunning: true // Stack was just updated, so it's running - }; - response.updates.forEach(function(u) { - if (u.hasUpdate) stackInfo.hasUpdate = true; + isRunning: true }); stackUpdateStatus[stackName] = stackInfo; updateStackUpdateUI(stackName, stackInfo); @@ -2011,30 +2113,14 @@ function refreshStackRow(stackId, project) { var response = JSON.parse(data); if (response.result === 'success') { var containers = response.containers || []; - // Normalize PascalCase keys - containers.forEach(function(c) { - if (c.UpdateStatus !== undefined && c.updateStatus === undefined) c.updateStatus = c.UpdateStatus; - if (c.LocalSha !== undefined && c.localSha === undefined) c.localSha = c.LocalSha; - if (c.RemoteSha !== undefined && c.remoteSha === undefined) c.remoteSha = c.RemoteSha; - if (c.hasUpdate === undefined && c.updateStatus) { - c.hasUpdate = (c.updateStatus === 'update-available'); - } + // Normalize all containers via factory function (PascalCase→camelCase) + containers = containers.map(function(c) { + var info = createContainerInfo(c); + // Preserve original keys for renderContainerDetails compatibility + return Object.assign({}, c, info); }); // Merge saved update status so we don't lose checked info - if (stackUpdateStatus[project] && stackUpdateStatus[project].containers) { - containers.forEach(function(container) { - var cName = container.Name || container.Service; - stackUpdateStatus[project].containers.forEach(function(update) { - if (cName === (update.container || update.name || update.service)) { - if (update.hasUpdate !== undefined) container.hasUpdate = update.hasUpdate; - if (update.updateStatus || update.status) container.updateStatus = update.status || update.updateStatus; - if (update.localSha) container.localSha = update.localSha; - if (update.remoteSha) container.remoteSha = update.remoteSha; - if (update.isPinned !== undefined) container.isPinned = update.isPinned; - } - }); - }); - } + mergeUpdateStatus(containers, project); // Update cache with fresh data stackContainersCache[stackId] = containers; stackDefinedServicesCache[stackId] = response.definedServices || containers.length; @@ -2310,17 +2396,20 @@ function executeStopAllStacks(stacks) { } // Helper to merge update status into containers array + // Uses createContainerInfo for consistent name resolution function mergeUpdateStatus(containers, project) { if (!containers || !stackUpdateStatus[project] || !stackUpdateStatus[project].containers) { return containers; } containers.forEach(function(container) { + var cInfo = createContainerInfo(container); stackUpdateStatus[project].containers.forEach(function(update) { - if (container.Name === update.container) { - container.hasUpdate = update.hasUpdate; - container.updateStatus = update.status; - container.localSha = update.localSha || ''; - container.remoteSha = update.remoteSha || ''; + var uInfo = createContainerInfo(update); + if (cInfo.name === uInfo.name) { + container.hasUpdate = uInfo.hasUpdate; + container.updateStatus = uInfo.updateStatus; + container.localSha = uInfo.localSha; + container.remoteSha = uInfo.remoteSha; } }); }); @@ -3619,31 +3708,14 @@ function loadStackContainerDetails(stackId, project) { if (response.result === 'success') { var containers = response.containers; - // Normalize PascalCase keys from server to camelCase used by client - containers.forEach(function(c) { - if (c.UpdateStatus !== undefined && c.updateStatus === undefined) c.updateStatus = c.UpdateStatus; - if (c.LocalSha !== undefined && c.localSha === undefined) c.localSha = c.LocalSha; - if (c.RemoteSha !== undefined && c.remoteSha === undefined) c.remoteSha = c.RemoteSha; - // Derive hasUpdate from updateStatus if not set - if (c.hasUpdate === undefined && c.updateStatus) { - c.hasUpdate = (c.updateStatus === 'update-available'); - } + // Normalize all containers via factory function (PascalCase→camelCase) + containers = containers.map(function(c) { + var info = createContainerInfo(c); + return Object.assign({}, c, info); }); // Merge update status from stackUpdateStatus if available - if (stackUpdateStatus[project] && stackUpdateStatus[project].containers) { - containers.forEach(function(container) { - var cName = container.Name || container.Service; - stackUpdateStatus[project].containers.forEach(function(update) { - if (cName === update.container) { - container.hasUpdate = update.hasUpdate; - container.updateStatus = update.status || update.updateStatus; - container.localSha = update.localSha || ''; - container.remoteSha = update.remoteSha || ''; - } - }); - }); - } + mergeUpdateStatus(containers, project); stackContainersCache[stackId] = containers; stackDefinedServicesCache[stackId] = response.definedServices || containers.length; @@ -3937,29 +4009,7 @@ function renderContainerDetails(stackId, containers, project) { function buildStackInfoFromCache(stackId, project) { var containers = stackContainersCache[stackId] || []; var definedServices = stackDefinedServicesCache[stackId] || containers.length; - var stackInfo = { - projectName: project, - containers: [], - isRunning: false, - hasUpdate: false, - totalServices: definedServices // Track total defined services for accurate status - }; - containers.forEach(function(c) { - var name = c.Name || c.Service || ''; - var ct = { - container: name, - hasUpdate: !!c.hasUpdate, - updateStatus: c.updateStatus || '', - localSha: c.localSha || '', - remoteSha: c.remoteSha || '', - isPinned: !!c.isPinned, - isRunning: (c.State === 'running') - }; - if (ct.hasUpdate) stackInfo.hasUpdate = true; - if (ct.isRunning) stackInfo.isRunning = true; - stackInfo.containers.push(ct); - }); - return stackInfo; + return createStackInfo(project, containers, { totalServices: definedServices }); } // Update only the parent stack row using cached container details @@ -3979,31 +4029,7 @@ function updateParentStackFromContainers(stackId, project) { // Update the update-column using existing helper (expects stackInfo) var stackInfo = buildStackInfoFromCache(stackId, project); // Merge any previously saved update status so we don't lose 'checked' state - var prevStatus = stackUpdateStatus[project] || {}; - ['lastChecked', 'updateAvailable', 'checking', 'updateStatus', 'checked'].forEach(function(k) { - if (typeof prevStatus[k] !== 'undefined') stackInfo[k] = prevStatus[k]; - }); - - // Also merge container-level update data from previous status - if (prevStatus.containers && stackInfo.containers) { - stackInfo.containers.forEach(function(c) { - var cName = c.name || c.service; - prevStatus.containers.forEach(function(pc) { - var pcName = pc.name || pc.service || pc.container; - if (cName === pcName) { - if (pc.hasUpdate !== undefined && !c.hasUpdate) c.hasUpdate = pc.hasUpdate; - if (pc.updateStatus && !c.updateStatus) c.updateStatus = pc.updateStatus; - if (pc.localSha && !c.localSha) c.localSha = pc.localSha; - if (pc.remoteSha && !c.remoteSha) c.remoteSha = pc.remoteSha; - if (pc.isPinned !== undefined) c.isPinned = pc.isPinned; - } - }); - }); - // Recompute hasUpdate from merged containers - stackInfo.hasUpdate = stackInfo.containers.some(function(c) { - return c.hasUpdate; - }); - } + mergeStackUpdateStatus(stackInfo, stackUpdateStatus[project] || {}); // Cache the merged update status and apply UI update stackUpdateStatus[project] = stackInfo; diff --git a/source/compose.manager/php/compose_util_functions.php b/source/compose.manager/php/compose_util_functions.php index 0ca766f..f727895 100644 --- a/source/compose.manager/php/compose_util_functions.php +++ b/source/compose.manager/php/compose_util_functions.php @@ -69,27 +69,26 @@ function echoComposeCommand($action, $recreate = false) $composeCommand = array($plugin_root . "scripts/compose.sh"); $project = basename($path); + + // Resolve stack identity via StackInfo + $stackInfo = StackInfo::fromProject($compose_root, $project); + $composeCommand[] = "-c" . $action; - $composeCommand[] = "-p" . sanitizeStr($project); + $composeCommand[] = "-p" . $stackInfo->sanitizedName; - if (isIndirect($path)) { - $indirectPath = getPath($path); - $found = findComposeFile($indirectPath); - $composeFile = $found ?: "$indirectPath/" . COMPOSE_FILE_NAMES[0]; - $composeCommand[] = "-f$composeFile"; - } else { - $found = findComposeFile($path); - $composeFile = $found ?: "$path/" . COMPOSE_FILE_NAMES[0]; - $composeCommand[] = "-f$composeFile"; + $composeFile = $stackInfo->composeFilePath ?? ($stackInfo->composeSource . '/' . COMPOSE_FILE_NAMES[0]); + $composeCommand[] = "-f$composeFile"; + + // Prune orphaned services from override before compose up + if ($action === 'up') { + $stackInfo->pruneOrphanOverrideServices(); } - // Resolve override using centralized helper - $overridePath = OverrideInfo::fromStack($compose_root, $project)->getOverridePath(); - $composeCommand[] = "-f" . $overridePath; + $composeCommand[] = "-f" . $stackInfo->getOverridePath(); - if (is_file("$path/envpath")) { - $envPath = "-e" . trim(file_get_contents("$path/envpath")); - $composeCommand[] = $envPath; + $envFilePath = $stackInfo->getEnvFilePath(); + if ($envFilePath !== null) { + $composeCommand[] = "-e" . $envFilePath; } // Support multiple profiles (comma-separated) @@ -173,50 +172,35 @@ function echoComposeCommandMultiple($action, $paths) foreach ($paths as $path) { $composeCommand = array($plugin_root . "scripts/compose.sh"); - $projectName = basename($path); $project = basename($path); - if (is_file("$path/name")) { - $projectName = trim(file_get_contents("$path/name")); - } - $stackNames[] = $projectName; + + // Resolve stack identity via StackInfo + $stackInfo = StackInfo::fromProject($compose_root, $project); + $stackNames[] = $stackInfo->getName(); $composeCommand[] = "-c" . $action; - $composeCommand[] = "-p" . sanitizeStr($project); - - if (isIndirect($path)) { - // For indirect paths, resolve the target path and then locate the compose file - $indirectPath = getPath($path); - $found = findComposeFile($indirectPath); - $composeFile = $found ?: "$indirectPath/" . COMPOSE_FILE_NAMES[0]; - $composeCommand[] = "-f$composeFile"; - } else { - $found = findComposeFile($path); - $composeFile = $found ?: "$path/" . COMPOSE_FILE_NAMES[0]; - $composeCommand[] = "-f$composeFile"; + $composeCommand[] = "-p" . $stackInfo->sanitizedName; + + $composeFile = $stackInfo->composeFilePath ?? ($stackInfo->composeSource . '/' . COMPOSE_FILE_NAMES[0]); + $composeCommand[] = "-f$composeFile"; + + // Prune orphaned services from override before compose up + if ($action === 'up') { + $stackInfo->pruneOrphanOverrideServices(); } - // Resolve override using centralized helper - $overridePath = OverrideInfo::fromStack($compose_root, $projectName)->getOverridePath(); - $composeCommand[] = "-f" . $overridePath; + $composeCommand[] = "-f" . $stackInfo->getOverridePath(); // Add env-file if available for this stack - if (is_file("$path/envpath")) { - $envPath = "-e" . trim(file_get_contents("$path/envpath")); - $composeCommand[] = $envPath; + $envFilePath = $stackInfo->getEnvFilePath(); + if ($envFilePath !== null) { + $composeCommand[] = "-e" . $envFilePath; } // Add default profiles for multi-stack operations - if (is_file("$path/default_profile")) { - $defaultProfiles = trim(file_get_contents("$path/default_profile")); - if ($defaultProfiles) { - // Support comma-separated profiles - $profileList = array_map('trim', explode(',', $defaultProfiles)); - foreach ($profileList as $p) { - if ($p) { - $composeCommand[] = "-g $p"; - } - } - } + $defaultProfiles = $stackInfo->getDefaultProfiles(); + foreach ($defaultProfiles as $p) { + $composeCommand[] = "-g $p"; } // Pass stack path for timestamp saving diff --git a/source/compose.manager/php/dashboard_stacks.php b/source/compose.manager/php/dashboard_stacks.php index 727f7bb..edbab31 100644 --- a/source/compose.manager/php/dashboard_stacks.php +++ b/source/compose.manager/php/dashboard_stacks.php @@ -68,23 +68,19 @@ $summary['total']++; - $projectName = $project; - if (is_file("$compose_root/$project/name")) { - $projectName = trim(file_get_contents("$compose_root/$project/name")); - } + // Resolve stack identity and metadata via StackInfo + $stackInfo = StackInfo::fromProject($compose_root, $project); + $projectName = $stackInfo->getName(); // Key containers by the sanitized directory name — this matches the -p flag in echoComposeCommand - $sanitizedName = sanitizeStr($project); + $sanitizedName = $stackInfo->sanitizedName; $projectContainers = $containersByProject[$sanitizedName] ?? []; $runningCount = 0; $totalContainers = count($projectContainers); - $startedAt = ''; - // Read stack started_at timestamp from file - if (is_file("$compose_root/$project/started_at")) { - $startedAt = trim(file_get_contents("$compose_root/$project/started_at")); - } + // Read stack started_at timestamp via StackInfo + $startedAt = $stackInfo->getStartedAt(); foreach ($projectContainers as $ct) { if (($ct['State'] ?? '') === 'running') { @@ -107,23 +103,9 @@ $summary['stopped']++; } - // Check for custom project icon (URL-based via icon_url file) - $icon = ''; - if (is_file("$compose_root/$project/icon_url")) { - $iconUrl = trim(@file_get_contents("$compose_root/$project/icon_url")); - if (filter_var($iconUrl, FILTER_VALIDATE_URL) && (strpos($iconUrl, 'http://') === 0 || strpos($iconUrl, 'https://') === 0)) { - $icon = $iconUrl; - } - } - - // Check for stack webui URL - $webui = ''; - if (is_file("$compose_root/$project/webui_url")) { - $webuiUrl = trim(@file_get_contents("$compose_root/$project/webui_url")); - if (!empty($webuiUrl)) { - $webui = $webuiUrl; - } - } + // Get custom project icon and webui URL via StackInfo + $icon = $stackInfo->getIconUrl(); + $webui = $stackInfo->getWebUIUrl(); // Check update status from central update-status.json file (set by "Check for Updates" button) $updateStatus = 'unknown'; diff --git a/source/compose.manager/php/exec.php b/source/compose.manager/php/exec.php index b4875ed..81a5840 100644 --- a/source/compose.manager/php/exec.php +++ b/source/compose.manager/php/exec.php @@ -524,8 +524,9 @@ function getPostScript(): string break; } - // Build compose CLI arguments (project name, file flags, env-file flag) - $args = buildComposeArgs($script); + // Resolve stack identity and compose CLI arguments via StackInfo + $stackInfo = StackInfo::fromProject($compose_root, $script); + $args = $stackInfo->buildComposeArgs(); $projectName = $args['projectName']; // Get container details in JSON format @@ -556,40 +557,34 @@ function getPostScript(): string $containers = []; // Load update status once before the loop (static data, doesn't change per-container) $updateStatusFile = UNRAID_UPDATE_STATUS_FILE; - $updateStatus = []; + $updateStatusData = []; if (is_file($updateStatusFile)) { - $updateStatus = json_decode(file_get_contents($updateStatusFile), true) ?: []; + $updateStatusData = json_decode(file_get_contents($updateStatusFile), true) ?: []; } - // Get defined service count (like compose_list.php does) - // This ensures the client shows the correct total even if not all services are running - $definedServices = 0; - $configCmd = "docker compose {$args['files']} {$args['envFile']} config --services 2>/dev/null"; - $configOutput = shell_exec($configCmd); - if ($configOutput) { - $services = array_filter(explode("\n", trim($configOutput))); - $definedServices = count($services); - } + // Get defined service count via StackInfo + $definedServicesList = $stackInfo->getDefinedServices(); + $definedServices = count($definedServicesList); if ($output) { // docker compose ps --format json outputs one JSON object per line $lines = explode("\n", trim($output)); foreach ($lines as $line) { if (!empty($line)) { - $container = json_decode($line, true); - if ($container) { + $rawContainer = json_decode($line, true); + if ($rawContainer) { // Get additional details using docker inspect - $containerName = $container['Name'] ?? ''; - if ($containerName) { - $inspectCmd = "docker inspect " . escapeshellarg($containerName) . " --format '{{json .}}' 2>/dev/null"; + $ctName = $rawContainer['Name'] ?? ''; + if ($ctName) { + $inspectCmd = "docker inspect " . escapeshellarg($ctName) . " --format '{{json .}}' 2>/dev/null"; $inspectOutput = shell_exec($inspectCmd); if ($inspectOutput) { $inspect = json_decode($inspectOutput, true); if ($inspect) { // Extract useful info from inspect - $container['Image'] = $inspect['Config']['Image'] ?? ''; - $container['Created'] = $inspect['Created'] ?? ''; - $container['StartedAt'] = $inspect['State']['StartedAt'] ?? ''; + $rawContainer['Image'] = $inspect['Config']['Image'] ?? ''; + $rawContainer['Created'] = $inspect['Created'] ?? ''; + $rawContainer['StartedAt'] = $inspect['State']['StartedAt'] ?? ''; // Get ports (raw bindings - IP resolved below after network detection) $ports = []; @@ -616,7 +611,7 @@ function getPostScript(): string $volumes[] = ['source' => $src, 'destination' => $dst, 'type' => $type]; } } - $container['Volumes'] = $volumes; + $rawContainer['Volumes'] = $volumes; // Get network info (include driver for IP resolution) $networks = []; @@ -628,13 +623,13 @@ function getPostScript(): string 'driver' => $networkDrivers[$netName] ?? '' ]; } - $container['Networks'] = $networks; + $rawContainer['Networks'] = $networks; // Get labels for WebUI $labels = $inspect['Config']['Labels'] ?? []; $webUITemplate = $labels[$docker_label_webui] ?? ''; - $container['Icon'] = $labels[$docker_label_icon] ?? ''; - $container['Shell'] = $labels[$docker_label_shell] ?? '/bin/bash'; + $rawContainer['Icon'] = $labels[$docker_label_icon] ?? ''; + $rawContainer['Shell'] = $labels[$docker_label_shell] ?? '/bin/bash'; // Resolve WebUI URL server-side (matching Unraid's DockerClient logic) // Determine the NetworkMode @@ -644,7 +639,7 @@ function getPostScript(): string [$networkMode] = explode(':', $networkMode); } - $container['WebUI'] = ''; + $rawContainer['WebUI'] = ''; // Resolve IP — Unraid logic: // host mode → host IP // macvlan/ipvlan → container IP @@ -670,7 +665,7 @@ function getPostScript(): string $lanIp = $resolvedIP ?: $hostIP; $portStrings[] = "$lanIp:{$p['hostPort']}->{$p['containerPort']}"; } - $container['Ports'] = $portStrings; + $rawContainer['Ports'] = $portStrings; if (!empty($webUITemplate) && $hostIP) { $resolvedURL = preg_replace('%\[IP\]%i', $resolvedIP, $webUITemplate); @@ -692,11 +687,11 @@ function getPostScript(): string } $resolvedURL = preg_replace('%\[PORT:\d+\]%i', $configPort, $resolvedURL); } - $container['WebUI'] = $resolvedURL; + $rawContainer['WebUI'] = $resolvedURL; } // Get update status from saved status file (read once before loop) - $imageName = $container['Image']; + $imageName = $rawContainer['Image']; // Ensure image has a tag for lookup if (strpos($imageName, ':') === false) { $imageName .= ':latest'; @@ -704,22 +699,22 @@ function getPostScript(): string // Also try without registry prefix $imageNameShort = preg_replace('/^[^\/]+\//', '', $imageName); - $container['UpdateStatus'] = 'unknown'; - $container['LocalSha'] = ''; - $container['RemoteSha'] = ''; + $rawContainer['updateStatus'] = 'unknown'; + $rawContainer['localSha'] = ''; + $rawContainer['remoteSha'] = ''; // Check both full name and short name $checkNames = [$imageName, $imageNameShort]; - foreach ($updateStatus as $key => $status) { + foreach ($updateStatusData as $key => $status) { foreach ($checkNames as $checkName) { if ($key === $checkName || strpos($key, $checkName) !== false || strpos($checkName, $key) !== false) { // Strip sha256: prefix before truncating to 12 hex chars $localRaw = $status['local'] ?? ''; $remoteRaw = $status['remote'] ?? ''; - $container['LocalSha'] = substr(str_replace('sha256:', '', $localRaw), 0, 8); - $container['RemoteSha'] = substr(str_replace('sha256:', '', $remoteRaw), 0, 8); + $rawContainer['localSha'] = substr(str_replace('sha256:', '', $localRaw), 0, 8); + $rawContainer['remoteSha'] = substr(str_replace('sha256:', '', $remoteRaw), 0, 8); if (!empty($status['local']) && !empty($status['remote'])) { - $container['UpdateStatus'] = ($status['local'] === $status['remote']) ? 'up-to-date' : 'update-available'; + $rawContainer['updateStatus'] = ($status['local'] === $status['remote']) ? 'up-to-date' : 'update-available'; } break 2; } @@ -728,7 +723,8 @@ function getPostScript(): string } } } - $containers[] = $container; + // Normalize through ContainerInfo for consistent camelCase output + $containers[] = ContainerInfo::fromDockerInspect($rawContainer)->toArray(); } } } @@ -767,8 +763,9 @@ function getPostScript(): string // Include Docker manager classes for update checking require_once("/usr/local/emhttp/plugins/dynamix.docker.manager/include/DockerClient.php"); - // Build compose CLI arguments (project name, file flags, env-file flag) - $args = buildComposeArgs($script); + // Resolve stack identity and compose CLI arguments via StackInfo + $stackInfo = StackInfo::fromProject($compose_root, $script); + $args = $stackInfo->buildComposeArgs(); $projectName = $args['projectName']; // Get container images @@ -850,14 +847,14 @@ function getPostScript(): string $hasUpdate = ($updateStatus === false); $statusText = ($updateStatus === null) ? 'unknown' : ($updateStatus ? 'up-to-date' : 'update-available'); - $updateResults[] = [ + $updateResults[] = ContainerInfo::fromUpdateResponse([ 'container' => $containerName, 'image' => $image, 'hasUpdate' => $hasUpdate, 'status' => $statusText, 'localSha' => $localSha, 'remoteSha' => $remoteSha - ]; + ])->toUpdateArray(); } } } @@ -937,8 +934,9 @@ function getPostScript(): string } } - // Build compose CLI arguments (includes env-file, override, etc.) - $args = buildComposeArgs($stackName); + // Resolve stack identity and compose CLI arguments via StackInfo + $stackInfoItem = StackInfo::fromProject($compose_root, $stackName); + $args = $stackInfoItem->buildComposeArgs(); $projectName = $args['projectName']; // Include --all so we can detect stacks that have stopped containers @@ -1021,14 +1019,14 @@ function getPostScript(): string if ($hasUpdate) $hasStackUpdate = true; - $stackUpdates[] = [ + $stackUpdates[] = ContainerInfo::fromUpdateResponse([ 'container' => $containerName, 'image' => $image, 'hasUpdate' => $hasUpdate, 'status' => ($updateStatus === null) ? 'unknown' : ($updateStatus ? 'up-to-date' : 'update-available'), 'localSha' => $localSha, 'remoteSha' => $remoteSha - ]; + ])->toUpdateArray(); } } } diff --git a/source/compose.manager/php/exec_functions.php b/source/compose.manager/php/exec_functions.php index 670d017..93315b9 100644 --- a/source/compose.manager/php/exec_functions.php +++ b/source/compose.manager/php/exec_functions.php @@ -62,9 +62,10 @@ function sanitizeFolderName($stackName) { /** * Build the common compose CLI arguments for a stack. * + * @deprecated Use StackInfo::fromProject($composeRoot, $stack)->buildComposeArgs() instead. + * * Resolves the project name, compose/override files, and env-file flag - * from the stack directory. Used by getStackContainers, checkStackUpdates, - * and checkAllStacksUpdates to avoid duplicating this logic. + * from the stack directory. Thin wrapper around StackInfo for backward compatibility. * * @param string $stack Stack directory name (basename under $compose_root) * @return array{projectName: string, files: string, envFile: string} @@ -72,28 +73,6 @@ function sanitizeFolderName($stackName) { function buildComposeArgs(string $stack): array { global $compose_root; - // Project name is always the sanitized directory basename, matching the -p flag in echoComposeCommand - // The name file is the display name only and must not affect Docker project identity - $projectName = sanitizeStr($stack); - - $basePath = getPath("$compose_root/$stack"); - $composeFile = findComposeFile($basePath) ?: "$basePath/compose.yaml"; - - $files = "-f " . escapeshellarg($composeFile); - - // Resolve override selection: prefer correctly-named indirect override if present, - // otherwise use project override (migrating legacy project override when applicable). require_once("/usr/local/emhttp/plugins/compose.manager/php/util.php"); - $overridePath = OverrideInfo::fromStack($compose_root, $stack)->getOverridePath(); - $files .= " -f " . escapeshellarg($overridePath); - - $envFile = ""; - if (is_file("$compose_root/$stack/envpath")) { - $envPath = trim(file_get_contents("$compose_root/$stack/envpath")); - if (is_file($envPath)) { - $envFile = "--env-file " . escapeshellarg($envPath); - } - } - - return ['projectName' => $projectName, 'files' => $files, 'envFile' => $envFile]; + return StackInfo::fromProject($compose_root, $stack)->buildComposeArgs(); } diff --git a/source/compose.manager/php/util.php b/source/compose.manager/php/util.php index 3ace7d1..1bd2aec 100644 --- a/source/compose.manager/php/util.php +++ b/source/compose.manager/php/util.php @@ -113,6 +113,132 @@ function hasComposeFile($dir) return findComposeFile($dir) !== false; } + + +function pruneOverrideContentServices(string $overrideContent, array $validServices): array +{ + $validMap = []; + foreach ($validServices as $service) { + if (is_string($service) && $service !== '') { + $validMap[$service] = true; + } + } + + if (empty($validMap) || $overrideContent === '') { + return ['content' => $overrideContent, 'removed' => [], 'changed' => false]; + } + + $lineEnding = (strpos($overrideContent, "\r\n") !== false) ? "\r\n" : "\n"; + $normalized = str_replace(["\r\n", "\r"], "\n", $overrideContent); + $hadTrailingNewline = substr($normalized, -1) === "\n"; + $lines = explode("\n", $normalized); + + $servicesStart = null; + $servicesEnd = count($lines); + + for ($i = 0; $i < count($lines); $i++) { + $line = $lines[$i]; + if ($servicesStart === null) { + if (preg_match('/^services\s*:/', $line)) { + $servicesStart = $i; + } + continue; + } + + // Detect the end of the services section by finding the next top-level YAML key: + // - a non-indented, non-comment line that looks like "key:" (optionally followed by a comment) + // - excluding the "services:" key itself + if (preg_match('/^[^\s#][^:]*:\s*(?:#.*)?$/', $line) && !preg_match('/^services\s*:/', $line)) { + $servicesEnd = $i; + break; + } + } + + if ($servicesStart === null || $servicesStart + 1 >= $servicesEnd) { + return ['content' => $overrideContent, 'removed' => [], 'changed' => false]; + } + + $serviceRanges = []; + $currentServiceName = null; + $currentServiceStart = null; + + for ($i = $servicesStart + 1; $i < $servicesEnd; $i++) { + $line = $lines[$i]; + // Match a single service definition line under `services:` with: + // - exactly two spaces of indentation + // - an optional quote character (single or double) around the service name (captured in group 1) + // - a service name that cannot start with or contain quotes, colon, hash, or whitespace + // - a trailing colon after the service name, optional whitespace, and an optional inline `# comment` + if (preg_match('/^ {2}(["\']?)([^"\':#\s][^"\':#]*)\1\s*:\s*(?:#.*)?$/', $line, $matches)) { + if ($currentServiceName !== null) { + $serviceRanges[] = [ + 'name' => $currentServiceName, + 'start' => $currentServiceStart, + 'end' => $i - 1 + ]; + } + $currentServiceName = trim($matches[2]); + $currentServiceStart = $i; + } + } + + if ($currentServiceName !== null) { + $serviceRanges[] = [ + 'name' => $currentServiceName, + 'start' => $currentServiceStart, + 'end' => $servicesEnd - 1 + ]; + } + + if (empty($serviceRanges)) { + return ['content' => $overrideContent, 'removed' => [], 'changed' => false]; + } + + $removedRanges = []; + $removedServices = []; + foreach ($serviceRanges as $range) { + if (!isset($validMap[$range['name']])) { + $removedRanges[] = $range; + $removedServices[] = $range['name']; + } + } + + if (empty($removedRanges)) { + return ['content' => $overrideContent, 'removed' => [], 'changed' => false]; + } + + if (count($removedRanges) === count($serviceRanges)) { + $newLines = array_slice($lines, 0, $servicesStart); + $newLines[] = 'services: {}'; + $newLines = array_merge($newLines, array_slice($lines, $servicesEnd)); + } else { + $removeByLine = []; + foreach ($removedRanges as $range) { + for ($lineIndex = $range['start']; $lineIndex <= $range['end']; $lineIndex++) { + $removeByLine[$lineIndex] = true; + } + } + + $newLines = []; + foreach ($lines as $lineIndex => $line) { + if (!isset($removeByLine[$lineIndex])) { + $newLines[] = $line; + } + } + } + + $newContent = implode("\n", $newLines); + if ($hadTrailingNewline && substr($newContent, -1) !== "\n") { + $newContent .= "\n"; + } + + if ($lineEnding === "\r\n") { + $newContent = str_replace("\n", "\r\n", $newContent); + } + + return ['content' => $newContent, 'removed' => $removedServices, 'changed' => true]; +} + class OverrideInfo { /** @@ -135,6 +261,10 @@ class OverrideInfo * @var bool True if indirect contains legacy-named override but not correctly-named one */ public bool $mismatchIndirectLegacy = false; + /** + * @var string|null Resolved path to the main compose file + */ + public ?string $composeFilePath = null; /** * @var string Compose root directory @@ -165,6 +295,8 @@ public static function fromStack(string $composeRoot, string $stack): OverrideIn /** * Resolve override information for a given stack and populate this instance. + * + * * @param string $stack * @return void */ @@ -175,6 +307,7 @@ private function resolve(string $stack): void $composeSource = $indirectPath == "" || $indirectPath === null ? $projectPath : $indirectPath; $foundCompose = findComposeFile($composeSource); + $this->composeFilePath = $foundCompose !== false ? $foundCompose : null; $composeBaseName = $foundCompose !== false ? basename($foundCompose) : COMPOSE_FILE_NAMES[0]; $this->computedName = preg_replace('/(\.[^.]+)$/', '.override$1', $composeBaseName); @@ -220,6 +353,87 @@ public function getOverridePath(): ?string return $this->useIndirect ? $this->indirectOverride : $this->projectOverride; } + /** + * Get the list of services defined in the main compose file. + * + * Uses `docker compose config --services` to accurately resolve + * services including extends, anchors, etc. + * + * @param string|null $envFilePath Optional path to env file + * @return string[] List of service names + */ + public function getDefinedServices(?string $envFilePath = null): array + { + if ($this->composeFilePath === null || !is_file($this->composeFilePath)) { + return []; + } + + $cmd = "docker compose -f " . escapeshellarg($this->composeFilePath); + if ($envFilePath !== null && $envFilePath !== '' && is_file($envFilePath)) { + $cmd .= " --env-file " . escapeshellarg($envFilePath); + } + $cmd .= " config --services 2>/dev/null"; + + $output = shell_exec($cmd); + if (!is_string($output) || trim($output) === '') { + return []; + } + + return array_values(array_filter(array_map('trim', explode("\n", trim($output))), function ($service) { + return $service !== ''; + })); + } + + /** + * Prune orphaned services from the override file. + * + * Compares the services in the override file against the services + * defined in the main compose file. Any override service not present + * in the main file is removed. The override file is rewritten in place. + * + * @param string|null $envFilePath Optional path to env file for service resolution + * @return array{changed: bool, removed: string[]} + */ + public function pruneOrphanServices(?string $envFilePath = null): array + { + $overridePath = $this->getOverridePath(); + if ($overridePath === null || $overridePath === '' || !is_file($overridePath)) { + return ['changed' => false, 'removed' => []]; + } + if ($this->composeFilePath === null || !is_file($this->composeFilePath)) { + return ['changed' => false, 'removed' => []]; + } + + $mainServices = $this->getDefinedServices($envFilePath); + if (empty($mainServices)) { + return ['changed' => false, 'removed' => []]; + } + + $overrideContent = file_get_contents($overridePath); + if ($overrideContent === false || $overrideContent === '') { + return ['changed' => false, 'removed' => []]; + } + + $result = pruneOverrideContentServices($overrideContent, $mainServices); + if (!($result['changed'] ?? false)) { + return ['changed' => false, 'removed' => []]; + } + + file_put_contents($overridePath, $result['content']); + + $removedServices = $result['removed'] ?? []; + if (!empty($removedServices)) { + clientDebug( + "[override] Pruned orphaned override services from " . basename($overridePath) . ": " . implode(', ', $removedServices), + null, + 'daemon', + 'info' + ); + } + + return ['changed' => true, 'removed' => $removedServices]; + } + /** * Get the project path for a stack * @param string $stack @@ -231,7 +445,518 @@ private function getProjectPath(string $stack): string } } +/** + * Normalized container information. + * + * Provides a single canonical shape for container data regardless of source + * (docker inspect response, update-check response, or cached status). + * Eliminates PascalCase/camelCase drift and container-name aliasing issues. + */ +class ContainerInfo +{ + /** @var string Canonical container name (from Name/Service/container) */ + public string $name = ''; + /** @var string Compose service name */ + public string $service = ''; + /** @var string Full image reference (e.g. library/nginx:latest) */ + public string $image = ''; + /** @var string Container state (running/exited/paused/restarting) */ + public string $state = ''; + /** @var bool Whether the container is currently running */ + public bool $isRunning = false; + /** @var bool Whether an image update is available */ + public bool $hasUpdate = false; + /** @var string Update status text (unknown/up-to-date/update-available) */ + public string $updateStatus = 'unknown'; + /** @var string Local image SHA (truncated) */ + public string $localSha = ''; + /** @var string Remote image SHA (truncated) */ + public string $remoteSha = ''; + /** @var bool Whether the image is pinned to a specific digest */ + public bool $isPinned = false; + /** @var string|null Pinned digest if isPinned is true */ + public ?string $pinnedDigest = null; + /** @var string Icon URL from Docker label */ + public string $icon = ''; + /** @var string Shell path from Docker label */ + public string $shell = '/bin/bash'; + /** @var string Resolved WebUI URL */ + public string $webUI = ''; + /** @var array Port mappings (e.g. ["192.168.1.1:8080->80/tcp"]) */ + public array $ports = []; + /** @var array Network info [{name, ip, driver}] */ + public array $networks = []; + /** @var array Volume mounts [{source, destination, type}] */ + public array $volumes = []; + /** @var string ISO datetime when container was created */ + public string $created = ''; + /** @var string ISO datetime when container was started */ + public string $startedAt = ''; + + private function __construct() {} + + /** + * Create a ContainerInfo from a fully-assembled docker inspect + compose ps result. + * + * This is the shape built by the getStackContainers action in exec.php. + * Accepts either PascalCase or camelCase keys and normalizes them. + * + * @param array $raw Associative array with container data + * @return ContainerInfo + */ + public static function fromDockerInspect(array $raw): self + { + $info = new self(); + $info->name = $raw['Name'] ?? $raw['name'] ?? ''; + $info->service = $raw['Service'] ?? $raw['service'] ?? ''; + $info->image = $raw['Image'] ?? $raw['image'] ?? ''; + $info->state = strtolower($raw['State'] ?? $raw['state'] ?? ''); + $info->isRunning = ($info->state === 'running'); + $info->icon = $raw['Icon'] ?? $raw['icon'] ?? ''; + $info->shell = $raw['Shell'] ?? $raw['shell'] ?? '/bin/bash'; + $info->webUI = $raw['WebUI'] ?? $raw['webUI'] ?? $raw['webui'] ?? ''; + $info->ports = $raw['Ports'] ?? $raw['ports'] ?? []; + $info->networks = $raw['Networks'] ?? $raw['networks'] ?? []; + $info->volumes = $raw['Volumes'] ?? $raw['volumes'] ?? []; + $info->created = $raw['Created'] ?? $raw['created'] ?? ''; + $info->startedAt = $raw['StartedAt'] ?? $raw['startedAt'] ?? ''; + + // Normalize update status (accept PascalCase or camelCase) + $info->updateStatus = $raw['updateStatus'] ?? $raw['UpdateStatus'] ?? $raw['status'] ?? 'unknown'; + $info->localSha = $raw['localSha'] ?? $raw['LocalSha'] ?? ''; + $info->remoteSha = $raw['remoteSha'] ?? $raw['RemoteSha'] ?? ''; + $info->hasUpdate = $raw['hasUpdate'] + ?? ($info->updateStatus === 'update-available'); + + // Derive pinned status from @sha256: in image reference + $info->derivePinned(); + + return $info; + } + + /** + * Create a ContainerInfo from an update-check response element. + * + * This is the per-container shape returned by checkStackUpdates (keys: + * container, image, hasUpdate, status, localSha, remoteSha). + * + * @param array $raw Associative array from update check + * @return ContainerInfo + */ + public static function fromUpdateResponse(array $raw): self + { + $info = new self(); + $info->name = $raw['container'] ?? $raw['name'] ?? $raw['Name'] ?? ''; + $info->service = $raw['service'] ?? $raw['Service'] ?? ''; + $info->image = $raw['image'] ?? $raw['Image'] ?? ''; + $info->hasUpdate = $raw['hasUpdate'] ?? false; + $info->updateStatus = $raw['status'] ?? $raw['updateStatus'] ?? 'unknown'; + $info->localSha = $raw['localSha'] ?? ''; + $info->remoteSha = $raw['remoteSha'] ?? ''; + + $info->derivePinned(); + + return $info; + } + + /** + * Merge update-check fields from another ContainerInfo without + * overwriting identity or runtime state fields. + * + * @param ContainerInfo $update The newer update data to merge in + */ + public function mergeUpdateStatus(ContainerInfo $update): void + { + if ($update->hasUpdate) { + $this->hasUpdate = true; + } + if ($update->updateStatus !== '' && $update->updateStatus !== 'unknown') { + $this->updateStatus = $update->updateStatus; + } + if ($update->localSha !== '') { + $this->localSha = $update->localSha; + } + if ($update->remoteSha !== '') { + $this->remoteSha = $update->remoteSha; + } + if ($update->isPinned) { + $this->isPinned = $update->isPinned; + $this->pinnedDigest = $update->pinnedDigest; + } + } + + /** + * Serialize to a consistently camelCase associative array for JSON responses. + * + * @return array + */ + public function toArray(): array + { + return [ + 'name' => $this->name, + 'service' => $this->service, + 'image' => $this->image, + 'state' => $this->state, + 'isRunning' => $this->isRunning, + 'hasUpdate' => $this->hasUpdate, + 'updateStatus' => $this->updateStatus, + 'localSha' => $this->localSha, + 'remoteSha' => $this->remoteSha, + 'isPinned' => $this->isPinned, + 'pinnedDigest' => $this->pinnedDigest, + 'icon' => $this->icon, + 'shell' => $this->shell, + 'webUI' => $this->webUI, + 'ports' => $this->ports, + 'networks' => $this->networks, + 'volumes' => $this->volumes, + 'created' => $this->created, + 'startedAt' => $this->startedAt, + ]; + } + + /** + * Serialize only the update-related fields (for update-check responses). + * + * @return array + */ + public function toUpdateArray(): array + { + return [ + 'name' => $this->name, + 'image' => $this->image, + 'hasUpdate' => $this->hasUpdate, + 'updateStatus' => $this->updateStatus, + 'localSha' => $this->localSha, + 'remoteSha' => $this->remoteSha, + 'isPinned' => $this->isPinned, + ]; + } + + /** + * Derive isPinned and pinnedDigest from the image reference. + */ + private function derivePinned(): void + { + if ($this->image !== '' && strpos($this->image, '@sha256:') !== false) { + $this->isPinned = true; + $parts = explode('@sha256:', $this->image, 2); + $this->pinnedDigest = $parts[1] ?? null; + } + } +} + +/** + * Centralized stack identity and metadata. + * + * Resolves and caches the canonical identity for a compose stack: directory + * name (project), sanitized Docker project name, compose file path, indirect + * target, override info, and provides lazy access to metadata files (name, + * description, envpath, icon_url, webui_url, etc.). + * + * Construction is intentionally eager for identity fields and override + * resolution (preserving current side-effect behavior). Metadata files are + * loaded lazily on first access. + */ +class StackInfo +{ + /** @var string Directory basename (canonical filesystem identity) */ + public string $project; + /** @var string sanitizeStr($project) — used as Docker -p project name */ + public string $sanitizedName; + /** @var string Full path to the stack directory ($composeRoot/$project) */ + public string $path; + /** @var string Resolved compose source directory (indirect target or $path) */ + public string $composeSource; + /** @var string|null Full path to the main compose file, or null if none */ + public ?string $composeFilePath; + /** @var bool Whether this stack uses an indirect compose path */ + public bool $isIndirect; + /** @var OverrideInfo Resolved override info (eager) */ + public OverrideInfo $overrideInfo; + /** @var string Compose root directory */ + private string $composeRoot; + + /** @var array Lazy-loaded metadata cache (field => value|null, unset = not loaded) */ + private array $metadataCache = []; + + /** + * @param string $composeRoot Compose root directory + * @param string $project Directory basename of the stack + */ + private function __construct(string $composeRoot, string $project) + { + $this->composeRoot = rtrim($composeRoot, '/'); + $this->project = $project; + $this->path = $this->composeRoot . '/' . $project; + $this->sanitizedName = sanitizeStr($project); + + // Resolve indirect + $this->isIndirect = isIndirect($this->path); + $this->composeSource = $this->isIndirect + ? (trim(file_get_contents($this->path . '/indirect')) ?: $this->path) + : $this->path; + + // Resolve compose file + $found = findComposeFile($this->composeSource); + $this->composeFilePath = ($found !== false) ? $found : null; + + // Eagerly resolve override info (preserves side effects: auto-create, migration) + $this->overrideInfo = OverrideInfo::fromStack($this->composeRoot, $project); + } + + /** + * Create a StackInfo for a project directory under the compose root. + * + * @param string $composeRoot The compose projects root directory + * @param string $project Directory basename of the stack + * @return StackInfo + */ + public static function fromProject(string $composeRoot, string $project): self + { + return new self($composeRoot, $project); + } + + // --------------------------------------------------------------- + // Lazy metadata getters — read from file on first access, cache + // --------------------------------------------------------------- + + /** + * Get the display name (from `name` file, falls back to $project). + * @return string + */ + public function getName(): string + { + return $this->readMetadata('name') ?? $this->project; + } + + /** + * Get the stack description. + * @return string + */ + public function getDescription(): string + { + return $this->readMetadata('description') ?? ''; + } + + /** + * Get the custom env file path (from `envpath` file). + * @return string|null + */ + public function getEnvFilePath(): ?string + { + $val = $this->readMetadata('envpath'); + return ($val !== null && $val !== '') ? $val : null; + } + + /** + * Get the icon URL (from `icon_url` file), validated. + * @return string|null + */ + public function getIconUrl(): ?string + { + $url = $this->readMetadata('icon_url'); + if ($url !== null && filter_var($url, FILTER_VALIDATE_URL) + && (strpos($url, 'http://') === 0 || strpos($url, 'https://') === 0)) { + return $url; + } + return null; + } + + /** + * Get the stack-level WebUI URL (from `webui_url` file), validated. + * @return string|null + */ + public function getWebUIUrl(): ?string + { + $url = $this->readMetadata('webui_url'); + if ($url !== null && filter_var($url, FILTER_VALIDATE_URL) + && (strpos($url, 'http://') === 0 || strpos($url, 'https://') === 0)) { + return $url; + } + return null; + } + + /** + * Get default profiles (from `default_profile` file), comma-split. + * @return string[] + */ + public function getDefaultProfiles(): array + { + $raw = $this->readMetadata('default_profile'); + if ($raw === null || $raw === '') { + return []; + } + return array_values(array_filter(array_map('trim', explode(',', $raw)))); + } + + /** + * Get autostart flag (from `autostart` file). + * @return bool + */ + public function getAutostart(): bool + { + $val = $this->readMetadata('autostart'); + return ($val !== null && strpos($val, 'true') !== false); + } + + /** + * Get the started_at timestamp (from `started_at` file). + * @return string|null + */ + public function getStartedAt(): ?string + { + $val = $this->readMetadata('started_at'); + return ($val !== null && $val !== '') ? $val : null; + } + + /** + * Get available profiles (from `profiles` JSON file). + * @return array + */ + public function getProfiles(): array + { + $raw = $this->readMetadata('profiles'); + if ($raw === null || $raw === '') { + return []; + } + $decoded = json_decode($raw, true); + return is_array($decoded) ? $decoded : []; + } + + // --------------------------------------------------------------- + // Derived helpers + // --------------------------------------------------------------- + + /** + * Get the OverrideInfo for this stack. + * @return OverrideInfo + */ + public function getOverrideInfo(): OverrideInfo + { + return $this->overrideInfo; + } + + /** + * Get the effective override file path (delegates to OverrideInfo). + * @return string|null + */ + public function getOverridePath(): ?string + { + return $this->overrideInfo->getOverridePath(); + } + + /** + * Get the list of services defined in the main compose file. + * + * Uses `docker compose config --services` to accurately resolve + * services including extends, anchors, etc. + * + * @return string[] List of service names + */ + public function getDefinedServices(): array + { + if ($this->composeFilePath === null || !is_file($this->composeFilePath)) { + return []; + } + + $cmd = "docker compose -f " . escapeshellarg($this->composeFilePath); + + // Include override file if available + $overridePath = $this->getOverridePath(); + if ($overridePath !== null && is_file($overridePath)) { + $cmd .= " -f " . escapeshellarg($overridePath); + } + + $envFilePath = $this->getEnvFilePath(); + if ($envFilePath !== null && is_file($envFilePath)) { + $cmd .= " --env-file " . escapeshellarg($envFilePath); + } + $cmd .= " config --services 2>/dev/null"; + + $output = shell_exec($cmd); + if (!is_string($output) || trim($output) === '') { + return []; + } + + return array_values(array_filter(array_map('trim', explode("\n", trim($output))), function ($service) { + return $service !== ''; + })); + } + + /** + * Prune orphaned services from the override file. + * + * Convenience method: resolves defined services, then delegates to + * OverrideInfo::pruneOrphanServices(). + * + * @return array{changed: bool, removed: string[]} + */ + public function pruneOrphanOverrideServices(): array + { + return $this->overrideInfo->pruneOrphanServices($this->getEnvFilePath()); + } + + /** + * Build the common compose CLI arguments for this stack. + * + * Returns the project name, file flags, and env-file flag suitable + * for passing to `docker compose`. + * + * @return array{projectName: string, files: string, envFile: string} + */ + public function buildComposeArgs(): array + { + $composeFile = $this->composeFilePath ?? ($this->composeSource . '/compose.yaml'); + + $files = "-f " . escapeshellarg($composeFile); + + $overridePath = $this->getOverridePath(); + if ($overridePath !== null) { + $files .= " -f " . escapeshellarg($overridePath); + } + + $envFile = ""; + $envPath = $this->getEnvFilePath(); + if ($envPath !== null && is_file($envPath)) { + $envFile = "--env-file " . escapeshellarg($envPath); + } + + return [ + 'projectName' => $this->sanitizedName, + 'files' => $files, + 'envFile' => $envFile, + ]; + } + + // --------------------------------------------------------------- + // Internal helpers + // --------------------------------------------------------------- + + /** + * Read a metadata file from the stack directory (lazy, cached). + * + * @param string $filename Metadata filename (e.g. 'name', 'envpath') + * @return string|null Trimmed file contents, or null if file doesn't exist + */ + private function readMetadata(string $filename): ?string + { + if (array_key_exists($filename, $this->metadataCache)) { + return $this->metadataCache[$filename]; + } + + $filePath = $this->path . '/' . $filename; + if (is_file($filePath)) { + $content = @file_get_contents($filePath); + $this->metadataCache[$filename] = ($content !== false) ? trim($content) : null; + } else { + $this->metadataCache[$filename] = null; + } + + return $this->metadataCache[$filename]; + } +} diff --git a/tests/unit/ContainerInfoTest.php b/tests/unit/ContainerInfoTest.php new file mode 100644 index 0000000..18db111 --- /dev/null +++ b/tests/unit/ContainerInfoTest.php @@ -0,0 +1,341 @@ + 'my-container', + 'Service' => 'web', + 'Image' => 'nginx:latest', + 'State' => 'running', + 'Icon' => 'https://example.com/icon.png', + 'Shell' => '/bin/sh', + 'WebUI' => 'http://localhost:8080', + 'Ports' => ['8080->80/tcp'], + 'Networks' => [['name' => 'bridge']], + 'Volumes' => [['source' => '/data']], + 'Created' => '2024-01-01T00:00:00Z', + 'StartedAt' => '2024-01-01T00:01:00Z', + ]; + + $info = \ContainerInfo::fromDockerInspect($raw); + + $this->assertSame('my-container', $info->name); + $this->assertSame('web', $info->service); + $this->assertSame('nginx:latest', $info->image); + $this->assertSame('running', $info->state); + $this->assertTrue($info->isRunning); + $this->assertSame('https://example.com/icon.png', $info->icon); + $this->assertSame('/bin/sh', $info->shell); + $this->assertSame('http://localhost:8080', $info->webUI); + $this->assertSame(['8080->80/tcp'], $info->ports); + $this->assertSame([['name' => 'bridge']], $info->networks); + $this->assertSame([['source' => '/data']], $info->volumes); + $this->assertSame('2024-01-01T00:00:00Z', $info->created); + $this->assertSame('2024-01-01T00:01:00Z', $info->startedAt); + } + + public function testFromDockerInspectCamelCaseKeys(): void + { + $raw = [ + 'name' => 'my-container', + 'service' => 'api', + 'image' => 'node:18', + 'state' => 'exited', + ]; + + $info = \ContainerInfo::fromDockerInspect($raw); + + $this->assertSame('my-container', $info->name); + $this->assertSame('api', $info->service); + $this->assertSame('node:18', $info->image); + $this->assertSame('exited', $info->state); + $this->assertFalse($info->isRunning); + } + + public function testFromDockerInspectUpdateStatusNormalization(): void + { + // PascalCase update fields + $raw = [ + 'Name' => 'test', + 'UpdateStatus' => 'update-available', + 'LocalSha' => 'abc123', + 'RemoteSha' => 'def456', + ]; + + $info = \ContainerInfo::fromDockerInspect($raw); + + $this->assertSame('update-available', $info->updateStatus); + $this->assertTrue($info->hasUpdate); + $this->assertSame('abc123', $info->localSha); + $this->assertSame('def456', $info->remoteSha); + } + + public function testFromDockerInspectDerivesPinnedFromSha256(): void + { + $raw = [ + 'Name' => 'pinned-container', + 'Image' => 'nginx@sha256:abcdef1234567890abcdef1234567890', + ]; + + $info = \ContainerInfo::fromDockerInspect($raw); + + $this->assertTrue($info->isPinned); + $this->assertSame('abcdef1234567890abcdef1234567890', $info->pinnedDigest); + } + + public function testFromDockerInspectNotPinnedWithoutSha256(): void + { + $raw = [ + 'Name' => 'normal-container', + 'Image' => 'nginx:latest', + ]; + + $info = \ContainerInfo::fromDockerInspect($raw); + + $this->assertFalse($info->isPinned); + $this->assertNull($info->pinnedDigest); + } + + public function testFromDockerInspectDefaultShell(): void + { + $raw = ['Name' => 'test']; + $info = \ContainerInfo::fromDockerInspect($raw); + $this->assertSame('/bin/bash', $info->shell); + } + + public function testFromDockerInspectDefaultUpdateStatus(): void + { + $raw = ['Name' => 'test']; + $info = \ContainerInfo::fromDockerInspect($raw); + $this->assertSame('unknown', $info->updateStatus); + $this->assertFalse($info->hasUpdate); + } + + public function testFromDockerInspectHasUpdateExplicitlyFalse(): void + { + $raw = [ + 'Name' => 'test', + 'hasUpdate' => false, + 'updateStatus' => 'update-available', + ]; + + $info = \ContainerInfo::fromDockerInspect($raw); + + // Explicit hasUpdate should win over derived + $this->assertFalse($info->hasUpdate); + } + + // =========================================== + // fromUpdateResponse Tests + // =========================================== + + public function testFromUpdateResponseBasicFields(): void + { + $raw = [ + 'container' => 'web-container', + 'image' => 'nginx:alpine', + 'hasUpdate' => true, + 'status' => 'update-available', + 'localSha' => 'aaa111', + 'remoteSha' => 'bbb222', + ]; + + $info = \ContainerInfo::fromUpdateResponse($raw); + + $this->assertSame('web-container', $info->name); + $this->assertSame('nginx:alpine', $info->image); + $this->assertTrue($info->hasUpdate); + $this->assertSame('update-available', $info->updateStatus); + $this->assertSame('aaa111', $info->localSha); + $this->assertSame('bbb222', $info->remoteSha); + } + + public function testFromUpdateResponseFallsBackToStatusField(): void + { + $raw = [ + 'container' => 'test', + 'status' => 'up-to-date', + ]; + + $info = \ContainerInfo::fromUpdateResponse($raw); + + $this->assertSame('up-to-date', $info->updateStatus); + $this->assertFalse($info->hasUpdate); + } + + // =========================================== + // mergeUpdateStatus Tests + // =========================================== + + public function testMergeUpdateStatus(): void + { + $base = \ContainerInfo::fromDockerInspect([ + 'Name' => 'test', + 'Image' => 'nginx:latest', + ]); + + $update = \ContainerInfo::fromUpdateResponse([ + 'container' => 'test', + 'hasUpdate' => true, + 'status' => 'update-available', + 'localSha' => 'aaa', + 'remoteSha' => 'bbb', + ]); + + $base->mergeUpdateStatus($update); + + $this->assertTrue($base->hasUpdate); + $this->assertSame('update-available', $base->updateStatus); + $this->assertSame('aaa', $base->localSha); + $this->assertSame('bbb', $base->remoteSha); + } + + public function testMergeUpdateStatusDoesNotOverwriteWithEmpty(): void + { + $base = \ContainerInfo::fromDockerInspect([ + 'Name' => 'test', + 'updateStatus' => 'update-available', + 'localSha' => 'existing-sha', + ]); + + $update = \ContainerInfo::fromUpdateResponse([ + 'container' => 'test', + 'status' => 'unknown', + 'localSha' => '', + ]); + + $base->mergeUpdateStatus($update); + + // Should keep existing values since update has unknown/empty + $this->assertSame('update-available', $base->updateStatus); + $this->assertSame('existing-sha', $base->localSha); + } + + // =========================================== + // toArray Tests + // =========================================== + + public function testToArrayProducesConsistentCamelCase(): void + { + $raw = [ + 'Name' => 'test-container', + 'Service' => 'web', + 'Image' => 'nginx:latest', + 'State' => 'running', + 'UpdateStatus' => 'up-to-date', + 'LocalSha' => 'sha1', + 'RemoteSha' => 'sha2', + ]; + + $info = \ContainerInfo::fromDockerInspect($raw); + $arr = $info->toArray(); + + // All keys should be camelCase + $this->assertArrayHasKey('name', $arr); + $this->assertArrayHasKey('service', $arr); + $this->assertArrayHasKey('image', $arr); + $this->assertArrayHasKey('state', $arr); + $this->assertArrayHasKey('isRunning', $arr); + $this->assertArrayHasKey('hasUpdate', $arr); + $this->assertArrayHasKey('updateStatus', $arr); + $this->assertArrayHasKey('localSha', $arr); + $this->assertArrayHasKey('remoteSha', $arr); + $this->assertArrayHasKey('isPinned', $arr); + $this->assertArrayHasKey('pinnedDigest', $arr); + $this->assertArrayHasKey('icon', $arr); + $this->assertArrayHasKey('shell', $arr); + $this->assertArrayHasKey('webUI', $arr); + $this->assertArrayHasKey('ports', $arr); + $this->assertArrayHasKey('networks', $arr); + $this->assertArrayHasKey('volumes', $arr); + $this->assertArrayHasKey('created', $arr); + $this->assertArrayHasKey('startedAt', $arr); + + // No PascalCase keys + $this->assertArrayNotHasKey('Name', $arr); + $this->assertArrayNotHasKey('Service', $arr); + $this->assertArrayNotHasKey('UpdateStatus', $arr); + } + + public function testToArrayValues(): void + { + $info = \ContainerInfo::fromDockerInspect([ + 'Name' => 'web', + 'State' => 'running', + 'Image' => 'nginx:latest', + ]); + + $arr = $info->toArray(); + + $this->assertSame('web', $arr['name']); + $this->assertSame('running', $arr['state']); + $this->assertTrue($arr['isRunning']); + $this->assertSame('nginx:latest', $arr['image']); + } + + // =========================================== + // toUpdateArray Tests + // =========================================== + + public function testToUpdateArrayContainsOnlyUpdateFields(): void + { + $info = \ContainerInfo::fromDockerInspect([ + 'Name' => 'test', + 'Image' => 'nginx:latest', + 'State' => 'running', + 'Icon' => 'icon.png', + ]); + + $arr = $info->toUpdateArray(); + + $expected = ['name', 'image', 'hasUpdate', 'updateStatus', 'localSha', 'remoteSha', 'isPinned']; + $this->assertSame($expected, array_keys($arr)); + + // Should NOT contain non-update fields + $this->assertArrayNotHasKey('state', $arr); + $this->assertArrayNotHasKey('icon', $arr); + $this->assertArrayNotHasKey('ports', $arr); + } + + // =========================================== + // Edge Cases + // =========================================== + + public function testFromDockerInspectEmptyArray(): void + { + $info = \ContainerInfo::fromDockerInspect([]); + + $this->assertSame('', $info->name); + $this->assertSame('', $info->service); + $this->assertSame('', $info->image); + $this->assertSame('', $info->state); + $this->assertFalse($info->isRunning); + $this->assertFalse($info->hasUpdate); + $this->assertSame('unknown', $info->updateStatus); + } + + public function testStateLowercaseNormalization(): void + { + $info = \ContainerInfo::fromDockerInspect([ + 'Name' => 'test', + 'State' => 'Running', + ]); + + $this->assertSame('running', $info->state); + $this->assertTrue($info->isRunning); + } +} diff --git a/tests/unit/OverrideInfoTest.php b/tests/unit/OverrideInfoTest.php index 7cdc50f..3c24d48 100644 --- a/tests/unit/OverrideInfoTest.php +++ b/tests/unit/OverrideInfoTest.php @@ -137,4 +137,118 @@ public function testLegacyProjectOverrideStaleRemoval(): void $this->assertFileDoesNotExist($legacyPath); $this->assertFileExists($legacyPath . '.bak'); } + + // =========================================== + // composeFilePath Tests + // =========================================== + + public function testComposeFilePathIsNullWhenNoComposeFile(): void + { + $stack = 'no-compose'; + $stackDir = $this->tempRoot . '/' . $stack; + mkdir($stackDir); + $info = \OverrideInfo::fromStack($this->tempRoot, $stack); + $this->assertNull($info->composeFilePath); + } + + public function testComposeFilePathIsSetWhenComposeFileExists(): void + { + $stack = 'has-compose'; + $stackDir = $this->tempRoot . '/' . $stack; + mkdir($stackDir); + file_put_contents("$stackDir/compose.yaml", "services:\n web:\n image: nginx\n"); + $info = \OverrideInfo::fromStack($this->tempRoot, $stack); + $this->assertEquals("$stackDir/compose.yaml", $info->composeFilePath); + } + + public function testComposeFilePathResolvesIndirect(): void + { + $stack = 'indirect-compose'; + $stackDir = $this->tempRoot . '/' . $stack; + mkdir($stackDir); + $indirectTarget = $this->tempRoot . '/indirect_compose_target'; + mkdir($indirectTarget); + file_put_contents("$stackDir/indirect", $indirectTarget); + file_put_contents("$indirectTarget/docker-compose.yml", "services:\n app:\n image: redis\n"); + $info = \OverrideInfo::fromStack($this->tempRoot, $stack); + $this->assertEquals("$indirectTarget/docker-compose.yml", $info->composeFilePath); + } + + // =========================================== + // pruneOrphanServices Tests + // =========================================== + + public function testPruneOrphanServicesReturnsUnchangedWhenNoOverride(): void + { + $stack = 'prune-no-override'; + $stackDir = $this->tempRoot . '/' . $stack; + mkdir($stackDir); + file_put_contents("$stackDir/compose.yaml", "services:\n web:\n image: nginx\n"); + // Delete the auto-created override so there's nothing to prune + $info = \OverrideInfo::fromStack($this->tempRoot, $stack); + $overridePath = $info->getOverridePath(); + if ($overridePath && is_file($overridePath)) { + unlink($overridePath); + } + // Re-create info without the override file existing + $info2 = new \ReflectionClass(\OverrideInfo::class); + // Just test via the public API by removing the override file + $result = $info->pruneOrphanServices(); + $this->assertFalse($result['changed']); + $this->assertEquals([], $result['removed']); + } + + public function testPruneOrphanServicesRemovesStaleEntries(): void + { + $stack = 'prune-stale'; + $stackDir = $this->tempRoot . '/' . $stack; + mkdir($stackDir); + file_put_contents("$stackDir/compose.yaml", "services:\n web:\n image: nginx\n"); + $info = \OverrideInfo::fromStack($this->tempRoot, $stack); + $overridePath = $info->getOverridePath(); + + // Write an override with an orphaned service + $overrideContent = "services:\n" . + " web:\n" . + " labels:\n" . + " test: \"1\"\n" . + " deleted-svc:\n" . + " labels:\n" . + " test: \"2\"\n"; + file_put_contents($overridePath, $overrideContent); + + $result = $info->pruneOrphanServices(); + + $this->assertTrue($result['changed']); + $this->assertEquals(['deleted-svc'], $result['removed']); + + // Verify override file was updated + $newContent = file_get_contents($overridePath); + $this->assertStringContainsString(" web:\n", $newContent); + $this->assertStringNotContainsString(" deleted-svc:\n", $newContent); + } + + public function testPruneOrphanServicesNoChangeWhenAllValid(): void + { + $stack = 'prune-valid'; + $stackDir = $this->tempRoot . '/' . $stack; + mkdir($stackDir); + file_put_contents("$stackDir/compose.yaml", "services:\n web:\n image: nginx\n api:\n image: node\n"); + $info = \OverrideInfo::fromStack($this->tempRoot, $stack); + $overridePath = $info->getOverridePath(); + + $overrideContent = "services:\n" . + " web:\n" . + " labels:\n" . + " test: \"1\"\n" . + " api:\n" . + " labels:\n" . + " test: \"2\"\n"; + file_put_contents($overridePath, $overrideContent); + + $result = $info->pruneOrphanServices(); + + $this->assertFalse($result['changed']); + $this->assertEquals([], $result['removed']); + } } diff --git a/tests/unit/StackInfoTest.php b/tests/unit/StackInfoTest.php new file mode 100644 index 0000000..a641b31 --- /dev/null +++ b/tests/unit/StackInfoTest.php @@ -0,0 +1,467 @@ +tempRoot = $this->createTempDir(); + } + + // =========================================== + // Factory / Identity Tests + // =========================================== + + public function testFromProjectCreatesInstance(): void + { + $stack = 'mystack'; + mkdir($this->tempRoot . '/' . $stack); + $info = \StackInfo::fromProject($this->tempRoot, $stack); + $this->assertInstanceOf(\StackInfo::class, $info); + } + + public function testProjectIdentityFields(): void + { + $stack = 'my-stack'; + mkdir($this->tempRoot . '/' . $stack); + $info = \StackInfo::fromProject($this->tempRoot, $stack); + + $this->assertSame('my-stack', $info->project); + $this->assertSame(sanitizeStr('my-stack'), $info->sanitizedName); + $this->assertSame($this->tempRoot . '/my-stack', $info->path); + $this->assertFalse($info->isIndirect); + } + + public function testSanitizedNameRemovesSpecialChars(): void + { + $stack = 'My Stack (v2)'; + mkdir($this->tempRoot . '/' . $stack); + $info = \StackInfo::fromProject($this->tempRoot, $stack); + + // sanitizeStr lowercases and removes non-alphanumeric/dash/underscore + $this->assertSame(sanitizeStr('My Stack (v2)'), $info->sanitizedName); + } + + public function testIndirectStackResolution(): void + { + $stack = 'indirect-stack'; + $stackDir = $this->tempRoot . '/' . $stack; + $indirectTarget = $this->tempRoot . '/actual_source'; + mkdir($stackDir); + mkdir($indirectTarget); + file_put_contents($stackDir . '/indirect', $indirectTarget); + + $info = \StackInfo::fromProject($this->tempRoot, $stack); + + $this->assertTrue($info->isIndirect); + $this->assertSame($indirectTarget, $info->composeSource); + } + + public function testNonIndirectStackSource(): void + { + $stack = 'direct-stack'; + $stackDir = $this->tempRoot . '/' . $stack; + mkdir($stackDir); + + $info = \StackInfo::fromProject($this->tempRoot, $stack); + + $this->assertFalse($info->isIndirect); + $this->assertSame($stackDir, $info->composeSource); + } + + // =========================================== + // Compose File Resolution Tests + // =========================================== + + public function testComposeFilePathNullWhenNoFile(): void + { + $stack = 'no-compose'; + mkdir($this->tempRoot . '/' . $stack); + $info = \StackInfo::fromProject($this->tempRoot, $stack); + $this->assertNull($info->composeFilePath); + } + + public function testComposeFilePathResolvedWithComposeYaml(): void + { + $stack = 'has-compose'; + $stackDir = $this->tempRoot . '/' . $stack; + mkdir($stackDir); + file_put_contents("$stackDir/compose.yaml", "services:\n web:\n image: nginx\n"); + + $info = \StackInfo::fromProject($this->tempRoot, $stack); + + $this->assertSame("$stackDir/compose.yaml", $info->composeFilePath); + } + + public function testComposeFilePathResolvedWithDockerComposeYml(): void + { + $stack = 'legacy-compose'; + $stackDir = $this->tempRoot . '/' . $stack; + mkdir($stackDir); + file_put_contents("$stackDir/docker-compose.yml", "services:\n web:\n image: nginx\n"); + + $info = \StackInfo::fromProject($this->tempRoot, $stack); + + $this->assertSame("$stackDir/docker-compose.yml", $info->composeFilePath); + } + + public function testComposeFilePathResolvedViaIndirect(): void + { + $stack = 'indirect-compose'; + $stackDir = $this->tempRoot . '/' . $stack; + $indirectTarget = $this->tempRoot . '/external_source'; + mkdir($stackDir); + mkdir($indirectTarget); + file_put_contents("$stackDir/indirect", $indirectTarget); + file_put_contents("$indirectTarget/compose.yaml", "services:\n app:\n image: redis\n"); + + $info = \StackInfo::fromProject($this->tempRoot, $stack); + + $this->assertSame("$indirectTarget/compose.yaml", $info->composeFilePath); + } + + // =========================================== + // Override Info Tests + // =========================================== + + public function testOverrideInfoIsResolved(): void + { + $stack = 'override-stack'; + mkdir($this->tempRoot . '/' . $stack); + $info = \StackInfo::fromProject($this->tempRoot, $stack); + + $this->assertInstanceOf(\OverrideInfo::class, $info->overrideInfo); + $this->assertInstanceOf(\OverrideInfo::class, $info->getOverrideInfo()); + } + + public function testGetOverridePathDelegates(): void + { + $stack = 'with-override'; + $stackDir = $this->tempRoot . '/' . $stack; + mkdir($stackDir); + file_put_contents("$stackDir/compose.override.yaml", '# override'); + + $info = \StackInfo::fromProject($this->tempRoot, $stack); + + $this->assertSame("$stackDir/compose.override.yaml", $info->getOverridePath()); + } + + // =========================================== + // Lazy Metadata Getter Tests + // =========================================== + + public function testGetNameFromFile(): void + { + $stack = 'named-stack'; + $stackDir = $this->tempRoot . '/' . $stack; + mkdir($stackDir); + file_put_contents("$stackDir/name", "My Display Name"); + + $info = \StackInfo::fromProject($this->tempRoot, $stack); + + $this->assertSame('My Display Name', $info->getName()); + } + + public function testGetNameFallsBackToProject(): void + { + $stack = 'unnamed-stack'; + mkdir($this->tempRoot . '/' . $stack); + $info = \StackInfo::fromProject($this->tempRoot, $stack); + + $this->assertSame('unnamed-stack', $info->getName()); + } + + public function testGetDescription(): void + { + $stack = 'desc-stack'; + $stackDir = $this->tempRoot . '/' . $stack; + mkdir($stackDir); + file_put_contents("$stackDir/description", "A test stack\nwith multiple lines"); + + $info = \StackInfo::fromProject($this->tempRoot, $stack); + + $this->assertSame("A test stack\nwith multiple lines", $info->getDescription()); + } + + public function testGetDescriptionEmptyWhenNoFile(): void + { + $stack = 'no-desc'; + mkdir($this->tempRoot . '/' . $stack); + $info = \StackInfo::fromProject($this->tempRoot, $stack); + + $this->assertSame('', $info->getDescription()); + } + + public function testGetEnvFilePath(): void + { + $stack = 'env-stack'; + $stackDir = $this->tempRoot . '/' . $stack; + mkdir($stackDir); + file_put_contents("$stackDir/envpath", "/path/to/.env"); + + $info = \StackInfo::fromProject($this->tempRoot, $stack); + + $this->assertSame('/path/to/.env', $info->getEnvFilePath()); + } + + public function testGetEnvFilePathNullWhenNoFile(): void + { + $stack = 'no-env'; + mkdir($this->tempRoot . '/' . $stack); + $info = \StackInfo::fromProject($this->tempRoot, $stack); + + $this->assertNull($info->getEnvFilePath()); + } + + public function testGetIconUrlValid(): void + { + $stack = 'icon-stack'; + $stackDir = $this->tempRoot . '/' . $stack; + mkdir($stackDir); + file_put_contents("$stackDir/icon_url", "https://example.com/icon.png"); + + $info = \StackInfo::fromProject($this->tempRoot, $stack); + + $this->assertSame('https://example.com/icon.png', $info->getIconUrl()); + } + + public function testGetIconUrlNullForInvalidUrl(): void + { + $stack = 'bad-icon'; + $stackDir = $this->tempRoot . '/' . $stack; + mkdir($stackDir); + file_put_contents("$stackDir/icon_url", "not-a-url"); + + $info = \StackInfo::fromProject($this->tempRoot, $stack); + + $this->assertNull($info->getIconUrl()); + } + + public function testGetIconUrlNullForFtpScheme(): void + { + $stack = 'ftp-icon'; + $stackDir = $this->tempRoot . '/' . $stack; + mkdir($stackDir); + file_put_contents("$stackDir/icon_url", "ftp://example.com/icon.png"); + + $info = \StackInfo::fromProject($this->tempRoot, $stack); + + $this->assertNull($info->getIconUrl()); + } + + public function testGetWebUIUrl(): void + { + $stack = 'webui-stack'; + $stackDir = $this->tempRoot . '/' . $stack; + mkdir($stackDir); + file_put_contents("$stackDir/webui_url", "http://192.168.1.1:8080"); + + $info = \StackInfo::fromProject($this->tempRoot, $stack); + + $this->assertSame('http://192.168.1.1:8080', $info->getWebUIUrl()); + } + + public function testGetWebUIUrlNullWhenInvalid(): void + { + $stack = 'bad-webui'; + $stackDir = $this->tempRoot . '/' . $stack; + mkdir($stackDir); + file_put_contents("$stackDir/webui_url", "javascript:alert(1)"); + + $info = \StackInfo::fromProject($this->tempRoot, $stack); + + $this->assertNull($info->getWebUIUrl()); + } + + public function testGetDefaultProfiles(): void + { + $stack = 'profiles-stack'; + $stackDir = $this->tempRoot . '/' . $stack; + mkdir($stackDir); + file_put_contents("$stackDir/default_profile", "dev, test , production"); + + $info = \StackInfo::fromProject($this->tempRoot, $stack); + + $this->assertSame(['dev', 'test', 'production'], $info->getDefaultProfiles()); + } + + public function testGetDefaultProfilesEmptyWhenNoFile(): void + { + $stack = 'no-profiles'; + mkdir($this->tempRoot . '/' . $stack); + $info = \StackInfo::fromProject($this->tempRoot, $stack); + + $this->assertSame([], $info->getDefaultProfiles()); + } + + public function testGetAutostartTrue(): void + { + $stack = 'autostart-stack'; + $stackDir = $this->tempRoot . '/' . $stack; + mkdir($stackDir); + file_put_contents("$stackDir/autostart", "true"); + + $info = \StackInfo::fromProject($this->tempRoot, $stack); + + $this->assertTrue($info->getAutostart()); + } + + public function testGetAutostartFalseWhenNoFile(): void + { + $stack = 'no-autostart'; + mkdir($this->tempRoot . '/' . $stack); + $info = \StackInfo::fromProject($this->tempRoot, $stack); + + $this->assertFalse($info->getAutostart()); + } + + public function testGetAutostartFalseWhenNotTrue(): void + { + $stack = 'false-autostart'; + $stackDir = $this->tempRoot . '/' . $stack; + mkdir($stackDir); + file_put_contents("$stackDir/autostart", "false"); + + $info = \StackInfo::fromProject($this->tempRoot, $stack); + + $this->assertFalse($info->getAutostart()); + } + + public function testGetStartedAt(): void + { + $stack = 'started-stack'; + $stackDir = $this->tempRoot . '/' . $stack; + mkdir($stackDir); + file_put_contents("$stackDir/started_at", "2024-06-15T10:30:00Z"); + + $info = \StackInfo::fromProject($this->tempRoot, $stack); + + $this->assertSame('2024-06-15T10:30:00Z', $info->getStartedAt()); + } + + public function testGetStartedAtNullWhenNoFile(): void + { + $stack = 'not-started'; + mkdir($this->tempRoot . '/' . $stack); + $info = \StackInfo::fromProject($this->tempRoot, $stack); + + $this->assertNull($info->getStartedAt()); + } + + public function testGetProfiles(): void + { + $stack = 'json-profiles'; + $stackDir = $this->tempRoot . '/' . $stack; + mkdir($stackDir); + file_put_contents("$stackDir/profiles", json_encode(['dev', 'staging', 'prod'])); + + $info = \StackInfo::fromProject($this->tempRoot, $stack); + + $this->assertSame(['dev', 'staging', 'prod'], $info->getProfiles()); + } + + public function testGetProfilesEmptyWhenNoFile(): void + { + $stack = 'no-json-profiles'; + mkdir($this->tempRoot . '/' . $stack); + $info = \StackInfo::fromProject($this->tempRoot, $stack); + + $this->assertSame([], $info->getProfiles()); + } + + public function testGetProfilesEmptyForInvalidJson(): void + { + $stack = 'bad-json'; + $stackDir = $this->tempRoot . '/' . $stack; + mkdir($stackDir); + file_put_contents("$stackDir/profiles", "not-valid-json{"); + + $info = \StackInfo::fromProject($this->tempRoot, $stack); + + $this->assertSame([], $info->getProfiles()); + } + + // =========================================== + // Metadata Caching Tests + // =========================================== + + public function testMetadataIsCachedOnSecondAccess(): void + { + $stack = 'cached-name'; + $stackDir = $this->tempRoot . '/' . $stack; + mkdir($stackDir); + file_put_contents("$stackDir/name", "Original Name"); + + $info = \StackInfo::fromProject($this->tempRoot, $stack); + + // First read + $this->assertSame('Original Name', $info->getName()); + + // Mutate the file — should still return cached value + file_put_contents("$stackDir/name", "Changed Name"); + $this->assertSame('Original Name', $info->getName()); + } + + // =========================================== + // buildComposeArgs Tests + // =========================================== + + public function testBuildComposeArgsBasic(): void + { + $stack = 'args-stack'; + $stackDir = $this->tempRoot . '/' . $stack; + mkdir($stackDir); + file_put_contents("$stackDir/compose.yaml", "services:\n web:\n image: nginx\n"); + + $info = \StackInfo::fromProject($this->tempRoot, $stack); + $args = $info->buildComposeArgs(); + + $this->assertArrayHasKey('projectName', $args); + $this->assertArrayHasKey('files', $args); + $this->assertArrayHasKey('envFile', $args); + $this->assertSame($info->sanitizedName, $args['projectName']); + $this->assertStringContainsString('compose.yaml', $args['files']); + } + + public function testBuildComposeArgsWithEnvFile(): void + { + $stack = 'env-args'; + $stackDir = $this->tempRoot . '/' . $stack; + mkdir($stackDir); + file_put_contents("$stackDir/compose.yaml", "services:\n web:\n image: nginx\n"); + $envPath = $stackDir . '/.env'; + file_put_contents($envPath, "KEY=value"); + file_put_contents("$stackDir/envpath", $envPath); + + $info = \StackInfo::fromProject($this->tempRoot, $stack); + $args = $info->buildComposeArgs(); + + $this->assertStringContainsString('--env-file', $args['envFile']); + } + + public function testBuildComposeArgsWithOverride(): void + { + $stack = 'override-args'; + $stackDir = $this->tempRoot . '/' . $stack; + mkdir($stackDir); + file_put_contents("$stackDir/compose.yaml", "services:\n web:\n image: nginx\n"); + file_put_contents("$stackDir/compose.override.yaml", "services:\n web:\n ports:\n - '80:80'\n"); + + $info = \StackInfo::fromProject($this->tempRoot, $stack); + $args = $info->buildComposeArgs(); + + // Should have two -f flags + $this->assertSame(2, substr_count($args['files'], '-f')); + $this->assertStringContainsString('compose.override.yaml', $args['files']); + } +} diff --git a/tests/unit/UtilTest.php b/tests/unit/UtilTest.php index 177340f..5aaa053 100644 --- a/tests/unit/UtilTest.php +++ b/tests/unit/UtilTest.php @@ -197,6 +197,52 @@ public function testGetStackLastResultHandlesInvalidJson(): void $this->assertNull($result); } + public function testPruneOverrideContentServicesRemovesOrphanedServices(): void + { + $override = "services:\n" . + " app:\n" . + " labels:\n" . + " test: \"1\"\n" . + " old-service:\n" . + " labels:\n" . + " test: \"2\"\n"; + + $result = pruneOverrideContentServices($override, ['app']); + + $this->assertTrue($result['changed']); + $this->assertEquals(['old-service'], $result['removed']); + $this->assertStringContainsString(" app:\n", $result['content']); + $this->assertStringNotContainsString(" old-service:\n", $result['content']); + } + + public function testPruneOverrideContentServicesSetsEmptyServicesMapWhenAllOrphaned(): void + { + $override = "services:\n" . + " old-service:\n" . + " labels:\n" . + " test: \"2\"\n"; + + $result = pruneOverrideContentServices($override, ['app']); + + $this->assertTrue($result['changed']); + $this->assertEquals(['old-service'], $result['removed']); + $this->assertStringContainsString('services: {}', $result['content']); + } + + public function testPruneOverrideContentServicesNoChangeWhenServicesMatch(): void + { + $override = "services:\n" . + " app:\n" . + " labels:\n" . + " test: \"1\"\n"; + + $result = pruneOverrideContentServices($override, ['app']); + + $this->assertFalse($result['changed']); + $this->assertEquals([], $result['removed']); + $this->assertEquals($override, $result['content']); + } + // =========================================== // Stack Locking Tests // =========================================== From 6a382d5456f63a63666d6256a860a5d95fadfd2d Mon Sep 17 00:00:00 2001 From: Nick Szittai Date: Sat, 7 Mar 2026 21:21:25 -0500 Subject: [PATCH 02/12] fix: standardize container property names to lowercase --- .../php/compose_manager_main.php | 44 +++++++++---------- 1 file changed, 22 insertions(+), 22 deletions(-) diff --git a/source/compose.manager/php/compose_manager_main.php b/source/compose.manager/php/compose_manager_main.php index e7a495a..f050e67 100755 --- a/source/compose.manager/php/compose_manager_main.php +++ b/source/compose.manager/php/compose_manager_main.php @@ -1152,9 +1152,9 @@ function updateStackUpdateUI(stackName, stackInfo) { if (stackContainersCache[stackId] && stackInfo.containers) { stackContainersCache[stackId].forEach(function(cached) { stackInfo.containers.forEach(function(updated) { - if (cached.Name === updated.container) { + if (cached.name === updated.name) { cached.hasUpdate = updated.hasUpdate; - cached.updateStatus = updated.status; + cached.updateStatus = updated.updateStatus; cached.localSha = updated.localSha || ''; cached.remoteSha = updated.remoteSha || ''; cached.isPinned = updated.isPinned || false; @@ -2511,13 +2511,13 @@ function renderStackActionDialog(action, stackName, path, profile, containers) { html += '
' + cfg.listTitle + '
'; containers.forEach(function(container, index) { - var containerName = container.Name || container.Service || 'Unknown'; - var shortName = container.Service || containerName.replace(/^[^-]+-/, ''); - var image = container.Image || ''; + var containerName = container.name || container.service || 'Unknown'; + var shortName = container.service || containerName.replace(/^[^-]+-/, ''); + var image = container.image || ''; var imageParts = image.split(':'); var imageName = imageParts[0].split('/').pop(); var imageTag = imageParts[1] || 'latest'; - var state = container.State || 'unknown'; + var state = container.state || 'unknown'; var stateColor = state === 'running' ? '#3c3' : (state === 'paused' ? '#f80' : '#888'); var stateIcon = state === 'running' ? 'play' : (state === 'paused' ? 'pause' : 'square'); @@ -2527,8 +2527,8 @@ function renderStackActionDialog(action, stackName, path, profile, containers) { var localSha = container.localSha || ''; var remoteSha = container.remoteSha || ''; - var iconSrc = (container.Icon && (container.Icon.indexOf('http://') === 0 || container.Icon.indexOf('https://') === 0 || container.Icon.indexOf('data:image/') === 0)) ? - escapeAttr(container.Icon) : + var iconSrc = (container.icon && (container.icon.indexOf('http://') === 0 || container.icon.indexOf('https://') === 0 || container.icon.indexOf('data:image/') === 0)) ? + escapeAttr(container.icon) : '/plugins/dynamix.docker.manager/images/question.png'; // Grey out containers without updates when showing update dialog @@ -3782,9 +3782,9 @@ function renderContainerDetails(stackId, containers, project) { html += ''; containers.forEach(function(container, idx) { - var containerName = container.Name || container.Service || 'Unknown'; - var shortName = container.Service || containerName.replace(/^[^-]+-/, ''); // Prefer service name; fall back to stripping project prefix - var image = container.Image || ''; + var containerName = container.name || container.service || 'Unknown'; + var shortName = container.service || containerName.replace(/^[^-]+-/, ''); // Prefer service name; fall back to stripping project prefix + var image = container.image || ''; // Parse image - handle docker.io/ prefix and @sha256: digest // Format could be: docker.io/library/redis:6.2-alpine@sha256:abc123... @@ -3805,8 +3805,8 @@ function renderContainerDetails(stackId, containers, project) { var imageParts = imageForParsing.split(':'); var imageSource = imageParts[0] || ''; // Image name without tag var imageTag = (imageParts[1] || 'latest') + digestSuffix; // Include digest suffix if present - var state = container.State || 'unknown'; - var containerId = (container.Id || containerName).substring(0, 12); + var state = container.state || 'unknown'; + var containerId = (container.id || containerName).substring(0, 12); var uniqueId = 'ct-' + stackId + '-' + idx; // Status like Docker tab @@ -3818,8 +3818,8 @@ function renderContainerDetails(stackId, containers, project) { // Get networks and IPs var networkNames = []; var ipAddresses = []; - if (container.Networks && container.Networks.length > 0) { - container.Networks.forEach(function(net) { + if (container.networks && container.networks.length > 0) { + container.networks.forEach(function(net) { networkNames.push(net.name || '-'); ipAddresses.push(net.ip || '-'); }); @@ -3832,8 +3832,8 @@ function renderContainerDetails(stackId, containers, project) { // Format ports - separate container ports and mapped ports var containerPorts = []; var lanPorts = []; - if (container.Ports && container.Ports.length > 0) { - container.Ports.forEach(function(p) { + if (container.ports && container.ports.length > 0) { + container.ports.forEach(function(p) { // Format: "192.168.1.10:8080->80/tcp" or "80/tcp" var parts = p.split('->'); if (parts.length === 2) { @@ -3850,8 +3850,8 @@ function renderContainerDetails(stackId, containers, project) { // WebUI // WebUI — already resolved server-side by exec.php var webui = ''; - if (container.WebUI) { - webui = container.WebUI; + if (container.webUI) { + webui = container.webUI; if (!isValidWebUIUrl(webui)) webui = ''; } @@ -3860,11 +3860,11 @@ function renderContainerDetails(stackId, containers, project) { // Container name column - matches Docker tab exactly html += ''; html += ''; - var containerShell = container.Shell || '/bin/sh'; + var containerShell = container.shell || '/bin/sh'; html += ''; // Use actual image like Docker tab - either container icon or default question.png - var iconSrc = (container.Icon && (isValidWebUIUrl(container.Icon) || container.Icon.startsWith('data:image/'))) ? - container.Icon : + var iconSrc = (container.icon && (isValidWebUIUrl(container.icon) || container.icon.startsWith('data:image/'))) ? + container.icon : '/plugins/dynamix.docker.manager/images/question.png'; html += ''; html += ''; From 8df776aa75f885439b426d7665b400fc2c5b913e Mon Sep 17 00:00:00 2001 From: Nick Szittai Date: Sat, 7 Mar 2026 21:40:04 -0500 Subject: [PATCH 03/12] fix: address code review findings for standard classes - Remove unnecessary Object.assign merging of PascalCase originals - Align JS createContainerInfo shell default to '/bin/bash' - Add @ to file_get_contents in StackInfo constructor - Make OverrideInfo::getDefinedServices() private - Fix htmlspecialchars(null) deprecation in compose_list.php - Remove extra blank doc-comment lines in OverrideInfo::resolve() --- source/compose.manager/php/compose_list.php | 2 +- .../compose.manager/php/compose_manager_main.php | 13 +++---------- source/compose.manager/php/util.php | 14 +++++++------- 3 files changed, 11 insertions(+), 18 deletions(-) diff --git a/source/compose.manager/php/compose_list.php b/source/compose.manager/php/compose_list.php index 64f1e20..adb59d9 100755 --- a/source/compose.manager/php/compose_list.php +++ b/source/compose.manager/php/compose_list.php @@ -158,7 +158,7 @@ $projectHtml = htmlspecialchars($project, ENT_QUOTES, 'UTF-8'); $descriptionHtml = $description; // Already contains
tags from earlier processing $pathHtml = htmlspecialchars("$compose_root/$project", ENT_QUOTES, 'UTF-8'); - $projectIconUrl = htmlspecialchars($projectIcon, ENT_QUOTES, 'UTF-8'); + $projectIconUrl = htmlspecialchars($projectIcon ?? '', ENT_QUOTES, 'UTF-8'); // Status like Docker tab (started/stopped with icon) $status = $isrunning ? ($runningCount == $containerCount ? 'started' : 'partial') : 'stopped'; diff --git a/source/compose.manager/php/compose_manager_main.php b/source/compose.manager/php/compose_manager_main.php index f050e67..ca4db1e 100755 --- a/source/compose.manager/php/compose_manager_main.php +++ b/source/compose.manager/php/compose_manager_main.php @@ -190,7 +190,7 @@ function createContainerInfo(raw) { isPinned: (raw.isPinned !== undefined) ? !!raw.isPinned : false, pinnedDigest: raw.pinnedDigest || raw.PinnedDigest || '', icon: raw.icon || raw.Icon || '', - shell: raw.shell || raw.Shell || '', + shell: raw.shell || raw.Shell || '/bin/bash', webUI: raw.webUI || raw.WebUI || '', ports: raw.ports || raw.Ports || '', networks: raw.networks || raw.Networks || '', @@ -2114,11 +2114,7 @@ function refreshStackRow(stackId, project) { if (response.result === 'success') { var containers = response.containers || []; // Normalize all containers via factory function (PascalCase→camelCase) - containers = containers.map(function(c) { - var info = createContainerInfo(c); - // Preserve original keys for renderContainerDetails compatibility - return Object.assign({}, c, info); - }); + containers = containers.map(createContainerInfo).filter(Boolean); // Merge saved update status so we don't lose checked info mergeUpdateStatus(containers, project); // Update cache with fresh data @@ -3709,10 +3705,7 @@ function loadStackContainerDetails(stackId, project) { var containers = response.containers; // Normalize all containers via factory function (PascalCase→camelCase) - containers = containers.map(function(c) { - var info = createContainerInfo(c); - return Object.assign({}, c, info); - }); + containers = containers.map(createContainerInfo).filter(Boolean); // Merge update status from stackUpdateStatus if available mergeUpdateStatus(containers, project); diff --git a/source/compose.manager/php/util.php b/source/compose.manager/php/util.php index 1bd2aec..3ed62e6 100644 --- a/source/compose.manager/php/util.php +++ b/source/compose.manager/php/util.php @@ -295,8 +295,7 @@ public static function fromStack(string $composeRoot, string $stack): OverrideIn /** * Resolve override information for a given stack and populate this instance. - * - * + * * @param string $stack * @return void */ @@ -354,15 +353,16 @@ public function getOverridePath(): ?string } /** - * Get the list of services defined in the main compose file. + * Get the list of services defined in the main compose file (without override). * - * Uses `docker compose config --services` to accurately resolve - * services including extends, anchors, etc. + * Used internally by pruneOrphanServices() to determine which services + * are valid. External callers should use StackInfo::getDefinedServices() + * which includes the override file. * * @param string|null $envFilePath Optional path to env file * @return string[] List of service names */ - public function getDefinedServices(?string $envFilePath = null): array + private function getDefinedServices(?string $envFilePath = null): array { if ($this->composeFilePath === null || !is_file($this->composeFilePath)) { return []; @@ -695,7 +695,7 @@ private function __construct(string $composeRoot, string $project) // Resolve indirect $this->isIndirect = isIndirect($this->path); $this->composeSource = $this->isIndirect - ? (trim(file_get_contents($this->path . '/indirect')) ?: $this->path) + ? (trim(@file_get_contents($this->path . '/indirect')) ?: $this->path) : $this->path; // Resolve compose file From 0158dba276dbba2eec0612a7476a49fe83c3bf02 Mon Sep 17 00:00:00 2001 From: Nick Szittai Date: Sat, 7 Mar 2026 23:40:12 -0500 Subject: [PATCH 04/12] feat: implement caching for StackInfo instances and add cache management methods --- source/compose.manager/php/util.php | 28 +++++++++++++++++++++++++++- tests/unit/StackInfoTest.php | 24 ++++++++++++++++++++++++ 2 files changed, 51 insertions(+), 1 deletion(-) diff --git a/source/compose.manager/php/util.php b/source/compose.manager/php/util.php index 3ed62e6..d6eb9f3 100644 --- a/source/compose.manager/php/util.php +++ b/source/compose.manager/php/util.php @@ -681,6 +681,9 @@ class StackInfo /** @var array Lazy-loaded metadata cache (field => value|null, unset = not loaded) */ private array $metadataCache = []; + /** @var array Static instance cache keyed by composeRoot/project */ + private static array $instances = []; + /** * @param string $composeRoot Compose root directory * @param string $project Directory basename of the stack @@ -709,13 +712,36 @@ private function __construct(string $composeRoot, string $project) /** * Create a StackInfo for a project directory under the compose root. * + * Returns a cached instance if one already exists for this composeRoot/project + * combination, avoiding redundant filesystem and override resolution work. + * * @param string $composeRoot The compose projects root directory * @param string $project Directory basename of the stack * @return StackInfo */ public static function fromProject(string $composeRoot, string $project): self { - return new self($composeRoot, $project); + $key = rtrim($composeRoot, '/') . '/' . $project; + if (!isset(self::$instances[$key])) { + self::$instances[$key] = new self($composeRoot, $project); + } + return self::$instances[$key]; + } + + /** + * Clear the static instance cache. + * + * Primarily useful in tests to ensure a clean state between test cases. + * + * @param string|null $key Optional specific key (composeRoot/project) to clear; null clears all. + */ + public static function clearCache(?string $key = null): void + { + if ($key !== null) { + unset(self::$instances[$key]); + } else { + self::$instances = []; + } } // --------------------------------------------------------------- diff --git a/tests/unit/StackInfoTest.php b/tests/unit/StackInfoTest.php index a641b31..4140836 100644 --- a/tests/unit/StackInfoTest.php +++ b/tests/unit/StackInfoTest.php @@ -15,6 +15,7 @@ class StackInfoTest extends TestCase protected function setUp(): void { parent::setUp(); + \StackInfo::clearCache(); $this->tempRoot = $this->createTempDir(); } @@ -412,6 +413,29 @@ public function testMetadataIsCachedOnSecondAccess(): void $this->assertSame('Original Name', $info->getName()); } + public function testFromProjectReturnsCachedInstance(): void + { + $stack = 'cached-instance'; + mkdir($this->tempRoot . '/' . $stack); + + $first = \StackInfo::fromProject($this->tempRoot, $stack); + $second = \StackInfo::fromProject($this->tempRoot, $stack); + + $this->assertSame($first, $second); + } + + public function testClearCacheForcesFreshInstance(): void + { + $stack = 'clear-cache'; + mkdir($this->tempRoot . '/' . $stack); + + $first = \StackInfo::fromProject($this->tempRoot, $stack); + \StackInfo::clearCache(); + $second = \StackInfo::fromProject($this->tempRoot, $stack); + + $this->assertNotSame($first, $second); + } + // =========================================== // buildComposeArgs Tests // =========================================== From b6192ca610aac1b44df045adbabe507ccb23b47d Mon Sep 17 00:00:00 2001 From: Nick Szittai Date: Sat, 7 Mar 2026 23:56:24 -0500 Subject: [PATCH 05/12] refactor: decouple OverrideInfo from stack identity resolution MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add OverrideInfo::fromStackInfo() as primary factory that accepts pre-resolved StackInfo fields, eliminating duplicate filesystem I/O - Extract resolveOverride() as shared core for both factory paths - Deprecate OverrideInfo::fromStack() (kept for backward compat) - Remove $composeRoot, getProjectPath(), getDefinedServices() from OverrideInfo — these were stack-level concerns - Simplify pruneOrphanServices() to accept string[] instead of resolving services internally - Add getMainFileServices() to StackInfo for pruning without override - Replace remaining OverrideInfo::fromStack() calls in exec.php with StackInfo::fromProject() - Fix addStack passing display name instead of folder basename - Add StackInfo::clearCache() to test setUp methods --- source/compose.manager/php/exec.php | 6 +- source/compose.manager/php/util.php | 207 +++++++++++++++------------- tests/unit/ExecActionsTest.php | 1 + tests/unit/OverrideInfoTest.php | 11 +- 4 files changed, 122 insertions(+), 103 deletions(-) diff --git a/source/compose.manager/php/exec.php b/source/compose.manager/php/exec.php index 81a5840..df74023 100644 --- a/source/compose.manager/php/exec.php +++ b/source/compose.manager/php/exec.php @@ -81,7 +81,7 @@ function getPostScript(): string } // Init override info to ensure override file is created for new stack (if not indirect) and to avoid errors when accessing settings before the override file is created - OverrideInfo::fromStack($compose_root, $stackName); + StackInfo::fromProject($compose_root, basename($folder)); // Save stack name (which may differ from folder name) for display purposes file_put_contents("$folder/name", $stackName); @@ -197,7 +197,7 @@ function getPostScript(): string $projectPath = "$compose_root/$script"; // Get Override file path and ensure project override exists (create blank if not) - $overridePath = OverrideInfo::fromStack($compose_root, $script)->getOverridePath(); + $overridePath = StackInfo::fromProject($compose_root, $script)->getOverridePath(); $scriptContents = is_file($overridePath) ? file_get_contents($overridePath) : ""; $scriptContents = str_replace("\r", "", $scriptContents); @@ -235,7 +235,7 @@ function getPostScript(): string $projectPath = "$compose_root/$script"; // Get Override file path and ensure project override exists (create blank if not) - $overridePath = OverrideInfo::fromStack($compose_root, $script)->getOverridePath(); + $overridePath = StackInfo::fromProject($compose_root, $script)->getOverridePath(); file_put_contents($overridePath, $scriptContents); echo "$overridePath saved"; diff --git a/source/compose.manager/php/util.php b/source/compose.manager/php/util.php index d6eb9f3..4b7c06d 100644 --- a/source/compose.manager/php/util.php +++ b/source/compose.manager/php/util.php @@ -266,81 +266,104 @@ class OverrideInfo */ public ?string $composeFilePath = null; - /** - * @var string Compose root directory - */ - private string $composeRoot; + private function __construct() {} /** - * Constructor - * @param string $composeRoot Compose root directory + * Create an OverrideInfo from a StackInfo instance. + * + * Primary factory — uses the pre-resolved identity fields from StackInfo + * (path, composeSource, composeFilePath, isIndirect) so that no duplicate + * filesystem resolution is needed. + * + * @param StackInfo $stackInfo The owning stack (must have identity fields populated) + * @return OverrideInfo */ - private function __construct(string $composeRoot) + public static function fromStackInfo(StackInfo $stackInfo): self { - $this->composeRoot = rtrim($composeRoot, "/"); + $indirectPath = $stackInfo->isIndirect ? $stackInfo->composeSource : null; + return self::resolveOverride($stackInfo->path, $indirectPath, $stackInfo->composeFilePath); } /** - * Static factory to create and resolve an OverrideInfo for a stack. + * Create an OverrideInfo by resolving paths from scratch. + * + * @deprecated Use StackInfo::fromProject() which provides OverrideInfo automatically + * via fromStackInfo(), avoiding duplicate filesystem resolution. + * * @param string $composeRoot * @param string $stack * @return OverrideInfo */ - public static function fromStack(string $composeRoot, string $stack): OverrideInfo + public static function fromStack(string $composeRoot, string $stack): self { - $info = new self($composeRoot); - $info->resolve($stack); - return $info; + $projectPath = rtrim($composeRoot, '/') . '/' . $stack; + $indirectPath = is_file("$projectPath/indirect") + ? trim(file_get_contents("$projectPath/indirect")) + : null; + if ($indirectPath === '') { + $indirectPath = null; + } + + $composeSource = $indirectPath ?? $projectPath; + $foundCompose = findComposeFile($composeSource); + $composeFilePath = $foundCompose !== false ? $foundCompose : null; + + return self::resolveOverride($projectPath, $indirectPath, $composeFilePath); } /** - * Resolve override information for a given stack and populate this instance. + * Core override resolution logic shared by both factories. * - * @param string $stack - * @return void + * Computes the override filename from the compose file, resolves project + * and indirect override paths, migrates/removes legacy overrides, and + * auto-creates a project override template if needed. + * + * @param string $projectPath Full path to the stack directory + * @param string|null $indirectPath Indirect target directory, or null if not indirect + * @param string|null $composeFilePath Resolved main compose file path, or null if none + * @return OverrideInfo */ - private function resolve(string $stack): void + private static function resolveOverride(string $projectPath, ?string $indirectPath, ?string $composeFilePath): self { - $projectPath = $this->getProjectPath($stack); - $indirectPath = is_file("$projectPath/indirect") ? trim(file_get_contents("$projectPath/indirect")) : null; - $composeSource = $indirectPath == "" || $indirectPath === null ? $projectPath : $indirectPath; + $info = new self(); + $info->composeFilePath = $composeFilePath; - $foundCompose = findComposeFile($composeSource); - $this->composeFilePath = $foundCompose !== false ? $foundCompose : null; - $composeBaseName = $foundCompose !== false ? basename($foundCompose) : COMPOSE_FILE_NAMES[0]; - $this->computedName = preg_replace('/(\.[^.]+)$/', '.override$1', $composeBaseName); + $composeBaseName = $composeFilePath !== null ? basename($composeFilePath) : COMPOSE_FILE_NAMES[0]; + $info->computedName = preg_replace('/(\.[^.]+)$/', '.override$1', $composeBaseName); - $this->projectOverride = $projectPath . '/' . $this->computedName; - $this->indirectOverride = $indirectPath !== "" && $indirectPath !== null ? ($indirectPath . '/' . $this->computedName) : null; + $info->projectOverride = $projectPath . '/' . $info->computedName; + $info->indirectOverride = $indirectPath !== null ? ($indirectPath . '/' . $info->computedName) : null; $legacyProject = $projectPath . '/docker-compose.override.yml'; - $legacyIndirect = $indirectPath !== "" && $indirectPath !== null ? ($indirectPath . '/docker-compose.override.yml') : null; + $legacyIndirect = $indirectPath !== null ? ($indirectPath . '/docker-compose.override.yml') : null; - $this->useIndirect = ($this->indirectOverride && is_file($this->indirectOverride)); - $this->mismatchIndirectLegacy = ($indirectPath !== "" && $legacyIndirect && is_file($legacyIndirect) && !($this->indirectOverride && is_file($this->indirectOverride))); + $info->useIndirect = ($info->indirectOverride && is_file($info->indirectOverride)); + $info->mismatchIndirectLegacy = ($indirectPath !== null && $legacyIndirect && is_file($legacyIndirect) && !($info->indirectOverride && is_file($info->indirectOverride))); // Migrate legacy project override to computed project override (project-only migration) - if (!is_file($this->projectOverride) && is_file($legacyProject) && realpath($legacyProject) !== @realpath($this->projectOverride)) { - @rename($legacyProject, $this->projectOverride); - clientDebug("[override] Migrated legacy project override $legacyProject -> $this->projectOverride", null, 'daemon', 'info'); + if (!is_file($info->projectOverride) && is_file($legacyProject) && realpath($legacyProject) !== @realpath($info->projectOverride)) { + @rename($legacyProject, $info->projectOverride); + clientDebug("[override] Migrated legacy project override $legacyProject -> $info->projectOverride", null, 'daemon', 'info'); } - if (is_file($this->projectOverride) && is_file($legacyProject) && realpath($legacyProject) !== @realpath($this->projectOverride)) { + if (is_file($info->projectOverride) && is_file($legacyProject) && realpath($legacyProject) !== @realpath($info->projectOverride)) { @rename($legacyProject, $legacyProject . ".bak"); clientDebug("[override] Removed stale legacy project override $legacyProject (mismatch with computed override)", null, 'daemon', 'info'); } - if ($this->mismatchIndirectLegacy) { + if ($info->mismatchIndirectLegacy) { clientDebug("[override] Indirect override exists with non-matching name; using project fallback.", null, 'daemon', 'warning'); } - if (!is_file($this->projectOverride) && !$this->useIndirect) { + if (!is_file($info->projectOverride) && !$info->useIndirect) { $overrideContent = "# Override file for UI labels (icon, webui, shell)\n"; $overrideContent .= "# This file is managed by Compose Manager\n"; $overrideContent .= "services: {}\n"; - file_put_contents($this->projectOverride, $overrideContent); - clientDebug("[override] Created missing project override template at $this->projectOverride", null, 'daemon', 'info'); + file_put_contents($info->projectOverride, $overrideContent); + clientDebug("[override] Created missing project override template at $info->projectOverride", null, 'daemon', 'info'); } + + return $info; } /** @@ -352,60 +375,24 @@ public function getOverridePath(): ?string return $this->useIndirect ? $this->indirectOverride : $this->projectOverride; } - /** - * Get the list of services defined in the main compose file (without override). - * - * Used internally by pruneOrphanServices() to determine which services - * are valid. External callers should use StackInfo::getDefinedServices() - * which includes the override file. - * - * @param string|null $envFilePath Optional path to env file - * @return string[] List of service names - */ - private function getDefinedServices(?string $envFilePath = null): array - { - if ($this->composeFilePath === null || !is_file($this->composeFilePath)) { - return []; - } - - $cmd = "docker compose -f " . escapeshellarg($this->composeFilePath); - if ($envFilePath !== null && $envFilePath !== '' && is_file($envFilePath)) { - $cmd .= " --env-file " . escapeshellarg($envFilePath); - } - $cmd .= " config --services 2>/dev/null"; - - $output = shell_exec($cmd); - if (!is_string($output) || trim($output) === '') { - return []; - } - - return array_values(array_filter(array_map('trim', explode("\n", trim($output))), function ($service) { - return $service !== ''; - })); - } - /** * Prune orphaned services from the override file. * - * Compares the services in the override file against the services - * defined in the main compose file. Any override service not present - * in the main file is removed. The override file is rewritten in place. + * Removes any services in the override that are not present in the + * provided list of valid service names. The override file is rewritten + * in place. * - * @param string|null $envFilePath Optional path to env file for service resolution + * @param string[] $validServices Service names that are defined in the main compose file * @return array{changed: bool, removed: string[]} */ - public function pruneOrphanServices(?string $envFilePath = null): array + public function pruneOrphanServices(array $validServices): array { $overridePath = $this->getOverridePath(); if ($overridePath === null || $overridePath === '' || !is_file($overridePath)) { return ['changed' => false, 'removed' => []]; } - if ($this->composeFilePath === null || !is_file($this->composeFilePath)) { - return ['changed' => false, 'removed' => []]; - } - $mainServices = $this->getDefinedServices($envFilePath); - if (empty($mainServices)) { + if (empty($validServices)) { return ['changed' => false, 'removed' => []]; } @@ -414,7 +401,7 @@ public function pruneOrphanServices(?string $envFilePath = null): array return ['changed' => false, 'removed' => []]; } - $result = pruneOverrideContentServices($overrideContent, $mainServices); + $result = pruneOverrideContentServices($overrideContent, $validServices); if (!($result['changed'] ?? false)) { return ['changed' => false, 'removed' => []]; } @@ -433,16 +420,6 @@ public function pruneOrphanServices(?string $envFilePath = null): array return ['changed' => true, 'removed' => $removedServices]; } - - /** - * Get the project path for a stack - * @param string $stack - * @return string - */ - private function getProjectPath(string $stack): string - { - return $this->composeRoot . '/' . $stack; - } } /** @@ -705,8 +682,8 @@ private function __construct(string $composeRoot, string $project) $found = findComposeFile($this->composeSource); $this->composeFilePath = ($found !== false) ? $found : null; - // Eagerly resolve override info (preserves side effects: auto-create, migration) - $this->overrideInfo = OverrideInfo::fromStack($this->composeRoot, $project); + // Eagerly resolve override info using pre-resolved identity (no duplicate I/O) + $this->overrideInfo = OverrideInfo::fromStackInfo($this); } /** @@ -914,14 +891,54 @@ public function getDefinedServices(): array /** * Prune orphaned services from the override file. * - * Convenience method: resolves defined services, then delegates to - * OverrideInfo::pruneOrphanServices(). + * Resolves valid services from the main compose file (without override), + * then delegates to OverrideInfo::pruneOrphanServices(). * * @return array{changed: bool, removed: string[]} */ public function pruneOrphanOverrideServices(): array { - return $this->overrideInfo->pruneOrphanServices($this->getEnvFilePath()); + $validServices = $this->getMainFileServices(); + if (empty($validServices)) { + return ['changed' => false, 'removed' => []]; + } + return $this->overrideInfo->pruneOrphanServices($validServices); + } + + /** + * Get the list of services defined in the main compose file only (without override). + * + * Used internally by pruneOrphanOverrideServices() to determine which services + * are valid. Excludes the override file so orphaned override services are not + * counted as valid. + * + * External callers should typically use getDefinedServices() which includes + * the override file for a complete picture. + * + * @return string[] List of service names + */ + private function getMainFileServices(): array + { + if ($this->composeFilePath === null || !is_file($this->composeFilePath)) { + return []; + } + + $cmd = "docker compose -f " . escapeshellarg($this->composeFilePath); + + $envFilePath = $this->getEnvFilePath(); + if ($envFilePath !== null && is_file($envFilePath)) { + $cmd .= " --env-file " . escapeshellarg($envFilePath); + } + $cmd .= " config --services 2>/dev/null"; + + $output = shell_exec($cmd); + if (!is_string($output) || trim($output) === '') { + return []; + } + + return array_values(array_filter(array_map('trim', explode("\n", trim($output))), function ($service) { + return $service !== ''; + })); } /** diff --git a/tests/unit/ExecActionsTest.php b/tests/unit/ExecActionsTest.php index 6f5313f..56d722f 100644 --- a/tests/unit/ExecActionsTest.php +++ b/tests/unit/ExecActionsTest.php @@ -27,6 +27,7 @@ class ExecActionsTest extends TestCase protected function setUp(): void { parent::setUp(); + \StackInfo::clearCache(); // Create test compose root $this->testComposeRoot = sys_get_temp_dir() . '/compose_exec_test_' . getmypid(); diff --git a/tests/unit/OverrideInfoTest.php b/tests/unit/OverrideInfoTest.php index 3c24d48..a58889e 100644 --- a/tests/unit/OverrideInfoTest.php +++ b/tests/unit/OverrideInfoTest.php @@ -16,6 +16,7 @@ class OverrideInfoTest extends TestCase protected function setUp(): void { parent::setUp(); + \StackInfo::clearCache(); $this->tempRoot = $this->createTempDir(); } @@ -190,10 +191,8 @@ public function testPruneOrphanServicesReturnsUnchangedWhenNoOverride(): void if ($overridePath && is_file($overridePath)) { unlink($overridePath); } - // Re-create info without the override file existing - $info2 = new \ReflectionClass(\OverrideInfo::class); // Just test via the public API by removing the override file - $result = $info->pruneOrphanServices(); + $result = $info->pruneOrphanServices(['web']); $this->assertFalse($result['changed']); $this->assertEquals([], $result['removed']); } @@ -217,7 +216,8 @@ public function testPruneOrphanServicesRemovesStaleEntries(): void " test: \"2\"\n"; file_put_contents($overridePath, $overrideContent); - $result = $info->pruneOrphanServices(); + // Pass valid services — 'web' is valid, 'deleted-svc' is orphaned + $result = $info->pruneOrphanServices(['web']); $this->assertTrue($result['changed']); $this->assertEquals(['deleted-svc'], $result['removed']); @@ -246,7 +246,8 @@ public function testPruneOrphanServicesNoChangeWhenAllValid(): void " test: \"2\"\n"; file_put_contents($overridePath, $overrideContent); - $result = $info->pruneOrphanServices(); + // Both services are valid + $result = $info->pruneOrphanServices(['web', 'api']); $this->assertFalse($result['changed']); $this->assertEquals([], $result['removed']); From 20519409389c4b93c15562ad27da620728e0bcda Mon Sep 17 00:00:00 2001 From: Nick Szittai Date: Sun, 8 Mar 2026 00:06:52 -0500 Subject: [PATCH 06/12] refactor: internalize stack creation into StackInfo::createNew() Move stack directory creation logic from the exec.php addStack handler into a new StackInfo::createNew() static factory method. This centralizes folder naming, collision avoidance, compose/indirect file wiring, metadata writes, and override initialization inside the domain class that already models these artifacts. - Add StackInfo::createNew() with folder sanitization, collision handling, indirect support, and metadata file creation - Move sanitizeFolderName() to util.php (backward-compat stub in exec_functions.php) - Reduce addStack case to input validation + factory call + JSON response - Add 10 unit tests covering createNew() scenarios (basic, indirect, collision, description, caching, override init) --- source/compose.manager/php/exec.php | 58 ++------- source/compose.manager/php/exec_functions.php | 22 ++-- source/compose.manager/php/util.php | 82 +++++++++++++ tests/unit/StackInfoTest.php | 112 ++++++++++++++++++ 4 files changed, 219 insertions(+), 55 deletions(-) diff --git a/source/compose.manager/php/exec.php b/source/compose.manager/php/exec.php index df74023..6f18116 100644 --- a/source/compose.manager/php/exec.php +++ b/source/compose.manager/php/exec.php @@ -33,10 +33,9 @@ function getPostScript(): string echo json_encode(['result' => 'success', 'config' => $cfg]); break; case 'addStack': - #Create indirect - $indirect = isset($_POST['stackPath']) ? trim($_POST['stackPath']) : ""; - if ($indirect != "") { - // Validate stackPath is under an allowed root (/mnt/ or /boot/config/) + // Validate indirect path (HTTP-boundary security check) + $indirect = isset($_POST['stackPath']) ? trim($_POST['stackPath']) : ''; + if ($indirect !== '') { $realIndirect = realpath(dirname($indirect)) ?: $indirect; if (strpos($realIndirect, '/mnt/') !== 0 && strpos($realIndirect, '/boot/config/') !== 0) { clientDebug("[stack] Failed to create stack: Invalid indirect path: $indirect", null, 'daemon', 'error'); @@ -50,52 +49,19 @@ function getPostScript(): string } } - #Create stack folder - $stackName = isset($_POST['stackName']) ? trim($_POST['stackName']) : ""; - $folderName = sanitizeFolderName($stackName); - $folder = "$compose_root/$folderName"; - while (true) { - if (is_dir($folder)) { - $folder .= mt_rand(); - } else { - break; - } - } - exec("mkdir -p " . escapeshellarg($folder)); - if (!is_dir($folder)) { - clientDebug("[stack] Failed to create stack: Unable to create directory: $folder", null, 'daemon', 'error'); - echo json_encode(['result' => 'error', 'message' => 'Failed to create stack directory.']); - break; - } + $stackName = isset($_POST['stackName']) ? trim($_POST['stackName']) : ''; + $stackDesc = isset($_POST['stackDesc']) ? trim($_POST['stackDesc']) : ''; - #Create stack files - if ($indirect != "") { - file_put_contents("$folder/indirect", $indirect); - if (!findComposeFile($indirect)) { - file_put_contents("$indirect/" . COMPOSE_FILE_NAMES[0], "services:\n"); - clientDebug("[stack] Indirect compose file not found at path: $indirect. Created stack with empty compose file.", null, 'daemon', 'warning'); - } - } else { - file_put_contents("$folder/" . COMPOSE_FILE_NAMES[0], "services:\n"); - clientDebug("[$stackName] Compose file not found at path: $folder. Created stack with empty compose file.", null, 'daemon', 'warning'); - } - - // Init override info to ensure override file is created for new stack (if not indirect) and to avoid errors when accessing settings before the override file is created - StackInfo::fromProject($compose_root, basename($folder)); - - // Save stack name (which may differ from folder name) for display purposes - file_put_contents("$folder/name", $stackName); - - // Save description if provided - $stackDesc = isset($_POST['stackDesc']) ? trim($_POST['stackDesc']) : ""; - if (!empty($stackDesc)) { - file_put_contents("$folder/description", trim($stackDesc)); + try { + $stack = StackInfo::createNew($compose_root, $stackName, $stackDesc, $indirect); + } catch (\RuntimeException $e) { + clientDebug('[stack] Failed to create stack: ' . $e->getMessage(), null, 'daemon', 'error'); + echo json_encode(['result' => 'error', 'message' => $e->getMessage()]); + break; } - // Return project info for opening the editor - $projectDir = basename($folder); clientDebug("[stack] Created stack: $stackName", null, 'daemon', 'info'); - echo json_encode(['result' => 'success', 'message' => '', 'project' => $projectDir, 'projectName' => $stackName]); + echo json_encode(['result' => 'success', 'message' => '', 'project' => $stack->project, 'projectName' => $stack->getName()]); break; case 'deleteStack': $stackName = isset($_POST['stackName']) ? basename(trim($_POST['stackName'])) : ""; diff --git a/source/compose.manager/php/exec_functions.php b/source/compose.manager/php/exec_functions.php index 93315b9..6774c86 100644 --- a/source/compose.manager/php/exec_functions.php +++ b/source/compose.manager/php/exec_functions.php @@ -45,18 +45,22 @@ function normalizeImageForUpdateCheck($image) { * Sanitize a stack name to create a safe folder name. * Removes special characters that could cause issues in paths. * + * @deprecated Moved to util.php. This stub remains for backward compatibility. + * * @param string $stackName The stack name to sanitize * @return string The sanitized folder name */ -function sanitizeFolderName($stackName) { - $folderName = str_replace('"', "", $stackName); - $folderName = str_replace("'", "", $folderName); - $folderName = str_replace("&", "", $folderName); - $folderName = str_replace("(", "", $folderName); - $folderName = str_replace(")", "", $folderName); - $folderName = preg_replace("/ {2,}/", " ", $folderName); - $folderName = preg_replace("/\s/", "_", $folderName); - return $folderName; +if (!function_exists('sanitizeFolderName')) { + function sanitizeFolderName($stackName) { + $folderName = str_replace('"', "", $stackName); + $folderName = str_replace("'", "", $folderName); + $folderName = str_replace("&", "", $folderName); + $folderName = str_replace("(", "", $folderName); + $folderName = str_replace(")", "", $folderName); + $folderName = preg_replace("/ {2,}/", " ", $folderName); + $folderName = preg_replace("/\s/", "_", $folderName); + return $folderName; + } } /** diff --git a/source/compose.manager/php/util.php b/source/compose.manager/php/util.php index 4b7c06d..b407cae 100644 --- a/source/compose.manager/php/util.php +++ b/source/compose.manager/php/util.php @@ -57,6 +57,25 @@ function sanitizeStr($a) return strtolower($a); } +/** + * Sanitize a stack name to create a safe folder name. + * Removes special characters that could cause issues in paths. + * + * @param string $stackName The stack name to sanitize + * @return string The sanitized folder name + */ +function sanitizeFolderName(string $stackName): string +{ + $folderName = str_replace('"', '', $stackName); + $folderName = str_replace("'", '', $folderName); + $folderName = str_replace('&', '', $folderName); + $folderName = str_replace('(', '', $folderName); + $folderName = str_replace(')', '', $folderName); + $folderName = preg_replace('/ {2,}/', ' ', $folderName); + $folderName = preg_replace('/\s/', '_', $folderName); + return $folderName; +} + function isIndirect($path) { return is_file("$path/indirect"); @@ -999,6 +1018,69 @@ private function readMetadata(string $filename): ?string return $this->metadataCache[$filename]; } + + // --------------------------------------------------------------- + // Static factory: create a new stack on disk + // --------------------------------------------------------------- + + /** + * Create a new stack directory with all required files. + * + * Handles folder naming (via sanitizeFolderName + collision avoidance), + * indirect wiring, default compose file creation, metadata files (name, + * description), and override initialization. + * + * Input validation (e.g. allowed indirect path roots) is the caller's + * responsibility — this method only deals with the filesystem structure. + * + * @param string $composeRoot The compose projects root directory + * @param string $stackName Human-readable stack name + * @param string $description Optional description text + * @param string $indirectPath Optional indirect compose source directory + * + * @return self The newly created (and cached) StackInfo instance + * + * @throws \RuntimeException If the stack directory cannot be created + */ + public static function createNew( + string $composeRoot, + string $stackName, + string $description = '', + string $indirectPath = '' + ): self { + $composeRoot = rtrim($composeRoot, '/'); + + // 1. Generate a unique folder name + $folderName = sanitizeFolderName($stackName); + $folder = $composeRoot . '/' . $folderName; + while (is_dir($folder)) { + $folder .= mt_rand(); + } + + // 2. Create the directory + if (!mkdir($folder, 0755, true) && !is_dir($folder)) { + throw new \RuntimeException("Failed to create stack directory: $folder"); + } + + // 3. Create compose / indirect files + if ($indirectPath !== '') { + file_put_contents("$folder/indirect", $indirectPath); + if (!findComposeFile($indirectPath)) { + file_put_contents("$indirectPath/" . COMPOSE_FILE_NAMES[0], "services:\n"); + } + } else { + file_put_contents("$folder/" . COMPOSE_FILE_NAMES[0], "services:\n"); + } + + // 4. Write metadata + file_put_contents("$folder/name", $stackName); + if ($description !== '') { + file_put_contents("$folder/description", $description); + } + + // 5. Build + cache the instance (resolves override, etc.) + return self::fromProject($composeRoot, basename($folder)); + } } diff --git a/tests/unit/StackInfoTest.php b/tests/unit/StackInfoTest.php index 4140836..844f8c8 100644 --- a/tests/unit/StackInfoTest.php +++ b/tests/unit/StackInfoTest.php @@ -488,4 +488,116 @@ public function testBuildComposeArgsWithOverride(): void $this->assertSame(2, substr_count($args['files'], '-f')); $this->assertStringContainsString('compose.override.yaml', $args['files']); } + + // =========================================== + // createNew() Tests + // =========================================== + + public function testCreateNewBasicStack(): void + { + $stack = \StackInfo::createNew($this->tempRoot, 'My Stack'); + + $this->assertInstanceOf(\StackInfo::class, $stack); + $this->assertDirectoryExists($stack->path); + $this->assertSame('My Stack', $stack->getName()); + $this->assertNotNull($stack->composeFilePath); + $this->assertFileExists($stack->composeFilePath); + $this->assertSame("services:\n", file_get_contents($stack->composeFilePath)); + $this->assertFalse($stack->isIndirect); + } + + public function testCreateNewSanitizesFolderName(): void + { + $stack = \StackInfo::createNew($this->tempRoot, 'My "Stack" (v2)'); + + // sanitizeFolderName removes quotes and parens, replaces spaces + $this->assertSame('My_Stack_v2', $stack->project); + $this->assertSame('My "Stack" (v2)', $stack->getName()); + } + + public function testCreateNewWithDescription(): void + { + $stack = \StackInfo::createNew($this->tempRoot, 'Described', 'A test stack'); + + $this->assertSame('A test stack', $stack->getDescription()); + $this->assertFileExists($stack->path . '/description'); + } + + public function testCreateNewWithoutDescription(): void + { + $stack = \StackInfo::createNew($this->tempRoot, 'NoDesc'); + + $this->assertSame('', $stack->getDescription()); + $this->assertFileDoesNotExist($stack->path . '/description'); + } + + public function testCreateNewWithIndirectPath(): void + { + $indirectDir = $this->tempRoot . '/external'; + mkdir($indirectDir, 0755, true); + + $stack = \StackInfo::createNew($this->tempRoot, 'Indirect Stack', '', $indirectDir); + + $this->assertTrue($stack->isIndirect); + $this->assertSame($indirectDir, $stack->composeSource); + $this->assertFileExists($stack->path . '/indirect'); + $this->assertSame($indirectDir, trim(file_get_contents($stack->path . '/indirect'))); + // Should have created compose.yaml at indirect target + $this->assertFileExists($indirectDir . '/compose.yaml'); + } + + public function testCreateNewIndirectExistingComposeFile(): void + { + $indirectDir = $this->tempRoot . '/existing'; + mkdir($indirectDir, 0755, true); + file_put_contents("$indirectDir/compose.yaml", "services:\n web:\n image: nginx\n"); + + $stack = \StackInfo::createNew($this->tempRoot, 'Existing Indirect', '', $indirectDir); + + $this->assertTrue($stack->isIndirect); + // Should NOT overwrite existing compose file + $this->assertSame("services:\n web:\n image: nginx\n", file_get_contents("$indirectDir/compose.yaml")); + } + + public function testCreateNewHandlesFolderCollision(): void + { + // Pre-create the folder that sanitizeFolderName would produce + $existingDir = $this->tempRoot . '/Collide'; + mkdir($existingDir, 0755, true); + + $stack = \StackInfo::createNew($this->tempRoot, 'Collide'); + + // Should have created a different folder (with random suffix) + $this->assertNotSame('Collide', $stack->project); + $this->assertStringStartsWith('Collide', $stack->project); + $this->assertDirectoryExists($stack->path); + } + + public function testCreateNewInitializesOverride(): void + { + $stack = \StackInfo::createNew($this->tempRoot, 'Override Init'); + + $this->assertInstanceOf(\OverrideInfo::class, $stack->overrideInfo); + // Override file should be created for non-indirect stacks + $overridePath = $stack->getOverridePath(); + $this->assertNotNull($overridePath); + $this->assertFileExists($overridePath); + } + + public function testCreateNewIsCached(): void + { + $stack = \StackInfo::createNew($this->tempRoot, 'Cached New'); + + // Fetching via fromProject should return the same cached instance + $fetched = \StackInfo::fromProject($this->tempRoot, $stack->project); + $this->assertSame($stack, $fetched); + } + + public function testCreateNewWritesNameFile(): void + { + $stack = \StackInfo::createNew($this->tempRoot, 'Display Name'); + + $this->assertFileExists($stack->path . '/name'); + $this->assertSame('Display Name', file_get_contents($stack->path . '/name')); + } } From 2bf7583a50cf0b058478eb3748eb69cde52b466e Mon Sep 17 00:00:00 2001 From: Nick Szittai Date: Sun, 8 Mar 2026 10:56:26 -0400 Subject: [PATCH 07/12] fix: improve StackInfo::createNew() folder naming and input validation - Prevent compounded random suffixes on folder name collisions by always appending a single random suffix to the base name - Add safety cap to collision attempts to avoid infinite loops - Validate empty and whitespace stack names, throwing clear exceptions - Add tests for empty name, whitespace name, and collision suffix behavior --- source/compose.manager/php/util.php | 22 ++++++++++++++++++++-- tests/unit/StackInfoTest.php | 28 ++++++++++++++++++++++++++++ 2 files changed, 48 insertions(+), 2 deletions(-) diff --git a/source/compose.manager/php/util.php b/source/compose.manager/php/util.php index b407cae..49c1566 100644 --- a/source/compose.manager/php/util.php +++ b/source/compose.manager/php/util.php @@ -1050,11 +1050,29 @@ public static function createNew( ): self { $composeRoot = rtrim($composeRoot, '/'); + // 0. Validate stack name is not empty + if (trim($stackName) === '') { + throw new \RuntimeException("Stack name cannot be empty."); + } + // 1. Generate a unique folder name $folderName = sanitizeFolderName($stackName); + if ($folderName === '') { + throw new \RuntimeException("Stack name produced an empty folder name after sanitization."); + } $folder = $composeRoot . '/' . $folderName; - while (is_dir($folder)) { - $folder .= mt_rand(); + if (is_dir($folder)) { + // Append a random suffix to the base name to avoid collision; + // re-derive from the clean base each attempt so suffixes don't compound. + $attempts = 0; + do { + $candidate = $composeRoot . '/' . $folderName . mt_rand(); + $attempts++; + if ($attempts > 100) { + throw new \RuntimeException("Unable to find a unique folder name for stack: $stackName"); + } + } while (is_dir($candidate)); + $folder = $candidate; } // 2. Create the directory diff --git a/tests/unit/StackInfoTest.php b/tests/unit/StackInfoTest.php index 844f8c8..6e21fef 100644 --- a/tests/unit/StackInfoTest.php +++ b/tests/unit/StackInfoTest.php @@ -600,4 +600,32 @@ public function testCreateNewWritesNameFile(): void $this->assertFileExists($stack->path . '/name'); $this->assertSame('Display Name', file_get_contents($stack->path . '/name')); } + + public function testCreateNewThrowsOnEmptyName(): void + { + $this->expectException(\RuntimeException::class); + $this->expectExceptionMessage('Stack name cannot be empty'); + \StackInfo::createNew($this->tempRoot, ''); + } + + public function testCreateNewThrowsOnWhitespaceName(): void + { + $this->expectException(\RuntimeException::class); + $this->expectExceptionMessage('Stack name cannot be empty'); + \StackInfo::createNew($this->tempRoot, ' '); + } + + public function testCreateNewCollisionSuffixDoesNotCompound(): void + { + // Pre-create the base folder + $baseName = 'CompoundTest'; + mkdir($this->tempRoot . '/' . $baseName, 0755, true); + + $stack = \StackInfo::createNew($this->tempRoot, $baseName); + + // The project name should be baseName + one random suffix, not compounded + $suffix = substr($stack->project, strlen($baseName)); + // The suffix should be a single numeric string (from one mt_rand call) + $this->assertMatchesRegularExpression('/^\d+$/', $suffix); + } } From 5d3575b55ca651559162cb4c8ae489d15ad382c6 Mon Sep 17 00:00:00 2001 From: Nick Szittai Date: Sun, 8 Mar 2026 23:50:49 -0400 Subject: [PATCH 08/12] feat: enhance container data handling with flexible property access and improved HTML escaping --- .../compose.manager.dashboard.page | 42 ++++++++++++------- 1 file changed, 28 insertions(+), 14 deletions(-) diff --git a/source/compose.manager/compose.manager.dashboard.page b/source/compose.manager/compose.manager.dashboard.page index 71f8ff9..ed88fe9 100644 --- a/source/compose.manager/compose.manager.dashboard.page +++ b/source/compose.manager/compose.manager.dashboard.page @@ -457,24 +457,38 @@ $script = <<<'EOT' if (response.containers && response.containers.length > 0) { var html = ''; response.containers.forEach(function(ct) { - // API returns capitalized property names: State, ID, Icon, Name, WebUI, Image, UpdateStatus, LocalSha, RemoteSha - var isRunning = ct.State === 'running'; + // Tolerate PascalCase/camelCase field variants from different API paths. + var ctName = ct.Name || ct.name || ct.Service || ct.service || 'unknown'; + var ctState = ct.State || ct.state || ''; + var ctIdRaw = ct.ID || ct.Id || ct.id || ctName; + var ctId = String(ctIdRaw || ctName); + var ctIdShort = ctId.substring(0, 12); + var ctIcon = ct.Icon || ct.icon || '/plugins/dynamix.docker.manager/images/question.png'; + var ctShell = ct.Shell || ct.shell || '/bin/bash'; + var ctWebUI = ct.WebUI || ct.webUI || ct.webui || ''; + var ctImage = ct.Image || ct.image || ''; + var ctUpdateStatus = ct.UpdateStatus || ct.updateStatus || 'unknown'; + var ctLocalSha = ct.LocalSha || ct.localSha || ''; + var ctRemoteSha = ct.RemoteSha || ct.remoteSha || ''; + var ctStartedAt = ct.StartedAt || ct.startedAt || ''; + + var isRunning = ctState === 'running'; var stateIcon = isRunning ? 'fa-play' : 'fa-square'; var stateColor = isRunning ? 'green-text' : 'red-text'; var stateText = isRunning ? 'started' : 'stopped'; - var ctElId = 'dash-ct-' + ct.ID.substring(0, 12); - var imgSrc = ct.Icon || '/plugins/dynamix.docker.manager/images/question.png'; - var shell = ct.Shell || '/bin/bash'; - var shortId = ct.ID.substring(0, 12); - var webui = resolveContainerWebUI(ct.WebUI); + var ctElId = 'dash-ct-' + ctIdShort; + var imgSrc = ctIcon; + var shell = ctShell; + var shortId = ctIdShort; + var webui = resolveContainerWebUI(ctWebUI); // Parse image to get repo and tag - var image = ct.Image || ''; + var image = ctImage; var imageDisplay = image.replace(/^.*\//, ''); // Remove registry prefix // Update status - var updateStatus = ct.UpdateStatus || 'unknown'; - var localSha = ct.LocalSha || ''; - var remoteSha = ct.RemoteSha || ''; + var updateStatus = ctUpdateStatus; + var localSha = ctLocalSha; + var remoteSha = ctRemoteSha; var updateHtml = ''; if (updateStatus === 'up-to-date') { updateHtml = ' current'; @@ -485,14 +499,14 @@ $script = <<<'EOT' } // Container uptime - use shared smart formatting - var ctUptime = formatUptime(ct.StartedAt, isRunning); + var ctUptime = formatUptime(ctStartedAt, isRunning); html += '
'; - html += ''; + html += ''; html += ''; html += ''; html += ''; - html += '
' + ct.Name + '
'; + html += '
' + escapeHtml(ctName) + '
'; html += '
' + stateText + '
'; html += '
'; html += ''; From d9f70f57f32e9f1992a9ea046195dc1e381aece4 Mon Sep 17 00:00:00 2001 From: Nick Szittai Date: Mon, 9 Mar 2026 09:02:57 -0400 Subject: [PATCH 09/12] fix: ensure container ID is a string before substring operation --- source/compose.manager/php/compose_manager_main.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/source/compose.manager/php/compose_manager_main.php b/source/compose.manager/php/compose_manager_main.php index ca4db1e..7eb0aaf 100755 --- a/source/compose.manager/php/compose_manager_main.php +++ b/source/compose.manager/php/compose_manager_main.php @@ -3799,7 +3799,7 @@ function renderContainerDetails(stackId, containers, project) { var imageSource = imageParts[0] || ''; // Image name without tag var imageTag = (imageParts[1] || 'latest') + digestSuffix; // Include digest suffix if present var state = container.state || 'unknown'; - var containerId = (container.id || containerName).substring(0, 12); + var containerId = String(container.id || containerName || '').substring(0, 12); var uniqueId = 'ct-' + stackId + '-' + idx; // Status like Docker tab From c8aac6f32984db22f648bc29e5fcd2b63931d78f Mon Sep 17 00:00:00 2001 From: Nick Szittai Date: Tue, 10 Mar 2026 00:08:27 -0400 Subject: [PATCH 10/12] fix: restore container discovery and pinned digest display after class refactor --- .../compose.manager/php/compose_manager_main.php | 4 ++-- source/compose.manager/php/exec.php | 1 + source/compose.manager/php/util.php | 6 ++++++ tests/unit/ContainerInfoTest.php | 14 ++++++++++++-- 4 files changed, 21 insertions(+), 4 deletions(-) diff --git a/source/compose.manager/php/compose_manager_main.php b/source/compose.manager/php/compose_manager_main.php index 7eb0aaf..0b9bc6f 100755 --- a/source/compose.manager/php/compose_manager_main.php +++ b/source/compose.manager/php/compose_manager_main.php @@ -195,7 +195,7 @@ function createContainerInfo(raw) { ports: raw.ports || raw.Ports || '', networks: raw.networks || raw.Networks || '', volumes: raw.volumes || raw.Volumes || '', - id: raw.id || raw.Id || '', + id: raw.id || raw.Id || raw.ID || '', created: raw.created || raw.Created || '', startedAt: raw.startedAt || raw.StartedAt || '' }; @@ -3879,7 +3879,7 @@ function renderContainerDetails(stackId, containers, project) { // Image is pinned with SHA256 digest - show pinned status html += ' pinned'; if (ctPinnedDigest) { - html += '
' + escapeHtml(ctPinnedDigest) + '
'; + html += '
' + escapeHtml(ctPinnedDigest.substring(0, 12)) + '
'; } } else if (ctHasUpdate) { // Update available - orange "update ready" style with SHA diff diff --git a/source/compose.manager/php/exec.php b/source/compose.manager/php/exec.php index 6f18116..b807803 100644 --- a/source/compose.manager/php/exec.php +++ b/source/compose.manager/php/exec.php @@ -548,6 +548,7 @@ function getPostScript(): string $inspect = json_decode($inspectOutput, true); if ($inspect) { // Extract useful info from inspect + $rawContainer['ID'] = $inspect['Id'] ?? ''; $rawContainer['Image'] = $inspect['Config']['Image'] ?? ''; $rawContainer['Created'] = $inspect['Created'] ?? ''; $rawContainer['StartedAt'] = $inspect['State']['StartedAt'] ?? ''; diff --git a/source/compose.manager/php/util.php b/source/compose.manager/php/util.php index 49c1566..4a6d88b 100644 --- a/source/compose.manager/php/util.php +++ b/source/compose.manager/php/util.php @@ -452,6 +452,8 @@ class ContainerInfo { /** @var string Canonical container name (from Name/Service/container) */ public string $name = ''; + /** @var string Docker container ID */ + public string $id = ''; /** @var string Compose service name */ public string $service = ''; /** @var string Full image reference (e.g. library/nginx:latest) */ @@ -504,6 +506,7 @@ public static function fromDockerInspect(array $raw): self { $info = new self(); $info->name = $raw['Name'] ?? $raw['name'] ?? ''; + $info->id = $raw['ID'] ?? $raw['Id'] ?? $raw['id'] ?? ''; $info->service = $raw['Service'] ?? $raw['service'] ?? ''; $info->image = $raw['Image'] ?? $raw['image'] ?? ''; $info->state = strtolower($raw['State'] ?? $raw['state'] ?? ''); @@ -543,6 +546,7 @@ public static function fromUpdateResponse(array $raw): self { $info = new self(); $info->name = $raw['container'] ?? $raw['name'] ?? $raw['Name'] ?? ''; + $info->id = $raw['ID'] ?? $raw['Id'] ?? $raw['id'] ?? ''; $info->service = $raw['service'] ?? $raw['Service'] ?? ''; $info->image = $raw['image'] ?? $raw['Image'] ?? ''; $info->hasUpdate = $raw['hasUpdate'] ?? false; @@ -590,6 +594,7 @@ public function toArray(): array { return [ 'name' => $this->name, + 'id' => $this->id, 'service' => $this->service, 'image' => $this->image, 'state' => $this->state, @@ -626,6 +631,7 @@ public function toUpdateArray(): array 'localSha' => $this->localSha, 'remoteSha' => $this->remoteSha, 'isPinned' => $this->isPinned, + 'pinnedDigest' => $this->pinnedDigest, ]; } diff --git a/tests/unit/ContainerInfoTest.php b/tests/unit/ContainerInfoTest.php index 18db111..b10d900 100644 --- a/tests/unit/ContainerInfoTest.php +++ b/tests/unit/ContainerInfoTest.php @@ -18,6 +18,7 @@ public function testFromDockerInspectPascalCaseKeys(): void { $raw = [ 'Name' => 'my-container', + 'ID' => 'abc123def4567890', 'Service' => 'web', 'Image' => 'nginx:latest', 'State' => 'running', @@ -34,6 +35,7 @@ public function testFromDockerInspectPascalCaseKeys(): void $info = \ContainerInfo::fromDockerInspect($raw); $this->assertSame('my-container', $info->name); + $this->assertSame('abc123def4567890', $info->id); $this->assertSame('web', $info->service); $this->assertSame('nginx:latest', $info->image); $this->assertSame('running', $info->state); @@ -52,6 +54,7 @@ public function testFromDockerInspectCamelCaseKeys(): void { $raw = [ 'name' => 'my-container', + 'id' => 'ffeeddccbbaa9988', 'service' => 'api', 'image' => 'node:18', 'state' => 'exited', @@ -60,6 +63,7 @@ public function testFromDockerInspectCamelCaseKeys(): void $info = \ContainerInfo::fromDockerInspect($raw); $this->assertSame('my-container', $info->name); + $this->assertSame('ffeeddccbbaa9988', $info->id); $this->assertSame('api', $info->service); $this->assertSame('node:18', $info->image); $this->assertSame('exited', $info->state); @@ -147,6 +151,7 @@ public function testFromUpdateResponseBasicFields(): void { $raw = [ 'container' => 'web-container', + 'id' => '0011223344556677', 'image' => 'nginx:alpine', 'hasUpdate' => true, 'status' => 'update-available', @@ -157,6 +162,7 @@ public function testFromUpdateResponseBasicFields(): void $info = \ContainerInfo::fromUpdateResponse($raw); $this->assertSame('web-container', $info->name); + $this->assertSame('0011223344556677', $info->id); $this->assertSame('nginx:alpine', $info->image); $this->assertTrue($info->hasUpdate); $this->assertSame('update-available', $info->updateStatus); @@ -246,6 +252,7 @@ public function testToArrayProducesConsistentCamelCase(): void // All keys should be camelCase $this->assertArrayHasKey('name', $arr); + $this->assertArrayHasKey('id', $arr); $this->assertArrayHasKey('service', $arr); $this->assertArrayHasKey('image', $arr); $this->assertArrayHasKey('state', $arr); @@ -275,6 +282,7 @@ public function testToArrayValues(): void { $info = \ContainerInfo::fromDockerInspect([ 'Name' => 'web', + 'ID' => '1234567890abcdef', 'State' => 'running', 'Image' => 'nginx:latest', ]); @@ -282,6 +290,7 @@ public function testToArrayValues(): void $arr = $info->toArray(); $this->assertSame('web', $arr['name']); + $this->assertSame('1234567890abcdef', $arr['id']); $this->assertSame('running', $arr['state']); $this->assertTrue($arr['isRunning']); $this->assertSame('nginx:latest', $arr['image']); @@ -295,15 +304,16 @@ public function testToUpdateArrayContainsOnlyUpdateFields(): void { $info = \ContainerInfo::fromDockerInspect([ 'Name' => 'test', - 'Image' => 'nginx:latest', + 'Image' => 'nginx@sha256:abcdef1234567890abcdef1234567890', 'State' => 'running', 'Icon' => 'icon.png', ]); $arr = $info->toUpdateArray(); - $expected = ['name', 'image', 'hasUpdate', 'updateStatus', 'localSha', 'remoteSha', 'isPinned']; + $expected = ['name', 'image', 'hasUpdate', 'updateStatus', 'localSha', 'remoteSha', 'isPinned', 'pinnedDigest']; $this->assertSame($expected, array_keys($arr)); + $this->assertSame('abcdef1234567890abcdef1234567890', $arr['pinnedDigest']); // Should NOT contain non-update fields $this->assertArrayNotHasKey('state', $arr); From 3e8ecc9a3053d4909e4d55f93a89ff18eb5a0122 Mon Sep 17 00:00:00 2001 From: Nick Szittai Date: Tue, 10 Mar 2026 14:10:17 -0400 Subject: [PATCH 11/12] fix: address Copilot PR review comments - sanitizeFolderName: block path traversal (/, \, ..) - StackInfo::createNew: add defense-in-depth path validation - Fix error message trailing period mismatch with tests - Use COMPOSE_FILE_NAMES[0] instead of hard-coded compose.yaml - Sanitize exception messages returned to client (hide filesystem paths) --- source/compose.manager/php/exec.php | 11 ++++++++++- source/compose.manager/php/util.php | 22 +++++++++++++++++++--- 2 files changed, 29 insertions(+), 4 deletions(-) diff --git a/source/compose.manager/php/exec.php b/source/compose.manager/php/exec.php index b807803..4e245b7 100644 --- a/source/compose.manager/php/exec.php +++ b/source/compose.manager/php/exec.php @@ -56,7 +56,16 @@ function getPostScript(): string $stack = StackInfo::createNew($compose_root, $stackName, $stackDesc, $indirect); } catch (\RuntimeException $e) { clientDebug('[stack] Failed to create stack: ' . $e->getMessage(), null, 'daemon', 'error'); - echo json_encode(['result' => 'error', 'message' => $e->getMessage()]); + // Return user-safe messages; avoid exposing filesystem paths + $userMessage = match (true) { + str_contains($e->getMessage(), 'cannot be empty') => 'Stack name cannot be empty.', + str_contains($e->getMessage(), 'empty folder name') => 'Invalid stack name.', + str_contains($e->getMessage(), 'unique folder name') => 'Could not create a unique folder for this stack.', + str_contains($e->getMessage(), 'escape compose root') => 'Invalid stack name.', + str_contains($e->getMessage(), 'Invalid compose root') => 'Server configuration error.', + default => 'Failed to create stack. Check server logs for details.', + }; + echo json_encode(['result' => 'error', 'message' => $userMessage]); break; } diff --git a/source/compose.manager/php/util.php b/source/compose.manager/php/util.php index 4a6d88b..2b48e36 100644 --- a/source/compose.manager/php/util.php +++ b/source/compose.manager/php/util.php @@ -59,7 +59,9 @@ function sanitizeStr($a) /** * Sanitize a stack name to create a safe folder name. - * Removes special characters that could cause issues in paths. + * Removes special characters that could cause issues in paths, + * including path separators and traversal sequences to prevent + * directory escape attacks. * * @param string $stackName The stack name to sanitize * @return string The sanitized folder name @@ -71,6 +73,8 @@ function sanitizeFolderName(string $stackName): string $folderName = str_replace('&', '', $folderName); $folderName = str_replace('(', '', $folderName); $folderName = str_replace(')', '', $folderName); + // Remove path separators and traversal sequences + $folderName = str_replace(['/', '\\', '..'], '', $folderName); $folderName = preg_replace('/ {2,}/', ' ', $folderName); $folderName = preg_replace('/\s/', '_', $folderName); return $folderName; @@ -976,7 +980,7 @@ private function getMainFileServices(): array */ public function buildComposeArgs(): array { - $composeFile = $this->composeFilePath ?? ($this->composeSource . '/compose.yaml'); + $composeFile = $this->composeFilePath ?? ($this->composeSource . '/' . COMPOSE_FILE_NAMES[0]); $files = "-f " . escapeshellarg($composeFile); @@ -1058,7 +1062,7 @@ public static function createNew( // 0. Validate stack name is not empty if (trim($stackName) === '') { - throw new \RuntimeException("Stack name cannot be empty."); + throw new \RuntimeException("Stack name cannot be empty"); } // 1. Generate a unique folder name @@ -1067,6 +1071,18 @@ public static function createNew( throw new \RuntimeException("Stack name produced an empty folder name after sanitization."); } $folder = $composeRoot . '/' . $folderName; + + // Verify the resolved path stays within composeRoot (defense-in-depth) + $realComposeRoot = realpath($composeRoot); + if ($realComposeRoot === false) { + throw new \RuntimeException("Invalid compose root directory."); + } + // For new folders, check that the parent resolves correctly + $resolvedParent = realpath(dirname($folder)); + if ($resolvedParent === false || strpos($resolvedParent, $realComposeRoot) !== 0) { + throw new \RuntimeException("Invalid stack name: path would escape compose root."); + } + if (is_dir($folder)) { // Append a random suffix to the base name to avoid collision; // re-derive from the clean base each attempt so suffixes don't compound. From 0ca9cc2bd92d8ce4729d743a9e417a922d75421c Mon Sep 17 00:00:00 2001 From: Nick Szittai Date: Tue, 10 Mar 2026 14:25:36 -0400 Subject: [PATCH 12/12] fix: update plugin URL to point to the dev branch for consistency --- compose.manager.plg | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/compose.manager.plg b/compose.manager.plg index 63424c0..3d7c25f 100644 --- a/compose.manager.plg +++ b/compose.manager.plg @@ -9,7 +9,7 @@ - +