Coverage for src / updates2mqtt / integrations / docker.py: 81%

401 statements  

« prev     ^ index     » next       coverage.py v7.13.1, created at 2026-01-20 02:29 +0000

1import random 

2import subprocess 

3import time 

4import typing 

5from collections.abc import AsyncGenerator, Callable 

6from enum import Enum 

7from http import HTTPStatus 

8from pathlib import Path 

9from threading import Event 

10from typing import Any, cast 

11 

12import docker 

13import docker.errors 

14import structlog 

15from docker.auth import resolve_repository_name 

16from docker.models.containers import Container 

17 

18from updates2mqtt.config import ( 

19 NO_KNOWN_IMAGE, 

20 DockerConfig, 

21 NodeConfig, 

22 PackageUpdateInfo, 

23 PublishPolicy, 

24 UpdatePolicy, 

25) 

26from updates2mqtt.integrations.docker_enrich import ( 

27 AuthError, 

28 CommonPackageEnricher, 

29 DefaultPackageEnricher, 

30 LabelEnricher, 

31 LinuxServerIOPackageEnricher, 

32 PackageEnricher, 

33 SourceReleaseEnricher, 

34) 

35from updates2mqtt.model import Discovery, ReleaseProvider, Selection, VersionPolicy, select_version 

36 

37from .git_utils import git_check_update_available, git_iso_timestamp, git_local_version, git_pull, git_trust 

38 

39if typing.TYPE_CHECKING: 

40 from docker.models.images import Image, RegistryData 

41 

42# distinguish docker build from docker pull? 

43 

44log = structlog.get_logger() 

45 

46 

47class DockerComposeCommand(Enum): 

48 BUILD = "build" 

49 UP = "up" 

50 

51 

52def safe_json_dt(t: float | None) -> str | None: 

53 return time.strftime("%Y-%m-%dT%H:%M:%S.0000", time.gmtime(t)) if t else None 

54 

55 

56class ContainerCustomization: 

57 """Local customization of a Docker container, by label or env var""" 

58 

59 label_prefix: str = "updates2mqtt." 

60 env_prefix: str = "UPD2MQTT_" 

61 

62 def __init__(self, container: Container) -> None: 

63 self.update: UpdatePolicy = UpdatePolicy.PASSIVE # was known as UPD2MQTT_UPDATE before policies and labels 

64 self.git_repo_path: str | None = None 

65 self.picture: str | None = None 

66 self.relnotes: str | None = None 

67 self.ignore: bool = False 

68 self.version_policy: VersionPolicy | None = None 

69 self.registry_token: str | None = None 

70 

71 if not container.attrs or container.attrs.get("Config") is None: 

72 return 

73 env_pairs: list[str] = container.attrs.get("Config", {}).get("Env") 

74 if env_pairs: 

75 c_env: dict[str, str] = dict(env.split("=", maxsplit=1) for env in env_pairs if "==" not in env) 

76 else: 

77 c_env = {} 

78 

79 for attr in dir(self): 

80 if "__" not in attr: 

81 label = f"{self.label_prefix}{attr.lower()}" 

82 env_var = f"{self.env_prefix}{attr.upper()}" 

83 v: Any = None 

84 if label in container.labels: 

85 # precedence to labels 

86 v = container.labels.get(label) 

87 log.debug( 

88 "%s set from label %s=%s", 

89 attr, 

90 label, 

91 v, 

92 integration="docker", 

93 container=container.name, 

94 action="customize", 

95 ) 

96 elif env_var in c_env: 

97 v = c_env[env_var] 

98 log.debug( 

99 "%s set from env var %s=%s", 

100 attr, 

101 env_var, 

102 v, 

103 integration="docker", 

104 container=container.name, 

105 action="customize", 

106 ) 

107 if v is not None: 

108 if isinstance(getattr(self, attr), bool): 

109 setattr(self, attr, v.upper() in ("TRUE", "YES", "1")) 

110 elif isinstance(getattr(self, attr), VersionPolicy): 110 ↛ 111line 110 didn't jump to line 111 because the condition on line 110 was never true

111 setattr(self, attr, VersionPolicy[v.upper()]) 

112 elif isinstance(getattr(self, attr), UpdatePolicy): 

113 setattr(self, attr, UpdatePolicy[v.upper()]) 

114 else: 

115 setattr(self, attr, v) 

116 

117 

118class DockerProvider(ReleaseProvider): 

119 def __init__( 

120 self, 

121 cfg: DockerConfig, 

122 node_cfg: NodeConfig, 

123 self_bounce: Event | None = None, 

124 ) -> None: 

125 super().__init__(node_cfg, "docker") 

126 self.client: docker.DockerClient = docker.from_env() 

127 self.cfg: DockerConfig = cfg 

128 

129 # TODO: refresh discovered packages periodically 

130 self.pause_api_until: dict[str, float] = {} 

131 self.api_throttle_pause: int = cfg.default_api_backoff 

132 self.self_bounce: Event | None = self_bounce 

133 self.pkg_enrichers: list[PackageEnricher] = [ 

134 CommonPackageEnricher(self.cfg), 

135 LinuxServerIOPackageEnricher(self.cfg), 

136 DefaultPackageEnricher(self.cfg), 

137 ] 

138 self.label_enricher = LabelEnricher() 

139 self.release_enricher = SourceReleaseEnricher() 

140 

141 def initialize(self) -> None: 

142 for enricher in self.pkg_enrichers: 

143 enricher.initialize() 

144 

145 def update(self, discovery: Discovery) -> bool: 

146 logger: Any = self.log.bind(container=discovery.name, action="update") 

147 logger.info("Updating - last at %s", discovery.update_last_attempt) 

148 discovery.update_last_attempt = time.time() 

149 self.fetch(discovery) 

150 restarted = self.restart(discovery) 

151 logger.info("Updated - recorded at %s", discovery.update_last_attempt) 

152 return restarted 

153 

154 def fetch(self, discovery: Discovery) -> None: 

155 logger = self.log.bind(container=discovery.name, action="fetch") 

156 

157 image_ref: str | None = discovery.custom.get("image_ref") 

158 platform: str | None = discovery.custom.get("platform") 

159 if discovery.custom.get("can_pull") and image_ref: 

160 logger.info("Pulling", image_ref=image_ref, platform=platform) 

161 image: Image = self.client.images.pull(image_ref, platform=platform, all_tags=False) 

162 if image: 162 ↛ 165line 162 didn't jump to line 165 because the condition on line 162 was always true

163 logger.info("Pulled", image_id=image.id, image_ref=image_ref, platform=platform) 

164 else: 

165 logger.warn("Unable to pull", image_ref=image_ref, platform=platform) 

166 elif discovery.can_build: 

167 compose_path: str | None = discovery.custom.get("compose_path") 

168 git_repo_path: str | None = discovery.custom.get("git_repo_path") 

169 logger.debug("can_build check", git_repo=git_repo_path) 

170 if not compose_path or not git_repo_path: 

171 logger.warn("No compose path or git repo path configured, skipped build") 

172 return 

173 

174 full_repo_path: Path = self.full_repo_path(compose_path, git_repo_path) 

175 if git_pull(full_repo_path, Path(self.node_cfg.git_path)): 

176 if compose_path: 176 ↛ 179line 176 didn't jump to line 179 because the condition on line 176 was always true

177 self.build(discovery, compose_path) 

178 else: 

179 logger.warn("No compose path configured, skipped build") 

180 else: 

181 logger.debug("Skipping git_pull, no update") 

182 

183 def full_repo_path(self, compose_path: str, git_repo_path: str) -> Path: 

184 if compose_path is None or git_repo_path is None: 184 ↛ 185line 184 didn't jump to line 185 because the condition on line 184 was never true

185 raise ValueError("Unexpected null paths") 

186 if compose_path and not Path(git_repo_path).is_absolute(): 186 ↛ 188line 186 didn't jump to line 188 because the condition on line 186 was always true

187 return Path(compose_path) / git_repo_path 

188 return Path(git_repo_path) 

189 

190 def build(self, discovery: Discovery, compose_path: str) -> bool: 

191 logger = self.log.bind(container=discovery.name, action="build") 

192 logger.info("Building", compose_path=compose_path) 

193 return self.execute_compose( 

194 command=DockerComposeCommand.BUILD, 

195 args="", 

196 service=discovery.custom.get("compose_service"), 

197 cwd=compose_path, 

198 logger=logger, 

199 ) 

200 

201 def execute_compose( 

202 self, command: DockerComposeCommand, args: str, service: str | None, cwd: str | None, logger: structlog.BoundLogger 

203 ) -> bool: 

204 if not cwd or not Path(cwd).is_dir(): 204 ↛ 205line 204 didn't jump to line 205 because the condition on line 204 was never true

205 logger.warn("Invalid compose path, skipped %s", command) 

206 return False 

207 

208 cmd: str = "docker-compose" if self.cfg.compose_version == "v1" else "docker compose" 

209 logger.info(f"Executing {cmd} {command} {args} {service}") 

210 cmd = cmd + " " + command.value 

211 if args: 211 ↛ 212line 211 didn't jump to line 212 because the condition on line 211 was never true

212 cmd = cmd + " " + args 

213 if service: 213 ↛ 214line 213 didn't jump to line 214 because the condition on line 213 was never true

214 cmd = cmd + " " + service 

215 

216 proc: subprocess.CompletedProcess[str] = subprocess.run(cmd, check=False, shell=True, cwd=cwd, text=True) 

217 if proc.returncode == 0: 

218 logger.info(f"{command} via compose successful") 

219 return True 

220 if proc.stderr and "unknown command: docker compose" in proc.stderr: 220 ↛ 221line 220 didn't jump to line 221 because the condition on line 220 was never true

221 logger.warning("docker compose set to wrong version, seems like v1 installed") 

222 self.cfg.compose_version = "v1" 

223 logger.warn( 

224 f"{command} failed: %s", 

225 proc.returncode, 

226 ) 

227 return False 

228 

229 def restart(self, discovery: Discovery) -> bool: 

230 logger = self.log.bind(container=discovery.name, action="restart") 

231 if self.self_bounce is not None and ( 

232 "ghcr.io/rhizomatics/updates2mqtt" in discovery.custom.get("image_ref", "") 

233 or (discovery.custom.get("git_repo_path") and discovery.custom.get("git_repo_path", "").endswith("updates2mqtt")) 

234 ): 

235 logger.warning("Attempting to self-bounce") 

236 self.self_bounce.set() 

237 compose_path = discovery.custom.get("compose_path") 

238 compose_service: str | None = discovery.custom.get("compose_service") 

239 return self.execute_compose( 

240 command=DockerComposeCommand.UP, args="--detach --yes", service=compose_service, cwd=compose_path, logger=logger 

241 ) 

242 

243 def rescan(self, discovery: Discovery) -> Discovery | None: 

244 logger = self.log.bind(container=discovery.name, action="rescan") 

245 try: 

246 c: Container = self.client.containers.get(discovery.name) 

247 if c: 247 ↛ 252line 247 didn't jump to line 252 because the condition on line 247 was always true

248 rediscovery = self.analyze(c, discovery.session, previous_discovery=discovery) 

249 if rediscovery: 249 ↛ 252line 249 didn't jump to line 252 because the condition on line 249 was always true

250 self.discoveries[rediscovery.name] = rediscovery 

251 return rediscovery 

252 logger.warn("Unable to find container for rescan") 

253 except docker.errors.NotFound: 

254 logger.warn("Container not found in Docker") 

255 except docker.errors.APIError: 

256 logger.exception("Docker API error retrieving container") 

257 return None 

258 

259 def check_throttle(self, repo_id: str) -> bool: 

260 if self.pause_api_until.get(repo_id) is not None: 

261 if self.pause_api_until[repo_id] < time.time(): 

262 del self.pause_api_until[repo_id] 

263 self.log.info("%s throttling wait complete", repo_id) 

264 else: 

265 self.log.debug("%s throttling has %0.3f secs left", repo_id, self.pause_api_until[repo_id] - time.time()) 

266 return True 

267 return False 

268 

269 def throttle(self, repo_id: str, retry_secs: int, explanation: str | None = None) -> None: 

270 retry_secs = self.api_throttle_pause if retry_secs <= 0 else retry_secs 

271 self.log.warn("%s throttling requests for %s seconds, %s", repo_id, retry_secs, explanation) 

272 self.pause_api_until[repo_id] = time.time() + retry_secs 

273 

274 def analyze(self, c: Container, session: str, previous_discovery: Discovery | None = None) -> Discovery | None: 

275 logger = self.log.bind(container=c.name, action="analyze") 

276 

277 image_ref: str | None = None 

278 image_name: str | None = None 

279 local_versions = None 

280 if c.attrs is None or not c.attrs: 280 ↛ 281line 280 didn't jump to line 281 because the condition on line 280 was never true

281 logger.warn("No container attributes found, discovery rejected") 

282 return None 

283 if c.name is None: 283 ↛ 284line 283 didn't jump to line 284 because the condition on line 283 was never true

284 logger.warn("No container name found, discovery rejected") 

285 return None 

286 

287 customization: ContainerCustomization = ContainerCustomization(c) 

288 if customization.ignore: 288 ↛ 289line 288 didn't jump to line 289 because the condition on line 288 was never true

289 logger.info("Container ignored due to UPD2MQTT_IGNORE setting") 

290 return None 

291 

292 image: Image | None = c.image 

293 repo_id: str = "DEFAULT" 

294 if image is not None and image.tags and len(image.tags) > 0: 294 ↛ 297line 294 didn't jump to line 297 because the condition on line 294 was always true

295 image_ref = image.tags[0] 

296 else: 

297 image_ref = c.attrs.get("Config", {}).get("Image") 

298 if image_ref is None: 298 ↛ 299line 298 didn't jump to line 299 because the condition on line 298 was never true

299 logger.warn("No image or image attributes found") 

300 else: 

301 repo_id, _ = resolve_repository_name(image_ref) 

302 try: 

303 image_name = image_ref.split(":")[0] 

304 except Exception as e: 

305 logger.warn("No tags found (%s) : %s", image, e) 

306 if image is not None and image.attrs is not None: 306 ↛ 313line 306 didn't jump to line 313 because the condition on line 306 was always true

307 try: 

308 local_versions = [i.split("@")[1][7:19] for i in image.attrs["RepoDigests"]] 

309 except Exception as e: 

310 logger.warn("Cannot determine local version: %s", e) 

311 logger.warn("RepoDigests=%s", image.attrs.get("RepoDigests")) 

312 

313 version_policy: VersionPolicy = VersionPolicy.AUTO if not customization.version_policy else customization.version_policy 

314 

315 if customization.update == UpdatePolicy.AUTO: 315 ↛ 316line 315 didn't jump to line 316 because the condition on line 315 was never true

316 logger.debug("Auto update policy detected") 

317 update_policy: UpdatePolicy = customization.update or UpdatePolicy.PASSIVE 

318 

319 platform: str = "Unknown" 

320 pkg_info: PackageUpdateInfo = self.default_metadata(image_name, image_ref=image_ref) 

321 

322 try: 

323 picture_url: str | None = customization.picture or pkg_info.logo_url 

324 relnotes_url: str | None = customization.relnotes or pkg_info.release_notes_url 

325 release_summary: str | None = None 

326 

327 if image is not None and image.attrs is not None: 327 ↛ 339line 327 didn't jump to line 339 because the condition on line 327 was always true

328 platform = "/".join( 

329 filter( 

330 None, 

331 [ 

332 image.attrs["Os"], 

333 image.attrs["Architecture"], 

334 image.attrs.get("Variant"), 

335 ], 

336 ), 

337 ) 

338 

339 reg_data: RegistryData | None = None 

340 latest_digest: str | None = NO_KNOWN_IMAGE 

341 latest_version: str | None = None 

342 

343 registry_throttled: bool = self.check_throttle(repo_id) 

344 

345 if image_ref and local_versions and not registry_throttled: 

346 retries_left = 3 

347 while reg_data is None and retries_left > 0 and not self.stopped.is_set(): 

348 try: 

349 logger.debug("Fetching registry data", image_ref=image_ref) 

350 reg_data = self.client.images.get_registry_data(image_ref) 

351 logger.debug( 

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

353 reg_data.id, 

354 reg_data.image_name, 

355 reg_data.attrs, 

356 ) 

357 latest_digest = reg_data.short_id[7:] if reg_data else None 

358 

359 except docker.errors.APIError as e: 

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

361 retry_secs: int 

362 try: 

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

364 except: # noqa: E722 

365 retry_secs = self.api_throttle_pause 

366 self.throttle(repo_id, retry_secs, e.explanation) 

367 registry_throttled = True 

368 return None 

369 retries_left -= 1 

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

371 logger.warn("Failed to fetch registry data: [%s] %s", e.errno, e.explanation) 

372 else: 

373 logger.debug("Failed to fetch registry data, retrying: %s", e) 

374 

375 installed_digest: str | None = NO_KNOWN_IMAGE 

376 installed_version: str | None = None 

377 if local_versions: 

378 # might be multiple RepoDigests if image has been pulled multiple times with diff manifests 

379 installed_digest = latest_digest if latest_digest in local_versions else local_versions[0] 

380 logger.debug(f"Setting local digest to {installed_digest}, local_versions:{local_versions}") 

381 

382 def save_if_set(key: str, val: str | None) -> None: 

383 if val is not None: 

384 custom[key] = val 

385 

386 image_ref = image_ref or "" 

387 

388 custom: dict[str, str | bool | int | list[str] | dict[str, Any] | None] = {} 

389 custom["platform"] = platform 

390 custom["image_ref"] = image_ref 

391 custom["installed_digest"] = installed_digest 

392 custom["latest_digest"] = latest_digest 

393 custom["repo_id"] = repo_id 

394 custom["git_repo_path"] = customization.git_repo_path 

395 

396 if c.labels: 

397 save_if_set("compose_path", c.labels.get("com.docker.compose.project.working_dir")) 

398 save_if_set("compose_version", c.labels.get("com.docker.compose.version")) 

399 save_if_set("compose_service", c.labels.get("com.docker.compose.service")) 

400 save_if_set("documentation_url", c.labels.get("org.opencontainers.image.documentation")) 

401 save_if_set("description", c.labels.get("org.opencontainers.image.description")) 

402 save_if_set("current_image_created", c.labels.get("org.opencontainers.image.created")) 

403 save_if_set("current_image_version", c.labels.get("org.opencontainers.image.version")) 

404 save_if_set("vendor", c.labels.get("org.opencontainers.image.vendor")) 

405 installed_version = c.labels.get("org.opencontainers.image.version") 

406 else: 

407 logger.debug("No annotations found on local container") 

408 # save_if_set("apt_pkgs", c_env.get("UPD2MQTT_APT_PKGS")) 

409 

410 annotations: dict[str, str] = {} 

411 if latest_digest is None or latest_digest == NO_KNOWN_IMAGE or registry_throttled: 

412 logger.debug( 

413 "Skipping image manifest enrichment", 

414 latest_digest=latest_digest, 

415 image_ref=image_ref, 

416 platform=platform, 

417 throttled=registry_throttled, 

418 ) 

419 else: 

420 os, arch = platform.split("/")[:2] if "/" in platform else (platform, "Unknown") 

421 try: 

422 annotations = self.label_enricher.fetch_annotations(image_ref, os, arch, token=customization.registry_token) 

423 except AuthError as e: 

424 logger.warning("Authentication error prevented Docker Registry enrichment: %s", e) 

425 

426 if annotations: 

427 latest_version = annotations.get("org.opencontainers.image.version") 

428 

429 release_info: dict[str, str] = self.release_enricher.enrich( 

430 annotations, source_repo_url=pkg_info.source_repo_url, release_url=relnotes_url 

431 ) 

432 logger.debug("Enriched release info: %s", release_info) 

433 

434 if release_info.get("release_url") and customization.relnotes is None: 434 ↛ 435line 434 didn't jump to line 435 because the condition on line 434 was never true

435 relnotes_url = release_info.pop("release_url") 

436 if release_info.get("release_summary"): 436 ↛ 437line 436 didn't jump to line 437 because the condition on line 436 was never true

437 release_summary = release_info.pop("release_summary") 

438 custom.update(release_info) 

439 

440 if custom.get("git_repo_path") and custom.get("compose_path"): 

441 full_repo_path: Path = Path(cast("str", custom.get("compose_path"))).joinpath( 

442 cast("str", custom.get("git_repo_path")) 

443 ) 

444 

445 git_trust(full_repo_path, Path(self.node_cfg.git_path)) 

446 save_if_set("git_local_timestamp", git_iso_timestamp(full_repo_path, Path(self.node_cfg.git_path))) 

447 features: list[str] = [] 

448 can_pull: bool = ( 

449 self.cfg.allow_pull 

450 and image_ref is not None 

451 and image_ref != "" 

452 and (installed_digest != NO_KNOWN_IMAGE or latest_digest != NO_KNOWN_IMAGE) 

453 ) 

454 if self.cfg.allow_pull and not can_pull: 

455 logger.debug( 

456 f"Pull unavailable, image_ref:{image_ref},installed_digest:{installed_digest},latest_digest:{latest_digest}" 

457 ) 

458 

459 can_build: bool = False 

460 if self.cfg.allow_build: 460 ↛ 482line 460 didn't jump to line 482 because the condition on line 460 was always true

461 can_build = custom.get("git_repo_path") is not None and custom.get("compose_path") is not None 

462 if not can_build: 

463 if custom.get("git_repo_path") is not None: 463 ↛ 464line 463 didn't jump to line 464 because the condition on line 463 was never true

464 logger.debug( 

465 "Local build ignored for git_repo_path=%s because no compose_path", custom.get("git_repo_path") 

466 ) 

467 else: 

468 full_repo_path = self.full_repo_path( 

469 cast("str", custom.get("compose_path")), cast("str", custom.get("git_repo_path")) 

470 ) 

471 if installed_digest is None or installed_digest == NO_KNOWN_IMAGE: 471 ↛ 474line 471 didn't jump to line 474 because the condition on line 471 was always true

472 installed_digest = git_local_version(full_repo_path, Path(self.node_cfg.git_path)) or NO_KNOWN_IMAGE 

473 

474 behind_count: int = git_check_update_available(full_repo_path, Path(self.node_cfg.git_path)) 

475 if behind_count > 0: 

476 if installed_digest is not None and installed_digest.startswith("git:"): 476 ↛ 482line 476 didn't jump to line 482 because the condition on line 476 was always true

477 latest_digest = f"{installed_digest}+{behind_count}" 

478 logger.info("Git update available, generating version %s", latest_digest) 

479 else: 

480 logger.debug(f"Git update not available, local repo:{full_repo_path}") 

481 

482 can_restart: bool = self.cfg.allow_restart and custom.get("compose_path") is not None 

483 

484 can_update: bool = False 

485 

486 if can_pull or can_build or can_restart: 486 ↛ 491line 486 didn't jump to line 491 because the condition on line 486 was always true

487 # public install-neutral capabilities and Home Assistant features 

488 can_update = True 

489 features.append("INSTALL") 

490 features.append("PROGRESS") 

491 elif any((self.cfg.allow_build, self.cfg.allow_restart, self.cfg.allow_pull)): 

492 logger.info(f"Update not available, can_pull:{can_pull}, can_build:{can_build},can_restart{can_restart}") 

493 if relnotes_url: 

494 features.append("RELEASE_NOTES") 

495 if can_pull: 

496 update_type = "Docker Image" 

497 elif can_build: 497 ↛ 500line 497 didn't jump to line 500 because the condition on line 497 was always true

498 update_type = "Docker Build" 

499 else: 

500 update_type = "Unavailable" 

501 custom["can_pull"] = can_pull 

502 # can_pull,can_build etc are only info flags 

503 # the HASS update process is driven by comparing current and available versions 

504 

505 public_installed_version = select_version( 

506 version_policy, installed_version, installed_digest, other_version=latest_version, other_digest=latest_digest 

507 ) 

508 if latest_digest in (installed_digest, NO_KNOWN_IMAGE) or registry_throttled: 

509 public_latest_version = public_installed_version 

510 else: 

511 public_latest_version = select_version( 

512 version_policy, 

513 latest_version, 

514 latest_digest, 

515 other_version=installed_version, 

516 other_digest=installed_digest, 

517 ) 

518 

519 publish_policy: PublishPolicy = PublishPolicy.HOMEASSISTANT 

520 img_ref_selection = Selection(self.cfg.image_ref_select, image_ref) 

521 version_selection = Selection(self.cfg.version_select, latest_version) 

522 if not img_ref_selection or not version_selection: 522 ↛ 523line 522 didn't jump to line 523 because the condition on line 522 was never true

523 self.log.info("Excluding from HA Discovery for include/exclude rule: %s, %s", image_ref, latest_version) 

524 publish_policy = PublishPolicy.MQTT 

525 

526 discovery: Discovery = Discovery( 

527 self, 

528 c.name, 

529 session, 

530 node=self.node_cfg.name, 

531 entity_picture_url=picture_url, 

532 release_url=relnotes_url, 

533 release_summary=release_summary, 

534 current_version=public_installed_version, 

535 publish_policy=publish_policy, 

536 update_policy=update_policy, 

537 version_policy=version_policy, 

538 latest_version=public_latest_version, 

539 device_icon=self.cfg.device_icon, 

540 can_update=can_update, 

541 update_type=update_type, 

542 can_build=can_build, 

543 can_restart=can_restart, 

544 status=(c.status == "running" and "on") or "off", 

545 custom=custom, 

546 features=features, 

547 throttled=registry_throttled, 

548 previous=previous_discovery, 

549 ) 

550 logger.debug("Analyze generated discovery: %s", discovery) 

551 return discovery 

552 except Exception: 

553 logger.exception("Docker Discovery Failure", container_attrs=c.attrs) 

554 logger.debug("Analyze returned empty discovery") 

555 return None 

556 

557 # def version(self, c: Container, version_type: str): 

558 # metadata_version: str = c.labels.get("org.opencontainers.image.version") 

559 # metadata_revision: str = c.labels.get("org.opencontainers.image.revision") 

560 

561 async def scan(self, session: str, shuffle: bool = True) -> AsyncGenerator[Discovery]: 

562 logger = self.log.bind(session=session, action="scan", source=self.source_type) 

563 containers: int = 0 

564 results: int = 0 

565 throttled: int = 0 

566 

567 targets: list[Container] = self.client.containers.list() 

568 if shuffle: 568 ↛ 570line 568 didn't jump to line 570 because the condition on line 568 was always true

569 random.shuffle(targets) 

570 logger.debug("Starting scanning %s containers", len(targets)) 

571 for c in targets: 

572 logger.debug("Analyzing container", container=c.name) 

573 if self.stopped.is_set(): 573 ↛ 574line 573 didn't jump to line 574 because the condition on line 573 was never true

574 logger.info(f"Shutdown detected, aborting scan at {c}") 

575 break 

576 containers = containers + 1 

577 result: Discovery | None = self.analyze(c, session) 

578 if result: 578 ↛ 585line 578 didn't jump to line 585 because the condition on line 578 was always true

579 logger.debug("Analyzed container", result_name=result.name, custom=result.custom) 

580 self.discoveries[result.name] = result 

581 results = results + 1 

582 throttled += 1 if result.throttled else 0 

583 yield result 

584 else: 

585 logger.debug("No result from analysis", container=c.name) 

586 logger.info("Completed", container_count=containers, throttled_count=throttled, result_count=results) 

587 

588 def command(self, discovery_name: str, command: str, on_update_start: Callable, on_update_end: Callable) -> bool: 

589 logger = self.log.bind(container=discovery_name, action="command", command=command) 

590 logger.info("Executing Command") 

591 discovery: Discovery | None = None 

592 updated: bool = False 

593 try: 

594 discovery = self.resolve(discovery_name) 

595 if not discovery: 

596 logger.warn("Unknown entity", entity=discovery_name) 

597 elif command != "install": 

598 logger.warn("Unknown command") 

599 else: 

600 if discovery.can_update: 

601 rediscovery: Discovery | None = None 

602 logger.info("Starting update ...") 

603 on_update_start(discovery) 

604 if self.update(discovery): 

605 logger.info("Rescanning ...") 

606 rediscovery = self.rescan(discovery) 

607 updated = rediscovery is not None 

608 logger.info("Rescanned %s: %s", updated, rediscovery) 

609 else: 

610 logger.info("Rescan with no result") 

611 on_update_end(rediscovery or discovery) 

612 else: 

613 logger.warning("Update not supported for this container") 

614 except Exception: 

615 logger.exception("Failed to handle", discovery_name=discovery_name, command=command) 

616 if discovery: 616 ↛ 618line 616 didn't jump to line 618 because the condition on line 616 was always true

617 on_update_end(discovery) 

618 return updated 

619 

620 def resolve(self, discovery_name: str) -> Discovery | None: 

621 return self.discoveries.get(discovery_name) 

622 

623 def default_metadata(self, image_name: str | None, image_ref: str | None) -> PackageUpdateInfo: 

624 for enricher in self.pkg_enrichers: 624 ↛ 628line 624 didn't jump to line 628 because the loop on line 624 didn't complete

625 pkg_info = enricher.enrich(image_name, image_ref, self.log) 

626 if pkg_info is not None: 

627 return pkg_info 

628 raise ValueError("No enricher could provide metadata, not even default enricher")