Compare commits
2
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
2c66a802c0 | ||
|
|
7869574fe2 |
@@ -29,7 +29,8 @@
|
|||||||
"Read(//tmp/**)",
|
"Read(//tmp/**)",
|
||||||
"Bash(KMTN_DATABASE_URL=\"sqlite+aiosqlite:///./kmountain.db\" KMTN_ADMIN_USERNAME=\"admin\" KMTN_ADMIN_PASSWORD=\"123\" KMTN_CORS_ORIGINS='[\"http://localhost:4200\"]' nohup uvicorn app.main:app --reload --host 0.0.0.0 --port 8000)",
|
"Bash(KMTN_DATABASE_URL=\"sqlite+aiosqlite:///./kmountain.db\" KMTN_ADMIN_USERNAME=\"admin\" KMTN_ADMIN_PASSWORD=\"123\" KMTN_CORS_ORIGINS='[\"http://localhost:4200\"]' nohup uvicorn app.main:app --reload --host 0.0.0.0 --port 8000)",
|
||||||
"Bash(echo \"Backend PID: $!\")",
|
"Bash(echo \"Backend PID: $!\")",
|
||||||
"Bash(curl -s http://localhost:8000/docs)"
|
"Bash(curl -s http://localhost:8000/docs)",
|
||||||
|
"WebSearch"
|
||||||
],
|
],
|
||||||
"additionalDirectories": [
|
"additionalDirectories": [
|
||||||
"/workspaces/web_app/.vscode"
|
"/workspaces/web_app/.vscode"
|
||||||
|
|||||||
@@ -27,3 +27,18 @@ API_UPSTREAM=http://api:8000
|
|||||||
# "http://yourhost:8000" — direct mode: browser calls the API container directly (CORS required).
|
# "http://yourhost:8000" — direct mode: browser calls the API container directly (CORS required).
|
||||||
# Use direct mode when an external ingress layer (e.g., TrueNAS middleware) breaks the nginx proxy chain.
|
# Use direct mode when an external ingress layer (e.g., TrueNAS middleware) breaks the nginx proxy chain.
|
||||||
API_PUBLIC_URL=""
|
API_PUBLIC_URL=""
|
||||||
|
|
||||||
|
# ── Mobile Build (Android — Flutter build container) ────────────────
|
||||||
|
# The build container runs alongside api/frontend in the same docker-compose project.
|
||||||
|
# The api container SSHes directly into the build container over the Docker network.
|
||||||
|
# Default is "build" (the docker-compose service name) — usually no override needed.
|
||||||
|
# KMTN_FLUTTER_BUILD_HOST=build
|
||||||
|
|
||||||
|
# Git repository URL for the Flutter project (cloned inside the build container)
|
||||||
|
# KMTN_FLUTTER_PROJECT_REPO_URL=http://192.168.1.74:30008/kfj001/kryz-go-flutter.git
|
||||||
|
|
||||||
|
# Project directory inside the build container (tilde expands to /home/vscode)
|
||||||
|
# KMTN_FLUTTER_PROJECT_DIR=~/kryz-go-flutter
|
||||||
|
|
||||||
|
# Glob pattern to find the built APK inside the build container
|
||||||
|
# KMTN_FLUTTER_APK_GLOB=~/kryz-go-flutter/build/app/outputs/flutter-apk/app-*.apk
|
||||||
|
|||||||
@@ -48,3 +48,4 @@ Thumbs.db
|
|||||||
|
|
||||||
# Dev SQLite database
|
# Dev SQLite database
|
||||||
backend/kmountain.db
|
backend/kmountain.db
|
||||||
|
kmtndata/
|
||||||
@@ -4,6 +4,11 @@ WORKDIR /app
|
|||||||
|
|
||||||
ENV PYTHONUNBUFFERED=1
|
ENV PYTHONUNBUFFERED=1
|
||||||
|
|
||||||
|
# SSH tools for mobile build orchestration (SSH to build host, docker exec into flutter-build container)
|
||||||
|
RUN apt-get update && apt-get install -y --no-install-recommends \
|
||||||
|
sshpass openssh-client \
|
||||||
|
&& rm -rf /var/lib/apt/lists/*
|
||||||
|
|
||||||
COPY requirements.txt .
|
COPY requirements.txt .
|
||||||
RUN pip install --no-cache-dir -r requirements.txt
|
RUN pip install --no-cache-dir -r requirements.txt
|
||||||
|
|
||||||
@@ -15,5 +20,8 @@ COPY . .
|
|||||||
RUN mkdir -p /app/geo
|
RUN mkdir -p /app/geo
|
||||||
COPY geo/ /app/geo/
|
COPY geo/ /app/geo/
|
||||||
|
|
||||||
|
# Ensure upload/artifact dirs exist at runtime
|
||||||
|
RUN mkdir -p /tmp/kmtn_mobile_build_uploads /tmp/kmtn_mobile_build_artifacts
|
||||||
|
|
||||||
EXPOSE 8000
|
EXPOSE 8000
|
||||||
CMD ["gunicorn", "app.main:app", "-k", "uvicorn.workers.UvicornWorker", "-b", "0.0.0.0:8000", "--workers", "1", "--timeout", "120"]
|
CMD ["gunicorn", "app.main:app", "-k", "uvicorn.workers.UvicornWorker", "-b", "0.0.0.0:8000", "--workers", "1", "--timeout", "120"]
|
||||||
|
|||||||
@@ -68,7 +68,20 @@ def _check_mobileprovision_for_carplay(path: Path) -> bool:
|
|||||||
return "carplay" in text
|
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]:
|
def _build_ssh_prefix(host: str) -> list[str]:
|
||||||
|
"""Build SSH prefix for connecting to the build container over the Docker network."""
|
||||||
if not host:
|
if not host:
|
||||||
raise RuntimeError("Build host is not configured")
|
raise RuntimeError("Build host is not configured")
|
||||||
return [
|
return [
|
||||||
@@ -85,6 +98,7 @@ def _build_ssh_prefix(host: str) -> list[str]:
|
|||||||
|
|
||||||
|
|
||||||
def _build_scp_prefix(host: str) -> list[str]:
|
def _build_scp_prefix(host: str) -> list[str]:
|
||||||
|
"""Build SCP prefix for connecting to the build container over the Docker network."""
|
||||||
if not host:
|
if not host:
|
||||||
raise RuntimeError("Build host is not configured")
|
raise RuntimeError("Build host is not configured")
|
||||||
return [
|
return [
|
||||||
@@ -99,14 +113,25 @@ def _build_scp_prefix(host: str) -> list[str]:
|
|||||||
]
|
]
|
||||||
|
|
||||||
|
|
||||||
def _run_sync_command(cmd: list[str]) -> tuple[int, str]:
|
async def _ssh_run(host: str, cmd: str) -> tuple[int, str]:
|
||||||
result = subprocess.run(cmd, capture_output=True, text=True)
|
"""Run a command inside the build container via SSH."""
|
||||||
output = (result.stdout or "") + ("\n" + result.stderr if result.stderr else "")
|
ssh_prefix = _build_ssh_prefix(host)
|
||||||
return result.returncode, output.strip()
|
full_cmd = ssh_prefix + [cmd]
|
||||||
|
return await _run_command(full_cmd)
|
||||||
|
|
||||||
|
|
||||||
async def _run_command(cmd: list[str]) -> tuple[int, str]:
|
async def _scp_to_container(host: str, local_path: str, remote_path: str) -> tuple[int, str]:
|
||||||
return await asyncio.to_thread(_run_sync_command, cmd)
|
"""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:
|
async def _append_log(session: AsyncSession, request: MobileBuildRequest, line: str) -> None:
|
||||||
@@ -142,8 +167,8 @@ def _preflight_issues(request: MobileBuildRequest, files: list[MobileBuildUpload
|
|||||||
if not request.build_number.strip():
|
if not request.build_number.strip():
|
||||||
issues.append("Build number is required.")
|
issues.append("Build number is required.")
|
||||||
|
|
||||||
if request.platform_android and not settings.MOBILE_BUILD_ANDROID_HOST:
|
if request.platform_android and not settings.FLUTTER_BUILD_HOST:
|
||||||
issues.append("Android build host is not configured.")
|
issues.append("Android build container host is not configured.")
|
||||||
|
|
||||||
if request.platform_ios and not settings.MOBILE_BUILD_IOS_HOST:
|
if request.platform_ios and not settings.MOBILE_BUILD_IOS_HOST:
|
||||||
issues.append("iOS build host is not configured.")
|
issues.append("iOS build host is not configured.")
|
||||||
@@ -256,7 +281,8 @@ async def _copy_worker_artifact(
|
|||||||
local_dir.mkdir(parents=True, exist_ok=True)
|
local_dir.mkdir(parents=True, exist_ok=True)
|
||||||
|
|
||||||
ssh_prefix = _build_ssh_prefix(host)
|
ssh_prefix = _build_ssh_prefix(host)
|
||||||
list_cmd = ssh_prefix + [f"ls -1t {shlex.quote(artifact_glob)} 2>/dev/null | head -n 1"]
|
# 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)
|
code, out = await _run_command(list_cmd)
|
||||||
if code != 0 or not out:
|
if code != 0 or not out:
|
||||||
raise RuntimeError(f"No {platform} artifact found using glob {artifact_glob}")
|
raise RuntimeError(f"No {platform} artifact found using glob {artifact_glob}")
|
||||||
@@ -362,6 +388,196 @@ async def _run_platform_build(
|
|||||||
await _append_log(session, request, f"[{platform}] Artifact copied successfully")
|
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 def _execute_request(request_id: int) -> None:
|
||||||
async with async_session() as session:
|
async with async_session() as session:
|
||||||
result = await session.execute(
|
result = await session.execute(
|
||||||
@@ -379,12 +595,10 @@ async def _execute_request(request_id: int) -> None:
|
|||||||
|
|
||||||
try:
|
try:
|
||||||
if request.platform_android:
|
if request.platform_android:
|
||||||
await _run_platform_build(
|
await _run_flutter_container_build(
|
||||||
session,
|
session,
|
||||||
request,
|
request,
|
||||||
"android",
|
settings.FLUTTER_BUILD_HOST,
|
||||||
settings.MOBILE_BUILD_ANDROID_HOST,
|
|
||||||
settings.MOBILE_BUILD_ANDROID_SCRIPT,
|
|
||||||
)
|
)
|
||||||
|
|
||||||
if request.platform_ios:
|
if request.platform_ios:
|
||||||
|
|||||||
+12
-6
@@ -18,19 +18,25 @@ class Settings(BaseSettings):
|
|||||||
LOGS_DIR: str = "/logs"
|
LOGS_DIR: str = "/logs"
|
||||||
GEOLITE2_DB_PATH: str = "/app/geo/GeoLite2-City.mmdb"
|
GEOLITE2_DB_PATH: str = "/app/geo/GeoLite2-City.mmdb"
|
||||||
|
|
||||||
# Mobile build orchestration
|
# Mobile build orchestration — matches the user created in build/Dockerfile
|
||||||
MOBILE_BUILD_SSH_USER: str = "buildbot"
|
MOBILE_BUILD_SSH_USER: str = "vscode"
|
||||||
MOBILE_BUILD_SSH_PASSWORD: str = "buildbot"
|
MOBILE_BUILD_SSH_PASSWORD: str = "123"
|
||||||
MOBILE_BUILD_SSH_PORT: int = 22
|
MOBILE_BUILD_SSH_PORT: int = 22
|
||||||
MOBILE_BUILD_ANDROID_HOST: str = ""
|
MOBILE_BUILD_ANDROID_HOST: str = ""
|
||||||
MOBILE_BUILD_IOS_HOST: str = ""
|
MOBILE_BUILD_IOS_HOST: str = ""
|
||||||
MOBILE_BUILD_REMOTE_APP_DIR: str = "~/mobile_app"
|
MOBILE_BUILD_REMOTE_APP_DIR: str = "/home/vscode/mobile_app"
|
||||||
MOBILE_BUILD_ANDROID_SCRIPT: str = "do_android_build.sh"
|
MOBILE_BUILD_ANDROID_SCRIPT: str = "do_android_build.sh"
|
||||||
MOBILE_BUILD_IOS_SCRIPT: str = "do_ios_build.sh"
|
MOBILE_BUILD_IOS_SCRIPT: str = "do_ios_build.sh"
|
||||||
MOBILE_BUILD_UPLOAD_DIR: str = "/tmp/kmtn_mobile_build_uploads"
|
MOBILE_BUILD_UPLOAD_DIR: str = "/tmp/kmtn_mobile_build_uploads"
|
||||||
MOBILE_BUILD_ARTIFACT_DIR: str = "/tmp/kmtn_mobile_build_artifacts"
|
MOBILE_BUILD_ARTIFACT_DIR: str = "/tmp/kmtn_mobile_build_artifacts"
|
||||||
MOBILE_BUILD_ANDROID_ARTIFACT_GLOB: str = "~/mobile_app/build/app/outputs/bundle/release/*.aab"
|
MOBILE_BUILD_ANDROID_ARTIFACT_GLOB: str = "/home/vscode/mobile_app/build/app/outputs/bundle/release/*.aab"
|
||||||
MOBILE_BUILD_IOS_ARTIFACT_GLOB: str = "~/mobile_app/build/ios/ipa/*.ipa"
|
MOBILE_BUILD_IOS_ARTIFACT_GLOB: str = "/home/vscode/mobile_app/build/ios/ipa/*.ipa"
|
||||||
|
|
||||||
|
# Flutter container-based Android build (SSH directly to build service over Docker network)
|
||||||
|
FLUTTER_BUILD_HOST: str = "build" # docker-compose service name
|
||||||
|
FLUTTER_PROJECT_REPO_URL: str = "http://192.168.1.74:30008/kfj001/kryz-go-flutter.git"
|
||||||
|
FLUTTER_PROJECT_DIR: str = "/home/vscode/kryz-go-flutter"
|
||||||
|
FLUTTER_APK_GLOB: str = "/home/vscode/kryz-go-flutter/build/app/outputs/flutter-apk/app-*.apk"
|
||||||
|
|
||||||
# Theme export
|
# Theme export
|
||||||
WEBSITE_URL: str = "https://kmountainflower.org"
|
WEBSITE_URL: str = "https://kmountainflower.org"
|
||||||
|
|||||||
@@ -0,0 +1,97 @@
|
|||||||
|
# Force Intel (amd64) architecture — required because Android SDK
|
||||||
|
# command-line tools are only published for x86_64 (Linux/macOS).
|
||||||
|
# On Apple Silicon hosts, Docker runs this image via Rosetta emulation.
|
||||||
|
FROM --platform=linux/amd64 docker.io/library/debian:bookworm-slim
|
||||||
|
|
||||||
|
# System dependencies + SSH server for intra-network access
|
||||||
|
RUN apt-get update && apt-get install -y \
|
||||||
|
git curl unzip wget python3 python3-venv python3-pip \
|
||||||
|
openjdk-17-jdk-headless \
|
||||||
|
openssh-server \
|
||||||
|
xz-utils \
|
||||||
|
&& rm -rf /var/lib/apt/lists/*
|
||||||
|
|
||||||
|
# SSH server configuration
|
||||||
|
RUN mkdir -p /run/sshd && \
|
||||||
|
ssh-keygen -A && \
|
||||||
|
chmod 644 /etc/ssh/ssh_host_*_key && \
|
||||||
|
sed -i 's/#PermitRootLogin.*/PermitRootLogin no/' /etc/ssh/sshd_config && \
|
||||||
|
echo "UsePAM no" >> /etc/ssh/sshd_config && \
|
||||||
|
echo "PermitUserEnvironment yes" >> /etc/ssh/sshd_config
|
||||||
|
|
||||||
|
# Set Java home — use /usr/lib/jvm/java-17-openjdk which is a stable
|
||||||
|
# symlink provided by Debian's jdk package, regardless of architecture.
|
||||||
|
ENV JAVA_HOME=/usr/lib/jvm/java-17-openjdk-amd64
|
||||||
|
ENV PATH="$JAVA_HOME/bin:$PATH"
|
||||||
|
|
||||||
|
# Install Flutter 3.41.8
|
||||||
|
ARG FLUTTER_VERSION=3.41.8
|
||||||
|
ARG FLUTTER_SDK_URL=https://storage.googleapis.com/flutter_infra_release/releases/stable/linux/flutter_linux_${FLUTTER_VERSION}-stable.tar.xz
|
||||||
|
RUN wget -qO- $FLUTTER_SDK_URL | tar -xJ -C /opt \
|
||||||
|
&& ln -s /opt/flutter/bin/flutter /usr/local/bin/flutter
|
||||||
|
|
||||||
|
# Android SDK setup
|
||||||
|
ENV ANDROID_HOME=/opt/android-sdk
|
||||||
|
RUN mkdir -p $ANDROID_HOME/cmdline-tools
|
||||||
|
# Download and install Android command-line tools
|
||||||
|
RUN wget -qO /tmp/cmdline-tools.zip \
|
||||||
|
https://dl.google.com/android/repository/commandlinetools-linux-11076708_latest.zip \
|
||||||
|
&& unzip -q /tmp/cmdline-tools.zip -d /tmp/cmdline-tools-extract \
|
||||||
|
&& mv /tmp/cmdline-tools-extract/cmdline-tools $ANDROID_HOME/cmdline-tools/latest \
|
||||||
|
&& rm -rf /tmp/cmdline-tools*
|
||||||
|
|
||||||
|
# Accept licenses and install required SDK packages
|
||||||
|
ENV CMDLINE_TOOLS=$ANDROID_HOME/cmdline-tools/latest/bin
|
||||||
|
RUN yes | $CMDLINE_TOOLS/sdkmanager --licenses >/dev/null 2>&1 || true
|
||||||
|
RUN $CMDLINE_TOOLS/sdkmanager "platform-tools" "platforms;android-35" "build-tools;35.0.0"
|
||||||
|
|
||||||
|
# Create vscode user (UID 1000, matching devcontainer convention)
|
||||||
|
RUN groupadd -g 1000 vscode && \
|
||||||
|
useradd -m -u 1000 -g 1000 -s /bin/bash vscode && \
|
||||||
|
chown -R vscode:vscode /opt/flutter /opt/android-sdk && \
|
||||||
|
mkdir -p /home/vscode && chown vscode:vscode /home/vscode
|
||||||
|
|
||||||
|
# Give vscode a simple password: '123'
|
||||||
|
RUN echo "vscode:123" | chpasswd
|
||||||
|
|
||||||
|
# Set up Flutter for the vscode user
|
||||||
|
USER vscode
|
||||||
|
WORKDIR /home/vscode
|
||||||
|
ENV PATH="/opt/flutter/bin:$ANDROID_HOME/cmdline-tools/latest/bin:$ANDROID_HOME/platform-tools:$JAVA_HOME/bin:$PATH"
|
||||||
|
|
||||||
|
# Pre-cache Flutter artifacts
|
||||||
|
RUN flutter precache --android
|
||||||
|
|
||||||
|
# Set Android HOME for vscode user
|
||||||
|
ENV ANDROID_HOME=/opt/android-sdk
|
||||||
|
|
||||||
|
# SSH non-interactive shells don't inherit Docker ENV at all (not even
|
||||||
|
# BASH_ENV). Use SSH's own ~/.ssh/environment file with
|
||||||
|
# PermitUserEnvironment to guarantee variables are set on every connection.
|
||||||
|
RUN mkdir -p /home/vscode/.ssh && \
|
||||||
|
echo 'JAVA_HOME=/usr/lib/jvm/java-17-openjdk-amd64' > /home/vscode/.ssh/environment && \
|
||||||
|
echo 'ANDROID_HOME=/opt/android-sdk' >> /home/vscode/.ssh/environment && \
|
||||||
|
echo 'PATH=/opt/flutter/bin:/opt/android-sdk/cmdline-tools/latest/bin:/opt/android-sdk/platform-tools:/usr/lib/jvm/java-17-openjdk-amd64/bin:/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin' >> /home/vscode/.ssh/environment && \
|
||||||
|
chown -R vscode:vscode /home/vscode/.ssh && \
|
||||||
|
chmod 700 /home/vscode/.ssh && \
|
||||||
|
chmod 644 /home/vscode/.ssh/environment
|
||||||
|
|
||||||
|
# Set up Gradle home so the wrapper works correctly in SSH sessions.
|
||||||
|
# org.gradle.daemon=false is required because SSH sessions are ephemeral
|
||||||
|
# — a daemon started in one session can't be reused in the next.
|
||||||
|
RUN mkdir -p /home/vscode/.gradle && \
|
||||||
|
echo 'org.gradle.daemon=false' > /home/vscode/.gradle/gradle.properties && \
|
||||||
|
echo 'org.gradle.jvmargs=-Xmx4g -XX:MaxMetaspaceSize=512m -XX:+UseSerialGC' >> /home/vscode/.gradle/gradle.properties && \
|
||||||
|
echo 'org.gradle.workers.max=1' >> /home/vscode/.gradle/gradle.properties && \
|
||||||
|
echo 'org.gradle.parallel=false' >> /home/vscode/.gradle/gradle.properties && \
|
||||||
|
echo 'kotlin.daemon.jvmargs=-Xmx512m' >> /home/vscode/.gradle/gradle.properties && \
|
||||||
|
echo 'kotlin.incremental=false' >> /home/vscode/.gradle/gradle.properties && \
|
||||||
|
echo 'kotlin.compiler.execution.strategy=in-process' >> /home/vscode/.gradle/gradle.properties && \
|
||||||
|
echo 'android.enableParallelPlugin=false' >> /home/vscode/.gradle/gradle.properties && \
|
||||||
|
chown -R vscode:vscode /home/vscode/.gradle
|
||||||
|
|
||||||
|
# Expose SSH for intra-network access from the api container
|
||||||
|
EXPOSE 22
|
||||||
|
|
||||||
|
# Start SSH daemon and keep container alive
|
||||||
|
CMD ["/usr/sbin/sshd", "-D"]
|
||||||
Binary file not shown.
@@ -0,0 +1,79 @@
|
|||||||
|
services:
|
||||||
|
db:
|
||||||
|
image: docker.io/library/postgres:16-alpine
|
||||||
|
restart: unless-stopped
|
||||||
|
# NOTE: Change POSTGRES_PASSWORD in production. Consider using Docker secrets or
|
||||||
|
# a managed PostgreSQL service instead of local credentials.
|
||||||
|
environment:
|
||||||
|
POSTGRES_DB: kmountain
|
||||||
|
POSTGRES_USER: postgres
|
||||||
|
POSTGRES_PASSWORD: postgres
|
||||||
|
volumes:
|
||||||
|
- pgdata:/var/lib/postgresql/data
|
||||||
|
- pgsocket:/var/run/postgresql
|
||||||
|
healthcheck:
|
||||||
|
test: ["CMD-SHELL", "pg_isready -U postgres"]
|
||||||
|
interval: 5s
|
||||||
|
timeout: 5s
|
||||||
|
retries: 5
|
||||||
|
|
||||||
|
# NOTE: Update KMTN_CORS_ORIGINS in production to your actual domain(s).
|
||||||
|
# Set KMTN_DATABASE_URL to your managed PostgreSQL connection if not using the local db service.
|
||||||
|
api:
|
||||||
|
build:
|
||||||
|
context: ./backend
|
||||||
|
dockerfile: Dockerfile
|
||||||
|
restart: unless-stopped
|
||||||
|
environment:
|
||||||
|
KMTN_DATABASE_URL: >-
|
||||||
|
postgresql+asyncpg://postgres:postgres@/kmountain?host=/var/run/postgresql
|
||||||
|
KMTN_CORS_ORIGINS: '["http://localhost:4200", "http://localhost"]'
|
||||||
|
KMTN_ADMIN_USERNAME : "admin"
|
||||||
|
KMTN_ADMIN_PASSWORD : "123"
|
||||||
|
KMTN_STATIC_UPSTREAM_URL: http://frontend:80
|
||||||
|
volumes:
|
||||||
|
- pgsocket:/var/run/postgresql
|
||||||
|
- logs:/logs
|
||||||
|
depends_on:
|
||||||
|
db:
|
||||||
|
condition: service_healthy
|
||||||
|
|
||||||
|
frontend:
|
||||||
|
build:
|
||||||
|
context: .
|
||||||
|
dockerfile: Dockerfile
|
||||||
|
restart: unless-stopped
|
||||||
|
environment:
|
||||||
|
API_UPSTREAM: http://api:8000
|
||||||
|
API_PUBLIC_URL: ""
|
||||||
|
ports:
|
||||||
|
- "4201:80"
|
||||||
|
volumes:
|
||||||
|
- logs:/logs
|
||||||
|
depends_on:
|
||||||
|
- api
|
||||||
|
|
||||||
|
# Flutter build container — runs alongside the api/frontend services.
|
||||||
|
# The api container SSHes directly into this container over the Docker network.
|
||||||
|
# Forced to linux/amd64 because Android SDK CLI tools are only published
|
||||||
|
# for x86_64. On Apple Silicon hosts, Docker runs via Rosetta emulation.
|
||||||
|
build:
|
||||||
|
build:
|
||||||
|
context: ./build
|
||||||
|
dockerfile: Dockerfile
|
||||||
|
platform: linux/amd64
|
||||||
|
restart: unless-stopped
|
||||||
|
mem_limit: 16g
|
||||||
|
mem_reservation: 8g
|
||||||
|
expose:
|
||||||
|
- "22"
|
||||||
|
volumes:
|
||||||
|
- flutter-cache:/home/vscode/.pub-cache
|
||||||
|
- android-licenses:/opt/android-sdk/licenses
|
||||||
|
|
||||||
|
volumes:
|
||||||
|
pgdata:
|
||||||
|
pgsocket:
|
||||||
|
logs:
|
||||||
|
flutter-cache:
|
||||||
|
android-licenses:
|
||||||
+20
-2
@@ -31,8 +31,6 @@ services:
|
|||||||
KMTN_ADMIN_USERNAME : "admin"
|
KMTN_ADMIN_USERNAME : "admin"
|
||||||
KMTN_ADMIN_PASSWORD : "123"
|
KMTN_ADMIN_PASSWORD : "123"
|
||||||
KMTN_STATIC_UPSTREAM_URL: http://frontend:80
|
KMTN_STATIC_UPSTREAM_URL: http://frontend:80
|
||||||
ports:
|
|
||||||
- "8000:8000"
|
|
||||||
volumes:
|
volumes:
|
||||||
- pgsocket:/var/run/postgresql
|
- pgsocket:/var/run/postgresql
|
||||||
- logs:/logs
|
- logs:/logs
|
||||||
@@ -55,7 +53,27 @@ services:
|
|||||||
depends_on:
|
depends_on:
|
||||||
- api
|
- api
|
||||||
|
|
||||||
|
# Flutter build container — runs alongside the api/frontend services.
|
||||||
|
# The api container SSHes directly into this container over the Docker network.
|
||||||
|
# Forced to linux/amd64 because Android SDK CLI tools are only published
|
||||||
|
# for x86_64. On Apple Silicon hosts, Docker runs via Rosetta emulation.
|
||||||
|
build:
|
||||||
|
build:
|
||||||
|
context: ./build
|
||||||
|
dockerfile: Dockerfile
|
||||||
|
platform: linux/amd64
|
||||||
|
restart: unless-stopped
|
||||||
|
mem_limit: 16g
|
||||||
|
mem_reservation: 8g
|
||||||
|
expose:
|
||||||
|
- "22"
|
||||||
|
volumes:
|
||||||
|
- flutter-cache:/home/vscode/.pub-cache
|
||||||
|
- android-licenses:/opt/android-sdk/licenses
|
||||||
|
|
||||||
volumes:
|
volumes:
|
||||||
pgdata:
|
pgdata:
|
||||||
pgsocket:
|
pgsocket:
|
||||||
logs:
|
logs:
|
||||||
|
flutter-cache:
|
||||||
|
android-licenses:
|
||||||
@@ -618,7 +618,9 @@
|
|||||||
<div class="artifact-meta">{{ artifact.platform }} • {{ artifact.artifact_type }} • {{
|
<div class="artifact-meta">{{ artifact.platform }} • {{ artifact.artifact_type }} • {{
|
||||||
artifact.sha256.slice(0, 12) }}…</div>
|
artifact.sha256.slice(0, 12) }}…</div>
|
||||||
</div>
|
</div>
|
||||||
<a class="btn-icon btn-edit" [href]="getArtifactDownloadHref(artifact.download_url)">Download</a>
|
<button class="btn-icon btn-edit" (click)="downloadArtifact(artifact)" [disabled]="downloadingArtifactId === artifact.id">
|
||||||
|
{{ downloadingArtifactId === artifact.id ? 'Downloading…' : 'Download' }}
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
} @empty {
|
} @empty {
|
||||||
<p class="empty-row">No artifacts available yet.</p>
|
<p class="empty-row">No artifacts available yet.</p>
|
||||||
|
|||||||
@@ -126,6 +126,7 @@ export class AdminComponent implements OnInit {
|
|||||||
mobileBuildErrorMessage = '';
|
mobileBuildErrorMessage = '';
|
||||||
mobileBuildPreflightIssues: string[] = [];
|
mobileBuildPreflightIssues: string[] = [];
|
||||||
mobileBuildBusy = false;
|
mobileBuildBusy = false;
|
||||||
|
downloadingArtifactId: number | null = null;
|
||||||
|
|
||||||
// ── Theme JSON status (mobile build tab) ────────────────────
|
// ── Theme JSON status (mobile build tab) ────────────────────
|
||||||
readonly themeJsonStatus$ = new BehaviorSubject<ThemeStatus | null>(null);
|
readonly themeJsonStatus$ = new BehaviorSubject<ThemeStatus | null>(null);
|
||||||
@@ -451,6 +452,26 @@ export class AdminComponent implements OnInit {
|
|||||||
return `${getAppConfig().apiBaseUrl}${path}`;
|
return `${getAppConfig().apiBaseUrl}${path}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async downloadArtifact(artifact: { id: number; file_name: string }): Promise<void> {
|
||||||
|
try {
|
||||||
|
this.downloadingArtifactId = artifact.id;
|
||||||
|
const blob = await firstValueFrom(this.mobileBuildService.downloadArtifact(artifact.id));
|
||||||
|
const url = window.URL.createObjectURL(blob);
|
||||||
|
const a = document.createElement('a');
|
||||||
|
a.style.display = 'none';
|
||||||
|
a.href = url;
|
||||||
|
a.download = artifact.file_name;
|
||||||
|
document.body.appendChild(a);
|
||||||
|
a.click();
|
||||||
|
window.URL.revokeObjectURL(url);
|
||||||
|
document.body.removeChild(a);
|
||||||
|
} catch {
|
||||||
|
this.mobileBuildErrorMessage = 'Failed to download artifact.';
|
||||||
|
} finally {
|
||||||
|
this.downloadingArtifactId = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// ── Show CRUD ───────────────────────────────────────────
|
// ── Show CRUD ───────────────────────────────────────────
|
||||||
|
|
||||||
openShowForm(): void {
|
openShowForm(): void {
|
||||||
|
|||||||
@@ -80,4 +80,11 @@ export class MobileBuildService {
|
|||||||
{},
|
{},
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
downloadArtifact(artifactId: number): Observable<Blob> {
|
||||||
|
return this.http.get(
|
||||||
|
`${this.baseUrl}/artifacts/${artifactId}/download`,
|
||||||
|
{ responseType: 'blob' },
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user