diff --git a/.claude/settings.local.json b/.claude/settings.local.json index 7d08af0..31fe729 100644 --- a/.claude/settings.local.json +++ b/.claude/settings.local.json @@ -29,7 +29,8 @@ "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(echo \"Backend PID: $!\")", - "Bash(curl -s http://localhost:8000/docs)" + "Bash(curl -s http://localhost:8000/docs)", + "WebSearch" ], "additionalDirectories": [ "/workspaces/web_app/.vscode" diff --git a/.env.example b/.env.example index a9f4d04..869a87a 100644 --- a/.env.example +++ b/.env.example @@ -27,3 +27,18 @@ API_UPSTREAM=http://api:8000 # "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. 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 diff --git a/backend/Dockerfile b/backend/Dockerfile index f659e6a..f034cbc 100644 --- a/backend/Dockerfile +++ b/backend/Dockerfile @@ -4,6 +4,11 @@ WORKDIR /app 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 . RUN pip install --no-cache-dir -r requirements.txt @@ -15,5 +20,8 @@ COPY . . RUN mkdir -p /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 CMD ["gunicorn", "app.main:app", "-k", "uvicorn.workers.UvicornWorker", "-b", "0.0.0.0:8000", "--workers", "1", "--timeout", "120"] diff --git a/backend/app/api/mobile_builds.py b/backend/app/api/mobile_builds.py index 3760de7..6faeea5 100644 --- a/backend/app/api/mobile_builds.py +++ b/backend/app/api/mobile_builds.py @@ -68,7 +68,20 @@ def _check_mobileprovision_for_carplay(path: Path) -> bool: 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 [ @@ -85,6 +98,7 @@ def _build_ssh_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: raise RuntimeError("Build host is not configured") return [ @@ -99,14 +113,25 @@ def _build_scp_prefix(host: str) -> list[str]: ] -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 _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 _run_command(cmd: list[str]) -> tuple[int, str]: - return await asyncio.to_thread(_run_sync_command, 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: @@ -142,8 +167,8 @@ def _preflight_issues(request: MobileBuildRequest, files: list[MobileBuildUpload 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_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.") @@ -256,7 +281,8 @@ async def _copy_worker_artifact( 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"] + # 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}") @@ -362,6 +388,196 @@ async def _run_platform_build( 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( @@ -379,12 +595,10 @@ async def _execute_request(request_id: int) -> None: try: if request.platform_android: - await _run_platform_build( + await _run_flutter_container_build( session, request, - "android", - settings.MOBILE_BUILD_ANDROID_HOST, - settings.MOBILE_BUILD_ANDROID_SCRIPT, + settings.FLUTTER_BUILD_HOST, ) if request.platform_ios: diff --git a/backend/app/config.py b/backend/app/config.py index 2755cc8..6745010 100644 --- a/backend/app/config.py +++ b/backend/app/config.py @@ -18,19 +18,25 @@ class Settings(BaseSettings): LOGS_DIR: str = "/logs" GEOLITE2_DB_PATH: str = "/app/geo/GeoLite2-City.mmdb" - # Mobile build orchestration - MOBILE_BUILD_SSH_USER: str = "buildbot" - MOBILE_BUILD_SSH_PASSWORD: str = "buildbot" + # Mobile build orchestration — matches the user created in build/Dockerfile + MOBILE_BUILD_SSH_USER: str = "vscode" + MOBILE_BUILD_SSH_PASSWORD: str = "123" MOBILE_BUILD_SSH_PORT: int = 22 MOBILE_BUILD_ANDROID_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_IOS_SCRIPT: str = "do_ios_build.sh" MOBILE_BUILD_UPLOAD_DIR: str = "/tmp/kmtn_mobile_build_uploads" 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_IOS_ARTIFACT_GLOB: str = "~/mobile_app/build/ios/ipa/*.ipa" + MOBILE_BUILD_ANDROID_ARTIFACT_GLOB: str = "/home/vscode/mobile_app/build/app/outputs/bundle/release/*.aab" + 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 WEBSITE_URL: str = "https://kmountainflower.org" diff --git a/build/Dockerfile b/build/Dockerfile new file mode 100644 index 0000000..f83f5ce --- /dev/null +++ b/build/Dockerfile @@ -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"] diff --git a/debug.keystore b/debug.keystore new file mode 100644 index 0000000..10ec4d9 Binary files /dev/null and b/debug.keystore differ diff --git a/docker-compose-2.yml b/docker-compose-2.yml new file mode 100644 index 0000000..347196a --- /dev/null +++ b/docker-compose-2.yml @@ -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: \ No newline at end of file diff --git a/docker-compose.yml b/docker-compose.yml index 354f7cc..f17b62f 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -31,8 +31,6 @@ services: KMTN_ADMIN_USERNAME : "admin" KMTN_ADMIN_PASSWORD : "123" KMTN_STATIC_UPSTREAM_URL: http://frontend:80 - ports: - - "8000:8000" volumes: - pgsocket:/var/run/postgresql - logs:/logs @@ -55,7 +53,27 @@ services: 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: \ No newline at end of file diff --git a/src/app/admin/admin.component.html b/src/app/admin/admin.component.html index a28bd46..8ca9c3f 100644 --- a/src/app/admin/admin.component.html +++ b/src/app/admin/admin.component.html @@ -618,7 +618,9 @@
{{ artifact.platform }} • {{ artifact.artifact_type }} • {{ artifact.sha256.slice(0, 12) }}…
- Download + } @empty {

No artifacts available yet.

diff --git a/src/app/admin/admin.component.ts b/src/app/admin/admin.component.ts index af01df3..f57497a 100644 --- a/src/app/admin/admin.component.ts +++ b/src/app/admin/admin.component.ts @@ -126,6 +126,7 @@ export class AdminComponent implements OnInit { mobileBuildErrorMessage = ''; mobileBuildPreflightIssues: string[] = []; mobileBuildBusy = false; + downloadingArtifactId: number | null = null; // ── Theme JSON status (mobile build tab) ──────────────────── readonly themeJsonStatus$ = new BehaviorSubject(null); @@ -451,6 +452,26 @@ export class AdminComponent implements OnInit { return `${getAppConfig().apiBaseUrl}${path}`; } + async downloadArtifact(artifact: { id: number; file_name: string }): Promise { + 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 ─────────────────────────────────────────── openShowForm(): void { diff --git a/src/app/services/mobile-build.service.ts b/src/app/services/mobile-build.service.ts index dd790a1..6ca61f2 100644 --- a/src/app/services/mobile-build.service.ts +++ b/src/app/services/mobile-build.service.ts @@ -80,4 +80,11 @@ export class MobileBuildService { {}, ); } + + downloadArtifact(artifactId: number): Observable { + return this.http.get( + `${this.baseUrl}/artifacts/${artifactId}/download`, + { responseType: 'blob' }, + ); + } }