updates2mqtt 1.5.1__py3-none-any.whl → 1.7.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,22 +1,39 @@
1
- import datetime
2
1
  import subprocess
3
2
  import time
4
3
  import typing
5
4
  from collections.abc import AsyncGenerator, Callable
6
5
  from enum import Enum
6
+ from http import HTTPStatus
7
7
  from pathlib import Path
8
+ from threading import Event
8
9
  from typing import Any, cast
9
10
 
10
11
  import docker
11
12
  import docker.errors
12
13
  import structlog
14
+ from docker.auth import resolve_repository_name
13
15
  from docker.models.containers import Container
14
- from hishel.httpx import SyncCacheClient
15
16
 
16
- from updates2mqtt.config import DockerConfig, DockerPackageUpdateInfo, NodeConfig, PackageUpdateInfo
17
- from updates2mqtt.model import Discovery, ReleaseProvider
18
-
19
- from .git_utils import git_check_update_available, git_pull, git_timestamp, git_trust
17
+ from updates2mqtt.config import (
18
+ NO_KNOWN_IMAGE,
19
+ DockerConfig,
20
+ NodeConfig,
21
+ PackageUpdateInfo,
22
+ PublishPolicy,
23
+ UpdatePolicy,
24
+ )
25
+ from updates2mqtt.integrations.docker_enrich import (
26
+ AuthError,
27
+ CommonPackageEnricher,
28
+ DefaultPackageEnricher,
29
+ LabelEnricher,
30
+ LinuxServerIOPackageEnricher,
31
+ PackageEnricher,
32
+ SourceReleaseEnricher,
33
+ )
34
+ from updates2mqtt.model import Discovery, ReleaseProvider, Selection, VersionPolicy, select_version
35
+
36
+ from .git_utils import git_check_update_available, git_iso_timestamp, git_local_version, git_pull, git_trust
20
37
 
21
38
  if typing.TYPE_CHECKING:
22
39
  from docker.models.images import Image, RegistryData
@@ -24,7 +41,6 @@ if typing.TYPE_CHECKING:
24
41
  # distinguish docker build from docker pull?
25
42
 
26
43
  log = structlog.get_logger()
27
- NO_KNOWN_IMAGE = "UNKNOWN"
28
44
 
29
45
 
30
46
  class DockerComposeCommand(Enum):
@@ -36,15 +52,94 @@ def safe_json_dt(t: float | None) -> str | None:
36
52
  return time.strftime("%Y-%m-%dT%H:%M:%S.0000", time.gmtime(t)) if t else None
37
53
 
38
54
 
55
+ class ContainerCustomization:
56
+ """Local customization of a Docker container, by label or env var"""
57
+
58
+ label_prefix: str = "updates2mqtt."
59
+ env_prefix: str = "UPD2MQTT_"
60
+
61
+ def __init__(self, container: Container) -> None:
62
+ self.update: str = "PASSIVE"
63
+ self.git_repo_path: str | None = None
64
+ self.picture: str | None = None
65
+ self.relnotes: str | None = None
66
+ self.ignore: bool = False
67
+ self.version_policy: VersionPolicy | None = None
68
+ self.registry_token: str | None = None
69
+
70
+ if not container.attrs or container.attrs.get("Config") is None:
71
+ return
72
+ env_pairs: list[str] = container.attrs.get("Config", {}).get("Env")
73
+ if env_pairs:
74
+ c_env: dict[str, str] = dict(env.split("=", maxsplit=1) for env in env_pairs if "==" not in env)
75
+ else:
76
+ c_env = {}
77
+
78
+ for attr in dir(self):
79
+ if "__" not in attr:
80
+ label = f"{self.label_prefix}{attr.lower()}"
81
+ env_var = f"{self.env_prefix}{attr.upper()}"
82
+ v: Any = None
83
+ if label in container.labels:
84
+ # precedence to labels
85
+ v = container.labels.get(label)
86
+ log.debug(
87
+ "%s set from label %s=%s",
88
+ attr,
89
+ label,
90
+ v,
91
+ integration="docker",
92
+ container=container.name,
93
+ action="customize",
94
+ )
95
+ elif env_var in c_env:
96
+ v = c_env[env_var]
97
+ log.debug(
98
+ "%s set from env var %s=%s",
99
+ attr,
100
+ env_var,
101
+ v,
102
+ integration="docker",
103
+ container=container.name,
104
+ action="customize",
105
+ )
106
+ if v is not None:
107
+ if isinstance(getattr(self, attr), bool):
108
+ setattr(self, attr, v.upper() in ("TRUE", "YES", "1"))
109
+ elif isinstance(getattr(self, attr), VersionPolicy):
110
+ setattr(self, attr, VersionPolicy[v.upper()])
111
+ else:
112
+ setattr(self, attr, v)
113
+
114
+ self.update = self.update.upper()
115
+
116
+
39
117
  class DockerProvider(ReleaseProvider):
40
- def __init__(self, cfg: DockerConfig, common_pkg_cfg: dict[str, PackageUpdateInfo], node_cfg: NodeConfig) -> None:
41
- super().__init__("docker")
118
+ def __init__(
119
+ self,
120
+ cfg: DockerConfig,
121
+ node_cfg: NodeConfig,
122
+ self_bounce: Event | None = None,
123
+ ) -> None:
124
+ super().__init__(node_cfg, "docker")
42
125
  self.client: docker.DockerClient = docker.from_env()
43
126
  self.cfg: DockerConfig = cfg
44
- self.node_cfg: NodeConfig = node_cfg
45
- self.common_pkgs: dict[str, PackageUpdateInfo] = common_pkg_cfg if common_pkg_cfg else {}
127
+
46
128
  # TODO: refresh discovered packages periodically
47
- self.discovered_pkgs: dict[str, PackageUpdateInfo] = self.discover_metadata()
129
+ self.pause_api_until: dict[str, float] = {}
130
+ self.api_throttle_pause: int = cfg.default_api_backoff
131
+ self.self_bounce: Event | None = self_bounce
132
+ self.pkg_enrichers: list[PackageEnricher] = [
133
+ CommonPackageEnricher(self.cfg),
134
+ LinuxServerIOPackageEnricher(self.cfg),
135
+ DefaultPackageEnricher(self.cfg),
136
+ ]
137
+ self.label_enricher = LabelEnricher()
138
+ self.release_enricher = SourceReleaseEnricher()
139
+
140
+ def initialize(self) -> None:
141
+ for enricher in self.pkg_enrichers:
142
+ enricher.initialize()
48
143
 
49
144
  def update(self, discovery: Discovery) -> bool:
50
145
  logger: Any = self.log.bind(container=discovery.name, action="update")
@@ -70,23 +165,30 @@ class DockerProvider(ReleaseProvider):
70
165
  elif discovery.can_build:
71
166
  compose_path: str | None = discovery.custom.get("compose_path")
72
167
  git_repo_path: str | None = discovery.custom.get("git_repo_path")
168
+ logger.debug("can_build check", git_repo=git_repo_path)
73
169
  if not compose_path or not git_repo_path:
74
170
  logger.warn("No compose path or git repo path configured, skipped build")
75
171
  return
76
- if compose_path and not Path(git_repo_path).is_absolute():
77
- full_repo_path: Path = Path(compose_path) / git_repo_path
78
- else:
79
- full_repo_path = Path(git_repo_path)
80
- if git_check_update_available(full_repo_path, Path(self.node_cfg.git_path)):
81
- git_pull(full_repo_path, Path(self.node_cfg.git_path))
82
- if compose_path:
83
- self.build(discovery, compose_path)
172
+
173
+ full_repo_path: Path = self.full_repo_path(compose_path, git_repo_path)
174
+ if git_pull(full_repo_path, Path(self.node_cfg.git_path)):
175
+ if compose_path:
176
+ self.build(discovery, compose_path)
177
+ else:
178
+ logger.warn("No compose path configured, skipped build")
84
179
  else:
85
- logger.warn("No compose path configured, skipped build")
180
+ logger.debug("Skipping git_pull, no update")
181
+
182
+ def full_repo_path(self, compose_path: str, git_repo_path: str) -> Path:
183
+ if compose_path is None or git_repo_path is None:
184
+ raise ValueError("Unexpected null paths")
185
+ if compose_path and not Path(git_repo_path).is_absolute():
186
+ return Path(compose_path) / git_repo_path
187
+ return Path(git_repo_path)
86
188
 
87
189
  def build(self, discovery: Discovery, compose_path: str) -> bool:
88
190
  logger = self.log.bind(container=discovery.name, action="build")
89
- logger.info("Building")
191
+ logger.info("Building", compose_path=compose_path)
90
192
  return self.execute_compose(
91
193
  command=DockerComposeCommand.BUILD,
92
194
  args="",
@@ -125,6 +227,12 @@ class DockerProvider(ReleaseProvider):
125
227
 
126
228
  def restart(self, discovery: Discovery) -> bool:
127
229
  logger = self.log.bind(container=discovery.name, action="restart")
230
+ if self.self_bounce is not None and (
231
+ "ghcr.io/rhizomatics/updates2mqtt" in discovery.custom.get("image_ref", "")
232
+ or discovery.custom.get("git_repo_path", "").endswith("updates2mqtt")
233
+ ):
234
+ logger.warning("Attempting to self-bounce")
235
+ self.self_bounce.set()
128
236
  compose_path = discovery.custom.get("compose_path")
129
237
  compose_service: str | None = discovery.custom.get("compose_service")
130
238
  return self.execute_compose(
@@ -136,7 +244,7 @@ class DockerProvider(ReleaseProvider):
136
244
  try:
137
245
  c: Container = self.client.containers.get(discovery.name)
138
246
  if c:
139
- rediscovery = self.analyze(c, discovery.session, original_discovery=discovery)
247
+ rediscovery = self.analyze(c, discovery.session, previous_discovery=discovery)
140
248
  if rediscovery:
141
249
  self.discoveries[rediscovery.name] = rediscovery
142
250
  return rediscovery
@@ -147,10 +255,21 @@ class DockerProvider(ReleaseProvider):
147
255
  logger.exception("Docker API error retrieving container")
148
256
  return None
149
257
 
150
- def analyze(self, c: Container, session: str, original_discovery: Discovery | None = None) -> Discovery | None:
258
+ def check_throttle(self, repo_id: str) -> bool:
259
+ if self.pause_api_until.get(repo_id) is not None:
260
+ if self.pause_api_until[repo_id] < time.time():
261
+ del self.pause_api_until[repo_id]
262
+ self.log.info("%s throttling wait complete", repo_id)
263
+ else:
264
+ self.log.debug("%s throttling has %s secs left", repo_id, self.pause_api_until[repo_id] - time.time())
265
+ return True
266
+ return False
267
+
268
+ def analyze(self, c: Container, session: str, previous_discovery: Discovery | None = None) -> Discovery | None:
151
269
  logger = self.log.bind(container=c.name, action="analyze")
152
- image_ref = None
153
- image_name = None
270
+
271
+ image_ref: str | None = None
272
+ image_name: str | None = None
154
273
  local_versions = None
155
274
  if c.attrs is None or not c.attrs:
156
275
  logger.warn("No container attributes found, discovery rejected")
@@ -159,17 +278,13 @@ class DockerProvider(ReleaseProvider):
159
278
  logger.warn("No container name found, discovery rejected")
160
279
  return None
161
280
 
162
- def env_override(env_var: str, default: Any) -> Any | None:
163
- return default if c_env.get(env_var) is None else c_env.get(env_var)
164
-
165
- env_str = c.attrs.get("Config", {}).get("Env")
166
- c_env = dict(env.split("=", maxsplit=1) for env in env_str if "==" not in env)
167
- ignore_container: str | None = env_override("UPD2MQTT_IGNORE", "FALSE")
168
- if ignore_container and ignore_container.upper() in ("1", "TRUE"):
281
+ customization: ContainerCustomization = ContainerCustomization(c)
282
+ if customization.ignore:
169
283
  logger.info("Container ignored due to UPD2MQTT_IGNORE setting")
170
284
  return None
171
285
 
172
286
  image: Image | None = c.image
287
+ repo_id: str = "DEFAULT"
173
288
  if image is not None and image.tags and len(image.tags) > 0:
174
289
  image_ref = image.tags[0]
175
290
  else:
@@ -177,6 +292,7 @@ class DockerProvider(ReleaseProvider):
177
292
  if image_ref is None:
178
293
  logger.warn("No image or image attributes found")
179
294
  else:
295
+ repo_id, _ = resolve_repository_name(image_ref)
180
296
  try:
181
297
  image_name = image_ref.split(":")[0]
182
298
  except Exception as e:
@@ -188,12 +304,24 @@ class DockerProvider(ReleaseProvider):
188
304
  logger.warn("Cannot determine local version: %s", e)
189
305
  logger.warn("RepoDigests=%s", image.attrs.get("RepoDigests"))
190
306
 
307
+ selection = Selection(self.cfg.image_ref_select, image_ref)
308
+ publish_policy: PublishPolicy = PublishPolicy.MQTT if not selection.result else PublishPolicy.HOMEASSISTANT
309
+ version_policy: VersionPolicy = VersionPolicy.AUTO if not customization.version_policy else customization.version_policy
310
+
311
+ if customization.update == "AUTO":
312
+ logger.debug("Auto update policy detected")
313
+ update_policy: UpdatePolicy = UpdatePolicy.AUTO
314
+ else:
315
+ update_policy = UpdatePolicy.PASSIVE
316
+
191
317
  platform: str = "Unknown"
192
318
  pkg_info: PackageUpdateInfo = self.default_metadata(image_name, image_ref=image_ref)
193
319
 
194
320
  try:
195
- picture_url = env_override("UPD2MQTT_PICTURE", pkg_info.logo_url)
196
- relnotes_url = env_override("UPD2MQTT_RELNOTES", pkg_info.release_notes_url)
321
+ picture_url: str | None = customization.picture or pkg_info.logo_url
322
+ relnotes_url: str | None = customization.relnotes or pkg_info.release_notes_url
323
+ release_summary: str | None = None
324
+
197
325
  if image is not None and image.attrs is not None:
198
326
  platform = "/".join(
199
327
  filter(
@@ -207,47 +335,103 @@ class DockerProvider(ReleaseProvider):
207
335
  )
208
336
 
209
337
  reg_data: RegistryData | None = None
210
- latest_version: str | None = NO_KNOWN_IMAGE
211
- local_version: str | None = NO_KNOWN_IMAGE
338
+ latest_digest: str | None = NO_KNOWN_IMAGE
339
+ latest_version: str | None = None
340
+
341
+ registry_throttled: bool = self.check_throttle(repo_id)
212
342
 
213
- if image_ref and local_versions:
343
+ if image_ref and local_versions and not registry_throttled:
214
344
  retries_left = 3
215
345
  while reg_data is None and retries_left > 0 and not self.stopped.is_set():
216
346
  try:
217
347
  logger.debug("Fetching registry data", image_ref=image_ref)
218
348
  reg_data = self.client.images.get_registry_data(image_ref)
219
- latest_version = reg_data.short_id[7:] if reg_data else None
349
+ logger.debug(
350
+ "Registry Data: id:%s,image:%s, attrs:%s",
351
+ reg_data.id,
352
+ reg_data.image_name,
353
+ reg_data.attrs,
354
+ )
355
+ latest_digest = reg_data.short_id[7:] if reg_data else None
356
+
220
357
  except docker.errors.APIError as e:
358
+ if e.status_code == HTTPStatus.TOO_MANY_REQUESTS:
359
+ retry_secs: int
360
+ try:
361
+ retry_secs = int(e.response.headers.get("Retry-After", self.api_throttle_pause)) # type: ignore[union-attr]
362
+ except: # noqa: E722
363
+ retry_secs = self.api_throttle_pause
364
+ logger.warn("Docker Registry throttling requests for %s seconds, %s", retry_secs, e.explanation)
365
+ self.pause_api_until[repo_id] = time.time() + retries_left
366
+ return None
221
367
  retries_left -= 1
222
368
  if retries_left == 0 or e.is_client_error():
223
369
  logger.warn("Failed to fetch registry data: [%s] %s", e.errno, e.explanation)
224
370
  else:
225
371
  logger.debug("Failed to fetch registry data, retrying: %s", e)
226
372
 
373
+ installed_digest: str | None = NO_KNOWN_IMAGE
374
+ installed_version: str | None = None
227
375
  if local_versions:
228
376
  # might be multiple RepoDigests if image has been pulled multiple times with diff manifests
229
- local_version = latest_version if latest_version in local_versions else local_versions[0]
377
+ installed_digest = latest_digest if latest_digest in local_versions else local_versions[0]
378
+ logger.debug(f"Setting local digest to {installed_digest}, local_versions:{local_versions}")
230
379
 
231
- def save_if_set(key: str, val: datetime.datetime | str | None) -> None:
380
+ def save_if_set(key: str, val: str | None) -> None:
232
381
  if val is not None:
233
382
  custom[key] = val
234
383
 
235
384
  image_ref = image_ref or ""
236
385
 
237
- custom: dict[str, str | datetime.datetime | bool] = {}
386
+ custom: dict[str, str | bool | int | list[str] | dict[str, Any] | None] = {}
238
387
  custom["platform"] = platform
239
388
  custom["image_ref"] = image_ref
240
- save_if_set("compose_path", c.labels.get("com.docker.compose.project.working_dir"))
241
- save_if_set("compose_version", c.labels.get("com.docker.compose.version"))
242
- save_if_set("compose_service", c.labels.get("com.docker.compose.service"))
243
- save_if_set("git_repo_path", c_env.get("UPD2MQTT_GIT_REPO_PATH"))
244
- save_if_set("apt_pkgs", c_env.get("UPD2MQTT_APT_PKGS"))
245
-
246
- if c_env.get("UPD2MQTT_UPDATE") == "AUTO":
247
- logger.debug("Auto update policy detected")
248
- update_policy = "Auto"
389
+ custom["installed_digest"] = installed_digest
390
+ custom["latest_digest"] = latest_digest
391
+ custom["repo_id"] = repo_id
392
+ custom["git_repo_path"] = customization.git_repo_path
393
+
394
+ if c.labels:
395
+ save_if_set("compose_path", c.labels.get("com.docker.compose.project.working_dir"))
396
+ save_if_set("compose_version", c.labels.get("com.docker.compose.version"))
397
+ save_if_set("compose_service", c.labels.get("com.docker.compose.service"))
398
+ save_if_set("documentation_url", c.labels.get("org.opencontainers.image.documentation"))
399
+ save_if_set("description", c.labels.get("org.opencontainers.image.description"))
400
+ save_if_set("current_image_created", c.labels.get("org.opencontainers.image.created"))
401
+ save_if_set("current_image_version", c.labels.get("org.opencontainers.image.version"))
402
+ save_if_set("vendor", c.labels.get("org.opencontainers.image.vendor"))
403
+ installed_version = c.labels.get("org.opencontainers.image.version")
249
404
  else:
250
- update_policy = "Passive"
405
+ logger.debug("No annotations found on local container")
406
+ # save_if_set("apt_pkgs", c_env.get("UPD2MQTT_APT_PKGS"))
407
+
408
+ if latest_digest is None or latest_digest == NO_KNOWN_IMAGE or registry_throttled:
409
+ logger.debug(
410
+ "Skipping image manifest enrichment",
411
+ latest_digest=latest_digest,
412
+ image_ref=image_ref,
413
+ platform=platform,
414
+ throttled=registry_throttled,
415
+ )
416
+ else:
417
+ os, arch = platform.split("/")[:2] if "/" in platform else (platform, "Unknown")
418
+ try:
419
+ annotations: dict[str, str] = self.label_enricher.fetch_annotations(
420
+ image_ref, os, arch, token=customization.registry_token
421
+ )
422
+ except AuthError as e:
423
+ logger.warning("Authentication error prevented Docker Registry entichment: %s", e)
424
+ annotations = {}
425
+
426
+ if annotations:
427
+ save_if_set("latest_image_created", annotations.get("org.opencontainers.image.created"))
428
+ save_if_set("source", annotations.get("org.opencontainers.image.source"))
429
+ save_if_set("documentation_url", annotations.get("org.opencontainers.image.documentation"))
430
+ save_if_set("description", annotations.get("org.opencontainers.image.description"))
431
+ save_if_set("latest_image_version", annotations.get("org.opencontainers.image.version"))
432
+ save_if_set("vendor", annotations.get("org.opencontainers.image.vendor"))
433
+ latest_version = annotations.get("org.opencontainers.image.version")
434
+ custom.update(self.release_enricher.enrich(annotations) or {})
251
435
 
252
436
  if custom.get("git_repo_path") and custom.get("compose_path"):
253
437
  full_repo_path: Path = Path(cast("str", custom.get("compose_path"))).joinpath(
@@ -255,21 +439,46 @@ class DockerProvider(ReleaseProvider):
255
439
  )
256
440
 
257
441
  git_trust(full_repo_path, Path(self.node_cfg.git_path))
258
- save_if_set("git_local_timestamp", git_timestamp(full_repo_path, Path(self.node_cfg.git_path)))
442
+ save_if_set("git_local_timestamp", git_iso_timestamp(full_repo_path, Path(self.node_cfg.git_path)))
259
443
  features: list[str] = []
260
444
  can_pull: bool = (
261
445
  self.cfg.allow_pull
262
446
  and image_ref is not None
263
447
  and image_ref != ""
264
- and (local_version != NO_KNOWN_IMAGE or latest_version != NO_KNOWN_IMAGE)
448
+ and (installed_digest != NO_KNOWN_IMAGE or latest_digest != NO_KNOWN_IMAGE)
265
449
  )
266
- can_build: bool = self.cfg.allow_build and custom.get("git_repo_path") is not None
450
+ if self.cfg.allow_pull and not can_pull:
451
+ logger.debug(
452
+ f"Pull unavailable, image_ref:{image_ref},installed_digest:{installed_digest},latest_digest:{latest_digest}"
453
+ )
454
+
455
+ can_build: bool = False
456
+ if self.cfg.allow_build:
457
+ can_build = custom.get("git_repo_path") is not None and custom.get("compose_path") is not None
458
+ if not can_build:
459
+ if custom.get("git_repo_path") is not None:
460
+ logger.debug(
461
+ "Local build ignored for git_repo_path=%s because no compose_path", custom.get("git_repo_path")
462
+ )
463
+ else:
464
+ full_repo_path = self.full_repo_path(
465
+ cast("str", custom.get("compose_path")), cast("str", custom.get("git_repo_path"))
466
+ )
467
+ if installed_digest is None or installed_digest == NO_KNOWN_IMAGE:
468
+ installed_digest = git_local_version(full_repo_path, Path(self.node_cfg.git_path)) or NO_KNOWN_IMAGE
469
+
470
+ behind_count: int = git_check_update_available(full_repo_path, Path(self.node_cfg.git_path))
471
+ if behind_count > 0:
472
+ if installed_digest is not None and installed_digest.startswith("git:"):
473
+ latest_digest = f"{installed_digest}+{behind_count}"
474
+ logger.info("Git update available, generating version %s", latest_digest)
475
+ else:
476
+ logger.debug(f"Git update not available, local repo:{full_repo_path}")
477
+
267
478
  can_restart: bool = self.cfg.allow_restart and custom.get("compose_path") is not None
479
+
268
480
  can_update: bool = False
269
- if self.cfg.allow_pull and not can_pull and not can_build:
270
- logger.info(
271
- f"Pull not available, image_ref:{image_ref},local_version:{local_version},latest_version:{latest_version}"
272
- )
481
+
273
482
  if can_pull or can_build or can_restart:
274
483
  # public install-neutral capabilities and Home Assistant features
275
484
  can_update = True
@@ -280,25 +489,35 @@ class DockerProvider(ReleaseProvider):
280
489
  if relnotes_url:
281
490
  features.append("RELEASE_NOTES")
282
491
  if can_pull:
283
- update_type: str = "Docker Image"
492
+ update_type = "Docker Image"
284
493
  elif can_build:
285
494
  update_type = "Docker Build"
286
495
  else:
287
496
  update_type = "Unavailable"
288
497
  custom["can_pull"] = can_pull
498
+ # can_pull,can_build etc are only info flags
499
+ # the HASS update process is driven by comparing current and available versions
289
500
 
290
- logger.debug("Analyze generated discovery", discovery_name=c.name, current_version=local_version)
291
- return Discovery(
501
+ public_installed_version = select_version(
502
+ version_policy, installed_version, installed_digest, other_version=latest_version, other_digest=latest_digest
503
+ )
504
+ public_latest_version = select_version(
505
+ version_policy, latest_version, latest_digest, other_version=installed_version, other_digest=installed_digest
506
+ )
507
+
508
+ discovery: Discovery = Discovery(
292
509
  self,
293
510
  c.name,
294
511
  session,
295
512
  node=self.node_cfg.name,
296
513
  entity_picture_url=picture_url,
297
514
  release_url=relnotes_url,
298
- current_version=local_version,
515
+ release_summary=release_summary,
516
+ current_version=public_installed_version,
517
+ publish_policy=publish_policy,
299
518
  update_policy=update_policy,
300
- update_last_attempt=(original_discovery and original_discovery.update_last_attempt) or None,
301
- latest_version=latest_version if latest_version != NO_KNOWN_IMAGE else local_version,
519
+ version_policy=version_policy,
520
+ latest_version=public_latest_version,
302
521
  device_icon=self.cfg.device_icon,
303
522
  can_update=can_update,
304
523
  update_type=update_type,
@@ -307,15 +526,25 @@ class DockerProvider(ReleaseProvider):
307
526
  status=(c.status == "running" and "on") or "off",
308
527
  custom=custom,
309
528
  features=features,
529
+ throttled=registry_throttled,
530
+ previous=previous_discovery,
310
531
  )
532
+ logger.debug("Analyze generated discovery: %s", discovery)
533
+ return discovery
311
534
  except Exception:
312
535
  logger.exception("Docker Discovery Failure", container_attrs=c.attrs)
313
536
  logger.debug("Analyze returned empty discovery")
314
537
  return None
315
538
 
539
+ # def version(self, c: Container, version_type: str):
540
+ # metadata_version: str = c.labels.get("org.opencontainers.image.version")
541
+ # metadata_revision: str = c.labels.get("org.opencontainers.image.revision")
542
+
316
543
  async def scan(self, session: str) -> AsyncGenerator[Discovery]:
317
544
  logger = self.log.bind(session=session, action="scan", source=self.source_type)
318
- containers = results = 0
545
+ containers: int = 0
546
+ results: int = 0
547
+ throttled: int = 0
319
548
  logger.debug("Starting container scan loop")
320
549
  for c in self.client.containers.list():
321
550
  logger.debug("Analyzing container", container=c.name)
@@ -328,14 +557,15 @@ class DockerProvider(ReleaseProvider):
328
557
  logger.debug("Analyzed container", result_name=result.name, custom=result.custom)
329
558
  self.discoveries[result.name] = result
330
559
  results = results + 1
560
+ throttled += 1 if result.throttled else 0
331
561
  yield result
332
562
  else:
333
563
  logger.debug("No result from analysis", container=c.name)
334
- logger.info("Completed", container_count=containers, result_count=results)
564
+ logger.info("Completed", container_count=containers, throttled_count=throttled, result_count=results)
335
565
 
336
566
  def command(self, discovery_name: str, command: str, on_update_start: Callable, on_update_end: Callable) -> bool:
337
567
  logger = self.log.bind(container=discovery_name, action="command", command=command)
338
- logger.info("Executing")
568
+ logger.info("Executing Command")
339
569
  discovery: Discovery | None = None
340
570
  updated: bool = False
341
571
  try:
@@ -368,87 +598,9 @@ class DockerProvider(ReleaseProvider):
368
598
  def resolve(self, discovery_name: str) -> Discovery | None:
369
599
  return self.discoveries.get(discovery_name)
370
600
 
371
- def hass_state_format(self, discovery: Discovery) -> dict: # noqa: ARG002
372
- # disable since hass mqtt update has strict json schema for message
373
- return {
374
- # "docker_image_ref": discovery.custom.get("image_ref"),
375
- # "last_update_attempt": safe_json_dt(discovery.update_last_attempt),
376
- # "can_pull": discovery.custom.get("can_pull"),
377
- # "can_build": discovery.custom.get("can_build"),
378
- # "can_restart": discovery.custom.get("can_restart"),
379
- # "git_repo_path": discovery.custom.get("git_repo_path"),
380
- # "compose_path": discovery.custom.get("compose_path"),
381
- # "platform": discovery.custom.get("platform"),
382
- }
383
-
384
601
  def default_metadata(self, image_name: str | None, image_ref: str | None) -> PackageUpdateInfo:
385
- def match(pkg: PackageUpdateInfo) -> bool:
386
- if pkg is not None and pkg.docker is not None and pkg.docker.image_name is not None:
387
- if image_name is not None and image_name == pkg.docker.image_name:
388
- return True
389
- if image_ref is not None and image_ref == pkg.docker.image_name:
390
- return True
391
- return False
392
-
393
- if image_name is not None and image_ref is not None:
394
- for pkg in self.common_pkgs.values():
395
- if match(pkg):
396
- self.log.debug(
397
- "Found common package",
398
- image_name=pkg.docker.image_name, # type: ignore [union-attr]
399
- logo_url=pkg.logo_url,
400
- relnotes_url=pkg.release_notes_url,
401
- )
402
- return pkg
403
- for pkg in self.discovered_pkgs.values():
404
- if match(pkg):
405
- self.log.debug(
406
- "Found discovered package",
407
- pkg=pkg.docker.image_name, # type: ignore [union-attr]
408
- logo_url=pkg.logo_url,
409
- relnotes_url=pkg.release_notes_url,
410
- )
411
- return pkg
412
-
413
- self.log.debug("No common or discovered package found", image_name=image_name)
414
- return PackageUpdateInfo(
415
- DockerPackageUpdateInfo(image_name or NO_KNOWN_IMAGE),
416
- logo_url=self.cfg.default_entity_picture_url,
417
- release_notes_url=None,
418
- )
419
-
420
- def discover_metadata(self) -> dict[str, PackageUpdateInfo]:
421
- pkgs: dict[str, PackageUpdateInfo] = {}
422
- cfg = self.cfg.discover_metadata.get("linuxserver.io")
423
- if cfg and cfg.enabled:
424
- linuxserver_metadata(pkgs, cache_ttl=cfg.cache_ttl)
425
- return pkgs
426
-
427
-
428
- def linuxserver_metadata_api(cache_ttl: int) -> dict:
429
- """Fetch and cache linuxserver.io API call for image metadata"""
430
- try:
431
- with SyncCacheClient(headers=[("cache-control", f"max-age={cache_ttl}")]) as client:
432
- log.debug(f"Fetching linuxserver.io metadata from API, cache_ttl={cache_ttl}")
433
- req = client.get("https://api.linuxserver.io/api/v1/images?include_config=false&include_deprecated=false")
434
- return req.json()
435
- except Exception:
436
- log.exception("Failed to fetch linuxserver.io metadata")
437
- return {}
438
-
439
-
440
- def linuxserver_metadata(discovered_pkgs: dict[str, PackageUpdateInfo], cache_ttl: int) -> None:
441
- """Fetch linuxserver.io metadata for all their images via their API"""
442
- repos: list = linuxserver_metadata_api(cache_ttl).get("data", {}).get("repositories", {}).get("linuxserver", [])
443
- added = 0
444
- for repo in repos:
445
- image_name = repo.get("name")
446
- if image_name and image_name not in discovered_pkgs:
447
- discovered_pkgs[image_name] = PackageUpdateInfo(
448
- DockerPackageUpdateInfo(f"lscr.io/linuxserver/{image_name}"),
449
- logo_url=repo["project_logo"],
450
- release_notes_url=f"{repo['github_url']}/releases",
451
- )
452
- added += 1
453
- log.debug("Added linuxserver.io package", pkg=image_name)
454
- log.info(f"Added {added} linuxserver.io package details")
602
+ for enricher in self.pkg_enrichers:
603
+ pkg_info = enricher.enrich(image_name, image_ref, self.log)
604
+ if pkg_info is not None:
605
+ return pkg_info
606
+ raise ValueError("No enricher could provide metadata, not even default enricher")