Skip to content

Commit 70688d8

Browse files
dkirov-ddclaude
andcommitted
refactor(downloader): resolve latest via S3 listing, drop latest.json reliance
Replace the ``latest.json`` rolling pointer fetch with an S3 ``ListObjectsV2`` walk over ``targets/<project>/``: filter keys to PEP 440 stable versions and pick the maximum. The chosen version is then fetched through TUF as before, so the pointer file the client trusts is still cryptographically verified. Why list S3 instead of parsing the signed targets metadata: once ``path_hash_prefixes`` delegations are in use, a client cannot tell from metadata alone which delegation signs the latest version of a given project. Listing the bucket sidesteps that — TUF still authoritatively verifies the chosen version's pointer. The publisher counterpart in agent-integrations-tuf drops ``latest.json`` entirely; see DataDog/agent-integrations-tuf PR DataDog#9. - ``_resolve_latest_version`` lists ``targets/<project>/`` via the S3 REST API (no boto3 dep), parses the XML response, follows the continuation-token pagination, and applies a PEP 440 stable filter - ``get_pointer(project, version=None)`` resolves ``version`` itself before delegating to the TUF Updater - 6 new offline tests cover max-version selection, pre-release/dev filtering, post-release support, the no-stable error, paginated listings, and non-pointer key skipping Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent ee63557 commit 70688d8

2 files changed

Lines changed: 285 additions & 45 deletions

File tree

datadog_checks_downloader/datadog_checks/downloader/download_v2.py

Lines changed: 136 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -6,8 +6,7 @@
66
77
The v2 format stores a JSON pointer file as a TUF target at:
88
9-
targets/<project>/<version>.json (versioned)
10-
targets/<project>/latest.json (latest stable, updated on each release)
9+
targets/<project>/<version>.json
1110
1211
Each pointer file contains:
1312
@@ -23,6 +22,21 @@
2322
The downloader TUF-verifies the pointer file, then fetches the wheel from
2423
``repository + wheel_path`` and verifies the sha256 digest and byte length
2524
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.
2640
"""
2741

2842
import hashlib
@@ -31,15 +45,44 @@
3145
import shutil
3246
import tempfile
3347
import urllib.error
48+
import urllib.parse
3449
import urllib.request
50+
import xml.etree.ElementTree as ET
3551
from pathlib import Path
3652

53+
from packaging.version import InvalidVersion, Version
3754
from tuf.ngclient import Updater
3855

3956
from .exceptions import DigestMismatch, TargetNotFoundError
4057

4158
logger = logging.getLogger(__name__)
4259

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+
4386

4487
class TUFPointerDownloader:
4588
"""Downloads Datadog integration wheels from a v2 TUF repository."""
@@ -72,10 +115,7 @@ def __init__(
72115
self._disable_verification = disable_verification
73116

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

80120
# ------------------------------------------------------------------
81121
# Internal helpers
@@ -101,17 +141,102 @@ def _make_updater(self, metadata_dir: Path, target_dir: Path) -> Updater:
101141
target_dir=str(target_dir),
102142
)
103143

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+
104221
# ------------------------------------------------------------------
105222
# Public API
106223
# ------------------------------------------------------------------
107224

108225
def get_pointer(self, project: str, version: str | None = None) -> dict:
109226
"""Return the TUF-verified pointer JSON for *project* at *version*.
110227
111-
*version* = None resolves to ``latest.json`` (stable releases only).
112-
Raises ``TargetNotFoundError`` if the target is absent from the TUF repo.
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.
113235
"""
114-
target_path = f'{project}/{version or "latest"}.json'
236+
if version is None:
237+
version = self._resolve_latest_version(project)
238+
239+
target_path = f'{project}/{version}.json'
115240

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

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

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

145266
pointer_path = target_dir / target_path
146267
pointer_path.parent.mkdir(parents=True, exist_ok=True)

0 commit comments

Comments
 (0)