This is an automated email from the ASF dual-hosted git repository.

janhoy pushed a commit to branch main
in repository https://gitbox.apache.org/repos/asf/solr-orbit.git


The following commit(s) were added to refs/heads/main by this push:
     new 041625c3 Find Docker Compose under either of its names (#120)
041625c3 is described below

commit 041625c3fe326b049483932a4f169c188298d381
Author: Jan Høydahl <[email protected]>
AuthorDate: Fri Oct 16 16:34:48 2026 +0200

    Find Docker Compose under either of its names (#120)
---
 .github/workflows/integration-test.yml          |   3 +
 it/__init__.py                                  |  12 ++-
 solrorbit/builder/launcher.py                   |   5 +-
 solrorbit/builder/launchers/docker_launcher.py  |   3 +-
 solrorbit/utils/docker.py                       |  76 ++++++++++++++++
 tests/builder/launcher_test.py                  |  16 +++-
 tests/builder/launchers/docker_launcher_test.py |  15 ++-
 tests/utils/docker_test.py                      | 116 ++++++++++++++++++++++++
 8 files changed, 231 insertions(+), 15 deletions(-)

diff --git a/.github/workflows/integration-test.yml 
b/.github/workflows/integration-test.yml
index ede2eea8..b1256acb 100644
--- a/.github/workflows/integration-test.yml
+++ b/.github/workflows/integration-test.yml
@@ -60,6 +60,9 @@ jobs:
         run: |
           java -version
           docker --version
+          # Which Compose is present decides how the launchers invoke it, and
+          # its absence is not otherwise visible until a test fails.
+          docker compose version || docker-compose version || echo "no Docker 
Compose found"
           uv --version
 
       - name: Run the integration suite
diff --git a/it/__init__.py b/it/__init__.py
index 789f9fbe..aad2b3cf 100644
--- a/it/__init__.py
+++ b/it/__init__.py
@@ -37,8 +37,8 @@ import datetime
 
 import pytest
 
-from solrorbit import client, config, version, paths
-from solrorbit.utils import process
+from solrorbit import client, config, exceptions, paths, version
+from solrorbit.utils import docker, process
 
 CONFIG_NAMES = ["in-memory-it"]
 # Overridable so a CI run can pin a single version without a code change.
@@ -146,8 +146,12 @@ def free_port():
 def check_prerequisites():
     if process.run_subprocess_with_logging("docker ps") != 0:
         raise AssertionError("Docker must be installed and the daemon must be 
up and running to run integration tests.")
-    if process.run_subprocess_with_logging("docker-compose --help") != 0:
-        raise AssertionError("Docker Compose is required to run integration 
tests.")
+    try:
+        # Compose v2 is a "docker compose" subcommand; the standalone binary is
+        # v1 and is absent from current CI images.
+        docker.compose_command()
+    except exceptions.SystemSetupError as exc:
+        raise AssertionError(str(exc)) from exc
 
 
 class ConfigFile:
diff --git a/solrorbit/builder/launcher.py b/solrorbit/builder/launcher.py
index cc41ff4d..279d1d48 100644
--- a/solrorbit/builder/launcher.py
+++ b/solrorbit/builder/launcher.py
@@ -34,7 +34,7 @@ import psutil
 
 from solrorbit import time, exceptions, telemetry
 from solrorbit.builder import cluster, java_resolver
-from solrorbit.utils import io, opts, process
+from solrorbit.utils import docker, io, opts, process
 
 
 class DockerLauncher:
@@ -76,7 +76,8 @@ class DockerLauncher:
         self._wait_for_healthy_running_container(container_id, 
DockerLauncher.PROCESS_WAIT_TIMEOUT_SECONDS)
 
     def _docker_compose(self, compose_config, cmd):
-        return "docker-compose -f {} {}".format(os.path.join(compose_config, 
"docker-compose.yml"), cmd)
+        return "{} -f {} {}".format(docker.compose_command(),
+                                    os.path.join(compose_config, 
"docker-compose.yml"), cmd)
 
     def _get_container_id(self, compose_config):
         compose_ps_cmd = self._docker_compose(compose_config, "ps -q")
diff --git a/solrorbit/builder/launchers/docker_launcher.py 
b/solrorbit/builder/launchers/docker_launcher.py
index 77306dfa..d50e28b9 100644
--- a/solrorbit/builder/launchers/docker_launcher.py
+++ b/solrorbit/builder/launchers/docker_launcher.py
@@ -4,6 +4,7 @@ import os
 from solrorbit import telemetry, time
 from solrorbit.builder import cluster
 from solrorbit.builder.launchers.launcher import Launcher
+from solrorbit.utils import docker
 from solrorbit.utils.periodic_waiter import PeriodicWaiter
 
 
@@ -46,7 +47,7 @@ class DockerLauncher(Launcher):
     def _docker_compose(self, compose_config, cmd):
         docker_compose_file = self._get_docker_compose_file(compose_config)
 
-        return f"docker-compose -f {docker_compose_file} {cmd}"
+        return f"{docker.compose_command()} -f {docker_compose_file} {cmd}"
 
     def _get_docker_compose_file(self, compose_config):
         return os.path.join(compose_config, "docker-compose.yml")
diff --git a/solrorbit/utils/docker.py b/solrorbit/utils/docker.py
new file mode 100644
index 00000000..acf22a89
--- /dev/null
+++ b/solrorbit/utils/docker.py
@@ -0,0 +1,76 @@
+# SPDX-License-Identifier: Apache-2.0
+#
+# Licensed to the Apache Software Foundation (ASF) under one or more
+# contributor license agreements. See the NOTICE file distributed with
+# this work for additional information regarding copyright ownership.
+# The ASF licenses this file to You under the Apache License, Version 2.0
+# (the "License"); you may not use this file except in compliance with
+# the License. You may obtain a copy of the License at
+#
+#    http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+
+"""Locating Docker Compose, which ships under two different names."""
+
+import functools
+import logging
+import subprocess
+
+from solrorbit import exceptions
+
+#: Compose v2, a subcommand of the Docker CLI. The current product, and on most
+#: hosts the only one present.
+COMPOSE_V2 = ("docker", "compose")
+
+#: Compose v1, a standalone binary. End of life since June 2023 and absent from
+#: current CI images and Linux distributions, but still installed by older
+#: Docker Desktop versions, so it remains worth falling back to.
+COMPOSE_V1 = ("docker-compose",)
+
+PROBE_TIMEOUT_SECONDS = 30
+
+logger = logging.getLogger(__name__)
+
+
+def _responds(command):
+    """Whether ``command version`` runs and reports success.
+
+    Both v1 and v2 implement ``version``, so one probe serves for either.
+    """
+    try:
+        completed = subprocess.run([*command, "version"], capture_output=True,
+                                   check=False, timeout=PROBE_TIMEOUT_SECONDS)
+    except (OSError, subprocess.SubprocessError) as exc:
+        # A missing binary raises FileNotFoundError rather than returning
+        # non-zero, which is the whole reason this lookup exists.
+        logger.debug("Docker Compose candidate %s is unavailable: %s", 
command, exc)
+        return False
+    return completed.returncode == 0
+
+
[email protected]_cache(maxsize=1)
+def compose_command():
+    """The Docker Compose invocation available on this host, as a string.
+
+    Prefers v2 and falls back to v1, so a host with either can run a benchmark.
+    The result is cached: this shells out, and it is called once per Compose
+    command built.
+
+    :raises SystemSetupError: if neither is present.
+    """
+    for candidate in (COMPOSE_V2, COMPOSE_V1):
+        if _responds(candidate):
+            resolved = " ".join(candidate)
+            logger.info("Using Docker Compose via [%s].", resolved)
+            return resolved
+    raise exceptions.SystemSetupError(
+        "Docker Compose is required but was not found. Install Docker Compose 
v2, "
+        "which ships with current Docker as the \"docker compose\" subcommand "
+        "(the standalone \"docker-compose\" binary is v1 and reached end of 
life "
+        "in June 2023)."
+    )
diff --git a/tests/builder/launcher_test.py b/tests/builder/launcher_test.py
index f4b99390..67ed7586 100644
--- a/tests/builder/launcher_test.py
+++ b/tests/builder/launcher_test.py
@@ -365,6 +365,14 @@ class TestClock:
 
 
 class DockerLauncherTests(TestCase):
+    def setUp(self):
+        # Pin the Compose flavour so these assertions do not depend on which
+        # Compose is installed on the machine running the tests.
+        composer = mock.patch("solrorbit.utils.docker.compose_command",
+                              return_value="docker compose")
+        self.addCleanup(composer.stop)
+        composer.start()
+
     @mock.patch("solrorbit.utils.process.run_subprocess_with_logging")
     @mock.patch("solrorbit.utils.process.run_subprocess_with_output")
     def test_starts_container_successfully(self, run_subprocess_with_output, 
run_subprocess_with_logging):
@@ -390,9 +398,9 @@ class DockerLauncherTests(TestCase):
         self.assertEqual("testnode", node.node_name)
         self.assertIsNotNone(node.telemetry)
 
-        run_subprocess_with_logging.assert_called_once_with("docker-compose -f 
/bin/docker-compose.yml up -d")
+        run_subprocess_with_logging.assert_called_once_with("docker compose -f 
/bin/docker-compose.yml up -d")
         run_subprocess_with_output.assert_has_calls([
-            mock.call("docker-compose -f /bin/docker-compose.yml ps -q"),
+            mock.call("docker compose -f /bin/docker-compose.yml ps -q"),
             mock.call('docker ps -a --filter "id=de604d0d" --filter 
"status=running" --filter "health=healthy" -q')
         ])
 
@@ -432,7 +440,7 @@ class DockerLauncherTests(TestCase):
 
         add_metadata_for_node.assert_called_once_with(metrics_store, 
"testnode", "127.0.0.1")
 
-        run_subprocess_with_logging.assert_called_once_with("docker-compose -f 
/bin/docker-compose.yml down")
+        run_subprocess_with_logging.assert_called_once_with("docker compose -f 
/bin/docker-compose.yml down")
 
     @mock.patch("solrorbit.telemetry.add_metadata_for_node")
     @mock.patch("solrorbit.utils.process.run_subprocess_with_logging")
@@ -447,4 +455,4 @@ class DockerLauncherTests(TestCase):
 
         self.assertEqual(0, add_metadata_for_node.call_count)
 
-        run_subprocess_with_logging.assert_called_once_with("docker-compose -f 
/bin/docker-compose.yml down")
+        run_subprocess_with_logging.assert_called_once_with("docker compose -f 
/bin/docker-compose.yml down")
diff --git a/tests/builder/launchers/docker_launcher_test.py 
b/tests/builder/launchers/docker_launcher_test.py
index 02fd2386..316e4aa1 100644
--- a/tests/builder/launchers/docker_launcher_test.py
+++ b/tests/builder/launchers/docker_launcher_test.py
@@ -11,6 +11,13 @@ from solrorbit.builder.provisioner import NodeConfiguration
 
 class DockerLauncherTests(TestCase):
     def setUp(self):
+        # Pin the Compose flavour so these assertions do not depend on which
+        # Compose is installed on the machine running the tests.
+        composer = mock.patch("solrorbit.utils.docker.compose_command",
+                              return_value="docker compose")
+        self.addCleanup(composer.stop)
+        composer.start()
+
         self.shell_executor = Mock()
         self.metrics_store = Mock()
 
@@ -40,8 +47,8 @@ class DockerLauncherTests(TestCase):
         self.assertIsNotNone(node.telemetry)
 
         self.shell_executor.execute.assert_has_calls([
-            mock.call(self.host, "docker-compose -f /bin/docker-compose.yml up 
-d"),
-            mock.call(self.host, "docker-compose -f /bin/docker-compose.yml ps 
-q", output=True),
+            mock.call(self.host, "docker compose -f /bin/docker-compose.yml up 
-d"),
+            mock.call(self.host, "docker compose -f /bin/docker-compose.yml ps 
-q", output=True),
         ])
 
     def test_container_not_started(self):
@@ -58,7 +65,7 @@ class DockerLauncherTests(TestCase):
         self.launcher.stop(self.host, nodes)
 
         add_metadata_for_node.assert_called_once_with(self.metrics_store, 
"testnode", "127.0.0.1")
-        self.shell_executor.execute.assert_called_once_with(self.host, 
"docker-compose -f /bin/docker-compose.yml down")
+        self.shell_executor.execute.assert_called_once_with(self.host, "docker 
compose -f /bin/docker-compose.yml down")
 
     @mock.patch("solrorbit.telemetry.add_metadata_for_node")
     def test_stops_container_when_no_metrics_store_is_provided(self, 
add_metadata_for_node):
@@ -68,7 +75,7 @@ class DockerLauncherTests(TestCase):
         self.launcher.stop(self.host, nodes)
 
         self.assertEqual(0, add_metadata_for_node.call_count)
-        self.shell_executor.execute.assert_called_once_with(self.host, 
"docker-compose -f /bin/docker-compose.yml down")
+        self.shell_executor.execute.assert_called_once_with(self.host, "docker 
compose -f /bin/docker-compose.yml down")
 
     def test_container_not_healthy(self):
         self.shell_executor.execute.return_value = []
diff --git a/tests/utils/docker_test.py b/tests/utils/docker_test.py
new file mode 100644
index 00000000..4347e24b
--- /dev/null
+++ b/tests/utils/docker_test.py
@@ -0,0 +1,116 @@
+# SPDX-License-Identifier: Apache-2.0
+#
+# Licensed to the Apache Software Foundation (ASF) under one or more
+# contributor license agreements. See the NOTICE file distributed with
+# this work for additional information regarding copyright ownership.
+# The ASF licenses this file to You under the Apache License, Version 2.0
+# (the "License"); you may not use this file except in compliance with
+# the License. You may obtain a copy of the License at
+#
+#    http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+
+"""Finding Docker Compose under either of its two names."""
+
+import subprocess
+import types
+
+import pytest
+
+from solrorbit import exceptions
+from solrorbit.utils import docker
+
+
+def _probe(available):
+    """A subprocess.run that succeeds only for the named commands.
+
+    A missing binary raises FileNotFoundError rather than returning non-zero,
+    which is how Compose v1's absence actually presents.
+    """
+    def run(command, **kwargs):
+        name = " ".join(command[:-1])
+        if name not in available:
+            raise FileNotFoundError(2, "No such file or directory", command[0])
+        return types.SimpleNamespace(returncode=0, stdout=b"", stderr=b"")
+    return run
+
+
[email protected](autouse=True)
+def _uncached():
+    docker.compose_command.cache_clear()
+    yield
+    docker.compose_command.cache_clear()
+
+
+class TestWhichComposeIsUsed:
+    def test_v2_is_preferred_when_both_are_present(self, monkeypatch):
+        monkeypatch.setattr(subprocess, "run", _probe({"docker compose", 
"docker-compose"}))
+        assert docker.compose_command() == "docker compose"
+
+    def test_v2_is_used_when_only_v2_is_present(self, monkeypatch):
+        # The current CI runners and every recent Docker install.
+        monkeypatch.setattr(subprocess, "run", _probe({"docker compose"}))
+        assert docker.compose_command() == "docker compose"
+
+    def test_v1_is_used_when_only_v1_is_present(self, monkeypatch):
+        # An older Docker Desktop, which is why the fallback stays.
+        monkeypatch.setattr(subprocess, "run", _probe({"docker-compose"}))
+        assert docker.compose_command() == "docker-compose"
+
+    def test_neither_present_is_an_explanatory_error(self, monkeypatch):
+        monkeypatch.setattr(subprocess, "run", _probe(set()))
+        with pytest.raises(exceptions.SystemSetupError, match="docker 
compose"):
+            docker.compose_command()
+
+    def test_a_candidate_that_runs_but_fails_is_not_used(self, monkeypatch):
+        # Docker present but the compose plugin missing: `docker compose 
version`
+        # exits non-zero rather than raising.
+        def run(command, **kwargs):
+            if command[0] == "docker-compose":
+                return types.SimpleNamespace(returncode=0, stdout=b"", 
stderr=b"")
+            return types.SimpleNamespace(returncode=1, stdout=b"", stderr=b"no 
such command")
+        monkeypatch.setattr(subprocess, "run", run)
+        assert docker.compose_command() == "docker-compose"
+
+    def test_the_probe_runs_once_however_often_it_is_asked(self, monkeypatch):
+        calls = []
+
+        def run(command, **kwargs):
+            calls.append(command)
+            return types.SimpleNamespace(returncode=0, stdout=b"", stderr=b"")
+
+        monkeypatch.setattr(subprocess, "run", run)
+        assert docker.compose_command() == docker.compose_command() == "docker 
compose"
+        assert len(calls) == 1
+
+    def test_a_probe_that_hangs_is_not_fatal(self, monkeypatch):
+        def run(command, **kwargs):
+            if command[0] == "docker":
+                raise subprocess.TimeoutExpired(cmd=command, 
timeout=docker.PROBE_TIMEOUT_SECONDS)
+            return types.SimpleNamespace(returncode=0, stdout=b"", stderr=b"")
+
+        monkeypatch.setattr(subprocess, "run", run)
+        assert docker.compose_command() == "docker-compose"
+
+
+class TestTheCommandsThatGetBuilt:
+    def test_the_local_launcher_uses_the_resolved_command(self, monkeypatch):
+        from solrorbit.builder import launcher as launcher_module
+
+        monkeypatch.setattr(subprocess, "run", _probe({"docker compose"}))
+        built = launcher_module.DockerLauncher._docker_compose(None, 
"/tmp/cfg", "up -d")
+        assert built == "docker compose -f /tmp/cfg/docker-compose.yml up -d"
+
+    def test_the_shell_launcher_uses_the_resolved_command(self, monkeypatch):
+        from solrorbit.builder.launchers import docker_launcher as 
docker_launcher_module
+
+        monkeypatch.setattr(subprocess, "run", _probe({"docker compose"}))
+        built = docker_launcher_module.DockerLauncher._docker_compose(
+            
docker_launcher_module.DockerLauncher.__new__(docker_launcher_module.DockerLauncher),
+            "/tmp/cfg", "ps -q")
+        assert built == "docker compose -f /tmp/cfg/docker-compose.yml ps -q"

Reply via email to