feat: Add AI-driven auto-update & restructure into package
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.
This commit is contained in:
parent
f52ff222f0
commit
f55f3d8e91
36 changed files with 2612 additions and 821 deletions
131
update_monitor/__main__.py
Normal file
131
update_monitor/__main__.py
Normal file
|
|
@ -0,0 +1,131 @@
|
|||
import argparse
|
||||
import sys
|
||||
|
||||
from .checker import build_summary, check_service
|
||||
from .classifier import Classifier
|
||||
from .comparator import compare_versions
|
||||
from .loader import load_dotenv, load_services_dir
|
||||
from .notifier import send_matrix_message, send_uptime_kuma_ping
|
||||
from .updater import run_update_script
|
||||
|
||||
|
||||
def main() -> int:
|
||||
parser = argparse.ArgumentParser(description="Check for webservice updates")
|
||||
parser.add_argument("--services", default="services", help="Path to services directory")
|
||||
parser.add_argument("--all", action="store_true", help="Show all services")
|
||||
parser.add_argument("--timeout", type=float, default=10.0, help="HTTP timeout in seconds")
|
||||
parser.add_argument("--user-agent", default="service-update-alerts/1.0", help="HTTP user agent")
|
||||
parser.add_argument("--auto-update", action="store_true", help="Automatically update services when safe")
|
||||
parser.add_argument("--classify-timeout", type=float, default=300.0, help="Timeout for Ollama classification in seconds")
|
||||
parser.add_argument("--script-timeout", type=float, default=300.0, help="Timeout for auto-update scripts in seconds")
|
||||
args = parser.parse_args()
|
||||
|
||||
load_dotenv()
|
||||
|
||||
try:
|
||||
services = load_services_dir(args.services)
|
||||
except Exception as exc:
|
||||
print(f"Failed to load services: {exc}", file=sys.stderr)
|
||||
return 1
|
||||
|
||||
results = [check_service(svc, args.timeout, args.user_agent) for svc in services.values()]
|
||||
|
||||
output_lines = []
|
||||
for result in sorted(results, key=lambda r: r["name"].lower()):
|
||||
comparison = compare_versions(result["current"], result["latest"])
|
||||
has_update = comparison == 1
|
||||
if not args.all and not has_update:
|
||||
continue
|
||||
current = result["current"] or "unknown"
|
||||
latest = result["latest"] or "unknown"
|
||||
notes = []
|
||||
if comparison is None and result["current"] and result["latest"]:
|
||||
notes.append("unparseable")
|
||||
if result["current_error"]:
|
||||
notes.append("current error")
|
||||
if result["latest_error"]:
|
||||
notes.append("latest error")
|
||||
suffix = f" [{' '.join(notes)}]" if notes else ""
|
||||
output_lines.append(f"{result['name']}: {current} -> {latest}{suffix}")
|
||||
|
||||
if output_lines:
|
||||
print("\n".join(output_lines))
|
||||
|
||||
for result in sorted(results, key=lambda r: r["name"].lower()):
|
||||
if result["current_error"]:
|
||||
print(f"{result['name']}: current version error: {result['current_error']}", file=sys.stderr)
|
||||
if result["latest_error"]:
|
||||
print(f"{result['name']}: latest version error: {result['latest_error']}", file=sys.stderr)
|
||||
|
||||
# Build one consolidated Matrix message
|
||||
matrix_sections = []
|
||||
|
||||
if args.auto_update:
|
||||
classifier = Classifier()
|
||||
auto_updated = []
|
||||
manual_needed = []
|
||||
update_pending = []
|
||||
|
||||
for result in results:
|
||||
if compare_versions(result["current"], result["latest"]) != 1:
|
||||
continue
|
||||
service = services[result["name"]]
|
||||
current = result["current"] or "unknown"
|
||||
latest = result["latest"] or "unknown"
|
||||
|
||||
script = service.service_dir / "auto-update.sh" if service.service_dir else None
|
||||
if not script or not script.exists():
|
||||
update_pending.append(f"- {service.name}: {current} -> {latest}")
|
||||
continue
|
||||
|
||||
classification = classifier.classify(
|
||||
service.service_dir,
|
||||
result.get("release_notes"),
|
||||
current,
|
||||
latest,
|
||||
timeout=args.classify_timeout,
|
||||
)
|
||||
if classification["action"] == "auto":
|
||||
script_result = run_update_script(script, current, latest, timeout=args.script_timeout)
|
||||
if script_result["success"]:
|
||||
msg = f"- {service.name}: {current} -> {latest}\n Reason: {classification['reason']}"
|
||||
if script_result["output"]:
|
||||
msg += f"\n Output: {script_result['output']}"
|
||||
auto_updated.append(msg)
|
||||
else:
|
||||
msg = f"- {service.name}: {current} -> {latest}\n Error: {script_result['error']}"
|
||||
manual_needed.append(msg)
|
||||
else:
|
||||
msg = f"- {service.name}: {current} -> {latest}\n Reason: {classification['reason']}"
|
||||
manual_needed.append(msg)
|
||||
|
||||
if auto_updated:
|
||||
matrix_sections.append("Auto-updated:\n" + "\n".join(auto_updated))
|
||||
if manual_needed:
|
||||
matrix_sections.append("Manual update needed:\n" + "\n".join(manual_needed))
|
||||
if update_pending:
|
||||
matrix_sections.append("Updates available (no auto-update configured):\n" + "\n".join(update_pending))
|
||||
else:
|
||||
summary, should_notify = build_summary(results)
|
||||
if should_notify:
|
||||
matrix_sections.append(summary)
|
||||
|
||||
errors = [r for r in results if r["current_error"] or r["latest_error"]]
|
||||
if errors:
|
||||
error_lines = []
|
||||
for r in errors:
|
||||
if r["current_error"]:
|
||||
error_lines.append(f"- {r['name']}: current version error")
|
||||
if r["latest_error"]:
|
||||
error_lines.append(f"- {r['name']}: latest version error")
|
||||
matrix_sections.append("Errors:\n" + "\n".join(error_lines))
|
||||
|
||||
if matrix_sections:
|
||||
send_matrix_message("\n\n".join(matrix_sections), args.timeout)
|
||||
|
||||
send_uptime_kuma_ping(args.timeout)
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
Loading…
Add table
Add a link
Reference in a new issue