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.
- updates2mqtt/app.py +23 -11
- updates2mqtt/config.py +30 -18
- updates2mqtt/hass_formatter.py +7 -17
- updates2mqtt/integrations/docker.py +303 -151
- updates2mqtt/integrations/docker_enrich.py +344 -0
- updates2mqtt/integrations/git_utils.py +71 -14
- updates2mqtt/model.py +150 -16
- updates2mqtt/mqtt.py +28 -7
- {updates2mqtt-1.5.1.dist-info → updates2mqtt-1.7.0.dist-info}/METADATA +68 -33
- updates2mqtt-1.7.0.dist-info/RECORD +16 -0
- {updates2mqtt-1.5.1.dist-info → updates2mqtt-1.7.0.dist-info}/WHEEL +1 -1
- {updates2mqtt-1.5.1.dist-info → updates2mqtt-1.7.0.dist-info}/entry_points.txt +1 -0
- updates2mqtt-1.5.1.dist-info/RECORD +0 -16
- updates2mqtt-1.5.1.dist-info/licenses/LICENSE +0 -201
|
@@ -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
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
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__(
|
|
41
|
-
|
|
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
|
-
|
|
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.
|
|
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
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
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.
|
|
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,
|
|
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
|
|
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
|
-
|
|
153
|
-
|
|
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
|
-
|
|
163
|
-
|
|
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 =
|
|
196
|
-
relnotes_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
|
-
|
|
211
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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:
|
|
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 |
|
|
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
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
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
|
-
|
|
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",
|
|
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 (
|
|
448
|
+
and (installed_digest != NO_KNOWN_IMAGE or latest_digest != NO_KNOWN_IMAGE)
|
|
265
449
|
)
|
|
266
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
291
|
-
|
|
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
|
-
|
|
515
|
+
release_summary=release_summary,
|
|
516
|
+
current_version=public_installed_version,
|
|
517
|
+
publish_policy=publish_policy,
|
|
299
518
|
update_policy=update_policy,
|
|
300
|
-
|
|
301
|
-
latest_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
|
|
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
|
-
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
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")
|