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
42 changes: 41 additions & 1 deletion source/compose.manager/include/Util.php
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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.
*
Expand Down
141 changes: 141 additions & 0 deletions tests/unit/PruneOrphanOverrideServicesTest.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,141 @@
<?php

declare(strict_types=1);

namespace ComposeManager\Tests;

use PluginTests\TestCase;

require_once '/usr/local/emhttp/plugins/compose.manager/include/Util.php';

/**
* StackInfo::pruneOrphanOverrideServices() with a stand-in docker CLI on PATH.
*
* 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. Given
* no -f flag, it finds compose.override.yaml in the project directory by
* itself, as compose does.
*/
final class PruneOrphanOverrideServicesTest extends TestCase
{
private string $root;
private string $bin;
private string $originalPath;

protected function setUp(): void
{
parent::setUp();
\StackInfo::clearCache();
$this->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));
}
}
Loading