Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
15 changes: 15 additions & 0 deletions source/compose.manager/include/ComposeManager.php
Original file line number Diff line number Diff line change
Expand Up @@ -477,6 +477,10 @@ function compose_manager_cpu_spec_count($cpuSpec)
<!-- ========== COMPOSE EDITOR PANEL ========== -->
<div class="editor-panel active" id="editor-panel-compose" role="tabpanel" aria-labelledby="editor-tab-compose">
<div class="editor-modal-body">
<div id="compose-file-selector-wrap" style="display:none;align-items:center;gap:8px;padding:6px 12px;">
<label for="compose-file-selector" style="margin:0;">File:</label>
<select id="compose-file-selector" onchange="switchComposeFile(this.value)"></select>
</div>
<div class="editor-container active" id="editor-container-compose">
<div id="editor-compose" style="width: 100%; height: 100%;"></div>
</div>
Expand Down Expand Up @@ -616,6 +620,17 @@ function compose_manager_cpu_spec_count($cpuSpec)
<div class="settings-field-help">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.</div>
</div>

<div class="settings-field">
<label for="settings-extra-compose-candidates">Additional Compose Files</label>
<div id="settings-extra-compose-candidates" style="display:none;"></div>
<div id="settings-extra-compose-none" class="settings-field-help" style="display:none;">No additional compose files found in the compose source folder (looking for <code>*compose*.yml</code> / <code>*compose*.yaml</code>).</div>
<details id="settings-extra-compose-external-wrap" style="margin-top:8px;">
<summary style="cursor:pointer;">External files (advanced)</summary>
<textarea id="settings-extra-compose-external" rows="2" placeholder="One absolute path per line, e.g. /mnt/user/appdata/shared/gpu.compose.yml"></textarea>
</details>
<div class="settings-field-help">Selected files are appended as additional <code>-f</code> flags after the main compose and override files (e.g., GPU or environment overrides). Setting this disables default file discovery.</div>
</div>

<div class="settings-field">
<label for="settings-env-path">External ENV File Path</label>
<input type="text" id="settings-env-path" placeholder="Default (uses .env in compose source folder)" data-pickroot="/" data-picktop="/mnt" data-pickcloseonfile="true">
Expand Down
15 changes: 15 additions & 0 deletions source/compose.manager/include/ContainerCache.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
<?php

$cacheFile = '/boot/config/plugins/compose.manager/containers.cache.json';
$cache = [];

if (is_file($cacheFile)) {
$decoded = json_decode(file_get_contents($cacheFile), true);
if (is_array($decoded)) {
$cache = $decoded;
}
}

header('Content-Type: application/json');
echo json_encode($cache, JSON_UNESCAPED_SLASHES);
exit;
125 changes: 123 additions & 2 deletions source/compose.manager/include/Exec.php
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}
}

if (!function_exists('composeLoadPersistentContainerCache')) {
function composeLoadPersistentContainerCache(): array
{
Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -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);
}
Expand Down Expand Up @@ -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';
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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) {
Expand Down
67 changes: 65 additions & 2 deletions source/compose.manager/include/Util.php
Original file line number Diff line number Diff line change
Expand Up @@ -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.
*
Expand Down Expand Up @@ -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 = [];
Expand All @@ -1979,6 +2033,10 @@ private function getComposeFilePaths(): array
$paths[] = $extraFile;
}

foreach ($this->getExtraComposeFiles() as $extraFile) {
$paths[] = $extraFile;
}

$normalized = [];
$unique = [];
foreach ($paths as $path) {
Expand Down Expand Up @@ -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
*/
Expand All @@ -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';
}

Expand Down
Loading