diff --git a/source/compose.manager/include/ComposeManager.php b/source/compose.manager/include/ComposeManager.php index f56a8fd7..dfaaadc0 100755 --- a/source/compose.manager/include/ComposeManager.php +++ b/source/compose.manager/include/ComposeManager.php @@ -477,6 +477,10 @@ function compose_manager_cpu_spec_count($cpuSpec)
+
@@ -616,6 +620,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/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 @@ +getEditableComposeFiles() as $candidate) { + if (Path::refersToSamePath($candidate, $requested)) { + return $candidate; + } + } + return false; + } +} + if (!function_exists('composeLoadPersistentContainerCache')) { function composeLoadPersistentContainerCache(): array { @@ -344,6 +367,16 @@ function composeResolveContainerIcon(string $containerName, string $service, arr $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); @@ -496,12 +529,24 @@ function composeResolveContainerIcon(string $containerName, string $service, arr $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); } @@ -766,6 +811,31 @@ function composeResolveContainerIcon(string $containerName, string $service, arr $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'; @@ -828,6 +898,9 @@ function composeResolveContainerIcon(string $containerName, string $service, arr 'iconUrl' => $iconUrl, 'webuiUrl' => $webuiUrl, 'defaultProfile' => $defaultProfile, + 'extraComposeFiles' => $extraComposeFiles, + 'composeFileCandidates' => $composeFileCandidates, + 'editableComposeFiles' => $stackInfo->getEditableComposeFiles(), 'labelsViewMode' => $labelsViewMode, 'useDefaultComposeFiles' => $useDefaultComposeFiles, 'indirectMode' => $stackInfo->indirectMode, @@ -961,6 +1034,43 @@ function composeResolveContainerIcon(string $containerName, string $service, arr } } + // 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 @@ -999,6 +1109,17 @@ function composeResolveContainerIcon(string $containerName, string $service, arr 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 0a7e2006..b2649d6d 100644 --- a/source/compose.manager/include/Util.php +++ b/source/compose.manager/include/Util.php @@ -1908,6 +1908,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. * @@ -1964,6 +1998,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 = []; @@ -1979,6 +2033,10 @@ private function getComposeFilePaths(): array $paths[] = $extraFile; } + foreach ($this->getExtraComposeFiles() as $extraFile) { + $paths[] = $extraFile; + } + $normalized = []; $unique = []; foreach ($paths as $path) { @@ -2019,8 +2077,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 */ @@ -2031,6 +2089,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 43170d31..2a7cfbd9 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 = $('