feat: implement update-service v1 migration and enrollment flow

- added /update/enroll endpoint and enrollment logic
- migrated update client to v1 api endpoints and bearer auth
- implemented remote status reporting in backend and scripts
- updated requirements and project status
This commit is contained in:
2025-12-31 00:05:46 +01:00
parent fde2825112
commit 47290d2d8f
41 changed files with 1384 additions and 19 deletions

View File

@ -70,20 +70,56 @@ def get_status(settings: Settings) -> Dict[str, Any]:
"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 check_update(settings: Settings) -> Dict[str, Any]:
headers = {}
if settings.update_token:
headers["Authorization"] = f"Bearer {settings.update_token}"
if not settings.update_token:
raise ValueError("Client is not enrolled (missing update token)")
headers = {"Authorization": f"Bearer {settings.update_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(settings.update_url, headers=headers)
response = client.get(manifest_url, headers=headers)
response.raise_for_status()
manifest = response.json()
@ -109,21 +145,65 @@ def check_update(settings: Settings) -> Dict[str, Any]:
}
def report_status(
settings: Settings,
status: str,
version: str,
error: str | None = None,
duration_ms: int | None = None,
) -> None:
if not settings.update_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 {settings.update_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"] = settings.update_token
env["SKD_UPDATE_STATUS_FILE"] = settings.update_status_file
env["SKD_UPDATE_LOG_FILE"] = settings.update_log_file
subprocess.Popen([str(script_path)], env=env, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
subprocess.Popen(
[str(script_path)], env=env, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL
)
def start_update(settings: Settings, version: str | None = None) -> None:
_write_status(settings, "in_progress", version or _read_version())
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:
_write_status(settings, "in_progress", _read_version())
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)