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

This commit is contained in:
timka123 committed 2026-10-22 21:11:04 +03:00
1 parent 61529be465
commit 1bdd65cc76
25 files changed
+2353 -4

No files matched your search

+373
View File
@@ -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