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 .= "
";
// 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..910adf4 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 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()],
+ '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/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);
}
diff --git a/source/compose.manager/javascript/composeManagerMain.js b/source/compose.manager/javascript/composeManagerMain.js
index 5a21464..bf64d1b 100644
--- a/source/compose.manager/javascript/composeManagerMain.js
+++ b/source/compose.manager/javascript/composeManagerMain.js
@@ -4115,6 +4115,90 @@ 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'
+ });
+}
+
+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');
+ 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 +4223,16 @@ function performComposeAction(opts) {
$.post(requestUrl, payload, function(data) {
var parsed = tryParseJson(data);
+ if (parsed && parsed.error === 'identity') {
+ if (stackName) {
+ setStackActionInProgress(stackName, false);
+ }
+ composeHandleIdentityError(parsed);
+ if (typeof onComplete === 'function') {
+ onComplete(parsed, data);
+ }
+ return;
+ }
if (parsed && parsed.background) {
if (!suppressBackgroundNotification) {
notifyBackgroundStarted(title, true);
@@ -4171,6 +4265,11 @@ function performComposeAction(opts) {
function confirmedComposeAction(path, opts) {
opts = opts || {};
var stackName = basename(path);
+
+ if (composeIdentityBlocked(path)) {
+ return;
+ }
+
opts = $.extend(true, {
actionName: '',
titlePrefix: '',
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));
+ }
+}
|