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 += ''; diff --git a/source/compose.manager/php/compose_list.php b/source/compose.manager/php/compose_list.php index b8695dc..adb59d9 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"; @@ -210,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'; @@ -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..0b9bc6f 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 || '/bin/bash', + 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 || 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 = {}; @@ -1045,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; @@ -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,10 @@ 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(createContainerInfo).filter(Boolean); // 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 +2392,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; } }); }); @@ -2422,13 +2507,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'); @@ -2438,8 +2523,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 @@ -3619,31 +3704,11 @@ 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(createContainerInfo).filter(Boolean); // 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; @@ -3710,9 +3775,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... @@ -3733,8 +3798,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 = String(container.id || containerName || '').substring(0, 12); var uniqueId = 'ct-' + stackId + '-' + idx; // Status like Docker tab @@ -3746,8 +3811,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 || '-'); }); @@ -3760,8 +3825,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) { @@ -3778,8 +3843,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 = ''; } @@ -3788,11 +3853,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 += ''; @@ -3814,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 @@ -3937,29 +4002,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 +4022,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..b807803 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 - OverrideInfo::fromStack($compose_root, $stackName); - - // 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'])) : ""; @@ -197,7 +163,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 +201,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"; @@ -524,8 +490,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 +523,35 @@ 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['ID'] = $inspect['Id'] ?? ''; + $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 +578,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 +590,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 +606,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 +632,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 +654,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 +666,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 +690,8 @@ function getPostScript(): string } } } - $containers[] = $container; + // Normalize through ContainerInfo for consistent camelCase output + $containers[] = ContainerInfo::fromDockerInspect($rawContainer)->toArray(); } } } @@ -767,8 +730,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 +814,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 +901,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 +986,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..6774c86 100644 --- a/source/compose.manager/php/exec_functions.php +++ b/source/compose.manager/php/exec_functions.php @@ -45,26 +45,31 @@ 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; + } } /** * 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 +77,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..4a6d88b 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"); @@ -113,6 +132,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,80 +280,109 @@ class OverrideInfo * @var bool True if indirect contains legacy-named override but not correctly-named one */ public bool $mismatchIndirectLegacy = false; - /** - * @var string Compose root directory + * @var string|null Resolved path to the main compose file */ - private string $composeRoot; + public ?string $composeFilePath = null; + + 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. - * @param string $stack - * @return void + * Core override resolution logic shared by both factories. + * + * 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); - $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; } /** @@ -221,17 +395,717 @@ public function getOverridePath(): ?string } /** - * Get the project path for a stack - * @param string $stack - * @return string + * Prune orphaned services from the override file. + * + * 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[] $validServices Service names that are defined in the main compose file + * @return array{changed: bool, removed: string[]} */ - private function getProjectPath(string $stack): string + public function pruneOrphanServices(array $validServices): array { - return $this->composeRoot . '/' . $stack; + $overridePath = $this->getOverridePath(); + if ($overridePath === null || $overridePath === '' || !is_file($overridePath)) { + return ['changed' => false, 'removed' => []]; + } + + if (empty($validServices)) { + return ['changed' => false, 'removed' => []]; + } + + $overrideContent = file_get_contents($overridePath); + if ($overrideContent === false || $overrideContent === '') { + return ['changed' => false, 'removed' => []]; + } + + $result = pruneOverrideContentServices($overrideContent, $validServices); + 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]; + } +} + +/** + * 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 Docker container ID */ + public string $id = ''; + /** @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->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'] ?? ''); + $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->id = $raw['ID'] ?? $raw['Id'] ?? $raw['id'] ?? ''; + $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, + 'id' => $this->id, + '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, + 'pinnedDigest' => $this->pinnedDigest, + ]; + } + + /** + * 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 = []; + + /** @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 + */ + 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 using pre-resolved identity (no duplicate I/O) + $this->overrideInfo = OverrideInfo::fromStackInfo($this); + } + /** + * 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 + { + $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 = []; + } + } + + // --------------------------------------------------------------- + // 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. + * + * 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 + { + $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 !== ''; + })); + } + + /** + * 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]; + } + + // --------------------------------------------------------------- + // 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, '/'); + + // 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; + 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 + 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/ContainerInfoTest.php b/tests/unit/ContainerInfoTest.php new file mode 100644 index 0000000..b10d900 --- /dev/null +++ b/tests/unit/ContainerInfoTest.php @@ -0,0 +1,351 @@ + 'my-container', + 'ID' => 'abc123def4567890', + '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('abc123def4567890', $info->id); + $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', + 'id' => 'ffeeddccbbaa9988', + 'service' => 'api', + 'image' => 'node:18', + 'state' => 'exited', + ]; + + $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); + $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', + 'id' => '0011223344556677', + 'image' => 'nginx:alpine', + 'hasUpdate' => true, + 'status' => 'update-available', + 'localSha' => 'aaa111', + 'remoteSha' => 'bbb222', + ]; + + $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); + $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('id', $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', + 'ID' => '1234567890abcdef', + 'State' => 'running', + 'Image' => 'nginx:latest', + ]); + + $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']); + } + + // =========================================== + // toUpdateArray Tests + // =========================================== + + public function testToUpdateArrayContainsOnlyUpdateFields(): void + { + $info = \ContainerInfo::fromDockerInspect([ + 'Name' => 'test', + 'Image' => 'nginx@sha256:abcdef1234567890abcdef1234567890', + 'State' => 'running', + 'Icon' => 'icon.png', + ]); + + $arr = $info->toUpdateArray(); + + $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); + $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/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 7cdc50f..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(); } @@ -137,4 +138,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); + } + // Just test via the public API by removing the override file + $result = $info->pruneOrphanServices(['web']); + $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); + + // Pass valid services — 'web' is valid, 'deleted-svc' is orphaned + $result = $info->pruneOrphanServices(['web']); + + $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); + + // Both services are valid + $result = $info->pruneOrphanServices(['web', 'api']); + + $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..6e21fef --- /dev/null +++ b/tests/unit/StackInfoTest.php @@ -0,0 +1,631 @@ +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()); + } + + 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 + // =========================================== + + 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']); + } + + // =========================================== + // 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')); + } + + 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); + } +} 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 // ===========================================