diff --git a/homecore_controller/CHANGELOG.md b/homecore_controller/CHANGELOG.md index f5cbcc7..6dbdc8e 100644 --- a/homecore_controller/CHANGELOG.md +++ b/homecore_controller/CHANGELOG.md @@ -1,5 +1,19 @@ # HomeCore Controller Changelog +## 0.1.2 + +- HomeCore Admin is now the authority for production controller updates. +- Controllers poll only for Admin-approved HomeCore releases. +- Unpublished releases never install. +- Home Assistant App repository is refreshed automatically. +- The controller verifies that the repository version exactly matches the Admin-approved version. +- Supervisor creates a backup before HomeCore updates. +- Supervisor installs the approved HomeCore Controller release. +- Successful updates report back to HomeCore Admin. +- Failed updates report back to HomeCore Admin. +- Automatic retry protection prevents update loops. +- Home Assistant customer-facing update controls are no longer required for normal HomeCore updates. + ## 0.1.1 - Controller setup can no longer create properties. diff --git a/homecore_controller/app/main.py b/homecore_controller/app/main.py index f891160..73f066e 100644 --- a/homecore_controller/app/main.py +++ b/homecore_controller/app/main.py @@ -14,8 +14,9 @@ from fastapi.responses import HTMLResponse from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PrivateKey from cryptography.hazmat.primitives import serialization import uvicorn +from updater import start_update_manager -VERSION = "0.1.1" +VERSION = "0.1.2" DATA = Path("/config") DATA.mkdir(parents=True, exist_ok=True) @@ -474,6 +475,13 @@ threading.Thread( daemon=True, ).start() +start_update_manager( + current_version=VERSION, + cloud_api_fn=options, + load_controller_state=load_state, + controller_session_fn=controller_session, +) + if __name__ == "__main__": uvicorn.run( app, diff --git a/homecore_controller/app/updater.py b/homecore_controller/app/updater.py new file mode 100644 index 0000000..e11936c --- /dev/null +++ b/homecore_controller/app/updater.py @@ -0,0 +1,409 @@ +from pathlib import Path +import json +import os +import re +import threading +import time + +import httpx + + +UPDATE_STATE = Path("/config/homecore_update.json") + +SUPERVISOR = "http://supervisor" + + +def version_tuple(value): + parts = re.findall(r"\d+", str(value)) + return tuple(int(x) for x in parts[:4]) + + +def load_update_state(): + try: + return json.loads(UPDATE_STATE.read_text()) + except Exception: + return {} + + +def save_update_state(data): + UPDATE_STATE.write_text( + json.dumps(data, indent=2) + ) + + +def supervisor_headers(): + token = os.getenv("SUPERVISOR_TOKEN") + + if not token: + raise RuntimeError( + "SUPERVISOR_TOKEN is not available" + ) + + return { + "Authorization": "Bearer " + token, + "Content-Type": "application/json", + } + + +def supervisor_request(method, path, payload=None, timeout=90): + r = httpx.request( + method, + SUPERVISOR + path, + headers=supervisor_headers(), + json=payload, + timeout=timeout, + ) + + r.raise_for_status() + + try: + body = r.json() + except Exception: + return {} + + if isinstance(body, dict): + if body.get("result") == "error": + raise RuntimeError( + body.get("message") or "Supervisor error" + ) + + if "data" in body: + return body["data"] + + return body + + +def find_homecore_app(): + data = supervisor_request( + "GET", + "/store/addons", + ) + + if isinstance(data, dict): + apps = data.get("addons", []) + else: + apps = data or [] + + candidates = [] + + for app in apps: + slug = str(app.get("slug", "")) + name = str(app.get("name", "")) + + if ( + name == "HomeCore Controller" + or slug == "homecore_controller" + or slug.endswith("_homecore_controller") + ): + candidates.append(app) + + if not candidates: + raise RuntimeError( + "HomeCore Controller was not found in the Home Assistant App store" + ) + + installed = [ + x for x in candidates + if x.get("installed") + ] + + return (installed or candidates)[0] + + +class HomeCoreUpdateManager: + + def __init__( + self, + current_version, + cloud_api_fn, + load_controller_state, + controller_session_fn, + ): + self.current_version = current_version + self.cloud_api_fn = cloud_api_fn + self.load_controller_state = load_controller_state + self.controller_session_fn = controller_session_fn + + self.last_report = None + + + def cloud_api(self): + return self.cloud_api_fn()["cloud_api"].rstrip("/") + + + def report( + self, + status, + target_version="", + detail="", + ): + key = ( + status, + target_version, + detail, + ) + + if key == self.last_report: + return + + self.last_report = key + + print( + f"HOMECORE UPDATE: {status} " + f"current={self.current_version} " + f"target={target_version} " + f"{detail}", + flush=True, + ) + + state = self.load_controller_state() + + controller_id = state.get("controller_id") + + if not controller_id: + return + + try: + token = self.controller_session_fn() + + httpx.post( + self.cloud_api() + + f"/api/v1/controllers/{controller_id}/update-status", + headers={ + "Authorization": "Bearer " + token + }, + json={ + "status": status, + "target_version": target_version, + "current_version": self.current_version, + "detail": detail, + }, + timeout=15, + ).raise_for_status() + + except Exception as exc: + print( + "HOMECORE UPDATE: status report failed:", + exc, + flush=True, + ) + + + def desired_release(self): + r = httpx.get( + self.cloud_api() + + "/api/v1/releases/latest", + params={ + "channel": "stable", + }, + timeout=15, + ) + + if r.status_code == 404: + return None + + r.raise_for_status() + + return r.json() + + + def mark_completed_if_required(self): + st = load_update_state() + + target = st.get("target_version", "") + + if ( + target + and target == self.current_version + and st.get("status") == "installing" + ): + self.report( + "completed", + target, + "HomeCore Controller update completed successfully", + ) + + save_update_state( + { + "status": "completed", + "target_version": target, + "completed_at": int(time.time()), + } + ) + + + def check_once(self): + + desired = self.desired_release() + + if not desired: + return + + target = str( + desired.get("version", "") + ).strip() + + if not target: + return + + if version_tuple(target) <= version_tuple( + self.current_version + ): + self.report( + "up_to_date", + target, + "Controller matches the approved HomeCore release", + ) + return + + previous = load_update_state() + + if ( + previous.get("target_version") == target + and previous.get("status") in ( + "installing", + "failed", + ) + ): + attempted = int( + previous.get("attempted_at", 0) + ) + + # Avoid update loops. + if time.time() - attempted < 600: + return + + self.report( + "checking", + target, + "Approved HomeCore release detected", + ) + + supervisor_request( + "POST", + "/store/reload", + ) + + time.sleep(3) + + app = find_homecore_app() + + slug = app.get("slug") + + repository_version = str( + app.get("version") + or app.get("version_latest") + or "" + ) + + installed_version = str( + app.get("installed") + or self.current_version + ) + + if not slug: + raise RuntimeError( + "HomeCore App slug could not be determined" + ) + + if repository_version != target: + self.report( + "waiting_repository", + target, + "Home Assistant repository currently offers " + + (repository_version or "no version"), + ) + return + + supervisor_request( + "GET", + f"/store/addons/{slug}/availability", + ) + + state = { + "status": "installing", + "target_version": target, + "installed_version": installed_version, + "repository_version": repository_version, + "slug": slug, + "attempted_at": int(time.time()), + } + + save_update_state(state) + + self.report( + "installing", + target, + "Backup requested; Supervisor update starting", + ) + + supervisor_request( + "POST", + f"/store/addons/{slug}/update", + { + "backup": True, + "background": True, + }, + ) + + print( + "HOMECORE UPDATE: Supervisor accepted update job", + flush=True, + ) + + + def run(self): + + self.mark_completed_if_required() + + while True: + + try: + self.check_once() + + except Exception as exc: + + st = load_update_state() + + target = st.get( + "target_version", + "", + ) + + save_update_state( + { + **st, + "status": "failed", + "target_version": target, + "detail": str(exc), + "attempted_at": int(time.time()), + } + ) + + self.report( + "failed", + target, + str(exc), + ) + + time.sleep(60) + + +def start_update_manager( + current_version, + cloud_api_fn, + load_controller_state, + controller_session_fn, +): + manager = HomeCoreUpdateManager( + current_version=current_version, + cloud_api_fn=cloud_api_fn, + load_controller_state=load_controller_state, + controller_session_fn=controller_session_fn, + ) + + threading.Thread( + target=manager.run, + daemon=True, + name="homecore-update-manager", + ).start() diff --git a/homecore_controller/config.yaml b/homecore_controller/config.yaml index a8f56ad..97ef257 100644 --- a/homecore_controller/config.yaml +++ b/homecore_controller/config.yaml @@ -1,5 +1,5 @@ name: HomeCore Controller -version: "0.1.1" +version: "0.1.2" slug: homecore_controller description: HomeCore local controller agent for Home Assistant Green. url: https://homecore.home-core.co.uk @@ -7,7 +7,7 @@ init: false arch: [aarch64] homeassistant_api: true hassio_api: true -hassio_role: homeassistant +hassio_role: manager ingress: true ingress_port: 8099 panel_icon: mdi:home-automation