Files
kmtnflower/plan_to_add_build_machine.md
T

21 KiB

Implementation Plan: Flutter Build Machine Container Architecture Overview The system has two deployment topologies:

Main server (docker-compose.yml): db, api, frontend containers Build host (192.168.1.74 or similar): runs the flutter-build container with Flutter 3.41.8, Android SDK, JDK 17 The api container SSHes to the build host, then uses docker exec to run commands inside the flutter-build container, and docker cp for file transfers. iOS builds remain unchanged (direct SSH to a Mac).

Phase 1: Flutter Build Container (New File) File: /workspaces/kmtnflower/build/Dockerfile

This Dockerfile creates the Flutter build environment. It should be based on debian:bookworm-slim as specified by the user.

FROM docker.io/library/debian:bookworm-slim

System dependencies

RUN apt-get update && apt-get install -y --no-install-recommends
git curl unzip wget python3 python3-venv python3-pip
openjdk-17-jdk-headless
&& rm -rf /var/lib/apt/lists/*

Set Java home

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 &&
mkdir -p /home/vscode && chown vscode:vscode /home/vscode

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

EXPOSE 3000 # For any potential debugging; not required for builds CMD ["sleep", "infinity"] Key design decisions for the Dockerfile:

Uses debian:bookworm-slim as the user specified Flutter 3.41.8 pinned explicitly via ARG Android SDK installed via command-line tools (no GUI needed) JDK 17 installed from Debian packages vscode user created with UID 1000 (matches devcontainer convention) flutter precache --android pre-downloads Android artifacts to speed up builds CMD ["sleep", "infinity"] keeps the container running for docker exec Phase 2: docker-compose.yml (New Service Definition) File: /workspaces/kmtnflower/docker-compose.yml

The build service is added to docker-compose.yml but is not started alongside the main stack. It is intended to be deployed independently on the build host. The service definition is provided for documentation and for docker compose build convenience.

Add a new service block:

build: build: context: ./build dockerfile: Dockerfile container_name: flutter-build restart: unless-stopped # The build container runs on the build host, not the main server. # Deploy with: docker compose up -d build (on the build host) volumes: - flutter-cache:/home/vscode/.pub-cache - android-licenses:/opt/android-sdk/licenses Add corresponding volumes at the bottom:

flutter-cache: android-licenses: Important: The build service is NOT included in the main stack's depends_on chain. It runs on a separate machine. The api container communicates with it over SSH.

Phase 3: Backend Dockerfile (SSH Tooling) File: /workspaces/kmtnflower/backend/Dockerfile

The current backend Dockerfile uses python:3.12-slim and does NOT include SSH tools. These are required for the backend to SSH to the build host.

Add after the COPY requirements.txt . line:

RUN apt-get update && apt-get install -y --no-install-recommends
sshpass openssh-client
&& rm -rf /var/lib/apt/lists/* Full modified Dockerfile structure:

FROM docker.io/library/python:3.12-slim AS base

WORKDIR /app

ENV PYTHONUNBUFFERED=1

SSH tools for mobile build orchestration

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

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"] Phase 4: Backend Config (New Settings) File: /workspaces/kmtnflower/backend/app/config.py

Add new settings under the "Mobile build orchestration" section (after line 33, before line 36):

# Flutter container-based Android build (runs on build host via SSH + docker exec)
FLUTTER_CONTAINER_NAME: str = "flutter-build"
FLUTTER_PROJECT_REPO_URL: str = "http://192.168.1.74:30008/kfj001/kryz-go-flutter.git"
FLUTTER_PROJECT_DIR: str = "~/kryz-go-flutter"
FLUTTER_APK_GLOB: str = "~/kryz-go-flutter/build/app/outputs/flutter-apk/app-*.apk"

These follow the existing KMTN_ prefix convention via model_config = {"env_prefix": "KMTN_"}.

Phase 5: mobile_builds.py (Container-Based Android Build) File: /workspaces/kmtnflower/backend/app/api/mobile_builds.py

This is the core change. The strategy is to add new helper functions for the container-based path and modify _run_platform_build() and _execute_request() to route Android through the container path while keeping iOS on the existing SSH-to-Mac path.

5a: New Helper Functions Add these functions after the existing _run_command() helper (around line 109):

── Container-based build helpers (Android via SSH + docker exec) ──

def _build_docker_exec_cmd(container: str, user: str, cmd: str) -> list[str]: """Build a docker exec command to run inside the Flutter container.""" return ["docker", "exec", "-u", user, container, "sh", "-c", cmd]

def _build_docker_cp_to_container(container: str, local: str, remote: str) -> list[str]: """Build a docker cp command to copy a file into the container.""" return ["docker", "cp", local, f"{container}:{remote}"]

def _build_docker_cp_from_container(container: str, remote: str, local: str) -> list[str]: """Build a docker cp command to copy a file from the container.""" return ["docker", "cp", f"{container}:{remote}", local]

async def _run_docker_exec_on_host( host: str, container: str, user: str, cmd: str ) -> tuple[int, str]: """SSH to build host and run a command inside the Flutter container via docker exec.""" ssh_prefix = _build_ssh_prefix(host) docker_cmd = _build_docker_exec_cmd(container, user, cmd) # ssh_prefix + [ "docker exec -u vscode flutter-build sh -c '...'" ] full_cmd = ssh_prefix + [" ".join(shlex.quote(c) for c in docker_cmd)] return await _run_command(full_cmd)

async def _run_docker_cp_on_host( host: str, container: str, local_path: str, remote_path: str, direction: Literal["to_container", "from_container"], ) -> tuple[int, str]: """SSH to build host and run docker cp to/from the Flutter container.""" ssh_prefix = _build_ssh_prefix(host) if direction == "to_container": docker_cmd = _build_docker_cp_to_container(container, local_path, remote_path) else: docker_cmd = _build_docker_cp_from_container(container, remote_path, local_path) full_cmd = ssh_prefix + [" ".join(shlex.quote(c) for c in docker_cmd)] return await _run_command(full_cmd) 5b: New Function -- _run_flutter_container_build() This function implements the full Android build flow inside the Flutter container:

async def _run_flutter_container_build( session: AsyncSession, request: MobileBuildRequest, host: str, ) -> None: """Execute an Android build inside the Flutter container on the build host.

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
"""
container = settings.FLUTTER_CONTAINER_NAME
user = "vscode"
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 {project_dir}/.git && echo EXISTS || echo MISSING"
code, out = await _run_docker_exec_on_host(host, container, user, check_cmd)
if code != 0:
    raise RuntimeError(f"Failed to check project directory: {out}")

if out.strip() == "MISSING":
    await _append_log(session, request, f"[android] Cloning {repo_url} into {project_dir}")
    clone_cmd = f"cd ~ && git clone {repo_url} kryz-go-flutter"
    code, out = await _run_docker_exec_on_host(host, container, user, 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 {project_dir} && git pull"
    code, out = await _run_docker_exec_on_host(host, container, user, pull_cmd)
    await _append_log(session, request, f"[android] Pull output: {out}")

# Step 2: flutter pub get
await _append_log(session, request, "[android] Running flutter pub get...")
pub_cmd = f"cd {project_dir} && flutter pub get"
code, out = await _run_docker_exec_on_host(host, container, user, 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 {project_dir} && "
    f"python3 -m venv .venv && "
    f"source .venv/bin/activate && "
    f"pip install --upgrade pip"
)
code, out = await _run_docker_exec_on_host(host, container, user, 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():
    # First SCP theme.json to the build host's /tmp
    upload_dir, _ = _ensure_dirs()
    scp_prefix = _build_scp_prefix(host)
    code, out = await _run_command(
        scp_prefix + [str(theme_path), f"{settings.MOBILE_BUILD_SSH_USER}@{host}:/tmp/theme.json"]
    )
    if code != 0:
        raise RuntimeError(f"Failed to SCP theme.json to build host: {out}")

    # Then docker cp from build host /tmp into the container
    code, out = await _run_docker_cp_on_host(
        host, container, "/tmp/theme.json", f"{project_dir}/theme.json", "to_container"
    )
    if code != 0:
        raise RuntimeError(f"Failed to copy theme.json into container: {out}")

    await _append_log(session, request, "[android] theme.json copied into 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 to build host /tmp
    scp_prefix = _build_scp_prefix(host)
    remote_tmp = f"/tmp/{row.file_kind}_{uuid.uuid4().hex}{Path(row.original_name).suffix}"
    code, out = await _run_command(
        scp_prefix + [str(local_path), f"{settings.MOBILE_BUILD_SSH_USER}@{host}:{remote_tmp}"]
    )
    if code != 0:
        raise RuntimeError(f"Failed to SCP {row.original_name} to build host: {out}")

    # docker cp into container
    container_dest = f"{project_dir}/{row.original_name}"
    code, out = await _run_docker_cp_on_host(
        host, container, remote_tmp, container_dest, "to_container"
    )
    if code != 0:
        raise RuntimeError(f"Failed to copy {row.original_name} into container: {out}")

    await _append_log(session, request, f"[android] Signing file {row.original_name} copied into container")

# Step 6: Run build_whitelabel.py
await _append_log(session, request, "[android] Running build_whitelabel.py...")
whitelabel_cmd = (
    f"cd {project_dir} && "
    f"source .venv/bin/activate && "
    f"python3 build_whitelabel.py"
)
code, out = await _run_docker_exec_on_host(host, container, user, 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
await _append_log(session, request, "[android] Running flutter build apk...")
build_cmd = f"cd {project_dir} && flutter build apk --release"
code, out = await _run_docker_exec_on_host(host, container, user, 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, container, user, project_dir)
await _append_log(session, request, "[android] APK artifact copied successfully")

5c: New Function -- _copy_container_artifact()

async def _copy_container_artifact( session: AsyncSession, request: MobileBuildRequest, host: str, container: str, user: str, project_dir: str, ) -> None: """Copy the APK from the Flutter container back to the backend.""" _, 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
list_cmd = f"ls -1t {apk_glob} 2>/dev/null | head -n 1"
code, out = await _run_docker_exec_on_host(host, container, user, 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

# Copy APK from container to build host /tmp
remote_tmp = f"/tmp/{file_name}"
code, out = await _run_docker_cp_on_host(
    host, container, remote_tmp, apk_path, "from_container"
)
if code != 0:
    raise RuntimeError(f"Failed to copy APK from container to build host: {out}")

# SCP from build host /tmp to backend
local_path = local_dir / file_name
scp_prefix = _build_scp_prefix(host)
code, out = await _run_command(
    scp_prefix + [f"{settings.MOBILE_BUILD_SSH_USER}@{host}:{remote_tmp}", str(local_path)]
)
if code != 0:
    raise RuntimeError(f"Failed to SCP APK from build host: {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()

5d: Modified _preflight_issues() Update the Android check (currently line 145-146) to accept either the SSH host OR the Flutter container config:

# Android: accept either legacy SSH host or container-based build host
if request.platform_android:
    if not settings.MOBILE_BUILD_ANDROID_HOST:
        issues.append("Android build host (MOBILE_BUILD_ANDROID_HOST) is not configured.")

The existing check is sufficient because the Android build host is still needed (it hosts the Docker container). The difference is HOW commands are executed on that host.

5e: Modified _execute_request() In the _execute_request() function (line 365), replace the Android branch to call the new container-based function:

    try:
        if request.platform_android:
            await _run_flutter_container_build(
                session,
                request,
                settings.MOBILE_BUILD_ANDROID_HOST,
            )

        if request.platform_ios:
            await _run_platform_build(
                session,
                request,
                "ios",
                settings.MOBILE_BUILD_IOS_HOST,
                settings.MOBILE_BUILD_IOS_SCRIPT,
            )

The iOS path remains completely unchanged -- it still uses the existing _run_platform_build() with SSH + shell script.

Phase 6: .env.example Updates File: /workspaces/kmtnflower/.env.example

Add the new config variables:

── Mobile Build (Android — Flutter container on build host) ───────

The build host IP/hostname where the flutter-build container runs

KMTN_MOBILE_BUILD_ANDROID_HOST=192.168.1.100

KMTN_FLUTTER_CONTAINER_NAME=flutter-build

KMTN_FLUTTER_PROJECT_REPO_URL=http://192.168.1.74:30008/kfj001/kryz-go-flutter.git

KMTN_FLUTTER_PROJECT_DIR=~/kryz-go-flutter

KMTN_FLUTTER_APK_GLOB=~/kryz-go-flutter/build/app/outputs/flutter-apk/app-*.apk

Deployment Instructions (Documentation) The build host setup requires these manual steps:

Install Docker on the build host (standard Docker Engine installation) Build the Flutter container: docker compose build build (from the project root, with the compose file) Start the Flutter container: docker compose up -d build Ensure SSH access from the api container to the build host: SSH port is open and reachable buildbot user exists with password auth (or configure key-based auth later) buildbot user has Docker CLI access (add to docker group) Ensure Git credentials are available inside the Flutter container for cloning the repo (either HTTP auth in the URL, or SSH keys baked into the container image) Summary of All File Changes File Action Description build/Dockerfile Create Flutter build container (debian:bookworm-slim, Flutter 3.41.8, Android SDK, JDK 17, python3) docker-compose.yml Modify Add build service definition + flutter-cache, android-licenses volumes backend/Dockerfile Modify Add sshpass and openssh-client packages; create upload/artifact dirs backend/app/config.py Modify Add 4 new settings: FLUTTER_CONTAINER_NAME, FLUTTER_PROJECT_REPO_URL, FLUTTER_PROJECT_DIR, FLUTTER_APK_GLOB backend/app/api/mobile_builds.py Modify Add ~6 new helper functions, new _run_flutter_container_build(), new _copy_container_artifact(), modified _execute_request() to route Android through container path .env.example Modify Document new env vars Risks and Considerations Command quoting: The docker exec commands are passed as a single string via sh -c. Nested quoting (especially with paths containing spaces) needs careful shlex.quote() handling. The helper functions use shlex.quote() on each docker command argument before joining.

Build host Docker access: The buildbot SSH user must be in the docker group on the build host, or the docker commands must be run with sudo. This is a deployment-time configuration.

Git credentials: The Flutter container needs credentials to clone http://192.168.1.74:30008/kfj001/kryz-go-flutter.git. Options: embed SSH key in the container image, use a GitLab deploy token in the URL, or configure git credential helper. This is not handled in the code but should be documented.

APK glob pattern: The default app-*.apk may match both app-release.apk and app-debug.apk. The ls -1t | head -n 1 pattern picks the most recently modified, which should be the release build after flutter build apk --release. Consider using a more specific glob like app-release.apk in production.

Existing _run_platform_build() preservation: The iOS path still uses _run_platform_build() with its SCP-based file upload and shell script execution. This function is kept intact and only the Android path in _execute_request() is changed.

Container state: The Flutter container is expected to be running when builds are triggered. Consider adding a preflight check that verifies the container is accessible via docker exec echo OK.

Critical Files for Implementation /workspaces/kmtnflower/backend/app/api/mobile_builds.py /workspaces/kmtnflower/backend/app/config.py /workspaces/kmtnflower/backend/Dockerfile /workspaces/kmtnflower/docker-compose.yml /workspaces/kmtnflower/build/Dockerfile