diff --git a/source/compose.manager/include/Util.php b/source/compose.manager/include/Util.php index e50b030f..3fdf3818 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,46 @@ 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, 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 + { + $leftOut = []; + foreach ([$this->overrideInfo->getProjectOverridePath(), $this->getIconNormalizationOverridePath()] as $path) { + if ($path !== null) { + $leftOut[$this->normalizeComposeFilePath($path)] = true; + } + } + + $flags = []; + if ($this->useDefaultComposeFileDiscovery()) { + $flags[] = '--project-directory ' . escapeshellarg($this->composeSource); + } + 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..89902529 --- /dev/null +++ b/tests/unit/PruneOrphanOverrideServicesTest.php @@ -0,0 +1,141 @@ +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" +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*) 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); + 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 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'; + 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)); + } +}