The monolithic check_updates.py has been split into the update_monitor/ package with separate modules for fetching, extraction, comparison, classification, and notification. This makes the codebase easier to navigate and test. The flat services.yaml is replaced by per-service directories under services/<name>/config.yaml, co-locating config with update scripts and AI review examples. When --auto-update is enabled, services with an auto-update.sh script get their release notes classified by a local Ollama model (using few-shot examples from review-examples/) to decide whether the update can be applied automatically or needs manual admin intervention. Results are reported in a single consolidated Matrix message. Immich is the first service with auto-update configured, using 7 real release note examples to train the classifier.
118 lines
4.5 KiB
Python
118 lines
4.5 KiB
Python
import os
|
|
from pathlib import Path
|
|
from typing import Any, Dict
|
|
|
|
import yaml
|
|
|
|
from .models import ExtractRule, ServiceConfig
|
|
|
|
|
|
def load_yaml(path: str) -> Dict[str, Any]:
|
|
with open(path, "r", encoding="utf-8") as handle:
|
|
return yaml.safe_load(handle) or {}
|
|
|
|
|
|
def load_dotenv(path: str = ".env") -> None:
|
|
if not os.path.exists(path):
|
|
return
|
|
try:
|
|
with open(path, "r", encoding="utf-8") as handle:
|
|
for raw_line in handle:
|
|
line = raw_line.strip()
|
|
if not line or line.startswith("#"):
|
|
continue
|
|
if "=" not in line:
|
|
continue
|
|
key, value = line.split("=", 1)
|
|
key = key.strip()
|
|
value = value.strip().strip('"').strip("'")
|
|
if key:
|
|
os.environ.setdefault(key, value)
|
|
except OSError:
|
|
return
|
|
|
|
|
|
def parse_extract_rule(raw: Any) -> "ExtractRule | None":
|
|
if not raw:
|
|
return None
|
|
rule_type = raw.get("type")
|
|
value = raw.get("value")
|
|
if not rule_type or not value:
|
|
return None
|
|
allowed = {"jsonpath", "jsonpath_join", "regex", "text", "header"}
|
|
if rule_type not in allowed:
|
|
raise ValueError(f"Unsupported extract rule type: {rule_type}")
|
|
return ExtractRule(type=rule_type, value=value)
|
|
|
|
|
|
def load_services(config: Dict[str, Any]) -> Dict[str, ServiceConfig]:
|
|
services = config.get("services")
|
|
if not isinstance(services, list):
|
|
raise ValueError("Config must include a 'services' list")
|
|
|
|
loaded: Dict[str, ServiceConfig] = {}
|
|
for entry in services:
|
|
if not isinstance(entry, dict):
|
|
raise ValueError("Each service entry must be a mapping")
|
|
name = entry.get("name")
|
|
base_url = entry.get("base_url")
|
|
if not name or not base_url:
|
|
raise ValueError("Each service must include name and base_url")
|
|
if not isinstance(name, str) or not isinstance(base_url, str):
|
|
raise ValueError("Service name and base_url must be strings")
|
|
|
|
current_headers = entry.get("current_version_headers")
|
|
upstream_headers = entry.get("upstream_latest_headers")
|
|
if current_headers is not None and not isinstance(current_headers, dict):
|
|
raise ValueError("current_version_headers must be a mapping")
|
|
if upstream_headers is not None and not isinstance(upstream_headers, dict):
|
|
raise ValueError("upstream_latest_headers must be a mapping")
|
|
|
|
current_url = entry.get("current_version_url")
|
|
upstream_url = entry.get("upstream_latest_version_url")
|
|
current_extract = parse_extract_rule(entry.get("current_version_extract"))
|
|
upstream_extract = parse_extract_rule(entry.get("upstream_latest_extract"))
|
|
if current_url and not current_extract:
|
|
raise ValueError(f"Service {name} must define current_version_extract")
|
|
if upstream_url and not upstream_extract:
|
|
raise ValueError(f"Service {name} must define upstream_latest_extract")
|
|
|
|
login = entry.get("login")
|
|
if login is not None and not isinstance(login, dict):
|
|
raise ValueError("login must be a mapping")
|
|
|
|
login_socket = entry.get("login_socket")
|
|
if login_socket is not None and not isinstance(login_socket, dict):
|
|
raise ValueError("login_socket must be a mapping")
|
|
|
|
loaded[name] = ServiceConfig(
|
|
name=name,
|
|
base_url=base_url,
|
|
current_version_url=current_url,
|
|
current_version_extract=current_extract,
|
|
current_version_headers=current_headers,
|
|
upstream_latest_version_url=upstream_url,
|
|
upstream_latest_extract=upstream_extract,
|
|
upstream_latest_headers=upstream_headers,
|
|
notes=entry.get("notes"),
|
|
login=login,
|
|
login_socket=login_socket,
|
|
)
|
|
return loaded
|
|
|
|
|
|
def load_services_dir(path: str) -> Dict[str, ServiceConfig]:
|
|
services_path = Path(path)
|
|
if not services_path.is_dir():
|
|
raise ValueError(f"Services directory not found: {path}")
|
|
loaded: Dict[str, ServiceConfig] = {}
|
|
for config_file in sorted(services_path.glob("*/config.yaml")):
|
|
service_dir = config_file.parent
|
|
try:
|
|
config = load_yaml(str(config_file))
|
|
except Exception as exc:
|
|
raise ValueError(f"Failed to load {config_file}: {exc}") from exc
|
|
for name, svc in load_services({"services": [config]}).items():
|
|
svc.service_dir = service_dir
|
|
loaded[name] = svc
|
|
return loaded
|