Coverage for src/updates2mqtt/integrations/docker_enrich.py: 83%

522 statements  

« prev     ^ index     » next       coverage.py v7.15.4, created at 2026-09-09 06:17 +0000

1import re 

2import typing 

3from abc import abstractmethod 

4from typing import Any, cast 

5 

6import structlog 

7from docker.auth import resolve_repository_name 

8from docker.models.containers import Container 

9from omegaconf import MissingMandatoryValue, OmegaConf, ValidationError 

10 

11from updates2mqtt.helpers import ( 

12 APIStatsCounter, 

13 CacheMetadata, 

14 ThrottledError, 

15 Throttler, 

16 fetch_url, 

17 httpx_json_content, 

18 validate_url, 

19) 

20from updates2mqtt.model import DiscoveryArtefactDetail, DiscoveryInstallationDetail, ReleaseDetail 

21 

22if typing.TYPE_CHECKING: 

23 from docker.models.images import RegistryData 

24 from httpx import Response 

25 from omegaconf.dictconfig import DictConfig 

26from http import HTTPStatus 

27 

28import docker 

29import docker.errors 

30 

31from updates2mqtt.config import ( 

32 PKG_INFO_FILE, 

33 SOURCE_PLATFORM_GITHUB, 

34 SOURCE_PLATFORM_GITLAB, 

35 SOURCE_PLATFORMS, 

36 CommonPackages, 

37 DockerConfig, 

38 DockerPackageUpdateInfo, 

39 MetadataSourceConfig, 

40 PackageUpdateInfo, 

41 RegistryConfig, 

42 VersionPolicy, 

43 docker_image_names, 

44) 

45 

46log: Any = structlog.get_logger() 

47 

48DIFF_URL_TEMPLATES = { 

49 SOURCE_PLATFORM_GITHUB: "{repo}/commit/{revision}", 

50} 

51RELEASE_URL_TEMPLATES = { 

52 SOURCE_PLATFORM_GITHUB: "{repo}/releases/tag/{version}", 

53} 

54UNKNOWN_RELEASE_URL_TEMPLATES = { 

55 SOURCE_PLATFORM_GITHUB: "{repo}/releases", 

56 SOURCE_PLATFORM_GITLAB: "{repo}/container_registry", 

57} 

58MISSING_VAL = "**MISSING**" 

59UNKNOWN_REGISTRY = "**UNKNOWN_REGISTRY**" 

60UNKNOWN_NAME = "**UNKNOWN_NAME**" 

61 

62HEADER_DOCKER_DIGEST = "docker-content-digest" 

63HEADER_DOCKER_API = "docker-distribution-api-version" 

64 

65TOKEN_URL_TEMPLATE = "https://{auth_host}/token?scope=repository:{image_name}:pull&service={service}" # nosec 

66 

67REGISTRY_GHCR = "ghcr.io" 

68REGISTRY_DOCKER = "docker.io" 

69REGISTRY_MCR = "mcr.microsoft.com" 

70REGISTRY_QUAY = "quay.io" 

71REGISTRY_LSCR = "lscr.io" 

72REGISTRY_CODEBERG = "codeberg.org" 

73REGISTRY_GITLAB = "registry.gitlab.com" 

74REGISTRY_BITBUCKET = "crg.apkg.io" 

75 

76 

77class RegistryInfo(typing.NamedTuple): 

78 auth_host: str | None 

79 api_host: str 

80 service: str 

81 url_template: str | None 

82 repo_template: str | None 

83 

84 

85REGISTRIES: dict[str, RegistryInfo] = { 

86 REGISTRY_DOCKER: RegistryInfo("auth.docker.io", "registry-1.docker.io", "registry.docker.io", TOKEN_URL_TEMPLATE, None), 

87 REGISTRY_MCR: RegistryInfo(None, "mcr.microsoft.com", "mcr.microsoft.com", None, None), 

88 REGISTRY_QUAY: RegistryInfo(None, "quay.io", "quay.io", TOKEN_URL_TEMPLATE, None), 

89 REGISTRY_GHCR: RegistryInfo("ghcr.io", "ghcr.io", "ghcr.io", TOKEN_URL_TEMPLATE, "https://github.com/{image_name}"), 

90 REGISTRY_LSCR: RegistryInfo("ghcr.io", "lscr.io", "ghcr.io", TOKEN_URL_TEMPLATE, None), 

91 REGISTRY_CODEBERG: RegistryInfo( 

92 "codeberg.org", 

93 "codeberg.org", 

94 "container_registry", 

95 TOKEN_URL_TEMPLATE, 

96 "https://codeberg.org/{image_name}", 

97 ), 

98 REGISTRY_BITBUCKET: RegistryInfo("crg.apkg.io", "crg.apkg.io", "crg.apkg.io", TOKEN_URL_TEMPLATE, repo_template=None), 

99 REGISTRY_GITLAB: RegistryInfo( 

100 "www.gitlab.com", 

101 "registry.gitlab.com", 

102 "container_registry", 

103 "https://{auth_host}/jwt/auth?service={service}&scope=repository:{image_name}:pull&offline_token=true&client_id=docker", 

104 "https://gitlab.com/{image_name}", 

105 ), 

106} 

107 

108# source: https://specs.opencontainers.org/distribution-spec/?v=v1.0.0#pull 

109OCI_NAME_RE = r"[a-z0-9]+((\.|_|__|-+)[a-z0-9]+)*(\/[a-z0-9]+((\.|_|__|-+)[a-z0-9]+)*)*" 

110OCI_TAG_RE = r"[a-zA-Z0-9_][a-zA-Z0-9._-]{0,127}" 

111 

112 

113class DockerImageInfo(DiscoveryArtefactDetail): 

114 """Normalize and shlep around the bits of an image def 

115 

116 index_name: aka index_name, e.g. ghcr.io 

117 name: image ref without index name or tag, e.g. nginx, or librenms/librenms 

118 tag: tag or digest 

119 untagged_ref: combined index name and package name 

120 """ 

121 

122 def __init__( 

123 self, 

124 ref: str, # ref with optional index name and tag or digest, index:name:tag_or_digest 

125 image_digest: str | None = None, 

126 tags: list[str] | None = None, 

127 attributes: dict[str, Any] | None = None, 

128 annotations: dict[str, Any] | None = None, 

129 platform: str | None = None, # test harness simplification 

130 version: str | None = None, # test harness simplification 

131 created: str | None = None, 

132 ) -> None: 

133 super().__init__() 

134 self.ref: str = ref 

135 self.version: str | None = version 

136 self.image_digest: str | None = image_digest 

137 self.short_digest: str | None = None 

138 self.repo_digest: str | None = None # the single RepoDigest known to match registry 

139 self.git_digest: str | None = None 

140 self.index_name: str | None = None 

141 self.name: str | None = None 

142 self.tag: str | None = None 

143 self.pinned_digest: str | None = None 

144 # untagged ref using combined index and remote name used only for pattern matching common pkg info 

145 self.untagged_ref: str | None = None # index_name/remote_name used for pkg match 

146 self.tag_or_digest: str | None = None # index_name/remote_name:**tag_or_digest** 

147 self.tags = tags 

148 self.attributes: dict[str, Any] = attributes or {} 

149 self.annotations: dict[str, Any] = annotations or {} 

150 self.throttled: bool = False 

151 self.origin: str | None = None 

152 self.error: str | None = None 

153 self.platform: str | None = platform 

154 self.custom: dict[str, str | float | int | bool | None] = {} 

155 self.created: str | None = created 

156 

157 self.local_build: bool = not self.repo_digests 

158 self.index_name, remote_name = resolve_repository_name(ref) 

159 

160 self.name = remote_name 

161 

162 if remote_name and ":" in remote_name and ("@" not in remote_name or remote_name.index("@") > remote_name.index(":")): 

163 # name:tag format 

164 self.name, self.tag_or_digest = remote_name.split(":", 1) 

165 self.untagged_ref = ref.split(":", 1)[0] 

166 self.tag = self.tag_or_digest 

167 

168 elif remote_name and "@" in remote_name: 

169 # name@digest format 

170 self.name, self.tag_or_digest = remote_name.split("@", 1) 

171 self.untagged_ref = ref.split("@", 1)[0] 

172 self.pinned_digest = self.tag_or_digest 

173 

174 if self.tag and "@" in self.tag: 

175 # name:tag@digest format 

176 # for pinned tags, care only about the digest part 

177 self.tag, self.tag_or_digest = self.tag.split("@", 1) 

178 self.pinned_digest = self.tag_or_digest 

179 if self.tag_or_digest is None: 

180 self.tag_or_digest = "latest" 

181 self.untagged_ref = ref 

182 self.tag = self.tag_or_digest 

183 

184 if self.repo_digest is None and len(self.repo_digests) == 1: 

185 # definite known RepoDigest 

186 # if its ambiguous, the final version selection will handle it 

187 self.repo_digest = self.repo_digests[0] 

188 

189 if self.index_name == "docker.io" and "/" not in self.name: 

190 # "official Docker images have an abbreviated library/foo name" 

191 self.name = f"library/{self.name}" 

192 if self.name is not None and not re.match(OCI_NAME_RE, self.name): 

193 log.warning("Invalid OCI image name: %s", self.name) 

194 if self.tag and not re.match(OCI_TAG_RE, self.tag): 

195 log.warning("Invalid OCI image tag: %s", self.tag) 

196 if "/" in self.name: 

197 self.unqualified_name: str = self.name.split("/", 1)[1] 

198 else: 

199 self.unqualified_name = self.name 

200 

201 if self.os and self.arch: 

202 self.platform = "/".join( 

203 filter( 

204 None, 

205 [self.os, self.arch, self.variant], 

206 ), 

207 ) 

208 

209 if self.image_digest is not None: 

210 self.image_digest = self.condense_digest(self.image_digest, short=False) 

211 self.short_digest = self.condense_digest(self.image_digest) # type: ignore[arg-type] 

212 

213 @property 

214 def repo_digests(self) -> list[str]: 

215 if self.repo_digest: 

216 return [self.repo_digest] 

217 # RepoDigest in image inspect, Registry Config object 

218 digests = [v.split("@", 1)[1] if "@" in v else v for v in self.attributes.get("RepoDigests", [])] 

219 return digests or [] 

220 

221 @property 

222 def pinned(self) -> bool: 

223 """Check if this is pinned and installed version consistent with pin""" 

224 return bool(self.pinned_digest and self.pinned_digest in self.repo_digests) 

225 

226 @property 

227 def os(self) -> str | None: 

228 return self.attributes.get("Os") 

229 

230 @property 

231 def arch(self) -> str | None: 

232 return self.attributes.get("Architecture") 

233 

234 @property 

235 def variant(self) -> str | None: 

236 return self.attributes.get("Variant") 

237 

238 def condense_digest(self, digest: str, short: bool = True) -> str | None: 

239 try: 

240 digest = digest.split("@")[1] if "@" in digest else digest # fully qualified RepoDigest 

241 if short: 

242 digest = digest.split(":")[1] if ":" in digest else digest # remove digest type prefix 

243 return digest[0:12] 

244 return digest 

245 except Exception as e: 

246 log.warning("Unable to condense digest %s: %s", digest, e) 

247 return None 

248 

249 def reuse(self) -> "DockerImageInfo": 

250 cloned = DockerImageInfo( 

251 self.ref, self.image_digest, self.tags, self.attributes, self.annotations, self.version, self.created 

252 ) 

253 cloned.origin = "REUSED" 

254 return cloned 

255 

256 def as_dict(self, minimal: bool = True) -> dict[str, str | list | dict | bool | int | None]: 

257 result: dict[str, str | list | dict | bool | int | None] = { 

258 "captured": self.captured.isoformat(), 

259 "image_ref": self.ref, 

260 "name": self.name, 

261 "version": self.version, 

262 "image_digest": self.image_digest, 

263 "repo_digest": self.repo_digest, 

264 "repo_digests": self.repo_digest, 

265 "git_digest": self.git_digest, 

266 "index_name": self.index_name, 

267 "tag": self.tag, 

268 "pinned_digest": self.pinned_digest, 

269 "tag_or_digest": self.tag_or_digest, 

270 "tags": self.tags, 

271 "origin": self.origin, 

272 "platform": self.platform, 

273 "local_build": self.local_build, 

274 "error": self.error, 

275 "throttled": self.throttled, 

276 "custom": self.custom, 

277 } 

278 if not minimal: 278 ↛ 281line 278 didn't jump to line 281 because the condition on line 278 was always true

279 result["attributes"] = self.attributes 

280 result["annotations"] = self.annotations 

281 return result 

282 

283 

284def id_source_platform(source: str | None) -> str | None: 

285 candidates: list[str] = [platform for platform, pattern in SOURCE_PLATFORMS.items() if re.match(pattern, source or "")] 

286 return candidates[0] if candidates else None 

287 

288 

289def _select_annotation( 

290 name: str, key: str, local_info: DockerImageInfo | None = None, registry_info: DockerImageInfo | None = None 

291) -> dict[str, str | None]: 

292 result: dict[str, str | None] = {} 

293 if registry_info: 

294 v: Any | None = registry_info.annotations.get(key) 

295 if v is not None: 

296 result[name] = v 

297 elif local_info: 297 ↛ 301line 297 didn't jump to line 301 because the condition on line 297 was always true

298 v = local_info.annotations.get(key) 

299 if v is not None: 299 ↛ 300line 299 didn't jump to line 300 because the condition on line 299 was never true

300 result[name] = v 

301 return result 

302 

303 

304def cherrypick_annotations( 

305 local_info: DockerImageInfo, registry_info: DockerImageInfo | None 

306) -> dict[str, str | float | int | bool | None]: 

307 """https://github.com/opencontainers/image-spec/blob/main/annotations.md""" 

308 results: dict[str, str | float | int | bool | None] = {} 

309 for either_name, either_label in [ 

310 ("documentation_url", "org.opencontainers.image.documentation"), 

311 ("description", "org.opencontainers.image.description"), 

312 ("licences", "org.opencontainers.image.licenses"), 

313 ("image_base", "org.opencontainers.image.base.name"), 

314 ("image_created", "org.opencontainers.image.created"), 

315 ("image_version", "org.opencontainers.image.version"), 

316 ("image_revision", "org.opencontainers.image.revision"), 

317 ("ref_name", "org.opencontainers.image.ref.name"), 

318 ("title", "org.opencontainers.image.title"), 

319 ("vendor", "org.opencontainers.image.vendor"), 

320 ("source", "org.opencontainers.image.source"), 

321 ]: 

322 results.update(_select_annotation(either_name, either_label, local_info, registry_info)) 

323 if ( 323 ↛ 329line 323 didn't jump to line 329 because the condition on line 323 was never true

324 results.get("ref_name") == "ubuntu" 

325 and local_info.name != "ubuntu" 

326 and results.get("image_version") 

327 and re.fullmatch(r"^2\d\.\d\d$", str(results["image_version"])) 

328 ): 

329 log.debug( 

330 "Suppressing %s base %s version leaking into image version: %s", 

331 local_info.name, 

332 results["ref_name"], 

333 results["image_version"], 

334 ) 

335 del results["image_version"] 

336 return results 

337 

338 

339class DockerServiceDetails(DiscoveryInstallationDetail): 

340 def __init__( 

341 self, 

342 container_name: str | None = None, 

343 compose_path: str | None = None, 

344 compose_version: str | None = None, 

345 compose_service: str | None = None, 

346 git_repo_path: str | None = None, 

347 ) -> None: 

348 self.container_name: str | None = container_name 

349 self.compose_path: str | None = compose_path 

350 self.compose_version: str | None = compose_version 

351 self.compose_service: str | None = compose_service 

352 self.git_repo_path: str | None = git_repo_path 

353 self.git_local_timestamp: str | None = None 

354 

355 def as_dict(self) -> dict[str, str | list | dict | bool | int | None]: 

356 results: dict[str, str | list | dict | bool | int | None] = { 

357 "container_name": self.container_name, 

358 "compose_path": self.compose_path, 

359 "compose_service": self.compose_service, 

360 "compose_version": self.compose_version, 

361 } 

362 if self.git_local_timestamp: 

363 results["git_local_timestamp"] = self.git_local_timestamp 

364 if self.git_repo_path: 

365 results["git_repo_path"] = self.git_repo_path 

366 return results 

367 

368 

369class LocalContainerInfo: 

370 def build_image_info(self, container: Container) -> tuple[DockerImageInfo, DockerServiceDetails]: 

371 """Image contents equiv to `docker inspect image <image_ref>`""" 

372 # container image can be none if someone ran `docker rmi -f` 

373 # so although this could be sourced from image, like `container.image.tags[0]` 

374 # use the container ref instead, which survives monkeying about with images 

375 image_ref: str = container.attrs.get("Config", {}).get("Image") or "" 

376 image_digest = container.attrs.get("Image") 

377 

378 image_info: DockerImageInfo = DockerImageInfo( 

379 image_ref, 

380 image_digest=image_digest, 

381 tags=container.image.tags if container and container.image else None, 

382 annotations=container.image.labels if container.image else None, 

383 attributes=container.image.attrs if container.image else None, 

384 ) 

385 service_info: DockerServiceDetails = DockerServiceDetails( 

386 container.name, 

387 compose_path=container.labels.get("com.docker.compose.project.working_dir"), 

388 compose_service=container.labels.get("com.docker.compose.service"), 

389 compose_version=container.labels.get("com.docker.compose.version"), 

390 ) 

391 

392 labels: dict[str, str | float | int | bool | None] = cherrypick_annotations(image_info, None) 

393 # capture container labels/annotations, not image ones 

394 labels = labels or {} 

395 if container.image and container.image.attrs: 395 ↛ 397line 395 didn't jump to line 397 because the condition on line 395 was always true

396 image_info.created = container.image.attrs.get("Created") 

397 image_info.custom = labels 

398 image_info.version = cast("str|None", labels.get("image_version")) 

399 

400 return image_info, service_info 

401 

402 

403class PackageEnricher: 

404 def __init__(self, docker_cfg: DockerConfig, packages: dict[str, PackageUpdateInfo] | None = None) -> None: 

405 self.pkgs: dict[str, PackageUpdateInfo] = packages or {} 

406 self.cfg: DockerConfig = docker_cfg 

407 self.log: Any = structlog.get_logger().bind(integration="docker") 

408 

409 def initialize(self) -> None: 

410 pass 

411 

412 def enrich(self, image_info: DockerImageInfo) -> PackageUpdateInfo | None: 

413 def match(pkg: PackageUpdateInfo) -> bool: 

414 if pkg is not None and pkg.docker is not None and pkg.docker.image_name is not None: 414 ↛ 420line 414 didn't jump to line 420 because the condition on line 414 was always true

415 image_names = docker_image_names(pkg.docker) 

416 if image_info.untagged_ref is not None and image_info.untagged_ref in image_names: 

417 return True 

418 if image_info.ref is not None and image_info.ref in image_names: 

419 return True 

420 return False 

421 

422 if image_info.untagged_ref is not None and image_info.ref is not None: 422 ↛ 432line 422 didn't jump to line 432 because the condition on line 422 was always true

423 for pkg in self.pkgs.values(): 

424 if match(pkg): 

425 self.log.debug( 

426 "Found common package", 

427 image_name=pkg.docker.image_name, # type: ignore [union-attr] 

428 logo_url=pkg.logo_url, 

429 relnotes_url=pkg.release_notes_url, 

430 ) 

431 return pkg 

432 return None 

433 

434 

435class DefaultPackageEnricher(PackageEnricher): 

436 def enrich(self, image_info: DockerImageInfo) -> PackageUpdateInfo | None: 

437 self.log.debug("Default pkg info", image_name=image_info.untagged_ref, image_ref=image_info.ref) 

438 return PackageUpdateInfo( 

439 DockerPackageUpdateInfo(image_info.untagged_ref or image_info.ref, version_policy=VersionPolicy.AUTO), 

440 logo_url=self.cfg.default_entity_picture_url, 

441 release_notes_url=None, 

442 ) 

443 

444 

445class CommonPackageEnricher(PackageEnricher): 

446 def initialize(self) -> None: 

447 base_cfg: DictConfig = OmegaConf.structured(CommonPackages) 

448 if PKG_INFO_FILE.exists(): 448 ↛ 455line 448 didn't jump to line 455 because the condition on line 448 was always true

449 self.log.debug("Loading common package update info", path=PKG_INFO_FILE) 

450 cfg: DictConfig = typing.cast("DictConfig", OmegaConf.merge(base_cfg, OmegaConf.load(PKG_INFO_FILE))) 

451 

452 OmegaConf.to_container(cfg, throw_on_missing=True) 

453 OmegaConf.set_readonly(cfg, True) 

454 else: 

455 self.log.warning("No common package update info found", path=PKG_INFO_FILE) 

456 cfg = base_cfg 

457 try: 

458 common_config: CommonPackages = typing.cast("CommonPackages", cfg) 

459 # omegaconf broken-ness on optional fields and converting to backclasses 

460 self.pkgs = common_config.common_packages 

461 # self.pkgs: dict[str, PackageUpdateInfo] = { 

462 # pkg: PackageUpdateInfo(**pkg_cfg) for pkg, pkg_cfg in cfg.common_packages.items() if pkg not in self.pkgs 

463 # } 

464 except (MissingMandatoryValue, ValidationError) as e: 

465 self.log.serror("Configuration error %s", e, path=PKG_INFO_FILE.as_posix()) 

466 raise 

467 

468 

469class LinuxServerIOPackageEnricher(PackageEnricher): 

470 def initialize(self) -> None: 

471 cfg: MetadataSourceConfig | None = self.cfg.discover_metadata.get("linuxserver.io") 

472 if cfg is None or not cfg.enabled: 

473 return 

474 

475 self.log.debug(f"Fetching linuxserver.io metadata from API, cache_ttl={cfg.cache_ttl}") 

476 response: Response | None = fetch_url( 

477 "https://api.linuxserver.io/api/v1/images?include_config=false&include_deprecated=false", 

478 cache_ttl=cfg.cache_ttl, 

479 ) 

480 if response and response.is_success: 

481 api_data: Any = response.json() 

482 repos: list = api_data.get("data", {}).get("repositories", {}).get("linuxserver", []) 

483 else: 

484 return 

485 

486 added = 0 

487 for repo in repos: 

488 image_name = repo.get("name") 

489 if image_name and image_name not in self.pkgs: 489 ↛ 487line 489 didn't jump to line 487 because the condition on line 489 was always true

490 github_url: str | None = repo.get("github_url") 

491 self.pkgs[image_name] = PackageUpdateInfo( 

492 DockerPackageUpdateInfo(f"lscr.io/linuxserver/{image_name}"), 

493 logo_url=repo.get("project_logo"), 

494 release_notes_url=f"{github_url}/releases" if github_url else None, 

495 ) 

496 added += 1 

497 self.log.debug(f"Added {added} linuxserver.io package details") 

498 

499 

500class SourceReleaseEnricher: 

501 def __init__(self) -> None: 

502 self.log: Any = structlog.get_logger().bind(integration="docker") 

503 

504 def enrich( 

505 self, registry_info: DockerImageInfo, source_repo_url: str | None = None, notes_url: str | None = None 

506 ) -> ReleaseDetail | None: 

507 detail = ReleaseDetail(registry_info.name or UNKNOWN_NAME) 

508 

509 detail.notes_url = notes_url 

510 detail.version = registry_info.annotations.get("org.opencontainers.image.version") 

511 detail.revision = registry_info.annotations.get("org.opencontainers.image.revision") 

512 # explicit source_repo_url overrides container, e.g. where container source is only the docker wrapper 

513 detail.source_url = source_repo_url or registry_info.annotations.get("org.opencontainers.image.source") 

514 

515 if detail.source_url is None and registry_info is not None and registry_info.index_name is not None: 

516 registry_config: RegistryInfo | None = REGISTRIES.get(registry_info.index_name) 

517 repo_template: str | None = registry_config.repo_template if registry_config else None 

518 if repo_template: 

519 source_url = repo_template.format(image_name=registry_info.name) 

520 if validate_url(source_url, cache_ttl=86400): 520 ↛ 524line 520 didn't jump to line 524 because the condition on line 520 was always true

521 detail.source_url = source_url 

522 self.log.info("Implied source from registry: %s", detail.source_url) 

523 

524 if detail.source_url is None and detail.notes_url is None and detail.revision is None and detail.version is None: 

525 return None 

526 

527 if detail.source_url and "#" in detail.source_url: 

528 detail.source_repo_url = detail.source_url.split("#", 1)[0] 

529 self.log.debug("Simplifying %s from %s", detail.source_repo_url, detail.source_url) 

530 else: 

531 detail.source_repo_url = detail.source_url 

532 

533 detail.source_platform = id_source_platform(detail.source_repo_url) 

534 if not detail.source_platform: 

535 self.log.debug("No known source platform found on container", source=detail.source_repo_url) 

536 return detail 

537 

538 template_vars: dict[str, str | None] = { 

539 "version": detail.version or MISSING_VAL, 

540 "revision": detail.revision or MISSING_VAL, 

541 "repo": detail.source_repo_url or MISSING_VAL, 

542 "source": detail.source_url or MISSING_VAL, 

543 } 

544 

545 try: 

546 diff_url_template: str | None = DIFF_URL_TEMPLATES.get(detail.source_platform) 

547 diff_url: str | None = diff_url_template.format(**template_vars) if diff_url_template else None 

548 if diff_url and MISSING_VAL not in diff_url and validate_url(diff_url, cache_ttl=3600): 548 ↛ 549line 548 didn't jump to line 549 because the condition on line 548 was never true

549 detail.diff_url = diff_url 

550 else: 

551 diff_url = None 

552 

553 if detail.notes_url is None and detail.source_platform in RELEASE_URL_TEMPLATES: 

554 platform_notes_url: str | None = RELEASE_URL_TEMPLATES[detail.source_platform].format(**template_vars) 

555 if ( 555 ↛ 560line 555 didn't jump to line 560 because the condition on line 555 was never true

556 platform_notes_url 

557 and MISSING_VAL not in platform_notes_url 

558 and validate_url(platform_notes_url, cache_ttl=86400) 

559 ): 

560 self.log.debug("Setting default known release notes url: %s", platform_notes_url) 

561 detail.notes_url = platform_notes_url 

562 

563 if detail.notes_url is None and detail.source_platform in UNKNOWN_RELEASE_URL_TEMPLATES: 

564 platform_notes_url = UNKNOWN_RELEASE_URL_TEMPLATES[detail.source_platform].format(**template_vars) 

565 if ( 

566 platform_notes_url 

567 and MISSING_VAL not in platform_notes_url 

568 and validate_url(platform_notes_url, cache_ttl=86400) 

569 ): 

570 self.log.debug("Setting default unknown release notes url: %s", platform_notes_url) 

571 detail.notes_url = platform_notes_url 

572 except Exception as e: 

573 self.log.error("Failed formatting enriched URLs with %s: %s", template_vars, e) 

574 

575 return detail 

576 

577 

578class AuthError(Exception): 

579 pass 

580 

581 

582class VersionLookup: 

583 def __init__(self) -> None: 

584 self.log: Any = structlog.get_logger().bind(integration="docker", tool="version_lookup") 

585 

586 @abstractmethod 

587 def lookup(self, local_image_info: DockerImageInfo, **kwargs) -> DockerImageInfo: 

588 pass 

589 

590 

591class ContainerDistributionAPIVersionLookup(VersionLookup): 

592 def __init__(self, throttler: Throttler, cfg: RegistryConfig) -> None: 

593 self.throttler: Throttler = throttler 

594 self.cfg: RegistryConfig = cfg 

595 self.log: Any = structlog.get_logger().bind(integration="docker", tool="version_lookup") 

596 self.api_stats = APIStatsCounter() 

597 

598 def fetch_token(self, registry: str, image_name: str) -> str | None: 

599 default_info = RegistryInfo(registry, registry, registry, TOKEN_URL_TEMPLATE, None) 

600 registry_info_: RegistryInfo = REGISTRIES.get(registry, default_info) 

601 auth_host: str | None = registry_info_.auth_host 

602 if auth_host is None: 

603 return None 

604 

605 service: str = registry_info_.service 

606 url_template: str | None = registry_info_.url_template 

607 auth_url: str | None = ( 

608 url_template.format(auth_host=auth_host, image_name=image_name, service=service) if url_template else None 

609 ) 

610 if auth_url is None: 610 ↛ 611line 610 didn't jump to line 611 because the condition on line 610 was never true

611 return None 

612 response: Response | None = fetch_url( 

613 auth_url, cache_ttl=self.cfg.token_cache_ttl, follow_redirects=True, api_stats_counter=self.api_stats 

614 ) 

615 

616 if response and response.is_success: 

617 api_data = httpx_json_content(response, {}) 

618 token: str | None = api_data.get("token") if api_data else None 

619 if token: 619 ↛ 621line 619 didn't jump to line 621 because the condition on line 619 was always true

620 return token 

621 self.log.warning("No token found in response for %s", auth_url) 

622 raise AuthError(f"No token found in response for {image_name}") 

623 

624 self.log.debug( 

625 "Non-success response at %s fetching token: %s", 

626 auth_url, 

627 (response and response.status_code) or None, 

628 ) 

629 if response and response.status_code == 404: 

630 self.log.debug( 

631 "Default token URL %s not found, calling /v2 endpoint to validate OCI API and provoke auth", auth_url 

632 ) 

633 response = fetch_url( 

634 f"https://{auth_host}/v2", 

635 follow_redirects=True, 

636 allow_stale=False, 

637 cache_ttl=0, 

638 api_stats_counter=self.api_stats, 

639 ) 

640 

641 if response and response.status_code == 401: 641 ↛ 662line 641 didn't jump to line 662 because the condition on line 641 was always true

642 auth = response.headers.get("www-authenticate") 

643 if not auth: 

644 self.log.warning("No www-authenticate header found in 401 response for %s", auth_url) 

645 raise AuthError(f"No www-authenticate header found on 401 for {image_name}") 

646 match = re.search(r'realm="([^"]+)",service="([^"]+)",scope="([^"]+)"', auth) 

647 if not match: 647 ↛ 648line 647 didn't jump to line 648 because the condition on line 647 was never true

648 self.log.warning("No realm/service/scope found in www-authenticate header for %s", auth_url) 

649 raise AuthError(f"No realm/service/scope found on 401 headers for {image_name}") 

650 

651 realm, service, scope = match.groups() 

652 auth_url = f"{realm}?service={service}&scope={scope}" 

653 response = fetch_url(auth_url, follow_redirects=True, api_stats_counter=self.api_stats) 

654 

655 if response and response.is_success: 655 ↛ 659line 655 didn't jump to line 659 because the condition on line 655 was always true

656 token_data = response.json() 

657 self.log.debug("Fetched registry token from %s", auth_url) 

658 return token_data.get("token") 

659 self.log.warning( 

660 "Alternative auth %s with status %s has no token", auth_url, (response and response.status_code) or None 

661 ) 

662 elif response: 

663 self.log.warning("Auth %s failed with status %s", auth_url, (response and response.status_code) or None) 

664 

665 raise AuthError(f"Failed to fetch token for {image_name} at {auth_url}") 

666 

667 def fetch_index( 

668 self, api_host: str, local_image_info: DockerImageInfo, token: str | None 

669 ) -> tuple[Any | None, str | None, CacheMetadata | None]: 

670 if local_image_info.tag: 670 ↛ 674line 670 didn't jump to line 674 because the condition on line 670 was always true

671 api_url: str = f"https://{api_host}/v2/{local_image_info.name}/manifests/{local_image_info.tag}" 

672 cache_ttl: int | None = self.cfg.mutable_cache_ttl 

673 else: 

674 api_url = f"https://{api_host}/v2/{local_image_info.name}/manifests/{local_image_info.pinned_digest}" 

675 cache_ttl = self.cfg.immutable_cache_ttl 

676 

677 response: Response | None = fetch_url( 

678 api_url, 

679 cache_ttl=cache_ttl, 

680 bearer_token=token, 

681 response_type=[ 

682 "application/vnd.oci.image.index.v1+json", 

683 "application/vnd.docker.distribution.manifest.list.v2+json", 

684 ], 

685 api_stats_counter=self.api_stats, 

686 ) 

687 

688 if response is None: 688 ↛ 689line 688 didn't jump to line 689 because the condition on line 688 was never true

689 self.log.warning("Empty response for manifest for image at %s", api_url) 

690 elif response.status_code == 429: 690 ↛ 691line 690 didn't jump to line 691 because the condition on line 690 was never true

691 self.throttler.throttle(local_image_info.index_name, raise_exception=True) 

692 elif not response.is_success: 

693 api_data = httpx_json_content(response, {}) 

694 self.log.warning( 

695 "Failed to fetch index from %s: %s", 

696 api_url, 

697 api_data.get("errors") if api_data else response.text, 

698 ) 

699 else: 

700 index = response.json() 

701 self.log.debug( 

702 "INDEX %s manifests, %s annotations, api: %s, header digest: %s", 

703 len(index.get("manifests", [])), 

704 len(index.get("annotations", [])), 

705 response.headers.get(HEADER_DOCKER_API, "N/A"), 

706 response.headers.get(HEADER_DOCKER_DIGEST, "N/A"), 

707 ) 

708 return index, response.headers.get(HEADER_DOCKER_DIGEST), CacheMetadata(response) 

709 return None, None, None 

710 

711 def fetch_object( 

712 self, 

713 api_host: str, 

714 local_image_info: DockerImageInfo, 

715 media_type: str, 

716 digest: str, 

717 token: str | None, 

718 follow_redirects: bool = False, 

719 api_type: str = "manifests", 

720 ) -> tuple[Any | None, CacheMetadata | None]: 

721 api_url = f"https://{api_host}/v2/{local_image_info.name}/{api_type}/{digest}" 

722 response = fetch_url( 

723 api_url, 

724 cache_ttl=self.cfg.immutable_cache_ttl, 

725 bearer_token=token, 

726 response_type=media_type, 

727 allow_stale=True, 

728 follow_redirects=follow_redirects, 

729 api_stats_counter=self.api_stats, 

730 ) 

731 

732 if response and response.is_success: 

733 obj = httpx_json_content(response, None) 

734 if obj: 734 ↛ 761line 734 didn't jump to line 761 because the condition on line 734 was always true

735 self.log.debug( 

736 "%s, header digest:%s, api: %s, %s annotations", 

737 api_type.upper(), 

738 response.headers.get(HEADER_DOCKER_DIGEST, "N/A"), 

739 response.headers.get(HEADER_DOCKER_API, "N/A"), 

740 len(obj.get("annotations", [])), 

741 ) 

742 return obj, CacheMetadata(response) 

743 elif response and response.status_code == 429: 743 ↛ 744line 743 didn't jump to line 744 because the condition on line 743 was never true

744 self.throttler.throttle(local_image_info.index_name, raise_exception=True) 

745 elif response and not response.is_success: 745 ↛ 760line 745 didn't jump to line 760 because the condition on line 745 was always true

746 api_data = httpx_json_content(response, {}) 

747 if response: 747 ↛ 755line 747 didn't jump to line 755 because the condition on line 747 was always true

748 self.log.warning( 

749 "Failed to fetch obj from %s: %s %s", 

750 api_url, 

751 response.status_code, 

752 api_data.get("errors") if api_data else response.text, 

753 ) 

754 else: 

755 self.log.warning( 

756 "Failed to fetch obj from %s: No Response, %s", api_url, api_data.get("errors") if api_data else None 

757 ) 

758 

759 else: 

760 self.log.error("Empty response from %s", api_url) 

761 return None, None 

762 

763 def lookup( 

764 self, 

765 local_image_info: DockerImageInfo, 

766 token: str | None = None, 

767 minimal: bool = False, 

768 **kwargs, 

769 ) -> DockerImageInfo: 

770 result: DockerImageInfo = DockerImageInfo(local_image_info.ref) 

771 if not local_image_info.name or not local_image_info.index_name: 771 ↛ 772line 771 didn't jump to line 772 because the condition on line 771 was never true

772 self.log.debug("No local pkg name or registry index name to check") 

773 return result 

774 

775 if self.throttler.check_throttle(local_image_info.index_name): 775 ↛ 776line 775 didn't jump to line 776 because the condition on line 775 was never true

776 result.throttled = True 

777 return result 

778 

779 if token: 779 ↛ 780line 779 didn't jump to line 780 because the condition on line 779 was never true

780 self.log.debug("Using provided token to fetch manifest for image %s", local_image_info.ref) 

781 else: 

782 try: 

783 token = self.fetch_token(local_image_info.index_name, local_image_info.name) 

784 except AuthError as e: 

785 self.log.warning("Authentication error prevented Docker Registry enrichment: %s", e) 

786 result.error = str(e) 

787 return result 

788 

789 index: Any | None = None 

790 index_digest: str | None = None # fetched from header, should be the image digest 

791 index_cache_metadata: CacheMetadata | None = None 

792 manifest_cache_metadata: CacheMetadata | None = None 

793 config_cache_metadata: CacheMetadata | None = None 

794 idx = local_image_info.index_name 

795 api_host: str | None = REGISTRIES.get( 

796 idx, 

797 RegistryInfo(idx, idx, idx, TOKEN_URL_TEMPLATE, None), 

798 ).api_host 

799 if api_host is None: 799 ↛ 800line 799 didn't jump to line 800 because the condition on line 799 was never true

800 self.log("No API host can be determined for %s", local_image_info.index_name) 

801 return result 

802 try: 

803 index, index_digest, index_cache_metadata = self.fetch_index(api_host, local_image_info, token) 

804 except ThrottledError: 

805 result.throttled = True 

806 index = None 

807 

808 if index: 

809 result.annotations = index.get("annotations", {}) 

810 for m in index.get("manifests", []): 

811 try: 

812 platform_info = m.get("platform", {}) 

813 except Exception as e: 

814 self.log.warning("Failed analyzing manifest data: %s: %s", m, e) 

815 continue 

816 if ( 

817 platform_info.get("os") == local_image_info.os 

818 and platform_info.get("architecture") == local_image_info.arch 

819 and ("Variant" not in platform_info or platform_info.get("Variant") == local_image_info.variant) 

820 ): 

821 if index_digest: 

822 result.image_digest = index_digest 

823 result.short_digest = result.condense_digest(index_digest) 

824 self.log.debug("Setting %s image digest %s", result.name, result.short_digest) 

825 

826 digest: str | None = m.get("digest") 

827 media_type = m.get("mediaType") 

828 manifest: Any | None = None 

829 

830 if digest: 830 ↛ 838line 830 didn't jump to line 838 because the condition on line 830 was always true

831 try: 

832 manifest, manifest_cache_metadata = self.fetch_object( 

833 api_host, local_image_info, media_type, digest, token 

834 ) 

835 except ThrottledError: 

836 result.throttled = True 

837 

838 if manifest: 

839 manifest_config: dict[str, Any] = manifest.get("config", {}) 

840 digest = manifest_config.get("digest") 

841 if digest is None: 841 ↛ 842line 841 didn't jump to line 842 because the condition on line 841 was never true

842 self.log.warning("Empty digest for %s %s %s", api_host, digest, media_type) 

843 else: 

844 result.repo_digest = result.condense_digest(digest, short=False) 

845 self.log.debug("Setting %s repo digest: %s", result.name, result.repo_digest) 

846 

847 if manifest.get("annotations"): 847 ↛ 850line 847 didn't jump to line 850 because the condition on line 847 was always true

848 result.annotations.update(manifest.get("annotations", {})) 

849 else: 

850 self.log.debug("No annotations found in manifest: %s", manifest) 

851 

852 if ( 

853 not minimal 

854 and manifest_config 

855 and manifest_config.get("mediaType") 

856 and manifest_config.get("digest") 

857 ): 

858 try: 

859 img_config, config_cache_metadata = self.fetch_object( 

860 api_host=api_host, 

861 local_image_info=local_image_info, 

862 media_type=manifest_config["mediaType"], 

863 digest=manifest_config["digest"], 

864 token=token, 

865 follow_redirects=True, 

866 api_type="blobs", 

867 ) 

868 if img_config: 868 ↛ 878line 868 didn't jump to line 878 because the condition on line 868 was always true

869 config = img_config.get("config") or img_config.get("Config") 

870 try: 

871 if config and "Labels" in config: 871 ↛ 873line 871 didn't jump to line 873 because the condition on line 871 was always true

872 result.annotations.update(config.get("Labels") or {}) 

873 result.annotations.update(img_config.get("annotations") or {}) 

874 except Exception as e: 

875 self.log.warning("Failure handling labels/annotations %s: %s", config, e) 

876 result.created = config.get("created") or config.get("Created") 

877 else: 

878 self.log.debug("No config found: %s", manifest) 

879 except Exception as e: 

880 self.log.warning("Failed to extract %s image info from config: %s", local_image_info.ref, e) 

881 

882 if not result.annotations: 

883 self.log.debug("No annotations found from registry data") 

884 

885 labels: dict[str, str | float | int | bool | None] = cherrypick_annotations(local_image_info, result) 

886 result.custom = labels or {} 

887 if index_cache_metadata: 

888 result.custom["index_cache_age"] = index_cache_metadata.age 

889 if manifest_cache_metadata: 

890 result.custom["manifest_cache_age"] = manifest_cache_metadata.age 

891 if config_cache_metadata: 

892 result.custom["config_cache_age"] = config_cache_metadata.age 

893 result.version = cast("str|None", labels.get("image_version")) 

894 result.origin = "OCI_V2" if not minimal else "OCI_V2_MINIMAL" 

895 

896 self.log.debug( 

897 "OCI_V2 Lookup for %s: short_digest:%s, repo_digest:%s, version: %s", 

898 local_image_info.name, 

899 result.short_digest, 

900 result.repo_digest, 

901 result.version, 

902 ) 

903 return result 

904 

905 

906class DockerClientVersionLookup(VersionLookup): 

907 """Query remote registry via local Docker API 

908 

909 No auth needed, however uses the old v1 APIs, and only Index available via API 

910 """ 

911 

912 def __init__(self, client: docker.DockerClient, throttler: Throttler, cfg: RegistryConfig, api_backoff: int = 30) -> None: 

913 self.client: docker.DockerClient = client 

914 self.throttler: Throttler = throttler 

915 self.cfg: RegistryConfig = cfg 

916 self.api_backoff: int = api_backoff 

917 self.log: Any = structlog.get_logger().bind(integration="docker", tool="version_lookup") 

918 

919 def lookup(self, local_image_info: DockerImageInfo, retries: int = 3, **kwargs) -> DockerImageInfo: 

920 retries_left = retries 

921 retry_secs: int = self.api_backoff 

922 reg_data: RegistryData | None = None 

923 

924 result = DockerImageInfo(local_image_info.ref) 

925 if local_image_info.index_name is None or local_image_info.ref is None: 925 ↛ 926line 925 didn't jump to line 926 because the condition on line 925 was never true

926 return result 

927 

928 while reg_data is None and retries_left > 0: 

929 if self.throttler.check_throttle(local_image_info.index_name): 

930 result.throttled = True 

931 break 

932 try: 

933 self.log.debug("Fetching registry data", image_ref=local_image_info.ref) 

934 reg_data = self.client.images.get_registry_data(local_image_info.ref) 

935 self.log.debug( 

936 "Registry Data: id:%s,image:%s, attrs:%s", 

937 reg_data.id, 

938 reg_data.image_name, 

939 reg_data.attrs, 

940 ) 

941 if reg_data: 941 ↛ 928line 941 didn't jump to line 928 because the condition on line 941 was always true

942 result.short_digest = result.condense_digest(reg_data.short_id) 

943 result.image_digest = result.condense_digest(reg_data.id, short=False) 

944 # result.name = reg_data.image_name 

945 result.attributes = reg_data.attrs 

946 result.annotations = reg_data.attrs.get("Config", {}).get("Labels") or {} 

947 result.error = None 

948 

949 except docker.errors.APIError as e: 

950 if e.status_code == HTTPStatus.TOO_MANY_REQUESTS: 950 ↛ 959line 950 didn't jump to line 959 because the condition on line 950 was always true

951 retry_secs = round(retry_secs**1.5) 

952 try: 

953 retry_secs = int(e.response.headers.get("Retry-After", -1)) # type: ignore[union-attr] 

954 except Exception as e2: 

955 self.log.debug("Failed to access headers for retry info: %s", e2) 

956 self.throttler.throttle(local_image_info.index_name, retry_secs, e.explanation) 

957 result.throttled = True 

958 return result 

959 result.error = str(e) 

960 retries_left -= 1 

961 if retries_left == 0 or e.is_client_error(): 

962 self.log.warning("Failed to fetch registry data: [%s] %s", e.errno, e.explanation) 

963 else: 

964 self.log.debug("Failed to fetch registry data, retrying: %s", e) 

965 

966 labels: dict[str, str | float | int | bool | None] = cherrypick_annotations(local_image_info, result) 

967 result.custom = labels or {} 

968 result.version = cast("str|None", labels.get("image_version")) 

969 result.created = cast("str|None", labels.get("image_created")) 

970 result.origin = "DOCKER_CLIENT" 

971 return result