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
« 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
12import docker
13import docker.errors
14import structlog
15from docker.auth import resolve_repository_name
16from docker.models.containers import Container
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
37from .git_utils import git_check_update_available, git_iso_timestamp, git_local_version, git_pull, git_trust
39if typing.TYPE_CHECKING:
40 from docker.models.images import Image, RegistryData
42# distinguish docker build from docker pull?
44log = structlog.get_logger()
47class DockerComposeCommand(Enum):
48 BUILD = "build"
49 UP = "up"
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
56class ContainerCustomization:
57 """Local customization of a Docker container, by label or env var"""
59 label_prefix: str = "updates2mqtt."
60 env_prefix: str = "UPD2MQTT_"
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
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 = {}
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)
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
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()
141 def initialize(self) -> None:
142 for enricher in self.pkg_enrichers:
143 enricher.initialize()
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
154 def fetch(self, discovery: Discovery) -> None:
155 logger = self.log.bind(container=discovery.name, action="fetch")
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
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")
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)
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 )
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
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
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
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 )
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
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
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
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")
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
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
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"))
313 version_policy: VersionPolicy = VersionPolicy.AUTO if not customization.version_policy else customization.version_policy
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
319 platform: str = "Unknown"
320 pkg_info: PackageUpdateInfo = self.default_metadata(image_name, image_ref=image_ref)
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
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 )
339 reg_data: RegistryData | None = None
340 latest_digest: str | None = NO_KNOWN_IMAGE
341 latest_version: str | None = None
343 registry_throttled: bool = self.check_throttle(repo_id)
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
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)
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}")
382 def save_if_set(key: str, val: str | None) -> None:
383 if val is not None:
384 custom[key] = val
386 image_ref = image_ref or ""
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
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"))
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)
426 if annotations:
427 latest_version = annotations.get("org.opencontainers.image.version")
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)
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)
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 )
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 )
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
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}")
482 can_restart: bool = self.cfg.allow_restart and custom.get("compose_path") is not None
484 can_update: bool = False
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
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 )
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
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
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")
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
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)
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
620 def resolve(self, discovery_name: str) -> Discovery | None:
621 return self.discoveries.get(discovery_name)
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")