Removes dated plans from the source tree
This commit is contained in:
@@ -1,481 +0,0 @@
|
||||
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
|
||||
@@ -1,76 +0,0 @@
|
||||
# Retire Donation Page, Add Configurable External Donation URL
|
||||
|
||||
## Context
|
||||
|
||||
The donation page (`/donate`) is being retired — it was a tier-based landing page with calls to action. All donation links will instead point to an external URL (e.g., a nonprofit payment processor). Admins configure this URL in the existing Station Config screen.
|
||||
|
||||
## Approach
|
||||
|
||||
1. Add `donation_url` field to the existing `StationConfig` singleton (backend model + schema, frontend interface + admin form)
|
||||
2. Replace all donation links across the site with external `<a href>` links driven by `config().donation_url`, hidden when empty
|
||||
3. Delete the donation page, tier service, tier admin UI, and all backend tier infrastructure
|
||||
|
||||
## Step-by-Step
|
||||
|
||||
### Phase 1 — Add `donation_url` to StationConfig (backend)
|
||||
|
||||
- **`backend/app/models.py`** — Add `donation_url = Column(String(500), nullable=False, default="")` to `StationConfig` class
|
||||
- **`backend/app/schemas.py`** — Add `donation_url: str` to `StationConfigResponse`; add `donation_url: Optional[str] = None` to `StationConfigUpdate`
|
||||
- **`backend/seed.py`** — Add `"donation_url": ""` to `STATION_CONFIG` dict
|
||||
|
||||
### Phase 2 — Add `donation_url` to StationConfig (frontend)
|
||||
|
||||
- **`src/app/interfaces/station-config.ts`** — Add `donation_url: string`
|
||||
- **`src/app/services/station-config.service.ts`** — Add `donation_url: ''` to `DEFAULT_CONFIG`
|
||||
- **`src/app/admin/admin-station-form.component.ts`** — Add `donation_url` property, wire it in `populate()` and `onSubmit()` payload
|
||||
- **`src/app/admin/admin-station-form.component.html`** — Add a "Donation" form section with a URL input field
|
||||
- **`src/app/admin/admin.component.html`** — Show `donation_url` in the station config summary view
|
||||
|
||||
### Phase 3 — Replace donation links with external links
|
||||
|
||||
All links switch from `routerLink="/donate"` to `[href]="config().donation_url"` with `target="_blank" rel="noopener noreferrer"`, wrapped in `@if (config().donation_url)` so they hide when empty.
|
||||
|
||||
- **`src/app/navbar/navbar.component.html`** — "Donate Now" button (line 37)
|
||||
- **`src/app/hero/hero.component.html`** — "Support the Station" button (line 28)
|
||||
- **`src/app/about/about.component.html`** — "Become a Member" CTA section (lines 76-88), wrap entire block in `@if`
|
||||
- **`src/app/footer/footer.component.html`** — "Donate" and "Become a Member" links (lines 26, 33)
|
||||
|
||||
### Phase 4 — Delete donation page
|
||||
|
||||
- Remove `/donate` route from **`src/app/app.routes.ts`** (lines 22-25)
|
||||
- Delete **`src/app/donate/`** directory (3 files: `.ts`, `.html`, `.scss`)
|
||||
|
||||
### Phase 5 — Remove tier infrastructure (frontend)
|
||||
|
||||
- Delete **`src/app/interfaces/tier.ts`**
|
||||
- Delete **`src/app/services/tier.service.ts`**
|
||||
- Delete **`src/app/admin/admin-tier-form.component.*`** (3 files)
|
||||
- **`src/app/admin/admin.component.ts`** — Remove `Tier`/`TierService`/`AdminTierFormComponent` imports, `TabKey` entry, injected service, `tiers$` BehaviorSubject, `editingTier`, `showTierForm`, `reloadTiers()`, and all tier CRUD methods
|
||||
- **`src/app/admin/admin.component.html`** — Remove Tiers tab button, Tiers tab content panel, tier form modal
|
||||
|
||||
### Phase 6 — Remove tier infrastructure (backend)
|
||||
|
||||
- Delete **`backend/app/api/tiers.py`**
|
||||
- **`backend/app/models.py`** — Delete `DonationTier` class
|
||||
- **`backend/app/schemas.py`** — Delete `TierCreate`, `TierUpdate`, `TierResponse`
|
||||
- **`backend/app/main.py`** — Remove `tiers` import and router registration
|
||||
- **`backend/app/api/admin.py`** — Remove `DonationTier` from reset truncation
|
||||
- **`backend/seed.py`** — Remove `DonationTier` import, `TIERS` list, `_upsert_tier()` helper, tier seeding, tier truncation
|
||||
|
||||
### Phase 7 — Contact form cleanup
|
||||
|
||||
- **`src/app/contact/contact.component.html`** — Remove `<option value="donation">Donation Question</option>`
|
||||
|
||||
## Execution Order
|
||||
|
||||
1 → 2 → 3 → 4 → 5 → 6 → 7
|
||||
|
||||
## Verification
|
||||
|
||||
1. Grep for remaining references to `Tier`, `tier`, `DonationTier`, `donate`, `/donate` — none should remain in live code
|
||||
2. `ng build` — no import errors, no unresolved references
|
||||
3. Backend starts without import errors
|
||||
4. Admin station form shows the new "External Donation URL" field
|
||||
5. Setting a URL → all 4 link locations render external links with `target="_blank"`
|
||||
6. Clearing the URL → all 4 link locations hide
|
||||
7. Navigate to `/donate` → 404 or wildcard redirect to home
|
||||
@@ -1,236 +0,0 @@
|
||||
# Plan: Migrate File Uploads from Disk to Database Blob Storage
|
||||
|
||||
## Context
|
||||
|
||||
Uploaded images are currently saved to `backend/uploads/` on disk with UUID-based filenames, served via a `StaticFiles` mount in dev and (presumably) Nginx in prod. The upload logic is copy-pasted across two backend files. The goal is to:
|
||||
|
||||
1. Unify the duplicated upload code into a single endpoint
|
||||
2. Store uploaded binary blobs in PostgreSQL instead of on disk
|
||||
3. Serve them through a new `/api/storage/{blob_id}` endpoint
|
||||
4. Update all frontend uploaders and display sites to work with the new URL format
|
||||
|
||||
Uploaded blob URLs will change from `uploads/{uuid}{ext}` (relative, disk-based) to `/api/storage/{blob_id}` (absolute API path, DB-backed). Static SVG defaults (`svg/small-flower.svg`) are untouched — they are Angular build assets, not uploaded files.
|
||||
|
||||
---
|
||||
|
||||
## Step 1 — Add `StorageBlob` model
|
||||
|
||||
**File:** `backend/app/models.py`
|
||||
|
||||
Add a new SQLAlchemy model at the bottom of the file (after `CommunityHighlight`):
|
||||
|
||||
```python
|
||||
from datetime import datetime, timezone
|
||||
|
||||
class StorageBlob(Base):
|
||||
__tablename__ = "storage_blobs"
|
||||
|
||||
id = Column(Integer, primary_key=True, autoincrement=True)
|
||||
blob_id = Column(String(32), unique=True, nullable=False, index=True)
|
||||
content_type = Column(String(100), nullable=False)
|
||||
size = Column(Integer, nullable=False)
|
||||
data = Column(LargeBinary, nullable=False)
|
||||
created_at = Column(DateTime, nullable=False, default=lambda: datetime.now(timezone.utc))
|
||||
```
|
||||
|
||||
- `blob_id`: 32-char hex string (from `uuid.uuid4().hex`), indexed for fast lookups
|
||||
- `data`: `LargeBinary` → PostgreSQL `BYTEA`, holds the actual image bytes
|
||||
- No FK relationships — blobs are referenced by URL string in existing models
|
||||
|
||||
No Alembic migration needed. `Base.metadata.create_all()` in `main.py` lifespan will create the table on next startup.
|
||||
|
||||
---
|
||||
|
||||
## Step 2 — Create the storage router
|
||||
|
||||
**New file:** `backend/app/api/storage.py`
|
||||
|
||||
Two endpoints in a single router:
|
||||
|
||||
### `POST /upload` (admin-only) — unified upload
|
||||
|
||||
```python
|
||||
ALLOWED_MIME_TYPES = {"image/png", "image/jpeg", "image/webp", "image/avif"}
|
||||
MAX_FILE_SIZE = 5 * 1024 * 1024 # 5 MB
|
||||
|
||||
# Magic byte signatures → MIME type
|
||||
MAGIC_BYTES = {
|
||||
b'\x89PNG\r\n\x1a\n': 'image/png',
|
||||
b'\xff\xd8\xff': 'image/jpeg',
|
||||
b'\x00\x00\x00\x1cftypavif': 'image/avif',
|
||||
b'\x00\x00\x00\x20ftypwebp': 'image/webp',
|
||||
}
|
||||
```
|
||||
|
||||
Logic:
|
||||
1. Read file content, enforce `MAX_FILE_SIZE`
|
||||
2. Validate magic bytes against `MAGIC_BYTES` table — reject if no match (this also rejects SVG, which has no binary magic bytes)
|
||||
3. Generate `blob_id = uuid.uuid4().hex`
|
||||
4. Insert `StorageBlob` row into the database
|
||||
5. Return `{"url": f"/api/storage/{blob_id}"}`
|
||||
|
||||
### `GET /{blob_id}` (public) — blob retrieval
|
||||
|
||||
1. Query `StorageBlob` by `blob_id`
|
||||
2. Return via `Response(content=blob.data, media_type=blob.content_type, headers={"Content-Length": str(blob.size)})`
|
||||
3. Return 404 if not found
|
||||
|
||||
---
|
||||
|
||||
## Step 3 — Register the storage router and remove the old upload router
|
||||
|
||||
**File:** `backend/app/main.py`
|
||||
|
||||
- Add `storage` to the import line (line 13): `from app.api import events, tiers, auth, station_config, history, team, community, shows, storage`
|
||||
- Add router registration: `app.include_router(storage.router, prefix="/api/storage", tags=["storage"])`
|
||||
- Remove `upload` from the import line
|
||||
- Remove `app.include_router(upload.router, prefix="/api/upload", tags=["upload"])`
|
||||
- Remove the `StaticFiles` mount block (lines 104–108) and its `from fastapi.staticfiles import StaticFiles` import (line 6)
|
||||
- Remove the `os.makedirs(uploads_dir, exist_ok=True)` block in the lifespan (lines 71–73)
|
||||
- Check if `os` import is still needed — it is not used elsewhere after removing uploads_dir, so remove it
|
||||
|
||||
---
|
||||
|
||||
## Step 4 — Delete the old disk-based upload file
|
||||
|
||||
**File:** `backend/app/api/upload.py` — **delete entirely**
|
||||
|
||||
This file is replaced by the new `storage.py` router.
|
||||
|
||||
---
|
||||
|
||||
## Step 5 — Remove duplicate upload code from station_config.py
|
||||
|
||||
**File:** `backend/app/api/station_config.py`
|
||||
|
||||
- Delete the `upload_image` endpoint function (lines 59–95)
|
||||
- Delete `UPLOAD_DIR` and `MAX_FILE_SIZE` constants (lines 18–19)
|
||||
- Remove unused imports: `os`, `uuid`, `UploadFile` from the `fastapi` import line (line 6)
|
||||
|
||||
The file will contain only the `GET /` and `PUT /` endpoints for station config CRUD.
|
||||
|
||||
---
|
||||
|
||||
## Step 6 — Add `resolveImageUrl()` helper to the frontend
|
||||
|
||||
**File:** `src/app/services/app-config.service.ts`
|
||||
|
||||
Add a utility function after `getAppConfig()`:
|
||||
|
||||
```typescript
|
||||
/** Resolve an image URL for rendering.
|
||||
* - Absolute HTTP(S) URLs are returned as-is.
|
||||
* - Static SVG assets (svg/…) are served by the Angular build — returned as-is.
|
||||
* - API paths (/api/storage/…) and legacy paths (uploads/…) are prefixed with apiBaseUrl.
|
||||
*/
|
||||
export function resolveImageUrl(url: string): string {
|
||||
if (!url) return '';
|
||||
if (url.startsWith('http://') || url.startsWith('https://')) return url;
|
||||
if (url.startsWith('svg/')) return url;
|
||||
if (url.startsWith('/')) return `${getAppConfig().apiBaseUrl}${url}`;
|
||||
return `${getAppConfig().apiBaseUrl}/${url}`;
|
||||
}
|
||||
```
|
||||
|
||||
Key subtlety: new blob URLs start with `/` (`/api/storage/...`), so they concatenate directly with `apiBaseUrl`. Legacy relative paths (`uploads/...`) still get a `/` separator. Static SVG paths are left alone.
|
||||
|
||||
---
|
||||
|
||||
## Step 7 — Update frontend upload services
|
||||
|
||||
**File:** `src/app/services/upload.service.ts`
|
||||
- Change `baseUrl` from `${getAppConfig().apiBaseUrl}/api/upload/image` to `${getAppConfig().apiBaseUrl}/api/storage/upload`
|
||||
|
||||
**File:** `src/app/services/station-config.service.ts`
|
||||
- Change `uploadImage()` to call `${getAppConfig().apiBaseUrl}/api/storage/upload` instead of `${this.baseUrl}/upload`
|
||||
- This unifies all uploads through the single storage endpoint
|
||||
|
||||
---
|
||||
|
||||
## Step 8 — Update admin form preview methods
|
||||
|
||||
Both admin forms have `getPreviewUrl()` methods that build the preview URL for uploaded images. Update them to use the shared `resolveImageUrl()`:
|
||||
|
||||
**File:** `src/app/admin/admin-team-form.component.ts`
|
||||
- Import `resolveImageUrl` from `../services/app-config.service`
|
||||
- Replace `getPreviewUrl()` body with `return resolveImageUrl(url);`
|
||||
|
||||
**File:** `src/app/admin/admin-show-form.component.ts`
|
||||
- Same change as above
|
||||
|
||||
---
|
||||
|
||||
## Step 9 — Update the schedule component's `resolveAsset()`
|
||||
|
||||
**File:** `src/app/schedule/schedule.component.ts`
|
||||
|
||||
Replace `resolveAsset()` to use the shared helper:
|
||||
|
||||
```typescript
|
||||
import { resolveImageUrl } from '../services/app-config.service';
|
||||
|
||||
resolveAsset(url: string | null): string {
|
||||
return resolveImageUrl(url ?? '');
|
||||
}
|
||||
```
|
||||
|
||||
No template changes needed — the schedule template already calls `resolveAsset()` for `show_art_url` and `hero_image_url`.
|
||||
|
||||
---
|
||||
|
||||
## Step 10 — Add `resolveImageUrl()` to all display components
|
||||
|
||||
Currently, StationConfig image fields (`logo_url`, `hero_background_url`, `hero_icon_url`, `hero_divider_url`) and `member.photo_url` are bound directly in templates without any URL resolution. With the new `/api/storage/{blob_id}` format, they need resolution in dev mode (where the Angular app runs on `:4200` and the API on `:8000`).
|
||||
|
||||
For each component: import `resolveImageUrl` in the `.ts` file and expose it as a public method. Then wrap every image binding in the template.
|
||||
|
||||
### hero.component.ts / hero.component.html
|
||||
- Add method: `resolveImageUrl(url: string): string { return resolveImageUrl(url); }`
|
||||
- Template changes (3 bindings):
|
||||
- Line 4: `[src]="config().hero_background_url"` → `[src]="resolveImageUrl(config().hero_background_url)"`
|
||||
- Line 14: `[src]="config().hero_icon_url"` → `[src]="resolveImageUrl(config().hero_icon_url)"`
|
||||
- Line 40: `[src]="config().hero_divider_url"` → `[src]="resolveImageUrl(config().hero_divider_url)"`
|
||||
|
||||
### navbar.component.ts / navbar.component.html
|
||||
- Add method
|
||||
- Template: Line 4: `[src]="config().logo_url"` → `[src]="resolveImageUrl(config().logo_url)"`
|
||||
|
||||
### footer.component.ts / footer.component.html
|
||||
- Add method
|
||||
- Template: Line 5: `[src]="config().logo_url"` → `[src]="resolveImageUrl(config().logo_url)"`
|
||||
|
||||
### about.component.ts / about.component.html
|
||||
- Add method
|
||||
- Template changes (3 bindings):
|
||||
- Line 5: `[src]="config().hero_icon_url"` → `[src]="resolveImageUrl(config().hero_icon_url)"`
|
||||
- Line 59: `[src]="member.photo_url"` → `[src]="resolveImageUrl(member.photo_url)"`
|
||||
- Line 79: `[src]="config().logo_url"` → `[src]="resolveImageUrl(config().logo_url)"`
|
||||
|
||||
### donate.component.ts / donate.component.html
|
||||
- Add method
|
||||
- Template changes (2 bindings):
|
||||
- Line 6: `[src]="config().hero_icon_url"` → `[src]="resolveImageUrl(config().hero_icon_url)"`
|
||||
- Line 45: `[src]="config().logo_url"` → `[src]="resolveImageUrl(config().logo_url)"`
|
||||
|
||||
### login.component.ts / login.component.html
|
||||
- Add method
|
||||
- Template: Line 4: `[src]="config().logo_url"` → `[src]="resolveImageUrl(config().logo_url)"`
|
||||
|
||||
### schedule.component.ts / schedule.component.html
|
||||
- Already has `resolveAsset()` — update it in Step 9
|
||||
- Add a separate `resolveImageUrl()` method for the StationConfig logo
|
||||
- Template: Line 4: `[src]="config().logo_url"` → `[src]="resolveImageUrl(config().logo_url)"`
|
||||
|
||||
---
|
||||
|
||||
## Verification
|
||||
|
||||
1. **Start the backend** — verify the `storage_blobs` table is created on startup
|
||||
2. **Swagger UI** (`http://localhost:8000/docs`):
|
||||
- `POST /api/storage/upload` — upload a PNG/JPEG/WebP/AVIF image → returns `{"url": "/api/storage/<hex>"}`
|
||||
- Upload an SVG → should return 400 (rejected)
|
||||
- Upload a non-image file with `Content-Type: image/png` → should return 400 (magic byte mismatch)
|
||||
- `GET /api/storage/<hex>` — returns the binary image with correct Content-Type
|
||||
3. **Admin UI** — log in, navigate to admin forms, upload images on team/show/station forms, verify the returned URL is stored in the correct field
|
||||
4. **Public pages** — visit home, about, schedule, donate pages — verify all images render correctly
|
||||
5. **Dev/prod parity** — verify that with `apiBaseUrl: ''` (prod), `/api/storage/{blob_id}` resolves correctly through the API proxy
|
||||
Reference in New Issue
Block a user