devservices 1.5.0__tar.gz → 1.5.3__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.0 → devservices-1.5.3}/PKG-INFO +1 -1
- {devservices-1.5.0 → devservices-1.5.3}/README.md +1 -1
- {devservices-1.5.0 → devservices-1.5.3}/devservices/commands/up.py +68 -31
- {devservices-1.5.0 → devservices-1.5.3}/devservices/exceptions.py +6 -1
- {devservices-1.5.0 → devservices-1.5.3}/devservices/main.py +7 -0
- {devservices-1.5.0 → devservices-1.5.3}/devservices/utils/dependencies.py +64 -28
- {devservices-1.5.0 → devservices-1.5.3}/devservices/utils/docker_compose.py +17 -9
- {devservices-1.5.0 → devservices-1.5.3}/devservices/utils/retry.py +4 -2
- {devservices-1.5.0 → devservices-1.5.3}/devservices.egg-info/PKG-INFO +1 -1
- {devservices-1.5.0 → devservices-1.5.3}/pyproject.toml +1 -1
- {devservices-1.5.0 → devservices-1.5.3}/tests/utils/test_dependencies.py +148 -2
- {devservices-1.5.0 → devservices-1.5.3}/tests/utils/test_docker_compose.py +36 -46
- {devservices-1.5.0 → devservices-1.5.3}/LICENSE.md +0 -0
- {devservices-1.5.0 → devservices-1.5.3}/devservices/__init__.py +0 -0
- {devservices-1.5.0 → devservices-1.5.3}/devservices/commands/__init__.py +0 -0
- {devservices-1.5.0 → devservices-1.5.3}/devservices/commands/down.py +0 -0
- {devservices-1.5.0 → devservices-1.5.3}/devservices/commands/foreground.py +0 -0
- {devservices-1.5.0 → devservices-1.5.3}/devservices/commands/list_dependencies.py +0 -0
- {devservices-1.5.0 → devservices-1.5.3}/devservices/commands/list_services.py +0 -0
- {devservices-1.5.0 → devservices-1.5.3}/devservices/commands/logs.py +0 -0
- {devservices-1.5.0 → devservices-1.5.3}/devservices/commands/purge.py +0 -0
- {devservices-1.5.0 → devservices-1.5.3}/devservices/commands/reset.py +0 -0
- {devservices-1.5.0 → devservices-1.5.3}/devservices/commands/serve.py +0 -0
- {devservices-1.5.0 → devservices-1.5.3}/devservices/commands/status.py +0 -0
- {devservices-1.5.0 → devservices-1.5.3}/devservices/commands/toggle.py +0 -0
- {devservices-1.5.0 → devservices-1.5.3}/devservices/commands/update.py +0 -0
- {devservices-1.5.0 → devservices-1.5.3}/devservices/configs/service_config.py +0 -0
- {devservices-1.5.0 → devservices-1.5.3}/devservices/constants.py +0 -0
- {devservices-1.5.0 → devservices-1.5.3}/devservices/utils/__init__.py +0 -0
- {devservices-1.5.0 → devservices-1.5.3}/devservices/utils/check_for_update.py +0 -0
- {devservices-1.5.0 → devservices-1.5.3}/devservices/utils/console.py +0 -0
- {devservices-1.5.0 → devservices-1.5.3}/devservices/utils/devenv.py +0 -0
- {devservices-1.5.0 → devservices-1.5.3}/devservices/utils/docker.py +0 -0
- {devservices-1.5.0 → devservices-1.5.3}/devservices/utils/file_lock.py +0 -0
- {devservices-1.5.0 → devservices-1.5.3}/devservices/utils/git.py +0 -0
- {devservices-1.5.0 → devservices-1.5.3}/devservices/utils/github.py +0 -0
- {devservices-1.5.0 → devservices-1.5.3}/devservices/utils/install_binary.py +0 -0
- {devservices-1.5.0 → devservices-1.5.3}/devservices/utils/services.py +0 -0
- {devservices-1.5.0 → devservices-1.5.3}/devservices/utils/state.py +0 -0
- {devservices-1.5.0 → devservices-1.5.3}/devservices/utils/supervisor.py +0 -0
- {devservices-1.5.0 → devservices-1.5.3}/devservices.egg-info/SOURCES.txt +0 -0
- {devservices-1.5.0 → devservices-1.5.3}/devservices.egg-info/dependency_links.txt +0 -0
- {devservices-1.5.0 → devservices-1.5.3}/devservices.egg-info/entry_points.txt +0 -0
- {devservices-1.5.0 → devservices-1.5.3}/devservices.egg-info/requires.txt +0 -0
- {devservices-1.5.0 → devservices-1.5.3}/devservices.egg-info/top_level.txt +0 -0
- {devservices-1.5.0 → devservices-1.5.3}/setup.cfg +0 -0
- {devservices-1.5.0 → devservices-1.5.3}/testing/__init__.py +0 -0
- {devservices-1.5.0 → devservices-1.5.3}/testing/utils.py +0 -0
- {devservices-1.5.0 → devservices-1.5.3}/tests/__init__.py +0 -0
- {devservices-1.5.0 → devservices-1.5.3}/tests/commands/test_down.py +0 -0
- {devservices-1.5.0 → devservices-1.5.3}/tests/commands/test_foreground.py +0 -0
- {devservices-1.5.0 → devservices-1.5.3}/tests/commands/test_list_dependencies.py +0 -0
- {devservices-1.5.0 → devservices-1.5.3}/tests/commands/test_list_services.py +0 -0
- {devservices-1.5.0 → devservices-1.5.3}/tests/commands/test_logs.py +0 -0
- {devservices-1.5.0 → devservices-1.5.3}/tests/commands/test_purge.py +0 -0
- {devservices-1.5.0 → devservices-1.5.3}/tests/commands/test_reset.py +0 -0
- {devservices-1.5.0 → devservices-1.5.3}/tests/commands/test_serve.py +0 -0
- {devservices-1.5.0 → devservices-1.5.3}/tests/commands/test_status.py +0 -0
- {devservices-1.5.0 → devservices-1.5.3}/tests/commands/test_toggle.py +0 -0
- {devservices-1.5.0 → devservices-1.5.3}/tests/commands/test_up.py +0 -0
- {devservices-1.5.0 → devservices-1.5.3}/tests/commands/test_update.py +0 -0
- {devservices-1.5.0 → devservices-1.5.3}/tests/configs/test_service_config.py +0 -0
- {devservices-1.5.0 → devservices-1.5.3}/tests/conftest.py +0 -0
- {devservices-1.5.0 → devservices-1.5.3}/tests/utils/test_check_for_update.py +0 -0
- {devservices-1.5.0 → devservices-1.5.3}/tests/utils/test_docker.py +0 -0
- {devservices-1.5.0 → devservices-1.5.3}/tests/utils/test_git.py +0 -0
- {devservices-1.5.0 → devservices-1.5.3}/tests/utils/test_github.py +0 -0
- {devservices-1.5.0 → devservices-1.5.3}/tests/utils/test_install_binary.py +0 -0
- {devservices-1.5.0 → devservices-1.5.3}/tests/utils/test_services.py +0 -0
- {devservices-1.5.0 → devservices-1.5.3}/tests/utils/test_state.py +0 -0
- {devservices-1.5.0 → devservices-1.5.3}/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.3
|
|
37
37
|
```
|
|
38
38
|
|
|
39
39
|
### 2. Add devservices config files
|
|
@@ -30,6 +30,7 @@ from devservices.exceptions import SupervisorError
|
|
|
30
30
|
from devservices.utils.console import Console
|
|
31
31
|
from devservices.utils.console import Status
|
|
32
32
|
from devservices.utils.dependencies import DependencyNode
|
|
33
|
+
from devservices.utils.dependencies import DependencyUpdateMode
|
|
33
34
|
from devservices.utils.dependencies import InstalledRemoteDependency
|
|
34
35
|
from devservices.utils.dependencies import construct_dependency_graph
|
|
35
36
|
from devservices.utils.dependencies import install_and_verify_dependencies
|
|
@@ -68,6 +69,12 @@ def add_parser(subparsers: _SubParsersAction[ArgumentParser]) -> None:
|
|
|
68
69
|
action="store_true",
|
|
69
70
|
default=False,
|
|
70
71
|
)
|
|
72
|
+
parser.add_argument(
|
|
73
|
+
"--offline",
|
|
74
|
+
help="Use cached dependencies and images without accessing the network",
|
|
75
|
+
action="store_true",
|
|
76
|
+
default=False,
|
|
77
|
+
)
|
|
71
78
|
parser.set_defaults(func=up)
|
|
72
79
|
|
|
73
80
|
|
|
@@ -96,6 +103,7 @@ def up(args: Namespace, existing_status: Status | None = None) -> None:
|
|
|
96
103
|
modes = service.config.modes
|
|
97
104
|
mode = args.mode
|
|
98
105
|
exclude_local = getattr(args, "exclude_local", False)
|
|
106
|
+
offline = getattr(args, "offline", False)
|
|
99
107
|
|
|
100
108
|
sentry_logger.info(
|
|
101
109
|
"Starting service",
|
|
@@ -162,7 +170,9 @@ def up(args: Namespace, existing_status: Status | None = None) -> None:
|
|
|
162
170
|
},
|
|
163
171
|
)
|
|
164
172
|
|
|
165
|
-
remote_dependencies = _install_service_dependencies(
|
|
173
|
+
remote_dependencies = _install_service_dependencies(
|
|
174
|
+
service, mode, status, offline
|
|
175
|
+
)
|
|
166
176
|
_create_devservices_network()
|
|
167
177
|
# Add the service to the starting services table
|
|
168
178
|
state.update_service_entry(service.name, mode, StateTables.STARTING_SERVICES)
|
|
@@ -204,6 +214,7 @@ def up(args: Namespace, existing_status: Status | None = None) -> None:
|
|
|
204
214
|
service_name=local_runtime_dependency_name,
|
|
205
215
|
mode="default", # We intentionally don't use the mode from the parent command here
|
|
206
216
|
exclude_local=True, # TODO: This should be False (or maybe whatever the parent command is set to)
|
|
217
|
+
offline=offline,
|
|
207
218
|
),
|
|
208
219
|
status,
|
|
209
220
|
)
|
|
@@ -216,7 +227,12 @@ def up(args: Namespace, existing_status: Status | None = None) -> None:
|
|
|
216
227
|
span.set_data("exclude_local", exclude_local)
|
|
217
228
|
try:
|
|
218
229
|
bring_up_docker_compose_services(
|
|
219
|
-
service,
|
|
230
|
+
service,
|
|
231
|
+
[mode],
|
|
232
|
+
remote_dependencies,
|
|
233
|
+
mode_dependencies,
|
|
234
|
+
status,
|
|
235
|
+
offline,
|
|
220
236
|
)
|
|
221
237
|
except DockerComposeError as dce:
|
|
222
238
|
capture_exception(dce, level="info")
|
|
@@ -239,25 +255,39 @@ def up(args: Namespace, existing_status: Status | None = None) -> None:
|
|
|
239
255
|
|
|
240
256
|
|
|
241
257
|
def _install_service_dependencies(
|
|
242
|
-
service: Service, mode: str, status: Status
|
|
258
|
+
service: Service, mode: str, status: Status, offline: bool = False
|
|
243
259
|
) -> set[InstalledRemoteDependency]:
|
|
244
260
|
with start_span(
|
|
245
261
|
op="service.dependencies.install", name="Install dependencies"
|
|
246
262
|
) as span:
|
|
247
|
-
status.info(
|
|
263
|
+
status.info(
|
|
264
|
+
"Using cached dependencies" if offline else "Retrieving dependencies"
|
|
265
|
+
)
|
|
248
266
|
span.set_data("service_name", service.name)
|
|
249
267
|
span.set_data("mode", mode)
|
|
268
|
+
span.set_data("offline", offline)
|
|
269
|
+
|
|
270
|
+
update_mode = (
|
|
271
|
+
DependencyUpdateMode.OFFLINE if offline else DependencyUpdateMode.FORCE
|
|
272
|
+
)
|
|
250
273
|
try:
|
|
251
274
|
remote_dependencies = install_and_verify_dependencies(
|
|
252
|
-
service,
|
|
275
|
+
service,
|
|
276
|
+
update_mode,
|
|
277
|
+
modes=[mode],
|
|
253
278
|
)
|
|
254
279
|
span.set_data("remote_dependency_count", len(remote_dependencies))
|
|
255
280
|
return remote_dependencies
|
|
256
281
|
except DependencyError as de:
|
|
257
282
|
capture_exception(de)
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
283
|
+
if offline:
|
|
284
|
+
status.failure(
|
|
285
|
+
f"{str(de)}. Run `devservices up` without `--offline` to fetch and cache dependencies"
|
|
286
|
+
)
|
|
287
|
+
else:
|
|
288
|
+
status.failure(
|
|
289
|
+
f"{str(de)}. If this error persists, try running `devservices purge`"
|
|
290
|
+
)
|
|
261
291
|
exit(1)
|
|
262
292
|
except ModeDoesNotExistError as mde:
|
|
263
293
|
status.failure(str(mde))
|
|
@@ -310,6 +340,7 @@ def bring_up_docker_compose_services(
|
|
|
310
340
|
remote_dependencies: set[InstalledRemoteDependency],
|
|
311
341
|
mode_dependencies: list[str],
|
|
312
342
|
status: Status,
|
|
343
|
+
offline: bool = False,
|
|
313
344
|
) -> None:
|
|
314
345
|
relative_local_dependency_directory = os.path.relpath(
|
|
315
346
|
os.path.join(DEVSERVICES_DEPENDENCIES_CACHE_DIR, DEPENDENCY_CONFIG_VERSION),
|
|
@@ -334,35 +365,41 @@ def bring_up_docker_compose_services(
|
|
|
334
365
|
),
|
|
335
366
|
)
|
|
336
367
|
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
pull_dependency_executor.submit(
|
|
352
|
-
_pull_dependency_images, cmd, current_env, status
|
|
353
|
-
)
|
|
354
|
-
for cmd in pull_commands
|
|
355
|
-
]
|
|
356
|
-
for future in concurrent.futures.as_completed(futures):
|
|
357
|
-
_ = future.result()
|
|
368
|
+
if offline:
|
|
369
|
+
status.info("Skipping image pull (offline mode)")
|
|
370
|
+
else:
|
|
371
|
+
# Pull all images in parallel
|
|
372
|
+
status.info("Pulling images")
|
|
373
|
+
pull_commands = get_docker_compose_commands_to_run(
|
|
374
|
+
service=service,
|
|
375
|
+
remote_dependencies=sorted_remote_dependencies,
|
|
376
|
+
current_env=current_env,
|
|
377
|
+
command="pull",
|
|
378
|
+
options=[],
|
|
379
|
+
service_config_file_path=service_config_file_path,
|
|
380
|
+
mode_dependencies=mode_dependencies,
|
|
381
|
+
)
|
|
358
382
|
|
|
359
|
-
|
|
383
|
+
with concurrent.futures.ThreadPoolExecutor() as pull_dependency_executor:
|
|
384
|
+
futures = [
|
|
385
|
+
pull_dependency_executor.submit(
|
|
386
|
+
_pull_dependency_images, cmd, current_env, status
|
|
387
|
+
)
|
|
388
|
+
for cmd in pull_commands
|
|
389
|
+
]
|
|
390
|
+
for future in concurrent.futures.as_completed(futures):
|
|
391
|
+
_ = future.result()
|
|
392
|
+
|
|
393
|
+
# Bring up all necessary containers. In offline mode, never pull missing images.
|
|
394
|
+
up_options = ["-d"]
|
|
395
|
+
if offline:
|
|
396
|
+
up_options += ["--pull", "never"]
|
|
360
397
|
up_commands = get_docker_compose_commands_to_run(
|
|
361
398
|
service=service,
|
|
362
399
|
remote_dependencies=sorted_remote_dependencies,
|
|
363
400
|
current_env=current_env,
|
|
364
401
|
command="up",
|
|
365
|
-
options=
|
|
402
|
+
options=up_options,
|
|
366
403
|
service_config_file_path=service_config_file_path,
|
|
367
404
|
mode_dependencies=mode_dependencies,
|
|
368
405
|
)
|
|
@@ -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,
|
|
@@ -10,6 +10,7 @@ from collections import deque
|
|
|
10
10
|
from concurrent.futures import ThreadPoolExecutor
|
|
11
11
|
from concurrent.futures import as_completed
|
|
12
12
|
from dataclasses import dataclass
|
|
13
|
+
from enum import StrEnum
|
|
13
14
|
from typing import TypeGuard
|
|
14
15
|
|
|
15
16
|
from sentry_sdk import logger as sentry_logger
|
|
@@ -113,13 +114,29 @@ class InstalledRemoteDependency:
|
|
|
113
114
|
mode: str = "default"
|
|
114
115
|
|
|
115
116
|
|
|
117
|
+
class DependencyUpdateMode(StrEnum):
|
|
118
|
+
"""Controls how remote dependencies are resolved during installation.
|
|
119
|
+
|
|
120
|
+
FORCE always fetches from the network, IF_NEEDED uses the cache when valid and
|
|
121
|
+
otherwise fetches, and OFFLINE never fetches and raises if the cache is
|
|
122
|
+
incomplete.
|
|
123
|
+
"""
|
|
124
|
+
|
|
125
|
+
FORCE = "force"
|
|
126
|
+
IF_NEEDED = "if_needed"
|
|
127
|
+
OFFLINE = "offline"
|
|
128
|
+
|
|
129
|
+
|
|
116
130
|
def install_and_verify_dependencies(
|
|
117
131
|
service: Service,
|
|
118
|
-
|
|
132
|
+
update_mode: DependencyUpdateMode = DependencyUpdateMode.IF_NEEDED,
|
|
119
133
|
modes: list[str] | None = None,
|
|
120
134
|
) -> set[InstalledRemoteDependency]:
|
|
121
135
|
"""
|
|
122
|
-
Install and verify dependencies for a service
|
|
136
|
+
Install and verify dependencies for a service.
|
|
137
|
+
|
|
138
|
+
update_mode controls whether dependencies are fetched from the network. See
|
|
139
|
+
DependencyUpdateMode.
|
|
123
140
|
"""
|
|
124
141
|
if modes is None:
|
|
125
142
|
modes = ["default"]
|
|
@@ -138,18 +155,18 @@ def install_and_verify_dependencies(
|
|
|
138
155
|
if dependency_key in mode_dependencies
|
|
139
156
|
]
|
|
140
157
|
|
|
141
|
-
|
|
142
|
-
|
|
158
|
+
skip_dependency_install = update_mode is DependencyUpdateMode.OFFLINE or (
|
|
159
|
+
update_mode is DependencyUpdateMode.IF_NEEDED
|
|
160
|
+
and verify_local_dependencies(matching_dependencies)
|
|
161
|
+
)
|
|
162
|
+
|
|
163
|
+
if skip_dependency_install:
|
|
164
|
+
remote_dependencies = get_installed_remote_dependencies(matching_dependencies)
|
|
143
165
|
else:
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
remote_dependencies = install_dependencies(matching_dependencies)
|
|
149
|
-
else:
|
|
150
|
-
remote_dependencies = get_installed_remote_dependencies(
|
|
151
|
-
matching_dependencies
|
|
152
|
-
)
|
|
166
|
+
# TODO: Figure out how to handle this case as installing dependencies may not be the right thing to do
|
|
167
|
+
# since the dependencies may have changed since the service was started.
|
|
168
|
+
remote_dependencies = install_dependencies(matching_dependencies)
|
|
169
|
+
|
|
153
170
|
return remote_dependencies
|
|
154
171
|
|
|
155
172
|
|
|
@@ -275,9 +292,10 @@ def get_installed_remote_dependencies(
|
|
|
275
292
|
mode=remote_config.mode,
|
|
276
293
|
)
|
|
277
294
|
)
|
|
278
|
-
|
|
279
|
-
|
|
295
|
+
active_nested_dependencies = _get_active_nested_dependencies(
|
|
296
|
+
service_config, remote_config.mode
|
|
280
297
|
)
|
|
298
|
+
nested_remote_configs = _get_remote_configs(active_nested_dependencies)
|
|
281
299
|
remote_configs.extend(nested_remote_configs)
|
|
282
300
|
|
|
283
301
|
return installed_dependencies
|
|
@@ -359,18 +377,9 @@ def install_dependency(dependency: RemoteConfig) -> set[InstalledRemoteDependenc
|
|
|
359
377
|
branch=dependency.branch,
|
|
360
378
|
) from e
|
|
361
379
|
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
mode=dependency.mode,
|
|
366
|
-
available_modes=list(installed_config.modes.keys()),
|
|
367
|
-
)
|
|
368
|
-
|
|
369
|
-
active_nested_dependencies = [
|
|
370
|
-
nested_dependency
|
|
371
|
-
for nested_dependency_name, nested_dependency in installed_config.dependencies.items()
|
|
372
|
-
if nested_dependency_name in installed_config.modes[dependency.mode]
|
|
373
|
-
]
|
|
380
|
+
active_nested_dependencies = _get_active_nested_dependencies(
|
|
381
|
+
installed_config, dependency.mode
|
|
382
|
+
)
|
|
374
383
|
nested_remote_configs = _get_remote_configs(active_nested_dependencies)
|
|
375
384
|
|
|
376
385
|
installed_dependencies: set[InstalledRemoteDependency] = set(
|
|
@@ -399,6 +408,14 @@ def install_dependency(dependency: RemoteConfig) -> set[InstalledRemoteDependenc
|
|
|
399
408
|
return installed_dependencies
|
|
400
409
|
|
|
401
410
|
|
|
411
|
+
def _is_retryable_download_error(e: BaseException) -> bool:
|
|
412
|
+
# GitHub's API intermittently returns 5xx (and 429 when throttled). Those
|
|
413
|
+
# are worth retrying; other HTTP errors (404, 401, 403) won't fix themselves.
|
|
414
|
+
if isinstance(e, urllib.error.HTTPError):
|
|
415
|
+
return e.code >= 500 or e.code == 429
|
|
416
|
+
return True
|
|
417
|
+
|
|
418
|
+
|
|
402
419
|
def _fetch_dependency(
|
|
403
420
|
dependency: RemoteConfig,
|
|
404
421
|
dependency_repo_dir: str,
|
|
@@ -431,8 +448,11 @@ def _fetch_dependency(
|
|
|
431
448
|
zip_data = io.BytesIO(
|
|
432
449
|
retry(
|
|
433
450
|
_download,
|
|
451
|
+
retries=5,
|
|
452
|
+
delay=1.0,
|
|
453
|
+
backoff=2.0,
|
|
434
454
|
exceptions=(OSError,),
|
|
435
|
-
should_retry=
|
|
455
|
+
should_retry=_is_retryable_download_error,
|
|
436
456
|
)
|
|
437
457
|
)
|
|
438
458
|
except (OSError, ValueError) as e:
|
|
@@ -484,6 +504,22 @@ def _fetch_dependency(
|
|
|
484
504
|
) from e
|
|
485
505
|
|
|
486
506
|
|
|
507
|
+
def _get_active_nested_dependencies(
|
|
508
|
+
service_config: ServiceConfig, mode: str
|
|
509
|
+
) -> list[Dependency]:
|
|
510
|
+
if mode not in service_config.modes:
|
|
511
|
+
raise ModeDoesNotExistError(
|
|
512
|
+
service_name=service_config.service_name,
|
|
513
|
+
mode=mode,
|
|
514
|
+
available_modes=list(service_config.modes.keys()),
|
|
515
|
+
)
|
|
516
|
+
return [
|
|
517
|
+
dependency
|
|
518
|
+
for dependency_name, dependency in service_config.dependencies.items()
|
|
519
|
+
if dependency_name in service_config.modes[mode]
|
|
520
|
+
]
|
|
521
|
+
|
|
522
|
+
|
|
487
523
|
def _get_remote_configs(dependencies: list[Dependency]) -> list[RemoteConfig]:
|
|
488
524
|
return [
|
|
489
525
|
dependency.remote
|
|
@@ -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
|
|
@@ -254,17 +255,25 @@ def get_docker_compose_commands_to_run(
|
|
|
254
255
|
service_config_file_path: str,
|
|
255
256
|
mode_dependencies: list[str],
|
|
256
257
|
) -> list[DockerComposeCommand]:
|
|
258
|
+
dependency_config_paths = [
|
|
259
|
+
os.path.join(dependency.repo_path, DEVSERVICES_DIR_NAME, CONFIG_FILE_NAME)
|
|
260
|
+
for dependency in remote_dependencies
|
|
261
|
+
]
|
|
262
|
+
|
|
263
|
+
# Each lookup shells out to `docker compose config`, so run them concurrently
|
|
264
|
+
with concurrent.futures.ThreadPoolExecutor() as executor:
|
|
265
|
+
*dependency_non_remote_services, non_remote_services = executor.map(
|
|
266
|
+
lambda config_path: get_non_remote_services(config_path, current_env),
|
|
267
|
+
[*dependency_config_paths, service_config_file_path],
|
|
268
|
+
)
|
|
269
|
+
|
|
257
270
|
docker_compose_commands = []
|
|
258
|
-
for dependency in
|
|
271
|
+
for dependency, dependency_config_path, dependency_services in zip(
|
|
272
|
+
remote_dependencies, dependency_config_paths, dependency_non_remote_services
|
|
273
|
+
):
|
|
259
274
|
# TODO: Consider passing in service config in InstalledRemoteDependency instead of loading it here
|
|
260
275
|
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(
|
|
276
|
+
services_to_use = dependency_services.intersection(
|
|
268
277
|
set(dependency_service_config.modes[dependency.mode])
|
|
269
278
|
)
|
|
270
279
|
docker_compose_commands.append(
|
|
@@ -278,7 +287,6 @@ def get_docker_compose_commands_to_run(
|
|
|
278
287
|
)
|
|
279
288
|
|
|
280
289
|
# Add docker compose command for the top level service
|
|
281
|
-
non_remote_services = get_non_remote_services(service_config_file_path, current_env)
|
|
282
290
|
services_to_use = non_remote_services.intersection(set(mode_dependencies))
|
|
283
291
|
if len(services_to_use) > 0:
|
|
284
292
|
docker_compose_commands.append(
|
|
@@ -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")
|
|
@@ -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",
|
|
@@ -316,6 +381,87 @@ def test_get_installed_remote_dependencies_single_dep_installed(tmp_path: Path)
|
|
|
316
381
|
}
|
|
317
382
|
|
|
318
383
|
|
|
384
|
+
def test_get_installed_remote_dependencies_does_not_return_unnecessary_dependencies(
|
|
385
|
+
tmp_path: Path,
|
|
386
|
+
) -> None:
|
|
387
|
+
"""Cached traversal only returns nested deps in the active mode."""
|
|
388
|
+
with mock.patch(
|
|
389
|
+
"devservices.utils.dependencies.DEVSERVICES_DEPENDENCIES_CACHE_DIR",
|
|
390
|
+
str(tmp_path / "dependency-dir"),
|
|
391
|
+
):
|
|
392
|
+
repo_a_config = {
|
|
393
|
+
"x-sentry-service-config": {
|
|
394
|
+
"version": 0.1,
|
|
395
|
+
"service_name": "repo-a",
|
|
396
|
+
"dependencies": {
|
|
397
|
+
"repo-b": {
|
|
398
|
+
"description": "nested dependency",
|
|
399
|
+
"remote": {
|
|
400
|
+
"repo_name": "repo-b",
|
|
401
|
+
"repo_link": "https://github.com/getsentry/repo-b",
|
|
402
|
+
"branch": "main",
|
|
403
|
+
},
|
|
404
|
+
},
|
|
405
|
+
"unnecessary-repo": {
|
|
406
|
+
"description": "unnecessary nested dependency",
|
|
407
|
+
"remote": {
|
|
408
|
+
"repo_name": "unnecessary-repo",
|
|
409
|
+
"repo_link": "invalid-link",
|
|
410
|
+
"branch": "main",
|
|
411
|
+
},
|
|
412
|
+
},
|
|
413
|
+
},
|
|
414
|
+
"modes": {"default": ["repo-b"], "other": ["unnecessary-repo"]},
|
|
415
|
+
},
|
|
416
|
+
}
|
|
417
|
+
|
|
418
|
+
with mock.patch(
|
|
419
|
+
"devservices.utils.dependencies.urllib.request.urlopen",
|
|
420
|
+
side_effect=_url_dispatch(
|
|
421
|
+
{
|
|
422
|
+
"repo-a": _make_zip_bytes(repo_a_config),
|
|
423
|
+
"repo-b": _make_zip_bytes(BASIC_SERVICE_CONFIG),
|
|
424
|
+
}
|
|
425
|
+
),
|
|
426
|
+
):
|
|
427
|
+
install_dependency(
|
|
428
|
+
RemoteConfig(
|
|
429
|
+
repo_name="repo-a",
|
|
430
|
+
branch="main",
|
|
431
|
+
repo_link="https://github.com/getsentry/repo-a",
|
|
432
|
+
)
|
|
433
|
+
)
|
|
434
|
+
|
|
435
|
+
installed = get_installed_remote_dependencies(
|
|
436
|
+
dependencies=[
|
|
437
|
+
Dependency(
|
|
438
|
+
description="test repo",
|
|
439
|
+
remote=RemoteConfig(
|
|
440
|
+
repo_name="repo-a",
|
|
441
|
+
branch="main",
|
|
442
|
+
repo_link="https://github.com/getsentry/repo-a",
|
|
443
|
+
),
|
|
444
|
+
dependency_type=DependencyType.SERVICE,
|
|
445
|
+
)
|
|
446
|
+
]
|
|
447
|
+
)
|
|
448
|
+
|
|
449
|
+
assert installed == {
|
|
450
|
+
InstalledRemoteDependency(
|
|
451
|
+
service_name="repo-a",
|
|
452
|
+
repo_path=str(
|
|
453
|
+
tmp_path / "dependency-dir" / DEPENDENCY_CONFIG_VERSION / "repo-a"
|
|
454
|
+
),
|
|
455
|
+
),
|
|
456
|
+
InstalledRemoteDependency(
|
|
457
|
+
service_name="basic",
|
|
458
|
+
repo_path=str(
|
|
459
|
+
tmp_path / "dependency-dir" / DEPENDENCY_CONFIG_VERSION / "repo-b"
|
|
460
|
+
),
|
|
461
|
+
),
|
|
462
|
+
}
|
|
463
|
+
|
|
464
|
+
|
|
319
465
|
def test_install_dependency_invalid_repo(tmp_path: Path) -> None:
|
|
320
466
|
with mock.patch(
|
|
321
467
|
"devservices.utils.dependencies.DEVSERVICES_DEPENDENCIES_CACHE_DIR",
|
|
@@ -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
|
|
|
@@ -27,6 +28,21 @@ from devservices.utils.services import Service
|
|
|
27
28
|
from testing.utils import create_mock_git_repo
|
|
28
29
|
|
|
29
30
|
|
|
31
|
+
def mock_docker_compose_config_services(
|
|
32
|
+
services_by_repo_path: dict[str, str],
|
|
33
|
+
) -> Callable[..., subprocess.CompletedProcess[str]]:
|
|
34
|
+
def _run(
|
|
35
|
+
cmd: list[str], *args: object, **kwargs: object
|
|
36
|
+
) -> subprocess.CompletedProcess[str]:
|
|
37
|
+
config_path = cmd[cmd.index("-f") + 1]
|
|
38
|
+
repo_path = os.path.dirname(os.path.dirname(config_path))
|
|
39
|
+
return subprocess.CompletedProcess(
|
|
40
|
+
args=cmd, returncode=0, stdout=services_by_repo_path[repo_path]
|
|
41
|
+
)
|
|
42
|
+
|
|
43
|
+
return _run
|
|
44
|
+
|
|
45
|
+
|
|
30
46
|
@mock.patch("subprocess.run")
|
|
31
47
|
def test_check_docker_compose_version_success(mock_run: mock.Mock) -> None:
|
|
32
48
|
mock_run.return_value.stdout = "2.29.7\n"
|
|
@@ -452,18 +468,12 @@ def test_get_all_commands_to_run_simple_remote(
|
|
|
452
468
|
parent_service_repo_path, DEVSERVICES_DIR_NAME, CONFIG_FILE_NAME
|
|
453
469
|
)
|
|
454
470
|
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
|
-
]
|
|
471
|
+
mock_run.side_effect = mock_docker_compose_config_services(
|
|
472
|
+
{
|
|
473
|
+
child_service_repo_path_str: "child-service\n",
|
|
474
|
+
parent_service_repo_path_str: "parent-service\n",
|
|
475
|
+
}
|
|
476
|
+
)
|
|
467
477
|
commands = get_docker_compose_commands_to_run(
|
|
468
478
|
service=service,
|
|
469
479
|
remote_dependencies=remote_dependencies,
|
|
@@ -555,23 +565,13 @@ def test_get_all_commands_to_run_complex_remote(
|
|
|
555
565
|
grandparent_service_repo_path_str, DEVSERVICES_DIR_NAME, CONFIG_FILE_NAME
|
|
556
566
|
)
|
|
557
567
|
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
|
-
]
|
|
568
|
+
mock_run.side_effect = mock_docker_compose_config_services(
|
|
569
|
+
{
|
|
570
|
+
child_service_repo_path_str: "child-service\n",
|
|
571
|
+
parent_service_repo_path_str: "parent-service\n",
|
|
572
|
+
grandparent_service_repo_path_str: "grandparent-service\n",
|
|
573
|
+
}
|
|
574
|
+
)
|
|
575
575
|
commands = get_docker_compose_commands_to_run(
|
|
576
576
|
service=service,
|
|
577
577
|
remote_dependencies=list(remote_dependencies),
|
|
@@ -685,23 +685,13 @@ def test_get_all_commands_to_run_complex_shared_dependency(
|
|
|
685
685
|
grandparent_service_repo_path_str, DEVSERVICES_DIR_NAME, CONFIG_FILE_NAME
|
|
686
686
|
)
|
|
687
687
|
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
|
-
]
|
|
688
|
+
mock_run.side_effect = mock_docker_compose_config_services(
|
|
689
|
+
{
|
|
690
|
+
child_service_repo_path_str: "child-service\n",
|
|
691
|
+
parent_service_repo_path_str: "parent-service\n",
|
|
692
|
+
grandparent_service_repo_path_str: "grandparent-service\n",
|
|
693
|
+
}
|
|
694
|
+
)
|
|
705
695
|
commands = get_docker_compose_commands_to_run(
|
|
706
696
|
service=service,
|
|
707
697
|
remote_dependencies=remote_dependencies,
|
|
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
|
|
File without changes
|
|
File without changes
|
|
File without changes
|