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
374 lines
13 KiB
Python
374 lines
13 KiB
Python
"""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
|