implement ota updates
lint / ruff (push) Successful in 14s
tests / desktop (push) Successful in 22s
docker / image (push) Successful in 43s
tests / web (push) Successful in 39s
tests / android-core (push) Successful in 1m1s
tests / backend (push) Successful in 2m12s
android / apk (push) Successful in 6m50s
lint / ruff (push) Successful in 14s
tests / desktop (push) Successful in 22s
docker / image (push) Successful in 43s
tests / web (push) Successful in 39s
tests / android-core (push) Successful in 1m1s
tests / backend (push) Successful in 2m12s
android / apk (push) Successful in 6m50s
This commit is contained in:
25 files changed
+2353
-4
No files matched your search
@@ -8,6 +8,7 @@ from app.api import (
|
||||
routes_settings,
|
||||
routes_tasks,
|
||||
routes_timer,
|
||||
routes_updates,
|
||||
)
|
||||
|
||||
api_router = APIRouter()
|
||||
@@ -16,5 +17,6 @@ api_router.include_router(routes_tasks.router)
|
||||
api_router.include_router(routes_blocker.router)
|
||||
api_router.include_router(routes_reports.router)
|
||||
api_router.include_router(routes_settings.router)
|
||||
api_router.include_router(routes_updates.router)
|
||||
|
||||
__all__ = ["api_router"]
|
||||
@@ -0,0 +1,53 @@
|
||||
"""The Android OTA feed.
|
||||
|
||||
The APK never comes from the phone's own Gitea credentials — the repository is
|
||||
private. The backend reads the newest successful Actions artifact and re-serves
|
||||
it here, behind the same auth as the rest of `/api`.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from fastapi import APIRouter, HTTPException
|
||||
from fastapi.responses import FileResponse
|
||||
|
||||
from app.services import android_updates
|
||||
|
||||
router = APIRouter(prefix="/api/updates", tags=["updates"])
|
||||
|
||||
|
||||
def _raise_http(exc: android_updates.UpdateUnavailable) -> None:
|
||||
if isinstance(exc, android_updates.UpdateNotConfigured):
|
||||
raise HTTPException(status_code=503, detail=str(exc)) from exc
|
||||
if isinstance(exc, android_updates.UpdateNotFound):
|
||||
raise HTTPException(status_code=404, detail=str(exc)) from exc
|
||||
raise HTTPException(status_code=502, detail=str(exc)) from exc
|
||||
|
||||
|
||||
@router.get("/android")
|
||||
def android_update() -> dict:
|
||||
"""Metadata for the newest successful master build.
|
||||
|
||||
The client compares `version_code` with its own; the backend deliberately
|
||||
does not try to know which build the phone is running.
|
||||
"""
|
||||
try:
|
||||
return android_updates.latest_android()
|
||||
except android_updates.UpdateUnavailable as exc:
|
||||
_raise_http(exc)
|
||||
|
||||
|
||||
@router.get("/android/apk")
|
||||
def android_apk() -> FileResponse:
|
||||
"""The APK for the newest successful master build."""
|
||||
try:
|
||||
path, metadata = android_updates.android_apk()
|
||||
except android_updates.UpdateUnavailable as exc:
|
||||
_raise_http(exc)
|
||||
|
||||
version = metadata.get("version_name") or metadata.get("version_code") or "latest"
|
||||
return FileResponse(
|
||||
path,
|
||||
media_type="application/vnd.android.package-archive",
|
||||
filename=f"kadence-{version}.apk",
|
||||
headers={"Cache-Control": "no-store"},
|
||||
)
|
||||
@@ -55,6 +55,30 @@ class EnvSettings(BaseSettings):
|
||||
default=True,
|
||||
description="Run the Telegram command bot when a token is configured.",
|
||||
)
|
||||
|
||||
# ---- Android OTA updates -----------------------------------------------
|
||||
# The Gitea repository is private, so these cannot be read anonymously.
|
||||
# The token is a read-only personal access token; it never leaves the
|
||||
# backend, and the Android client only sees /api/updates/android.
|
||||
gitea_url: str = Field(
|
||||
default="https://git.timka.su",
|
||||
description="Base URL of the Gitea instance that runs the Android workflow.",
|
||||
)
|
||||
gitea_repo: str = Field(
|
||||
default="timka123/kadence",
|
||||
description="owner/repository whose Actions artifacts carry the APK.",
|
||||
)
|
||||
gitea_token: str = Field(
|
||||
default="",
|
||||
description=(
|
||||
"Read-only Gitea token used to read Actions artifacts. Empty disables "
|
||||
"the Android update endpoint."
|
||||
),
|
||||
)
|
||||
gitea_update_branch: str = Field(
|
||||
default="master",
|
||||
description="Only successful builds from this branch are offered as updates.",
|
||||
)
|
||||
api_token: str = Field(
|
||||
default="",
|
||||
description=(
|
||||
|
||||
@@ -0,0 +1,373 @@
|
||||
"""The Android OTA feed, read from this repository's Gitea Actions artifacts.
|
||||
|
||||
The Gitea repository is private, so the phone cannot read Actions artifacts
|
||||
itself. This module is the only place that touches the Gitea token: it finds the
|
||||
newest successful build from the configured branch, reads the small metadata
|
||||
artifact, and pulls the APK out of the matching artifact zip on demand.
|
||||
|
||||
Nothing here trusts the metadata blindly. Every field falls back to what the
|
||||
Gitea run itself reports, so an older artifact that predates `update.json` still
|
||||
produces a usable answer instead of a 500.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import logging
|
||||
import shutil
|
||||
import tempfile
|
||||
import threading
|
||||
import time
|
||||
import zipfile
|
||||
from dataclasses import dataclass
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
import httpx
|
||||
|
||||
from app.config import EnvSettings, env_settings
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
|
||||
METADATA_ARTIFACT = "kadence-update-metadata"
|
||||
APK_ARTIFACT = "kadence-release-apk"
|
||||
|
||||
_METADATA_TTL_SECONDS = 60.0
|
||||
_HTTP_TIMEOUT = 30.0
|
||||
_DOWNLOAD_TIMEOUT = 120.0
|
||||
_MAX_METADATA_BYTES = 1_000_000
|
||||
_CACHE_DIR = Path(tempfile.gettempdir()) / "kadence-updates"
|
||||
|
||||
|
||||
class UpdateUnavailable(RuntimeError):
|
||||
"""The feed cannot be built right now."""
|
||||
|
||||
|
||||
class UpdateNotConfigured(UpdateUnavailable):
|
||||
"""No Gitea token was supplied, so there is nothing to read."""
|
||||
|
||||
|
||||
class UpdateNotFound(UpdateUnavailable):
|
||||
"""There is no successful artifact for the configured branch."""
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class ResolvedUpdate:
|
||||
metadata: dict[str, Any]
|
||||
apk_artifact_id: int
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class Artifact:
|
||||
id: int
|
||||
name: str
|
||||
created_at: str
|
||||
size_bytes: int
|
||||
expired: bool
|
||||
run_id: int
|
||||
run_number: int
|
||||
branch: str
|
||||
commit: str
|
||||
conclusion: str
|
||||
|
||||
|
||||
_lock = threading.Lock()
|
||||
_resolved_cache: tuple[float, ResolvedUpdate] | None = None
|
||||
_apk_cache: dict[int, Path] = {}
|
||||
|
||||
|
||||
def reset_cache() -> None:
|
||||
"""Drop the in-process caches. Tests use this between Gitea responses."""
|
||||
global _resolved_cache
|
||||
with _lock:
|
||||
_resolved_cache = None
|
||||
_apk_cache.clear()
|
||||
|
||||
|
||||
# ---- Gitea reads -----------------------------------------------------------
|
||||
|
||||
|
||||
def _settings() -> EnvSettings:
|
||||
settings = env_settings()
|
||||
if not settings.gitea_token:
|
||||
raise UpdateNotConfigured(
|
||||
"Android updates are not configured: set GITEA_TOKEN on the backend."
|
||||
)
|
||||
if not settings.gitea_repo or "/" not in settings.gitea_repo:
|
||||
raise UpdateNotConfigured("Android updates need GITEA_REPO as owner/repository.")
|
||||
return settings
|
||||
|
||||
|
||||
def _client(settings: EnvSettings, *, timeout: float = _HTTP_TIMEOUT) -> httpx.Client:
|
||||
return httpx.Client(
|
||||
base_url=settings.gitea_url.rstrip("/"),
|
||||
headers={
|
||||
"Authorization": f"token {settings.gitea_token}",
|
||||
"Accept": "application/json",
|
||||
},
|
||||
timeout=timeout,
|
||||
follow_redirects=True,
|
||||
)
|
||||
|
||||
|
||||
def _api(settings: EnvSettings, path: str) -> str:
|
||||
return f"/api/v1/repos/{settings.gitea_repo}{path}"
|
||||
|
||||
|
||||
def _artifact(raw: Any) -> Artifact | None:
|
||||
if not isinstance(raw, dict):
|
||||
return None
|
||||
run = raw.get("workflow_run")
|
||||
run = run if isinstance(run, dict) else {}
|
||||
try:
|
||||
artifact_id = int(raw.get("id") or 0)
|
||||
except (TypeError, ValueError):
|
||||
return None
|
||||
if artifact_id <= 0:
|
||||
return None
|
||||
return Artifact(
|
||||
id=artifact_id,
|
||||
name=str(raw.get("name") or ""),
|
||||
created_at=str(raw.get("created_at") or ""),
|
||||
size_bytes=int(raw.get("size_in_bytes") or 0),
|
||||
expired=bool(raw.get("expired")),
|
||||
run_id=int(run.get("id") or 0),
|
||||
run_number=int(run.get("run_number") or 0),
|
||||
branch=str(run.get("head_branch") or ""),
|
||||
commit=str(run.get("head_sha") or ""),
|
||||
conclusion=str(run.get("conclusion") or ""),
|
||||
)
|
||||
|
||||
|
||||
def _list_artifacts(
|
||||
client: httpx.Client, settings: EnvSettings, name: str
|
||||
) -> list[Artifact]:
|
||||
try:
|
||||
response = client.get(_api(settings, "/actions/artifacts"), params={"name": name})
|
||||
response.raise_for_status()
|
||||
payload = response.json()
|
||||
except (httpx.HTTPError, ValueError) as exc:
|
||||
raise UpdateUnavailable(f"Could not read Gitea artifacts: {exc}") from exc
|
||||
|
||||
entries = payload.get("artifacts") if isinstance(payload, dict) else None
|
||||
if not isinstance(entries, list):
|
||||
return []
|
||||
return [artifact for raw in entries if (artifact := _artifact(raw)) is not None]
|
||||
|
||||
|
||||
def _successful(artifacts: list[Artifact], branch: str) -> list[Artifact]:
|
||||
"""Newest first, and only successful builds from the requested branch."""
|
||||
usable = [
|
||||
artifact
|
||||
for artifact in artifacts
|
||||
if not artifact.expired
|
||||
and artifact.conclusion == "success"
|
||||
and artifact.branch == branch
|
||||
]
|
||||
# `run_number` is the monotonic number Android uses as `versionCode`. The
|
||||
# created_at string is the tie-breaker for older Gitea runs that report 0.
|
||||
return sorted(usable, key=lambda item: (item.run_number, item.created_at), reverse=True)
|
||||
|
||||
|
||||
def _download_artifact(client: httpx.Client, settings: EnvSettings, artifact: Artifact) -> Path:
|
||||
"""Download one artifact zip to a temporary file and return its path."""
|
||||
_CACHE_DIR.mkdir(parents=True, exist_ok=True)
|
||||
target = _CACHE_DIR / f"artifact-{artifact.id}.zip"
|
||||
if target.is_file() and target.stat().st_size > 0:
|
||||
return target
|
||||
|
||||
temporary = target.with_suffix(".zip.part")
|
||||
try:
|
||||
with client.stream(
|
||||
"GET",
|
||||
_api(settings, f"/actions/artifacts/{artifact.id}/zip"),
|
||||
timeout=_DOWNLOAD_TIMEOUT,
|
||||
) as response:
|
||||
response.raise_for_status()
|
||||
with temporary.open("wb") as handle:
|
||||
for chunk in response.iter_bytes():
|
||||
handle.write(chunk)
|
||||
if temporary.stat().st_size == 0:
|
||||
raise UpdateUnavailable("Gitea returned an empty artifact archive.")
|
||||
temporary.replace(target)
|
||||
except (httpx.HTTPError, OSError) as exc:
|
||||
temporary.unlink(missing_ok=True)
|
||||
raise UpdateUnavailable(f"Could not download the Gitea artifact: {exc}") from exc
|
||||
return target
|
||||
|
||||
|
||||
def _member(zf: zipfile.ZipFile, basename: str) -> zipfile.ZipInfo | None:
|
||||
for info in zf.infolist():
|
||||
if not info.is_dir() and Path(info.filename).name == basename:
|
||||
return info
|
||||
return None
|
||||
|
||||
|
||||
def _read_metadata(client: httpx.Client, settings: EnvSettings, artifact: Artifact) -> dict[str, Any]:
|
||||
archive = _download_artifact(client, settings, artifact)
|
||||
try:
|
||||
with zipfile.ZipFile(archive) as zf:
|
||||
info = _member(zf, "update.json")
|
||||
if info is None:
|
||||
raise UpdateUnavailable("The update metadata artifact has no update.json.")
|
||||
if info.file_size > _MAX_METADATA_BYTES:
|
||||
raise UpdateUnavailable("The update metadata artifact is unexpectedly large.")
|
||||
raw = json.loads(zf.read(info).decode("utf-8"))
|
||||
except (zipfile.BadZipFile, OSError, UnicodeDecodeError, ValueError) as exc:
|
||||
raise UpdateUnavailable(f"Could not read the update metadata: {exc}") from exc
|
||||
return raw if isinstance(raw, dict) else {}
|
||||
|
||||
|
||||
def _int(value: Any, fallback: int = 0) -> int:
|
||||
try:
|
||||
return int(value)
|
||||
except (TypeError, ValueError):
|
||||
return fallback
|
||||
|
||||
|
||||
def _metadata_from_artifact(raw: dict[str, Any], artifact: Artifact) -> dict[str, Any]:
|
||||
run_number = artifact.run_number
|
||||
return {
|
||||
"version_code": _int(raw.get("version_code"), run_number),
|
||||
"version_name": str(
|
||||
raw.get("version_name") or f"0.1.0-master.{run_number or artifact.run_id}"
|
||||
),
|
||||
"commit": str(raw.get("commit") or artifact.commit),
|
||||
"built_at": str(raw.get("built_at") or artifact.created_at),
|
||||
"size_bytes": _int(raw.get("size_bytes"), artifact.size_bytes),
|
||||
"sha256": str(raw.get("sha256") or ""),
|
||||
"signer_sha256": str(raw.get("signer_sha256") or ""),
|
||||
"run_number": run_number,
|
||||
}
|
||||
|
||||
|
||||
def _derive_metadata(artifact: Artifact) -> dict[str, Any]:
|
||||
"""For artifacts built before the metadata artifact existed."""
|
||||
return _metadata_from_artifact({}, artifact)
|
||||
|
||||
|
||||
def _apk_for_run(artifacts: list[Artifact], update: Artifact) -> Artifact | None:
|
||||
for artifact in artifacts:
|
||||
if artifact.run_id and artifact.run_id == update.run_id and not artifact.expired:
|
||||
return artifact
|
||||
# Defensive fallback for an instance that omits workflow_run.id on an
|
||||
# artifact but keeps the run number.
|
||||
if update.run_number:
|
||||
for artifact in artifacts:
|
||||
if artifact.run_number == update.run_number and not artifact.expired:
|
||||
return artifact
|
||||
return None
|
||||
|
||||
|
||||
def _resolve(settings: EnvSettings) -> ResolvedUpdate:
|
||||
global _resolved_cache
|
||||
|
||||
now = time.monotonic()
|
||||
with _lock:
|
||||
cached = _resolved_cache
|
||||
if cached is not None and now - cached[0] < _METADATA_TTL_SECONDS:
|
||||
return cached[1]
|
||||
|
||||
with _client(settings) as client:
|
||||
metadata_artifacts = _successful(
|
||||
_list_artifacts(client, settings, METADATA_ARTIFACT),
|
||||
settings.gitea_update_branch,
|
||||
)
|
||||
apk_artifacts = _list_artifacts(client, settings, APK_ARTIFACT)
|
||||
usable_apk = _successful(apk_artifacts, settings.gitea_update_branch)
|
||||
|
||||
resolved: ResolvedUpdate | None = None
|
||||
for artifact in metadata_artifacts:
|
||||
apk = _apk_for_run(usable_apk, artifact)
|
||||
if apk is None:
|
||||
continue
|
||||
resolved = ResolvedUpdate(
|
||||
metadata=_metadata_from_artifact(
|
||||
_read_metadata(client, settings, artifact), artifact
|
||||
),
|
||||
apk_artifact_id=apk.id,
|
||||
)
|
||||
break
|
||||
|
||||
# Before the first build after this feature landed there is no metadata
|
||||
# artifact. The run number is the versionCode, so the newest APK
|
||||
# artifact is still enough to offer an update.
|
||||
if resolved is None and usable_apk:
|
||||
newest = usable_apk[0]
|
||||
resolved = ResolvedUpdate(
|
||||
metadata=_derive_metadata(newest),
|
||||
apk_artifact_id=newest.id,
|
||||
)
|
||||
|
||||
if resolved is None:
|
||||
raise UpdateNotFound(
|
||||
f"No successful {settings.gitea_update_branch} build with an APK was found."
|
||||
)
|
||||
|
||||
with _lock:
|
||||
_resolved_cache = (time.monotonic(), resolved)
|
||||
return resolved
|
||||
|
||||
|
||||
def _apk_file(client: httpx.Client, settings: EnvSettings, artifact_id: int) -> Path:
|
||||
with _lock:
|
||||
cached = _apk_cache.get(artifact_id)
|
||||
if cached is not None and cached.is_file() and cached.stat().st_size > 0:
|
||||
return cached
|
||||
|
||||
# The artifact list does not carry the APK's own record here, so fetch it by
|
||||
# id. The id is enough for both the zip URL and the cache key.
|
||||
artifact = Artifact(
|
||||
id=artifact_id,
|
||||
name=APK_ARTIFACT,
|
||||
created_at="",
|
||||
size_bytes=0,
|
||||
expired=False,
|
||||
run_id=0,
|
||||
run_number=0,
|
||||
branch="",
|
||||
commit="",
|
||||
conclusion="success",
|
||||
)
|
||||
archive = _download_artifact(client, settings, artifact)
|
||||
_CACHE_DIR.mkdir(parents=True, exist_ok=True)
|
||||
target = _CACHE_DIR / f"kadence-{artifact_id}.apk"
|
||||
temporary = target.with_suffix(".apk.part")
|
||||
try:
|
||||
with zipfile.ZipFile(archive) as zf:
|
||||
info = _member(zf, "app-release.apk")
|
||||
if info is None:
|
||||
raise UpdateUnavailable("The APK artifact has no app-release.apk.")
|
||||
with zf.open(info) as source, temporary.open("wb") as destination:
|
||||
shutil.copyfileobj(source, destination)
|
||||
if temporary.stat().st_size == 0:
|
||||
raise UpdateUnavailable("The APK inside the artifact is empty.")
|
||||
temporary.replace(target)
|
||||
except (zipfile.BadZipFile, OSError) as exc:
|
||||
temporary.unlink(missing_ok=True)
|
||||
raise UpdateUnavailable(f"Could not extract the APK: {exc}") from exc
|
||||
except UpdateUnavailable:
|
||||
temporary.unlink(missing_ok=True)
|
||||
raise
|
||||
|
||||
with _lock:
|
||||
_apk_cache[artifact_id] = target
|
||||
return target
|
||||
|
||||
|
||||
# ---- the public feed -------------------------------------------------------
|
||||
|
||||
|
||||
def latest_android() -> dict[str, Any]:
|
||||
"""The newest successful build, as JSON-ready metadata."""
|
||||
settings = _settings()
|
||||
return _resolve(settings).metadata
|
||||
|
||||
|
||||
def android_apk() -> tuple[Path, dict[str, Any]]:
|
||||
"""The APK for the newest build, along with its metadata."""
|
||||
settings = _settings()
|
||||
resolved = _resolve(settings)
|
||||
with _client(settings) as client:
|
||||
path = _apk_file(client, settings, resolved.apk_artifact_id)
|
||||
return path, resolved.metadata
|
||||
Reference in new issue
Block a user