devservices 1.3.1__tar.gz → 1.4.0__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.3.1 → devservices-1.4.0}/PKG-INFO +1 -1
- {devservices-1.3.1 → devservices-1.4.0}/README.md +1 -1
- {devservices-1.3.1 → devservices-1.4.0}/devservices/constants.py +0 -5
- {devservices-1.3.1 → devservices-1.4.0}/devservices/exceptions.py +0 -19
- {devservices-1.3.1 → devservices-1.4.0}/devservices/utils/dependencies.py +66 -313
- {devservices-1.3.1 → devservices-1.4.0}/devservices/utils/install_binary.py +15 -20
- devservices-1.4.0/devservices/utils/retry.py +45 -0
- {devservices-1.3.1 → devservices-1.4.0}/devservices.egg-info/PKG-INFO +1 -1
- {devservices-1.3.1 → devservices-1.4.0}/devservices.egg-info/SOURCES.txt +1 -0
- {devservices-1.3.1 → devservices-1.4.0}/pyproject.toml +2 -2
- devservices-1.4.0/testing/utils.py +82 -0
- {devservices-1.3.1 → devservices-1.4.0}/tests/commands/test_down.py +153 -166
- {devservices-1.3.1 → devservices-1.4.0}/tests/commands/test_serve.py +5 -1
- {devservices-1.3.1 → devservices-1.4.0}/tests/commands/test_status.py +9 -7
- {devservices-1.3.1 → devservices-1.4.0}/tests/commands/test_toggle.py +87 -93
- {devservices-1.3.1 → devservices-1.4.0}/tests/commands/test_up.py +70 -75
- {devservices-1.3.1 → devservices-1.4.0}/tests/utils/test_dependencies.py +646 -1773
- devservices-1.3.1/testing/utils.py +0 -41
- {devservices-1.3.1 → devservices-1.4.0}/LICENSE.md +0 -0
- {devservices-1.3.1 → devservices-1.4.0}/devservices/__init__.py +0 -0
- {devservices-1.3.1 → devservices-1.4.0}/devservices/commands/__init__.py +0 -0
- {devservices-1.3.1 → devservices-1.4.0}/devservices/commands/down.py +0 -0
- {devservices-1.3.1 → devservices-1.4.0}/devservices/commands/foreground.py +0 -0
- {devservices-1.3.1 → devservices-1.4.0}/devservices/commands/list_dependencies.py +0 -0
- {devservices-1.3.1 → devservices-1.4.0}/devservices/commands/list_services.py +0 -0
- {devservices-1.3.1 → devservices-1.4.0}/devservices/commands/logs.py +0 -0
- {devservices-1.3.1 → devservices-1.4.0}/devservices/commands/purge.py +0 -0
- {devservices-1.3.1 → devservices-1.4.0}/devservices/commands/reset.py +0 -0
- {devservices-1.3.1 → devservices-1.4.0}/devservices/commands/serve.py +0 -0
- {devservices-1.3.1 → devservices-1.4.0}/devservices/commands/status.py +0 -0
- {devservices-1.3.1 → devservices-1.4.0}/devservices/commands/toggle.py +0 -0
- {devservices-1.3.1 → devservices-1.4.0}/devservices/commands/up.py +0 -0
- {devservices-1.3.1 → devservices-1.4.0}/devservices/commands/update.py +0 -0
- {devservices-1.3.1 → devservices-1.4.0}/devservices/configs/service_config.py +0 -0
- {devservices-1.3.1 → devservices-1.4.0}/devservices/main.py +0 -0
- {devservices-1.3.1 → devservices-1.4.0}/devservices/utils/__init__.py +0 -0
- {devservices-1.3.1 → devservices-1.4.0}/devservices/utils/check_for_update.py +0 -0
- {devservices-1.3.1 → devservices-1.4.0}/devservices/utils/console.py +0 -0
- {devservices-1.3.1 → devservices-1.4.0}/devservices/utils/devenv.py +0 -0
- {devservices-1.3.1 → devservices-1.4.0}/devservices/utils/docker.py +0 -0
- {devservices-1.3.1 → devservices-1.4.0}/devservices/utils/docker_compose.py +0 -0
- {devservices-1.3.1 → devservices-1.4.0}/devservices/utils/file_lock.py +0 -0
- {devservices-1.3.1 → devservices-1.4.0}/devservices/utils/git.py +0 -0
- {devservices-1.3.1 → devservices-1.4.0}/devservices/utils/services.py +0 -0
- {devservices-1.3.1 → devservices-1.4.0}/devservices/utils/state.py +0 -0
- {devservices-1.3.1 → devservices-1.4.0}/devservices/utils/supervisor.py +0 -0
- {devservices-1.3.1 → devservices-1.4.0}/devservices.egg-info/dependency_links.txt +0 -0
- {devservices-1.3.1 → devservices-1.4.0}/devservices.egg-info/entry_points.txt +0 -0
- {devservices-1.3.1 → devservices-1.4.0}/devservices.egg-info/requires.txt +0 -0
- {devservices-1.3.1 → devservices-1.4.0}/devservices.egg-info/top_level.txt +0 -0
- {devservices-1.3.1 → devservices-1.4.0}/setup.cfg +0 -0
- {devservices-1.3.1 → devservices-1.4.0}/testing/__init__.py +0 -0
- {devservices-1.3.1 → devservices-1.4.0}/tests/__init__.py +0 -0
- {devservices-1.3.1 → devservices-1.4.0}/tests/commands/test_foreground.py +0 -0
- {devservices-1.3.1 → devservices-1.4.0}/tests/commands/test_list_dependencies.py +0 -0
- {devservices-1.3.1 → devservices-1.4.0}/tests/commands/test_list_services.py +0 -0
- {devservices-1.3.1 → devservices-1.4.0}/tests/commands/test_logs.py +0 -0
- {devservices-1.3.1 → devservices-1.4.0}/tests/commands/test_purge.py +0 -0
- {devservices-1.3.1 → devservices-1.4.0}/tests/commands/test_reset.py +0 -0
- {devservices-1.3.1 → devservices-1.4.0}/tests/commands/test_update.py +0 -0
- {devservices-1.3.1 → devservices-1.4.0}/tests/configs/test_service_config.py +0 -0
- {devservices-1.3.1 → devservices-1.4.0}/tests/conftest.py +0 -0
- {devservices-1.3.1 → devservices-1.4.0}/tests/utils/test_check_for_update.py +0 -0
- {devservices-1.3.1 → devservices-1.4.0}/tests/utils/test_docker.py +0 -0
- {devservices-1.3.1 → devservices-1.4.0}/tests/utils/test_docker_compose.py +0 -0
- {devservices-1.3.1 → devservices-1.4.0}/tests/utils/test_git.py +0 -0
- {devservices-1.3.1 → devservices-1.4.0}/tests/utils/test_install_binary.py +0 -0
- {devservices-1.3.1 → devservices-1.4.0}/tests/utils/test_services.py +0 -0
- {devservices-1.3.1 → devservices-1.4.0}/tests/utils/test_state.py +0 -0
- {devservices-1.3.1 → devservices-1.4.0}/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.
|
|
36
|
+
devservices==1.4.0
|
|
37
37
|
```
|
|
38
38
|
|
|
39
39
|
### 2. Add devservices config files
|
|
@@ -37,11 +37,6 @@ STATE_DB_FILE = os.path.join(DEVSERVICES_LOCAL_DIR, "state")
|
|
|
37
37
|
DEVSERVICES_ORCHESTRATOR_LABEL = "orchestrator=devservices"
|
|
38
38
|
|
|
39
39
|
DEPENDENCY_CONFIG_VERSION = "v1"
|
|
40
|
-
DEPENDENCY_GIT_PARTIAL_CLONE_CONFIG_OPTIONS = {
|
|
41
|
-
"protocol.version": "2",
|
|
42
|
-
"extensions.partialClone": "true",
|
|
43
|
-
"core.sparseCheckout": "true",
|
|
44
|
-
}
|
|
45
40
|
|
|
46
41
|
DEVSERVICES_RELEASES_URL = (
|
|
47
42
|
"https://api.github.com/repos/getsentry/devservices/releases/latest"
|
|
@@ -102,13 +102,6 @@ class DependencyError(Exception):
|
|
|
102
102
|
return f"DependencyError: {self.repo_name} ({self.repo_link}) on {self.branch}"
|
|
103
103
|
|
|
104
104
|
|
|
105
|
-
class UnableToCloneDependencyError(DependencyError):
|
|
106
|
-
"""Raised when a dependency is unable to be cloned."""
|
|
107
|
-
|
|
108
|
-
def __str__(self) -> str:
|
|
109
|
-
return f"Unable to clone dependency: {self.repo_name} ({self.repo_link}) on {self.branch}"
|
|
110
|
-
|
|
111
|
-
|
|
112
105
|
class InvalidDependencyConfigError(DependencyError):
|
|
113
106
|
"""Raised when a dependency's config is invalid."""
|
|
114
107
|
|
|
@@ -142,18 +135,6 @@ class GitError(Exception):
|
|
|
142
135
|
self.stderr = stderr
|
|
143
136
|
|
|
144
137
|
|
|
145
|
-
class GitConfigError(Exception):
|
|
146
|
-
"""Base class for git config related errors."""
|
|
147
|
-
|
|
148
|
-
pass
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
class FailedToSetGitConfigError(GitConfigError):
|
|
152
|
-
"""Raised when a git config cannot be set."""
|
|
153
|
-
|
|
154
|
-
pass
|
|
155
|
-
|
|
156
|
-
|
|
157
138
|
class ContainerHealthcheckFailedError(Exception):
|
|
158
139
|
"""Raised when a container is not healthy."""
|
|
159
140
|
|
|
@@ -1,21 +1,18 @@
|
|
|
1
1
|
from __future__ import annotations
|
|
2
2
|
|
|
3
|
-
import
|
|
3
|
+
import io
|
|
4
4
|
import os
|
|
5
5
|
import shutil
|
|
6
|
-
import
|
|
7
|
-
import
|
|
8
|
-
import
|
|
6
|
+
import urllib.error
|
|
7
|
+
import urllib.request
|
|
8
|
+
import zipfile
|
|
9
9
|
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 typing import TextIO
|
|
14
13
|
from typing import TypeGuard
|
|
15
14
|
|
|
16
|
-
from sentry_sdk import capture_message
|
|
17
15
|
from sentry_sdk import logger as sentry_logger
|
|
18
|
-
from sentry_sdk import set_context
|
|
19
16
|
|
|
20
17
|
from devservices.configs.service_config import Dependency
|
|
21
18
|
from devservices.configs.service_config import RemoteConfig
|
|
@@ -23,21 +20,18 @@ from devservices.configs.service_config import ServiceConfig
|
|
|
23
20
|
from devservices.configs.service_config import load_service_config_from_file
|
|
24
21
|
from devservices.constants import CONFIG_FILE_NAME
|
|
25
22
|
from devservices.constants import DEPENDENCY_CONFIG_VERSION
|
|
26
|
-
from devservices.constants import DEPENDENCY_GIT_PARTIAL_CLONE_CONFIG_OPTIONS
|
|
27
23
|
from devservices.constants import DEVSERVICES_DEPENDENCIES_CACHE_DIR
|
|
28
24
|
from devservices.constants import DEVSERVICES_DIR_NAME
|
|
29
|
-
from devservices.constants import LOGGER_NAME
|
|
30
25
|
from devservices.constants import DependencyType
|
|
31
26
|
from devservices.exceptions import ConfigNotFoundError
|
|
32
27
|
from devservices.exceptions import ConfigParseError
|
|
33
28
|
from devservices.exceptions import ConfigValidationError
|
|
34
29
|
from devservices.exceptions import DependencyError
|
|
35
30
|
from devservices.exceptions import DependencyNotInstalledError
|
|
36
|
-
from devservices.exceptions import FailedToSetGitConfigError
|
|
37
31
|
from devservices.exceptions import InvalidDependencyConfigError
|
|
38
32
|
from devservices.exceptions import ModeDoesNotExistError
|
|
39
|
-
from devservices.exceptions import UnableToCloneDependencyError
|
|
40
33
|
from devservices.utils.file_lock import lock
|
|
34
|
+
from devservices.utils.retry import retry
|
|
41
35
|
from devservices.utils.services import Service
|
|
42
36
|
from devservices.utils.services import find_matching_service
|
|
43
37
|
from devservices.utils.services import get_active_service_names
|
|
@@ -45,17 +39,6 @@ from devservices.utils.state import ServiceRuntime
|
|
|
45
39
|
from devservices.utils.state import State
|
|
46
40
|
from devservices.utils.state import StateTables
|
|
47
41
|
|
|
48
|
-
RELEVANT_GIT_CONFIG_KEYS = [
|
|
49
|
-
"init.defaultbranch",
|
|
50
|
-
"core.sparsecheckout",
|
|
51
|
-
"remote.origin.url",
|
|
52
|
-
"remote.origin.fetch",
|
|
53
|
-
"remote.origin.promisor",
|
|
54
|
-
"remote.origin.partialclonefilter",
|
|
55
|
-
"protocol.version",
|
|
56
|
-
"extensions.partialclone",
|
|
57
|
-
]
|
|
58
|
-
|
|
59
42
|
|
|
60
43
|
@dataclass(frozen=True, eq=True)
|
|
61
44
|
class DependencyNode:
|
|
@@ -129,87 +112,6 @@ class InstalledRemoteDependency:
|
|
|
129
112
|
mode: str = "default"
|
|
130
113
|
|
|
131
114
|
|
|
132
|
-
class SparseCheckoutManager:
|
|
133
|
-
"""
|
|
134
|
-
Manages sparse checkout for a repo
|
|
135
|
-
"""
|
|
136
|
-
|
|
137
|
-
def __init__(self, repo_dir: str):
|
|
138
|
-
self.repo_dir = repo_dir
|
|
139
|
-
|
|
140
|
-
def init_sparse_checkout(self) -> None:
|
|
141
|
-
"""
|
|
142
|
-
Initialize sparse checkout for the repo
|
|
143
|
-
"""
|
|
144
|
-
_run_command(["git", "sparse-checkout", "init"], cwd=self.repo_dir)
|
|
145
|
-
|
|
146
|
-
def set_sparse_checkout(self, pattern: str) -> None:
|
|
147
|
-
"""
|
|
148
|
-
Set sparse checkout patterns for the repo
|
|
149
|
-
"""
|
|
150
|
-
self.init_sparse_checkout()
|
|
151
|
-
_run_command(["git", "sparse-checkout", "set", pattern], cwd=self.repo_dir)
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
class GitConfigManager:
|
|
155
|
-
"""
|
|
156
|
-
Manages git config for a repo
|
|
157
|
-
"""
|
|
158
|
-
|
|
159
|
-
def __init__(
|
|
160
|
-
self,
|
|
161
|
-
repo_dir: str,
|
|
162
|
-
config_options: dict[str, str],
|
|
163
|
-
sparse_pattern: str | None = None,
|
|
164
|
-
) -> None:
|
|
165
|
-
self.repo_dir = repo_dir
|
|
166
|
-
self.config_options = config_options
|
|
167
|
-
self.sparse_pattern = sparse_pattern
|
|
168
|
-
self.sparse_checkout_manager = SparseCheckoutManager(repo_dir)
|
|
169
|
-
|
|
170
|
-
def ensure_config(self) -> None:
|
|
171
|
-
"""
|
|
172
|
-
Ensure that the git config is set correctly for the repo
|
|
173
|
-
"""
|
|
174
|
-
# Otherwise, set the config options
|
|
175
|
-
for key, value in self.config_options.items():
|
|
176
|
-
self._set_config(key, value)
|
|
177
|
-
|
|
178
|
-
if self.sparse_pattern:
|
|
179
|
-
self.sparse_checkout_manager.set_sparse_checkout(self.sparse_pattern)
|
|
180
|
-
|
|
181
|
-
def get_relevant_config(self) -> dict[str, str]:
|
|
182
|
-
"""
|
|
183
|
-
Get the relevant git config entries (to avoid logging sensitive information)
|
|
184
|
-
"""
|
|
185
|
-
git_config = (
|
|
186
|
-
subprocess.check_output(
|
|
187
|
-
["git", "config", "--list"],
|
|
188
|
-
cwd=self.repo_dir,
|
|
189
|
-
stderr=subprocess.PIPE,
|
|
190
|
-
)
|
|
191
|
-
.decode()
|
|
192
|
-
.strip()
|
|
193
|
-
)
|
|
194
|
-
git_config_dict = dict()
|
|
195
|
-
for line in git_config.split("\n"):
|
|
196
|
-
if not line:
|
|
197
|
-
continue
|
|
198
|
-
key, value = line.split("=")
|
|
199
|
-
if key in RELEVANT_GIT_CONFIG_KEYS:
|
|
200
|
-
git_config_dict[key] = value
|
|
201
|
-
return git_config_dict
|
|
202
|
-
|
|
203
|
-
def _set_config(self, key: str, value: str) -> None:
|
|
204
|
-
"""
|
|
205
|
-
Set a git config option for the repo
|
|
206
|
-
"""
|
|
207
|
-
try:
|
|
208
|
-
_run_command(["git", "config", key, value], cwd=self.repo_dir)
|
|
209
|
-
except subprocess.CalledProcessError as e:
|
|
210
|
-
raise FailedToSetGitConfigError from e
|
|
211
|
-
|
|
212
|
-
|
|
213
115
|
def install_and_verify_dependencies(
|
|
214
116
|
service: Service,
|
|
215
117
|
force_update_dependencies: bool = False,
|
|
@@ -435,14 +337,7 @@ def install_dependency(dependency: RemoteConfig) -> set[InstalledRemoteDependenc
|
|
|
435
337
|
DEVSERVICES_DEPENDENCIES_CACHE_DIR, f"{dependency.repo_name}.lock"
|
|
436
338
|
)
|
|
437
339
|
with lock(lock_path):
|
|
438
|
-
|
|
439
|
-
os.path.exists(dependency_repo_dir)
|
|
440
|
-
and _is_valid_repo(dependency_repo_dir)
|
|
441
|
-
and _has_valid_config_file(dependency_repo_dir)
|
|
442
|
-
):
|
|
443
|
-
_update_dependency(dependency, dependency_repo_dir)
|
|
444
|
-
else:
|
|
445
|
-
_checkout_dependency(dependency, dependency_repo_dir)
|
|
340
|
+
_fetch_dependency(dependency, dependency_repo_dir)
|
|
446
341
|
|
|
447
342
|
if not verify_local_dependency(dependency):
|
|
448
343
|
# TODO: what should we do if the local dependency isn't installed correctly?
|
|
@@ -503,185 +398,101 @@ def install_dependency(dependency: RemoteConfig) -> set[InstalledRemoteDependenc
|
|
|
503
398
|
return installed_dependencies
|
|
504
399
|
|
|
505
400
|
|
|
506
|
-
def
|
|
401
|
+
def _parse_github_repo_path(repo_link: str) -> str:
|
|
402
|
+
url = repo_link.rstrip("/").removesuffix(".git")
|
|
403
|
+
if "github.com/" not in url:
|
|
404
|
+
raise ValueError(f"Not a GitHub URL: {repo_link}")
|
|
405
|
+
return url.split("github.com/", 1)[1]
|
|
406
|
+
|
|
407
|
+
|
|
408
|
+
def _fetch_dependency(
|
|
507
409
|
dependency: RemoteConfig,
|
|
508
410
|
dependency_repo_dir: str,
|
|
509
411
|
) -> None:
|
|
510
412
|
sentry_logger.info(
|
|
511
|
-
"
|
|
413
|
+
"Fetching dependency",
|
|
512
414
|
extra={
|
|
513
415
|
"repo_name": dependency.repo_name,
|
|
514
416
|
"repo_link": dependency.repo_link,
|
|
515
417
|
"branch": dependency.branch,
|
|
516
418
|
},
|
|
517
419
|
)
|
|
518
|
-
git_config_manager = GitConfigManager(
|
|
519
|
-
dependency_repo_dir,
|
|
520
|
-
DEPENDENCY_GIT_PARTIAL_CLONE_CONFIG_OPTIONS,
|
|
521
|
-
f"{DEVSERVICES_DIR_NAME}/",
|
|
522
|
-
)
|
|
523
420
|
try:
|
|
524
|
-
|
|
525
|
-
except
|
|
421
|
+
repo_path = _parse_github_repo_path(dependency.repo_link)
|
|
422
|
+
except ValueError as e:
|
|
526
423
|
raise DependencyError(
|
|
527
424
|
repo_name=dependency.repo_name,
|
|
528
425
|
repo_link=dependency.repo_link,
|
|
529
426
|
branch=dependency.branch,
|
|
530
427
|
) from e
|
|
531
428
|
|
|
532
|
-
|
|
533
|
-
|
|
534
|
-
|
|
535
|
-
|
|
536
|
-
|
|
537
|
-
|
|
538
|
-
dependency.branch,
|
|
539
|
-
"--filter=blob:none",
|
|
540
|
-
"--no-recurse-submodules", # Avoid fetching submodules
|
|
541
|
-
],
|
|
542
|
-
cwd=dependency_repo_dir,
|
|
429
|
+
zip_url = f"https://api.github.com/repos/{repo_path}/zipball/{dependency.branch}"
|
|
430
|
+
|
|
431
|
+
def _download() -> bytes:
|
|
432
|
+
req = urllib.request.Request(
|
|
433
|
+
zip_url,
|
|
434
|
+
headers={"Accept": "application/vnd.github+json"},
|
|
543
435
|
)
|
|
544
|
-
|
|
545
|
-
|
|
546
|
-
_try_set_git_config_context(git_config_manager)
|
|
547
|
-
raise DependencyError(
|
|
548
|
-
repo_name=dependency.repo_name,
|
|
549
|
-
repo_link=dependency.repo_link,
|
|
550
|
-
branch=dependency.branch,
|
|
551
|
-
stderr=e.stderr,
|
|
552
|
-
) from e
|
|
436
|
+
with urllib.request.urlopen(req) as response:
|
|
437
|
+
return bytes(response.read())
|
|
553
438
|
|
|
554
|
-
# Check if the local repo is up-to-date
|
|
555
439
|
try:
|
|
556
|
-
|
|
557
|
-
|
|
440
|
+
zip_data = io.BytesIO(
|
|
441
|
+
retry(
|
|
442
|
+
_download,
|
|
443
|
+
exceptions=(OSError,),
|
|
444
|
+
should_retry=lambda e: not isinstance(e, urllib.error.HTTPError),
|
|
445
|
+
)
|
|
446
|
+
)
|
|
447
|
+
except (OSError, ValueError) as e:
|
|
558
448
|
raise DependencyError(
|
|
559
449
|
repo_name=dependency.repo_name,
|
|
560
450
|
repo_link=dependency.repo_link,
|
|
561
451
|
branch=dependency.branch,
|
|
562
|
-
stderr=e.stderr,
|
|
563
452
|
) from e
|
|
564
453
|
|
|
565
454
|
try:
|
|
566
|
-
|
|
567
|
-
|
|
568
|
-
|
|
569
|
-
|
|
570
|
-
|
|
571
|
-
|
|
572
|
-
|
|
573
|
-
|
|
574
|
-
|
|
575
|
-
|
|
576
|
-
|
|
577
|
-
|
|
578
|
-
|
|
579
|
-
|
|
580
|
-
|
|
581
|
-
|
|
582
|
-
|
|
455
|
+
with zipfile.ZipFile(zip_data) as zf:
|
|
456
|
+
names = zf.namelist()
|
|
457
|
+
if not names:
|
|
458
|
+
raise DependencyError(
|
|
459
|
+
repo_name=dependency.repo_name,
|
|
460
|
+
repo_link=dependency.repo_link,
|
|
461
|
+
branch=dependency.branch,
|
|
462
|
+
)
|
|
463
|
+
# GitHub zips always have a single top-level directory: "owner-repo-sha/"
|
|
464
|
+
prefix = names[0].split("/")[0] + "/"
|
|
465
|
+
devservices_prefix = f"{prefix}{DEVSERVICES_DIR_NAME}/"
|
|
466
|
+
to_extract = [
|
|
467
|
+
name
|
|
468
|
+
for name in names
|
|
469
|
+
if name.startswith(devservices_prefix) and not name.endswith("/")
|
|
470
|
+
]
|
|
471
|
+
if not to_extract:
|
|
472
|
+
raise DependencyError(
|
|
473
|
+
repo_name=dependency.repo_name,
|
|
474
|
+
repo_link=dependency.repo_link,
|
|
475
|
+
branch=dependency.branch,
|
|
476
|
+
)
|
|
583
477
|
|
|
584
|
-
|
|
585
|
-
|
|
586
|
-
|
|
587
|
-
|
|
478
|
+
if os.path.exists(dependency_repo_dir):
|
|
479
|
+
shutil.rmtree(dependency_repo_dir)
|
|
480
|
+
os.makedirs(dependency_repo_dir)
|
|
481
|
+
|
|
482
|
+
for name in to_extract:
|
|
483
|
+
relative_path = name[len(prefix) :]
|
|
484
|
+
target = os.path.join(dependency_repo_dir, relative_path)
|
|
485
|
+
os.makedirs(os.path.dirname(target), exist_ok=True)
|
|
486
|
+
with zf.open(name) as src, open(target, "wb") as dst:
|
|
487
|
+
shutil.copyfileobj(src, dst)
|
|
488
|
+
except (zipfile.BadZipFile, OSError) as e:
|
|
588
489
|
raise DependencyError(
|
|
589
490
|
repo_name=dependency.repo_name,
|
|
590
491
|
repo_link=dependency.repo_link,
|
|
591
492
|
branch=dependency.branch,
|
|
592
|
-
stderr=e.stderr,
|
|
593
493
|
) from e
|
|
594
494
|
|
|
595
495
|
|
|
596
|
-
def _checkout_dependency(
|
|
597
|
-
dependency: RemoteConfig,
|
|
598
|
-
dependency_repo_dir: str,
|
|
599
|
-
) -> None:
|
|
600
|
-
sentry_logger.info(
|
|
601
|
-
"Checking out dependency",
|
|
602
|
-
extra={
|
|
603
|
-
"repo_name": dependency.repo_name,
|
|
604
|
-
"repo_link": dependency.repo_link,
|
|
605
|
-
"branch": dependency.branch,
|
|
606
|
-
},
|
|
607
|
-
)
|
|
608
|
-
with tempfile.TemporaryDirectory() as temp_dir:
|
|
609
|
-
try:
|
|
610
|
-
_run_command(
|
|
611
|
-
[
|
|
612
|
-
"git",
|
|
613
|
-
"clone",
|
|
614
|
-
"--filter=blob:none",
|
|
615
|
-
"--no-checkout",
|
|
616
|
-
dependency.repo_link,
|
|
617
|
-
temp_dir,
|
|
618
|
-
],
|
|
619
|
-
cwd=temp_dir,
|
|
620
|
-
)
|
|
621
|
-
except subprocess.CalledProcessError as e:
|
|
622
|
-
raise UnableToCloneDependencyError(
|
|
623
|
-
repo_name=dependency.repo_name,
|
|
624
|
-
repo_link=dependency.repo_link,
|
|
625
|
-
branch=dependency.branch,
|
|
626
|
-
stderr=e.stderr,
|
|
627
|
-
) from e
|
|
628
|
-
|
|
629
|
-
# Setup config for partial clone and sparse checkout
|
|
630
|
-
git_config_manager = GitConfigManager(
|
|
631
|
-
temp_dir,
|
|
632
|
-
DEPENDENCY_GIT_PARTIAL_CLONE_CONFIG_OPTIONS,
|
|
633
|
-
f"{DEVSERVICES_DIR_NAME}/",
|
|
634
|
-
)
|
|
635
|
-
try:
|
|
636
|
-
git_config_manager.ensure_config()
|
|
637
|
-
except FailedToSetGitConfigError as e:
|
|
638
|
-
raise DependencyError(
|
|
639
|
-
repo_name=dependency.repo_name,
|
|
640
|
-
repo_link=dependency.repo_link,
|
|
641
|
-
branch=dependency.branch,
|
|
642
|
-
) from e
|
|
643
|
-
|
|
644
|
-
try:
|
|
645
|
-
_run_command(
|
|
646
|
-
["git", "checkout", dependency.branch],
|
|
647
|
-
cwd=temp_dir,
|
|
648
|
-
)
|
|
649
|
-
except subprocess.CalledProcessError as e:
|
|
650
|
-
raise DependencyError(
|
|
651
|
-
repo_name=dependency.repo_name,
|
|
652
|
-
repo_link=dependency.repo_link,
|
|
653
|
-
branch=dependency.branch,
|
|
654
|
-
stderr=e.stderr,
|
|
655
|
-
) from e
|
|
656
|
-
|
|
657
|
-
# Clean up the existing directory if it exists
|
|
658
|
-
if os.path.exists(dependency_repo_dir):
|
|
659
|
-
shutil.rmtree(dependency_repo_dir)
|
|
660
|
-
# Copy the cloned repo to the dependency cache directory
|
|
661
|
-
try:
|
|
662
|
-
shutil.copytree(temp_dir, dst=dependency_repo_dir)
|
|
663
|
-
except FileExistsError as e:
|
|
664
|
-
raise DependencyError(
|
|
665
|
-
repo_name=dependency.repo_name,
|
|
666
|
-
repo_link=dependency.repo_link,
|
|
667
|
-
branch=dependency.branch,
|
|
668
|
-
) from e
|
|
669
|
-
|
|
670
|
-
|
|
671
|
-
def _is_valid_repo(path: str) -> bool:
|
|
672
|
-
if not os.path.exists(os.path.join(path, ".git")):
|
|
673
|
-
return False
|
|
674
|
-
try:
|
|
675
|
-
_run_command(["git", "rev-parse", "--is-inside-work-tree"], cwd=path)
|
|
676
|
-
return True
|
|
677
|
-
except subprocess.CalledProcessError:
|
|
678
|
-
return False
|
|
679
|
-
|
|
680
|
-
|
|
681
|
-
def _has_valid_config_file(path: str) -> bool:
|
|
682
|
-
return os.path.exists(os.path.join(path, DEVSERVICES_DIR_NAME, CONFIG_FILE_NAME))
|
|
683
|
-
|
|
684
|
-
|
|
685
496
|
def _get_remote_configs(dependencies: list[Dependency]) -> list[RemoteConfig]:
|
|
686
497
|
return [
|
|
687
498
|
dependency.remote
|
|
@@ -694,64 +505,6 @@ def _has_remote_config(remote_config: RemoteConfig | None) -> TypeGuard[RemoteCo
|
|
|
694
505
|
return remote_config is not None
|
|
695
506
|
|
|
696
507
|
|
|
697
|
-
def _rev_parse(repo_dir: str, ref: str) -> str:
|
|
698
|
-
logger = logging.getLogger(LOGGER_NAME)
|
|
699
|
-
logger.debug("Parsing revision for %s (%s)", ref, repo_dir)
|
|
700
|
-
rev = (
|
|
701
|
-
subprocess.check_output(
|
|
702
|
-
["git", "rev-parse", ref], cwd=repo_dir, stderr=subprocess.PIPE
|
|
703
|
-
)
|
|
704
|
-
.strip()
|
|
705
|
-
.decode()
|
|
706
|
-
)
|
|
707
|
-
logger.debug("Parsed revision %s for %s (%s)", rev, ref, repo_dir)
|
|
708
|
-
return rev
|
|
709
|
-
|
|
710
|
-
|
|
711
|
-
def _run_command(
|
|
712
|
-
cmd: list[str], cwd: str, stdout: int | TextIO | None = subprocess.DEVNULL
|
|
713
|
-
) -> None:
|
|
714
|
-
logger = logging.getLogger(LOGGER_NAME)
|
|
715
|
-
logger.debug("Running command: %s in %s", " ".join(cmd), cwd)
|
|
716
|
-
subprocess.run(cmd, cwd=cwd, check=True, stdout=stdout, stderr=subprocess.PIPE)
|
|
717
|
-
|
|
718
|
-
|
|
719
|
-
def _run_command_with_retries(
|
|
720
|
-
cmd: list[str],
|
|
721
|
-
cwd: str,
|
|
722
|
-
stdout: int | TextIO | None = subprocess.DEVNULL,
|
|
723
|
-
retries: int = 3,
|
|
724
|
-
backoff: int = 2,
|
|
725
|
-
) -> None:
|
|
726
|
-
for i in range(retries):
|
|
727
|
-
try:
|
|
728
|
-
_run_command(cmd, cwd=cwd, stdout=stdout)
|
|
729
|
-
break
|
|
730
|
-
except subprocess.CalledProcessError as e:
|
|
731
|
-
logger = logging.getLogger(LOGGER_NAME)
|
|
732
|
-
logger.debug(
|
|
733
|
-
"Attempt %s of %s for %s failed: %s", i + 1, retries, cmd, e.stderr
|
|
734
|
-
)
|
|
735
|
-
capture_message(
|
|
736
|
-
f"Attempt {i + 1} of {retries} for {cmd} failed: {e.stderr}",
|
|
737
|
-
level="warning",
|
|
738
|
-
)
|
|
739
|
-
if i == retries - 1:
|
|
740
|
-
raise e
|
|
741
|
-
time.sleep(backoff**i)
|
|
742
|
-
|
|
743
|
-
|
|
744
|
-
def _try_set_git_config_context(
|
|
745
|
-
git_config_manager: GitConfigManager,
|
|
746
|
-
) -> None:
|
|
747
|
-
try:
|
|
748
|
-
git_config = git_config_manager.get_relevant_config()
|
|
749
|
-
set_context("git_config", git_config)
|
|
750
|
-
except subprocess.CalledProcessError as e:
|
|
751
|
-
logger = logging.getLogger(LOGGER_NAME)
|
|
752
|
-
logger.exception(e)
|
|
753
|
-
|
|
754
|
-
|
|
755
508
|
def get_remote_dependency_config(remote_config: RemoteConfig) -> ServiceConfig:
|
|
756
509
|
dependency_repo_dir = os.path.join(
|
|
757
510
|
DEVSERVICES_DEPENDENCIES_CACHE_DIR,
|
|
@@ -3,12 +3,12 @@ from __future__ import annotations
|
|
|
3
3
|
import os
|
|
4
4
|
import shutil
|
|
5
5
|
import tempfile
|
|
6
|
-
import time
|
|
7
6
|
from urllib.request import urlretrieve
|
|
8
7
|
|
|
9
8
|
from devservices.constants import BINARY_PERMISSIONS
|
|
10
9
|
from devservices.exceptions import BinaryInstallError
|
|
11
10
|
from devservices.utils.console import Console
|
|
11
|
+
from devservices.utils.retry import retry
|
|
12
12
|
|
|
13
13
|
|
|
14
14
|
def install_binary(
|
|
@@ -18,29 +18,24 @@ def install_binary(
|
|
|
18
18
|
url: str,
|
|
19
19
|
) -> None:
|
|
20
20
|
console = Console()
|
|
21
|
-
with tempfile.TemporaryDirectory() as temp_dir:
|
|
21
|
+
with tempfile.TemporaryDirectory(ignore_cleanup_errors=True) as temp_dir:
|
|
22
22
|
temp_file = os.path.join(temp_dir, binary_name)
|
|
23
23
|
|
|
24
|
-
# Download the binary with retries
|
|
25
|
-
max_retries = 3
|
|
26
|
-
retry_delay_seconds = 1
|
|
27
24
|
console.info(f"Downloading {binary_name} {version} from {url}...")
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
urlretrieve(url, temp_file)
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
) from e
|
|
25
|
+
try:
|
|
26
|
+
retry(
|
|
27
|
+
lambda: urlretrieve(url, temp_file),
|
|
28
|
+
retries=3,
|
|
29
|
+
delay=1.0,
|
|
30
|
+
on_retry=lambda e, remaining: console.warning(
|
|
31
|
+
f"Download failed. Retrying in 1 seconds... ({remaining} retries left)"
|
|
32
|
+
),
|
|
33
|
+
)
|
|
34
|
+
except Exception as e:
|
|
35
|
+
raise BinaryInstallError(
|
|
36
|
+
f"Failed to download {binary_name} after 3 attempts: {e}"
|
|
37
|
+
) from e
|
|
42
38
|
|
|
43
|
-
# Make the binary executable
|
|
44
39
|
try:
|
|
45
40
|
os.chmod(temp_file, BINARY_PERMISSIONS)
|
|
46
41
|
except (PermissionError, FileNotFoundError) as e:
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import time
|
|
4
|
+
from collections.abc import Callable
|
|
5
|
+
from typing import TypeVar
|
|
6
|
+
|
|
7
|
+
T = TypeVar("T")
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
def retry(
|
|
11
|
+
fn: Callable[[], T],
|
|
12
|
+
*,
|
|
13
|
+
retries: int = 3,
|
|
14
|
+
delay: float = 1.0,
|
|
15
|
+
exceptions: tuple[type[BaseException], ...] = (Exception,),
|
|
16
|
+
on_retry: Callable[[BaseException, int], None] | None = None,
|
|
17
|
+
should_retry: Callable[[BaseException], bool] | None = None,
|
|
18
|
+
) -> T:
|
|
19
|
+
"""
|
|
20
|
+
Call fn() up to `retries` times, sleeping `delay` seconds between attempts.
|
|
21
|
+
Raises the last exception if all attempts fail.
|
|
22
|
+
|
|
23
|
+
Args:
|
|
24
|
+
fn: Callable to invoke.
|
|
25
|
+
retries: Total number of attempts (must be >= 1).
|
|
26
|
+
delay: Seconds to sleep between attempts.
|
|
27
|
+
exceptions: Exception types that trigger a retry.
|
|
28
|
+
on_retry: Optional callback(exc, attempts_remaining) invoked before each sleep.
|
|
29
|
+
should_retry: Optional predicate; if it returns False the exception is re-raised
|
|
30
|
+
immediately without further attempts.
|
|
31
|
+
"""
|
|
32
|
+
if retries < 1:
|
|
33
|
+
raise ValueError("retries must be >= 1")
|
|
34
|
+
for attempt in range(retries):
|
|
35
|
+
try:
|
|
36
|
+
return fn()
|
|
37
|
+
except exceptions as e:
|
|
38
|
+
if attempt == retries - 1 or (
|
|
39
|
+
should_retry is not None and not should_retry(e)
|
|
40
|
+
):
|
|
41
|
+
raise
|
|
42
|
+
if on_retry is not None:
|
|
43
|
+
on_retry(e, retries - attempt - 1)
|
|
44
|
+
time.sleep(delay)
|
|
45
|
+
raise AssertionError("unreachable")
|
|
@@ -35,6 +35,7 @@ devservices/utils/docker_compose.py
|
|
|
35
35
|
devservices/utils/file_lock.py
|
|
36
36
|
devservices/utils/git.py
|
|
37
37
|
devservices/utils/install_binary.py
|
|
38
|
+
devservices/utils/retry.py
|
|
38
39
|
devservices/utils/services.py
|
|
39
40
|
devservices/utils/state.py
|
|
40
41
|
devservices/utils/supervisor.py
|
|
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
|
|
|
4
4
|
|
|
5
5
|
[project]
|
|
6
6
|
name = "devservices"
|
|
7
|
-
version = "1.
|
|
7
|
+
version = "1.4.0"
|
|
8
8
|
# 3.11 is just for internal pypi compat
|
|
9
9
|
# but we test/support on 3.13
|
|
10
10
|
requires-python = ">=3.11"
|
|
@@ -22,7 +22,7 @@ dev = [
|
|
|
22
22
|
"freezegun",
|
|
23
23
|
"mypy",
|
|
24
24
|
"pre-commit",
|
|
25
|
-
"pytest
|
|
25
|
+
"pytest>=9.0.3",
|
|
26
26
|
"pytest-cov",
|
|
27
27
|
"ruff",
|
|
28
28
|
"setuptools>=70",
|