devservices 1.3.2__tar.gz → 1.4.1__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.2 → devservices-1.4.1}/PKG-INFO +1 -1
- {devservices-1.3.2 → devservices-1.4.1}/README.md +1 -1
- {devservices-1.3.2 → devservices-1.4.1}/devservices/constants.py +0 -5
- {devservices-1.3.2 → devservices-1.4.1}/devservices/exceptions.py +0 -19
- {devservices-1.3.2 → devservices-1.4.1}/devservices/utils/dependencies.py +58 -314
- devservices-1.4.1/devservices/utils/github.py +89 -0
- {devservices-1.3.2 → devservices-1.4.1}/devservices/utils/install_binary.py +14 -19
- devservices-1.4.1/devservices/utils/retry.py +45 -0
- {devservices-1.3.2 → devservices-1.4.1}/devservices.egg-info/PKG-INFO +1 -1
- {devservices-1.3.2 → devservices-1.4.1}/devservices.egg-info/SOURCES.txt +3 -0
- {devservices-1.3.2 → devservices-1.4.1}/pyproject.toml +7 -2
- devservices-1.4.1/testing/utils.py +82 -0
- {devservices-1.3.2 → devservices-1.4.1}/tests/commands/test_down.py +153 -166
- {devservices-1.3.2 → devservices-1.4.1}/tests/commands/test_serve.py +5 -1
- {devservices-1.3.2 → devservices-1.4.1}/tests/commands/test_status.py +9 -7
- {devservices-1.3.2 → devservices-1.4.1}/tests/commands/test_toggle.py +87 -93
- {devservices-1.3.2 → devservices-1.4.1}/tests/commands/test_up.py +70 -75
- devservices-1.4.1/tests/conftest.py +32 -0
- {devservices-1.3.2 → devservices-1.4.1}/tests/utils/test_dependencies.py +633 -1765
- devservices-1.4.1/tests/utils/test_github.py +145 -0
- devservices-1.3.2/testing/utils.py +0 -41
- devservices-1.3.2/tests/conftest.py +0 -10
- {devservices-1.3.2 → devservices-1.4.1}/LICENSE.md +0 -0
- {devservices-1.3.2 → devservices-1.4.1}/devservices/__init__.py +0 -0
- {devservices-1.3.2 → devservices-1.4.1}/devservices/commands/__init__.py +0 -0
- {devservices-1.3.2 → devservices-1.4.1}/devservices/commands/down.py +0 -0
- {devservices-1.3.2 → devservices-1.4.1}/devservices/commands/foreground.py +0 -0
- {devservices-1.3.2 → devservices-1.4.1}/devservices/commands/list_dependencies.py +0 -0
- {devservices-1.3.2 → devservices-1.4.1}/devservices/commands/list_services.py +0 -0
- {devservices-1.3.2 → devservices-1.4.1}/devservices/commands/logs.py +0 -0
- {devservices-1.3.2 → devservices-1.4.1}/devservices/commands/purge.py +0 -0
- {devservices-1.3.2 → devservices-1.4.1}/devservices/commands/reset.py +0 -0
- {devservices-1.3.2 → devservices-1.4.1}/devservices/commands/serve.py +0 -0
- {devservices-1.3.2 → devservices-1.4.1}/devservices/commands/status.py +0 -0
- {devservices-1.3.2 → devservices-1.4.1}/devservices/commands/toggle.py +0 -0
- {devservices-1.3.2 → devservices-1.4.1}/devservices/commands/up.py +0 -0
- {devservices-1.3.2 → devservices-1.4.1}/devservices/commands/update.py +0 -0
- {devservices-1.3.2 → devservices-1.4.1}/devservices/configs/service_config.py +0 -0
- {devservices-1.3.2 → devservices-1.4.1}/devservices/main.py +0 -0
- {devservices-1.3.2 → devservices-1.4.1}/devservices/utils/__init__.py +0 -0
- {devservices-1.3.2 → devservices-1.4.1}/devservices/utils/check_for_update.py +0 -0
- {devservices-1.3.2 → devservices-1.4.1}/devservices/utils/console.py +0 -0
- {devservices-1.3.2 → devservices-1.4.1}/devservices/utils/devenv.py +0 -0
- {devservices-1.3.2 → devservices-1.4.1}/devservices/utils/docker.py +0 -0
- {devservices-1.3.2 → devservices-1.4.1}/devservices/utils/docker_compose.py +0 -0
- {devservices-1.3.2 → devservices-1.4.1}/devservices/utils/file_lock.py +0 -0
- {devservices-1.3.2 → devservices-1.4.1}/devservices/utils/git.py +0 -0
- {devservices-1.3.2 → devservices-1.4.1}/devservices/utils/services.py +0 -0
- {devservices-1.3.2 → devservices-1.4.1}/devservices/utils/state.py +0 -0
- {devservices-1.3.2 → devservices-1.4.1}/devservices/utils/supervisor.py +0 -0
- {devservices-1.3.2 → devservices-1.4.1}/devservices.egg-info/dependency_links.txt +0 -0
- {devservices-1.3.2 → devservices-1.4.1}/devservices.egg-info/entry_points.txt +0 -0
- {devservices-1.3.2 → devservices-1.4.1}/devservices.egg-info/requires.txt +0 -0
- {devservices-1.3.2 → devservices-1.4.1}/devservices.egg-info/top_level.txt +0 -0
- {devservices-1.3.2 → devservices-1.4.1}/setup.cfg +0 -0
- {devservices-1.3.2 → devservices-1.4.1}/testing/__init__.py +0 -0
- {devservices-1.3.2 → devservices-1.4.1}/tests/__init__.py +0 -0
- {devservices-1.3.2 → devservices-1.4.1}/tests/commands/test_foreground.py +0 -0
- {devservices-1.3.2 → devservices-1.4.1}/tests/commands/test_list_dependencies.py +0 -0
- {devservices-1.3.2 → devservices-1.4.1}/tests/commands/test_list_services.py +0 -0
- {devservices-1.3.2 → devservices-1.4.1}/tests/commands/test_logs.py +0 -0
- {devservices-1.3.2 → devservices-1.4.1}/tests/commands/test_purge.py +0 -0
- {devservices-1.3.2 → devservices-1.4.1}/tests/commands/test_reset.py +0 -0
- {devservices-1.3.2 → devservices-1.4.1}/tests/commands/test_update.py +0 -0
- {devservices-1.3.2 → devservices-1.4.1}/tests/configs/test_service_config.py +0 -0
- {devservices-1.3.2 → devservices-1.4.1}/tests/utils/test_check_for_update.py +0 -0
- {devservices-1.3.2 → devservices-1.4.1}/tests/utils/test_docker.py +0 -0
- {devservices-1.3.2 → devservices-1.4.1}/tests/utils/test_docker_compose.py +0 -0
- {devservices-1.3.2 → devservices-1.4.1}/tests/utils/test_git.py +0 -0
- {devservices-1.3.2 → devservices-1.4.1}/tests/utils/test_install_binary.py +0 -0
- {devservices-1.3.2 → devservices-1.4.1}/tests/utils/test_services.py +0 -0
- {devservices-1.3.2 → devservices-1.4.1}/tests/utils/test_state.py +0 -0
- {devservices-1.3.2 → devservices-1.4.1}/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.1
|
|
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,19 @@ 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.
|
|
33
|
+
from devservices.utils import github
|
|
40
34
|
from devservices.utils.file_lock import lock
|
|
35
|
+
from devservices.utils.retry import retry
|
|
41
36
|
from devservices.utils.services import Service
|
|
42
37
|
from devservices.utils.services import find_matching_service
|
|
43
38
|
from devservices.utils.services import get_active_service_names
|
|
@@ -45,17 +40,6 @@ from devservices.utils.state import ServiceRuntime
|
|
|
45
40
|
from devservices.utils.state import State
|
|
46
41
|
from devservices.utils.state import StateTables
|
|
47
42
|
|
|
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
43
|
|
|
60
44
|
@dataclass(frozen=True, eq=True)
|
|
61
45
|
class DependencyNode:
|
|
@@ -129,87 +113,6 @@ class InstalledRemoteDependency:
|
|
|
129
113
|
mode: str = "default"
|
|
130
114
|
|
|
131
115
|
|
|
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
116
|
def install_and_verify_dependencies(
|
|
214
117
|
service: Service,
|
|
215
118
|
force_update_dependencies: bool = False,
|
|
@@ -435,14 +338,7 @@ def install_dependency(dependency: RemoteConfig) -> set[InstalledRemoteDependenc
|
|
|
435
338
|
DEVSERVICES_DEPENDENCIES_CACHE_DIR, f"{dependency.repo_name}.lock"
|
|
436
339
|
)
|
|
437
340
|
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)
|
|
341
|
+
_fetch_dependency(dependency, dependency_repo_dir)
|
|
446
342
|
|
|
447
343
|
if not verify_local_dependency(dependency):
|
|
448
344
|
# TODO: what should we do if the local dependency isn't installed correctly?
|
|
@@ -503,185 +399,91 @@ def install_dependency(dependency: RemoteConfig) -> set[InstalledRemoteDependenc
|
|
|
503
399
|
return installed_dependencies
|
|
504
400
|
|
|
505
401
|
|
|
506
|
-
def
|
|
402
|
+
def _fetch_dependency(
|
|
507
403
|
dependency: RemoteConfig,
|
|
508
404
|
dependency_repo_dir: str,
|
|
509
405
|
) -> None:
|
|
510
406
|
sentry_logger.info(
|
|
511
|
-
"
|
|
407
|
+
"Fetching dependency",
|
|
512
408
|
extra={
|
|
513
409
|
"repo_name": dependency.repo_name,
|
|
514
410
|
"repo_link": dependency.repo_link,
|
|
515
411
|
"branch": dependency.branch,
|
|
516
412
|
},
|
|
517
413
|
)
|
|
518
|
-
git_config_manager = GitConfigManager(
|
|
519
|
-
dependency_repo_dir,
|
|
520
|
-
DEPENDENCY_GIT_PARTIAL_CLONE_CONFIG_OPTIONS,
|
|
521
|
-
f"{DEVSERVICES_DIR_NAME}/",
|
|
522
|
-
)
|
|
523
414
|
try:
|
|
524
|
-
|
|
525
|
-
except
|
|
415
|
+
repo_path = github.parse_repo_path(dependency.repo_link)
|
|
416
|
+
except ValueError as e:
|
|
526
417
|
raise DependencyError(
|
|
527
418
|
repo_name=dependency.repo_name,
|
|
528
419
|
repo_link=dependency.repo_link,
|
|
529
420
|
branch=dependency.branch,
|
|
530
421
|
) from e
|
|
531
422
|
|
|
532
|
-
|
|
533
|
-
_run_command_with_retries(
|
|
534
|
-
[
|
|
535
|
-
"git",
|
|
536
|
-
"fetch",
|
|
537
|
-
"origin",
|
|
538
|
-
dependency.branch,
|
|
539
|
-
"--filter=blob:none",
|
|
540
|
-
"--no-recurse-submodules", # Avoid fetching submodules
|
|
541
|
-
],
|
|
542
|
-
cwd=dependency_repo_dir,
|
|
543
|
-
)
|
|
544
|
-
except subprocess.CalledProcessError as e:
|
|
545
|
-
# Try to set the git config context to help with debugging
|
|
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
|
|
423
|
+
zip_url = github.zipball_url(repo_path, dependency.branch)
|
|
553
424
|
|
|
554
|
-
|
|
555
|
-
|
|
556
|
-
|
|
557
|
-
|
|
558
|
-
raise DependencyError(
|
|
559
|
-
repo_name=dependency.repo_name,
|
|
560
|
-
repo_link=dependency.repo_link,
|
|
561
|
-
branch=dependency.branch,
|
|
562
|
-
stderr=e.stderr,
|
|
563
|
-
) from e
|
|
425
|
+
def _download() -> bytes:
|
|
426
|
+
req = github.build_api_request(zip_url)
|
|
427
|
+
with urllib.request.urlopen(req) as response:
|
|
428
|
+
return bytes(response.read())
|
|
564
429
|
|
|
565
430
|
try:
|
|
566
|
-
|
|
567
|
-
|
|
431
|
+
zip_data = io.BytesIO(
|
|
432
|
+
retry(
|
|
433
|
+
_download,
|
|
434
|
+
exceptions=(OSError,),
|
|
435
|
+
should_retry=lambda e: not isinstance(e, urllib.error.HTTPError),
|
|
436
|
+
)
|
|
437
|
+
)
|
|
438
|
+
except (OSError, ValueError) as e:
|
|
568
439
|
raise DependencyError(
|
|
569
440
|
repo_name=dependency.repo_name,
|
|
570
441
|
repo_link=dependency.repo_link,
|
|
571
442
|
branch=dependency.branch,
|
|
572
|
-
stderr=e.stderr,
|
|
573
443
|
) from e
|
|
574
444
|
|
|
575
|
-
if local_commit == remote_commit:
|
|
576
|
-
# Already up-to-date, don't pull anything
|
|
577
|
-
logger = logging.getLogger(LOGGER_NAME)
|
|
578
|
-
logger.debug(
|
|
579
|
-
"Dependency %s is already up-to-date, not pulling anything",
|
|
580
|
-
dependency.repo_name,
|
|
581
|
-
)
|
|
582
|
-
return
|
|
583
|
-
|
|
584
|
-
# If it's not up-to-date, checkout the latest changes (forcibly)
|
|
585
445
|
try:
|
|
586
|
-
|
|
587
|
-
|
|
446
|
+
with zipfile.ZipFile(zip_data) as zf:
|
|
447
|
+
names = zf.namelist()
|
|
448
|
+
if not names:
|
|
449
|
+
raise DependencyError(
|
|
450
|
+
repo_name=dependency.repo_name,
|
|
451
|
+
repo_link=dependency.repo_link,
|
|
452
|
+
branch=dependency.branch,
|
|
453
|
+
)
|
|
454
|
+
# GitHub zips always have a single top-level directory: "owner-repo-sha/"
|
|
455
|
+
prefix = names[0].split("/")[0] + "/"
|
|
456
|
+
devservices_prefix = f"{prefix}{DEVSERVICES_DIR_NAME}/"
|
|
457
|
+
to_extract = [
|
|
458
|
+
name
|
|
459
|
+
for name in names
|
|
460
|
+
if name.startswith(devservices_prefix) and not name.endswith("/")
|
|
461
|
+
]
|
|
462
|
+
if not to_extract:
|
|
463
|
+
raise DependencyError(
|
|
464
|
+
repo_name=dependency.repo_name,
|
|
465
|
+
repo_link=dependency.repo_link,
|
|
466
|
+
branch=dependency.branch,
|
|
467
|
+
)
|
|
468
|
+
|
|
469
|
+
if os.path.exists(dependency_repo_dir):
|
|
470
|
+
shutil.rmtree(dependency_repo_dir)
|
|
471
|
+
os.makedirs(dependency_repo_dir)
|
|
472
|
+
|
|
473
|
+
for name in to_extract:
|
|
474
|
+
relative_path = name[len(prefix) :]
|
|
475
|
+
target = os.path.join(dependency_repo_dir, relative_path)
|
|
476
|
+
os.makedirs(os.path.dirname(target), exist_ok=True)
|
|
477
|
+
with zf.open(name) as src, open(target, "wb") as dst:
|
|
478
|
+
shutil.copyfileobj(src, dst)
|
|
479
|
+
except (zipfile.BadZipFile, OSError) as e:
|
|
588
480
|
raise DependencyError(
|
|
589
481
|
repo_name=dependency.repo_name,
|
|
590
482
|
repo_link=dependency.repo_link,
|
|
591
483
|
branch=dependency.branch,
|
|
592
|
-
stderr=e.stderr,
|
|
593
484
|
) from e
|
|
594
485
|
|
|
595
486
|
|
|
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(ignore_cleanup_errors=True) 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
487
|
def _get_remote_configs(dependencies: list[Dependency]) -> list[RemoteConfig]:
|
|
686
488
|
return [
|
|
687
489
|
dependency.remote
|
|
@@ -694,64 +496,6 @@ def _has_remote_config(remote_config: RemoteConfig | None) -> TypeGuard[RemoteCo
|
|
|
694
496
|
return remote_config is not None
|
|
695
497
|
|
|
696
498
|
|
|
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
499
|
def get_remote_dependency_config(remote_config: RemoteConfig) -> ServiceConfig:
|
|
756
500
|
dependency_repo_dir = os.path.join(
|
|
757
501
|
DEVSERVICES_DEPENDENCIES_CACHE_DIR,
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import os
|
|
4
|
+
import shutil
|
|
5
|
+
import subprocess
|
|
6
|
+
import sys
|
|
7
|
+
import threading
|
|
8
|
+
import urllib.request
|
|
9
|
+
|
|
10
|
+
from devservices.utils.console import Console
|
|
11
|
+
|
|
12
|
+
GITHUB_API_ACCEPT = "application/vnd.github+json"
|
|
13
|
+
|
|
14
|
+
_auth_warning_lock = threading.Lock()
|
|
15
|
+
_auth_warned = False
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def parse_repo_path(repo_link: str) -> str:
|
|
19
|
+
"""Extract the "owner/repo" path from a GitHub URL."""
|
|
20
|
+
url = repo_link.rstrip("/").removesuffix(".git")
|
|
21
|
+
if "github.com/" not in url:
|
|
22
|
+
raise ValueError(f"Not a GitHub URL: {repo_link}")
|
|
23
|
+
return url.split("github.com/", 1)[1]
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def zipball_url(repo_path: str, ref: str) -> str:
|
|
27
|
+
return f"https://api.github.com/repos/{repo_path}/zipball/{ref}"
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def build_api_request(url: str) -> urllib.request.Request:
|
|
31
|
+
"""Build an authenticated GitHub API request, authenticating when possible.
|
|
32
|
+
|
|
33
|
+
Authenticated requests get the 5000 req/hr rate limit instead of the 60
|
|
34
|
+
req/hr unauthenticated limit (which CI runners, sharing egress IPs, exhaust
|
|
35
|
+
quickly and then receive HTTP 403s).
|
|
36
|
+
"""
|
|
37
|
+
req = urllib.request.Request(url, headers={"Accept": GITHUB_API_ACCEPT})
|
|
38
|
+
token = _get_token()
|
|
39
|
+
if token:
|
|
40
|
+
# Use an unredirected header so the token is only sent to api.github.com
|
|
41
|
+
# and is NOT forwarded when the API 302-redirects the download to
|
|
42
|
+
# codeload.github.com (and S3-backed hosts for other endpoints), which
|
|
43
|
+
# reject or mishandle requests that carry an Authorization header.
|
|
44
|
+
req.add_unredirected_header("Authorization", f"Bearer {token}")
|
|
45
|
+
return req
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def _get_token() -> str | None:
|
|
49
|
+
# Prefer an explicit token from the environment (CI sets these), otherwise
|
|
50
|
+
# fall back to the locally authenticated `gh` CLI so local users are authed
|
|
51
|
+
# without extra setup.
|
|
52
|
+
token = os.environ.get("GITHUB_TOKEN") or os.environ.get("GH_TOKEN")
|
|
53
|
+
if token:
|
|
54
|
+
return token
|
|
55
|
+
if shutil.which("gh") is not None:
|
|
56
|
+
try:
|
|
57
|
+
result = subprocess.run(
|
|
58
|
+
["gh", "auth", "token"],
|
|
59
|
+
capture_output=True,
|
|
60
|
+
text=True,
|
|
61
|
+
timeout=5,
|
|
62
|
+
)
|
|
63
|
+
except (OSError, subprocess.SubprocessError):
|
|
64
|
+
result = None
|
|
65
|
+
if result is not None and result.returncode == 0:
|
|
66
|
+
token = result.stdout.strip()
|
|
67
|
+
if token:
|
|
68
|
+
return token
|
|
69
|
+
_warn_unauthenticated()
|
|
70
|
+
return None
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def _warn_unauthenticated() -> None:
|
|
74
|
+
# Fetches run concurrently across dependencies; only warn once per process so
|
|
75
|
+
# we don't spam the same message for every dependency.
|
|
76
|
+
global _auth_warned
|
|
77
|
+
with _auth_warning_lock:
|
|
78
|
+
if _auth_warned:
|
|
79
|
+
return
|
|
80
|
+
_auth_warned = True
|
|
81
|
+
if sys.platform == "darwin":
|
|
82
|
+
install_hint = "Install the GitHub CLI (brew install gh)"
|
|
83
|
+
else:
|
|
84
|
+
install_hint = "Install the GitHub CLI (https://github.com/cli/cli)"
|
|
85
|
+
Console().warning(
|
|
86
|
+
"Could not authenticate with GitHub; falling back to unauthenticated "
|
|
87
|
+
f"downloads, which are heavily rate-limited. {install_hint} and run "
|
|
88
|
+
"`gh auth login` to authenticate."
|
|
89
|
+
)
|
|
@@ -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(
|
|
@@ -21,26 +21,21 @@ def install_binary(
|
|
|
21
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:
|