From 760ef5be98e69c3fa0204b333b1546b96367d8e4 Mon Sep 17 00:00:00 2001 From: mstrhakr Date: Sat, 6 Jun 2026 05:11:45 -0400 Subject: [PATCH 1/4] fix: update container cache loading to use PHP endpoint and improve data parsing; [Bug]: [error] 12159#12159: *38373 open() "/usr/local/emhttp/plugins/compose.manager/containers.cache.json" failed (2: No such file or directory) while sending to client Fixes mstrhakr/compose_plugin#117 --- source/compose.manager/include/ContainerCache.php | 15 +++++++++++++++ .../javascript/composeManagerMain.js | 4 ++-- 2 files changed, 17 insertions(+), 2 deletions(-) create mode 100644 source/compose.manager/include/ContainerCache.php diff --git a/source/compose.manager/include/ContainerCache.php b/source/compose.manager/include/ContainerCache.php new file mode 100644 index 00000000..0aba7034 --- /dev/null +++ b/source/compose.manager/include/ContainerCache.php @@ -0,0 +1,15 @@ + Date: Sat, 6 Jun 2026 09:12:52 +0000 Subject: [PATCH 2/4] chore: update changelog for v2026.06.06 [skip ci] --- compose.manager.plg | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/compose.manager.plg b/compose.manager.plg index 670363e4..ed5c4f0c 100644 --- a/compose.manager.plg +++ b/compose.manager.plg @@ -36,9 +36,9 @@ > -###2026.05.31 -- Bug Fixes: remove unused stack priority assignment in docker_started event handler -- [View all changes](https://github.com/mstrhakr/compose_plugin/compare/v2026.05.28...v2026.05.31) +###2026.06.06 +- Bug Fixes: update container cache loading to use PHP endpoint and improve data parsing; [Bug]: [error] 12159#12159: *38373 open() "/usr/local/emhttp/plugins/compose.manager/containers.cache.json" failed (2: No such file or directory) while sending to client Fixes mstrhakr/compose_plugin#117 +- [View all changes](https://github.com/mstrhakr/compose_plugin/compare/v2026.05.31...v2026.06.06) From a2312e43cabeb7a3fa10293d386a949e9f062127 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Sat, 6 Jun 2026 09:13:36 +0000 Subject: [PATCH 3/4] Release v2026.06.06 [skip ci] --- compose.manager.plg | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/compose.manager.plg b/compose.manager.plg index ed5c4f0c..a5bfefc3 100644 --- a/compose.manager.plg +++ b/compose.manager.plg @@ -2,11 +2,11 @@ - + - - + + From fb0a9c8ac5448e789808d6b420dd3951cbddbe72 Mon Sep 17 00:00:00 2001 From: Joly0 <13993216+Joly0@users.noreply.github.com> Date: Fri, 12 Jun 2026 10:53:53 +0200 Subject: [PATCH 4/4] Add support for additional compose files per stack Until now a stack could only use its main compose file plus the override file the plugin manages. Many projects ship optional override files (for example docker-compose.gpu.yml for GPU support), but there was no way to include them, because the plugin builds the docker compose command itself and there was nowhere to add another -f file. What this adds: - A new "Additional Compose Files" section in the stack settings. It lists the compose files found in the stack's compose folder as checkboxes, so you can simply tick the ones you want. An advanced field is available for files that live somewhere else (absolute paths, one per line). - The selection is stored in a small extra_compose_files file in the stack folder (one path per line) and the files are appended as extra -f flags after the main compose file and the override file. This automatically applies to every action (up, down, update, pull, logs, autostart) because the command arguments are built in one central place. - The editor's Compose tab got a file dropdown that lists every file the stack is made of (main file, override file, additional files), so all of them can be viewed and edited directly. Safety details: - The editor can only open files that actually belong to the stack; the request is checked against the stack's own file list, so the endpoint cannot be used to read or write unrelated files. - Saved entries must be .yml/.yaml files and absolute paths must be under /mnt/ or /boot/config/. - A settings save coming from an outdated cached browser session leaves the new setting untouched instead of silently clearing it. - Entries pointing to files that no longer exist are skipped with a logged warning instead of breaking the stack. - Selecting additional files switches the stack to explicit file mode, so the selection cannot be ignored by Docker Compose default file discovery. --- .../include/ComposeManager.php | 15 ++ source/compose.manager/include/Exec.php | 125 +++++++++++- source/compose.manager/include/Util.php | 67 +++++- .../javascript/composeManagerMain.js | 190 +++++++++++++++++- tests/unit/ExecActionsTest.php | 46 +++++ tests/unit/ExtraComposeFilesTest.php | 153 ++++++++++++++ 6 files changed, 583 insertions(+), 13 deletions(-) create mode 100644 tests/unit/ExtraComposeFilesTest.php diff --git a/source/compose.manager/include/ComposeManager.php b/source/compose.manager/include/ComposeManager.php index 2e0258d2..64324474 100755 --- a/source/compose.manager/include/ComposeManager.php +++ b/source/compose.manager/include/ComposeManager.php @@ -471,6 +471,10 @@ function compose_manager_cpu_spec_count($cpuSpec)
+
@@ -610,6 +614,17 @@ function compose_manager_cpu_spec_count($cpuSpec)
Path to a specific external compose file (e.g., /mnt/user/appdata/myapp/custom.compose.yml). Leave empty to use folder mode or local project files.
+
+ + + +
+ External files (advanced) + +
+
Selected files are appended as additional -f flags after the main compose and override files (e.g., GPU or environment overrides). Setting this disables default file discovery.
+
+
diff --git a/source/compose.manager/include/Exec.php b/source/compose.manager/include/Exec.php index 63bcfa9b..dd2c315d 100644 --- a/source/compose.manager/include/Exec.php +++ b/source/compose.manager/include/Exec.php @@ -63,6 +63,29 @@ function rejectStaleClientPath(?string $actualPath, string $label): bool } } +if (!function_exists('resolveRequestedComposeFile')) { + /** + * Resolve an explicitly requested compose file (`file` POST param) + * against the stack's editable compose files. + * + * @return string|false|null Matched path, false when the request is + * invalid, or null when no file was requested. + */ + function resolveRequestedComposeFile(StackInfo $stackInfo) + { + $requested = isset($_POST['file']) ? trim((string) $_POST['file']) : ''; + if ($requested === '') { + return null; + } + foreach ($stackInfo->getEditableComposeFiles() as $candidate) { + if (Path::refersToSamePath($candidate, $requested)) { + return $candidate; + } + } + return false; + } +} + switch ($_POST['action']) { case 'composeLogger': $message = $_POST['msg'] ?? ''; @@ -279,6 +302,16 @@ function rejectStaleClientPath(?string $actualPath, string $label): bool $stackInfo = StackInfo::fromProject($compose_root, $script); $composeFilePath = $stackInfo->composeFilePath ?? ($stackInfo->composeSource . '/compose.yaml'); + // Optional explicit file selection (e.g. additional compose files) + $requestedFile = resolveRequestedComposeFile($stackInfo); + if ($requestedFile === false) { + echo json_encode(['result' => 'error', 'message' => 'Requested file is not part of this stack.']); + break; + } + if ($requestedFile !== null) { + $composeFilePath = $requestedFile; + } + // Check file existence consistently regardless of how path was resolved if (is_file($composeFilePath)) { $scriptContents = file_get_contents($composeFilePath); @@ -431,12 +464,24 @@ function rejectStaleClientPath(?string $actualPath, string $label): bool $stackInfo = StackInfo::fromProject($compose_root, $script); $composeFilePath = $stackInfo->composeFilePath ?? ($stackInfo->composeSource . '/' . COMPOSE_FILE_NAMES[0]); + // Optional explicit file selection (e.g. additional compose files) + $requestedFile = resolveRequestedComposeFile($stackInfo); + if ($requestedFile === false) { + echo json_encode(['result' => 'error', 'message' => 'Requested file is not part of this stack.']); + break; + } + $isMainComposeFile = $requestedFile === null || Path::refersToSamePath($requestedFile, $composeFilePath); + if ($requestedFile !== null) { + $composeFilePath = $requestedFile; + } + if (rejectStaleClientPath($composeFilePath, 'compose file')) { break; } - // Before saving, detect service renames and migrate override entries in the project override only - if (is_file($composeFilePath)) { + // Before saving, detect service renames and migrate override entries in the project override only. + // Rename migration only applies to the main compose file. + if ($isMainComposeFile && is_file($composeFilePath)) { $oldContent = file_get_contents($composeFilePath); $stackInfo->overrideInfo->migrateOnRename($oldContent, $scriptContents); } @@ -701,6 +746,31 @@ function rejectStaleClientPath(?string $actualPath, string $label): bool $defaultProfileFile = "$compose_root/$script/default_profile"; $defaultProfile = is_file($defaultProfileFile) ? trim(file_get_contents($defaultProfileFile)) : ""; + // Get additional compose files (one path per line) + $extraComposeFilesFile = "$compose_root/$script/extra_compose_files"; + $extraComposeFiles = is_file($extraComposeFilesFile) ? trim(file_get_contents($extraComposeFilesFile)) : ""; + + // Candidate compose files in the compose source folder for the + // Additional Compose Files selector (*compose*.y(a)ml, excluding the + // main compose file and override files) + $composeFileCandidates = []; + $mainComposeBase = $stackInfo->composeFilePath !== null ? basename($stackInfo->composeFilePath) : ''; + $candidateGlob = glob($stackInfo->composeSource . '/*.{yml,yaml}', GLOB_BRACE) ?: []; + foreach ($candidateGlob as $candidatePath) { + $candidateBase = basename($candidatePath); + if (stripos($candidateBase, 'compose') === false) { + continue; + } + if ($candidateBase === $mainComposeBase) { + continue; + } + if (stripos($candidateBase, '.override.') !== false) { + continue; + } + $composeFileCandidates[] = $candidateBase; + } + sort($composeFileCandidates); + // Get labels tab view mode (per-stack) $labelsViewModeFile = "$compose_root/$script/labels_view_mode"; $labelsViewMode = is_file($labelsViewModeFile) ? strtolower(trim(file_get_contents($labelsViewModeFile))) : 'basic'; @@ -763,6 +833,9 @@ function rejectStaleClientPath(?string $actualPath, string $label): bool 'iconUrl' => $iconUrl, 'webuiUrl' => $webuiUrl, 'defaultProfile' => $defaultProfile, + 'extraComposeFiles' => $extraComposeFiles, + 'composeFileCandidates' => $composeFileCandidates, + 'editableComposeFiles' => $stackInfo->getEditableComposeFiles(), 'labelsViewMode' => $labelsViewMode, 'useDefaultComposeFiles' => $useDefaultComposeFiles, 'indirectMode' => $stackInfo->indirectMode, @@ -896,6 +969,43 @@ function rejectStaleClientPath(?string $actualPath, string $label): bool } } + // Additional compose files (one path per line, absolute or relative to compose source). + // Only processed when the client sends the field: a stale UI session + // that predates this setting must not silently clear it. + $extraComposeFilesProvided = isset($_POST['extraComposeFiles']); + $extraComposeFiles = $extraComposeFilesProvided ? trim($_POST['extraComposeFiles']) : ""; + $extraComposeFilesNormalized = []; + $extraComposeFilesError = ''; + foreach (preg_split('/\R/', $extraComposeFiles) as $extraLine) { + $extraLine = trim($extraLine); + if ($extraLine === '' || strpos($extraLine, '#') === 0) { + continue; + } + if (preg_match('/\.ya?ml$/i', $extraLine) !== 1) { + $extraComposeFilesError = 'Additional compose file must be a .yml or .yaml file: ' . $extraLine; + break; + } + if (Path::isAbsolutePath($extraLine)) { + $realExtra = realpath($extraLine); + if ($realExtra === false || !is_file($realExtra)) { + $extraComposeFilesError = 'Additional compose file does not exist: ' . $extraLine; + break; + } + if (!Path::isAllowedPath($realExtra, ['/mnt', '/boot/config'])) { + $extraComposeFilesError = 'Additional compose files must be under /mnt/ or /boot/config/.'; + break; + } + } elseif (strpos($extraLine, '..') !== false) { + $extraComposeFilesError = 'Relative additional compose file paths must not contain "..": ' . $extraLine; + break; + } + $extraComposeFilesNormalized[] = $extraLine; + } + if ($extraComposeFilesError !== '') { + echo json_encode(['result' => 'error', 'message' => $extraComposeFilesError]); + break; + } + // --- All validation passed, now write everything --- // Set env path @@ -934,6 +1044,17 @@ function rejectStaleClientPath(?string $actualPath, string $label): bool file_put_contents($defaultProfileFile, $defaultProfile); } + // Set additional compose files (skipped entirely when the field was not sent) + if ($extraComposeFilesProvided) { + $extraComposeFilesFile = "$compose_root/$script/extra_compose_files"; + if (empty($extraComposeFilesNormalized)) { + if (is_file($extraComposeFilesFile)) + @unlink($extraComposeFilesFile); + } else { + file_put_contents($extraComposeFilesFile, implode("\n", $extraComposeFilesNormalized) . "\n"); + } + } + // Set compose file discovery mode $useDefaultComposeFilesFile = "$compose_root/$script/use_default_compose_files"; if ($useDefaultComposeFiles) { diff --git a/source/compose.manager/include/Util.php b/source/compose.manager/include/Util.php index 170adcda..6ec5889f 100644 --- a/source/compose.manager/include/Util.php +++ b/source/compose.manager/include/Util.php @@ -1764,6 +1764,40 @@ private function getAdditionalComposeFilesFromEnv(): array return array_values(array_unique($files)); } + /** + * Parse additional compose file paths from the `extra_compose_files` + * metadata file (one path per line; `#` comments allowed). + * + * Paths may be absolute or relative to the compose source folder. + * Missing files are skipped with a warning so a stale entry cannot + * break stack operations. + * + * @return string[] + */ + private function getExtraComposeFiles(): array + { + $raw = $this->readMetadata('extra_compose_files'); + if ($raw === null || $raw === '') { + return []; + } + + $files = []; + foreach (preg_split('/\R/', $raw) as $line) { + $line = trim($line); + if ($line === '' || str_starts_with($line, '#')) { + continue; + } + $path = Path::isAbsolutePath($line) ? $line : $this->composeSource . '/' . $line; + if (!is_file($path)) { + composeLogger("Ignoring missing extra compose file for stack $this->projectFolder", ['path' => $path], 'user', 'warning', 'stack'); + continue; + } + $files[] = $path; + } + + return $files; + } + /** * Normalize compose file paths for deduplication. * @@ -1820,6 +1854,26 @@ private static function splitComposeFileValue(string $value): array return $entries; } + /** + * Compose files that are directly editable in the stack editor: every + * existing file that contributes to the compose command, in `-f` order + * (main compose file, override file, COMPOSE_FILE env entries, and + * `extra_compose_files` metadata). + * + * @return string[] + */ + public function getEditableComposeFiles(): array + { + $files = []; + foreach ($this->getComposeFilePaths() as $path) { + if (!is_file($path)) { + continue; + } + $files[] = $path; + } + return $files; + } + private function getComposeFilePaths(): array { $paths = []; @@ -1835,6 +1889,10 @@ private function getComposeFilePaths(): array $paths[] = $extraFile; } + foreach ($this->getExtraComposeFiles() as $extraFile) { + $paths[] = $extraFile; + } + $normalized = []; $unique = []; foreach ($paths as $path) { @@ -1875,8 +1933,8 @@ public function useDefaultComposeFileDiscovery(): bool /** * Determine whether this stack has manual settings that require explicit mode. * - * Explicit env-path and indirect-file selections are treated as explicit-mode - * signals, so default file discovery is bypassed. + * Explicit env-path, extra compose files, and indirect-file selections are + * treated as explicit-mode signals, so default file discovery is bypassed. * * @return bool */ @@ -1887,6 +1945,11 @@ private function hasManualComposePathOverrides(): bool return true; } + $extraComposeFiles = $this->readMetadata('extra_compose_files'); + if ($extraComposeFiles !== null && $extraComposeFiles !== '') { + return true; + } + return $this->indirectMode === 'file'; } diff --git a/source/compose.manager/javascript/composeManagerMain.js b/source/compose.manager/javascript/composeManagerMain.js index 8ea6981c..600072bc 100644 --- a/source/compose.manager/javascript/composeManagerMain.js +++ b/source/compose.manager/javascript/composeManagerMain.js @@ -91,15 +91,14 @@ function parseMemUsagePair(memStr) { } } - var hasEnvPath = ($('#settings-env-path').val() || '').trim() !== ''; - var hasExternalComposeFilePath = ($('#settings-external-compose-file').val() || '').trim() !== ''; + var manualOverrideReasons = []; + if (($('#settings-env-path').val() || '').trim() !== '') manualOverrideReasons.push('Env File Path'); + if (($('#settings-external-compose-file').val() || '').trim() !== '') manualOverrideReasons.push('External Compose File'); + if (getExtraComposeFilesValue() !== '') manualOverrideReasons.push('Additional Compose Files'); - if (hasEnvPath || hasExternalComposeFilePath) { - var reason = hasEnvPath && hasExternalComposeFilePath - ? 'Default discovery disabled because Env File Path and External Compose File are set.' - : (hasEnvPath - ? 'Default discovery disabled because Env File Path is set.' - : 'Default discovery disabled because External Compose File is set.'); + if (manualOverrideReasons.length > 0) { + var reason = 'Default discovery disabled because ' + manualOverrideReasons.join(' and ') + + (manualOverrideReasons.length > 1 ? ' are set.' : ' is set.'); if ($checkbox.is(':checked')) { $checkbox.prop('checked', false); @@ -115,6 +114,76 @@ function parseMemUsagePair(memStr) { } } +// ---- Additional Compose Files settings UI ---- + +// Combine checked folder candidates and external lines into the +// newline-separated value stored in the extra_compose_files metadata file. +function getExtraComposeFilesValue() { + var lines = []; + $('#settings-extra-compose-candidates input[type="checkbox"]:checked').each(function() { + lines.push($(this).val()); + }); + ($('#settings-extra-compose-external').val() || '').split('\n').forEach(function(line) { + line = line.trim(); + if (line) lines.push(line); + }); + return lines.join('\n'); +} + +// Render the candidate checkbox list and route non-candidate entries +// (external/absolute paths) into the advanced textarea. +function renderExtraComposeFiles(candidates, rawValue) { + var $list = $('#settings-extra-compose-candidates'); + var $none = $('#settings-extra-compose-none'); + var $external = $('#settings-extra-compose-external'); + + var selected = []; + (rawValue || '').split('\n').forEach(function(line) { + line = line.trim(); + if (line && line.indexOf('#') !== 0) selected.push(line); + }); + + candidates = candidates || []; + var externalLines = selected.filter(function(entry) { + return candidates.indexOf(entry) === -1; + }); + + $list.empty(); + candidates.forEach(function(name) { + var $label = $('