Files
kiddo/backend/update.py
stephan b6d81885de fix: reload update token from file to bypass stale cache (v0.3.3)
- Implemented _get_fresh_token in backend/update.py
- Ensures scripts receive the current token even if settings are cached
- Bumped version to 0.3.3
2026-01-16 12:07:52 +01:00

302 lines
9.1 KiB
Python

import json
import os
import subprocess
from datetime import datetime, timezone
from pathlib import Path
from typing import Any, Dict, List
import httpx
from backend.settings import Settings
def _project_root() -> Path:
return Path(__file__).resolve().parents[1]
def _read_version() -> str:
try:
return (_project_root() / "VERSION").read_text(encoding="utf-8").strip()
except OSError:
return "unknown"
def _status_path(settings: Settings) -> Path:
return Path(settings.update_status_file)
def _log_path(settings: Settings) -> Path:
return Path(settings.update_log_file)
def _ensure_parent(path: Path) -> None:
path.parent.mkdir(parents=True, exist_ok=True)
def _write_status(settings: Settings, status: str, version: str, error: str | None = None) -> None:
status_path = _status_path(settings)
_ensure_parent(status_path)
payload = {
"device_id": os.uname().nodename,
"version": version,
"status": status,
"error": error or "",
"timestamp": datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ"),
}
status_path.write_text(json.dumps(payload), encoding="utf-8")
_append_log(settings, payload)
def _append_log(settings: Settings, payload: Dict[str, Any]) -> None:
log_path = _log_path(settings)
_ensure_parent(log_path)
with log_path.open("a", encoding="utf-8") as handle:
handle.write(json.dumps(payload) + "\n")
def get_status(settings: Settings) -> Dict[str, Any]:
current_version = _read_version()
status_path = _status_path(settings)
if status_path.exists():
try:
data = json.loads(status_path.read_text(encoding="utf-8"))
except json.JSONDecodeError:
data = {}
else:
data = {}
return {
"current_version": current_version,
"last_status": data.get("status", "unknown"),
"last_error": data.get("error") or None,
"last_timestamp": data.get("timestamp"),
"enrolled": bool(settings.update_token),
}
def enroll(settings: Settings) -> str:
if not settings.update_enroll_token:
raise ValueError("No enrollment token provided in settings")
enroll_url = f"{settings.update_service_url}/v1/enroll"
payload = {
"project_id": settings.update_project_id,
"client_id": os.uname().nodename,
"software_id": "safe-kiddo",
"enroll_token": settings.update_enroll_token,
}
with httpx.Client(timeout=10.0) as client:
response = client.post(enroll_url, json=payload)
response.raise_for_status()
data = response.json()
token = data.get("token")
if not token:
raise ValueError("Enrollment response did not contain a token")
# Save token
token_path = Path(settings.update_token_file)
_ensure_parent(token_path)
token_path.write_text(token, encoding="utf-8")
# Update settings object for immediate use
settings.update_token = token
return token
def _parse_version(value: str) -> List[int]:
return [int(part) for part in value.split(".")]
def _get_fresh_token(settings: Settings) -> str:
"""Always reload token from file/env to avoid stale cache."""
# Env var takes precedence
env_token = os.getenv("SKD_UPDATE_TOKEN", "")
if env_token:
return env_token
# Fallback to file
token_file = Path(settings.update_token_file)
if token_file.exists():
try:
return token_file.read_text(encoding="utf-8").strip()
except OSError:
pass
return settings.update_token # Fallback to cached value
def check_update(settings: Settings) -> Dict[str, Any]:
token = _get_fresh_token(settings)
if not token:
raise ValueError("Client is not enrolled (missing update token)")
headers = {"Authorization": f"Bearer {token}"}
manifest_url = (
f"{settings.update_service_url}/v1/projects/{settings.update_project_id}/manifest"
)
with httpx.Client(timeout=10.0) as client:
response = client.get(manifest_url, headers=headers)
response.raise_for_status()
manifest = response.json()
latest_version = manifest.get("version", "")
artifact_url = manifest.get("artifact_url", "")
sha256 = manifest.get("sha256", "")
message = manifest.get("message")
available = False
current_version = _read_version()
try:
available = _parse_version(latest_version) > _parse_version(current_version)
except ValueError:
if latest_version and latest_version != current_version:
available = True
return {
"available": available,
"latest_version": latest_version,
"artifact_url": artifact_url,
"sha256": sha256,
"message": message,
}
def report_status(
settings: Settings,
status: str,
version: str,
error: str | None = None,
duration_ms: int | None = None,
) -> None:
token = _get_fresh_token(settings)
if not token:
return
report_url = (
f"{settings.update_service_url}/v1/projects/{settings.update_project_id}/status"
)
payload = {
"project_id": settings.update_project_id,
"version": version,
"status": status,
"timestamp": datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ"),
"client_id": os.uname().nodename,
}
if error:
payload["error_code"] = error
payload["reason"] = error
if duration_ms is not None:
payload["duration_ms"] = duration_ms
try:
headers = {"Authorization": f"Bearer {token}"}
with httpx.Client(timeout=10.0) as client:
client.post(report_url, json=payload, headers=headers).raise_for_status()
except Exception:
# We don't want to crash if status reporting fails
pass
def _run_async(script_path: Path, settings: Settings) -> None:
env = os.environ.copy()
env["SKD_UPDATE_SERVICE_URL"] = settings.update_service_url
env["SKD_UPDATE_PROJECT_ID"] = settings.update_project_id
env["SKD_UPDATE_TOKEN"] = _get_fresh_token(settings)
env["SKD_UPDATE_STATUS_FILE"] = settings.update_status_file
env["SKD_UPDATE_LOG_FILE"] = settings.update_log_file
# Use systemd-run to detach the update process from the current service unit.
# This ensures the script survives 'systemctl stop skd'.
# We use --unit to give it a predictable name prefix (though unique suffix is added)
# and --scope (or --service) to create a new unit.
# Since we need root (and likely run as root), this should work.
# Note: --collect ensures garbage collection of the transient unit.
cmd = [
"systemd-run",
"--unit=skd-update",
"--collect",
"--description=Safe Kiddo Update Process",
str(script_path),
]
subprocess.Popen(
cmd,
env=env,
cwd="/",
stdout=subprocess.DEVNULL,
stderr=subprocess.DEVNULL,
)
def start_update(settings: Settings, version: str | None = None) -> None:
current_version = version or _read_version()
_write_status(settings, "in_progress", current_version)
report_status(settings, "in_progress", current_version)
script = _project_root() / "scripts" / "update_client.sh"
_run_async(script, settings)
def start_rollback(settings: Settings) -> None:
current_version = _read_version()
_write_status(settings, "in_progress", current_version)
report_status(settings, "in_progress", current_version)
script = _project_root() / "scripts" / "rollback_client.sh"
_run_async(script, settings)
def get_logs(settings: Settings, limit: int = 200) -> List[Dict[str, Any]]:
log_path = _log_path(settings)
if not log_path.exists():
return []
lines = log_path.read_text(encoding="utf-8").splitlines()
entries: List[Dict[str, Any]] = []
for line in lines[-limit:]:
try:
entries.append(json.loads(line))
except json.JSONDecodeError:
continue
return entries
def get_service_status(settings: Settings) -> Dict[str, Any]:
base_url = settings.update_service_url.rstrip("/")
if not base_url:
return {
"url": "",
"reachable": False,
"status_code": None,
"error": "update service url not configured",
"checked_url": "",
"environment": "unknown",
}
env = "prod"
lowered = base_url.lower()
if "://dev." in lowered or lowered.startswith("dev."):
env = "dev"
elif "://staging." in lowered or lowered.startswith("staging."):
env = "staging"
check_url = base_url
try:
with httpx.Client(timeout=3.0) as client:
response = client.get(check_url)
return {
"url": base_url,
"reachable": True,
"status_code": response.status_code,
"error": None,
"checked_url": check_url,
"environment": env,
}
except Exception as exc:
return {
"url": base_url,
"reachable": False,
"status_code": None,
"error": str(exc),
"checked_url": check_url,
"environment": env,
}