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
updates2mqtt/app.py
CHANGED
|
@@ -21,7 +21,6 @@ from .mqtt import MqttPublisher
|
|
|
21
21
|
log = structlog.get_logger()
|
|
22
22
|
|
|
23
23
|
CONF_FILE = Path("conf/config.yaml")
|
|
24
|
-
PKG_INFO_FILE = Path("./common_packages.yaml")
|
|
25
24
|
UPDATE_INTERVAL = 60 * 60 * 4
|
|
26
25
|
|
|
27
26
|
# #TODO:
|
|
@@ -56,7 +55,15 @@ class App:
|
|
|
56
55
|
self.scan_count: int = 0
|
|
57
56
|
self.last_scan: str | None = None
|
|
58
57
|
if self.cfg.docker.enabled:
|
|
59
|
-
self.scanners.append(
|
|
58
|
+
self.scanners.append(
|
|
59
|
+
DockerProvider(
|
|
60
|
+
self.cfg.docker,
|
|
61
|
+
self.cfg.node,
|
|
62
|
+
packages=self.cfg.packages,
|
|
63
|
+
github_cfg=self.cfg.github,
|
|
64
|
+
self_bounce=self.self_bounce,
|
|
65
|
+
)
|
|
66
|
+
)
|
|
60
67
|
self.stopped = Event()
|
|
61
68
|
self.healthcheck_topic = self.cfg.node.healthcheck.topic_template.format(node_name=self.cfg.node.name)
|
|
62
69
|
|
|
@@ -71,9 +78,6 @@ class App:
|
|
|
71
78
|
session = uuid.uuid4().hex
|
|
72
79
|
for scanner in self.scanners:
|
|
73
80
|
slog = log.bind(source_type=scanner.source_type, session=session)
|
|
74
|
-
slog.info("Cleaning topics before scan")
|
|
75
|
-
if self.scan_count == 0:
|
|
76
|
-
await self.publisher.clean_topics(scanner, None, force=True)
|
|
77
81
|
if self.stopped.is_set():
|
|
78
82
|
break
|
|
79
83
|
slog.info("Scanning ...")
|
|
@@ -84,7 +88,7 @@ class App:
|
|
|
84
88
|
if self.stopped.is_set():
|
|
85
89
|
slog.debug("Breaking scan loop on stopped event")
|
|
86
90
|
break
|
|
87
|
-
await self.publisher.clean_topics(scanner
|
|
91
|
+
await self.publisher.clean_topics(scanner)
|
|
88
92
|
self.scan_count += 1
|
|
89
93
|
slog.info(f"Scan #{self.scan_count} complete")
|
|
90
94
|
self.last_scan_timestamp = datetime.now(UTC).isoformat()
|
updates2mqtt/cli.py
CHANGED
|
@@ -4,7 +4,7 @@ import structlog
|
|
|
4
4
|
from omegaconf import DictConfig, OmegaConf
|
|
5
5
|
from rich import print_json
|
|
6
6
|
|
|
7
|
-
from updates2mqtt.config import DockerConfig, NodeConfig, RegistryConfig
|
|
7
|
+
from updates2mqtt.config import DockerConfig, GitHubConfig, NodeConfig, RegistryConfig
|
|
8
8
|
from updates2mqtt.helpers import Throttler
|
|
9
9
|
from updates2mqtt.integrations.docker import DockerProvider
|
|
10
10
|
from updates2mqtt.integrations.docker_enrich import (
|
|
@@ -24,18 +24,20 @@ log = structlog.get_logger()
|
|
|
24
24
|
"""
|
|
25
25
|
Super simple CLI
|
|
26
26
|
|
|
27
|
-
|
|
27
|
+
Command can be `container`,`tags`,`manifest` or `blob`
|
|
28
28
|
|
|
29
|
-
|
|
29
|
+
* `container=container-name`
|
|
30
|
+
* `container=hash`
|
|
31
|
+
* `tags=ghcr.io/
|
|
32
|
+
* `blob=mcr.microsoft.com/dotnet/sdk:latest`
|
|
33
|
+
* `tags=quay.io/linuxserver.io/babybuddy`
|
|
34
|
+
* `blob=ghcr.io/blakeblackshear/frigate@sha256:759c36ee869e3e60258350a2e221eae1a4ba1018613e0334f1bc84eb09c4bbbc`
|
|
30
35
|
|
|
31
|
-
|
|
36
|
+
In addition, a `log_level=DEBUG` or other level can be added, `github_token` to try a personal access
|
|
37
|
+
token for GitHub release info retrieval, or `api=docker_client` to use the older API (defaults to `api=OCI_V2`)
|
|
32
38
|
|
|
33
|
-
python3 updates2mqtt/cli.py manifest=ghcr.io/blakeblackshear/frigate:stable
|
|
34
39
|
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
python3 updates2mqtt/cli.py manifest=ghcr.io/blakeblackshear/frigate@sha256:c68fd78fd3237c9ba81b5aa927f17b54f46705990f43b4b5d5596cfbbb626af4
|
|
38
|
-
""" # noqa: E501
|
|
40
|
+
"""
|
|
39
41
|
|
|
40
42
|
OCI_MANIFEST_TYPES: list[str] = [
|
|
41
43
|
"application/vnd.oci.image.manifest.v1+json",
|
|
@@ -91,7 +93,9 @@ ALL_OCI_MEDIA_TYPES: list[str] = (
|
|
|
91
93
|
)
|
|
92
94
|
|
|
93
95
|
|
|
94
|
-
def dump_url(doc_type: str, img_ref: str) -> None:
|
|
96
|
+
def dump_url(doc_type: str, img_ref: str, cli_conf: DictConfig) -> None:
|
|
97
|
+
structlog.configure(wrapper_class=structlog.make_filtering_bound_logger(cli_conf.get("log_level", "WARNING")))
|
|
98
|
+
|
|
95
99
|
lookup = ContainerDistributionAPIVersionLookup(Throttler(), RegistryConfig())
|
|
96
100
|
img_info = DockerImageInfo(img_ref)
|
|
97
101
|
if not img_info.index_name or not img_info.name:
|
|
@@ -110,35 +114,49 @@ def dump_url(doc_type: str, img_ref: str) -> None:
|
|
|
110
114
|
log.warning("No tag or digest found in %s", img_ref)
|
|
111
115
|
return
|
|
112
116
|
url = f"https://{api_host}/v2/{img_info.name}/manifests/{img_info.tag_or_digest}"
|
|
117
|
+
elif doc_type == "tags":
|
|
118
|
+
url = f"https://{api_host}/v2/{img_info.name}/tags/list"
|
|
113
119
|
else:
|
|
114
120
|
return
|
|
115
121
|
|
|
116
122
|
token: str | None = lookup.fetch_token(img_info.index_name, img_info.name)
|
|
117
123
|
|
|
118
124
|
response: Response | None = fetch_url(url, bearer_token=token, follow_redirects=True, response_type=ALL_OCI_MEDIA_TYPES)
|
|
119
|
-
if response:
|
|
125
|
+
if response and response.is_error:
|
|
126
|
+
log.warning(f"{response.status_code}: {url}")
|
|
127
|
+
log.warning(response.text)
|
|
128
|
+
elif response and response.is_success:
|
|
120
129
|
log.debug(f"{response.status_code}: {url}")
|
|
121
130
|
log.debug("HEADERS")
|
|
122
131
|
for k, v in response.headers.items():
|
|
123
132
|
log.debug(f"{k}: {v}")
|
|
124
133
|
log.debug("CONTENTS")
|
|
134
|
+
|
|
125
135
|
print_json(response.text)
|
|
126
136
|
|
|
127
137
|
|
|
128
138
|
def main() -> None:
|
|
129
139
|
# will be a proper cli someday
|
|
130
140
|
cli_conf: DictConfig = OmegaConf.from_cli()
|
|
131
|
-
structlog.configure(wrapper_class=structlog.make_filtering_bound_logger(cli_conf.get("log_level", "WARNING")))
|
|
132
141
|
|
|
133
142
|
if cli_conf.get("blob"):
|
|
134
|
-
dump_url("blob", cli_conf.get("blob"))
|
|
143
|
+
dump_url("blob", cli_conf.get("blob"), cli_conf)
|
|
135
144
|
elif cli_conf.get("manifest"):
|
|
136
|
-
dump_url("manifest", cli_conf.get("manifest"))
|
|
145
|
+
dump_url("manifest", cli_conf.get("manifest"), cli_conf)
|
|
146
|
+
elif cli_conf.get("tags"):
|
|
147
|
+
dump_url("tags", cli_conf.get("tags"), cli_conf)
|
|
137
148
|
|
|
138
149
|
else:
|
|
150
|
+
structlog.configure(wrapper_class=structlog.make_filtering_bound_logger(cli_conf.get("log_level", "INFO")))
|
|
151
|
+
|
|
139
152
|
docker_scanner = DockerProvider(
|
|
140
|
-
DockerConfig(registry=RegistryConfig(api=cli_conf.get("api", "OCI_V2"))),
|
|
153
|
+
DockerConfig(registry=RegistryConfig(api=cli_conf.get("api", "OCI_V2"))),
|
|
154
|
+
NodeConfig(),
|
|
155
|
+
packages={},
|
|
156
|
+
github_cfg=GitHubConfig(access_token=cli_conf.get("github_token")),
|
|
157
|
+
self_bounce=None,
|
|
141
158
|
)
|
|
159
|
+
docker_scanner.initialize()
|
|
142
160
|
discovery: Discovery | None = docker_scanner.rescan(
|
|
143
161
|
Discovery(docker_scanner, cli_conf.get("container", "frigate"), "cli", "manual")
|
|
144
162
|
)
|
updates2mqtt/config.py
CHANGED
|
@@ -67,6 +67,11 @@ class MqttConfig:
|
|
|
67
67
|
protocol: str = "${oc.env:MQTT_VERSION,3.11}"
|
|
68
68
|
|
|
69
69
|
|
|
70
|
+
@dataclass
|
|
71
|
+
class GitHubConfig:
|
|
72
|
+
access_token: str | None = None
|
|
73
|
+
|
|
74
|
+
|
|
70
75
|
@dataclass
|
|
71
76
|
class MetadataSourceConfig:
|
|
72
77
|
enabled: bool = True
|
|
@@ -84,6 +89,21 @@ class VersionPolicy(StrEnum):
|
|
|
84
89
|
VERSION = "VERSION"
|
|
85
90
|
DIGEST = "DIGEST"
|
|
86
91
|
VERSION_DIGEST = "VERSION_DIGEST"
|
|
92
|
+
TIMESTAMP = "TIMESTAMP"
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
@dataclass
|
|
96
|
+
class DockerPackageUpdateInfo:
|
|
97
|
+
image_name: str = MISSING # untagged image ref
|
|
98
|
+
version_policy: VersionPolicy = VersionPolicy.AUTO
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
@dataclass
|
|
102
|
+
class PackageUpdateInfo:
|
|
103
|
+
docker: DockerPackageUpdateInfo | None = field(default_factory=DockerPackageUpdateInfo)
|
|
104
|
+
logo_url: str | None = None
|
|
105
|
+
release_notes_url: str | None = None
|
|
106
|
+
source_repo_url: str | None = None
|
|
87
107
|
|
|
88
108
|
|
|
89
109
|
@dataclass
|
|
@@ -149,25 +169,14 @@ class Config:
|
|
|
149
169
|
mqtt: MqttConfig = field(default_factory=MqttConfig) # pyright: ignore[reportArgumentType, reportCallIssue]
|
|
150
170
|
homeassistant: HomeAssistantConfig = field(default_factory=HomeAssistantConfig)
|
|
151
171
|
docker: DockerConfig = field(default_factory=DockerConfig)
|
|
172
|
+
github: GitHubConfig = field(default_factory=GitHubConfig)
|
|
152
173
|
scan_interval: int = 60 * 60 * 3
|
|
174
|
+
packages: dict[str, PackageUpdateInfo] = field(default_factory=dict)
|
|
153
175
|
|
|
154
176
|
|
|
155
177
|
@dataclass
|
|
156
|
-
class
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
@dataclass
|
|
161
|
-
class PackageUpdateInfo:
|
|
162
|
-
docker: DockerPackageUpdateInfo | None = field(default_factory=DockerPackageUpdateInfo)
|
|
163
|
-
logo_url: str | None = None
|
|
164
|
-
release_notes_url: str | None = None
|
|
165
|
-
source_repo_url: str | None = None
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
@dataclass
|
|
169
|
-
class UpdateInfoConfig:
|
|
170
|
-
common_packages: dict[str, PackageUpdateInfo] = field(default_factory=lambda: {})
|
|
178
|
+
class CommonPackages:
|
|
179
|
+
common_packages: dict[str, PackageUpdateInfo] = field(default_factory=dict)
|
|
171
180
|
|
|
172
181
|
|
|
173
182
|
class IncompleteConfigException(BaseException):
|
|
@@ -188,13 +197,13 @@ def load_app_config(conf_file_path: Path, return_invalid: bool = False) -> Confi
|
|
|
188
197
|
try:
|
|
189
198
|
log.debug(f"Creating config directory {conf_file_path.parent} if not already present")
|
|
190
199
|
conf_file_path.parent.mkdir(parents=True, exist_ok=True)
|
|
191
|
-
except Exception:
|
|
192
|
-
log.warning("Unable to create config directory", path=conf_file_path.parent)
|
|
200
|
+
except Exception as e:
|
|
201
|
+
log.warning("Unable to create config directory: %s", e, path=conf_file_path.parent)
|
|
193
202
|
try:
|
|
194
203
|
conf_file_path.write_text(OmegaConf.to_yaml(base_cfg))
|
|
195
204
|
log.info(f"Auto-generated a new config file at {conf_file_path}")
|
|
196
|
-
except Exception:
|
|
197
|
-
log.warning("Unable to write config file", path=conf_file_path)
|
|
205
|
+
except Exception as e:
|
|
206
|
+
log.warning("Unable to write config file: %s", e, path=conf_file_path)
|
|
198
207
|
cfg = base_cfg
|
|
199
208
|
else:
|
|
200
209
|
cfg = base_cfg
|
updates2mqtt/helpers.py
CHANGED
|
@@ -145,8 +145,8 @@ class APIStats:
|
|
|
145
145
|
"""Log line friendly string summary"""
|
|
146
146
|
return (
|
|
147
147
|
f"fetches: {self.fetches}, cache ratio: {self.hit_ratio():.2%}, revalidated: {self.revalidated}, "
|
|
148
|
-
+ f"errors: {', '.join(f'{status_code}:{fails}' for status_code, fails in self.failed.items())}, "
|
|
149
|
-
+ f"oldest cache hit: {self.max_cache_age:.2f}, avg elapsed: {self.average_elapsed()}"
|
|
148
|
+
+ f"errors: {', '.join(f'{status_code}:{fails}' for status_code, fails in self.failed.items()) or '0'}, "
|
|
149
|
+
+ f"oldest cache hit: {self.max_cache_age:.2f}s, avg elapsed: {self.average_elapsed()}s"
|
|
150
150
|
)
|
|
151
151
|
|
|
152
152
|
|
|
@@ -196,8 +196,9 @@ def fetch_url(
|
|
|
196
196
|
allow_stale=allow_stale,
|
|
197
197
|
)
|
|
198
198
|
)
|
|
199
|
+
log_headers: list[tuple[str, str]] = [h for h in headers if len(h) > 1 and h[0] != "Authorization"]
|
|
199
200
|
with SyncCacheClient(headers=headers, follow_redirects=follow_redirects, policy=cache_policy) as client:
|
|
200
|
-
log.debug(f"Fetching URL {url}, redirects={follow_redirects}, headers={
|
|
201
|
+
log.debug(f"Fetching URL {url}, redirects={follow_redirects}, headers={log_headers}, cache_ttl={cache_ttl}")
|
|
201
202
|
response: Response = client.request(method=method, url=url, extensions={"hishel_ttl": cache_ttl})
|
|
202
203
|
cache_metadata: CacheMetadata = CacheMetadata(response)
|
|
203
204
|
if not response.is_success:
|
|
@@ -221,6 +222,45 @@ def fetch_url(
|
|
|
221
222
|
return None
|
|
222
223
|
|
|
223
224
|
|
|
224
|
-
def validate_url(url: str, cache_ttl: int =
|
|
225
|
+
def validate_url(url: str, cache_ttl: int = 1500) -> bool:
|
|
225
226
|
response: Response | None = fetch_url(url, method="HEAD", cache_ttl=cache_ttl, follow_redirects=True)
|
|
226
227
|
return response is not None and response.status_code != 404
|
|
228
|
+
|
|
229
|
+
|
|
230
|
+
def sanitize_name(name: str, replacement: str = "_", max_len: int = 64) -> str:
|
|
231
|
+
"""Strict sanitization that removes/replaces common problematic characters for MQTT or HA
|
|
232
|
+
|
|
233
|
+
- Replaces spaces with underscores
|
|
234
|
+
- Removes control characters
|
|
235
|
+
- Ensures alphanumeric safety for broader compatibility
|
|
236
|
+
|
|
237
|
+
Args:
|
|
238
|
+
name: The topic component string to sanitize
|
|
239
|
+
replacement: Character to replace invalid characters with (default: "_")
|
|
240
|
+
max_len: Largest acceptable name size
|
|
241
|
+
|
|
242
|
+
Returns:
|
|
243
|
+
Sanitized topic string safe for most MQTT brokers
|
|
244
|
+
|
|
245
|
+
"""
|
|
246
|
+
if not name:
|
|
247
|
+
raise ValueError("Name cannot be empty")
|
|
248
|
+
orig_name: str = name
|
|
249
|
+
name = re.sub(r"[^A-Za-z0-9_\-\.]+", replacement, name)
|
|
250
|
+
|
|
251
|
+
# Replace multiple consecutive replacement chars with single one
|
|
252
|
+
if replacement:
|
|
253
|
+
pattern = re.escape(replacement) + "+"
|
|
254
|
+
name = re.sub(pattern, replacement, name)
|
|
255
|
+
|
|
256
|
+
# Trim to max length
|
|
257
|
+
topic_bytes = name.encode("utf-8")
|
|
258
|
+
if len(topic_bytes) > max_len:
|
|
259
|
+
name = topic_bytes[:max_len].decode("utf-8", errors="ignore")
|
|
260
|
+
|
|
261
|
+
if not name:
|
|
262
|
+
raise ValueError("Topic became empty after sanitization")
|
|
263
|
+
if name != orig_name:
|
|
264
|
+
log.info("Component name %s changed to %s for MQTT/HA compatibility", orig_name, name)
|
|
265
|
+
|
|
266
|
+
return name
|
|
@@ -19,6 +19,7 @@ from updates2mqtt.config import (
|
|
|
19
19
|
UNKNOWN_VERSION,
|
|
20
20
|
VERSION_RE,
|
|
21
21
|
DockerConfig,
|
|
22
|
+
GitHubConfig,
|
|
22
23
|
NodeConfig,
|
|
23
24
|
PackageUpdateInfo,
|
|
24
25
|
PublishPolicy,
|
|
@@ -127,6 +128,8 @@ class DockerProvider(ReleaseProvider):
|
|
|
127
128
|
self,
|
|
128
129
|
cfg: DockerConfig,
|
|
129
130
|
node_cfg: NodeConfig,
|
|
131
|
+
packages: dict[str, PackageUpdateInfo] | None = None,
|
|
132
|
+
github_cfg: GitHubConfig | None = None,
|
|
130
133
|
self_bounce: Event | None = None,
|
|
131
134
|
) -> None:
|
|
132
135
|
super().__init__(node_cfg, "docker")
|
|
@@ -136,8 +139,9 @@ class DockerProvider(ReleaseProvider):
|
|
|
136
139
|
# TODO: refresh discovered packages periodically
|
|
137
140
|
self.throttler = Throttler(self.cfg.default_api_backoff, self.log, self.stopped)
|
|
138
141
|
self.self_bounce: Event | None = self_bounce
|
|
142
|
+
|
|
139
143
|
self.pkg_enrichers: list[PackageEnricher] = [
|
|
140
|
-
CommonPackageEnricher(self.cfg),
|
|
144
|
+
CommonPackageEnricher(self.cfg, packages),
|
|
141
145
|
LinuxServerIOPackageEnricher(self.cfg),
|
|
142
146
|
DefaultPackageEnricher(self.cfg),
|
|
143
147
|
]
|
|
@@ -145,12 +149,13 @@ class DockerProvider(ReleaseProvider):
|
|
|
145
149
|
self.client, self.throttler, self.cfg.registry, self.cfg.default_api_backoff
|
|
146
150
|
)
|
|
147
151
|
self.registry_image_lookup = ContainerDistributionAPIVersionLookup(self.throttler, self.cfg.registry)
|
|
148
|
-
self.release_enricher = SourceReleaseEnricher()
|
|
152
|
+
self.release_enricher = SourceReleaseEnricher(github_cfg)
|
|
149
153
|
self.local_info_builder = LocalContainerInfo()
|
|
150
154
|
|
|
151
155
|
def initialize(self) -> None:
|
|
152
156
|
for enricher in self.pkg_enrichers:
|
|
153
157
|
enricher.initialize()
|
|
158
|
+
self.log.debug("Docker provider initialized")
|
|
154
159
|
|
|
155
160
|
def update(self, discovery: Discovery) -> bool:
|
|
156
161
|
logger: Any = self.log.bind(container=discovery.name, action="update")
|
|
@@ -267,11 +272,11 @@ class DockerProvider(ReleaseProvider):
|
|
|
267
272
|
)
|
|
268
273
|
|
|
269
274
|
def rescan(self, discovery: Discovery) -> Discovery | None:
|
|
270
|
-
logger = self.log.bind(container=discovery.name, action="rescan")
|
|
275
|
+
logger: Any = self.log.bind(container=discovery.name, action="rescan")
|
|
271
276
|
try:
|
|
272
277
|
c: Container = self.client.containers.get(discovery.name)
|
|
273
278
|
if c:
|
|
274
|
-
rediscovery = self.analyze(c, discovery.session, previous_discovery=discovery)
|
|
279
|
+
rediscovery: Discovery | None = self.analyze(c, discovery.session, previous_discovery=discovery)
|
|
275
280
|
if rediscovery and not rediscovery.throttled:
|
|
276
281
|
self.discoveries[rediscovery.name] = rediscovery
|
|
277
282
|
return rediscovery
|
|
@@ -296,9 +301,7 @@ class DockerProvider(ReleaseProvider):
|
|
|
296
301
|
if customization.ignore:
|
|
297
302
|
logger.info("Container ignored due to UPD2MQTT_IGNORE setting")
|
|
298
303
|
return None
|
|
299
|
-
|
|
300
|
-
self.cfg.version_policy if not customization.version_policy else customization.version_policy
|
|
301
|
-
)
|
|
304
|
+
|
|
302
305
|
if customization.update == UpdatePolicy.AUTO:
|
|
303
306
|
logger.debug("Auto update policy detected")
|
|
304
307
|
update_policy: UpdatePolicy = customization.update or UpdatePolicy.PASSIVE
|
|
@@ -308,6 +311,20 @@ class DockerProvider(ReleaseProvider):
|
|
|
308
311
|
local_info, service_info = self.local_info_builder.build_image_info(c)
|
|
309
312
|
pkg_info: PackageUpdateInfo = self.default_metadata(local_info)
|
|
310
313
|
|
|
314
|
+
version_policy: VersionPolicy
|
|
315
|
+
if customization.version_policy:
|
|
316
|
+
logger.debug("Overriding version_policy to local customization: %s", customization.version_policy)
|
|
317
|
+
version_policy = customization.version_policy
|
|
318
|
+
else:
|
|
319
|
+
if self.cfg.version_policy == VersionPolicy.AUTO and pkg_info.docker:
|
|
320
|
+
logger.debug(
|
|
321
|
+
"Version policy, pkg level %s, config level: %s", pkg_info.docker.version_policy, self.cfg.version_policy
|
|
322
|
+
)
|
|
323
|
+
version_policy = pkg_info.docker.version_policy or self.cfg.version_policy
|
|
324
|
+
else:
|
|
325
|
+
logger.debug("Version policy, fixed config level: %s", self.cfg.version_policy)
|
|
326
|
+
version_policy = self.cfg.version_policy
|
|
327
|
+
|
|
311
328
|
try:
|
|
312
329
|
service_info.git_repo_path = customization.git_repo_path
|
|
313
330
|
|
|
@@ -516,7 +533,7 @@ class DockerProvider(ReleaseProvider):
|
|
|
516
533
|
|
|
517
534
|
def default_metadata(self, image_info: DockerImageInfo) -> PackageUpdateInfo:
|
|
518
535
|
for enricher in self.pkg_enrichers:
|
|
519
|
-
pkg_info = enricher.enrich(image_info)
|
|
536
|
+
pkg_info: PackageUpdateInfo | None = enricher.enrich(image_info)
|
|
520
537
|
if pkg_info is not None:
|
|
521
538
|
return pkg_info
|
|
522
539
|
raise ValueError("No enricher could provide metadata, not even default enricher")
|
|
@@ -583,30 +600,41 @@ def select_versions(version_policy: VersionPolicy, installed: DockerImageInfo, l
|
|
|
583
600
|
basis("version-digest"),
|
|
584
601
|
)
|
|
585
602
|
|
|
603
|
+
if (
|
|
604
|
+
version_policy == VersionPolicy.TIMESTAMP
|
|
605
|
+
and installed.created
|
|
606
|
+
and latest.created
|
|
607
|
+
and (
|
|
608
|
+
(latest.created > installed.created and latest.short_digest != installed.short_digest)
|
|
609
|
+
or (latest.created == installed.created and latest.short_digest == installed.short_digest)
|
|
610
|
+
)
|
|
611
|
+
):
|
|
612
|
+
return installed.created, latest.created, basis("timestamp")
|
|
613
|
+
|
|
586
614
|
phase = 1
|
|
587
615
|
if version_policy == VersionPolicy.AUTO and (
|
|
588
616
|
(installed.version == latest.version and installed.short_digest == latest.short_digest)
|
|
589
617
|
or (installed.version != latest.version and installed.short_digest != latest.short_digest)
|
|
590
618
|
):
|
|
591
|
-
# detect semver, or
|
|
619
|
+
# detect semver, or v semver (e.g. v1.030)
|
|
592
620
|
# only use this if both version and digest are consistently agreeing or disagreeing
|
|
593
621
|
# if the strict conditions work, people see nice version numbers on screen rather than hashes
|
|
594
622
|
if (
|
|
595
623
|
installed.version
|
|
596
|
-
and re.
|
|
624
|
+
and re.fullmatch(SEMVER_RE, installed.version or "")
|
|
597
625
|
and latest.version
|
|
598
|
-
and re.
|
|
626
|
+
and re.fullmatch(SEMVER_RE, latest.version or "")
|
|
599
627
|
):
|
|
600
628
|
# Smells like semver, override if not using version_policy
|
|
601
629
|
return installed.version, latest.version, basis("semver")
|
|
602
630
|
if (
|
|
603
631
|
installed.version
|
|
604
|
-
and re.
|
|
632
|
+
and re.fullmatch(VERSION_RE, installed.version or "")
|
|
605
633
|
and latest.version
|
|
606
|
-
and re.
|
|
634
|
+
and re.fullmatch(VERSION_RE, latest.version or "")
|
|
607
635
|
):
|
|
608
636
|
# Smells like casual semver, override if not using version_policy
|
|
609
|
-
return installed.version, latest.version, basis("
|
|
637
|
+
return installed.version, latest.version, basis("casualver")
|
|
610
638
|
|
|
611
639
|
# AUTO or fallback
|
|
612
640
|
phase = 2
|
|
@@ -629,6 +657,15 @@ def select_versions(version_policy: VersionPolicy, installed: DockerImageInfo, l
|
|
|
629
657
|
|
|
630
658
|
# Fall back to digests, image or repo index
|
|
631
659
|
phase = 4
|
|
660
|
+
if (
|
|
661
|
+
installed.created
|
|
662
|
+
and latest.created
|
|
663
|
+
and (
|
|
664
|
+
(latest.created > installed.created and latest.short_digest != installed.short_digest)
|
|
665
|
+
or (latest.created == installed.created and latest.short_digest == installed.short_digest)
|
|
666
|
+
)
|
|
667
|
+
):
|
|
668
|
+
return installed.created, latest.created, basis("timestamp")
|
|
632
669
|
if installed_digest_available and latest_digest_available:
|
|
633
670
|
return installed.short_digest, latest.short_digest, basis("digest") # type: ignore[return-value]
|
|
634
671
|
if installed.version and not latest.version and not latest.short_digest and not latest.repo_digest:
|
|
@@ -655,7 +692,8 @@ def select_versions(version_policy: VersionPolicy, installed: DockerImageInfo, l
|
|
|
655
692
|
return condense_repo_id(latest), condense_repo_id(latest), basis("repo-digest")
|
|
656
693
|
|
|
657
694
|
if installed_digest_available and not latest_digest_available:
|
|
658
|
-
|
|
695
|
+
# no new digest, so latest is the current
|
|
696
|
+
return installed.short_digest, installed.short_digest, basis("digest") # type: ignore[return-value]
|
|
659
697
|
|
|
660
698
|
log.warn("No versions can be determined for %s", installed.ref)
|
|
661
699
|
phase = 999
|