From faa6fd0eb0a7c57242b97221baeee351ccb63324 Mon Sep 17 00:00:00 2001 From: Grant Emsley Date: Sun, 4 Oct 2026 00:34:22 -0400 Subject: [PATCH 1/2] fix(override): prune services the compose file no longer has pruneOrphanOverrideServices() asks docker compose which services the main compose file defines, so it can drop override entries for the rest. The -f list it used included the managed override itself, so an entry for a removed or renamed service (labels only, no image) made compose fail with 'has neither an image nor a build context', the list came back empty, and nothing was ever pruned. The following up then failed on the same entry. The services are now asked for without the managed override and the icon override generated from it. Default file discovery mode is unchanged: it passes no -f flags to leave one out of. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_011iUWDG8wTp8MhsFDqNC4NL --- source/compose.manager/include/Util.php | 41 +++++++- .../unit/PruneOrphanOverrideServicesTest.php | 97 +++++++++++++++++++ 2 files changed, 137 insertions(+), 1 deletion(-) create mode 100644 tests/unit/PruneOrphanOverrideServicesTest.php diff --git a/source/compose.manager/include/Util.php b/source/compose.manager/include/Util.php index e50b030f..4771fd94 100644 --- a/source/compose.manager/include/Util.php +++ b/source/compose.manager/include/Util.php @@ -3536,7 +3536,7 @@ private function getMainFileServices(): array return []; } - $cmd = "docker compose " . $this->buildComposeFileFlags(); + $cmd = "docker compose " . $this->buildComposeFileFlagsWithoutManagedOverride(); $envFlag = $this->buildEnvFileFlag(); if ($envFlag !== '') { $cmd .= " " . $envFlag; @@ -3553,6 +3553,45 @@ private function getMainFileServices(): array })); } + /** + * The -f flags for this stack, leaving out the plugin-managed override and + * the icon override generated from it. + * + * Both files hold only labels. An entry for a service the compose file no + * longer has defines a service with no image, and compose refuses the + * whole project over it ("has neither an image nor a build context"), so + * they must be left out when asking which services are valid. + * + * With default file discovery there are no -f flags to leave one out of, + * so this returns the same as buildComposeFileFlags(). + * + * @return string + */ + private function buildComposeFileFlagsWithoutManagedOverride(): string + { + if ($this->useDefaultComposeFileDiscovery()) { + return $this->buildComposeFileFlags(); + } + + $leftOut = []; + foreach ([$this->overrideInfo->getProjectOverridePath(), $this->getIconNormalizationOverridePath()] as $path) { + if ($path !== null) { + $leftOut[$this->normalizeComposeFilePath($path)] = true; + } + } + + $flags = []; + foreach ($this->getComposeFilePaths() as $filePath) { + if (isset($leftOut[$this->normalizeComposeFilePath($filePath)])) { + continue; + } + if (is_file($filePath)) { + $flags[] = '-f ' . escapeshellarg($filePath); + } + } + return implode(' ', $flags); + } + /** * Build the common compose CLI arguments for this stack. * diff --git a/tests/unit/PruneOrphanOverrideServicesTest.php b/tests/unit/PruneOrphanOverrideServicesTest.php new file mode 100644 index 00000000..9836394f --- /dev/null +++ b/tests/unit/PruneOrphanOverrideServicesTest.php @@ -0,0 +1,97 @@ +root = $this->createTempDir(); + $this->bin = $this->createTempDir(); + $this->originalPath = (string) getenv('PATH'); + + file_put_contents($this->bin . '/docker', <<<'SH' +#!/bin/bash +# Record the call, then answer like docker compose config --services. +printf '%s\n' "$*" >> "$(dirname "$0")/calls.log" +for arg in "$@"; do + case "$arg" in + *override*) + echo 'service "gone" has neither an image nor a build context specified: invalid compose project' >&2 + exit 1 + ;; + esac +done +echo web +SH); + chmod($this->bin . '/docker', 0755); + putenv('PATH=' . $this->bin . ':' . $this->originalPath); + } + + protected function tearDown(): void + { + putenv('PATH=' . $this->originalPath); + parent::tearDown(); + } + + public function testServiceNoLongerInTheComposeFileIsPrunedFromTheManagedOverride(): void + { + $stackDir = $this->root . '/mystack'; + mkdir($stackDir); + file_put_contents($stackDir . '/compose.yaml', "services:\n web:\n image: traefik/whoami\n"); + $stack = \StackInfo::fromProject($this->root, 'mystack'); + $override = (string) $stack->getOverridePath(); + file_put_contents( + $override, + "services:\n web:\n labels:\n net.unraid.docker.webui: http://x\n" + . " gone:\n labels:\n net.unraid.docker.webui: http://y\n" + ); + \StackInfo::clearCache(); + $stack = \StackInfo::fromProject($this->root, 'mystack'); + + $result = $stack->pruneOrphanOverrideServices(); + + $this->assertSame(['gone'], $result['removed']); + $content = (string) file_get_contents($override); + $this->assertStringNotContainsString('gone', $content); + $this->assertStringContainsString('web', $content); + // The services were asked for without the override. + $this->assertStringNotContainsString($override, (string) file_get_contents($this->bin . '/calls.log')); + } + + public function testOverrideWithOnlyCurrentServicesIsLeftAlone(): void + { + $stackDir = $this->root . '/mystack'; + mkdir($stackDir); + file_put_contents($stackDir . '/compose.yaml', "services:\n web:\n image: traefik/whoami\n"); + $stack = \StackInfo::fromProject($this->root, 'mystack'); + $override = (string) $stack->getOverridePath(); + $content = "services:\n web:\n labels:\n net.unraid.docker.webui: http://x\n"; + file_put_contents($override, $content); + \StackInfo::clearCache(); + + $result = \StackInfo::fromProject($this->root, 'mystack')->pruneOrphanOverrideServices(); + + $this->assertFalse($result['changed']); + $this->assertSame($content, file_get_contents($override)); + } +} From 6b12e7082ed2c7bd4a4c1d8ab50655423506dda8 Mon Sep 17 00:00:00 2001 From: Grant Emsley Date: Tue, 6 Oct 2026 12:13:34 -0400 Subject: [PATCH 2/2] fix(override): leave the override out with default file discovery too With use_default_compose_files on, the services query ran with only --project-directory, so compose found compose.override.yaml next to the compose file by itself and refused the project over the orphan entry. Pruning stayed off in that mode. Name the files with -f in that mode as well, from the same project directory, leaving out the managed override as before. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01PnMScBowJk7yyfEwraTdgg --- source/compose.manager/include/Util.php | 13 ++--- .../unit/PruneOrphanOverrideServicesTest.php | 54 +++++++++++++++++-- 2 files changed, 56 insertions(+), 11 deletions(-) diff --git a/source/compose.manager/include/Util.php b/source/compose.manager/include/Util.php index 4771fd94..3fdf3818 100644 --- a/source/compose.manager/include/Util.php +++ b/source/compose.manager/include/Util.php @@ -3562,17 +3562,15 @@ private function getMainFileServices(): array * whole project over it ("has neither an image nor a build context"), so * they must be left out when asking which services are valid. * - * With default file discovery there are no -f flags to leave one out of, - * so this returns the same as buildComposeFileFlags(). + * With default file discovery, compose would find the managed override by + * itself: it sits next to the compose file under the override name + * compose looks for. So the files are named with -f in that mode too, + * from the same project directory compose would use. * * @return string */ private function buildComposeFileFlagsWithoutManagedOverride(): string { - if ($this->useDefaultComposeFileDiscovery()) { - return $this->buildComposeFileFlags(); - } - $leftOut = []; foreach ([$this->overrideInfo->getProjectOverridePath(), $this->getIconNormalizationOverridePath()] as $path) { if ($path !== null) { @@ -3581,6 +3579,9 @@ private function buildComposeFileFlagsWithoutManagedOverride(): string } $flags = []; + if ($this->useDefaultComposeFileDiscovery()) { + $flags[] = '--project-directory ' . escapeshellarg($this->composeSource); + } foreach ($this->getComposeFilePaths() as $filePath) { if (isset($leftOut[$this->normalizeComposeFilePath($filePath)])) { continue; diff --git a/tests/unit/PruneOrphanOverrideServicesTest.php b/tests/unit/PruneOrphanOverrideServicesTest.php index 9836394f..89902529 100644 --- a/tests/unit/PruneOrphanOverrideServicesTest.php +++ b/tests/unit/PruneOrphanOverrideServicesTest.php @@ -13,7 +13,9 @@ * * The stand-in behaves like docker compose does for `config --services`: it * fails when it is given the managed override with an entry for a service - * that has no image, and otherwise prints the compose file's services. + * that has no image, and otherwise prints the compose file's services. Given + * no -f flag, it finds compose.override.yaml in the project directory by + * itself, as compose does. */ final class PruneOrphanOverrideServicesTest extends TestCase { @@ -33,14 +35,28 @@ protected function setUp(): void #!/bin/bash # Record the call, then answer like docker compose config --services. printf '%s\n' "$*" >> "$(dirname "$0")/calls.log" +refuse() { + echo 'service "gone" has neither an image nor a build context specified: invalid compose project' >&2 + exit 1 +} +project_directory='' +files_named=no +previous='' for arg in "$@"; do case "$arg" in - *override*) - echo 'service "gone" has neither an image nor a build context specified: invalid compose project' >&2 - exit 1 - ;; + *override*) refuse ;; esac + if [ "$previous" = '--project-directory' ]; then + project_directory="$arg" + fi + if [ "$previous" = '-f' ]; then + files_named=yes + fi + previous="$arg" done +if [ "$files_named" = no ] && [ -f "$project_directory/compose.override.yaml" ]; then + refuse +fi echo web SH); chmod($this->bin . '/docker', 0755); @@ -78,6 +94,34 @@ public function testServiceNoLongerInTheComposeFileIsPrunedFromTheManagedOverrid $this->assertStringNotContainsString($override, (string) file_get_contents($this->bin . '/calls.log')); } + public function testServiceIsPrunedWhenTheStackUsesDefaultFileDiscovery(): void + { + $stackDir = $this->root . '/mystack'; + mkdir($stackDir); + file_put_contents($stackDir . '/compose.yaml', "services:\n web:\n image: traefik/whoami\n"); + file_put_contents($stackDir . '/use_default_compose_files', 'true'); + $stack = \StackInfo::fromProject($this->root, 'mystack'); + $this->assertTrue($stack->useDefaultComposeFileDiscovery()); + $override = (string) $stack->getOverridePath(); + $this->assertSame($stackDir . '/compose.override.yaml', $override); + file_put_contents( + $override, + "services:\n web:\n labels:\n net.unraid.docker.webui: http://x\n" + . " gone:\n labels:\n net.unraid.docker.webui: http://y\n" + ); + \StackInfo::clearCache(); + $stack = \StackInfo::fromProject($this->root, 'mystack'); + + $result = $stack->pruneOrphanOverrideServices(); + + $this->assertSame(['gone'], $result['removed']); + $this->assertStringNotContainsString('gone', (string) file_get_contents($override)); + // The compose file was named, so compose could not pick up the override by itself. + $calls = (string) file_get_contents($this->bin . '/calls.log'); + $this->assertStringContainsString("-f {$stackDir}/compose.yaml", $calls); + $this->assertStringContainsString("--project-directory {$stackDir}", $calls); + } + public function testOverrideWithOnlyCurrentServicesIsLeftAlone(): void { $stackDir = $this->root . '/mystack';