Files
kmtnflower/backend/app/api/mobile_builds.py
T

641 lines
22 KiB
Python

import asyncio
import hashlib
import os
import shlex
import subprocess
import uuid
from datetime import datetime
from pathlib import Path
from typing import Literal
from fastapi import APIRouter, Depends, File, HTTPException, Query, UploadFile, status
from fastapi.responses import FileResponse
from sqlalchemy import delete, select
from sqlalchemy.ext.asyncio import AsyncSession
from app.auth import get_current_admin_user
from app.config import settings
from app.database import async_session, get_session
from app.models import MobileBuildArtifact, MobileBuildRequest, MobileBuildUploadedFile, StationConfig
from app.schemas import (
MobileBuildArtifactResponse,
MobileBuildCreate,
MobileBuildDefaultsResponse,
MobileBuildPreflightResponse,
MobileBuildRequestResponse,
MobileBuildUploadedFileResponse,
)
from app.user_models import User
router = APIRouter()
_ALLOWED_KINDS = {
"android": {"keystore", "descriptor"},
"ios": {"certificate", "profile"},
}
_RUNNING_TASKS: set[int] = set()
def _utc_iso(value: datetime | None) -> str | None:
if value is None:
return None
return value.replace(microsecond=0).isoformat() + "Z"
def _hash_file(path: Path) -> str:
h = hashlib.sha256()
with path.open("rb") as f:
for chunk in iter(lambda: f.read(1024 * 1024), b""):
h.update(chunk)
return h.hexdigest()
def _ensure_dirs() -> tuple[Path, Path]:
upload_dir = Path(settings.MOBILE_BUILD_UPLOAD_DIR)
artifact_dir = Path(settings.MOBILE_BUILD_ARTIFACT_DIR)
upload_dir.mkdir(parents=True, exist_ok=True)
artifact_dir.mkdir(parents=True, exist_ok=True)
return upload_dir, artifact_dir
def _check_mobileprovision_for_carplay(path: Path) -> bool:
try:
raw = path.read_bytes()
except Exception:
return False
text = raw.decode("latin-1", errors="ignore").lower()
return "carplay" in text
def _build_ssh_prefix(host: str) -> list[str]:
if not host:
raise RuntimeError("Build host is not configured")
return [
"sshpass",
"-p",
settings.MOBILE_BUILD_SSH_PASSWORD,
"ssh",
"-p",
str(settings.MOBILE_BUILD_SSH_PORT),
"-o",
"StrictHostKeyChecking=no",
f"{settings.MOBILE_BUILD_SSH_USER}@{host}",
]
def _build_scp_prefix(host: str) -> list[str]:
if not host:
raise RuntimeError("Build host is not configured")
return [
"sshpass",
"-p",
settings.MOBILE_BUILD_SSH_PASSWORD,
"scp",
"-P",
str(settings.MOBILE_BUILD_SSH_PORT),
"-o",
"StrictHostKeyChecking=no",
]
def _run_sync_command(cmd: list[str]) -> tuple[int, str]:
result = subprocess.run(cmd, capture_output=True, text=True)
output = (result.stdout or "") + ("\n" + result.stderr if result.stderr else "")
return result.returncode, output.strip()
async def _run_command(cmd: list[str]) -> tuple[int, str]:
return await asyncio.to_thread(_run_sync_command, cmd)
async def _append_log(session: AsyncSession, request: MobileBuildRequest, line: str) -> None:
if request.build_log:
request.build_log += "\n"
request.build_log += line
request.updated_at = datetime.utcnow()
await session.commit()
async def _load_files(session: AsyncSession, request_id: int) -> list[MobileBuildUploadedFile]:
result = await session.execute(
select(MobileBuildUploadedFile).where(MobileBuildUploadedFile.request_id == request_id)
)
return list(result.scalars().all())
def _preflight_issues(request: MobileBuildRequest, files: list[MobileBuildUploadedFile]) -> list[str]:
issues: list[str] = []
if not request.platform_android and not request.platform_ios:
issues.append("Select at least one platform.")
if request.platform_android and not request.android_application_id:
issues.append("Android application ID is required when Android is selected.")
if request.platform_ios and not request.ios_bundle_id:
issues.append("iOS bundle ID is required when iOS is selected.")
if not request.version_name.strip():
issues.append("Version name is required.")
if not request.build_number.strip():
issues.append("Build number is required.")
if request.platform_android and not settings.MOBILE_BUILD_ANDROID_HOST:
issues.append("Android build host is not configured.")
if request.platform_ios and not settings.MOBILE_BUILD_IOS_HOST:
issues.append("iOS build host is not configured.")
grouped: dict[str, dict[str, MobileBuildUploadedFile]] = {"android": {}, "ios": {}}
for f in files:
grouped.setdefault(f.platform, {})[f.file_kind] = f
if request.platform_android:
present = set(grouped.get("android", {}).keys())
expected = _ALLOWED_KINDS["android"]
if present != expected:
missing = sorted(expected - present)
extra = sorted(present - expected)
if missing:
issues.append(f"Android requires files for kinds: {', '.join(missing)}")
if extra:
issues.append(f"Android has unsupported file kinds: {', '.join(extra)}")
if request.platform_ios:
present = set(grouped.get("ios", {}).keys())
expected = _ALLOWED_KINDS["ios"]
if present != expected:
missing = sorted(expected - present)
extra = sorted(present - expected)
if missing:
issues.append(f"iOS requires files for kinds: {', '.join(missing)}")
if extra:
issues.append(f"iOS has unsupported file kinds: {', '.join(extra)}")
profile = grouped.get("ios", {}).get("profile")
if profile:
profile_path = Path(profile.stored_path)
if not _check_mobileprovision_for_carplay(profile_path):
issues.append("iOS provisioning profile does not appear to include CarPlay entitlement text.")
return issues
def _to_file_response(row: MobileBuildUploadedFile) -> MobileBuildUploadedFileResponse:
return MobileBuildUploadedFileResponse(
id=row.id,
request_id=row.request_id,
platform=row.platform,
file_kind=row.file_kind,
original_name=row.original_name,
size=row.size,
uploaded_at=_utc_iso(row.uploaded_at) or "",
)
def _to_artifact_response(row: MobileBuildArtifact) -> MobileBuildArtifactResponse:
return MobileBuildArtifactResponse(
id=row.id,
request_id=row.request_id,
platform=row.platform,
artifact_type=row.artifact_type,
file_name=row.file_name,
sha256=row.sha256,
size=row.size,
created_at=_utc_iso(row.created_at) or "",
download_url=f"/api/mobile-builds/artifacts/{row.id}/download",
)
async def _hydrate_request(session: AsyncSession, request: MobileBuildRequest) -> MobileBuildRequestResponse:
files_result = await session.execute(
select(MobileBuildUploadedFile).where(MobileBuildUploadedFile.request_id == request.id)
)
artifact_result = await session.execute(
select(MobileBuildArtifact).where(MobileBuildArtifact.request_id == request.id)
)
files = list(files_result.scalars().all())
artifacts = list(artifact_result.scalars().all())
return MobileBuildRequestResponse(
id=request.id,
tenant_key=request.tenant_key,
platform_android=request.platform_android,
platform_ios=request.platform_ios,
android_application_id=request.android_application_id,
ios_bundle_id=request.ios_bundle_id,
version_name=request.version_name,
build_number=request.build_number,
release_profile=request.release_profile,
status=request.status,
preflight_passed=request.preflight_passed,
preflight_report=request.preflight_report,
build_log=request.build_log,
error_message=request.error_message,
created_at=_utc_iso(request.created_at) or "",
updated_at=_utc_iso(request.updated_at) or "",
started_at=_utc_iso(request.started_at),
completed_at=_utc_iso(request.completed_at),
files=[_to_file_response(f) for f in files],
artifacts=[_to_artifact_response(a) for a in artifacts],
)
async def _copy_worker_artifact(
session: AsyncSession,
request: MobileBuildRequest,
platform: Literal["android", "ios"],
host: str,
artifact_glob: str,
) -> None:
_, artifact_dir = _ensure_dirs()
local_dir = artifact_dir / f"request_{request.id}" / platform
local_dir.mkdir(parents=True, exist_ok=True)
ssh_prefix = _build_ssh_prefix(host)
list_cmd = ssh_prefix + [f"ls -1t {shlex.quote(artifact_glob)} 2>/dev/null | head -n 1"]
code, out = await _run_command(list_cmd)
if code != 0 or not out:
raise RuntimeError(f"No {platform} artifact found using glob {artifact_glob}")
remote_path = out.splitlines()[-1].strip()
if not remote_path:
raise RuntimeError(f"No {platform} artifact path returned by worker")
file_name = Path(remote_path).name
local_path = local_dir / file_name
scp_cmd = _build_scp_prefix(host) + [
f"{settings.MOBILE_BUILD_SSH_USER}@{host}:{remote_path}",
str(local_path),
]
code, scp_out = await _run_command(scp_cmd)
if code != 0:
raise RuntimeError(f"Failed to copy {platform} artifact: {scp_out}")
artifact = MobileBuildArtifact(
request_id=request.id,
platform=platform,
artifact_type="aab" if platform == "android" else "ipa",
file_name=file_name,
file_path=str(local_path),
sha256=_hash_file(local_path),
size=local_path.stat().st_size,
)
session.add(artifact)
await session.commit()
async def _upload_files_to_worker(
files: list[MobileBuildUploadedFile], host: str
) -> None:
scp_prefix = _build_scp_prefix(host)
remote_target = f"{settings.MOBILE_BUILD_SSH_USER}@{host}:{settings.MOBILE_BUILD_REMOTE_APP_DIR}/"
for row in files:
local_path = Path(row.stored_path)
if not local_path.exists():
raise RuntimeError(f"Uploaded file missing: {row.original_name}")
cmd = scp_prefix + [str(local_path), remote_target]
code, out = await _run_command(cmd)
if code != 0:
raise RuntimeError(f"Failed to upload file {row.original_name}: {out}")
async def _upload_theme_to_worker(
session: AsyncSession,
request: MobileBuildRequest,
host: str,
) -> None:
"""SCP theme.json to the build worker (no-op if file doesn't exist)."""
theme_path = Path(settings.THEME_JSON_PATH)
if not theme_path.exists():
await _append_log(session, request, "[theme] theme.json not found — skipping upload")
return
scp_prefix = _build_scp_prefix(host)
remote_target = f"{settings.MOBILE_BUILD_SSH_USER}@{host}:{settings.MOBILE_BUILD_REMOTE_APP_DIR}/theme.json"
code, out = await _run_command(scp_prefix + [str(theme_path), remote_target])
if code != 0:
raise RuntimeError(f"Failed to upload theme.json: {out}")
await _append_log(session, request, "[theme] theme.json uploaded to worker")
async def _run_platform_build(
session: AsyncSession,
request: MobileBuildRequest,
platform: Literal["android", "ios"],
host: str,
script_name: str,
) -> None:
files = await _load_files(session, request.id)
platform_files = [f for f in files if f.platform == platform]
await _append_log(session, request, f"[{platform}] Uploading {len(platform_files)} file(s) to worker")
await _upload_files_to_worker(platform_files, host)
# Upload theme.json to worker
await _upload_theme_to_worker(session, request, host)
ssh_prefix = _build_ssh_prefix(host)
cmd = ssh_prefix + [
(
f"cd {settings.MOBILE_BUILD_REMOTE_APP_DIR} && "
f"chmod +x ./{script_name} && "
f"./{script_name}"
)
]
await _append_log(session, request, f"[{platform}] Executing {script_name} on {host}")
code, out = await _run_command(cmd)
await _append_log(session, request, f"[{platform}] Exit code {code}\n{out}")
if code != 0:
raise RuntimeError(f"{platform} build failed with exit code {code}")
artifact_glob = (
settings.MOBILE_BUILD_ANDROID_ARTIFACT_GLOB
if platform == "android"
else settings.MOBILE_BUILD_IOS_ARTIFACT_GLOB
)
await _copy_worker_artifact(session, request, platform, host, artifact_glob)
await _append_log(session, request, f"[{platform}] Artifact copied successfully")
async def _execute_request(request_id: int) -> None:
async with async_session() as session:
result = await session.execute(
select(MobileBuildRequest).where(MobileBuildRequest.id == request_id)
)
request = result.scalar_one_or_none()
if request is None:
return
request.status = "building"
request.error_message = None
request.started_at = datetime.utcnow()
request.updated_at = datetime.utcnow()
await session.commit()
try:
if request.platform_android:
await _run_platform_build(
session,
request,
"android",
settings.MOBILE_BUILD_ANDROID_HOST,
settings.MOBILE_BUILD_ANDROID_SCRIPT,
)
if request.platform_ios:
await _run_platform_build(
session,
request,
"ios",
settings.MOBILE_BUILD_IOS_HOST,
settings.MOBILE_BUILD_IOS_SCRIPT,
)
request.status = "completed"
request.completed_at = datetime.utcnow()
request.updated_at = datetime.utcnow()
await session.commit()
except Exception as exc:
request.status = "failed"
request.error_message = str(exc)
request.completed_at = datetime.utcnow()
request.updated_at = datetime.utcnow()
await _append_log(session, request, f"Build failed: {exc}")
await session.commit()
finally:
_RUNNING_TASKS.discard(request_id)
@router.get("/defaults", response_model=MobileBuildDefaultsResponse)
async def get_mobile_build_defaults(session: AsyncSession = Depends(get_session)):
result = await session.execute(
select(StationConfig).where(StationConfig.key == "default")
)
cfg = result.scalar_one_or_none()
if cfg is None:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Station config not found")
app_name = f"{cfg.name_primary} {cfg.name_secondary}".strip()
return MobileBuildDefaultsResponse(
tenant_key=cfg.key,
app_name=app_name,
tagline=cfg.tagline,
support_email=cfg.email,
website=cfg.website,
stream_url=cfg.stream_url,
stream_metadata_url=cfg.stream_metadata_url,
)
@router.get("/requests", response_model=list[MobileBuildRequestResponse])
async def list_mobile_build_requests(
session: AsyncSession = Depends(get_session),
_: User = Depends(get_current_admin_user),
):
result = await session.execute(
select(MobileBuildRequest).order_by(MobileBuildRequest.created_at.desc())
)
rows = list(result.scalars().all())
return [await _hydrate_request(session, row) for row in rows]
@router.post("/requests", response_model=MobileBuildRequestResponse, status_code=201)
async def create_mobile_build_request(
payload: MobileBuildCreate,
session: AsyncSession = Depends(get_session),
user: User = Depends(get_current_admin_user),
):
request = MobileBuildRequest(
tenant_key="default",
platform_android=payload.platform_android,
platform_ios=payload.platform_ios,
android_application_id=payload.android_application_id,
ios_bundle_id=payload.ios_bundle_id,
version_name=payload.version_name,
build_number=payload.build_number,
release_profile=payload.release_profile,
status="draft",
created_by_id=user.id,
updated_at=datetime.utcnow(),
)
session.add(request)
await session.commit()
await session.refresh(request)
return await _hydrate_request(session, request)
@router.get("/requests/{request_id}", response_model=MobileBuildRequestResponse)
async def get_mobile_build_request(
request_id: int,
session: AsyncSession = Depends(get_session),
_: User = Depends(get_current_admin_user),
):
result = await session.execute(
select(MobileBuildRequest).where(MobileBuildRequest.id == request_id)
)
request = result.scalar_one_or_none()
if request is None:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Build request not found")
return await _hydrate_request(session, request)
@router.post("/requests/{request_id}/files", response_model=MobileBuildUploadedFileResponse)
async def upload_mobile_build_file(
request_id: int,
platform: Literal["android", "ios"] = Query(...),
file_kind: str = Query(...),
file: UploadFile = File(...),
session: AsyncSession = Depends(get_session),
_: User = Depends(get_current_admin_user),
):
if file_kind not in _ALLOWED_KINDS[platform]:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=f"Unsupported file_kind '{file_kind}' for platform '{platform}'",
)
request_result = await session.execute(
select(MobileBuildRequest).where(MobileBuildRequest.id == request_id)
)
request = request_result.scalar_one_or_none()
if request is None:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Build request not found")
upload_dir, _ = _ensure_dirs()
req_dir = upload_dir / f"request_{request_id}"
req_dir.mkdir(parents=True, exist_ok=True)
suffix = Path(file.filename or "upload.bin").suffix
local_name = f"{platform}_{file_kind}_{uuid.uuid4().hex}{suffix}"
local_path = req_dir / local_name
payload = await file.read()
local_path.write_bytes(payload)
sha = hashlib.sha256(payload).hexdigest()
size = len(payload)
await session.execute(
delete(MobileBuildUploadedFile).where(
MobileBuildUploadedFile.request_id == request_id,
MobileBuildUploadedFile.platform == platform,
MobileBuildUploadedFile.file_kind == file_kind,
)
)
row = MobileBuildUploadedFile(
request_id=request_id,
platform=platform,
file_kind=file_kind,
original_name=file.filename or local_name,
stored_path=str(local_path),
sha256=sha,
size=size,
)
session.add(row)
request.preflight_passed = False
request.preflight_report = ""
request.updated_at = datetime.utcnow()
await session.commit()
await session.refresh(row)
return _to_file_response(row)
@router.post("/requests/{request_id}/preflight", response_model=MobileBuildPreflightResponse)
async def preflight_mobile_build(
request_id: int,
session: AsyncSession = Depends(get_session),
_: User = Depends(get_current_admin_user),
):
result = await session.execute(
select(MobileBuildRequest).where(MobileBuildRequest.id == request_id)
)
request = result.scalar_one_or_none()
if request is None:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Build request not found")
files = await _load_files(session, request_id)
issues = _preflight_issues(request, files)
request.preflight_passed = len(issues) == 0
request.preflight_report = "\n".join(issues)
request.updated_at = datetime.utcnow()
await session.commit()
return MobileBuildPreflightResponse(passed=request.preflight_passed, issues=issues)
@router.post("/requests/{request_id}/trigger", response_model=MobileBuildRequestResponse)
async def trigger_mobile_build(
request_id: int,
session: AsyncSession = Depends(get_session),
_: User = Depends(get_current_admin_user),
):
result = await session.execute(
select(MobileBuildRequest).where(MobileBuildRequest.id == request_id)
)
request = result.scalar_one_or_none()
if request is None:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Build request not found")
files = await _load_files(session, request_id)
issues = _preflight_issues(request, files)
if issues:
request.preflight_passed = False
request.preflight_report = "\n".join(issues)
request.updated_at = datetime.utcnow()
await session.commit()
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=issues)
if request.status in {"queued", "building"}:
raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail="Build is already in progress")
request.status = "queued"
request.preflight_passed = True
request.preflight_report = ""
request.error_message = None
request.build_log = ""
request.started_at = None
request.completed_at = None
request.updated_at = datetime.utcnow()
await session.execute(
delete(MobileBuildArtifact).where(MobileBuildArtifact.request_id == request_id)
)
await session.commit()
if request_id not in _RUNNING_TASKS:
_RUNNING_TASKS.add(request_id)
asyncio.create_task(_execute_request(request_id))
return await _hydrate_request(session, request)
@router.get("/artifacts/{artifact_id}/download")
async def download_mobile_build_artifact(
artifact_id: int,
session: AsyncSession = Depends(get_session),
_: User = Depends(get_current_admin_user),
):
result = await session.execute(
select(MobileBuildArtifact).where(MobileBuildArtifact.id == artifact_id)
)
row = result.scalar_one_or_none()
if row is None:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Artifact not found")
path = Path(row.file_path)
if not path.exists():
raise HTTPException(status_code=status.HTTP_410_GONE, detail="Artifact file no longer exists")
return FileResponse(path=str(path), filename=row.file_name, media_type="application/octet-stream")