Skip to content

Commit fda9976

Browse files
committed
Revert "refactor(downloader): resolve latest via S3 listing, drop latest.json reliance"
This reverts commit 70688d8.
1 parent 70688d8 commit fda9976

2 files changed

Lines changed: 45 additions & 285 deletions

File tree

datadog_checks_downloader/datadog_checks/downloader/download_v2.py

Lines changed: 15 additions & 136 deletions
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,8 @@
66
77
The v2 format stores a JSON pointer file as a TUF target at:
88
9-
targets/<project>/<version>.json
9+
targets/<project>/<version>.json (versioned)
10+
targets/<project>/latest.json (latest stable, updated on each release)
1011
1112
Each pointer file contains:
1213
@@ -22,21 +23,6 @@
2223
The downloader TUF-verifies the pointer file, then fetches the wheel from
2324
``repository + wheel_path`` and verifies the sha256 digest and byte length
2425
before writing to disk.
25-
26-
Latest-version resolution
27-
-------------------------
28-
29-
When ``version`` is omitted, the downloader lists the bucket prefix
30-
``targets/<project>/`` directly via S3's ``ListObjectsV2`` REST API,
31-
filters keys to PEP 440 stable versions, and picks the maximum. The
32-
chosen version is then fetched through TUF as usual, so the pointer file
33-
the client trusts is still cryptographically verified.
34-
35-
Why list S3 instead of parsing the signed targets metadata: once
36-
``path_hash_prefixes`` delegations are in use in the TUF repo, the client
37-
cannot tell from the metadata alone which delegation signs the latest
38-
version of a given project. A bucket listing sidesteps that — TUF still
39-
authoritatively verifies whichever version is then requested.
4026
"""
4127

4228
import hashlib
@@ -45,44 +31,15 @@
4531
import shutil
4632
import tempfile
4733
import urllib.error
48-
import urllib.parse
4934
import urllib.request
50-
import xml.etree.ElementTree as ET
5135
from pathlib import Path
5236

53-
from packaging.version import InvalidVersion, Version
5437
from tuf.ngclient import Updater
5538

5639
from .exceptions import DigestMismatch, TargetNotFoundError
5740

5841
logger = logging.getLogger(__name__)
5942

60-
# S3 ListObjectsV2 returns at most 1000 keys per request; pagination
61-
# continues via the ``NextContinuationToken`` in the response. Constant is
62-
# the AWS-imposed max, included for clarity in the URL builder below.
63-
_S3_MAX_KEYS = 1000
64-
65-
# All ListBucketResult elements live under this namespace. Without a
66-
# namespace prefix in the XPath, ``ElementTree.find`` won't match them.
67-
_S3_NS = {'s3': 'http://s3.amazonaws.com/doc/2006-03-01/'}
68-
69-
70-
def _is_stable(version_str: str) -> bool:
71-
"""Return True if *version_str* is a stable PEP 440 release.
72-
73-
Stable means: parses as PEP 440, not a pre-release (aN/bN/rcN), not a
74-
dev release (.devN), and no local-version identifier (+local).
75-
Post-releases count as stable. Mirrors the publisher's definition so
76-
the two sides agree on what "stable" means.
77-
"""
78-
try:
79-
v = Version(version_str)
80-
except InvalidVersion:
81-
return False
82-
if v.is_prerelease or v.is_devrelease:
83-
return False
84-
return v.local is None
85-
8643

8744
class TUFPointerDownloader:
8845
"""Downloads Datadog integration wheels from a v2 TUF repository."""
@@ -115,7 +72,10 @@ def __init__(
11572
self._disable_verification = disable_verification
11673

11774
if disable_verification:
118-
logger.warning('Running with TUF verification disabled. Integrity is protected only by TLS (HTTPS).')
75+
logger.warning(
76+
'Running with TUF verification disabled. '
77+
'Integrity is protected only by TLS (HTTPS).'
78+
)
11979

12080
# ------------------------------------------------------------------
12181
# Internal helpers
@@ -141,102 +101,17 @@ def _make_updater(self, metadata_dir: Path, target_dir: Path) -> Updater:
141101
target_dir=str(target_dir),
142102
)
143103

144-
def _list_s3_keys(self, prefix: str) -> list[str]:
145-
"""List every key under *prefix* in the public S3 bucket.
146-
147-
Uses S3's ListObjectsV2 REST API directly so we don't depend on
148-
boto3. Handles pagination via ``ContinuationToken``; returns all
149-
keys under the prefix in arbitrary order (caller filters).
150-
151-
Raises ``TargetNotFoundError`` on HTTP 404 — that's how a missing
152-
project surfaces (S3 returns the bucket but with KeyCount=0, but a
153-
wholly unreachable bucket returns 404 / 403).
154-
"""
155-
keys: list[str] = []
156-
continuation_token: str | None = None
157-
while True:
158-
params = {
159-
'list-type': '2',
160-
'prefix': prefix,
161-
'max-keys': str(_S3_MAX_KEYS),
162-
}
163-
if continuation_token is not None:
164-
params['continuation-token'] = continuation_token
165-
url = f'{self._repository_url}/?{urllib.parse.urlencode(params)}'
166-
try:
167-
with urllib.request.urlopen(url) as resp:
168-
body = resp.read()
169-
except urllib.error.HTTPError as exc:
170-
raise TargetNotFoundError(f'Cannot list {prefix} (HTTP {exc.code})') from exc
171-
172-
root = ET.fromstring(body)
173-
for content in root.findall('s3:Contents', _S3_NS):
174-
key_elem = content.find('s3:Key', _S3_NS)
175-
if key_elem is not None and key_elem.text:
176-
keys.append(key_elem.text)
177-
178-
truncated_elem = root.find('s3:IsTruncated', _S3_NS)
179-
if truncated_elem is None or truncated_elem.text != 'true':
180-
break
181-
token_elem = root.find('s3:NextContinuationToken', _S3_NS)
182-
if token_elem is None or not token_elem.text:
183-
# Truncated but no continuation token — treat as terminal
184-
# rather than loop forever. S3 should never produce this
185-
# combination, but guard anyway.
186-
break
187-
continuation_token = token_elem.text
188-
return keys
189-
190-
def _resolve_latest_version(self, project: str) -> str:
191-
"""List the bucket and return the highest PEP 440 stable version
192-
for *project*.
193-
194-
Raises ``TargetNotFoundError`` if no stable version is published.
195-
"""
196-
prefix = f'targets/{project}/'
197-
keys = self._list_s3_keys(prefix)
198-
199-
# Each key under the prefix is ``targets/<project>/<version>.json``.
200-
# Strip the prefix and the ``.json`` suffix to recover the version
201-
# string, then filter to keys that look like ``<version>.json``
202-
# (no further path segments — defensive against future siblings
203-
# under ``targets/<project>/`` that aren't pointer files).
204-
candidates: list[Version] = []
205-
for key in keys:
206-
tail = key[len(prefix) :]
207-
if '/' in tail or not tail.endswith('.json'):
208-
continue
209-
version_str = tail[: -len('.json')]
210-
if not _is_stable(version_str):
211-
continue
212-
try:
213-
candidates.append(Version(version_str))
214-
except InvalidVersion:
215-
continue
216-
217-
if not candidates:
218-
raise TargetNotFoundError(f'No stable version found under s3 prefix {prefix!r}')
219-
return str(max(candidates))
220-
221104
# ------------------------------------------------------------------
222105
# Public API
223106
# ------------------------------------------------------------------
224107

225108
def get_pointer(self, project: str, version: str | None = None) -> dict:
226109
"""Return the TUF-verified pointer JSON for *project* at *version*.
227110
228-
*version* = None resolves to the highest PEP 440 stable version
229-
published under ``targets/<project>/`` in the bucket. The chosen
230-
version is then fetched through TUF, so the returned pointer is
231-
still cryptographically verified.
232-
233-
Raises ``TargetNotFoundError`` if the target is absent from TUF or
234-
no stable version is published.
111+
*version* = None resolves to ``latest.json`` (stable releases only).
112+
Raises ``TargetNotFoundError`` if the target is absent from the TUF repo.
235113
"""
236-
if version is None:
237-
version = self._resolve_latest_version(project)
238-
239-
target_path = f'{project}/{version}.json'
114+
target_path = f'{project}/{version or "latest"}.json'
240115

241116
with tempfile.TemporaryDirectory() as tmp:
242117
metadata_dir = Path(tmp) / 'metadata'
@@ -251,7 +126,9 @@ def get_pointer(self, project: str, version: str | None = None) -> dict:
251126
return json.loads(resp.read())
252127
except urllib.error.HTTPError as exc:
253128
if exc.code == 404:
254-
raise TargetNotFoundError(f'Pointer not found: {target_path}') from exc
129+
raise TargetNotFoundError(
130+
f'Pointer not found: {target_path}'
131+
) from exc
255132
raise
256133

257134
self._bootstrap_metadata_dir(metadata_dir)
@@ -261,7 +138,9 @@ def get_pointer(self, project: str, version: str | None = None) -> dict:
261138

262139
target_info = updater.get_targetinfo(target_path)
263140
if target_info is None:
264-
raise TargetNotFoundError(f'No TUF target for {project!r} version {version!r}')
141+
raise TargetNotFoundError(
142+
f'No TUF target for {project!r} version {version or "latest"!r}'
143+
)
265144

266145
pointer_path = target_dir / target_path
267146
pointer_path.parent.mkdir(parents=True, exist_ok=True)

0 commit comments

Comments
 (0)