updates2mqtt 1.7.3__py3-none-any.whl → 1.8.1__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 +10 -6
- updates2mqtt/cli.py +33 -15
- updates2mqtt/config.py +28 -19
- updates2mqtt/helpers.py +44 -4
- updates2mqtt/integrations/docker.py +53 -15
- updates2mqtt/integrations/docker_enrich.py +168 -51
- updates2mqtt/model.py +26 -10
- updates2mqtt/mqtt.py +109 -60
- {updates2mqtt-1.7.3.dist-info → updates2mqtt-1.8.1.dist-info}/METADATA +8 -44
- updates2mqtt-1.8.1.dist-info/RECORD +18 -0
- {updates2mqtt-1.7.3.dist-info → updates2mqtt-1.8.1.dist-info}/WHEEL +1 -1
- updates2mqtt-1.7.3.dist-info/RECORD +0 -18
- {updates2mqtt-1.7.3.dist-info → updates2mqtt-1.8.1.dist-info}/entry_points.txt +0 -0
|
@@ -14,6 +14,7 @@ from updates2mqtt.model import DiscoveryArtefactDetail, DiscoveryInstallationDet
|
|
|
14
14
|
|
|
15
15
|
if typing.TYPE_CHECKING:
|
|
16
16
|
from docker.models.images import RegistryData
|
|
17
|
+
from omegaconf.dictconfig import DictConfig
|
|
17
18
|
from http import HTTPStatus
|
|
18
19
|
|
|
19
20
|
import docker
|
|
@@ -21,23 +22,35 @@ import docker.errors
|
|
|
21
22
|
|
|
22
23
|
from updates2mqtt.config import (
|
|
23
24
|
PKG_INFO_FILE,
|
|
25
|
+
CommonPackages,
|
|
24
26
|
DockerConfig,
|
|
25
27
|
DockerPackageUpdateInfo,
|
|
28
|
+
GitHubConfig,
|
|
26
29
|
PackageUpdateInfo,
|
|
27
30
|
RegistryConfig,
|
|
28
|
-
|
|
31
|
+
VersionPolicy,
|
|
29
32
|
)
|
|
30
33
|
|
|
31
|
-
log = structlog.get_logger()
|
|
34
|
+
log: Any = structlog.get_logger()
|
|
32
35
|
|
|
33
36
|
SOURCE_PLATFORM_GITHUB = "GitHub"
|
|
34
37
|
SOURCE_PLATFORM_CODEBERG = "CodeBerg"
|
|
35
|
-
|
|
38
|
+
SOURCE_PLATFORM_GITLAB = "GitLab"
|
|
39
|
+
SOURCE_PLATFORMS = {
|
|
40
|
+
SOURCE_PLATFORM_GITHUB: r"https://github.com/.*",
|
|
41
|
+
SOURCE_PLATFORM_GITLAB: r"https://gitlab.com/.*",
|
|
42
|
+
SOURCE_PLATFORM_CODEBERG: r"https://codeberg.org/.*",
|
|
43
|
+
}
|
|
36
44
|
DIFF_URL_TEMPLATES = {
|
|
37
45
|
SOURCE_PLATFORM_GITHUB: "{repo}/commit/{revision}",
|
|
38
46
|
}
|
|
39
|
-
RELEASE_URL_TEMPLATES = {
|
|
40
|
-
|
|
47
|
+
RELEASE_URL_TEMPLATES = {
|
|
48
|
+
SOURCE_PLATFORM_GITHUB: "{repo}/releases/tag/{version}",
|
|
49
|
+
}
|
|
50
|
+
UNKNOWN_RELEASE_URL_TEMPLATES = {
|
|
51
|
+
SOURCE_PLATFORM_GITHUB: "{repo}/releases",
|
|
52
|
+
SOURCE_PLATFORM_GITLAB: "{repo}/container_registry",
|
|
53
|
+
}
|
|
41
54
|
MISSING_VAL = "**MISSING**"
|
|
42
55
|
UNKNOWN_REGISTRY = "**UNKNOWN_REGISTRY**"
|
|
43
56
|
|
|
@@ -45,18 +58,27 @@ HEADER_DOCKER_DIGEST = "docker-content-digest"
|
|
|
45
58
|
HEADER_DOCKER_API = "docker-distribution-api-version"
|
|
46
59
|
|
|
47
60
|
TOKEN_URL_TEMPLATE = "https://{auth_host}/token?scope=repository:{image_name}:pull&service={service}" # noqa: S105 # nosec
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
"
|
|
52
|
-
"
|
|
53
|
-
"
|
|
54
|
-
"
|
|
61
|
+
|
|
62
|
+
REGISTRIES: dict[str, tuple[str | None, str, str, str | None, str | None]] = {
|
|
63
|
+
# registry: (auth_host, api_host, service, url_template, repo_template)
|
|
64
|
+
"docker.io": ("auth.docker.io", "registry-1.docker.io", "registry.docker.io", TOKEN_URL_TEMPLATE, None),
|
|
65
|
+
"mcr.microsoft.com": (None, "mcr.microsoft.com", "mcr.microsoft.com", None, None),
|
|
66
|
+
"quay.io": (None, "quay.io", "quay.io", TOKEN_URL_TEMPLATE, None),
|
|
67
|
+
"ghcr.io": ("ghcr.io", "ghcr.io", "ghcr.io", TOKEN_URL_TEMPLATE, "https://github.com/{image_name}"),
|
|
68
|
+
"lscr.io": ("ghcr.io", "lscr.io", "ghcr.io", TOKEN_URL_TEMPLATE, None),
|
|
69
|
+
"codeberg.org": (
|
|
70
|
+
"codeberg.org",
|
|
71
|
+
"codeberg.org",
|
|
72
|
+
"container_registry",
|
|
73
|
+
TOKEN_URL_TEMPLATE,
|
|
74
|
+
"https://codeberg.org/{image_name}",
|
|
75
|
+
),
|
|
55
76
|
"registry.gitlab.com": (
|
|
56
77
|
"www.gitlab.com",
|
|
57
78
|
"registry.gitlab.com",
|
|
58
79
|
"container_registry",
|
|
59
80
|
"https://{auth_host}/jwt/auth?service={service}&scope=repository:{image_name}:pull&offline_token=true&client_id=docker",
|
|
81
|
+
"https://gitlab.com/{image_name}",
|
|
60
82
|
),
|
|
61
83
|
}
|
|
62
84
|
|
|
@@ -83,7 +105,9 @@ class DockerImageInfo(DiscoveryArtefactDetail):
|
|
|
83
105
|
annotations: dict[str, Any] | None = None,
|
|
84
106
|
platform: str | None = None, # test harness simplification
|
|
85
107
|
version: str | None = None, # test harness simplification
|
|
108
|
+
created: str | None = None,
|
|
86
109
|
) -> None:
|
|
110
|
+
super().__init__()
|
|
87
111
|
self.ref: str = ref
|
|
88
112
|
self.version: str | None = version
|
|
89
113
|
self.image_digest: str | None = image_digest
|
|
@@ -105,6 +129,7 @@ class DockerImageInfo(DiscoveryArtefactDetail):
|
|
|
105
129
|
self.error: str | None = None
|
|
106
130
|
self.platform: str | None = platform
|
|
107
131
|
self.custom: dict[str, str | float | int | bool | None] = {}
|
|
132
|
+
self.created: str | None = created
|
|
108
133
|
|
|
109
134
|
self.local_build: bool = not self.repo_digests
|
|
110
135
|
self.index_name, remote_name = resolve_repository_name(ref)
|
|
@@ -190,16 +215,20 @@ class DockerImageInfo(DiscoveryArtefactDetail):
|
|
|
190
215
|
digest = digest.split(":")[1] if ":" in digest else digest # remove digest type prefix
|
|
191
216
|
return digest[0:12]
|
|
192
217
|
return digest
|
|
193
|
-
except Exception:
|
|
218
|
+
except Exception as e:
|
|
219
|
+
log.warning("Unable to condense digest %s: %s", digest, e)
|
|
194
220
|
return None
|
|
195
221
|
|
|
196
222
|
def reuse(self) -> "DockerImageInfo":
|
|
197
|
-
cloned = DockerImageInfo(
|
|
223
|
+
cloned = DockerImageInfo(
|
|
224
|
+
self.ref, self.image_digest, self.tags, self.attributes, self.annotations, self.version, self.created
|
|
225
|
+
)
|
|
198
226
|
cloned.origin = "REUSED"
|
|
199
227
|
return cloned
|
|
200
228
|
|
|
201
229
|
def as_dict(self, minimal: bool = True) -> dict[str, str | list | dict | bool | int | None]:
|
|
202
230
|
result: dict[str, str | list | dict | bool | int | None] = {
|
|
231
|
+
"captured": self.captured.isoformat(),
|
|
203
232
|
"image_ref": self.ref,
|
|
204
233
|
"name": self.name,
|
|
205
234
|
"version": self.version,
|
|
@@ -246,7 +275,7 @@ def _select_annotation(
|
|
|
246
275
|
|
|
247
276
|
|
|
248
277
|
def cherrypick_annotations(
|
|
249
|
-
local_info: DockerImageInfo
|
|
278
|
+
local_info: DockerImageInfo, registry_info: DockerImageInfo | None
|
|
250
279
|
) -> dict[str, str | float | int | bool | None]:
|
|
251
280
|
"""https://github.com/opencontainers/image-spec/blob/main/annotations.md"""
|
|
252
281
|
results: dict[str, str | float | int | bool | None] = {}
|
|
@@ -258,11 +287,25 @@ def cherrypick_annotations(
|
|
|
258
287
|
("image_created", "org.opencontainers.image.created"),
|
|
259
288
|
("image_version", "org.opencontainers.image.version"),
|
|
260
289
|
("image_revision", "org.opencontainers.image.revision"),
|
|
290
|
+
("ref_name", "org.opencontainers.image.ref.name"),
|
|
261
291
|
("title", "org.opencontainers.image.title"),
|
|
262
292
|
("vendor", "org.opencontainers.image.vendor"),
|
|
263
293
|
("source", "org.opencontainers.image.source"),
|
|
264
294
|
]:
|
|
265
295
|
results.update(_select_annotation(either_name, either_label, local_info, registry_info))
|
|
296
|
+
if (
|
|
297
|
+
results.get("ref_name") == "ubuntu"
|
|
298
|
+
and local_info.name != "ubuntu"
|
|
299
|
+
and results.get("image_version")
|
|
300
|
+
and re.fullmatch(r"^2\d\.\d\d$", cast("str", results["image_version"]))
|
|
301
|
+
):
|
|
302
|
+
log.debug(
|
|
303
|
+
"Suppressing %s base %s version leaking into image version: %s",
|
|
304
|
+
local_info.name,
|
|
305
|
+
results["ref_name"],
|
|
306
|
+
results["image_version"],
|
|
307
|
+
)
|
|
308
|
+
del results["image_version"]
|
|
266
309
|
return results
|
|
267
310
|
|
|
268
311
|
|
|
@@ -322,14 +365,17 @@ class LocalContainerInfo:
|
|
|
322
365
|
labels: dict[str, str | float | int | bool | None] = cherrypick_annotations(image_info, None)
|
|
323
366
|
# capture container labels/annotations, not image ones
|
|
324
367
|
labels = labels or {}
|
|
368
|
+
if container.image and container.image.attrs:
|
|
369
|
+
image_info.created = container.image.attrs.get("Created")
|
|
325
370
|
image_info.custom = labels
|
|
326
371
|
image_info.version = cast("str|None", labels.get("image_version"))
|
|
372
|
+
|
|
327
373
|
return image_info, service_info
|
|
328
374
|
|
|
329
375
|
|
|
330
376
|
class PackageEnricher:
|
|
331
|
-
def __init__(self, docker_cfg: DockerConfig) -> None:
|
|
332
|
-
self.pkgs: dict[str, PackageUpdateInfo] = {}
|
|
377
|
+
def __init__(self, docker_cfg: DockerConfig, packages: dict[str, PackageUpdateInfo] | None = None) -> None:
|
|
378
|
+
self.pkgs: dict[str, PackageUpdateInfo] = packages or {}
|
|
333
379
|
self.cfg: DockerConfig = docker_cfg
|
|
334
380
|
self.log: Any = structlog.get_logger().bind(integration="docker")
|
|
335
381
|
|
|
@@ -362,7 +408,7 @@ class DefaultPackageEnricher(PackageEnricher):
|
|
|
362
408
|
def enrich(self, image_info: DockerImageInfo) -> PackageUpdateInfo | None:
|
|
363
409
|
self.log.debug("Default pkg info", image_name=image_info.untagged_ref, image_ref=image_info.ref)
|
|
364
410
|
return PackageUpdateInfo(
|
|
365
|
-
DockerPackageUpdateInfo(image_info.untagged_ref or image_info.ref),
|
|
411
|
+
DockerPackageUpdateInfo(image_info.untagged_ref or image_info.ref, version_policy=VersionPolicy.AUTO),
|
|
366
412
|
logo_url=self.cfg.default_entity_picture_url,
|
|
367
413
|
release_notes_url=None,
|
|
368
414
|
)
|
|
@@ -370,19 +416,25 @@ class DefaultPackageEnricher(PackageEnricher):
|
|
|
370
416
|
|
|
371
417
|
class CommonPackageEnricher(PackageEnricher):
|
|
372
418
|
def initialize(self) -> None:
|
|
419
|
+
base_cfg: DictConfig = OmegaConf.structured(CommonPackages)
|
|
373
420
|
if PKG_INFO_FILE.exists():
|
|
374
|
-
log.debug("Loading common package update info", path=PKG_INFO_FILE)
|
|
375
|
-
cfg = OmegaConf.load(PKG_INFO_FILE)
|
|
421
|
+
self.log.debug("Loading common package update info", path=PKG_INFO_FILE)
|
|
422
|
+
cfg: DictConfig = typing.cast("DictConfig", OmegaConf.merge(base_cfg, OmegaConf.load(PKG_INFO_FILE)))
|
|
423
|
+
|
|
424
|
+
OmegaConf.to_container(cfg, throw_on_missing=True)
|
|
425
|
+
OmegaConf.set_readonly(cfg, True)
|
|
376
426
|
else:
|
|
377
|
-
log.warn("No common package update info found", path=PKG_INFO_FILE)
|
|
378
|
-
cfg =
|
|
427
|
+
self.log.warn("No common package update info found", path=PKG_INFO_FILE)
|
|
428
|
+
cfg = base_cfg
|
|
379
429
|
try:
|
|
430
|
+
common_config: CommonPackages = typing.cast("CommonPackages", cfg)
|
|
380
431
|
# omegaconf broken-ness on optional fields and converting to backclasses
|
|
381
|
-
self.pkgs
|
|
382
|
-
|
|
383
|
-
|
|
432
|
+
self.pkgs = common_config.common_packages
|
|
433
|
+
# self.pkgs: dict[str, PackageUpdateInfo] = {
|
|
434
|
+
# pkg: PackageUpdateInfo(**pkg_cfg) for pkg, pkg_cfg in cfg.common_packages.items() if pkg not in self.pkgs
|
|
435
|
+
# }
|
|
384
436
|
except (MissingMandatoryValue, ValidationError) as e:
|
|
385
|
-
log.
|
|
437
|
+
self.log.serror("Configuration error %s", e, path=PKG_INFO_FILE.as_posix())
|
|
386
438
|
raise
|
|
387
439
|
|
|
388
440
|
|
|
@@ -392,7 +444,7 @@ class LinuxServerIOPackageEnricher(PackageEnricher):
|
|
|
392
444
|
if cfg is None or not cfg.enabled:
|
|
393
445
|
return
|
|
394
446
|
|
|
395
|
-
log.debug(f"Fetching linuxserver.io metadata from API, cache_ttl={cfg.cache_ttl}")
|
|
447
|
+
self.log.debug(f"Fetching linuxserver.io metadata from API, cache_ttl={cfg.cache_ttl}")
|
|
396
448
|
response: Response | None = fetch_url(
|
|
397
449
|
"https://api.linuxserver.io/api/v1/images?include_config=false&include_deprecated=false",
|
|
398
450
|
cache_ttl=cfg.cache_ttl,
|
|
@@ -413,26 +465,38 @@ class LinuxServerIOPackageEnricher(PackageEnricher):
|
|
|
413
465
|
release_notes_url=f"{repo['github_url']}/releases",
|
|
414
466
|
)
|
|
415
467
|
added += 1
|
|
416
|
-
|
|
417
|
-
log.info(f"Added {added} linuxserver.io package details")
|
|
468
|
+
self.log.info(f"Added {added} linuxserver.io package details")
|
|
418
469
|
|
|
419
470
|
|
|
420
471
|
class SourceReleaseEnricher:
|
|
421
|
-
def __init__(self) -> None:
|
|
472
|
+
def __init__(self, gh_cfg: GitHubConfig | None = None) -> None:
|
|
422
473
|
self.log: Any = structlog.get_logger().bind(integration="docker")
|
|
474
|
+
self.gh_cfg: GitHubConfig | None = gh_cfg
|
|
423
475
|
|
|
424
476
|
def enrich(
|
|
425
477
|
self, registry_info: DockerImageInfo, source_repo_url: str | None = None, notes_url: str | None = None
|
|
426
478
|
) -> ReleaseDetail | None:
|
|
427
|
-
if not registry_info.annotations and not source_repo_url and not notes_url:
|
|
428
|
-
return None
|
|
429
|
-
|
|
430
479
|
detail = ReleaseDetail()
|
|
431
480
|
|
|
432
481
|
detail.notes_url = notes_url
|
|
433
482
|
detail.version = registry_info.annotations.get("org.opencontainers.image.version")
|
|
434
483
|
detail.revision = registry_info.annotations.get("org.opencontainers.image.revision")
|
|
435
|
-
|
|
484
|
+
# explicit source_repo_url overrides container, e.g. where container source is only the docker wrapper
|
|
485
|
+
detail.source_url = source_repo_url or registry_info.annotations.get("org.opencontainers.image.source")
|
|
486
|
+
|
|
487
|
+
if detail.source_url is None and registry_info is not None and registry_info.index_name is not None:
|
|
488
|
+
registry_config: tuple[str | None, str, str, str | None, str | None] | None = REGISTRIES.get(
|
|
489
|
+
registry_info.index_name
|
|
490
|
+
)
|
|
491
|
+
repo_template: str | None = registry_config[4] if registry_config else None
|
|
492
|
+
if repo_template:
|
|
493
|
+
source_url = repo_template.format(image_name=registry_info.name)
|
|
494
|
+
if validate_url(source_url, cache_ttl=86400):
|
|
495
|
+
detail.source_url = source_url
|
|
496
|
+
self.log.info("Implied source from registry: %s", detail.source_url)
|
|
497
|
+
|
|
498
|
+
if detail.source_url is None and detail.notes_url is None and detail.revision is None and detail.version is None:
|
|
499
|
+
return None
|
|
436
500
|
|
|
437
501
|
if detail.source_url and "#" in detail.source_url:
|
|
438
502
|
detail.source_repo_url = detail.source_url.split("#", 1)[0]
|
|
@@ -452,30 +516,77 @@ class SourceReleaseEnricher:
|
|
|
452
516
|
"source": detail.source_url or MISSING_VAL,
|
|
453
517
|
}
|
|
454
518
|
|
|
455
|
-
|
|
456
|
-
|
|
519
|
+
diff_url_template: str | None = DIFF_URL_TEMPLATES.get(detail.source_platform)
|
|
520
|
+
diff_url: str | None = diff_url_template.format(**template_vars) if diff_url_template else None
|
|
521
|
+
if diff_url and MISSING_VAL not in diff_url and validate_url(diff_url, cache_ttl=3600):
|
|
457
522
|
detail.diff_url = diff_url
|
|
458
523
|
else:
|
|
459
524
|
diff_url = None
|
|
460
525
|
|
|
461
|
-
if detail.notes_url is None:
|
|
462
|
-
|
|
463
|
-
|
|
464
|
-
|
|
465
|
-
|
|
466
|
-
|
|
467
|
-
|
|
468
|
-
|
|
469
|
-
|
|
526
|
+
if detail.notes_url is None and detail.source_platform in RELEASE_URL_TEMPLATES:
|
|
527
|
+
platform_notes_url: str | None = RELEASE_URL_TEMPLATES[detail.source_platform].format(**template_vars)
|
|
528
|
+
if (
|
|
529
|
+
platform_notes_url
|
|
530
|
+
and MISSING_VAL not in platform_notes_url
|
|
531
|
+
and validate_url(platform_notes_url, cache_ttl=86400)
|
|
532
|
+
):
|
|
533
|
+
self.log.debug("Setting default known release notes url: %s", platform_notes_url)
|
|
534
|
+
detail.notes_url = platform_notes_url
|
|
535
|
+
|
|
536
|
+
if detail.notes_url is None and detail.source_platform in UNKNOWN_RELEASE_URL_TEMPLATES:
|
|
537
|
+
platform_notes_url = UNKNOWN_RELEASE_URL_TEMPLATES[detail.source_platform].format(**template_vars)
|
|
538
|
+
if (
|
|
539
|
+
platform_notes_url
|
|
540
|
+
and MISSING_VAL not in platform_notes_url
|
|
541
|
+
and validate_url(platform_notes_url, cache_ttl=86400)
|
|
542
|
+
):
|
|
543
|
+
self.log.debug("Setting default unknown release notes url: %s", platform_notes_url)
|
|
544
|
+
detail.notes_url = platform_notes_url
|
|
545
|
+
|
|
546
|
+
if detail.source_platform == SOURCE_PLATFORM_GITHUB and detail.source_repo_url and detail.version is not None:
|
|
547
|
+
access_token: str | None = self.gh_cfg.access_token if self.gh_cfg else None
|
|
548
|
+
if access_token:
|
|
549
|
+
self.log.debug("Using configured bearer token (%s chars) for GitHub API", len(access_token))
|
|
470
550
|
base_api = detail.source_repo_url.replace("https://github.com", "https://api.github.com/repos")
|
|
471
551
|
|
|
472
|
-
api_response: Response | None = fetch_url(
|
|
552
|
+
api_response: Response | None = fetch_url(
|
|
553
|
+
f"{base_api}/releases/tags/{detail.version}", bearer_token=access_token, allow_stale=True
|
|
554
|
+
)
|
|
555
|
+
if api_response and api_response.status_code == 404:
|
|
556
|
+
# possible that source version doesn't match release gag
|
|
557
|
+
alt_api_response: Response | None = fetch_url(f"{base_api}/releases/latest", bearer_token=access_token)
|
|
558
|
+
if alt_api_response and alt_api_response.is_success:
|
|
559
|
+
alt_api_results = httpx_json_content(alt_api_response, {})
|
|
560
|
+
if alt_api_results and re.fullmatch(f"(V|v|r|R)?{detail.version}", alt_api_results.get("tag_name")):
|
|
561
|
+
self.log.info(
|
|
562
|
+
f"Matched {registry_info.name} {detail.version} to latest release {alt_api_results['tag_name']}"
|
|
563
|
+
)
|
|
564
|
+
api_response = alt_api_response
|
|
565
|
+
elif alt_api_results:
|
|
566
|
+
self.log.debug(
|
|
567
|
+
"Failed to match %s release %s on GitHub, found tag %s for name %s published at %s",
|
|
568
|
+
registry_info.name,
|
|
569
|
+
detail.version,
|
|
570
|
+
alt_api_results.get("tag_name"),
|
|
571
|
+
alt_api_results.get("name"),
|
|
572
|
+
alt_api_results.get("published_at"),
|
|
573
|
+
)
|
|
574
|
+
|
|
473
575
|
if api_response and api_response.is_success:
|
|
474
576
|
api_results: Any = httpx_json_content(api_response, {})
|
|
475
577
|
detail.summary = api_results.get("body") # ty:ignore[possibly-missing-attribute]
|
|
476
578
|
reactions = api_results.get("reactions") # ty:ignore[possibly-missing-attribute]
|
|
477
579
|
if reactions:
|
|
478
580
|
detail.net_score = reactions.get("+1", 0) - reactions.get("-1", 0)
|
|
581
|
+
elif api_response:
|
|
582
|
+
api_results = httpx_json_content(api_response, default={})
|
|
583
|
+
self.log.debug(
|
|
584
|
+
"Failed to find %s release %s on GitHub, status %s, errors; %s",
|
|
585
|
+
registry_info.name,
|
|
586
|
+
detail.version,
|
|
587
|
+
api_response.status_code,
|
|
588
|
+
api_results.get("errors"),
|
|
589
|
+
)
|
|
479
590
|
else:
|
|
480
591
|
self.log.debug(
|
|
481
592
|
"Failed to fetch GitHub release info",
|
|
@@ -523,14 +634,18 @@ class ContainerDistributionAPIVersionLookup(VersionLookup):
|
|
|
523
634
|
self.api_stats = APIStatsCounter()
|
|
524
635
|
|
|
525
636
|
def fetch_token(self, registry: str, image_name: str) -> str | None:
|
|
526
|
-
default_host: tuple[str, str, str, str] = (registry, registry, registry, TOKEN_URL_TEMPLATE)
|
|
637
|
+
default_host: tuple[str, str, str, str, None] = (registry, registry, registry, TOKEN_URL_TEMPLATE, None)
|
|
527
638
|
auth_host: str | None = REGISTRIES.get(registry, default_host)[0]
|
|
528
639
|
if auth_host is None:
|
|
529
640
|
return None
|
|
530
641
|
|
|
531
642
|
service: str = REGISTRIES.get(registry, default_host)[2]
|
|
532
|
-
url_template: str = REGISTRIES.get(registry, default_host)[3]
|
|
533
|
-
auth_url: str
|
|
643
|
+
url_template: str | None = REGISTRIES.get(registry, default_host)[3]
|
|
644
|
+
auth_url: str | None = (
|
|
645
|
+
url_template.format(auth_host=auth_host, image_name=image_name, service=service) if url_template else None
|
|
646
|
+
)
|
|
647
|
+
if auth_url is None:
|
|
648
|
+
return None
|
|
534
649
|
response: Response | None = fetch_url(
|
|
535
650
|
auth_url, cache_ttl=self.cfg.token_cache_ttl, follow_redirects=True, api_stats_counter=self.api_stats
|
|
536
651
|
)
|
|
@@ -737,7 +852,7 @@ class ContainerDistributionAPIVersionLookup(VersionLookup):
|
|
|
737
852
|
if index_digest:
|
|
738
853
|
result.image_digest = index_digest
|
|
739
854
|
result.short_digest = result.condense_digest(index_digest)
|
|
740
|
-
log.debug("Setting %s image digest %s", result.name, result.short_digest)
|
|
855
|
+
self.log.debug("Setting %s image digest %s", result.name, result.short_digest)
|
|
741
856
|
|
|
742
857
|
digest: str | None = m.get("digest")
|
|
743
858
|
media_type = m.get("mediaType")
|
|
@@ -757,7 +872,7 @@ class ContainerDistributionAPIVersionLookup(VersionLookup):
|
|
|
757
872
|
self.log.warning("Empty digest for %s %s %s", api_host, digest, media_type)
|
|
758
873
|
else:
|
|
759
874
|
result.repo_digest = result.condense_digest(digest, short=False)
|
|
760
|
-
log.debug("Setting %s repo digest: %s", result.name, result.repo_digest)
|
|
875
|
+
self.log.debug("Setting %s repo digest: %s", result.name, result.repo_digest)
|
|
761
876
|
|
|
762
877
|
if manifest.get("annotations"):
|
|
763
878
|
result.annotations.update(manifest.get("annotations", {}))
|
|
@@ -780,6 +895,7 @@ class ContainerDistributionAPIVersionLookup(VersionLookup):
|
|
|
780
895
|
if config and "Labels" in config:
|
|
781
896
|
result.annotations.update(config.get("Labels") or {})
|
|
782
897
|
result.annotations.update(img_config.get("annotations") or {})
|
|
898
|
+
result.created = config.get("created") or config.get("Created")
|
|
783
899
|
else:
|
|
784
900
|
self.log.debug("No config found: %s", manifest)
|
|
785
901
|
except Exception as e:
|
|
@@ -872,5 +988,6 @@ class DockerClientVersionLookup(VersionLookup):
|
|
|
872
988
|
labels: dict[str, str | float | int | bool | None] = cherrypick_annotations(local_image_info, result)
|
|
873
989
|
result.custom = labels or {}
|
|
874
990
|
result.version = cast("str|None", labels.get("image_version"))
|
|
991
|
+
result.created = cast("str|None", labels.get("image_created"))
|
|
875
992
|
result.origin = "DOCKER_CLIENT"
|
|
876
993
|
return result
|
updates2mqtt/model.py
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import datetime as dt
|
|
1
2
|
import json
|
|
2
3
|
import time
|
|
3
4
|
from abc import abstractmethod
|
|
@@ -6,27 +7,40 @@ from threading import Event
|
|
|
6
7
|
from typing import Any
|
|
7
8
|
|
|
8
9
|
import structlog
|
|
10
|
+
from tzlocal import get_localzone
|
|
9
11
|
|
|
10
12
|
from updates2mqtt.config import NodeConfig, PublishPolicy, UpdatePolicy, VersionPolicy
|
|
11
|
-
from updates2mqtt.helpers import timestamp
|
|
13
|
+
from updates2mqtt.helpers import sanitize_name, timestamp
|
|
12
14
|
|
|
13
15
|
|
|
14
|
-
class
|
|
15
|
-
|
|
16
|
+
class DiscoveryDetail:
|
|
17
|
+
def __init__(self) -> None:
|
|
18
|
+
self.captured: dt.datetime = dt.datetime.now(tz=get_localzone())
|
|
16
19
|
|
|
20
|
+
@abstractmethod
|
|
17
21
|
def as_dict(self) -> dict[str, str | list | dict | bool | int | None]:
|
|
18
22
|
return {}
|
|
19
23
|
|
|
24
|
+
def __str__(self) -> str:
|
|
25
|
+
"""Log friendly"""
|
|
26
|
+
return ",".join(f"{k}:{v}" for k, v in self.as_dict().items())
|
|
27
|
+
|
|
20
28
|
|
|
21
|
-
class
|
|
29
|
+
class DiscoveryArtefactDetail(DiscoveryDetail):
|
|
22
30
|
"""Provider specific detail"""
|
|
23
31
|
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
32
|
+
def __init__(self) -> None:
|
|
33
|
+
super().__init__()
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
class DiscoveryInstallationDetail(DiscoveryDetail):
|
|
37
|
+
"""Provider specific detail"""
|
|
38
|
+
|
|
39
|
+
def __init__(self) -> None:
|
|
40
|
+
super().__init__()
|
|
27
41
|
|
|
28
42
|
|
|
29
|
-
class ReleaseDetail:
|
|
43
|
+
class ReleaseDetail(DiscoveryDetail):
|
|
30
44
|
"""The artefact source details
|
|
31
45
|
|
|
32
46
|
Note this may be an actual software package, or the source details of the wrapping of it
|
|
@@ -34,6 +48,7 @@ class ReleaseDetail:
|
|
|
34
48
|
"""
|
|
35
49
|
|
|
36
50
|
def __init__(self, notes_url: str | None = None, summary: str | None = None) -> None:
|
|
51
|
+
super().__init__()
|
|
37
52
|
self.source_platform: str | None = None
|
|
38
53
|
self.source_repo_url: str | None = None
|
|
39
54
|
self.source_url: str | None = None
|
|
@@ -45,8 +60,9 @@ class ReleaseDetail:
|
|
|
45
60
|
self.summary: str | None = summary
|
|
46
61
|
self.net_score: int | None = None
|
|
47
62
|
|
|
48
|
-
def as_dict(self) -> dict[str, str | None]:
|
|
63
|
+
def as_dict(self) -> dict[str, str | list | dict | bool | int | None]:
|
|
49
64
|
return {
|
|
65
|
+
"captured": self.captured.isoformat(),
|
|
50
66
|
"title": self.title,
|
|
51
67
|
"version": self.version,
|
|
52
68
|
"source_platform": self.source_platform,
|
|
@@ -94,7 +110,7 @@ class Discovery:
|
|
|
94
110
|
self.provider: ReleaseProvider = provider
|
|
95
111
|
self.source_type: str = provider.source_type
|
|
96
112
|
self.session: str = session
|
|
97
|
-
self.name: str = name
|
|
113
|
+
self.name: str = sanitize_name(name)
|
|
98
114
|
self.node: str = node
|
|
99
115
|
self.entity_picture_url: str | None = entity_picture_url
|
|
100
116
|
self.current_version: str | None = current_version
|