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:
@ -19,6 +19,8 @@ from backend.auth import (
|
||||
from backend.models import (
|
||||
ActionRequest,
|
||||
ActionResponse,
|
||||
EnrollRequest,
|
||||
EnrollResponse,
|
||||
LoginRequest,
|
||||
LoginResponse,
|
||||
UpdateActionResponse,
|
||||
@ -239,6 +241,24 @@ def update_status(settings: Settings = Depends(get_settings)) -> UpdateStatus:
|
||||
return UpdateStatus(**status_data)
|
||||
|
||||
|
||||
@app.post("/update/enroll", response_model=EnrollResponse, dependencies=[Depends(get_current_admin)])
|
||||
def update_enroll(
|
||||
payload: EnrollRequest | None = Body(default=None),
|
||||
settings: Settings = Depends(get_settings),
|
||||
) -> EnrollResponse:
|
||||
if payload and payload.enroll_token:
|
||||
settings.update_enroll_token = payload.enroll_token
|
||||
try:
|
||||
update.enroll(settings)
|
||||
return EnrollResponse(enrolled=True, message="Enrollment successful")
|
||||
except Exception as exc:
|
||||
logger.exception("Enrollment failed")
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail=f"Enrollment failed: {str(exc)}",
|
||||
) from exc
|
||||
|
||||
|
||||
@app.post("/update/check", response_model=UpdateCheckResponse, dependencies=[Depends(get_current_admin)])
|
||||
def update_check(settings: Settings = Depends(get_settings)) -> UpdateCheckResponse:
|
||||
try:
|
||||
|
||||
@ -41,6 +41,16 @@ class UpdateStatus(BaseModel):
|
||||
last_status: str
|
||||
last_error: Optional[str] = None
|
||||
last_timestamp: Optional[str] = None
|
||||
enrolled: bool = False
|
||||
|
||||
|
||||
class EnrollRequest(BaseModel):
|
||||
enroll_token: Optional[str] = None
|
||||
|
||||
|
||||
class EnrollResponse(BaseModel):
|
||||
enrolled: bool
|
||||
message: str
|
||||
|
||||
|
||||
class UpdateCheckResponse(BaseModel):
|
||||
|
||||
@ -40,7 +40,13 @@ class Settings:
|
||||
self.notify_timeout: int = int(os.getenv("SKD_NOTIFY_TIMEOUT", "5"))
|
||||
self.dry_run: bool = os.getenv("SKD_DRY_RUN", "false").lower() == "true"
|
||||
self.update_url: str = os.getenv("SKD_UPDATE_URL", "https://update.wlkns.org")
|
||||
self.update_token: str = os.getenv("SKD_UPDATE_TOKEN", "")
|
||||
self.update_service_url: str = os.getenv("SKD_UPDATE_SERVICE_URL", "https://update.wlkns.org")
|
||||
self.update_project_id: str = os.getenv("SKD_UPDATE_PROJECT_ID", "safe-kiddo-control")
|
||||
self.update_enroll_token: str = os.getenv("SKD_UPDATE_ENROLL_TOKEN", "")
|
||||
self.update_token_file: str = os.getenv(
|
||||
"SKD_UPDATE_TOKEN_FILE", "/var/lib/skd/update_token"
|
||||
)
|
||||
self.update_token: str = self._load_update_token()
|
||||
self.update_interval: int = int(os.getenv("SKD_UPDATE_INTERVAL", "3600"))
|
||||
self.update_status_url: str = os.getenv(
|
||||
"SKD_UPDATE_STATUS_URL", "https://update.wlkns.org/status"
|
||||
@ -63,6 +69,18 @@ class Settings:
|
||||
def _parse_list(value: str) -> List[str]:
|
||||
return [item for item in (part.strip() for part in value.split(",")) if item]
|
||||
|
||||
def _load_update_token(self) -> str:
|
||||
env_token = os.getenv("SKD_UPDATE_TOKEN", "")
|
||||
if env_token:
|
||||
return env_token
|
||||
if os.path.exists(self.update_token_file):
|
||||
try:
|
||||
with open(self.update_token_file, "r", encoding="utf-8") as f:
|
||||
return f.read().strip()
|
||||
except OSError:
|
||||
pass
|
||||
return ""
|
||||
|
||||
|
||||
@lru_cache(maxsize=1)
|
||||
def get_settings() -> Settings:
|
||||
|
||||
@ -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)
|
||||
|
||||
|
||||
Reference in New Issue
Block a user