|
8 | 8 |
|
9 | 9 | targets/<project>/<version>.json (versioned pointer) |
10 | 10 |
|
| 11 | +Target files are content-addressed on S3 (consistent-snapshot format): |
| 12 | +the actual file at ``targets/<project>/<version>.json`` is stored as |
| 13 | +``targets/<project>/{sha256}.{version}.json``. The TUF Updater resolves |
| 14 | +the hash-prefixed path automatically via ``N.targets.json`` metadata. |
| 15 | +
|
11 | 16 | Each pointer file contains: |
12 | 17 |
|
13 | 18 | { |
|
33 | 38 | import urllib.request |
34 | 39 | from pathlib import Path |
35 | 40 |
|
| 41 | +from packaging.version import InvalidVersion, Version |
| 42 | +from tuf.api.metadata import Metadata, Snapshot, Targets, Timestamp |
36 | 43 | from tuf.ngclient import Updater |
| 44 | +from tuf.ngclient.config import UpdaterConfig |
37 | 45 |
|
38 | 46 | from .exceptions import DigestMismatch, TargetNotFoundError |
39 | 47 |
|
40 | | -V2_REPOSITORY_URL = "https://agent-integration-wheels-prod.s3.amazonaws.com" |
41 | | - |
42 | 48 | logger = logging.getLogger(__name__) |
43 | 49 |
|
| 50 | +V2_REPOSITORY_URL = "https://agent-integration-wheels-prod.s3.amazonaws.com" |
| 51 | + |
44 | 52 |
|
45 | 53 | class TUFPointerDownloader: |
46 | 54 | """Downloads Datadog integration wheels from a v2 TUF repository.""" |
@@ -87,48 +95,110 @@ def _make_updater(self, metadata_dir: Path, target_dir: Path) -> Updater: |
87 | 95 | metadata_base_url=f'{self._repository_url}/metadata/', |
88 | 96 | target_base_url=f'{self._repository_url}/targets/', |
89 | 97 | target_dir=str(target_dir), |
| 98 | + config=UpdaterConfig(prefix_targets_with_hash=True), |
90 | 99 | ) |
91 | 100 |
|
| 101 | + @staticmethod |
| 102 | + def _resolve_version(project: str, version: str | None, targets: Targets) -> str: |
| 103 | + """Return the concrete version to fetch. |
| 104 | +
|
| 105 | + If *version* is given, return it as-is (TUF will raise if absent). |
| 106 | + If None, scan *targets* for all ``<project>/<ver>.json`` entries and |
| 107 | + return the highest stable PEP 440 version. |
| 108 | + """ |
| 109 | + if version is not None: |
| 110 | + return version |
| 111 | + |
| 112 | + prefix = f"{project}/" |
| 113 | + stable: list[Version] = [] |
| 114 | + for target_path in targets.targets: |
| 115 | + if not (target_path.startswith(prefix) and target_path.endswith(".json")): |
| 116 | + continue |
| 117 | + stem = target_path[len(prefix): -len(".json")] |
| 118 | + try: |
| 119 | + v = Version(stem) |
| 120 | + except InvalidVersion: |
| 121 | + continue |
| 122 | + if not v.is_prerelease: |
| 123 | + stable.append(v) |
| 124 | + |
| 125 | + if not stable: |
| 126 | + raise TargetNotFoundError(f"No stable releases found for {project!r} in targets metadata") |
| 127 | + return str(max(stable)) |
| 128 | + |
| 129 | + def _fetch_targets_unverified(self) -> Targets: |
| 130 | + """Fetch targets metadata without verifying TUF signatures. |
| 131 | +
|
| 132 | + Walks timestamp → snapshot → targets to resolve the current versioned |
| 133 | + targets file and returns the parsed Targets signed payload. |
| 134 | + """ |
| 135 | + base = self._repository_url |
| 136 | + |
| 137 | + with urllib.request.urlopen(f'{base}/metadata/timestamp.json') as r: |
| 138 | + ts: Metadata[Timestamp] = Metadata[Timestamp].from_bytes(r.read()) |
| 139 | + snap_ver = ts.signed.snapshot_meta.version |
| 140 | + |
| 141 | + with urllib.request.urlopen(f'{base}/metadata/{snap_ver}.snapshot.json') as r: |
| 142 | + snap: Metadata[Snapshot] = Metadata[Snapshot].from_bytes(r.read()) |
| 143 | + tgt_ver = snap.signed.meta['targets.json'].version |
| 144 | + |
| 145 | + with urllib.request.urlopen(f'{base}/metadata/{tgt_ver}.targets.json') as r: |
| 146 | + return Metadata[Targets].from_bytes(r.read()).signed |
| 147 | + |
92 | 148 | # ------------------------------------------------------------------ |
93 | 149 | # Public API |
94 | 150 | # ------------------------------------------------------------------ |
95 | 151 |
|
96 | 152 | def get_pointer(self, project: str, version: str | None = None) -> dict: |
97 | | - """Return the TUF-verified pointer JSON for *project* at *version*. |
| 153 | + """Return the pointer JSON for *project* at *version*. |
98 | 154 |
|
99 | | - *version* = None resolves to ``latest.json`` (stable releases only). |
| 155 | + Resolves *version* = None to the latest stable release by scanning |
| 156 | + ``N.targets.json``. The pointer file is fetched via its hash-prefixed |
| 157 | + consistent-snapshot path. |
100 | 158 | Raises ``TargetNotFoundError`` if the target is absent from the TUF repo. |
101 | 159 | """ |
102 | | - target_path = f'{project}/{version or "latest"}.json' |
103 | | - |
104 | 160 | with tempfile.TemporaryDirectory() as tmp: |
105 | 161 | metadata_dir = Path(tmp) / 'metadata' |
106 | 162 | target_dir = Path(tmp) / 'targets' |
107 | 163 | metadata_dir.mkdir() |
108 | 164 | target_dir.mkdir() |
109 | 165 |
|
110 | 166 | if self._disable_verification: |
111 | | - url = f'{self._repository_url}/targets/{target_path}' |
| 167 | + targets = self._fetch_targets_unverified() |
| 168 | + resolved = self._resolve_version(project, version, targets) |
| 169 | + target_path = f'{project}/{resolved}.json' |
| 170 | + |
| 171 | + entry = targets.targets.get(target_path) |
| 172 | + if entry is None: |
| 173 | + raise TargetNotFoundError(f'Target not found: {target_path}') |
| 174 | + |
| 175 | + sha256 = entry.hashes.get('sha256', '') |
| 176 | + url = f'{self._repository_url}/targets/{project}/{sha256}.{resolved}.json' |
112 | 177 | try: |
113 | 178 | with urllib.request.urlopen(url) as resp: |
114 | 179 | return json.loads(resp.read()) |
115 | 180 | except urllib.error.HTTPError as exc: |
116 | 181 | if exc.code == 404: |
117 | | - raise TargetNotFoundError( |
118 | | - f'Pointer not found: {target_path}' |
119 | | - ) from exc |
| 182 | + raise TargetNotFoundError(f'Pointer not found: {url}') from exc |
120 | 183 | raise |
121 | 184 |
|
122 | 185 | self._bootstrap_metadata_dir(metadata_dir) |
123 | | - |
124 | 186 | updater = self._make_updater(metadata_dir, target_dir) |
125 | 187 | updater.refresh() |
126 | 188 |
|
| 189 | + # Parse the downloaded N.targets.json to resolve version and |
| 190 | + # enumerate the hash-prefixed path the Updater will fetch. |
| 191 | + candidates = sorted(metadata_dir.glob('*.targets.json')) |
| 192 | + if not candidates: |
| 193 | + raise TargetNotFoundError(f'No targets metadata after refresh for {project!r}') |
| 194 | + targets = Metadata[Targets].from_file(str(candidates[-1])).signed |
| 195 | + |
| 196 | + resolved = self._resolve_version(project, version, targets) |
| 197 | + target_path = f'{project}/{resolved}.json' |
| 198 | + |
127 | 199 | target_info = updater.get_targetinfo(target_path) |
128 | 200 | if target_info is None: |
129 | | - raise TargetNotFoundError( |
130 | | - f'No TUF target for {project!r} version {version or "latest"!r}' |
131 | | - ) |
| 201 | + raise TargetNotFoundError(f'No TUF target for {project!r} version {resolved!r}') |
132 | 202 |
|
133 | 203 | pointer_path = target_dir / target_path |
134 | 204 | pointer_path.parent.mkdir(parents=True, exist_ok=True) |
|
0 commit comments