diff --git a/devservices/constants.py b/devservices/constants.py index b8c3c05..86dd1be 100644 --- a/devservices/constants.py +++ b/devservices/constants.py @@ -2,9 +2,10 @@ import os -MINIMUM_DOCKER_COMPOSE_VERSION = "2.21.0" +MINIMUM_DOCKER_COMPOSE_VERSION = "2.29.7" DEVSERVICES_DIR_NAME = "devservices" CONFIG_FILE_NAME = "config.yml" +DOCKER_USER_PLUGIN_DIR = os.path.expanduser("~/.docker/cli-plugins/") DEVSERVICES_LOCAL_DIR = os.path.expanduser("~/.local/share/sentry-devservices") DEVSERVICES_LOCAL_DEPENDENCIES_DIR = os.path.join(DEVSERVICES_LOCAL_DIR, "dependencies") diff --git a/devservices/exceptions.py b/devservices/exceptions.py index b2f9558..d8a8a91 100644 --- a/devservices/exceptions.py +++ b/devservices/exceptions.py @@ -31,8 +31,8 @@ class ConfigParseError(ConfigError): pass -class DockerComposeVersionError(Exception): - """Raised when the Docker Compose version is unsupported.""" +class DockerComposeInstallationError(Exception): + """Raised when the Docker Compose installation fails.""" pass diff --git a/devservices/utils/docker_compose.py b/devservices/utils/docker_compose.py index 6f1637f..2e52c1b 100644 --- a/devservices/utils/docker_compose.py +++ b/devservices/utils/docker_compose.py @@ -1,9 +1,14 @@ from __future__ import annotations import os +import platform import re +import shutil import subprocess +import tempfile +import time from typing import cast +from urllib.request import urlretrieve from packaging import version @@ -11,9 +16,10 @@ from devservices.constants import DEVSERVICES_DIR_NAME from devservices.constants import DEVSERVICES_LOCAL_DEPENDENCIES_DIR from devservices.constants import DEVSERVICES_LOCAL_DEPENDENCIES_DIR_KEY +from devservices.constants import DOCKER_USER_PLUGIN_DIR from devservices.constants import MINIMUM_DOCKER_COMPOSE_VERSION from devservices.exceptions import DockerComposeError -from devservices.exceptions import DockerComposeVersionError +from devservices.exceptions import DockerComposeInstallationError from devservices.utils.dependencies import install_dependencies from devservices.utils.dependencies import verify_local_dependencies from devservices.utils.services import Service @@ -36,6 +42,94 @@ def get_active_docker_compose_projects() -> list[str]: return running_projects.split("\n")[:-1] +def install_docker_compose() -> None: + # Determine the platform + system = platform.system() + machine = platform.machine() + + # Map machine architecture to Docker's naming convention + arch_map = { + "x86_64": "x86_64", + "AMD64": "x86_64", + "arm64": "aarch64", + "aarch64": "aarch64", + "ARM64": "aarch64", + } + + arch = arch_map.get(machine) + if not arch: + raise DockerComposeInstallationError(f"Unsupported architecture: {machine}") + + # Determine the download URL based on the platform + if system == "Linux": + binary_name = f"docker-compose-linux-{arch}" + elif system == "Darwin": + binary_name = f"docker-compose-darwin-{arch}" + else: + raise DockerComposeInstallationError(f"Unsupported operating system: {system}") + + url = f"https://github.com/docker/compose/releases/download/v{MINIMUM_DOCKER_COMPOSE_VERSION}/{binary_name}" + + # Create a temporary directory + with tempfile.TemporaryDirectory() as temp_dir: + temp_file = os.path.join(temp_dir, "docker-compose") + + # Download the Docker Compose binary with retries + max_retries = 3 + retry_delay_seconds = 1 + print( + f"Downloading Docker Compose {MINIMUM_DOCKER_COMPOSE_VERSION} from {url}..." + ) + for attempt in range(max_retries): + try: + urlretrieve(url, temp_file) + break + except Exception as e: + if attempt < max_retries - 1: + print( + f"Download failed. Retrying in {retry_delay_seconds} seconds... (Attempt {attempt + 1}/{max_retries})" + ) + time.sleep(retry_delay_seconds) + else: + raise DockerComposeInstallationError( + f"Failed to download Docker Compose after {max_retries} attempts: {e}" + ) + + # Make the binary executable + try: + os.chmod(temp_file, 0o755) + except Exception as e: + raise DockerComposeInstallationError( + f"Failed to set executable permissions: {e}" + ) + + destination = os.path.join(DOCKER_USER_PLUGIN_DIR, "docker-compose") + os.makedirs(DOCKER_USER_PLUGIN_DIR, exist_ok=True) + + try: + shutil.move(temp_file, destination) + except Exception as e: + raise DockerComposeInstallationError( + f"Failed to move Docker Compose binary to {destination}: {e}" + ) + + print( + f"Docker Compose {MINIMUM_DOCKER_COMPOSE_VERSION} installed successfully to {destination}" + ) + + # Verify the installation + try: + version = subprocess.run( + ["docker", "compose", "version", "--short"], capture_output=True, text=True + ).stdout + except Exception as e: + raise DockerComposeInstallationError( + f"Failed to verify Docker Compose installation: {e}" + ) + + print(f"Verified Docker Compose installation: v{version}") + + def check_docker_compose_version() -> None: cmd = ["docker", "compose", "version", "--short"] try: @@ -46,17 +140,14 @@ def check_docker_compose_version() -> None: text=True, check=True, ) - - except subprocess.CalledProcessError as e: - raise DockerComposeError( - command=" ".join(cmd), - returncode=e.returncode, - stdout=e.stdout, - stderr=e.stderr, + except subprocess.CalledProcessError: + result = None + print( + f"Docker Compose is not installed, attempting to install v{MINIMUM_DOCKER_COMPOSE_VERSION}" ) # Extract the version number from the output - version_output = result.stdout.strip() + version_output = result.stdout.strip() if result is not None else "" # Use regex to find the version number pattern = r"^(\d+\.\d+\.\d+)" @@ -69,13 +160,20 @@ def check_docker_compose_version() -> None: docker_compose_version = None if docker_compose_version is None: - raise DockerComposeVersionError("Unable to detect docker compose version") - elif version.parse(docker_compose_version) < version.parse( + print( + f"Unable to detect Docker Compose version, attempting to install v{MINIMUM_DOCKER_COMPOSE_VERSION}" + ) + elif version.parse(docker_compose_version) != version.parse( MINIMUM_DOCKER_COMPOSE_VERSION ): - raise DockerComposeVersionError( - f"Docker compose version unsupported, please upgrade to >= {MINIMUM_DOCKER_COMPOSE_VERSION}" + print( + f"Docker Compose version v{docker_compose_version} unsupported, attempting to install v{MINIMUM_DOCKER_COMPOSE_VERSION}" ) + elif version.parse(docker_compose_version) == version.parse( + MINIMUM_DOCKER_COMPOSE_VERSION + ): + return + install_docker_compose() def run_docker_compose_command( diff --git a/tests/utils/test_docker_compose.py b/tests/utils/test_docker_compose.py index 6511e6c..c804acd 100644 --- a/tests/utils/test_docker_compose.py +++ b/tests/utils/test_docker_compose.py @@ -1,44 +1,229 @@ from __future__ import annotations +import os import subprocess from unittest import mock import pytest -from devservices.exceptions import DockerComposeError -from devservices.exceptions import DockerComposeVersionError +from devservices.exceptions import DockerComposeInstallationError from devservices.utils.docker_compose import check_docker_compose_version +from devservices.utils.docker_compose import install_docker_compose @mock.patch("subprocess.run") def test_check_docker_compose_version_success(mock_run: mock.Mock) -> None: - mock_run.return_value.stdout = "2.21.0-desktop.1\n" + mock_run.return_value.stdout = "2.29.7\n" check_docker_compose_version() # Should not raise any exception @mock.patch("subprocess.run") -def test_check_docker_compose_version_unsupported(mock_run: mock.Mock) -> None: +@mock.patch( + "devservices.utils.docker_compose.install_docker_compose", side_effect=lambda: None +) +def test_check_docker_compose_version_unsupported( + mock_install_docker_compose: mock.Mock, mock_run: mock.Mock +) -> None: mock_run.return_value.stdout = "2.20.0-desktop.1\n" + check_docker_compose_version() + assert mock_install_docker_compose.is_called() + + +@mock.patch("subprocess.run") +@mock.patch( + "devservices.utils.docker_compose.install_docker_compose", side_effect=lambda: None +) +def test_check_docker_compose_invalid_version( + mock_install_docker_compose: mock.Mock, mock_run: mock.Mock +) -> None: + mock_run.return_value.stdout = "Unable to find version\n" + check_docker_compose_version() + assert mock_install_docker_compose.is_called() + + +@mock.patch( + "subprocess.run", + side_effect=subprocess.CalledProcessError( + returncode=1, cmd="docker compose version --short" + ), +) +@mock.patch( + "devservices.utils.docker_compose.install_docker_compose", side_effect=lambda: None +) +def test_check_docker_compose_command_failure( + mock_install_docker_compose: mock.Mock, mock_run: mock.Mock +) -> None: + check_docker_compose_version() + assert mock_install_docker_compose.is_called() + + +@mock.patch("platform.system", return_value="UnsupportedSystem") +@mock.patch("platform.machine", return_value="arm64") +def test_install_docker_compose_unsupported_os( + mock_system: mock.Mock, mock_machine: mock.Mock +) -> None: with pytest.raises( - DockerComposeVersionError, - match="Docker compose version unsupported, please upgrade to >= 2.21.0", + DockerComposeInstallationError, + match="Unsupported operating system: UnsupportedSystem", ): - check_docker_compose_version() + install_docker_compose() -@mock.patch("subprocess.run") -def test_check_docker_compose_version_undetected(mock_run: mock.Mock) -> None: - mock_run.return_value.stdout = "invalid_version\n" +@mock.patch("platform.system", return_value="Darwin") +@mock.patch("platform.machine", return_value="unsupported_architecture") +def test_install_docker_compose_unsupported_architecture( + mock_machine: mock.Mock, mock_system: mock.Mock +) -> None: with pytest.raises( - DockerComposeVersionError, match="Unable to detect docker compose version" + DockerComposeInstallationError, + match="Unsupported architecture: unsupported_architecture", ): - check_docker_compose_version() + install_docker_compose() -@mock.patch("subprocess.run") -def test_check_docker_compose_version_error(mock_run: mock.Mock) -> None: - mock_run.side_effect = subprocess.CalledProcessError( - 1, "docker compose version --short", stderr="Error" +@mock.patch("platform.system", return_value="Darwin") +@mock.patch("platform.machine", return_value="arm64") +@mock.patch( + "devservices.utils.docker_compose.urlretrieve", + side_effect=Exception("Connection error"), +) +def test_install_docker_compose_connection_error( + mock_urlretrieve: mock.Mock, mock_machine: mock.Mock, mock_system: mock.Mock +) -> None: + with pytest.raises( + DockerComposeInstallationError, + match="Failed to download Docker Compose after 3 attempts: Connection error", + ): + install_docker_compose() + + +@mock.patch("platform.system", return_value="Darwin") +@mock.patch("platform.machine", return_value="arm64") +@mock.patch("devservices.utils.docker_compose.urlretrieve") +def test_install_docker_compose_chmod_error( + mock_urlretrieve: mock.Mock, mock_machine: mock.Mock, mock_system: mock.Mock +) -> None: + with pytest.raises( + DockerComposeInstallationError, + match=r"Failed to set executable permissions: \[Errno 2\] No such file or directory:.*", + ): + install_docker_compose() + + +@mock.patch("platform.system", return_value="Darwin") +@mock.patch("platform.machine", return_value="arm64") +@mock.patch("devservices.utils.docker_compose.urlretrieve") +@mock.patch("devservices.utils.docker_compose.os.chmod") +def test_install_docker_compose_shutil_move_error( + mock_chmod: mock.Mock, + mock_urlretrieve: mock.Mock, + mock_machine: mock.Mock, + mock_system: mock.Mock, +) -> None: + with pytest.raises( + DockerComposeInstallationError, + match=r"Failed to move Docker Compose binary to.*", + ): + install_docker_compose() + + +@mock.patch("platform.system", return_value="Darwin") +@mock.patch("platform.machine", return_value="arm64") +@mock.patch("devservices.utils.docker_compose.urlretrieve") +@mock.patch("devservices.utils.docker_compose.os.chmod") +@mock.patch("devservices.utils.docker_compose.shutil.move") +@mock.patch( + "devservices.utils.docker_compose.subprocess.run", + side_effect=Exception("Docker Compose failed"), +) +def test_install_docker_compose_compose_verification_error( + mock_subprocess_run: mock.Mock, + mock_shutil_move: mock.Mock, + mock_chmod: mock.Mock, + mock_urlretrieve: mock.Mock, + mock_machine: mock.Mock, + mock_system: mock.Mock, +) -> None: + with pytest.raises( + DockerComposeInstallationError, + match="Failed to verify Docker Compose installation: Docker Compose failed", + ): + install_docker_compose() + + +@mock.patch("tempfile.TemporaryDirectory") +@mock.patch("platform.system", return_value="Darwin") +@mock.patch("platform.machine", return_value="arm64") +@mock.patch("devservices.utils.docker_compose.urlretrieve") +@mock.patch("devservices.utils.docker_compose.os.chmod") +@mock.patch("devservices.utils.docker_compose.shutil.move") +@mock.patch( + "devservices.utils.docker_compose.subprocess.run", + return_value=subprocess.CompletedProcess( + args=["docker", "compose", "version", "--short"], + returncode=0, + stdout="2.29.7\n", + ), +) +def test_install_docker_compose_macos_arm64( + mock_subprocess_run: mock.Mock, + mock_shutil_move: mock.Mock, + mock_chmod: mock.Mock, + mock_urlretrieve: mock.Mock, + mock_machine: mock.Mock, + mock_system: mock.Mock, + mock_tempdir: mock.Mock, +) -> None: + mock_tempdir.return_value.__enter__.return_value = "tempdir" + install_docker_compose() + mock_urlretrieve.assert_called_once_with( + "https://github.com/docker/compose/releases/download/v2.29.7/docker-compose-darwin-aarch64", + "tempdir/docker-compose", + ) + mock_chmod.assert_called_once_with("tempdir/docker-compose", 0o755) + mock_shutil_move.assert_called_once_with( + "tempdir/docker-compose", + os.path.expanduser("~/.docker/cli-plugins/docker-compose"), + ) + mock_subprocess_run.assert_called_once_with( + ["docker", "compose", "version", "--short"], capture_output=True, text=True + ) + + +@mock.patch("tempfile.TemporaryDirectory") +@mock.patch("platform.system", return_value="Linux") +@mock.patch("platform.machine", return_value="x86_64") +@mock.patch("devservices.utils.docker_compose.urlretrieve") +@mock.patch("devservices.utils.docker_compose.os.chmod") +@mock.patch("devservices.utils.docker_compose.shutil.move") +@mock.patch( + "devservices.utils.docker_compose.subprocess.run", + return_value=subprocess.CompletedProcess( + args=["docker", "compose", "version", "--short"], + returncode=0, + stdout="2.29.7\n", + ), +) +def test_install_docker_compose_linux_x86( + mock_subprocess_run: mock.Mock, + mock_shutil_move: mock.Mock, + mock_chmod: mock.Mock, + mock_urlretrieve: mock.Mock, + mock_machine: mock.Mock, + mock_system: mock.Mock, + mock_tempdir: mock.Mock, +) -> None: + mock_tempdir.return_value.__enter__.return_value = "tempdir" + install_docker_compose() + mock_urlretrieve.assert_called_once_with( + "https://github.com/docker/compose/releases/download/v2.29.7/docker-compose-linux-x86_64", + "tempdir/docker-compose", + ) + mock_chmod.assert_called_once_with("tempdir/docker-compose", 0o755) + mock_shutil_move.assert_called_once_with( + "tempdir/docker-compose", + os.path.expanduser("~/.docker/cli-plugins/docker-compose"), + ) + mock_subprocess_run.assert_called_once_with( + ["docker", "compose", "version", "--short"], capture_output=True, text=True ) - with pytest.raises(DockerComposeError): - check_docker_compose_version()