';
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
// ===========================================
|