homecore-apps/homecore_controller/app/updater.py

393 lines
11 KiB
Python

from pathlib import Path
import hashlib
import hmac
import json
import os
import re
import secrets
import threading
import time
import httpx
UPDATE_STATE = Path("/config/homecore_update.json")
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):
parts = re.findall(r"\d+", str(value))
return tuple(int(x) for x in parts[:4])
def canonical(data):
return json.dumps(
data,
sort_keys=True,
separators=(",", ":"),
).encode()
def load_json(path):
try:
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):
save_json(UPDATE_STATE, data)
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 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:
expected = sign_body(body)
except Exception:
return False
return hmac.compare_digest(signature, expected)
class HomeCoreUpdateManager:
"""
Manual, per-controller HomeCore update coordinator.
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__(
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, request_id="", target_version="", detail=""):
key = (status, request_id, target_version, detail)
if key == self.last_report:
return
self.last_report = key
print(
f"HOMECORE UPDATE: {status} "
f"request={request_id or '-'} "
f"current={self.current_version} "
f"target={target_version or '-'} "
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={
"request_id": request_id,
"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 update_command(self):
state = self.load_controller_state()
controller_id = state.get("controller_id")
if not controller_id:
return None
token = self.controller_session_fn()
r = httpx.get(
self.cloud_api()
+ f"/api/v1/controllers/{controller_id}/update-command",
headers={"Authorization": "Bearer " + token},
timeout=15,
)
r.raise_for_status()
body = r.json()
return body.get("command") if isinstance(body, dict) else None
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 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": "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:
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(
"completed",
request_id,
target,
"Controller already has the requested HomeCore version",
)
save_update_state(
{
"status": "completed",
"request_id": request_id,
"target_version": target,
"completed_at": int(time.time()),
}
)
return
if version_tuple(target) < version_tuple(self.current_version):
raise RuntimeError(
"Automatic downgrade is not supported; controller has "
+ self.current_version
+ " and request targets "
+ target
)
self.report(
"preparing",
request_id,
target,
"Manual HomeCore Admin update request received",
)
# This proves the separate updater is installed before we acknowledge
# the request as delegated.
read_secret()
self.delegate(request_id, target)
save_update_state(
{
"status": "delegated",
"request_id": request_id,
"target_version": target,
"delegated_at": int(time.time()),
}
)
self.report(
"installing",
request_id,
target,
"Handed to HomeCore Updater; backup and install pending",
)
def run(self):
# 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:
self.check_once()
except Exception as exc:
st = load_update_state()
request_id = str(st.get("request_id", ""))
target = str(st.get("target_version", ""))
if not request_id:
try:
cmd = self.update_command() or {}
request_id = str(cmd.get("request_id", ""))
target = str(cmd.get("target_version", ""))
except Exception:
pass
failed_state = {
**st,
"status": "failed",
"request_id": request_id,
"target_version": target,
"detail": str(exc),
"failed_at": int(time.time()),
}
save_update_state(failed_state)
self.report("failed", request_id, target, str(exc))
time.sleep(10)
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()