diff --git a/homecore_controller/CHANGELOG.md b/homecore_controller/CHANGELOG.md index 50eabba..2acb7a0 100644 --- a/homecore_controller/CHANGELOG.md +++ b/homecore_controller/CHANGELOG.md @@ -1,5 +1,14 @@ # HomeCore Controller Changelog +## 0.1.17 + +- Introduces the production HomeCore dual-app update architecture. +- HomeCore Controller no longer attempts to update itself. +- Admin update commands are locally authenticated and delegated to the separate HomeCore Updater app through `/share/homecore`. +- HomeCore Updater performs the Supervisor update while HomeCore Controller is replaced. +- The restarted Controller reports the Updater result back to HomeCore Admin. +- Automatic rollout remains disabled; every deployment is still initiated manually for one selected controller. + ## 0.1.16 - End-to-end validation release for the HomeCore Admin manual per-controller update path using Home Assistant Core's `hassio/update/addon` WebSocket command. diff --git a/homecore_controller/app/main.py b/homecore_controller/app/main.py index a34e079..52d092e 100644 --- a/homecore_controller/app/main.py +++ b/homecore_controller/app/main.py @@ -16,7 +16,7 @@ from cryptography.hazmat.primitives import serialization import uvicorn from updater import start_update_manager -VERSION = "0.1.16" +VERSION = "0.1.17" print( "HOMECORE SUPERVISOR: token " diff --git a/homecore_controller/app/updater.py b/homecore_controller/app/updater.py index 63b1269..8e09fb2 100644 --- a/homecore_controller/app/updater.py +++ b/homecore_controller/app/updater.py @@ -1,17 +1,23 @@ from pathlib import Path -import asyncio +import hashlib +import hmac import json import os import re +import secrets import threading import time import httpx -import websockets UPDATE_STATE = Path("/config/homecore_update.json") -SUPERVISOR = "http://supervisor" +SHARE = Path("/share/homecore") +SECRET_FILE = SHARE / "updater-secret" +REQUEST_FILE = SHARE / "update-request.json" +RESULT_FILE = SHARE / "update-result.json" + +SHARE.mkdir(parents=True, exist_ok=True) def version_tuple(value): @@ -19,166 +25,82 @@ def version_tuple(value): return tuple(int(x) for x in parts[:4]) -def load_update_state(): +def canonical(data): + return json.dumps( + data, + sort_keys=True, + separators=(",", ":"), + ).encode() + + +def load_json(path): try: - return json.loads(UPDATE_STATE.read_text()) + return json.loads(path.read_text()) except Exception: return {} +def save_json(path, data): + tmp = path.with_suffix(path.suffix + ".tmp") + tmp.write_text(json.dumps(data, indent=2, sort_keys=True) + "\n") + os.replace(tmp, path) + + +def load_update_state(): + return load_json(UPDATE_STATE) + + def save_update_state(data): - UPDATE_STATE.write_text(json.dumps(data, indent=2)) + save_json(UPDATE_STATE, data) -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 read_secret(): + if not SECRET_FILE.exists(): + raise RuntimeError( + "HomeCore Updater is not installed or is not ready" + ) + + secret = SECRET_FILE.read_text().strip() + if len(secret) < 32: + raise RuntimeError("HomeCore Updater shared secret is invalid") + + return secret.encode() -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() +def sign_body(body): + secret = read_secret() + return hmac.new( + secret, + canonical(body), + hashlib.sha256, + ).hexdigest() + + +def verify_signed(data): + if not isinstance(data, dict): + return False + + signature = str(data.get("signature", "")).strip() + if not signature: + return False + + body = {k: v for k, v in data.items() if k != "signature"} try: - body = r.json() + expected = sign_body(body) except Exception: - return {} + return False - 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 - - -HOME_ASSISTANT_WEBSOCKET = "ws://supervisor/core/websocket" - - -async def _homeassistant_update_app_async(slug, backup=True): - token = os.getenv("SUPERVISOR_TOKEN") - if not token: - raise RuntimeError("SUPERVISOR_TOKEN is not available") - - async with websockets.connect( - HOME_ASSISTANT_WEBSOCKET, - open_timeout=20, - close_timeout=5, - ping_interval=None, - ) as ws: - first = json.loads(await asyncio.wait_for(ws.recv(), timeout=20)) - if first.get("type") != "auth_required": - raise RuntimeError( - "Unexpected Home Assistant WebSocket greeting: " - + str(first.get("type")) - ) - - await ws.send( - json.dumps( - { - "type": "auth", - "access_token": token, - } - ) - ) - - auth = json.loads(await asyncio.wait_for(ws.recv(), timeout=20)) - if auth.get("type") != "auth_ok": - raise RuntimeError( - "Home Assistant WebSocket authentication failed: " - + str(auth.get("message") or auth.get("type")) - ) - - command_id = 1 - await ws.send( - json.dumps( - { - "id": command_id, - "type": "hassio/update/addon", - "addon": slug, - "backup": bool(backup), - } - ) - ) - - result = json.loads(await asyncio.wait_for(ws.recv(), timeout=300)) - - if result.get("id") != command_id: - raise RuntimeError( - "Unexpected Home Assistant update response ID" - ) - - if not result.get("success"): - error = result.get("error") or {} - raise RuntimeError( - str(error.get("message") or error or "Home Assistant update failed") - ) - - -def homeassistant_update_app(slug, backup=True): - """ - Ask Home Assistant Core to update this app. - - Calling Supervisor's /store/addons//update endpoint directly from - the app is blocked because an app cannot update itself. Home Assistant - Core exposes the admin WebSocket command hassio/update/addon specifically - for normal app updates. The Supervisor proxy authenticates this app using - SUPERVISOR_TOKEN. - """ - return asyncio.run( - _homeassistant_update_app_async(slug, backup=backup) - ) - - - -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] + return hmac.compare_digest(signature, expected) class HomeCoreUpdateManager: """ - Manual, per-controller update manager. + Manual, per-controller HomeCore update coordinator. - The manager never asks for a global/latest release. It polls only for a - command addressed to this controller. A command exists only after an - administrator presses Install Update for that controller in HomeCore Admin. - Failed updates are not retried automatically. + This controller never updates itself. HomeCore Admin sends a command to + this controller, which writes a locally authenticated request for the + separate HomeCore Updater app. The Updater performs the Supervisor update. """ def __init__( @@ -252,49 +174,111 @@ class HomeCoreUpdateManager: body = r.json() return body.get("command") if isinstance(body, dict) else None - def mark_completed_if_required(self): + def delegated_result(self): + data = load_json(RESULT_FILE) + if not data or not verify_signed(data): + return {} + return data + + def process_result(self): st = load_update_state() request_id = str(st.get("request_id", "")) target = str(st.get("target_version", "")) - if ( - request_id - and target - and target == self.current_version - and st.get("status") == "installing" - ): - self.report( - "completed", - request_id, - target, - "HomeCore Controller update completed successfully", - ) + if not request_id or not target: + return False + + result = self.delegated_result() + if str(result.get("request_id", "")) != request_id: + return False + + status = str(result.get("status", "")) + detail = str(result.get("detail", "")) + + if status == "failed": + self.report("failed", request_id, target, detail) save_update_state( { **st, - "status": "completed", - "completed_at": int(time.time()), + "status": "failed", + "detail": detail, + "failed_at": int(time.time()), } ) + return True + + if status == "installing": + self.report("installing", request_id, target, detail) + return True + + if status == "completed": + if self.current_version == target: + self.report( + "completed", + request_id, + target, + detail or "HomeCore Controller update completed successfully", + ) + save_update_state( + { + **st, + "status": "completed", + "completed_at": int(time.time()), + } + ) + return True + + # The Updater has completed replacement but this old process may + # still be exiting. The new controller version will report success. + return True + + return False + + def delegate(self, request_id, target): + state = self.load_controller_state() + controller_id = str(state.get("controller_id", "")).strip() + if not controller_id: + raise RuntimeError("Controller is not bound") + + body = { + "schema": 1, + "request_id": request_id, + "target_version": target, + "current_version": self.current_version, + "controller_id": controller_id, + "issued_at": int(time.time()), + "nonce": secrets.token_hex(16), + } + body["signature"] = sign_body(body) + save_json(REQUEST_FILE, body) + + print( + "HOMECORE UPDATE: delegated to HomeCore Updater " + f"request={request_id} target={target}", + flush=True, + ) def check_once(self): + self.process_result() + command = self.update_command() if not command: return request_id = str(command.get("request_id", "")).strip() target = str(command.get("target_version", "")).strip() + if not request_id or not target: raise RuntimeError("HomeCore Cloud returned an incomplete update command") previous = load_update_state() - if ( - str(previous.get("request_id", "")) == request_id - and previous.get("status") == "installing" - ): - # Supervisor already accepted this exact request. Wait for the app - # restart rather than ever issuing the update a second time. - return + + if str(previous.get("request_id", "")) == request_id: + status = str(previous.get("status", "")) + if status in ("delegated", "installing", "completed", "failed"): + # Never execute the same Admin request twice. + self.process_result() + return if target == self.current_version: self.report( @@ -328,99 +312,35 @@ class HomeCoreUpdateManager: "Manual HomeCore Admin update request received", ) - supervisor_request("POST", "/store/reload") - time.sleep(3) + # This proves the separate updater is installed before we acknowledge + # the request as delegated. + read_secret() - app = find_homecore_app() - slug = str(app.get("slug", "")) - # Home Assistant Supervisor reports: - # version = installed app version - # version_latest = newest version offered by the repository - # - # Always compare the Admin target against version_latest first. - repository_version = str( - app.get("version_latest") - or app.get("version_available") - or app.get("version") - or "" + self.delegate(request_id, target) + + save_update_state( + { + "status": "delegated", + "request_id": request_id, + "target_version": target, + "delegated_at": int(time.time()), + } ) - print( - "HOMECORE UPDATE: repository check " - f"installed={app.get('version') or '-'} " - f"latest={app.get('version_latest') or '-'} " - f"target={target}", - flush=True, - ) - - installed_field = app.get("installed") - installed_version = ( - installed_field - if isinstance(installed_field, str) - else self.current_version - ) - - if not slug: - raise RuntimeError("HomeCore App slug could not be determined") - - if repository_version != target: - self.report( - "waiting_repository", - request_id, - target, - "Home Assistant repository currently offers " - + (repository_version or "no version"), - ) - save_update_state( - { - "status": "waiting_repository", - "request_id": request_id, - "target_version": target, - "repository_version": repository_version, - "checked_at": int(time.time()), - } - ) - return - - supervisor_request("GET", f"/store/addons/{slug}/availability") - - state = { - "status": "installing", - "request_id": request_id, - "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", request_id, target, - "Backup requested; Home Assistant Core app update starting", - ) - - print( - "HOMECORE UPDATE: Home Assistant WebSocket update " - f"slug={slug} target={target}", - flush=True, - ) - - # Home Assistant Supervisor blocks an app from calling its own - # /store/addons//update endpoint directly. Route the request - # through Home Assistant Core's admin WebSocket command instead. - # Core then performs the normal Supervisor app update on our behalf. - homeassistant_update_app(slug, backup=True) - - print( - "HOMECORE UPDATE: Home Assistant accepted manual app update", - flush=True, + "Handed to HomeCore Updater; backup and install pending", ) def run(self): - self.mark_completed_if_required() + # If this is the new version after a delegated update, report the + # Updater's completed result back to HomeCore Admin immediately. + try: + self.process_result() + except Exception as exc: + print("HOMECORE UPDATE: startup result check failed:", exc, flush=True) while True: try: @@ -430,8 +350,6 @@ class HomeCoreUpdateManager: request_id = str(st.get("request_id", "")) target = str(st.get("target_version", "")) - # If the failure happened before state was written, attempt to - # recover the active command once for accurate reporting. if not request_id: try: cmd = self.update_command() or {} @@ -451,10 +369,7 @@ class HomeCoreUpdateManager: save_update_state(failed_state) self.report("failed", request_id, target, str(exc)) - # No automatic retry. The cloud suppresses a failed request; - # Admin must explicitly create a new request by pressing Install. - - time.sleep(30) + time.sleep(10) def start_update_manager( diff --git a/homecore_controller/config.yaml b/homecore_controller/config.yaml index ce13eb8..8cc14ff 100644 --- a/homecore_controller/config.yaml +++ b/homecore_controller/config.yaml @@ -1,5 +1,5 @@ name: HomeCore Controller -version: "0.1.16" +version: "0.1.17" slug: homecore_controller description: HomeCore local controller agent for Home Assistant Green. url: https://homecore.home-core.co.uk @@ -23,3 +23,5 @@ schema: map: - type: addon_config read_only: false + - type: share + read_only: false