855 lines
32 KiB
Python
855 lines
32 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 _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)
|
|
|
|
|
|
# ── Build container SSH helpers (direct SSH over Docker network) ──
|
|
|
|
def _build_ssh_prefix(host: str) -> list[str]:
|
|
"""Build SSH prefix for connecting to the build container over the Docker network."""
|
|
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]:
|
|
"""Build SCP prefix for connecting to the build container over the Docker network."""
|
|
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",
|
|
]
|
|
|
|
|
|
async def _ssh_run(host: str, cmd: str) -> tuple[int, str]:
|
|
"""Run a command inside the build container via SSH."""
|
|
ssh_prefix = _build_ssh_prefix(host)
|
|
full_cmd = ssh_prefix + [cmd]
|
|
return await _run_command(full_cmd)
|
|
|
|
|
|
async def _scp_to_container(host: str, local_path: str, remote_path: str) -> tuple[int, str]:
|
|
"""SCP a file from the backend into the build container."""
|
|
scp_prefix = _build_scp_prefix(host)
|
|
full_cmd = scp_prefix + [str(local_path), f"{settings.MOBILE_BUILD_SSH_USER}@{host}:{remote_path}"]
|
|
return await _run_command(full_cmd)
|
|
|
|
|
|
async def _scp_from_container(host: str, remote_path: str, local_path: str) -> tuple[int, str]:
|
|
"""SCP a file from the build container to the backend."""
|
|
scp_prefix = _build_scp_prefix(host)
|
|
full_cmd = scp_prefix + [f"{settings.MOBILE_BUILD_SSH_USER}@{host}:{remote_path}", str(local_path)]
|
|
return await _run_command(full_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.FLUTTER_BUILD_HOST:
|
|
issues.append("Android build container 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)
|
|
# Don't shlex.quote the glob — the shell needs to expand the wildcard
|
|
list_cmd = ssh_prefix + [f"ls -1t {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")
|
|
|
|
|
|
# ── Container-based Android build flow (direct SSH to build service) ──
|
|
|
|
async def _copy_container_artifact(
|
|
session: AsyncSession,
|
|
request: MobileBuildRequest,
|
|
host: str,
|
|
project_dir: str,
|
|
) -> None:
|
|
"""Copy the APK from the Flutter build container back to the backend via SCP."""
|
|
_, artifact_dir = _ensure_dirs()
|
|
local_dir = artifact_dir / f"request_{request.id}" / "android"
|
|
local_dir.mkdir(parents=True, exist_ok=True)
|
|
|
|
# Find the APK inside the container
|
|
apk_glob = settings.FLUTTER_APK_GLOB
|
|
# Don't shlex.quote the glob — the shell needs to expand the wildcard
|
|
list_cmd = f"ls -1t {apk_glob} 2>/dev/null | head -n 1"
|
|
code, out = await _ssh_run(host, list_cmd)
|
|
if code != 0 or not out:
|
|
raise RuntimeError(f"No APK artifact found using glob {apk_glob}")
|
|
|
|
apk_path = out.strip()
|
|
file_name = Path(apk_path).name
|
|
|
|
# SCP directly from build container to backend
|
|
local_path = local_dir / file_name
|
|
code, out = await _scp_from_container(host, apk_path, local_path)
|
|
if code != 0:
|
|
raise RuntimeError(f"Failed to SCP APK from build container: {out}")
|
|
|
|
artifact = MobileBuildArtifact(
|
|
request_id=request.id,
|
|
platform="android",
|
|
artifact_type="apk",
|
|
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 _run_flutter_container_build(
|
|
session: AsyncSession,
|
|
request: MobileBuildRequest,
|
|
host: str,
|
|
) -> None:
|
|
"""Execute an Android build inside the Flutter build container via direct SSH.
|
|
|
|
Flow:
|
|
1. Clone the Flutter project (if not already cloned)
|
|
2. Run flutter pub get
|
|
3. Set up Python venv + activate
|
|
4. Copy theme.json into the project directory
|
|
5. Copy signing files (keystore + descriptor) into the project
|
|
6. Run build_whitelabel.py
|
|
7. Run flutter build apk
|
|
8. Copy the APK artifact back to the backend
|
|
"""
|
|
project_dir = settings.FLUTTER_PROJECT_DIR # ~/kryz-go-flutter
|
|
repo_url = settings.FLUTTER_PROJECT_REPO_URL
|
|
|
|
# Step 1: Clone the Flutter project (skip if already exists)
|
|
await _append_log(session, request, "[android] Checking Flutter project directory...")
|
|
check_cmd = f"test -d {shlex.quote(project_dir)}/.git"
|
|
code, out = await _ssh_run(host, check_cmd)
|
|
directory_exists = (code == 0)
|
|
|
|
if not directory_exists:
|
|
await _append_log(session, request, f"[android] Cloning {repo_url} into {project_dir}")
|
|
clone_cmd = f"cd /home/vscode && git clone {shlex.quote(repo_url)} kryz-go-flutter"
|
|
code, out = await _ssh_run(host, clone_cmd)
|
|
await _append_log(session, request, f"[android] Clone output: {out}")
|
|
if code != 0:
|
|
raise RuntimeError(f"Git clone failed: {out}")
|
|
else:
|
|
await _append_log(session, request, "[android] Project directory exists, pulling latest...")
|
|
pull_cmd = f"cd {shlex.quote(project_dir)} && git pull"
|
|
code, out = await _ssh_run(host, pull_cmd)
|
|
await _append_log(session, request, f"[android] Pull output: {out}")
|
|
if code != 0:
|
|
# Directory may be stale/corrupt — remove and re-clone
|
|
await _append_log(session, request, "[android] Pull failed, re-cloning...")
|
|
rm_cmd = f"rm -rf {shlex.quote(project_dir)}"
|
|
await _ssh_run(host, rm_cmd)
|
|
clone_cmd = f"cd /home/vscode && git clone {shlex.quote(repo_url)} kryz-go-flutter"
|
|
code, out = await _ssh_run(host, clone_cmd)
|
|
await _append_log(session, request, f"[android] Re-clone output: {out}")
|
|
if code != 0:
|
|
raise RuntimeError(f"Git re-clone failed: {out}")
|
|
|
|
# Step 2: flutter pub get
|
|
await _append_log(session, request, "[android] Running flutter pub get...")
|
|
pub_cmd = f"cd {shlex.quote(project_dir)} && flutter pub get"
|
|
code, out = await _ssh_run(host, pub_cmd)
|
|
await _append_log(session, request, f"[android] Pub get output: {out}")
|
|
if code != 0:
|
|
raise RuntimeError(f"flutter pub get failed: {out}")
|
|
|
|
# Step 3: Python venv setup
|
|
await _append_log(session, request, "[android] Setting up Python venv...")
|
|
venv_cmd = (
|
|
f"cd {shlex.quote(project_dir)} && "
|
|
f"python3 -m venv .venv && "
|
|
f"source .venv/bin/activate && "
|
|
f"pip install --upgrade pip"
|
|
)
|
|
code, out = await _ssh_run(host, venv_cmd)
|
|
await _append_log(session, request, f"[android] Venv setup output: {out}")
|
|
if code != 0:
|
|
raise RuntimeError(f"Python venv setup failed: {out}")
|
|
|
|
# Step 4: Copy theme.json into the project directory
|
|
theme_path = Path(settings.THEME_JSON_PATH)
|
|
if theme_path.exists():
|
|
code, out = await _scp_to_container(host, theme_path, f"{project_dir}/theme.json")
|
|
if code != 0:
|
|
raise RuntimeError(f"Failed to copy theme.json to build container: {out}")
|
|
await _append_log(session, request, "[android] theme.json copied to build container")
|
|
else:
|
|
await _append_log(session, request, "[android] theme.json not found, skipping")
|
|
|
|
# Step 5: Copy signing files into the container
|
|
files = await _load_files(session, request.id)
|
|
android_files = [f for f in files if f.platform == "android"]
|
|
|
|
for row in android_files:
|
|
local_path = Path(row.stored_path)
|
|
if not local_path.exists():
|
|
raise RuntimeError(f"Signing file missing: {row.original_name}")
|
|
|
|
# SCP directly to build container
|
|
remote_path = f"{project_dir}/{row.original_name}"
|
|
code, out = await _scp_to_container(host, local_path, remote_path)
|
|
if code != 0:
|
|
raise RuntimeError(f"Failed to SCP {row.original_name} to build container: {out}")
|
|
|
|
await _append_log(session, request, f"[android] Signing file {row.original_name} copied to build container")
|
|
|
|
# Step 6: Run build_whitelabel.py
|
|
await _append_log(session, request, "[android] Running build_whitelabel.py...")
|
|
whitelabel_cmd = (
|
|
f"cd {shlex.quote(project_dir)} && "
|
|
f"source .venv/bin/activate && "
|
|
f"python3 build_whitelabel.py"
|
|
)
|
|
code, out = await _ssh_run(host, whitelabel_cmd)
|
|
await _append_log(session, request, f"[android] build_whitelabel.py output:\n{out}")
|
|
if code != 0:
|
|
raise RuntimeError(f"build_whitelabel.py failed: {out}")
|
|
|
|
# Step 7: flutter build apk
|
|
# Container-safe flags:
|
|
# - GRADLE_OPTS disables native VFS file watching (inotify EBADF in Docker)
|
|
# - KOTLIN_COMPILE_DAEMON_ENABLED=false avoids the Kotlin compile daemon
|
|
# crashing with the same inotify issue
|
|
# - Project-level gradle.properties ensures the Gradle wrapper always
|
|
# honors these settings (user-level ~/.gradle/ may not be read)
|
|
# - Memory is aggressively capped: 1g JVM heap, single worker, no
|
|
# parallel builds, in-process Kotlin compilation, SerialGC (avoids
|
|
# parallel GC overhead). Combined with the 4g container memory limit,
|
|
# this leaves ~3g for native compilation (clang, dll, etc.)
|
|
await _append_log(session, request, "[android] Running flutter build apk...")
|
|
android_props = f"{project_dir}/android/gradle.properties"
|
|
build_cmd = (
|
|
f"cd {shlex.quote(project_dir)}/android && "
|
|
f"echo 'org.gradle.daemon=false' >> gradle.properties && "
|
|
# f"echo 'org.gradle.jvmargs=-Xmx4g -XX:MaxMetaspaceSize=512m -XX:+UseSerialGC' >> gradle.properties && "
|
|
f"echo 'org.gradle.workers.max=1' >> gradle.properties && "
|
|
# f"echo 'org.gradle.parallel=false' >> gradle.properties && "
|
|
# f"echo 'kotlin.compiler.execution.strategy=in-process' >> gradle.properties && "
|
|
# f"echo 'kotlin.incremental=false' >> gradle.properties && "
|
|
# f"echo 'android.enableParallelPlugin=false' >> gradle.properties && "
|
|
f"echo 'org.gradle.vfs.watch=false' >> gradle.properties && "
|
|
f"cd {shlex.quote(project_dir)} && "
|
|
f"GRADLE_OPTS='-Dorg.gradle.vfs.watch=false' "
|
|
# f"KOTLIN_COMPILE_DAEMON_ENABLED=false "
|
|
f"flutter build apk --release"
|
|
)
|
|
code, out = await _ssh_run(host, build_cmd)
|
|
await _append_log(session, request, f"[android] Build output:\n{out}")
|
|
if code != 0:
|
|
raise RuntimeError(f"flutter build apk failed: {out}")
|
|
|
|
# Step 8: Copy APK artifact back
|
|
await _copy_container_artifact(session, request, host, project_dir)
|
|
await _append_log(session, request, "[android] APK 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_flutter_container_build(
|
|
session,
|
|
request,
|
|
settings.FLUTTER_BUILD_HOST,
|
|
)
|
|
|
|
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")
|