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"