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
7 changes: 6 additions & 1 deletion source/compose.manager/include/ComposeList.php
Original file line number Diff line number Diff line change
Expand Up @@ -176,11 +176,16 @@ function composeRowBuildFailurePayload(string $composeRoot, string $project, str
exit;
}
try {
$stackInfos = [StackInfo::fromProject($compose_root, $project)];
$stackInfo = StackInfo::fromProject($compose_root, $project);
} catch (\Throwable $e) {
echo json_encode(composeRowBuildFailurePayload($compose_root, $project, $e->getMessage()));
exit;
}
// The stack's containers from one docker ps call, as the full list gets them
// (StackInfo::allFromRoot()), rather than docker compose ps, which reads the
// compose files first and takes several times as long for every row.
$stackInfo->setContainerList(StackInfo::containersByProject($stackInfo->projectName)[$stackInfo->projectName] ?? []);
$stackInfos = [$stackInfo];
} else {
$stackInfos = StackInfo::allFromRoot($compose_root);
}
Expand Down
50 changes: 37 additions & 13 deletions source/compose.manager/include/Util.php
Original file line number Diff line number Diff line change
Expand Up @@ -3395,8 +3395,8 @@ private function isProfilesCacheStale(): bool
* Extract available profiles via `docker compose config --profiles`.
*
* Mirrors the approach used by {@see getDefinedServices()}. On success
* the result is written back to the profiles metadata file so subsequent
* reads hit the fast-path cache.
* the result, even an empty one, is written back to the profiles metadata
* file so subsequent reads hit the fast-path cache.
*
* @return string[]
*/
Expand All @@ -3413,17 +3413,23 @@ private function extractProfilesFromCompose(): array
}
$cmd .= " config --profiles 2>/dev/null";

$output = shell_exec($cmd);
if (!is_string($output) || trim($output) === '') {
$output = [];
$exitCode = 0;
exec($cmd, $output, $exitCode);
if ($exitCode !== 0) {
// Compose could not read the stack (a broken compose file, say): nothing is
// cached, so the next read asks again once it is fixed.
return [];
}

$profiles = array_values(array_filter(
array_map('trim', explode("\n", trim($output))),
array_map('trim', $output),
fn(string $p): bool => $p !== ''
));

// Write-through: persist so future reads hit the cache.
// Write-through: persist so future reads hit the cache. An empty list is cached
// too: most stacks have no profiles, and asking compose again costs about 0.1 s
// per stack on every load of the stack list.
$this->writeMetadata('profiles', json_encode($profiles));

return $profiles;
Expand Down Expand Up @@ -4197,8 +4203,31 @@ public static function allFromRoot(string $composeRoot, bool $skipDocker = false

// Batch-preload container data with a single docker ps call to avoid
// O(n) docker invocations when callers iterate getContainerList().
$containersByProject = self::containersByProject();
foreach ($stacks as $stack) {
$key = $stack->projectName;
$stack->setContainerList($containersByProject[$key] ?? []);
}

return $stacks;
}

/**
* The containers of compose projects, from a single `docker ps` call, by project name.
*
* Rows are `docker ps --format json` rows, filtered on the compose project label.
* That is much quicker than `docker compose ps` for each stack, which has to read
* the stack's compose files first. Pass a project name to get only that project's
* containers.
*
* @param string|null $projectName One project, or null for every compose project
* @return array<string, array[]> Rows by project name
*/
public static function containersByProject(?string $projectName = null): array
Comment thread
grantemsley marked this conversation as resolved.
{
$filter = 'label=com.docker.compose.project' . ($projectName === null ? '' : '=' . $projectName);
$containersByProject = [];
$psOutput = shell_exec("docker ps -a --filter label=com.docker.compose.project --format json 2>/dev/null");
$psOutput = shell_exec('docker ps -a --filter ' . escapeshellarg($filter) . ' --format json 2>/dev/null');
if ($psOutput) {
foreach (explode("\n", trim($psOutput)) as $line) {
if ($line === '') {
Expand All @@ -4215,12 +4244,7 @@ public static function allFromRoot(string $composeRoot, bool $skipDocker = false
}
}
}
foreach ($stacks as $stack) {
$key = $stack->projectName;
$stack->setContainerList($containersByProject[$key] ?? []);
}

return $stacks;
return $containersByProject;
}

/**
Expand Down
93 changes: 93 additions & 0 deletions tests/unit/StackInfoTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -1011,6 +1011,99 @@ public function testGetProfilesEmptyWhenNoFile(): void
$this->assertSame([], $info->getProfiles());
}

public function testAStackWithoutProfilesAsksComposeOnlyOnce(): void
{
$stackDir = $this->tempRoot . '/no-profiles';
mkdir($stackDir);
file_put_contents($stackDir . '/compose.yaml', "services:\n web:\n image: nginx\n");
// docker compose config --profiles prints nothing for a stack without profiles.
$calls = $this->withDockerStandIn(0, function () use ($stackDir): void {
$this->assertSame([], \StackInfo::fromProject($this->tempRoot, 'no-profiles')->getProfiles());
\StackInfo::clearCache();
$this->assertSame([], \StackInfo::fromProject($this->tempRoot, 'no-profiles')->getProfiles());
});

$this->assertCount(1, $calls);
$this->assertSame('[]', file_get_contents($stackDir . '/profiles'));
}

public function testProfilesAreNotCachedWhenComposeCannotReadTheStack(): void
{
$stackDir = $this->tempRoot . '/broken';
mkdir($stackDir);
file_put_contents($stackDir . '/compose.yaml', "services:\n web:\n image: nginx\n");
$calls = $this->withDockerStandIn(1, function (): void {
$this->assertSame([], \StackInfo::fromProject($this->tempRoot, 'broken')->getProfiles());
\StackInfo::clearCache();
$this->assertSame([], \StackInfo::fromProject($this->tempRoot, 'broken')->getProfiles());
});

// Asked again, since the failure might be fixed by the next read.
$this->assertCount(2, $calls);
$this->assertFileDoesNotExist($stackDir . '/profiles');
}

public function testContainersByProjectGroupsEveryProjectsContainersByItsLabel(): void
{
// docker ps --format json prints one row per line; Labels is one comma-separated string.
$psOutput = implode("\n", [
json_encode(['Names' => 'web-app-1', 'Labels' => 'com.docker.compose.project=web,com.docker.compose.service=app']),
// The project label is not always first, and other compose labels start the same way.
json_encode(['Names' => 'web-db-1', 'Labels' => 'com.docker.compose.project.working_dir=/mnt/web,com.docker.compose.project=web,com.docker.compose.service=db']),
json_encode(['Names' => 'media-plex-1', 'Labels' => 'com.docker.compose.service=plex,com.docker.compose.project=media']),
]) . "\n";
$byProject = [];
$calls = $this->withDockerStandIn(0, function () use (&$byProject): void {
$byProject = \StackInfo::containersByProject();
}, $psOutput);

$this->assertSame(['web', 'media'], array_keys($byProject));
$this->assertSame(['web-app-1', 'web-db-1'], array_column($byProject['web'], 'Names'));
$this->assertSame(['media-plex-1'], array_column($byProject['media'], 'Names'));
// Every compose project: the label filter has no value.
$this->assertSame(['ps -a --filter label=com.docker.compose.project --format json'], $calls);
}

public function testContainersByProjectAsksDockerForOneProjectOnly(): void
{
$psOutput = json_encode(['Names' => 'web-app-1', 'Labels' => 'com.docker.compose.project=web']) . "\n";
$byProject = [];
$calls = $this->withDockerStandIn(0, function () use (&$byProject): void {
$byProject = \StackInfo::containersByProject('web');
}, $psOutput);

$this->assertSame(['web'], array_keys($byProject));
$this->assertSame(['ps -a --filter label=com.docker.compose.project=web --format json'], $calls);
}

/**
* Run $work with a docker on PATH that prints $output and exits with $exitCode,
* and return the arguments of every call made to it, one line per call.
*
* @return string[]
*/
private function withDockerStandIn(int $exitCode, callable $work, string $output = ''): array
{
$binDir = $this->tempRoot . '/bin';
$callsFile = $this->tempRoot . '/docker-calls';
$outputFile = $this->tempRoot . '/docker-output';
mkdir($binDir);
file_put_contents($outputFile, $output);
file_put_contents(
$binDir . '/docker',
"#!/bin/sh\necho \"\$*\" >> " . escapeshellarg($callsFile) . "\ncat " . escapeshellarg($outputFile) . "\nexit $exitCode\n"
);
chmod($binDir . '/docker', 0755);
$originalPath = (string) getenv('PATH');
putenv('PATH=' . $binDir . ':' . $originalPath);
try {
$work();
} finally {
putenv('PATH=' . $originalPath);
}
return is_file($callsFile) ? file($callsFile, FILE_IGNORE_NEW_LINES) : [];
}

public function testGetProfilesEmptyForInvalidJson(): void
{
$stack = 'bad-json';
Expand Down
Loading