Skip to content

Commit 1bbcbaa

Browse files
committed
Simplify v2 downloader implementation
1 parent 71a7fe5 commit 1bbcbaa

6 files changed

Lines changed: 77 additions & 139 deletions

File tree

datadog_checks_downloader/datadog_checks/downloader/cli.py

Lines changed: 19 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -8,11 +8,14 @@
88
import os
99
import re
1010
import sys
11+
import urllib.error
1112

1213
# 2nd party.
14+
from tuf.api.exceptions import DownloadError
15+
1316
from .download import DEFAULT_ROOT_LAYOUT_TYPE, REPOSITORY_URL_PREFIX, ROOT_LAYOUTS, TUFDownloader
1417
from .download_v2 import V2_REPOSITORY_URL, TUFPointerDownloader
15-
from .exceptions import NonCanonicalVersion, NonDatadogPackage
18+
from .exceptions import NonCanonicalVersion, NonDatadogPackage, TargetNotFoundError
1619

1720
# Private module functions.
1821

@@ -26,6 +29,14 @@ def __is_canonical(version):
2629
return re.match(P, version) is not None
2730

2831

32+
def _v2_failure_category(exc: Exception) -> str:
33+
if isinstance(exc, TargetNotFoundError):
34+
return 'target version not found'
35+
if isinstance(exc, (DownloadError, TimeoutError, urllib.error.URLError)):
36+
return 'network error'
37+
return 'other'
38+
39+
2940
def __find_shipped_integrations():
3041
# Recurse up from site-packages until we find the Agent root directory.
3142
# The relative path differs between operating systems.
@@ -158,7 +169,12 @@ def download():
158169
except Exception as exc:
159170
import logging
160171

161-
logging.getLogger(__name__).info('v2 download failed (%s: %s), falling back to v1', type(exc).__name__, exc)
172+
logging.getLogger(__name__).info(
173+
'v2 download failed (%s, %s: %s), falling back to v1',
174+
_v2_failure_category(exc),
175+
type(exc).__name__,
176+
exc,
177+
)
162178
tuf_downloader, standard_distribution_name, version, ignore_python_version = instantiate_downloader()
163179
run_downloader(tuf_downloader, standard_distribution_name, version, ignore_python_version)
164180

@@ -187,18 +203,14 @@ def _download_v2():
187203
# v1 flags that are not applicable in v2: accept and warn so that callers
188204
# upgrading from v1 get a clear message instead of an argument error.
189205
parser.add_argument('--type', type=str, default=None, dest='_type_ignored')
190-
parser.add_argument('--force', action='store_true', dest='_force_ignored')
191206
parser.add_argument('--ignore-python-version', action='store_true', dest='_ignore_python_version')
192207

193208
args = parser.parse_args()
194209

195210
if not args.standard_distribution_name.startswith('datadog-'):
196211
raise NonDatadogPackage(args.standard_distribution_name)
197212

198-
if args.version and not re.match(
199-
r'^([1-9]\d*!)?(0|[1-9]\d*)(\.(0|[1-9]\d*))*((a|b|rc)(0|[1-9]\d*))?(\.post(0|[1-9]\d*))?(\.dev(0|[1-9]\d*))?$',
200-
args.version,
201-
):
213+
if args.version and not __is_canonical(args.version):
202214
raise NonCanonicalVersion(args.version)
203215

204216
if args._type_ignored is not None:

datadog_checks_downloader/datadog_checks/downloader/data/v2/metadata/1.root.json renamed to datadog_checks_downloader/datadog_checks/downloader/data/v2/metadata/root.json

File renamed without changes.

datadog_checks_downloader/datadog_checks/downloader/download_v2.py

Lines changed: 4 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -2,32 +2,10 @@
22
# All rights reserved
33
# Licensed under a 3-clause BSD style license (see LICENSE)
44

5-
"""TUF pointer-file downloader (v2 repository format).
5+
"""TUF pointer-file downloader for the v2 repository format.
66
7-
The v2 format stores a JSON pointer file as a TUF target at:
8-
9-
targets/<project>/<version>.json (versioned pointer)
10-
targets/<project>/latest.json (copy of latest stable version pointer)
11-
12-
Target files are content-addressed on S3 (consistent-snapshot format):
13-
the actual file at ``targets/<project>/<name>.json`` is stored as
14-
``targets/<project>/{sha256}.{name}.json``. The TUF Updater resolves
15-
the hash-prefixed path automatically via TUF metadata.
16-
17-
Each pointer file contains:
18-
19-
{
20-
"digest": "<sha256 hex>",
21-
"length": <int>,
22-
"version": "<semver>",
23-
"repository": "https://<bucket>.s3.amazonaws.com",
24-
"wheel_path": "/wheels/<project>/<wheel>.whl",
25-
"attestation_path": "/attestations/<project>/<version>.sigstore.json"
26-
}
27-
28-
The downloader TUF-verifies the pointer file, then fetches the wheel from
29-
``repository + wheel_path`` and verifies the sha256 digest and byte length
30-
before writing to disk.
7+
The downloader TUF-verifies a JSON pointer target, downloads the referenced
8+
wheel, and verifies the wheel digest and byte length before writing it to disk.
319
"""
3210

3311
import hashlib
@@ -82,7 +60,7 @@ def _bootstrap_metadata_dir(self, metadata_dir: Path) -> None:
8260
"""Seed *metadata_dir* with the bundled initial root.json trust anchor."""
8361
dest = metadata_dir / 'root.json'
8462
metadata = importlib.resources.files('datadog_checks.downloader') / 'data' / 'v2' / 'metadata'
85-
dest.write_bytes((metadata / '1.root.json').read_bytes())
63+
dest.write_bytes((metadata / 'root.json').read_bytes())
8664

8765
def _make_updater(self, metadata_dir: Path, target_dir: Path) -> Updater:
8866
return Updater(

datadog_checks_downloader/pyproject.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -56,7 +56,7 @@ include = [
5656
"/datadog_checks/downloader",
5757
]
5858
artifacts = [
59-
"/datadog_checks/downloader/data/v2/metadata/1.root.json",
59+
"/datadog_checks/downloader/data/v2/metadata/root.json",
6060
]
6161
dev-mode-dirs = [
6262
".",

datadog_checks_downloader/tests/test_unit.py

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,23 @@
11
# (C) Datadog, Inc. 2023-present
22
# All rights reserved
33
# Licensed under a 3-clause BSD style license (see LICENSE)
4+
import urllib.error
5+
6+
from datadog_checks.downloader.cli import _v2_failure_category
47
from datadog_checks.downloader.download import TUFDownloader
8+
from datadog_checks.downloader.exceptions import TargetNotFoundError
9+
10+
11+
def test_v2_target_not_found_failure_category():
12+
assert _v2_failure_category(TargetNotFoundError('missing')) == 'target version not found'
13+
14+
15+
def test_v2_network_failure_category():
16+
assert _v2_failure_category(urllib.error.URLError('timeout')) == 'network error'
17+
18+
19+
def test_v2_other_failure_category():
20+
assert _v2_failure_category(ValueError('bad pointer')) == 'other'
521

622

723
def test_non_official_wheel_filter(mocker):

datadog_checks_downloader/tests/test_v2_downloader.py

Lines changed: 37 additions & 105 deletions
Original file line numberDiff line numberDiff line change
@@ -2,11 +2,7 @@
22
# All rights reserved
33
# Licensed under a 3-clause BSD style license (see LICENSE)
44

5-
"""Unit tests for TUFPointerDownloader (v2 repository format).
6-
7-
All tests are offline: the TUF Updater and HTTP calls are mocked so that no
8-
network traffic is needed.
9-
"""
5+
"""Unit tests for TUFPointerDownloader (v2 repository format)."""
106

117
import hashlib
128
import json
@@ -18,30 +14,25 @@
1814
from datadog_checks.downloader.download_v2 import TUFPointerDownloader
1915
from datadog_checks.downloader.exceptions import DigestMismatch, TargetNotFoundError
2016

21-
# ---------------------------------------------------------------------------
22-
# Shared fixtures
23-
# ---------------------------------------------------------------------------
24-
2517
_PROJECT = 'datadog-postgres'
2618
_VERSION = '14.0.0'
2719
_WHEEL_CONTENT = b'fake wheel bytes for testing'
2820
_WHEEL_DIGEST = hashlib.sha256(_WHEEL_CONTENT).hexdigest()
2921
_WHEEL_LENGTH = len(_WHEEL_CONTENT)
22+
_REPO_URL = 'https://agent-integration-wheels-staging.s3.amazonaws.com'
3023

3124
_POINTER = {
3225
'digest': _WHEEL_DIGEST,
3326
'length': _WHEEL_LENGTH,
3427
'version': _VERSION,
35-
'repository': 'https://agent-integration-wheels-staging.s3.amazonaws.com',
28+
'repository': _REPO_URL,
3629
'wheel_path': f'/wheels/{_PROJECT}/datadog_postgres-{_VERSION}-py3-none-any.whl',
3730
'attestation_path': f'/attestations/{_PROJECT}/{_VERSION}.sigstore.json',
3831
}
3932

40-
_REPO_URL = 'https://agent-integration-wheels-staging.s3.amazonaws.com'
41-
4233

4334
def _mock_tuf_updater(pointer: dict) -> MagicMock:
44-
"""Return a mock Updater that yields *pointer* as the target content."""
35+
"""Return an Updater mock that writes *pointer* as target content."""
4536
pointer_bytes = json.dumps(pointer).encode()
4637
mock_updater = MagicMock()
4738
mock_updater.get_targetinfo.return_value = MagicMock()
@@ -54,9 +45,26 @@ def fake_download_target(_target_info, dest_path):
5445
return mock_updater
5546

5647

57-
# ---------------------------------------------------------------------------
58-
# target-path unit tests
59-
# ---------------------------------------------------------------------------
48+
def _mock_response(content: bytes) -> MagicMock:
49+
response = MagicMock()
50+
response.__enter__ = lambda s: s
51+
response.__exit__ = MagicMock(return_value=False)
52+
response.read.return_value = content
53+
return response
54+
55+
56+
@pytest.fixture
57+
def mock_urlopen():
58+
with patch('datadog_checks.downloader.download_v2.urllib.request.urlopen') as mock:
59+
mock.return_value = _mock_response(_WHEEL_CONTENT)
60+
yield mock
61+
62+
63+
@pytest.fixture
64+
def mock_updater_cls():
65+
with patch('datadog_checks.downloader.download_v2.Updater') as mock:
66+
mock.return_value = _mock_tuf_updater(_POINTER)
67+
yield mock
6068

6169

6270
class TestTargetPath:
@@ -67,85 +75,38 @@ def test_missing_version_uses_latest_pointer(self):
6775
assert TUFPointerDownloader._target_path(_PROJECT, None) == f'{_PROJECT}/latest.json'
6876

6977

70-
# ---------------------------------------------------------------------------
71-
# Happy-path integration tests
72-
# ---------------------------------------------------------------------------
73-
74-
7578
@pytest.mark.offline
7679
class TestHappyPath:
77-
@patch('datadog_checks.downloader.download_v2.Updater')
78-
@patch('datadog_checks.downloader.download_v2.urllib.request.urlopen')
7980
def test_download_returns_wheel_path(self, mock_urlopen, mock_updater_cls, tmp_path):
80-
mock_updater_cls.return_value = _mock_tuf_updater(_POINTER)
81-
82-
mock_urlopen.return_value.__enter__ = lambda s: s
83-
mock_urlopen.return_value.__exit__ = MagicMock(return_value=False)
84-
mock_urlopen.return_value.read.return_value = _WHEEL_CONTENT
85-
8681
downloader = TUFPointerDownloader(repository_url=_REPO_URL)
8782
wheel_path = downloader.download(_PROJECT, version=_VERSION, dest_dir=tmp_path)
8883

8984
assert wheel_path.exists()
9085
assert wheel_path.read_bytes() == _WHEEL_CONTENT
9186
assert wheel_path.name == f'datadog_postgres-{_VERSION}-py3-none-any.whl'
9287

93-
@patch('datadog_checks.downloader.download_v2.Updater')
94-
@patch('datadog_checks.downloader.download_v2.urllib.request.urlopen')
9588
def test_repository_flag_overrides_pointer_repository(self, mock_urlopen, mock_updater_cls, tmp_path):
96-
"""--repository supersedes the repository field baked into the pointer."""
97-
staging_url = 'https://agent-integration-wheels-staging.s3.amazonaws.com'
9889
prod_pointer = {**_POINTER, 'repository': 'https://agent-integration-wheels-prod.s3.amazonaws.com'}
9990
mock_updater_cls.return_value = _mock_tuf_updater(prod_pointer)
10091

101-
captured_urls: list[str] = []
102-
103-
def fake_urlopen(url):
104-
captured_urls.append(url)
105-
mock_resp = MagicMock()
106-
mock_resp.__enter__ = lambda s: s
107-
mock_resp.__exit__ = MagicMock(return_value=False)
108-
mock_resp.read.return_value = _WHEEL_CONTENT
109-
return mock_resp
110-
111-
mock_urlopen.side_effect = fake_urlopen
112-
113-
downloader = TUFPointerDownloader(repository_url=staging_url)
92+
downloader = TUFPointerDownloader(repository_url=_REPO_URL)
11493
downloader.download(_PROJECT, version=_VERSION, dest_dir=tmp_path)
11594

116-
wheel_fetch_url = captured_urls[-1]
117-
assert wheel_fetch_url.startswith(staging_url), (
118-
f'Expected wheel fetch from {staging_url!r}, got {wheel_fetch_url!r}'
95+
mock_urlopen.assert_called_once_with(
96+
f'{_REPO_URL}/wheels/{_PROJECT}/datadog_postgres-{_VERSION}-py3-none-any.whl'
11997
)
12098

121-
@patch('datadog_checks.downloader.download_v2.Updater')
122-
@patch('datadog_checks.downloader.download_v2.urllib.request.urlopen')
12399
def test_version_none_fetches_latest_pointer(self, mock_urlopen, mock_updater_cls, tmp_path):
124-
"""version=None fetches <project>/latest.json."""
125-
mock_updater = _mock_tuf_updater(_POINTER)
126-
mock_updater_cls.return_value = mock_updater
127-
128-
mock_urlopen.return_value.__enter__ = lambda s: s
129-
mock_urlopen.return_value.__exit__ = MagicMock(return_value=False)
130-
mock_urlopen.return_value.read.return_value = _WHEEL_CONTENT
131-
132100
downloader = TUFPointerDownloader(repository_url=_REPO_URL)
133101
downloader.download(_PROJECT, dest_dir=tmp_path)
134102

135-
call_args = mock_updater.get_targetinfo.call_args[0][0]
136-
assert call_args == f'{_PROJECT}/latest.json'
137-
138-
139-
# ---------------------------------------------------------------------------
140-
# Error-path tests
141-
# ---------------------------------------------------------------------------
103+
mock_updater = mock_updater_cls.return_value
104+
assert mock_updater.get_targetinfo.call_args[0][0] == f'{_PROJECT}/latest.json'
142105

143106

144107
@pytest.mark.offline
145108
class TestTargetNotFound:
146-
@patch('datadog_checks.downloader.download_v2.Updater')
147-
@patch('datadog_checks.downloader.download_v2.urllib.request.urlopen')
148-
def test_raises_when_tuf_target_absent(self, mock_urlopen, mock_updater_cls, tmp_path):
109+
def test_raises_when_tuf_target_absent(self, mock_urlopen, mock_updater_cls):
149110
mock_updater = MagicMock()
150111
mock_updater.get_targetinfo.return_value = None
151112
mock_updater_cls.return_value = mock_updater
@@ -157,66 +118,37 @@ def test_raises_when_tuf_target_absent(self, mock_urlopen, mock_updater_cls, tmp
157118

158119
@pytest.mark.offline
159120
class TestDigestMismatch:
160-
@patch('datadog_checks.downloader.download_v2.Updater')
161-
@patch('datadog_checks.downloader.download_v2.urllib.request.urlopen')
162121
def test_raises_on_corrupted_wheel(self, mock_urlopen, mock_updater_cls, tmp_path):
163-
mock_updater_cls.return_value = _mock_tuf_updater(_POINTER)
164-
165-
mock_urlopen.return_value.__enter__ = lambda s: s
166-
mock_urlopen.return_value.__exit__ = MagicMock(return_value=False)
167-
mock_urlopen.return_value.read.return_value = b'tampered bytes'
122+
mock_urlopen.return_value = _mock_response(b'tampered bytes')
168123

169124
downloader = TUFPointerDownloader(repository_url=_REPO_URL)
170125
with pytest.raises(DigestMismatch, match=_PROJECT):
171126
downloader.download(_PROJECT, version=_VERSION, dest_dir=tmp_path)
172127

173-
@patch('datadog_checks.downloader.download_v2.Updater')
174-
@patch('datadog_checks.downloader.download_v2.urllib.request.urlopen')
175128
def test_raises_on_length_mismatch(self, mock_urlopen, mock_updater_cls, tmp_path):
176129
bad_pointer = {**_POINTER, 'length': _WHEEL_LENGTH + 1}
177130
mock_updater_cls.return_value = _mock_tuf_updater(bad_pointer)
178131

179-
mock_urlopen.return_value.__enter__ = lambda s: s
180-
mock_urlopen.return_value.__exit__ = MagicMock(return_value=False)
181-
mock_urlopen.return_value.read.return_value = _WHEEL_CONTENT
182-
183132
downloader = TUFPointerDownloader(repository_url=_REPO_URL)
184133
with pytest.raises(DigestMismatch, match='length'):
185134
downloader.download(_PROJECT, version=_VERSION, dest_dir=tmp_path)
186135

187136

188-
# ---------------------------------------------------------------------------
189-
# Disable-verification mode
190-
# ---------------------------------------------------------------------------
191-
192-
193137
@pytest.mark.offline
194138
class TestDisableVerification:
195-
@patch('datadog_checks.downloader.download_v2.urllib.request.urlopen')
196139
def test_directly_downloads_wheel_without_tuf_or_digest_checks(self, mock_urlopen, tmp_path):
197-
"""disable_verification bypasses TUF/pointers and downloads the wheel by
198-
convention from /wheels/<project>/<wheel>.whl."""
199-
captured_urls: list[str] = []
200-
201-
def fake_urlopen(url):
202-
captured_urls.append(url)
203-
mock_resp = MagicMock()
204-
mock_resp.__enter__ = lambda s: s
205-
mock_resp.__exit__ = MagicMock(return_value=False)
206-
mock_resp.read.return_value = b'bytes not matching any signed pointer'
207-
return mock_resp
208-
209-
mock_urlopen.side_effect = fake_urlopen
140+
content = b'bytes not matching any signed pointer'
141+
mock_urlopen.return_value = _mock_response(content)
210142

211143
with patch('datadog_checks.downloader.download_v2.Updater') as mock_updater_cls:
212144
downloader = TUFPointerDownloader(repository_url=_REPO_URL, disable_verification=True)
213145
wheel_path = downloader.download(_PROJECT, version=_VERSION, dest_dir=tmp_path)
214146

215-
assert captured_urls == [
216-
f'{_REPO_URL}/wheels/{_PROJECT}/datadog_postgres-{_VERSION}-py3-none-any.whl',
217-
]
147+
mock_urlopen.assert_called_once_with(
148+
f'{_REPO_URL}/wheels/{_PROJECT}/datadog_postgres-{_VERSION}-py3-none-any.whl'
149+
)
218150
assert wheel_path.name == f'datadog_postgres-{_VERSION}-py3-none-any.whl'
219-
assert wheel_path.read_bytes() == b'bytes not matching any signed pointer'
151+
assert wheel_path.read_bytes() == content
220152
mock_updater_cls.assert_not_called()
221153

222154
def test_direct_download_requires_explicit_version(self, tmp_path):

0 commit comments

Comments
 (0)