diff --git a/source/compose.manager/include/ComposeList.php b/source/compose.manager/include/ComposeList.php index a41c4c6d..75b8064e 100755 --- a/source/compose.manager/include/ComposeList.php +++ b/source/compose.manager/include/ComposeList.php @@ -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); } diff --git a/source/compose.manager/include/Util.php b/source/compose.manager/include/Util.php index e4f9fe8d..3b9eb32a 100644 --- a/source/compose.manager/include/Util.php +++ b/source/compose.manager/include/Util.php @@ -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[] */ @@ -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; @@ -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 Rows by project name + */ + public static function containersByProject(?string $projectName = null): array + { + $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 === '') { @@ -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; } /** diff --git a/tests/unit/StackInfoTest.php b/tests/unit/StackInfoTest.php index 2496f44e..d7a9a436 100644 --- a/tests/unit/StackInfoTest.php +++ b/tests/unit/StackInfoTest.php @@ -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';