From bc8563181317650b92e48a641d58873900357aa2 Mon Sep 17 00:00:00 2001 From: mstrhakr Date: Tue, 1 Sep 2026 13:46:23 -0400 Subject: [PATCH 1/5] fix(identity): add fail-closed legacy project identity resolver Add ProjectIdentity resolver that compares folder-derived and legacy name-derived candidates against live compose project label ownership across containers, volumes, and networks. Persist resolved runtime identity to project_name metadata and wire StackInfo to always use that centralized pinned identity for docker compose -p. Fail closed on ambiguous or unprobeable identity and add explicit owner-choice support via StackInfo. --- .../include/ProjectIdentity.php | 388 ++++++++++++++++++ source/compose.manager/include/Util.php | 49 +++ 2 files changed, 437 insertions(+) create mode 100644 source/compose.manager/include/ProjectIdentity.php diff --git a/source/compose.manager/include/ProjectIdentity.php b/source/compose.manager/include/ProjectIdentity.php new file mode 100644 index 0000000..018fa97 --- /dev/null +++ b/source/compose.manager/include/ProjectIdentity.php @@ -0,0 +1,388 @@ + Per-candidate Docker resource counts */ + public array $evidence = []; + + /** @var callable|null Test/injection hook replacing the live Docker probe */ + private static $probeOverride = null; + + /** @var array|null Cached probe result for this request */ + private static ?array $probeCache = null; + + /** @var bool Whether $probeCache holds a completed probe (null result means Docker was unreachable) */ + private static bool $probeCached = false; + + private function __construct() + { + $this->projectFolder = ''; + $this->folderCandidate = ''; + $this->legacyCandidate = ''; + $this->projectName = ''; + $this->source = self::SOURCE_UNRESOLVED; + $this->resolved = false; + } + + /** + * Replace the live Docker probe (tests, or callers with pre-fetched data). + * + * The callable must return a map of project name => resource counts, or + * null when Docker could not be queried at all. + * + * @param callable|null $probe + */ + public static function setProbe(?callable $probe): void + { + self::$probeOverride = $probe; + self::clearProbeCache(); + } + + /** Drop the memoized probe result (call after Docker state changes). */ + public static function clearProbeCache(): void + { + self::$probeCache = null; + self::$probeCached = false; + } + + /** + * Resolve the effective runtime project identity for a stack. + * + * Pins the result to `project_name` whenever it can be proven, so the + * Docker probe only ever runs once per stack. + * + * @param string $stackPath Absolute path to the stack directory + * @param string $projectFolder Stack directory basename + * @param string|null $displayName Raw contents of the `name` metadata file + */ + public static function resolve(string $stackPath, string $projectFolder, ?string $displayName): self + { + $identity = new self(); + $identity->projectFolder = $projectFolder; + $identity->folderCandidate = compose_manager_sanitize_project_name($projectFolder); + + $rawLegacy = ($displayName === null) ? '' : trim($displayName); + $identity->legacyCandidate = ($rawLegacy === '') + ? $identity->folderCandidate + : compose_manager_sanitize_project_name($rawLegacy); + + $pinned = self::readPinned($stackPath); + if ($pinned !== null) { + $identity->projectName = $pinned; + $identity->source = self::SOURCE_PERSISTED; + $identity->resolved = true; + return $identity; + } + + // Candidates agree: the Plus identity is already the legacy identity. + if ($identity->legacyCandidate === $identity->folderCandidate) { + $identity->projectName = $identity->folderCandidate; + $identity->source = self::SOURCE_CANONICAL; + $identity->resolved = true; + self::pin($stackPath, $identity->projectName); + return $identity; + } + + $probe = self::probe(); + if ($probe === null) { + // Docker is unreachable: "no containers found" would be a lie, so + // refuse to pin anything and block mutations until we can prove it. + $identity->projectName = $identity->folderCandidate; + $identity->source = self::SOURCE_UNRESOLVED; + $identity->conflictReason = self::CONFLICT_PROBE_FAILED; + $identity->resolved = false; + return $identity; + } + + $identity->evidence = [ + $identity->folderCandidate => self::countsFor($probe, $identity->folderCandidate), + $identity->legacyCandidate => self::countsFor($probe, $identity->legacyCandidate), + ]; + + $folderOwns = self::hasResources($identity->evidence[$identity->folderCandidate]); + $legacyOwns = self::hasResources($identity->evidence[$identity->legacyCandidate]); + + if ($folderOwns && $legacyOwns) { + $identity->projectName = $identity->folderCandidate; + $identity->source = self::SOURCE_UNRESOLVED; + $identity->conflictReason = self::CONFLICT_AMBIGUOUS; + $identity->resolved = false; + composeLogger( + "Ambiguous compose project identity for '$projectFolder'; mutating actions are blocked until an identity is chosen", + ['folderCandidate' => $identity->folderCandidate, 'legacyCandidate' => $identity->legacyCandidate, 'evidence' => $identity->evidence], + 'user', + 'warning', + 'identity' + ); + return $identity; + } + + if ($legacyOwns) { + $identity->projectName = $identity->legacyCandidate; + $identity->source = self::SOURCE_LEGACY_RUNTIME; + } elseif ($folderOwns) { + $identity->projectName = $identity->folderCandidate; + $identity->source = self::SOURCE_FOLDER_RUNTIME; + } else { + $identity->projectName = $identity->folderCandidate; + $identity->source = self::SOURCE_UNUSED; + } + + $identity->resolved = true; + self::pin($stackPath, $identity->projectName); + composeLogger( + "Pinned compose project identity '{$identity->projectName}' for '$projectFolder' ({$identity->source})", + ['folderCandidate' => $identity->folderCandidate, 'legacyCandidate' => $identity->legacyCandidate, 'evidence' => $identity->evidence], + 'user', + 'info', + 'identity' + ); + + return $identity; + } + + /** + * Apply an explicit owner decision and pin it. + * + * @param string $stackPath Absolute path to the stack directory + * @param string $choice Must be one of this identity's candidates + * + * @throws \RuntimeException When the choice is not a candidate or cannot be written + */ + public function chooseIdentity(string $stackPath, string $choice): void + { + $choice = compose_manager_sanitize_project_name(trim($choice)); + if ($choice !== $this->folderCandidate && $choice !== $this->legacyCandidate) { + throw new \RuntimeException("Project identity '$choice' is not a candidate for '$this->projectFolder'"); + } + if (!self::pin($stackPath, $choice)) { + throw new \RuntimeException("Failed to persist project identity for '$this->projectFolder'"); + } + + $this->projectName = $choice; + $this->source = self::SOURCE_PERSISTED; + $this->resolved = true; + $this->conflictReason = null; + } + + /** + * Human-readable summary for the migration preview and UI warnings. + * + * @return array + */ + public function toArray(): array + { + return [ + 'projectFolder' => $this->projectFolder, + 'projectName' => $this->projectName, + 'folderCandidate' => $this->folderCandidate, + 'legacyCandidate' => $this->legacyCandidate, + 'source' => $this->source, + 'resolved' => $this->resolved, + 'conflictReason' => $this->conflictReason, + 'evidence' => $this->evidence, + 'message' => $this->getMessage(), + ]; + } + + /** Explain the current state in words suitable for the WebGUI. */ + public function getMessage(): string + { + switch ($this->conflictReason) { + case self::CONFLICT_AMBIGUOUS: + return "Both '$this->folderCandidate' and '$this->legacyCandidate' own live Docker resources. " + . 'Choose which one this stack should use before running any action.'; + case self::CONFLICT_PROBE_FAILED: + return 'Docker could not be queried, so the runtime project name of this imported stack cannot be verified. ' + . 'Actions are blocked until Docker is reachable.'; + } + + if ($this->source === self::SOURCE_LEGACY_RUNTIME) { + return "Preserved the imported runtime project name '$this->projectName'."; + } + + return "Using project name '$this->projectName'."; + } + + // --------------------------------------------------------------- + // Internals + // --------------------------------------------------------------- + + /** + * Read and sanitize the pinned identity, if any. + */ + private static function readPinned(string $stackPath): ?string + { + $file = rtrim($stackPath, '/') . '/' . self::METADATA_FILE; + if (!is_file($file)) { + return null; + } + $raw = @file_get_contents($file); + if ($raw === false || trim($raw) === '') { + return null; + } + return compose_manager_sanitize_project_name(trim($raw)); + } + + /** + * Persist the resolved identity so it is never recomputed. + */ + private static function pin(string $stackPath, string $projectName): bool + { + $file = rtrim($stackPath, '/') . '/' . self::METADATA_FILE; + return @file_put_contents($file, $projectName) !== false; + } + + /** + * @param array $probe + * @return array{containers: int, volumes: int, networks: int} + */ + private static function countsFor(array $probe, string $candidate): array + { + return $probe[$candidate] ?? ['containers' => 0, 'volumes' => 0, 'networks' => 0]; + } + + /** + * @param array{containers: int, volumes: int, networks: int} $counts + */ + private static function hasResources(array $counts): bool + { + return ($counts['containers'] + $counts['volumes'] + $counts['networks']) > 0; + } + + /** + * Collect `com.docker.compose.project` ownership across containers, volumes + * and networks. + * + * Volumes and networks matter as much as containers: a stack that was taken + * down still owns named volumes, and re-creating it under a different + * project name would silently hand it empty storage. + * + * @return array|null Null when Docker could not be queried + */ + public static function probe(): ?array + { + if (self::$probeOverride !== null) { + $result = (self::$probeOverride)(); + return is_array($result) ? $result : null; + } + + if (self::$probeCached) { + return self::$probeCache; + } + + self::$probeCached = true; + self::$probeCache = null; + + $label = 'com.docker.compose.project'; + $cmd = 'docker ps -a --filter label=' . $label . ' --format "containers {{.Labels}}" 2>/dev/null' + . ' && docker volume ls --filter label=' . $label . ' --format "volumes {{.Labels}}" 2>/dev/null' + . ' && docker network ls --filter label=' . $label . ' --format "networks {{.Labels}}" 2>/dev/null' + . '; printf "__rc=%s" "$?"'; + + $output = shell_exec($cmd); + $counts = self::parseProbeOutput(is_string($output) ? $output : null); + if ($counts === null) { + composeLogger('Docker project-label probe failed; legacy identity migration deferred', ['output' => $output], 'user', 'warning', 'identity'); + return null; + } + + self::$probeCache = $counts; + return $counts; + } + + /** + * Parse the tagged ` ` probe output into per-project counts. + * + * @return array|null Null when the probe did not report success + */ + public static function parseProbeOutput(?string $output): ?array + { + if ($output === null || !preg_match('/__rc=(\d+)\s*$/', $output, $rc) || $rc[1] !== '0') { + return null; + } + + $counts = []; + foreach (explode("\n", $output) as $line) { + if (!preg_match('/^(containers|volumes|networks)\s+(.*)$/', trim($line), $m)) { + continue; + } + if (!preg_match('/com\.docker\.compose\.project=([^,]+)/', $m[2], $p)) { + continue; + } + $project = trim($p[1]); + if ($project === '') { + continue; + } + if (!isset($counts[$project])) { + $counts[$project] = ['containers' => 0, 'volumes' => 0, 'networks' => 0]; + } + $counts[$project][$m[1]]++; + } + + return $counts; + } +} diff --git a/source/compose.manager/include/Util.php b/source/compose.manager/include/Util.php index 2aacac8..99d531f 100644 --- a/source/compose.manager/include/Util.php +++ b/source/compose.manager/include/Util.php @@ -1,6 +1,7 @@ displayName = $this->getDisplayName(); + // Stacks imported from the original compose plugin may still run under a + // `name`-derived project identity; resolve (and pin) it before anything + // builds a `docker compose -p` argument from $this->projectName. + $this->identity = ProjectIdentity::resolve($this->path, $this->projectFolder, $this->displayName); + $this->projectName = $this->identity->projectName; + // Resolve indirect path and compose source (indirect if present, else direct) $this->isIndirect = $this->isIndirect(); $this->indirectPath = $this->readMetadata('indirect'); @@ -2423,6 +2432,39 @@ public function getName(): string return $this->displayName; } + /** + * Whether the effective `docker compose -p` identity has been proven. + * + * Returns false for imported stacks whose runtime project name is still + * ambiguous. Callers that mutate Docker state must refuse to run. + */ + public function hasResolvedIdentity(): bool + { + return $this->identity->resolved; + } + + /** + * Reason the stack is blocked from mutating actions, or null when it is not. + */ + public function getIdentityBlockReason(): ?string + { + return $this->identity->resolved ? null : $this->identity->getMessage(); + } + + /** + * Pin an owner-selected runtime project identity. + * + * @param string $projectName One of the identity's candidates + * @throws \RuntimeException When the choice is not a candidate + */ + public function applyIdentityChoice(string $projectName): void + { + $this->identity->chooseIdentity($this->path, $projectName); + $this->projectName = $this->identity->projectName; + $this->cachedContainerList = null; + $this->cachedContainerCounts = null; + } + /** * Get the stack description. * @return string @@ -3646,6 +3688,13 @@ public static function createNew( // Write metadata file_put_contents("$path/name", $projectName); + // Pin the runtime identity up front. Without this a collision-suffixed + // folder (my-stack-001) would look like a legacy stack whose display + // name resolves to an existing project (my-stack) and could adopt it. + file_put_contents( + $path . '/' . ProjectIdentity::METADATA_FILE, + self::sanitizeProjectString(basename($path)) + ); if ($description !== '') { file_put_contents("$path/description", $description); } From 1117c185d2f9a0eeb82c6bd2c59c53f7abf85388 Mon Sep 17 00:00:00 2001 From: mstrhakr Date: Tue, 1 Sep 2026 13:46:31 -0400 Subject: [PATCH 2/5] fix(identity): block mutating actions until project identity is proven Enforce resolved project identity across compose action builders, single and multi-stack command launchers, manual and scheduled auto-update paths, and event/script consumers via compose_args. Add read-only identity preview endpoint plus explicit identity selection endpoint for owner-driven conflict resolution. Surface blocked stacks in list/UI with warning indicator and chooser modal when actions hit ambiguous identity. --- source/compose.manager/include/AutoUpdate.php | 12 +++ .../include/AutoUpdateRunner.php | 10 +++ .../include/ComposeCommandBuilder.php | 17 ++++ .../compose.manager/include/ComposeList.php | 9 +- source/compose.manager/include/Exec.php | 40 +++++++++ source/compose.manager/include/Helpers.php | 50 ++++++++++- .../javascript/composeManagerMain.js | 86 +++++++++++++++++++ 7 files changed, 222 insertions(+), 2 deletions(-) diff --git a/source/compose.manager/include/AutoUpdate.php b/source/compose.manager/include/AutoUpdate.php index dcda183..103c05c 100644 --- a/source/compose.manager/include/AutoUpdate.php +++ b/source/compose.manager/include/AutoUpdate.php @@ -114,6 +114,18 @@ // Resolve project name - always use compose-safe sanitized projectName. $stackInfo = StackInfo::fromComposePath($compose_root, $path); if ($stackInfo !== null) { + if (!$stackInfo->hasResolvedIdentity()) { + composeLogger( + "Blocked manual auto-update for '{$stackInfo->projectFolder}': compose project identity is unresolved", + ['identity' => $stackInfo->identity->toArray()], + 'user', + 'warning', + 'identity' + ); + http_response_code(409); + echo json_encode(array('error' => $stackInfo->getIdentityBlockReason())); + break; + } $projectName = $stackInfo->projectName; } else { $projectName = basename($path); diff --git a/source/compose.manager/include/AutoUpdateRunner.php b/source/compose.manager/include/AutoUpdateRunner.php index a6e2ec5..f817f63 100644 --- a/source/compose.manager/include/AutoUpdateRunner.php +++ b/source/compose.manager/include/AutoUpdateRunner.php @@ -104,6 +104,16 @@ // because docker compose requires lowercase alphanumeric project names. $stackInfo = StackInfo::fromComposePath($compose_root, $path); if ($stackInfo !== null) { + if (!$stackInfo->hasResolvedIdentity()) { + composeLogger( + "Skipped scheduled auto-update for '{$stackInfo->projectFolder}': compose project identity is unresolved", + ['identity' => $stackInfo->identity->toArray()], + 'daemon', + 'warning', + 'identity' + ); + continue; + } $projectName = $stackInfo->projectName; } else { $projectName = StackInfo::sanitizeProjectString(basename($path)); diff --git a/source/compose.manager/include/ComposeCommandBuilder.php b/source/compose.manager/include/ComposeCommandBuilder.php index 9e3ddc8..2177be3 100644 --- a/source/compose.manager/include/ComposeCommandBuilder.php +++ b/source/compose.manager/include/ComposeCommandBuilder.php @@ -24,6 +24,7 @@ public static function buildForAction(StackInfo $stackInfo, string $action, ?str { $action = strtolower(trim($action)); self::assertSupportedAction($action); + self::assertResolvedIdentity($stackInfo, $action); $args = $stackInfo->buildComposeArgs(); @@ -85,4 +86,20 @@ private static function assertSupportedAction(string $action): void throw new \InvalidArgumentException('Unsupported compose action: ' . $action); } } + + /** + * Refuse to build mutating arguments for a stack whose runtime project + * identity could not be proven (see {@see ProjectIdentity}). + */ + private static function assertResolvedIdentity(StackInfo $stackInfo, string $action): void + { + if ($action === 'logs' || $stackInfo->hasResolvedIdentity()) { + return; + } + + throw new \RuntimeException( + "Compose project identity for '{$stackInfo->projectFolder}' is unresolved: " + . (string) $stackInfo->getIdentityBlockReason() + ); + } } diff --git a/source/compose.manager/include/ComposeList.php b/source/compose.manager/include/ComposeList.php index ccb83c4..a41c4c6 100755 --- a/source/compose.manager/include/ComposeList.php +++ b/source/compose.manager/include/ComposeList.php @@ -290,6 +290,10 @@ function composeRowBuildFailurePayload(string $composeRoot, string $project, str $invalidIndirectPath = $stackInfo->invalidIndirectPath; $hasInvalidIndirect = ($invalidIndirectPath !== null && trim($invalidIndirectPath) !== ''); $invalidIndirectPathHtml = htmlspecialchars($invalidIndirectPath ?? '', ENT_QUOTES, 'UTF-8'); + $identityBlocked = !$stackInfo->hasResolvedIdentity(); + $identityMessageHtml = htmlspecialchars((string) $stackInfo->getIdentityBlockReason(), ENT_QUOTES, 'UTF-8'); + $identityFolderHtml = htmlspecialchars($stackInfo->identity->folderCandidate, ENT_QUOTES, 'UTF-8'); + $identityLegacyHtml = htmlspecialchars($stackInfo->identity->legacyCandidate, ENT_QUOTES, 'UTF-8'); // Status icon, label, color — derived from centralized getStackState() $status = $stackState['state']; @@ -342,7 +346,7 @@ function composeRowBuildFailurePayload(string $composeRoot, string $project, str $hasBuild = $stackInfo->hasBuildConfig() ? '1' : '0'; // Main row - Docker tab structure with expand arrow on left - $o .= ""; + $o .= ""; // Arrow column $o .= ""; @@ -376,6 +380,9 @@ function composeRowBuildFailurePayload(string $composeRoot, string $project, str ], 'user', 'debug', 'stack-list'); $o .= " "; } + if ($identityBlocked) { + $o .= " "; + } $o .= "
"; $o .= "Project: $projectHtml"; $o .= "
"; diff --git a/source/compose.manager/include/Exec.php b/source/compose.manager/include/Exec.php index 202f641..869e54a 100644 --- a/source/compose.manager/include/Exec.php +++ b/source/compose.manager/include/Exec.php @@ -2428,4 +2428,44 @@ function composeResolveContainerIcon(string $containerName, string $service, arr } echo json_encode($out); break; + + case 'getProjectIdentities': + // Read-only migration preview: folder, display name, effective runtime + // project, live-container match, and whether owner input is required. + $identities = []; + foreach (StackInfo::allFromRoot($compose_root, true) as $stackInfo) { + $identity = $stackInfo->identity->toArray(); + $identity['displayName'] = $stackInfo->getName(); + $identities[] = $identity; + } + echo json_encode([ + 'result' => 'success', + 'identities' => $identities, + 'blocked' => count(array_filter($identities, static fn(array $i): bool => !$i['resolved'])), + ]); + break; + + case 'setProjectIdentity': + // Owner decision for a stack whose runtime identity could not be proven. + $stackName = isset($_POST['stackName']) ? basename(trim($_POST['stackName'])) : ''; + $chosen = isset($_POST['projectName']) ? trim($_POST['projectName']) : ''; + if ($stackName === '' || $chosen === '') { + echo json_encode(['result' => 'error', 'message' => 'Stack and project name are required.']); + break; + } + try { + $stackInfo = StackInfo::fromProject($compose_root, $stackName); + $stackInfo->applyIdentityChoice($chosen); + } catch (\Throwable $e) { + composeLogger('Failed to apply project identity choice', [ + 'stackName' => $stackName, + 'projectName' => $chosen, + 'error' => $e->getMessage(), + ], 'user', 'error', 'identity'); + echo json_encode(['result' => 'error', 'message' => $e->getMessage()]); + break; + } + composeLogger("Owner pinned compose project identity '$chosen' for '$stackName'", null, 'user', 'info', 'identity'); + echo json_encode(['result' => 'success', 'identity' => $stackInfo->identity->toArray()]); + break; } diff --git a/source/compose.manager/include/Helpers.php b/source/compose.manager/include/Helpers.php index bcfae3e..2c6142f 100644 --- a/source/compose.manager/include/Helpers.php +++ b/source/compose.manager/include/Helpers.php @@ -174,6 +174,26 @@ function echoComposeCommand($action, array $options = []) echo ''; return; } + + // Fail closed: never hand Docker Compose a project name we could not prove. + if (!$stackInfo->hasResolvedIdentity()) { + composeLogger( + "Blocked '$action' for '{$stackInfo->projectFolder}': compose project identity is unresolved", + ['action' => $action, 'path' => $path, 'identity' => $stackInfo->identity->toArray()], + 'user', + 'warning', + 'identity' + ); + echo json_encode([ + 'error' => 'identity', + 'project' => $stackInfo->projectFolder, + 'folderCandidate' => $stackInfo->identity->folderCandidate, + 'legacyCandidate' => $stackInfo->identity->legacyCandidate, + 'message' => $stackInfo->getIdentityBlockReason(), + ]); + return; + } + $args = $stackInfo->buildComposeArgs(); $composeCommand[] = "-c" . $action; @@ -278,6 +298,7 @@ function echoComposeCommandMultiple($action, array $options = []) // Build a combined command that runs compose up/down for each stack sequentially $commands = array(); $stackNames = array(); + $blockedStacks = array(); foreach ($paths as $path) { composeLogger("Processing stack for multi-compose action: " . $path, ['path' => $path, 'action' => $action], 'user', 'debug', 'compose-multi'); @@ -292,6 +313,20 @@ function echoComposeCommandMultiple($action, array $options = []) composeLogger("Skipping invalid stack during multi-compose action", ['action' => $action, 'path' => $path, 'error' => $e->getMessage()], 'user', 'warning', 'compose-multi'); continue; } + + // Fail closed: never hand Docker Compose a project name we could not prove. + if (!$stackInfo->hasResolvedIdentity()) { + composeLogger( + "Skipping '{$stackInfo->projectFolder}' during multi-compose action: compose project identity is unresolved", + ['action' => $action, 'path' => $path, 'identity' => $stackInfo->identity->toArray()], + 'user', + 'warning', + 'identity' + ); + $blockedStacks[] = $stackInfo->getName(); + continue; + } + $stackNames[] = $stackInfo->getName(); $args = $stackInfo->buildComposeArgs(); @@ -345,7 +380,15 @@ function echoComposeCommandMultiple($action, array $options = []) } if (empty($commands)) { - composeLogger("Multi Compose operation aborted: no valid stacks resolved", ['action' => $action], 'user', 'warning', 'compose-multi'); + composeLogger("Multi Compose operation aborted: no valid stacks resolved", ['action' => $action, 'blocked' => $blockedStacks], 'user', 'warning', 'compose-multi'); + if (!empty($blockedStacks)) { + echo json_encode([ + 'error' => 'identity', + 'stacks' => $blockedStacks, + 'message' => 'Compose project identity is unresolved for: ' . implode(', ', $blockedStacks), + ]); + return; + } echo ''; return; } @@ -386,6 +429,11 @@ function echoComposeCommandMultiple($action, array $options = []) $scriptContent = "#!/bin/bash\n"; $scriptContent .= "# Multi-stack compose script (ttyd) - auto-generated\n\n"; + foreach ($blockedStacks as $blocked) { + $blockedTitle = str_replace(['\\', '"'], ['\\\\', '\\"'], $blocked); + $scriptContent .= "echo \"! Skipped " . $blockedTitle . ": compose project identity is unresolved\"\n"; + } + foreach ($commands as $idx => $cmd) { $cmdStr = implode(" ", array_map('escapeshellarg', $cmd)); $stackTitle = str_replace(['\\', '"'], ['\\\\', '\\"'], $stackNames[$idx]); diff --git a/source/compose.manager/javascript/composeManagerMain.js b/source/compose.manager/javascript/composeManagerMain.js index 5a21464..155d4e7 100644 --- a/source/compose.manager/javascript/composeManagerMain.js +++ b/source/compose.manager/javascript/composeManagerMain.js @@ -4115,6 +4115,77 @@ function composeActionStateText(actionName) { return map[actionName] || 'checking...'; } +// Stacks imported from the original compose plugin can have two plausible +// `docker compose -p` identities. The backend refuses to act until the owner +// picks one; this prompt is how they do it. +function composeShowIdentityChooser(info) { + var project = info.project || ''; + var legacy = info.legacyCandidate || ''; + var folder = info.folderCandidate || ''; + var html = composeEscapeHtml(info.message || 'This stack has an ambiguous Docker Compose project name.'); + + if (project && legacy && folder) { + html += "

" + + " "; + } + + swal({ + title: 'Compose project identity required', + text: html, + html: true, + type: 'warning', + showConfirmButton: false, + showCancelButton: true, + cancelButtonText: 'Cancel' + }); +} + +$(document).on('click', '.compose-identity-warning', function (event) { + event.stopPropagation(); + var $row = $(this).closest('tr.compose-sortable'); + composeShowIdentityChooser({ + project: $row.data('project'), + message: $row.data('identity-message'), + folderCandidate: $row.data('identity-folder'), + legacyCandidate: $row.data('identity-legacy') + }); +}); + +$(document).on('click', '.compose-identity-choice', function () { + var project = $(this).data('project'); + var choice = $(this).data('choice'); + $.post(compURL, { action: 'setProjectIdentity', stackName: project, projectName: choice }, function (data) { + var parsed = tryParseJson(data); + if (parsed && parsed.result === 'success') { + swal({ title: 'Identity pinned', text: 'Using project name "' + choice + '".', type: 'success' }, function () { + location.reload(); + }); + } else { + swal('Error', (parsed && parsed.message) || 'Failed to pin project identity.', 'error'); + } + }); +}); + +// Returns true (and prompts) when the stack's runtime project identity is +// unproven, so no compose action should be dispatched. +function composeIdentityBlocked(path) { + var $row = $('tr.compose-sortable').filter(function () { + return String($(this).data('path')) === String(path); + }).first(); + if (!$row.length || String($row.data('identity-blocked')) !== '1') { + return false; + } + composeShowIdentityChooser({ + project: $row.data('project'), + message: $row.data('identity-message'), + folderCandidate: $row.data('identity-folder'), + legacyCandidate: $row.data('identity-legacy') + }); + return true; +} + function performComposeAction(opts) { opts = opts || {}; var stackName = opts.stackName; @@ -4139,6 +4210,16 @@ function performComposeAction(opts) { $.post(requestUrl, payload, function(data) { var parsed = tryParseJson(data); + if (parsed && parsed.error === 'identity') { + if (stackName) { + setStackActionInProgress(stackName, false); + } + composeShowIdentityChooser(parsed); + if (typeof onComplete === 'function') { + onComplete(parsed, data); + } + return; + } if (parsed && parsed.background) { if (!suppressBackgroundNotification) { notifyBackgroundStarted(title, true); @@ -4171,6 +4252,11 @@ function performComposeAction(opts) { function confirmedComposeAction(path, opts) { opts = opts || {}; var stackName = basename(path); + + if (composeIdentityBlocked(path)) { + return; + } + opts = $.extend(true, { actionName: '', titlePrefix: '', From 32c8bc6b7f54563c35953559cba2009c8ada110e Mon Sep 17 00:00:00 2001 From: mstrhakr Date: Tue, 1 Sep 2026 13:46:38 -0400 Subject: [PATCH 3/5] test(identity): add regression suite for legacy migration and fail-closed behavior Add ProjectIdentity tests covering legacy name mismatch, running/stopped ownership, volume-only ownership, ambiguous dual ownership, no ownership, Docker probe failure, pinned reuse, owner choice, and logs-vs-mutation guard behavior. Set deterministic default probe in test bootstrap so unit tests never depend on host Docker state. --- tests/bootstrap.php | 6 + tests/unit/ProjectIdentityTest.php | 309 +++++++++++++++++++++++++++++ 2 files changed, 315 insertions(+) create mode 100644 tests/unit/ProjectIdentityTest.php diff --git a/tests/bootstrap.php b/tests/bootstrap.php index e6c5ade..e0ca4e3 100644 --- a/tests/bootstrap.php +++ b/tests/bootstrap.php @@ -101,3 +101,9 @@ function composeLogger($message, $data = null, $type = 'daemon', $level = 'info' // Set compose_root global (used by plugin) $GLOBALS['compose_root'] = $testComposeRoot; + +// Legacy project-identity migration must never shell out to the host's Docker +// during tests. Default to "Docker reachable, no compose resources"; individual +// tests override this with ProjectIdentity::setProbe(). +require_once '/usr/local/emhttp/plugins/compose.manager/include/ProjectIdentity.php'; +ProjectIdentity::setProbe(static fn(): array => []); diff --git a/tests/unit/ProjectIdentityTest.php b/tests/unit/ProjectIdentityTest.php new file mode 100644 index 0000000..2e09cb3 --- /dev/null +++ b/tests/unit/ProjectIdentityTest.php @@ -0,0 +1,309 @@ + []); + $this->tempRoot = $this->createTempDir(); + } + + protected function tearDown(): void + { + \ProjectIdentity::setProbe(static fn(): array => []); + parent::tearDown(); + } + + /** + * Create a legacy-style stack: folder name and `name` file deliberately differ. + */ + private function makeStack(string $folder, ?string $name = null): string + { + $path = $this->tempRoot . '/' . $folder; + mkdir($path, 0755, true); + file_put_contents($path . '/compose.yaml', "services:\n"); + if ($name !== null) { + file_put_contents($path . '/name', $name); + } + return $path; + } + + /** + * @param array $containerCountsByProject + * @return array + */ + private function containers(array $containerCountsByProject): array + { + $probe = []; + foreach ($containerCountsByProject as $project => $count) { + $probe[$project] = ['containers' => $count, 'volumes' => 0, 'networks' => 0]; + } + return $probe; + } + + public function testLegacyRuntimeIdentityIsPreservedWhenItOwnsRunningContainers(): void + { + $this->makeStack('legacy-folder', 'legacy_runtime'); + \ProjectIdentity::setProbe(fn(): array => $this->containers(['legacy_runtime' => 2])); + + $stack = \StackInfo::fromProject($this->tempRoot, 'legacy-folder'); + + $this->assertSame('legacy_runtime', $stack->projectName); + $this->assertSame(\ProjectIdentity::SOURCE_LEGACY_RUNTIME, $stack->identity->source); + $this->assertTrue($stack->hasResolvedIdentity()); + $this->assertSame('legacy-folder', $stack->projectFolder); + $this->assertSame('legacy_runtime', $stack->displayName); + } + + public function testLegacyRuntimeIdentityIsPreservedForStoppedContainers(): void + { + // `docker ps -a` reports stopped containers too — a stack that is merely + // down must still keep its identity. + $this->makeStack('legacy-folder', 'legacy_runtime'); + \ProjectIdentity::setProbe(fn(): array => $this->containers(['legacy_runtime' => 1])); + + $stack = \StackInfo::fromProject($this->tempRoot, 'legacy-folder'); + + $this->assertSame('legacy_runtime', $stack->projectName); + $this->assertTrue($stack->hasResolvedIdentity()); + } + + public function testLegacyIdentityIsPreservedWhenOnlyVolumesRemain(): void + { + $this->makeStack('legacy-folder', 'legacy_runtime'); + \ProjectIdentity::setProbe(static fn(): array => [ + 'legacy_runtime' => ['containers' => 0, 'volumes' => 3, 'networks' => 0], + ]); + + $stack = \StackInfo::fromProject($this->tempRoot, 'legacy-folder'); + + $this->assertSame('legacy_runtime', $stack->projectName); + $this->assertSame(\ProjectIdentity::SOURCE_LEGACY_RUNTIME, $stack->identity->source); + } + + public function testFolderIdentityWinsWhenItOwnsTheContainers(): void + { + $this->makeStack('legacy-folder', 'legacy_runtime'); + \ProjectIdentity::setProbe(fn(): array => $this->containers(['legacy-folder' => 1])); + + $stack = \StackInfo::fromProject($this->tempRoot, 'legacy-folder'); + + $this->assertSame('legacy-folder', $stack->projectName); + $this->assertSame(\ProjectIdentity::SOURCE_FOLDER_RUNTIME, $stack->identity->source); + $this->assertTrue($stack->hasResolvedIdentity()); + } + + public function testBothCandidatesPresentFailsClosed(): void + { + $path = $this->makeStack('legacy-folder', 'legacy_runtime'); + \ProjectIdentity::setProbe(fn(): array => $this->containers([ + 'legacy-folder' => 1, + 'legacy_runtime' => 1, + ])); + + $stack = \StackInfo::fromProject($this->tempRoot, 'legacy-folder'); + + $this->assertFalse($stack->hasResolvedIdentity()); + $this->assertSame(\ProjectIdentity::CONFLICT_AMBIGUOUS, $stack->identity->conflictReason); + $this->assertNotNull($stack->getIdentityBlockReason()); + $this->assertFileDoesNotExist($path . '/' . \ProjectIdentity::METADATA_FILE); + } + + public function testNeitherCandidatePresentUsesFolderIdentity(): void + { + $path = $this->makeStack('legacy-folder', 'legacy_runtime'); + \ProjectIdentity::setProbe(fn(): array => $this->containers(['someone-else' => 4])); + + $stack = \StackInfo::fromProject($this->tempRoot, 'legacy-folder'); + + $this->assertSame('legacy-folder', $stack->projectName); + $this->assertSame(\ProjectIdentity::SOURCE_UNUSED, $stack->identity->source); + $this->assertTrue($stack->hasResolvedIdentity()); + $this->assertSame('legacy-folder', trim((string) file_get_contents($path . '/' . \ProjectIdentity::METADATA_FILE))); + } + + public function testUnreachableDockerFailsClosed(): void + { + $path = $this->makeStack('legacy-folder', 'legacy_runtime'); + \ProjectIdentity::setProbe(static fn() => null); + + $stack = \StackInfo::fromProject($this->tempRoot, 'legacy-folder'); + + $this->assertFalse($stack->hasResolvedIdentity()); + $this->assertSame(\ProjectIdentity::CONFLICT_PROBE_FAILED, $stack->identity->conflictReason); + $this->assertFileDoesNotExist($path . '/' . \ProjectIdentity::METADATA_FILE); + } + + public function testMatchingCandidatesNeverProbeDocker(): void + { + $this->makeStack('mystack', 'mystack'); + \ProjectIdentity::setProbe(function (): array { + $this->fail('Docker must not be probed when the candidates already agree'); + }); + + $stack = \StackInfo::fromProject($this->tempRoot, 'mystack'); + + $this->assertSame('mystack', $stack->projectName); + $this->assertSame(\ProjectIdentity::SOURCE_CANONICAL, $stack->identity->source); + } + + public function testPinnedIdentityIsReusedWithoutProbing(): void + { + $path = $this->makeStack('legacy-folder', 'legacy_runtime'); + file_put_contents($path . '/' . \ProjectIdentity::METADATA_FILE, 'legacy_runtime'); + \ProjectIdentity::setProbe(function (): array { + $this->fail('Docker must not be probed once the identity is pinned'); + }); + + $stack = \StackInfo::fromProject($this->tempRoot, 'legacy-folder'); + + $this->assertSame('legacy_runtime', $stack->projectName); + $this->assertSame(\ProjectIdentity::SOURCE_PERSISTED, $stack->identity->source); + $this->assertTrue($stack->hasResolvedIdentity()); + } + + public function testResolutionIsPinnedSoLaterRenamesDoNotMoveIdentity(): void + { + $path = $this->makeStack('legacy-folder', 'legacy_runtime'); + \ProjectIdentity::setProbe(fn(): array => $this->containers(['legacy_runtime' => 1])); + \StackInfo::fromProject($this->tempRoot, 'legacy-folder'); + + // Owner renames the stack in the WebGUI; identity must not follow. + file_put_contents($path . '/name', 'Totally Different'); + \StackInfo::clearCache(); + \ProjectIdentity::setProbe(fn(): array => $this->containers(['totally_different' => 9])); + + $stack = \StackInfo::fromProject($this->tempRoot, 'legacy-folder'); + + $this->assertSame('legacy_runtime', $stack->projectName); + $this->assertSame('Totally Different', $stack->displayName); + } + + public function testNewPlusStackPinsFolderDerivedIdentity(): void + { + \ProjectIdentity::setProbe(fn(): array => $this->containers(['my-stack' => 1])); + + $stack = \StackInfo::createNew($this->tempRoot, 'My-Stack'); + + $this->assertSame('my-stack', $stack->projectName); + $this->assertTrue($stack->hasResolvedIdentity()); + $this->assertSame('my-stack', trim((string) file_get_contents($stack->path . '/' . \ProjectIdentity::METADATA_FILE))); + } + + public function testCollisionSuffixedStackDoesNotAdoptExistingProjectIdentity(): void + { + $this->makeStack('my-stack', 'my-stack'); + \ProjectIdentity::setProbe(fn(): array => $this->containers(['my-stack' => 3])); + + $stack = \StackInfo::createNew($this->tempRoot, 'my-stack'); + + $this->assertSame('my-stack-001', $stack->projectFolder); + $this->assertSame('my-stack-001', $stack->projectName); + } + + public function testOwnerCanPinAmbiguousIdentity(): void + { + $path = $this->makeStack('legacy-folder', 'legacy_runtime'); + \ProjectIdentity::setProbe(fn(): array => $this->containers([ + 'legacy-folder' => 1, + 'legacy_runtime' => 1, + ])); + + $stack = \StackInfo::fromProject($this->tempRoot, 'legacy-folder'); + $this->assertFalse($stack->hasResolvedIdentity()); + + $stack->applyIdentityChoice('legacy_runtime'); + + $this->assertTrue($stack->hasResolvedIdentity()); + $this->assertSame('legacy_runtime', $stack->projectName); + $this->assertSame('legacy_runtime', trim((string) file_get_contents($path . '/' . \ProjectIdentity::METADATA_FILE))); + } + + public function testOwnerCannotPinAnArbitraryIdentity(): void + { + $this->makeStack('legacy-folder', 'legacy_runtime'); + \ProjectIdentity::setProbe(fn(): array => $this->containers([ + 'legacy-folder' => 1, + 'legacy_runtime' => 1, + ])); + $stack = \StackInfo::fromProject($this->tempRoot, 'legacy-folder'); + + $this->expectException(\RuntimeException::class); + $stack->applyIdentityChoice('some-other-project'); + } + + public function testMutatingComposeArgsAreRefusedWhileUnresolved(): void + { + $this->makeStack('legacy-folder', 'legacy_runtime'); + \ProjectIdentity::setProbe(fn(): array => $this->containers([ + 'legacy-folder' => 1, + 'legacy_runtime' => 1, + ])); + $stack = \StackInfo::fromProject($this->tempRoot, 'legacy-folder'); + + $this->expectException(\RuntimeException::class); + \ComposeCommandBuilder::buildForAction($stack, 'up'); + } + + public function testReadOnlyLogsAreAllowedWhileUnresolved(): void + { + $this->makeStack('legacy-folder', 'legacy_runtime'); + \ProjectIdentity::setProbe(fn(): array => $this->containers([ + 'legacy-folder' => 1, + 'legacy_runtime' => 1, + ])); + $stack = \StackInfo::fromProject($this->tempRoot, 'legacy-folder'); + + $args = \ComposeCommandBuilder::buildForAction($stack, 'logs'); + + $this->assertSame('logs', $args['action']); + } + + public function testProbeParsesDockerLabelOutputPerResourceType(): void + { + $output = implode("\n", [ + 'containers com.docker.compose.project=legacy_runtime,com.docker.compose.service=web', + 'containers com.docker.compose.project=legacy_runtime,com.docker.compose.service=db', + 'volumes com.docker.compose.project=legacy_runtime,com.docker.compose.volume=data', + 'networks com.docker.compose.project=other-stack', + 'containers net.unraid.docker.managed=composeman', + ]) . "\n__rc=0"; + + $counts = \ProjectIdentity::parseProbeOutput($output); + + $this->assertSame( + ['containers' => 2, 'volumes' => 1, 'networks' => 0], + $counts['legacy_runtime'] + ); + $this->assertSame( + ['containers' => 0, 'volumes' => 0, 'networks' => 1], + $counts['other-stack'] + ); + } + + public function testProbeReturnsNullOnNonZeroExit(): void + { + $this->assertNull(\ProjectIdentity::parseProbeOutput("__rc=127")); + $this->assertNull(\ProjectIdentity::parseProbeOutput(null)); + } +} From 481157cab5c9d7d75f079bd7c284931e833b1c93 Mon Sep 17 00:00:00 2001 From: mstrhakr Date: Tue, 1 Sep 2026 14:16:19 -0400 Subject: [PATCH 4/5] fix(identity): allow logs when identity unresolved Scope the fail-closed identity guard in echoComposeCommand() to mutating actions only. composeLogs stays read-only and continues opening ttyd even when identity is unresolved. --- source/compose.manager/include/Helpers.php | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/source/compose.manager/include/Helpers.php b/source/compose.manager/include/Helpers.php index 2c6142f..910adf4 100644 --- a/source/compose.manager/include/Helpers.php +++ b/source/compose.manager/include/Helpers.php @@ -175,8 +175,8 @@ function echoComposeCommand($action, array $options = []) return; } - // Fail closed: never hand Docker Compose a project name we could not prove. - if (!$stackInfo->hasResolvedIdentity()) { + // Fail closed for mutating actions only; logs remains read-only. + if ($action !== 'logs' && !$stackInfo->hasResolvedIdentity()) { composeLogger( "Blocked '$action' for '{$stackInfo->projectFolder}': compose project identity is unresolved", ['action' => $action, 'path' => $path, 'identity' => $stackInfo->identity->toArray()], From 38b528089a98b70541d50f943d2573e6ec3d012c Mon Sep 17 00:00:00 2001 From: mstrhakr Date: Tue, 1 Sep 2026 14:22:03 -0400 Subject: [PATCH 5/5] fix(ui): show plain warning for multi-stack identity errors Handle identity error payloads that do not include per-stack candidates (e.g. multi-stack operations) with a simple warning dialog instead of opening the chooser with no options. --- .../javascript/composeManagerMain.js | 15 ++++++++++++++- 1 file changed, 14 insertions(+), 1 deletion(-) diff --git a/source/compose.manager/javascript/composeManagerMain.js b/source/compose.manager/javascript/composeManagerMain.js index 155d4e7..bf64d1b 100644 --- a/source/compose.manager/javascript/composeManagerMain.js +++ b/source/compose.manager/javascript/composeManagerMain.js @@ -4142,6 +4142,19 @@ function composeShowIdentityChooser(info) { }); } +function composeHandleIdentityError(info) { + var hasChoices = info && info.project && info.legacyCandidate && info.folderCandidate; + if (hasChoices) { + composeShowIdentityChooser(info); + return; + } + + var message = (info && info.message) + ? String(info.message) + : 'Compose project identity is unresolved. Resolve each affected stack before retrying.'; + swal('Compose project identity required', message, 'warning'); +} + $(document).on('click', '.compose-identity-warning', function (event) { event.stopPropagation(); var $row = $(this).closest('tr.compose-sortable'); @@ -4214,7 +4227,7 @@ function performComposeAction(opts) { if (stackName) { setStackActionInProgress(stackName, false); } - composeShowIdentityChooser(parsed); + composeHandleIdentityError(parsed); if (typeof onComplete === 'function') { onComplete(parsed, data); }