HomeCore Controller v0.1.17 staged from HomeCore Admin
This commit is contained in:
parent
56831c699f
commit
a72badc548
@ -1,5 +1,14 @@
|
|||||||
# HomeCore Controller Changelog
|
# 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
|
## 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.
|
- End-to-end validation release for the HomeCore Admin manual per-controller update path using Home Assistant Core's `hassio/update/addon` WebSocket command.
|
||||||
|
|||||||
@ -16,7 +16,7 @@ from cryptography.hazmat.primitives import serialization
|
|||||||
import uvicorn
|
import uvicorn
|
||||||
from updater import start_update_manager
|
from updater import start_update_manager
|
||||||
|
|
||||||
VERSION = "0.1.16"
|
VERSION = "0.1.17"
|
||||||
|
|
||||||
print(
|
print(
|
||||||
"HOMECORE SUPERVISOR: token "
|
"HOMECORE SUPERVISOR: token "
|
||||||
|
|||||||
@ -1,17 +1,23 @@
|
|||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
import asyncio
|
import hashlib
|
||||||
|
import hmac
|
||||||
import json
|
import json
|
||||||
import os
|
import os
|
||||||
import re
|
import re
|
||||||
|
import secrets
|
||||||
import threading
|
import threading
|
||||||
import time
|
import time
|
||||||
|
|
||||||
import httpx
|
import httpx
|
||||||
import websockets
|
|
||||||
|
|
||||||
|
|
||||||
UPDATE_STATE = Path("/config/homecore_update.json")
|
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):
|
def version_tuple(value):
|
||||||
@ -19,166 +25,82 @@ def version_tuple(value):
|
|||||||
return tuple(int(x) for x in parts[:4])
|
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:
|
try:
|
||||||
return json.loads(UPDATE_STATE.read_text())
|
return json.loads(path.read_text())
|
||||||
except Exception:
|
except Exception:
|
||||||
return {}
|
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):
|
def save_update_state(data):
|
||||||
UPDATE_STATE.write_text(json.dumps(data, indent=2))
|
save_json(UPDATE_STATE, data)
|
||||||
|
|
||||||
|
|
||||||
def supervisor_headers():
|
def read_secret():
|
||||||
token = os.getenv("SUPERVISOR_TOKEN")
|
if not SECRET_FILE.exists():
|
||||||
if not token:
|
raise RuntimeError(
|
||||||
raise RuntimeError("SUPERVISOR_TOKEN is not available")
|
"HomeCore Updater is not installed or is not ready"
|
||||||
return {
|
)
|
||||||
"Authorization": "Bearer " + token,
|
|
||||||
"Content-Type": "application/json",
|
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):
|
def sign_body(body):
|
||||||
r = httpx.request(
|
secret = read_secret()
|
||||||
method,
|
return hmac.new(
|
||||||
SUPERVISOR + path,
|
secret,
|
||||||
headers=supervisor_headers(),
|
canonical(body),
|
||||||
json=payload,
|
hashlib.sha256,
|
||||||
timeout=timeout,
|
).hexdigest()
|
||||||
)
|
|
||||||
r.raise_for_status()
|
|
||||||
|
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:
|
try:
|
||||||
body = r.json()
|
expected = sign_body(body)
|
||||||
except Exception:
|
except Exception:
|
||||||
return {}
|
return False
|
||||||
|
|
||||||
if isinstance(body, dict):
|
return hmac.compare_digest(signature, expected)
|
||||||
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/<slug>/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]
|
|
||||||
|
|
||||||
|
|
||||||
class HomeCoreUpdateManager:
|
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
|
This controller never updates itself. HomeCore Admin sends a command to
|
||||||
command addressed to this controller. A command exists only after an
|
this controller, which writes a locally authenticated request for the
|
||||||
administrator presses Install Update for that controller in HomeCore Admin.
|
separate HomeCore Updater app. The Updater performs the Supervisor update.
|
||||||
Failed updates are not retried automatically.
|
|
||||||
"""
|
"""
|
||||||
|
|
||||||
def __init__(
|
def __init__(
|
||||||
@ -252,49 +174,111 @@ class HomeCoreUpdateManager:
|
|||||||
body = r.json()
|
body = r.json()
|
||||||
return body.get("command") if isinstance(body, dict) else None
|
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()
|
st = load_update_state()
|
||||||
request_id = str(st.get("request_id", ""))
|
request_id = str(st.get("request_id", ""))
|
||||||
target = str(st.get("target_version", ""))
|
target = str(st.get("target_version", ""))
|
||||||
|
|
||||||
if (
|
if not request_id or not target:
|
||||||
request_id
|
return False
|
||||||
and target
|
|
||||||
and target == self.current_version
|
result = self.delegated_result()
|
||||||
and st.get("status") == "installing"
|
if str(result.get("request_id", "")) != request_id:
|
||||||
):
|
return False
|
||||||
self.report(
|
|
||||||
"completed",
|
status = str(result.get("status", ""))
|
||||||
request_id,
|
detail = str(result.get("detail", ""))
|
||||||
target,
|
|
||||||
"HomeCore Controller update completed successfully",
|
if status == "failed":
|
||||||
)
|
self.report("failed", request_id, target, detail)
|
||||||
save_update_state(
|
save_update_state(
|
||||||
{
|
{
|
||||||
**st,
|
**st,
|
||||||
"status": "completed",
|
"status": "failed",
|
||||||
"completed_at": int(time.time()),
|
"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):
|
def check_once(self):
|
||||||
|
self.process_result()
|
||||||
|
|
||||||
command = self.update_command()
|
command = self.update_command()
|
||||||
if not command:
|
if not command:
|
||||||
return
|
return
|
||||||
|
|
||||||
request_id = str(command.get("request_id", "")).strip()
|
request_id = str(command.get("request_id", "")).strip()
|
||||||
target = str(command.get("target_version", "")).strip()
|
target = str(command.get("target_version", "")).strip()
|
||||||
|
|
||||||
if not request_id or not target:
|
if not request_id or not target:
|
||||||
raise RuntimeError("HomeCore Cloud returned an incomplete update command")
|
raise RuntimeError("HomeCore Cloud returned an incomplete update command")
|
||||||
|
|
||||||
previous = load_update_state()
|
previous = load_update_state()
|
||||||
if (
|
|
||||||
str(previous.get("request_id", "")) == request_id
|
if str(previous.get("request_id", "")) == request_id:
|
||||||
and previous.get("status") == "installing"
|
status = str(previous.get("status", ""))
|
||||||
):
|
if status in ("delegated", "installing", "completed", "failed"):
|
||||||
# Supervisor already accepted this exact request. Wait for the app
|
# Never execute the same Admin request twice.
|
||||||
# restart rather than ever issuing the update a second time.
|
self.process_result()
|
||||||
return
|
return
|
||||||
|
|
||||||
if target == self.current_version:
|
if target == self.current_version:
|
||||||
self.report(
|
self.report(
|
||||||
@ -328,99 +312,35 @@ class HomeCoreUpdateManager:
|
|||||||
"Manual HomeCore Admin update request received",
|
"Manual HomeCore Admin update request received",
|
||||||
)
|
)
|
||||||
|
|
||||||
supervisor_request("POST", "/store/reload")
|
# This proves the separate updater is installed before we acknowledge
|
||||||
time.sleep(3)
|
# the request as delegated.
|
||||||
|
read_secret()
|
||||||
|
|
||||||
app = find_homecore_app()
|
self.delegate(request_id, target)
|
||||||
slug = str(app.get("slug", ""))
|
|
||||||
# Home Assistant Supervisor reports:
|
save_update_state(
|
||||||
# version = installed app version
|
{
|
||||||
# version_latest = newest version offered by the repository
|
"status": "delegated",
|
||||||
#
|
"request_id": request_id,
|
||||||
# Always compare the Admin target against version_latest first.
|
"target_version": target,
|
||||||
repository_version = str(
|
"delegated_at": int(time.time()),
|
||||||
app.get("version_latest")
|
}
|
||||||
or app.get("version_available")
|
|
||||||
or app.get("version")
|
|
||||||
or ""
|
|
||||||
)
|
)
|
||||||
|
|
||||||
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(
|
self.report(
|
||||||
"installing",
|
"installing",
|
||||||
request_id,
|
request_id,
|
||||||
target,
|
target,
|
||||||
"Backup requested; Home Assistant Core app update starting",
|
"Handed to HomeCore Updater; backup and install pending",
|
||||||
)
|
|
||||||
|
|
||||||
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/<slug>/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,
|
|
||||||
)
|
)
|
||||||
|
|
||||||
def run(self):
|
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:
|
while True:
|
||||||
try:
|
try:
|
||||||
@ -430,8 +350,6 @@ class HomeCoreUpdateManager:
|
|||||||
request_id = str(st.get("request_id", ""))
|
request_id = str(st.get("request_id", ""))
|
||||||
target = str(st.get("target_version", ""))
|
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:
|
if not request_id:
|
||||||
try:
|
try:
|
||||||
cmd = self.update_command() or {}
|
cmd = self.update_command() or {}
|
||||||
@ -451,10 +369,7 @@ class HomeCoreUpdateManager:
|
|||||||
save_update_state(failed_state)
|
save_update_state(failed_state)
|
||||||
self.report("failed", request_id, target, str(exc))
|
self.report("failed", request_id, target, str(exc))
|
||||||
|
|
||||||
# No automatic retry. The cloud suppresses a failed request;
|
time.sleep(10)
|
||||||
# Admin must explicitly create a new request by pressing Install.
|
|
||||||
|
|
||||||
time.sleep(30)
|
|
||||||
|
|
||||||
|
|
||||||
def start_update_manager(
|
def start_update_manager(
|
||||||
|
|||||||
@ -1,5 +1,5 @@
|
|||||||
name: HomeCore Controller
|
name: HomeCore Controller
|
||||||
version: "0.1.16"
|
version: "0.1.17"
|
||||||
slug: homecore_controller
|
slug: homecore_controller
|
||||||
description: HomeCore local controller agent for Home Assistant Green.
|
description: HomeCore local controller agent for Home Assistant Green.
|
||||||
url: https://homecore.home-core.co.uk
|
url: https://homecore.home-core.co.uk
|
||||||
@ -23,3 +23,5 @@ schema:
|
|||||||
map:
|
map:
|
||||||
- type: addon_config
|
- type: addon_config
|
||||||
read_only: false
|
read_only: false
|
||||||
|
- type: share
|
||||||
|
read_only: false
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user