devservices 1.5.1__tar.gz → 1.5.4__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- {devservices-1.5.1 → devservices-1.5.4}/PKG-INFO +1 -1
- {devservices-1.5.1 → devservices-1.5.4}/README.md +1 -1
- {devservices-1.5.1 → devservices-1.5.4}/devservices/constants.py +7 -6
- {devservices-1.5.1 → devservices-1.5.4}/devservices/exceptions.py +6 -1
- {devservices-1.5.1 → devservices-1.5.4}/devservices/main.py +7 -0
- {devservices-1.5.1 → devservices-1.5.4}/devservices/utils/dependencies.py +14 -2
- {devservices-1.5.1 → devservices-1.5.4}/devservices/utils/docker_compose.py +32 -41
- {devservices-1.5.1 → devservices-1.5.4}/devservices/utils/install_binary.py +16 -5
- {devservices-1.5.1 → devservices-1.5.4}/devservices/utils/retry.py +4 -2
- {devservices-1.5.1 → devservices-1.5.4}/devservices.egg-info/PKG-INFO +1 -1
- {devservices-1.5.1 → devservices-1.5.4}/pyproject.toml +1 -1
- {devservices-1.5.1 → devservices-1.5.4}/testing/utils.py +2 -2
- {devservices-1.5.1 → devservices-1.5.4}/tests/utils/test_dependencies.py +67 -2
- {devservices-1.5.1 → devservices-1.5.4}/tests/utils/test_docker_compose.py +87 -66
- {devservices-1.5.1 → devservices-1.5.4}/tests/utils/test_install_binary.py +56 -10
- {devservices-1.5.1 → devservices-1.5.4}/LICENSE.md +0 -0
- {devservices-1.5.1 → devservices-1.5.4}/devservices/__init__.py +0 -0
- {devservices-1.5.1 → devservices-1.5.4}/devservices/commands/__init__.py +0 -0
- {devservices-1.5.1 → devservices-1.5.4}/devservices/commands/down.py +0 -0
- {devservices-1.5.1 → devservices-1.5.4}/devservices/commands/foreground.py +0 -0
- {devservices-1.5.1 → devservices-1.5.4}/devservices/commands/list_dependencies.py +0 -0
- {devservices-1.5.1 → devservices-1.5.4}/devservices/commands/list_services.py +0 -0
- {devservices-1.5.1 → devservices-1.5.4}/devservices/commands/logs.py +0 -0
- {devservices-1.5.1 → devservices-1.5.4}/devservices/commands/purge.py +0 -0
- {devservices-1.5.1 → devservices-1.5.4}/devservices/commands/reset.py +0 -0
- {devservices-1.5.1 → devservices-1.5.4}/devservices/commands/serve.py +0 -0
- {devservices-1.5.1 → devservices-1.5.4}/devservices/commands/status.py +0 -0
- {devservices-1.5.1 → devservices-1.5.4}/devservices/commands/toggle.py +0 -0
- {devservices-1.5.1 → devservices-1.5.4}/devservices/commands/up.py +0 -0
- {devservices-1.5.1 → devservices-1.5.4}/devservices/commands/update.py +0 -0
- {devservices-1.5.1 → devservices-1.5.4}/devservices/configs/service_config.py +0 -0
- {devservices-1.5.1 → devservices-1.5.4}/devservices/utils/__init__.py +0 -0
- {devservices-1.5.1 → devservices-1.5.4}/devservices/utils/check_for_update.py +0 -0
- {devservices-1.5.1 → devservices-1.5.4}/devservices/utils/console.py +0 -0
- {devservices-1.5.1 → devservices-1.5.4}/devservices/utils/devenv.py +0 -0
- {devservices-1.5.1 → devservices-1.5.4}/devservices/utils/docker.py +0 -0
- {devservices-1.5.1 → devservices-1.5.4}/devservices/utils/file_lock.py +0 -0
- {devservices-1.5.1 → devservices-1.5.4}/devservices/utils/git.py +0 -0
- {devservices-1.5.1 → devservices-1.5.4}/devservices/utils/github.py +0 -0
- {devservices-1.5.1 → devservices-1.5.4}/devservices/utils/services.py +0 -0
- {devservices-1.5.1 → devservices-1.5.4}/devservices/utils/state.py +0 -0
- {devservices-1.5.1 → devservices-1.5.4}/devservices/utils/supervisor.py +0 -0
- {devservices-1.5.1 → devservices-1.5.4}/devservices.egg-info/SOURCES.txt +0 -0
- {devservices-1.5.1 → devservices-1.5.4}/devservices.egg-info/dependency_links.txt +0 -0
- {devservices-1.5.1 → devservices-1.5.4}/devservices.egg-info/entry_points.txt +0 -0
- {devservices-1.5.1 → devservices-1.5.4}/devservices.egg-info/requires.txt +0 -0
- {devservices-1.5.1 → devservices-1.5.4}/devservices.egg-info/top_level.txt +0 -0
- {devservices-1.5.1 → devservices-1.5.4}/setup.cfg +0 -0
- {devservices-1.5.1 → devservices-1.5.4}/testing/__init__.py +0 -0
- {devservices-1.5.1 → devservices-1.5.4}/tests/__init__.py +0 -0
- {devservices-1.5.1 → devservices-1.5.4}/tests/commands/test_down.py +0 -0
- {devservices-1.5.1 → devservices-1.5.4}/tests/commands/test_foreground.py +0 -0
- {devservices-1.5.1 → devservices-1.5.4}/tests/commands/test_list_dependencies.py +0 -0
- {devservices-1.5.1 → devservices-1.5.4}/tests/commands/test_list_services.py +0 -0
- {devservices-1.5.1 → devservices-1.5.4}/tests/commands/test_logs.py +0 -0
- {devservices-1.5.1 → devservices-1.5.4}/tests/commands/test_purge.py +0 -0
- {devservices-1.5.1 → devservices-1.5.4}/tests/commands/test_reset.py +0 -0
- {devservices-1.5.1 → devservices-1.5.4}/tests/commands/test_serve.py +0 -0
- {devservices-1.5.1 → devservices-1.5.4}/tests/commands/test_status.py +0 -0
- {devservices-1.5.1 → devservices-1.5.4}/tests/commands/test_toggle.py +0 -0
- {devservices-1.5.1 → devservices-1.5.4}/tests/commands/test_up.py +0 -0
- {devservices-1.5.1 → devservices-1.5.4}/tests/commands/test_update.py +0 -0
- {devservices-1.5.1 → devservices-1.5.4}/tests/configs/test_service_config.py +0 -0
- {devservices-1.5.1 → devservices-1.5.4}/tests/conftest.py +0 -0
- {devservices-1.5.1 → devservices-1.5.4}/tests/utils/test_check_for_update.py +0 -0
- {devservices-1.5.1 → devservices-1.5.4}/tests/utils/test_docker.py +0 -0
- {devservices-1.5.1 → devservices-1.5.4}/tests/utils/test_git.py +0 -0
- {devservices-1.5.1 → devservices-1.5.4}/tests/utils/test_github.py +0 -0
- {devservices-1.5.1 → devservices-1.5.4}/tests/utils/test_services.py +0 -0
- {devservices-1.5.1 → devservices-1.5.4}/tests/utils/test_state.py +0 -0
- {devservices-1.5.1 → devservices-1.5.4}/tests/utils/test_supervisor.py +0 -0
|
@@ -33,7 +33,7 @@ NOTE: service-name is an optional parameter. If not provided, devservices will a
|
|
|
33
33
|
The recommended way to install devservices is through a virtualenv in the requirements.txt. Once that is installed and a devservices config file is added, you should be able to run `devservices up` to begin local development.
|
|
34
34
|
|
|
35
35
|
```
|
|
36
|
-
devservices==1.5.
|
|
36
|
+
devservices==1.5.4
|
|
37
37
|
```
|
|
38
38
|
|
|
39
39
|
### 2. Add devservices config files
|
|
@@ -22,7 +22,10 @@ class DependencyType(StrEnum):
|
|
|
22
22
|
SUPERVISOR = "supervisor"
|
|
23
23
|
|
|
24
24
|
|
|
25
|
+
# Any installed Docker Compose at or above the minimum is used as is. The install
|
|
26
|
+
# version is only downloaded when Compose is missing or older than the minimum.
|
|
25
27
|
MINIMUM_DOCKER_COMPOSE_VERSION = "2.29.7"
|
|
28
|
+
DOCKER_COMPOSE_INSTALL_VERSION = "5.6.0"
|
|
26
29
|
DEVSERVICES_DIR_NAME = "devservices"
|
|
27
30
|
CONFIG_FILE_NAME = "config.yml"
|
|
28
31
|
DOCKER_CONFIG_DIR = os.environ.get("DOCKER_CONFIG", os.path.expanduser("~/.docker"))
|
|
@@ -42,15 +45,13 @@ DEVSERVICES_RELEASES_URL = (
|
|
|
42
45
|
"https://api.github.com/repos/getsentry/devservices/releases/latest"
|
|
43
46
|
)
|
|
44
47
|
|
|
45
|
-
|
|
46
|
-
# gsutil cp docker-compose-darwin-aarch64 gs://sentry-dev-infra-assets/docker-compose/v2.29.7/docker-compose-darwin-aarch64
|
|
47
|
-
# gsutil cp docker-compose-linux-x86_64 gs://sentry-dev-infra-assets/docker-compose/v2.29.7/docker-compose-linux-x86_64
|
|
48
|
-
DOCKER_COMPOSE_DOWNLOAD_URL = (
|
|
49
|
-
"https://storage.googleapis.com/sentry-dev-infra-assets/docker-compose"
|
|
50
|
-
)
|
|
48
|
+
DOCKER_COMPOSE_DOWNLOAD_URL = "https://github.com/docker/compose/releases/download"
|
|
51
49
|
|
|
52
50
|
DEVSERVICES_DOWNLOAD_URL = "https://github.com/getsentry/devservices/releases/download"
|
|
53
51
|
BINARY_PERMISSIONS = 0o755
|
|
52
|
+
# Seconds a download may stall (connecting or between reads) before the attempt
|
|
53
|
+
# is abandoned and retried.
|
|
54
|
+
DOWNLOAD_TIMEOUT = 15
|
|
54
55
|
MAX_LOG_LINES = "100"
|
|
55
56
|
LOGGER_NAME = "devservices"
|
|
56
57
|
DOCKER_NETWORK_NAME = "devservices"
|
|
@@ -99,7 +99,12 @@ class DependencyError(Exception):
|
|
|
99
99
|
self.stderr = stderr
|
|
100
100
|
|
|
101
101
|
def __str__(self) -> str:
|
|
102
|
-
|
|
102
|
+
message = (
|
|
103
|
+
f"DependencyError: {self.repo_name} ({self.repo_link}) on {self.branch}"
|
|
104
|
+
)
|
|
105
|
+
if self.__cause__ is not None:
|
|
106
|
+
message += f" ({type(self.__cause__).__name__}: {self.__cause__})"
|
|
107
|
+
return message
|
|
103
108
|
|
|
104
109
|
|
|
105
110
|
class InvalidDependencyConfigError(DependencyError):
|
|
@@ -84,6 +84,13 @@ if not disable_sentry:
|
|
|
84
84
|
ArgvIntegration(),
|
|
85
85
|
LoggingIntegration(sentry_logs_level=logging.DEBUG),
|
|
86
86
|
],
|
|
87
|
+
# By default the Sentry SDK loads integrations for all relevant libraries in
|
|
88
|
+
# the venv. When running devservices in the seer directory we end up loading
|
|
89
|
+
# Hugging Face, OpenAI, Anthropic and Celery. Likewise in sentry we end up
|
|
90
|
+
# loading Django, Redis and boto3. This is in spite of devservices not
|
|
91
|
+
# actually using those libraries. By disabling this behaviour we can reduce
|
|
92
|
+
# startup time for devservices.
|
|
93
|
+
auto_enabling_integrations=False,
|
|
87
94
|
enable_logs=True,
|
|
88
95
|
environment=sentry_environment,
|
|
89
96
|
before_send=before_send_error,
|
|
@@ -23,6 +23,7 @@ from devservices.constants import CONFIG_FILE_NAME
|
|
|
23
23
|
from devservices.constants import DEPENDENCY_CONFIG_VERSION
|
|
24
24
|
from devservices.constants import DEVSERVICES_DEPENDENCIES_CACHE_DIR
|
|
25
25
|
from devservices.constants import DEVSERVICES_DIR_NAME
|
|
26
|
+
from devservices.constants import DOWNLOAD_TIMEOUT
|
|
26
27
|
from devservices.constants import DependencyType
|
|
27
28
|
from devservices.exceptions import ConfigNotFoundError
|
|
28
29
|
from devservices.exceptions import ConfigParseError
|
|
@@ -408,6 +409,14 @@ def install_dependency(dependency: RemoteConfig) -> set[InstalledRemoteDependenc
|
|
|
408
409
|
return installed_dependencies
|
|
409
410
|
|
|
410
411
|
|
|
412
|
+
def _is_retryable_download_error(e: BaseException) -> bool:
|
|
413
|
+
# GitHub's API intermittently returns 5xx (and 429 when throttled). Those
|
|
414
|
+
# are worth retrying; other HTTP errors (404, 401, 403) won't fix themselves.
|
|
415
|
+
if isinstance(e, urllib.error.HTTPError):
|
|
416
|
+
return e.code >= 500 or e.code == 429
|
|
417
|
+
return True
|
|
418
|
+
|
|
419
|
+
|
|
411
420
|
def _fetch_dependency(
|
|
412
421
|
dependency: RemoteConfig,
|
|
413
422
|
dependency_repo_dir: str,
|
|
@@ -433,15 +442,18 @@ def _fetch_dependency(
|
|
|
433
442
|
|
|
434
443
|
def _download() -> bytes:
|
|
435
444
|
req = github.build_api_request(zip_url)
|
|
436
|
-
with urllib.request.urlopen(req) as response:
|
|
445
|
+
with urllib.request.urlopen(req, timeout=DOWNLOAD_TIMEOUT) as response:
|
|
437
446
|
return bytes(response.read())
|
|
438
447
|
|
|
439
448
|
try:
|
|
440
449
|
zip_data = io.BytesIO(
|
|
441
450
|
retry(
|
|
442
451
|
_download,
|
|
452
|
+
retries=5,
|
|
453
|
+
delay=1.0,
|
|
454
|
+
backoff=2.0,
|
|
443
455
|
exceptions=(OSError,),
|
|
444
|
-
should_retry=
|
|
456
|
+
should_retry=_is_retryable_download_error,
|
|
445
457
|
)
|
|
446
458
|
)
|
|
447
459
|
except (OSError, ValueError) as e:
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
from __future__ import annotations
|
|
2
2
|
|
|
3
|
+
import concurrent.futures
|
|
3
4
|
import json
|
|
4
5
|
import logging
|
|
5
6
|
import os
|
|
@@ -9,7 +10,6 @@ import shlex
|
|
|
9
10
|
import subprocess
|
|
10
11
|
import time
|
|
11
12
|
from typing import NamedTuple
|
|
12
|
-
from typing import cast
|
|
13
13
|
|
|
14
14
|
from packaging import version
|
|
15
15
|
from sentry_sdk import logger as sentry_logger
|
|
@@ -18,6 +18,7 @@ from devservices.configs.service_config import load_service_config_from_file
|
|
|
18
18
|
from devservices.constants import CONFIG_FILE_NAME
|
|
19
19
|
from devservices.constants import DEVSERVICES_DIR_NAME
|
|
20
20
|
from devservices.constants import DOCKER_COMPOSE_DOWNLOAD_URL
|
|
21
|
+
from devservices.constants import DOCKER_COMPOSE_INSTALL_VERSION
|
|
21
22
|
from devservices.constants import DOCKER_USER_PLUGIN_DIR
|
|
22
23
|
from devservices.constants import LOGGER_NAME
|
|
23
24
|
from devservices.constants import MINIMUM_DOCKER_COMPOSE_VERSION
|
|
@@ -68,7 +69,7 @@ def install_docker_compose() -> None:
|
|
|
68
69
|
else:
|
|
69
70
|
raise DockerComposeInstallationError(f"Unsupported operating system: {system}")
|
|
70
71
|
|
|
71
|
-
url = f"{DOCKER_COMPOSE_DOWNLOAD_URL}/v{
|
|
72
|
+
url = f"{DOCKER_COMPOSE_DOWNLOAD_URL}/v{DOCKER_COMPOSE_INSTALL_VERSION}/{binary_name_with_extension}"
|
|
72
73
|
destination = os.path.join(DOCKER_USER_PLUGIN_DIR, binary_name)
|
|
73
74
|
os.makedirs(DOCKER_USER_PLUGIN_DIR, exist_ok=True)
|
|
74
75
|
|
|
@@ -76,14 +77,14 @@ def install_docker_compose() -> None:
|
|
|
76
77
|
install_binary(
|
|
77
78
|
binary_name,
|
|
78
79
|
destination,
|
|
79
|
-
|
|
80
|
+
DOCKER_COMPOSE_INSTALL_VERSION,
|
|
80
81
|
url,
|
|
81
82
|
)
|
|
82
83
|
except BinaryInstallError as e:
|
|
83
84
|
raise DockerComposeInstallationError(f"Failed to install Docker Compose: {e}")
|
|
84
85
|
|
|
85
86
|
console.success(
|
|
86
|
-
f"Docker Compose {
|
|
87
|
+
f"Docker Compose {DOCKER_COMPOSE_INSTALL_VERSION} installed successfully to {destination}"
|
|
87
88
|
)
|
|
88
89
|
|
|
89
90
|
# Verify the installation
|
|
@@ -137,42 +138,25 @@ def check_docker_compose_version() -> None:
|
|
|
137
138
|
# Throw an error if docker daemon isn't running
|
|
138
139
|
check_docker_daemon_running()
|
|
139
140
|
try:
|
|
140
|
-
|
|
141
|
-
docker_compose_version = get_docker_compose_version()
|
|
141
|
+
version_output = get_docker_compose_version()
|
|
142
142
|
except DockerComposeError:
|
|
143
|
-
docker_compose_version = None
|
|
144
143
|
console.warning(
|
|
145
|
-
f"Docker Compose is not installed, attempting to install v{
|
|
144
|
+
f"Docker Compose is not installed, attempting to install v{DOCKER_COMPOSE_INSTALL_VERSION}"
|
|
146
145
|
)
|
|
146
|
+
install_docker_compose()
|
|
147
|
+
return
|
|
147
148
|
|
|
148
|
-
#
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
)
|
|
152
|
-
|
|
153
|
-
# Use regex to find the version number
|
|
154
|
-
pattern = r"^(\d+\.\d+\.\d+)"
|
|
155
|
-
|
|
156
|
-
match = re.search(pattern, version_output)
|
|
157
|
-
if match:
|
|
158
|
-
# There is a chance that Any type is returned, so cast this
|
|
159
|
-
docker_compose_version = cast(str, match.group(1))
|
|
160
|
-
else:
|
|
161
|
-
docker_compose_version = None
|
|
162
|
-
|
|
163
|
-
if docker_compose_version is None:
|
|
149
|
+
# Docker Desktop reports versions like "2.29.7-desktop.1", so only match the prefix
|
|
150
|
+
match = re.search(r"^(\d+\.\d+\.\d+)", version_output)
|
|
151
|
+
if match is None:
|
|
164
152
|
console.warning(
|
|
165
|
-
f"Unable to detect Docker Compose version, attempting to install v{
|
|
153
|
+
f"Unable to detect Docker Compose version, attempting to install v{DOCKER_COMPOSE_INSTALL_VERSION}"
|
|
166
154
|
)
|
|
167
|
-
elif version.parse(
|
|
168
|
-
MINIMUM_DOCKER_COMPOSE_VERSION
|
|
169
|
-
):
|
|
155
|
+
elif version.parse(match.group(1)) < version.parse(MINIMUM_DOCKER_COMPOSE_VERSION):
|
|
170
156
|
console.warning(
|
|
171
|
-
f"Docker Compose
|
|
157
|
+
f"Docker Compose v{match.group(1)} is older than the minimum supported v{MINIMUM_DOCKER_COMPOSE_VERSION}, attempting to install v{DOCKER_COMPOSE_INSTALL_VERSION}"
|
|
172
158
|
)
|
|
173
|
-
|
|
174
|
-
MINIMUM_DOCKER_COMPOSE_VERSION
|
|
175
|
-
):
|
|
159
|
+
else:
|
|
176
160
|
return
|
|
177
161
|
install_docker_compose()
|
|
178
162
|
|
|
@@ -254,17 +238,25 @@ def get_docker_compose_commands_to_run(
|
|
|
254
238
|
service_config_file_path: str,
|
|
255
239
|
mode_dependencies: list[str],
|
|
256
240
|
) -> list[DockerComposeCommand]:
|
|
241
|
+
dependency_config_paths = [
|
|
242
|
+
os.path.join(dependency.repo_path, DEVSERVICES_DIR_NAME, CONFIG_FILE_NAME)
|
|
243
|
+
for dependency in remote_dependencies
|
|
244
|
+
]
|
|
245
|
+
|
|
246
|
+
# Each lookup shells out to `docker compose config`, so run them concurrently
|
|
247
|
+
with concurrent.futures.ThreadPoolExecutor() as executor:
|
|
248
|
+
*dependency_non_remote_services, non_remote_services = executor.map(
|
|
249
|
+
lambda config_path: get_non_remote_services(config_path, current_env),
|
|
250
|
+
[*dependency_config_paths, service_config_file_path],
|
|
251
|
+
)
|
|
252
|
+
|
|
257
253
|
docker_compose_commands = []
|
|
258
|
-
for dependency in
|
|
254
|
+
for dependency, dependency_config_path, dependency_services in zip(
|
|
255
|
+
remote_dependencies, dependency_config_paths, dependency_non_remote_services
|
|
256
|
+
):
|
|
259
257
|
# TODO: Consider passing in service config in InstalledRemoteDependency instead of loading it here
|
|
260
258
|
dependency_service_config = load_service_config_from_file(dependency.repo_path)
|
|
261
|
-
|
|
262
|
-
dependency.repo_path, DEVSERVICES_DIR_NAME, CONFIG_FILE_NAME
|
|
263
|
-
)
|
|
264
|
-
non_remote_services = get_non_remote_services(
|
|
265
|
-
dependency_config_path, current_env
|
|
266
|
-
)
|
|
267
|
-
services_to_use = non_remote_services.intersection(
|
|
259
|
+
services_to_use = dependency_services.intersection(
|
|
268
260
|
set(dependency_service_config.modes[dependency.mode])
|
|
269
261
|
)
|
|
270
262
|
docker_compose_commands.append(
|
|
@@ -278,7 +270,6 @@ def get_docker_compose_commands_to_run(
|
|
|
278
270
|
)
|
|
279
271
|
|
|
280
272
|
# Add docker compose command for the top level service
|
|
281
|
-
non_remote_services = get_non_remote_services(service_config_file_path, current_env)
|
|
282
273
|
services_to_use = non_remote_services.intersection(set(mode_dependencies))
|
|
283
274
|
if len(services_to_use) > 0:
|
|
284
275
|
docker_compose_commands.append(
|
|
@@ -3,13 +3,23 @@ from __future__ import annotations
|
|
|
3
3
|
import os
|
|
4
4
|
import shutil
|
|
5
5
|
import tempfile
|
|
6
|
-
from urllib.request import
|
|
6
|
+
from urllib.request import urlopen
|
|
7
7
|
|
|
8
8
|
from devservices.constants import BINARY_PERMISSIONS
|
|
9
|
+
from devservices.constants import DOWNLOAD_TIMEOUT
|
|
9
10
|
from devservices.exceptions import BinaryInstallError
|
|
10
11
|
from devservices.utils.console import Console
|
|
11
12
|
from devservices.utils.retry import retry
|
|
12
13
|
|
|
14
|
+
DOWNLOAD_ATTEMPTS = 3
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def download_file(url: str, path: str) -> None:
|
|
18
|
+
# urlretrieve has no timeout, so a stalled connection would hang forever.
|
|
19
|
+
# The timeout applies to connecting and to each read, not the whole download.
|
|
20
|
+
with urlopen(url, timeout=DOWNLOAD_TIMEOUT) as response, open(path, "wb") as f:
|
|
21
|
+
shutil.copyfileobj(response, f)
|
|
22
|
+
|
|
13
23
|
|
|
14
24
|
def install_binary(
|
|
15
25
|
binary_name: str,
|
|
@@ -24,16 +34,17 @@ def install_binary(
|
|
|
24
34
|
console.info(f"Downloading {binary_name} {version} from {url}...")
|
|
25
35
|
try:
|
|
26
36
|
retry(
|
|
27
|
-
lambda:
|
|
28
|
-
retries=
|
|
37
|
+
lambda: download_file(url, temp_file),
|
|
38
|
+
retries=DOWNLOAD_ATTEMPTS,
|
|
29
39
|
delay=1.0,
|
|
40
|
+
backoff=2.0,
|
|
30
41
|
on_retry=lambda e, remaining: console.warning(
|
|
31
|
-
f"Download failed. Retrying
|
|
42
|
+
f"Download failed ({e}). Retrying... ({remaining} retries left)"
|
|
32
43
|
),
|
|
33
44
|
)
|
|
34
45
|
except Exception as e:
|
|
35
46
|
raise BinaryInstallError(
|
|
36
|
-
f"Failed to download {binary_name} after
|
|
47
|
+
f"Failed to download {binary_name} after {DOWNLOAD_ATTEMPTS} attempts: {e}"
|
|
37
48
|
) from e
|
|
38
49
|
|
|
39
50
|
try:
|
|
@@ -12,6 +12,7 @@ def retry(
|
|
|
12
12
|
*,
|
|
13
13
|
retries: int = 3,
|
|
14
14
|
delay: float = 1.0,
|
|
15
|
+
backoff: float = 1.0,
|
|
15
16
|
exceptions: tuple[type[BaseException], ...] = (Exception,),
|
|
16
17
|
on_retry: Callable[[BaseException, int], None] | None = None,
|
|
17
18
|
should_retry: Callable[[BaseException], bool] | None = None,
|
|
@@ -23,7 +24,8 @@ def retry(
|
|
|
23
24
|
Args:
|
|
24
25
|
fn: Callable to invoke.
|
|
25
26
|
retries: Total number of attempts (must be >= 1).
|
|
26
|
-
delay: Seconds to sleep
|
|
27
|
+
delay: Seconds to sleep before the first retry.
|
|
28
|
+
backoff: Multiplier applied to the delay after each retry.
|
|
27
29
|
exceptions: Exception types that trigger a retry.
|
|
28
30
|
on_retry: Optional callback(exc, attempts_remaining) invoked before each sleep.
|
|
29
31
|
should_retry: Optional predicate; if it returns False the exception is re-raised
|
|
@@ -41,5 +43,5 @@ def retry(
|
|
|
41
43
|
raise
|
|
42
44
|
if on_retry is not None:
|
|
43
45
|
on_retry(e, retries - attempt - 1)
|
|
44
|
-
time.sleep(delay)
|
|
46
|
+
time.sleep(delay * backoff**attempt)
|
|
45
47
|
raise AssertionError("unreachable")
|
|
@@ -69,10 +69,10 @@ def make_urlopen_response(zip_bytes: bytes) -> mock.MagicMock:
|
|
|
69
69
|
|
|
70
70
|
def url_dispatch(
|
|
71
71
|
zip_bytes_by_repo_name: dict[str, bytes],
|
|
72
|
-
) -> Callable[
|
|
72
|
+
) -> Callable[..., mock.MagicMock]:
|
|
73
73
|
"""Return a urlopen side_effect that routes requests by repo name in the URL."""
|
|
74
74
|
|
|
75
|
-
def _side_effect(request: mock.MagicMock) -> mock.MagicMock:
|
|
75
|
+
def _side_effect(request: mock.MagicMock, timeout: float) -> mock.MagicMock:
|
|
76
76
|
url = request.full_url
|
|
77
77
|
for repo_name, zip_bytes in zip_bytes_by_repo_name.items():
|
|
78
78
|
if f"/{repo_name}/" in url:
|
|
@@ -106,7 +106,7 @@ def test_fetch_dependency_network_error(tmp_path: Path) -> None:
|
|
|
106
106
|
_fetch_dependency(dep, str(tmp_path / "dest"))
|
|
107
107
|
|
|
108
108
|
|
|
109
|
-
def
|
|
109
|
+
def test_fetch_dependency_client_error_not_retried(tmp_path: Path) -> None:
|
|
110
110
|
dep = RemoteConfig(
|
|
111
111
|
repo_name="test-repo",
|
|
112
112
|
branch="main",
|
|
@@ -125,10 +125,75 @@ def test_fetch_dependency_http_error_not_retried(tmp_path: Path) -> None:
|
|
|
125
125
|
)
|
|
126
126
|
with urlopen_mock as mock_urlopen, pytest.raises(DependencyError):
|
|
127
127
|
_fetch_dependency(dep, str(tmp_path / "dest"))
|
|
128
|
-
#
|
|
128
|
+
# Client errors won't fix themselves, so urlopen is only ever called once.
|
|
129
129
|
mock_urlopen.assert_called_once()
|
|
130
130
|
|
|
131
131
|
|
|
132
|
+
def _http_error(code: int, msg: str) -> urllib.error.HTTPError:
|
|
133
|
+
return urllib.error.HTTPError(
|
|
134
|
+
url="https://api.github.com/repos/getsentry/test-repo/zipball/main",
|
|
135
|
+
code=code,
|
|
136
|
+
msg=msg,
|
|
137
|
+
hdrs=None, # type: ignore[arg-type]
|
|
138
|
+
fp=None,
|
|
139
|
+
)
|
|
140
|
+
|
|
141
|
+
|
|
142
|
+
@pytest.mark.parametrize(
|
|
143
|
+
("code", "msg"),
|
|
144
|
+
[(500, "Internal Server Error"), (503, "Service Unavailable"), (429, "Too Many")],
|
|
145
|
+
)
|
|
146
|
+
def test_fetch_dependency_retries_transient_http_error(
|
|
147
|
+
tmp_path: Path, code: int, msg: str
|
|
148
|
+
) -> None:
|
|
149
|
+
dep = RemoteConfig(
|
|
150
|
+
repo_name="test-repo",
|
|
151
|
+
branch="main",
|
|
152
|
+
repo_link="https://github.com/getsentry/test-repo",
|
|
153
|
+
)
|
|
154
|
+
zip_bytes = _make_zip_bytes(BASIC_SERVICE_CONFIG)
|
|
155
|
+
dest = str(tmp_path / "dest")
|
|
156
|
+
with (
|
|
157
|
+
mock.patch("devservices.utils.retry.time.sleep") as mock_sleep,
|
|
158
|
+
mock.patch(
|
|
159
|
+
"devservices.utils.dependencies.urllib.request.urlopen",
|
|
160
|
+
side_effect=[
|
|
161
|
+
_http_error(code, msg),
|
|
162
|
+
_http_error(code, msg),
|
|
163
|
+
_make_urlopen_response(zip_bytes),
|
|
164
|
+
],
|
|
165
|
+
) as mock_urlopen,
|
|
166
|
+
):
|
|
167
|
+
_fetch_dependency(dep, dest)
|
|
168
|
+
assert mock_urlopen.call_count == 3
|
|
169
|
+
assert [c.args[0] for c in mock_sleep.call_args_list] == [1.0, 2.0]
|
|
170
|
+
assert (Path(dest) / DEVSERVICES_DIR_NAME / CONFIG_FILE_NAME).exists()
|
|
171
|
+
|
|
172
|
+
|
|
173
|
+
def test_fetch_dependency_server_error_exhausts_retries(tmp_path: Path) -> None:
|
|
174
|
+
dep = RemoteConfig(
|
|
175
|
+
repo_name="test-repo",
|
|
176
|
+
branch="main",
|
|
177
|
+
repo_link="https://github.com/getsentry/test-repo",
|
|
178
|
+
)
|
|
179
|
+
with (
|
|
180
|
+
mock.patch("devservices.utils.retry.time.sleep") as mock_sleep,
|
|
181
|
+
mock.patch(
|
|
182
|
+
"devservices.utils.dependencies.urllib.request.urlopen",
|
|
183
|
+
side_effect=_http_error(500, "Internal Server Error"),
|
|
184
|
+
) as mock_urlopen,
|
|
185
|
+
pytest.raises(DependencyError) as exc_info,
|
|
186
|
+
):
|
|
187
|
+
_fetch_dependency(dep, str(tmp_path / "dest"))
|
|
188
|
+
assert mock_urlopen.call_count == 5
|
|
189
|
+
assert [c.args[0] for c in mock_sleep.call_args_list] == [1.0, 2.0, 4.0, 8.0]
|
|
190
|
+
# The underlying cause is surfaced so CI logs show what actually failed.
|
|
191
|
+
assert str(exc_info.value) == (
|
|
192
|
+
"DependencyError: test-repo (https://github.com/getsentry/test-repo) on main "
|
|
193
|
+
"(HTTPError: HTTP Error 500: Internal Server Error)"
|
|
194
|
+
)
|
|
195
|
+
|
|
196
|
+
|
|
132
197
|
def test_fetch_dependency_read_error(tmp_path: Path) -> None:
|
|
133
198
|
dep = RemoteConfig(
|
|
134
199
|
repo_name="test-repo",
|
|
@@ -2,6 +2,7 @@ from __future__ import annotations
|
|
|
2
2
|
|
|
3
3
|
import os
|
|
4
4
|
import subprocess
|
|
5
|
+
from collections.abc import Callable
|
|
5
6
|
from pathlib import Path
|
|
6
7
|
from unittest import mock
|
|
7
8
|
|
|
@@ -10,6 +11,7 @@ import pytest
|
|
|
10
11
|
from devservices.configs.service_config import load_service_config_from_file
|
|
11
12
|
from devservices.constants import CONFIG_FILE_NAME
|
|
12
13
|
from devservices.constants import DEVSERVICES_DIR_NAME
|
|
14
|
+
from devservices.constants import MINIMUM_DOCKER_COMPOSE_VERSION
|
|
13
15
|
from devservices.exceptions import BinaryInstallError
|
|
14
16
|
from devservices.exceptions import DockerComposeError
|
|
15
17
|
from devservices.exceptions import DockerComposeInstallationError
|
|
@@ -27,10 +29,29 @@ from devservices.utils.services import Service
|
|
|
27
29
|
from testing.utils import create_mock_git_repo
|
|
28
30
|
|
|
29
31
|
|
|
32
|
+
def mock_docker_compose_config_services(
|
|
33
|
+
services_by_repo_path: dict[str, str],
|
|
34
|
+
) -> Callable[..., subprocess.CompletedProcess[str]]:
|
|
35
|
+
def _run(
|
|
36
|
+
cmd: list[str], *args: object, **kwargs: object
|
|
37
|
+
) -> subprocess.CompletedProcess[str]:
|
|
38
|
+
config_path = cmd[cmd.index("-f") + 1]
|
|
39
|
+
repo_path = os.path.dirname(os.path.dirname(config_path))
|
|
40
|
+
return subprocess.CompletedProcess(
|
|
41
|
+
args=cmd, returncode=0, stdout=services_by_repo_path[repo_path]
|
|
42
|
+
)
|
|
43
|
+
|
|
44
|
+
return _run
|
|
45
|
+
|
|
46
|
+
|
|
30
47
|
@mock.patch("subprocess.run")
|
|
31
|
-
|
|
32
|
-
|
|
48
|
+
@mock.patch("devservices.utils.docker_compose.install_docker_compose")
|
|
49
|
+
def test_check_docker_compose_version_success(
|
|
50
|
+
mock_install_docker_compose: mock.Mock, mock_run: mock.Mock
|
|
51
|
+
) -> None:
|
|
52
|
+
mock_run.return_value.stdout = f"{MINIMUM_DOCKER_COMPOSE_VERSION}\n"
|
|
33
53
|
check_docker_compose_version() # Should not raise any exception
|
|
54
|
+
mock_install_docker_compose.assert_not_called()
|
|
34
55
|
|
|
35
56
|
|
|
36
57
|
@mock.patch("subprocess.run")
|
|
@@ -62,7 +83,33 @@ def test_check_docker_compose_version_unsupported(
|
|
|
62
83
|
) -> None:
|
|
63
84
|
mock_run.return_value.stdout = "2.20.0-desktop.1\n"
|
|
64
85
|
check_docker_compose_version()
|
|
65
|
-
|
|
86
|
+
mock_install_docker_compose.assert_called_once()
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
@pytest.mark.parametrize(
|
|
90
|
+
"installed_version",
|
|
91
|
+
["2.29.7", "2.29.7-desktop.1", "2.38.2", "2.40.3-desktop.1", "5.6.0", "6.0.0"],
|
|
92
|
+
)
|
|
93
|
+
@mock.patch("subprocess.run")
|
|
94
|
+
@mock.patch("devservices.utils.docker_compose.install_docker_compose")
|
|
95
|
+
def test_check_docker_compose_version_at_or_above_minimum(
|
|
96
|
+
mock_install_docker_compose: mock.Mock,
|
|
97
|
+
mock_run: mock.Mock,
|
|
98
|
+
installed_version: str,
|
|
99
|
+
) -> None:
|
|
100
|
+
mock_run.return_value.stdout = f"{installed_version}\n"
|
|
101
|
+
check_docker_compose_version()
|
|
102
|
+
mock_install_docker_compose.assert_not_called()
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
@mock.patch("subprocess.run")
|
|
106
|
+
@mock.patch("devservices.utils.docker_compose.install_docker_compose")
|
|
107
|
+
def test_check_docker_compose_version_below_minimum(
|
|
108
|
+
mock_install_docker_compose: mock.Mock, mock_run: mock.Mock
|
|
109
|
+
) -> None:
|
|
110
|
+
mock_run.return_value.stdout = "2.29.6\n"
|
|
111
|
+
check_docker_compose_version()
|
|
112
|
+
mock_install_docker_compose.assert_called_once()
|
|
66
113
|
|
|
67
114
|
|
|
68
115
|
@mock.patch("subprocess.run")
|
|
@@ -170,7 +217,7 @@ def test_install_docker_compose_binary_install_error(
|
|
|
170
217
|
|
|
171
218
|
@mock.patch("platform.system", return_value="Darwin")
|
|
172
219
|
@mock.patch("platform.machine", return_value="arm64")
|
|
173
|
-
@mock.patch("devservices.utils.install_binary.
|
|
220
|
+
@mock.patch("devservices.utils.install_binary.download_file")
|
|
174
221
|
@mock.patch("devservices.utils.install_binary.os.chmod")
|
|
175
222
|
@mock.patch("devservices.utils.install_binary.shutil.move")
|
|
176
223
|
@mock.patch(
|
|
@@ -186,7 +233,7 @@ def test_install_docker_compose_compose_verification_error(
|
|
|
186
233
|
_mock_get_docker_compose_version: mock.Mock,
|
|
187
234
|
_mock_shutil_move: mock.Mock,
|
|
188
235
|
_mock_chmod: mock.Mock,
|
|
189
|
-
|
|
236
|
+
_mock_download_file: mock.Mock,
|
|
190
237
|
_mock_machine: mock.Mock,
|
|
191
238
|
_mock_system: mock.Mock,
|
|
192
239
|
) -> None:
|
|
@@ -200,26 +247,26 @@ def test_install_docker_compose_compose_verification_error(
|
|
|
200
247
|
@mock.patch("devservices.utils.install_binary.tempfile.TemporaryDirectory")
|
|
201
248
|
@mock.patch("platform.system", return_value="Darwin")
|
|
202
249
|
@mock.patch("platform.machine", return_value="arm64")
|
|
203
|
-
@mock.patch("devservices.utils.install_binary.
|
|
250
|
+
@mock.patch("devservices.utils.install_binary.download_file")
|
|
204
251
|
@mock.patch("devservices.utils.install_binary.os.chmod")
|
|
205
252
|
@mock.patch("devservices.utils.install_binary.shutil.move")
|
|
206
253
|
@mock.patch(
|
|
207
254
|
"devservices.utils.docker_compose.get_docker_compose_version",
|
|
208
|
-
return_value="
|
|
255
|
+
return_value="5.6.0\n",
|
|
209
256
|
)
|
|
210
257
|
def test_install_docker_compose_macos_arm64(
|
|
211
258
|
mock_get_docker_compose_version: mock.Mock,
|
|
212
259
|
mock_shutil_move: mock.Mock,
|
|
213
260
|
mock_chmod: mock.Mock,
|
|
214
|
-
|
|
261
|
+
mock_download_file: mock.Mock,
|
|
215
262
|
_mock_machine: mock.Mock,
|
|
216
263
|
_mock_system: mock.Mock,
|
|
217
264
|
mock_tempdir: mock.Mock,
|
|
218
265
|
) -> None:
|
|
219
266
|
mock_tempdir.return_value.__enter__.return_value = "tempdir"
|
|
220
267
|
install_docker_compose()
|
|
221
|
-
|
|
222
|
-
"https://
|
|
268
|
+
mock_download_file.assert_called_once_with(
|
|
269
|
+
"https://github.com/docker/compose/releases/download/v5.6.0/docker-compose-darwin-aarch64",
|
|
223
270
|
"tempdir/docker-compose",
|
|
224
271
|
)
|
|
225
272
|
mock_chmod.assert_called_once_with("tempdir/docker-compose", 0o755)
|
|
@@ -233,26 +280,26 @@ def test_install_docker_compose_macos_arm64(
|
|
|
233
280
|
@mock.patch("devservices.utils.install_binary.tempfile.TemporaryDirectory")
|
|
234
281
|
@mock.patch("platform.system", return_value="Linux")
|
|
235
282
|
@mock.patch("platform.machine", return_value="x86_64")
|
|
236
|
-
@mock.patch("devservices.utils.install_binary.
|
|
283
|
+
@mock.patch("devservices.utils.install_binary.download_file")
|
|
237
284
|
@mock.patch("devservices.utils.install_binary.os.chmod")
|
|
238
285
|
@mock.patch("devservices.utils.install_binary.shutil.move")
|
|
239
286
|
@mock.patch(
|
|
240
287
|
"devservices.utils.docker_compose.get_docker_compose_version",
|
|
241
|
-
return_value="
|
|
288
|
+
return_value="5.6.0\n",
|
|
242
289
|
)
|
|
243
290
|
def test_install_docker_compose_linux_x86(
|
|
244
291
|
mock_get_docker_compose_version: mock.Mock,
|
|
245
292
|
mock_shutil_move: mock.Mock,
|
|
246
293
|
mock_chmod: mock.Mock,
|
|
247
|
-
|
|
294
|
+
mock_download_file: mock.Mock,
|
|
248
295
|
_mock_machine: mock.Mock,
|
|
249
296
|
_mock_system: mock.Mock,
|
|
250
297
|
mock_tempdir: mock.Mock,
|
|
251
298
|
) -> None:
|
|
252
299
|
mock_tempdir.return_value.__enter__.return_value = "tempdir"
|
|
253
300
|
install_docker_compose()
|
|
254
|
-
|
|
255
|
-
"https://
|
|
301
|
+
mock_download_file.assert_called_once_with(
|
|
302
|
+
"https://github.com/docker/compose/releases/download/v5.6.0/docker-compose-linux-x86_64",
|
|
256
303
|
"tempdir/docker-compose",
|
|
257
304
|
)
|
|
258
305
|
mock_chmod.assert_called_once_with("tempdir/docker-compose", 0o755)
|
|
@@ -266,18 +313,18 @@ def test_install_docker_compose_linux_x86(
|
|
|
266
313
|
@mock.patch("devservices.utils.install_binary.tempfile.TemporaryDirectory")
|
|
267
314
|
@mock.patch("platform.system", return_value="Darwin")
|
|
268
315
|
@mock.patch("platform.machine", return_value="arm64")
|
|
269
|
-
@mock.patch("devservices.utils.install_binary.
|
|
316
|
+
@mock.patch("devservices.utils.install_binary.download_file")
|
|
270
317
|
@mock.patch("devservices.utils.install_binary.os.chmod")
|
|
271
318
|
@mock.patch("devservices.utils.install_binary.shutil.move")
|
|
272
319
|
@mock.patch(
|
|
273
320
|
"devservices.utils.docker_compose.get_docker_compose_version",
|
|
274
|
-
return_value="
|
|
321
|
+
return_value="5.6.0\n",
|
|
275
322
|
)
|
|
276
323
|
def test_install_docker_compose_custom_docker_config_dir(
|
|
277
324
|
mock_get_docker_compose_version: mock.Mock,
|
|
278
325
|
mock_shutil_move: mock.Mock,
|
|
279
326
|
mock_chmod: mock.Mock,
|
|
280
|
-
|
|
327
|
+
mock_download_file: mock.Mock,
|
|
281
328
|
_mock_machine: mock.Mock,
|
|
282
329
|
_mock_system: mock.Mock,
|
|
283
330
|
mock_tempdir: mock.Mock,
|
|
@@ -288,8 +335,8 @@ def test_install_docker_compose_custom_docker_config_dir(
|
|
|
288
335
|
"tempdir/docker/config/cli-plugins",
|
|
289
336
|
):
|
|
290
337
|
install_docker_compose()
|
|
291
|
-
|
|
292
|
-
"https://
|
|
338
|
+
mock_download_file.assert_called_once_with(
|
|
339
|
+
"https://github.com/docker/compose/releases/download/v5.6.0/docker-compose-darwin-aarch64",
|
|
293
340
|
"tempdir/docker-compose",
|
|
294
341
|
)
|
|
295
342
|
mock_chmod.assert_called_once_with("tempdir/docker-compose", 0o755)
|
|
@@ -452,18 +499,12 @@ def test_get_all_commands_to_run_simple_remote(
|
|
|
452
499
|
parent_service_repo_path, DEVSERVICES_DIR_NAME, CONFIG_FILE_NAME
|
|
453
500
|
)
|
|
454
501
|
mode_dependencies = service_config.modes["default"]
|
|
455
|
-
mock_run.side_effect =
|
|
456
|
-
|
|
457
|
-
|
|
458
|
-
|
|
459
|
-
|
|
460
|
-
|
|
461
|
-
subprocess.CompletedProcess(
|
|
462
|
-
args=["docker", "compose", "config", "--services"],
|
|
463
|
-
returncode=0,
|
|
464
|
-
stdout="parent-service\n",
|
|
465
|
-
),
|
|
466
|
-
]
|
|
502
|
+
mock_run.side_effect = mock_docker_compose_config_services(
|
|
503
|
+
{
|
|
504
|
+
child_service_repo_path_str: "child-service\n",
|
|
505
|
+
parent_service_repo_path_str: "parent-service\n",
|
|
506
|
+
}
|
|
507
|
+
)
|
|
467
508
|
commands = get_docker_compose_commands_to_run(
|
|
468
509
|
service=service,
|
|
469
510
|
remote_dependencies=remote_dependencies,
|
|
@@ -555,23 +596,13 @@ def test_get_all_commands_to_run_complex_remote(
|
|
|
555
596
|
grandparent_service_repo_path_str, DEVSERVICES_DIR_NAME, CONFIG_FILE_NAME
|
|
556
597
|
)
|
|
557
598
|
mode_dependencies = service_config.modes["default"]
|
|
558
|
-
mock_run.side_effect =
|
|
559
|
-
|
|
560
|
-
|
|
561
|
-
|
|
562
|
-
|
|
563
|
-
|
|
564
|
-
|
|
565
|
-
args=["docker", "compose", "config", "--services"],
|
|
566
|
-
returncode=0,
|
|
567
|
-
stdout="parent-service\n",
|
|
568
|
-
),
|
|
569
|
-
subprocess.CompletedProcess(
|
|
570
|
-
args=["docker", "compose", "config", "--services"],
|
|
571
|
-
returncode=0,
|
|
572
|
-
stdout="grandparent-service\n",
|
|
573
|
-
),
|
|
574
|
-
]
|
|
599
|
+
mock_run.side_effect = mock_docker_compose_config_services(
|
|
600
|
+
{
|
|
601
|
+
child_service_repo_path_str: "child-service\n",
|
|
602
|
+
parent_service_repo_path_str: "parent-service\n",
|
|
603
|
+
grandparent_service_repo_path_str: "grandparent-service\n",
|
|
604
|
+
}
|
|
605
|
+
)
|
|
575
606
|
commands = get_docker_compose_commands_to_run(
|
|
576
607
|
service=service,
|
|
577
608
|
remote_dependencies=list(remote_dependencies),
|
|
@@ -685,23 +716,13 @@ def test_get_all_commands_to_run_complex_shared_dependency(
|
|
|
685
716
|
grandparent_service_repo_path_str, DEVSERVICES_DIR_NAME, CONFIG_FILE_NAME
|
|
686
717
|
)
|
|
687
718
|
mode_dependencies = service_config.modes["default"]
|
|
688
|
-
mock_run.side_effect =
|
|
689
|
-
|
|
690
|
-
|
|
691
|
-
|
|
692
|
-
|
|
693
|
-
|
|
694
|
-
|
|
695
|
-
args=["docker", "compose", "config", "--services"],
|
|
696
|
-
returncode=0,
|
|
697
|
-
stdout="parent-service\n",
|
|
698
|
-
),
|
|
699
|
-
subprocess.CompletedProcess(
|
|
700
|
-
args=["docker", "compose", "config", "--services"],
|
|
701
|
-
returncode=0,
|
|
702
|
-
stdout="grandparent-service\n",
|
|
703
|
-
),
|
|
704
|
-
]
|
|
719
|
+
mock_run.side_effect = mock_docker_compose_config_services(
|
|
720
|
+
{
|
|
721
|
+
child_service_repo_path_str: "child-service\n",
|
|
722
|
+
parent_service_repo_path_str: "parent-service\n",
|
|
723
|
+
grandparent_service_repo_path_str: "grandparent-service\n",
|
|
724
|
+
}
|
|
725
|
+
)
|
|
705
726
|
commands = get_docker_compose_commands_to_run(
|
|
706
727
|
service=service,
|
|
707
728
|
remote_dependencies=remote_dependencies,
|
|
@@ -1,19 +1,23 @@
|
|
|
1
1
|
from __future__ import annotations
|
|
2
2
|
|
|
3
|
+
import io
|
|
4
|
+
import pathlib
|
|
3
5
|
from unittest import mock
|
|
4
6
|
|
|
5
7
|
import pytest
|
|
6
8
|
|
|
9
|
+
from devservices.constants import DOWNLOAD_TIMEOUT
|
|
7
10
|
from devservices.exceptions import BinaryInstallError
|
|
11
|
+
from devservices.utils.install_binary import download_file
|
|
8
12
|
from devservices.utils.install_binary import install_binary
|
|
9
13
|
|
|
10
14
|
|
|
11
15
|
@mock.patch(
|
|
12
|
-
"devservices.utils.install_binary.
|
|
16
|
+
"devservices.utils.install_binary.download_file",
|
|
13
17
|
side_effect=Exception("Connection error"),
|
|
14
18
|
)
|
|
15
19
|
def test_install_docker_compose_connection_error(
|
|
16
|
-
|
|
20
|
+
mock_download_file: mock.Mock, capsys: pytest.CaptureFixture[str]
|
|
17
21
|
) -> None:
|
|
18
22
|
with pytest.raises(
|
|
19
23
|
BinaryInstallError,
|
|
@@ -33,9 +37,9 @@ def test_install_docker_compose_connection_error(
|
|
|
33
37
|
assert "Download failed. Retrying in 1 seconds... (Attempt 2/2)" in captured.out
|
|
34
38
|
|
|
35
39
|
|
|
36
|
-
@mock.patch("devservices.utils.install_binary.
|
|
40
|
+
@mock.patch("devservices.utils.install_binary.download_file")
|
|
37
41
|
def test_install_docker_compose_chmod_file_not_found_error(
|
|
38
|
-
|
|
42
|
+
mock_download_file: mock.Mock,
|
|
39
43
|
) -> None:
|
|
40
44
|
with pytest.raises(
|
|
41
45
|
BinaryInstallError,
|
|
@@ -49,11 +53,11 @@ def test_install_docker_compose_chmod_file_not_found_error(
|
|
|
49
53
|
)
|
|
50
54
|
|
|
51
55
|
|
|
52
|
-
@mock.patch("devservices.utils.install_binary.
|
|
56
|
+
@mock.patch("devservices.utils.install_binary.download_file")
|
|
53
57
|
@mock.patch("devservices.utils.install_binary.os.chmod")
|
|
54
58
|
def test_install_docker_compose_shutil_file_not_found_error(
|
|
55
59
|
mock_chmod: mock.Mock,
|
|
56
|
-
|
|
60
|
+
mock_download_file: mock.Mock,
|
|
57
61
|
) -> None:
|
|
58
62
|
with pytest.raises(
|
|
59
63
|
BinaryInstallError,
|
|
@@ -67,14 +71,14 @@ def test_install_docker_compose_shutil_file_not_found_error(
|
|
|
67
71
|
)
|
|
68
72
|
|
|
69
73
|
|
|
70
|
-
@mock.patch("devservices.utils.install_binary.
|
|
74
|
+
@mock.patch("devservices.utils.install_binary.download_file")
|
|
71
75
|
@mock.patch(
|
|
72
76
|
"devservices.utils.install_binary.os.chmod",
|
|
73
77
|
side_effect=PermissionError("Insufficient Permissions"),
|
|
74
78
|
)
|
|
75
79
|
def test_install_docker_compose_chmod_permission_error(
|
|
76
80
|
mock_chmod: mock.Mock,
|
|
77
|
-
|
|
81
|
+
mock_download_file: mock.Mock,
|
|
78
82
|
) -> None:
|
|
79
83
|
with pytest.raises(
|
|
80
84
|
BinaryInstallError,
|
|
@@ -88,7 +92,7 @@ def test_install_docker_compose_chmod_permission_error(
|
|
|
88
92
|
)
|
|
89
93
|
|
|
90
94
|
|
|
91
|
-
@mock.patch("devservices.utils.install_binary.
|
|
95
|
+
@mock.patch("devservices.utils.install_binary.download_file")
|
|
92
96
|
@mock.patch("devservices.utils.install_binary.os.chmod")
|
|
93
97
|
@mock.patch(
|
|
94
98
|
"devservices.utils.install_binary.shutil.move",
|
|
@@ -96,7 +100,7 @@ def test_install_docker_compose_chmod_permission_error(
|
|
|
96
100
|
)
|
|
97
101
|
def test_install_docker_compose_shutil_move_permission_error(
|
|
98
102
|
mock_chmod: mock.Mock,
|
|
99
|
-
|
|
103
|
+
mock_download_file: mock.Mock,
|
|
100
104
|
mock_move: mock.Mock,
|
|
101
105
|
) -> None:
|
|
102
106
|
with pytest.raises(
|
|
@@ -109,3 +113,45 @@ def test_install_docker_compose_shutil_move_permission_error(
|
|
|
109
113
|
"1.0.0",
|
|
110
114
|
"http:://example.com",
|
|
111
115
|
)
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
@mock.patch("devservices.utils.install_binary.urlopen")
|
|
119
|
+
def test_download_file_uses_timeout(
|
|
120
|
+
mock_urlopen: mock.Mock, tmp_path: pathlib.Path
|
|
121
|
+
) -> None:
|
|
122
|
+
mock_urlopen.return_value.__enter__.return_value = io.BytesIO(b"binary")
|
|
123
|
+
path = tmp_path / "binary-name"
|
|
124
|
+
|
|
125
|
+
download_file("https://example.com/binary", str(path))
|
|
126
|
+
|
|
127
|
+
mock_urlopen.assert_called_once_with(
|
|
128
|
+
"https://example.com/binary", timeout=DOWNLOAD_TIMEOUT
|
|
129
|
+
)
|
|
130
|
+
assert path.read_bytes() == b"binary"
|
|
131
|
+
|
|
132
|
+
|
|
133
|
+
@mock.patch("devservices.utils.retry.time.sleep")
|
|
134
|
+
@mock.patch(
|
|
135
|
+
"devservices.utils.install_binary.download_file",
|
|
136
|
+
side_effect=[TimeoutError("timed out"), None],
|
|
137
|
+
)
|
|
138
|
+
@mock.patch("devservices.utils.install_binary.os.chmod")
|
|
139
|
+
@mock.patch("devservices.utils.install_binary.shutil.move")
|
|
140
|
+
def test_install_binary_retries_stalled_download(
|
|
141
|
+
mock_move: mock.Mock,
|
|
142
|
+
mock_chmod: mock.Mock,
|
|
143
|
+
mock_download_file: mock.Mock,
|
|
144
|
+
mock_sleep: mock.Mock,
|
|
145
|
+
capsys: pytest.CaptureFixture[str],
|
|
146
|
+
) -> None:
|
|
147
|
+
install_binary(
|
|
148
|
+
"binary-name",
|
|
149
|
+
"exec_path",
|
|
150
|
+
"1.0.0",
|
|
151
|
+
"https://example.com/binary",
|
|
152
|
+
)
|
|
153
|
+
|
|
154
|
+
assert mock_download_file.call_count == 2
|
|
155
|
+
mock_sleep.assert_called_once_with(1.0)
|
|
156
|
+
mock_move.assert_called_once()
|
|
157
|
+
assert "Download failed (timed out)" in capsys.readouterr().out
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|