Coverage for src/updates2mqtt/config.py: 92%
186 statements
« prev ^ index » next coverage.py v7.15.1, created at 2026-07-20 20:21 +0000
« prev ^ index » next coverage.py v7.15.1, created at 2026-07-20 20:21 +0000
1import os
2import ssl
3import typing
4from dataclasses import dataclass, field
5from enum import StrEnum
6from pathlib import Path
8import structlog
9from omegaconf import MISSING, DictConfig, MissingMandatoryValue, OmegaConf, ValidationError
11log = structlog.get_logger()
13PKG_INFO_FILE = Path("./common_packages.yaml")
14UNKNOWN_VERSION = "UNKNOWN"
15VERSION_RE = r"[vVr]?[0-9]+(\.[0-9]+)*"
16# source: https://semver.org/#is-there-a-suggested-regular-expression-regex-to-check-a-semver-string
17SEMVER_RE = r"^(?P<major>0|[1-9]\d*)\.(?P<minor>0|[1-9]\d*)\.(?P<patch>0|[1-9]\d*)(?:-(?P<prerelease>(?:0|[1-9]\d*|\d*[a-zA-Z-][0-9a-zA-Z-]*)(?:\.(?:0|[1-9]\d*|\d*[a-zA-Z-][0-9a-zA-Z-]*))*))?(?:\+(?P<buildmetadata>[0-9a-zA-Z-]+(?:\.[0-9a-zA-Z-]+)*))?$" # noqa: E501
19SOURCE_PLATFORM_GITHUB = "GitHub"
20SOURCE_PLATFORM_CODEBERG = "CodeBerg"
21SOURCE_PLATFORM_GITLAB = "GitLab"
22SOURCE_PLATFORM_SOURCEHUT = "SourceHut"
23SOURCE_PLATFORMS: dict[str, str] = {
24 SOURCE_PLATFORM_GITHUB: r"https://github.com/.*",
25 SOURCE_PLATFORM_GITLAB: r"https://gitlab.com/.*",
26 SOURCE_PLATFORM_CODEBERG: r"https://codeberg.org/.*",
27 SOURCE_PLATFORM_SOURCEHUT: r"https://sr.ht/.*",
28}
31class UpdatePolicy(StrEnum):
32 AUTO = "Auto"
33 PASSIVE = "Passive"
36class PublishPolicy(StrEnum):
37 HOMEASSISTANT = "HomeAssistant"
38 MQTT = "MQTT"
39 SILENT = "Silent"
42class LogLevel(StrEnum):
43 DEBUG = "DEBUG"
44 INFO = "INFO"
45 WARNING = "WARNING"
46 ERROR = "ERROR"
47 CRITICAL = "CRITICAL"
50class RegistryAPI(StrEnum):
51 OCI_V2 = "OCI_V2"
52 OCI_V2_MINIMAL = "OCI_V2"
53 DOCKER_CLIENT = "DOCKER_CLIENT"
54 DISABLED = "DISABLED"
57class VersionType:
58 SHORT_SHA = "short_sha"
59 FULL_SHA = "full_sha"
60 VERSION_REVISION = "version_revision"
61 VERSION = "version"
64@dataclass
65class RegistryConfig:
66 api: RegistryAPI = RegistryAPI.OCI_V2
67 mutable_cache_ttl: int | None = None # default to server cache hint
68 immutable_cache_ttl: int | None = 7776000 # 90 days
69 token_cache_ttl: int | None = None # default to server cache hint
72@dataclass
73class MqttConfig:
74 host: str = "${oc.env:MQTT_HOST,localhost}"
75 user: str = f"${{oc.env:MQTT_USER,{MISSING}}}"
76 password: str | None = "${oc.env:MQTT_PASS,''}" # noqa: S105
77 port: int = "${oc.decode:${oc.env:MQTT_PORT,1883}}" # type: ignore[assignment]
78 topic_root: str = "updates2mqtt"
79 protocol: str = "${oc.env:MQTT_VERSION,3.11}"
80 connect_timeout: float = 20
81 keepalive: int = 30
82 ca_certs: str | None = "${oc.env:MQTT_CA_CERTS,''}"
83 client_cert: str | None = "${oc.env:MQTT_CLIENT_CERT,''}"
84 client_key: str | None = "${oc.env:MQTT_CLIENT_KEY,''}"
85 cert_reqs: ssl.VerifyMode = ssl.CERT_REQUIRED
88@dataclass
89class GitHubConfig:
90 access_token: str | None = None
91 mutable_cache_ttl: int = 60 * 60 * 15
94@dataclass
95class MetadataSourceConfig:
96 enabled: bool = True
97 cache_ttl: int = 60 * 60 * 24 * 7 # 1 week
100@dataclass
101class Selector:
102 include: list[str] | None = None
103 exclude: list[str] | None = None
106class VersionPolicy(StrEnum):
107 AUTO = "AUTO"
108 VERSION = "VERSION"
109 DIGEST = "DIGEST"
110 VERSION_DIGEST = "VERSION_DIGEST"
111 TIMESTAMP = "TIMESTAMP"
114@dataclass
115class DockerPackageUpdateInfo:
116 image_name: typing.Any = MISSING # untagged image ref, either a single string or a list of strings
117 version_policy: VersionPolicy = VersionPolicy.AUTO
120def docker_image_names(docker_info: DockerPackageUpdateInfo) -> list[str]:
121 if isinstance(docker_info.image_name, str):
122 return [docker_info.image_name]
123 return list(docker_info.image_name)
126@dataclass
127class PackageUpdateInfo:
128 docker: DockerPackageUpdateInfo | None = field(default_factory=DockerPackageUpdateInfo)
129 logo_url: str | None = None
130 release_notes_url: str | None = None
131 source_repo_url: str | None = None
134@dataclass
135class DockerConfig:
136 enabled: bool = True
137 allow_pull: bool = True
138 allow_restart: bool = True
139 allow_build: bool = True
140 compose_version: str = "v2"
141 default_entity_picture_url: str = "https://www.docker.com/wp-content/uploads/2022/03/Moby-logo.png"
142 # Icon to show when browsing entities in Home Assistant
143 device_icon: str = "mdi:docker"
144 discover_metadata: dict[str, MetadataSourceConfig] = field(
145 default_factory=lambda: {"linuxserver.io": MetadataSourceConfig(enabled=True)}
146 )
147 registry: RegistryConfig = field(default_factory=lambda: RegistryConfig())
148 default_api_backoff: int = 60 * 15
149 image_ref_select: Selector = field(default_factory=lambda: Selector())
150 version_select: Selector = field(default_factory=lambda: Selector())
151 version_policy: VersionPolicy = VersionPolicy.AUTO
152 registry_select: Selector = field(default_factory=lambda: Selector())
155@dataclass
156class HomeAssistantDiscoveryConfig:
157 prefix: str = "homeassistant"
158 enabled: bool = True
161@dataclass
162class HomeAssistantConfig:
163 discovery: HomeAssistantDiscoveryConfig = field(default_factory=HomeAssistantDiscoveryConfig)
164 state_topic_suffix: str = "state"
165 device_creation: bool = True
166 force_command_topic: bool = False
167 extra_attributes: bool = True
168 area: str | None = None
169 release_summary_max_size: int = 6144
172@dataclass
173class HealthCheckConfig:
174 enabled: bool = True
175 interval: int = 300 # Interval in seconds to publish heartbeat message, 0 to disable
176 topic_template: str = "healthcheck/{node_name}/updates2mqtt"
179@dataclass
180class NodeConfig:
181 name: str = field(default_factory=lambda: os.getenv("NODE_NAME", os.uname().nodename.replace(".local", "")))
182 git_path: str = "/usr/bin/git"
183 healthcheck: HealthCheckConfig = field(default_factory=HealthCheckConfig)
186@dataclass
187class LogConfig:
188 level: LogLevel = "${oc.decode:${oc.env:U2M_LOG_LEVEL,INFO}}" # type: ignore[assignment] # pyright: ignore[reportAssignmentType]
189 json: bool = field(default=False, doc="Use JSON structured logging for non-interactive running")
192@dataclass
193class Config:
194 log: LogConfig = field(default_factory=LogConfig) # pyright: ignore[reportArgumentType, reportCallIssue]
195 node: NodeConfig = field(default_factory=NodeConfig)
196 mqtt: MqttConfig = field(default_factory=MqttConfig) # pyright: ignore[reportArgumentType, reportCallIssue]
197 homeassistant: HomeAssistantConfig = field(default_factory=HomeAssistantConfig)
198 docker: DockerConfig = field(default_factory=DockerConfig)
199 github: GitHubConfig = field(default_factory=GitHubConfig)
200 scan_interval: int = 60 * 60 * 3
201 packages: dict[str, PackageUpdateInfo] = field(default_factory=dict)
204@dataclass
205class CommonPackages:
206 common_packages: dict[str, PackageUpdateInfo] = field(default_factory=dict)
209class IncompleteConfigException(BaseException):
210 pass
213def is_autogen_config() -> bool:
214 env_var: str | None = os.environ.get("U2M_AUTOGEN_CONFIG")
215 return not (env_var and env_var.lower() in ("no", "0", "false"))
218def load_app_config(conf_file_path: Path, return_invalid: bool = False) -> Config | None:
219 base_cfg: DictConfig = OmegaConf.structured(Config)
220 if conf_file_path.exists():
221 cfg: DictConfig = typing.cast("DictConfig", OmegaConf.merge(base_cfg, OmegaConf.load(conf_file_path)))
222 elif is_autogen_config():
223 if not conf_file_path.parent.exists(): 223 ↛ 224line 223 didn't jump to line 224 because the condition on line 223 was never true
224 try:
225 log.debug(f"Creating config directory {conf_file_path.parent} if not already present")
226 conf_file_path.parent.mkdir(parents=True, exist_ok=True)
227 except Exception as e:
228 log.warning("Unable to create config directory: %s", e, path=conf_file_path.parent)
229 try:
230 conf_file_path.write_text(OmegaConf.to_yaml(base_cfg))
231 log.info(f"Auto-generated a new config file at {conf_file_path}")
232 except Exception as e:
233 log.warning("Unable to write config file: %s", e, path=conf_file_path)
234 cfg = base_cfg
235 else:
236 cfg = base_cfg
238 try:
239 # Validate that all required fields are present, throw exception now rather than when config first used
240 OmegaConf.to_container(cfg, throw_on_missing=True)
241 OmegaConf.set_readonly(cfg, True)
242 config: Config = typing.cast("Config", cfg)
244 if config.mqtt.user in ("", MISSING):
245 log.info("The config has place holders for MQTT user")
246 if not return_invalid: 246 ↛ 247line 246 didn't jump to line 247 because the condition on line 246 was never true
247 return None
248 return config
249 except (MissingMandatoryValue, ValidationError) as e:
250 log.error("Configuration error %s", e, path=conf_file_path.as_posix())
251 if return_invalid and cfg is not None:
252 return typing.cast("Config", cfg)
253 raise