diff --git a/.github/workflows/deploy-app.yml b/.github/workflows/deploy-app.yml new file mode 100644 index 0000000..a79e797 --- /dev/null +++ b/.github/workflows/deploy-app.yml @@ -0,0 +1,85 @@ +name: Build And Deploy App + +on: + push: + branches: ["main"] + workflow_dispatch: + +permissions: + contents: read + packages: write + +jobs: + build: + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@v3 + + - name: Log in to GHCR + uses: docker/login-action@v3 + with: + registry: ghcr.io + username: ${{ github.actor }} + password: ${{ secrets.GITHUB_TOKEN }} + + - name: Extract image metadata + id: meta + uses: docker/metadata-action@v5 + with: + images: ghcr.io/georgepearse/genesis + tags: | + type=raw,value=latest + type=sha + + - name: Build and push image + uses: docker/build-push-action@v6 + with: + context: . + file: ./Dockerfile + push: true + tags: ${{ steps.meta.outputs.tags }} + labels: ${{ steps.meta.outputs.labels }} + cache-from: type=gha + cache-to: type=gha,mode=max + + deploy: + runs-on: ubuntu-latest + needs: build + if: ${{ secrets.DEPLOY_HOST != '' && secrets.DEPLOY_USER != '' && secrets.DEPLOY_SSH_KEY != '' && secrets.GHCR_USERNAME != '' && secrets.GHCR_PAT != '' && secrets.GENESIS_ENV != '' }} + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Copy compose file + uses: appleboy/scp-action@v0.1.7 + with: + host: ${{ secrets.DEPLOY_HOST }} + username: ${{ secrets.DEPLOY_USER }} + key: ${{ secrets.DEPLOY_SSH_KEY }} + source: "docker-compose.prod.yml" + target: ${{ secrets.DEPLOY_PATH || '/opt/genesis' }} + + - name: Deploy on server + uses: appleboy/ssh-action@v1.2.0 + env: + DEPLOY_PATH: ${{ secrets.DEPLOY_PATH || '/opt/genesis' }} + GHCR_USERNAME: ${{ secrets.GHCR_USERNAME }} + GHCR_PAT: ${{ secrets.GHCR_PAT }} + GENESIS_ENV: ${{ secrets.GENESIS_ENV }} + with: + host: ${{ secrets.DEPLOY_HOST }} + username: ${{ secrets.DEPLOY_USER }} + key: ${{ secrets.DEPLOY_SSH_KEY }} + envs: DEPLOY_PATH,GHCR_USERNAME,GHCR_PAT,GENESIS_ENV + script: | + set -euo pipefail + mkdir -p "$DEPLOY_PATH" + cd "$DEPLOY_PATH" + printf '%s' "$GENESIS_ENV" > .env + echo "$GHCR_PAT" | docker login ghcr.io -u "$GHCR_USERNAME" --password-stdin + docker compose -f docker-compose.prod.yml pull + docker compose -f docker-compose.prod.yml up -d diff --git a/Dockerfile b/Dockerfile index de44e65..9a20ceb 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,10 +1,19 @@ -# Backend Dockerfile for Genesis Evolution Platform +FROM node:22-bookworm-slim AS frontend-build + +WORKDIR /frontend + +COPY genesis/webui/frontend/package.json ./ +COPY genesis/webui/frontend/package-lock.json ./ +RUN npm ci + +COPY genesis/webui/frontend/ ./ +RUN npm run build + + FROM python:3.12-slim -# Set working directory WORKDIR /app -# Install system dependencies RUN apt-get update && apt-get install -y \ build-essential \ curl \ @@ -12,30 +21,26 @@ RUN apt-get update && apt-get install -y \ postgresql-client \ && rm -rf /var/lib/apt/lists/* -# Copy dependency files COPY pyproject.toml ./ COPY README.md ./ -# Copy application code COPY genesis/ ./genesis/ COPY configs/ ./configs/ COPY examples/ ./examples/ COPY tests/ ./tests/ -# Install Python dependencies +COPY --from=frontend-build /frontend/dist ./genesis/webui/frontend/dist + RUN pip install --no-cache-dir --upgrade pip && \ pip install --no-cache-dir -e . -# Create directory for results and database RUN mkdir -p /app/results /app/data -# Expose port for any potential API/web service EXPOSE 8000 -# Set environment variables ENV PYTHONUNBUFFERED=1 ENV GENESIS_DATA_DIR=/app/data ENV GENESIS_RESULTS_DIR=/app/results +ENV GENESIS_WEBUI_PORT=8000 -# Default command (can be overridden in docker-compose) -CMD ["python", "-m", "genesis.launch_hydra"] +CMD ["python", "-m", "genesis.webui.backend_server"] diff --git a/docker-compose.prod.yml b/docker-compose.prod.yml new file mode 100644 index 0000000..9a5d731 --- /dev/null +++ b/docker-compose.prod.yml @@ -0,0 +1,44 @@ +services: + clickhouse: + image: clickhouse/clickhouse-server:latest + restart: unless-stopped + ports: + - "8123:8123" + - "9000:9000" + volumes: + - clickhouse_data:/var/lib/clickhouse + ulimits: + nofile: + soft: 262144 + hard: 262144 + healthcheck: + test: ["CMD", "wget", "--spider", "-q", "localhost:8123/ping"] + interval: 10s + timeout: 5s + retries: 5 + + backend: + image: ghcr.io/georgepearse/genesis:latest + restart: unless-stopped + depends_on: + clickhouse: + condition: service_healthy + ports: + - "${GENESIS_WEBUI_PORT:-8000}:8000" + environment: + GENESIS_DATA_DIR: /app/data + GENESIS_RESULTS_DIR: /app/results + PYTHONUNBUFFERED: 1 + GENESIS_WEBUI_PORT: 8000 + CLICKHOUSE_URL: http://default:@clickhouse:8123/default + CLICKHOUSE_HOST: clickhouse + CLICKHOUSE_PORT: 8123 + OPENAI_API_KEY: ${OPENAI_API_KEY:-} + ANTHROPIC_API_KEY: ${ANTHROPIC_API_KEY:-} + E2B_API_KEY: ${E2B_API_KEY:-} + volumes: + - ./data:/app/data + - ./results:/app/results + +volumes: + clickhouse_data: diff --git a/docker-compose.yml b/docker-compose.yml index ad1362c..09873cc 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -66,7 +66,7 @@ services: networks: - genesis-network restart: unless-stopped - command: ["python", "-m", "genesis.webui.visualization", "--port", "8000"] + command: ["python", "-m", "genesis.webui.backend_server"] volumes: clickhouse_data: diff --git a/docs/deployment.md b/docs/deployment.md new file mode 100644 index 0000000..183dd45 --- /dev/null +++ b/docs/deployment.md @@ -0,0 +1,89 @@ +# Deployment + +## Backend Decision + +Deploy the Python backend, not the Rust backend. + +Why: +- The Python backend already exposes the WebUI API and legacy endpoints the frontend uses. +- The repository already had Docker and ClickHouse wiring around the Python service. +- The Rust backend currently behaves as an evolution runner, not the integrated system entrypoint. +- The Rust backend has a small passing test suite, but it does not yet own frontend serving, API compatibility, or the operational deployment shape. + +The practical deployment unit today is: +- `genesis.webui.backend_server` +- bundled React frontend +- ClickHouse + +## What Changed + +- `Dockerfile` now builds the React frontend and copies the static bundle into the Python image. +- `genesis/webui/backend_server.py` now serves the built frontend directly. +- `genesis/webui/frontend/src/services/api.ts` now uses same-origin requests by default instead of hardcoding `http://localhost:8000`. +- `docker-compose.yml` now starts the real backend module. +- `.github/workflows/deploy-app.yml` now builds and publishes `ghcr.io/georgepearse/genesis`, then optionally deploys it over SSH. + +## Auto-Deploy Flow + +On every push to `main`: + +1. GitHub Actions builds the production image. +2. The image is pushed to GHCR as `ghcr.io/georgepearse/genesis:latest`. +3. If deploy secrets are configured, the workflow SSHes into your server. +4. The server pulls the latest image and runs `docker compose -f docker-compose.prod.yml up -d`. + +## Required GitHub Secrets + +Set these in GitHub Actions before enabling automatic rollout: + +- `DEPLOY_HOST`: server hostname +- `DEPLOY_USER`: SSH user +- `DEPLOY_SSH_KEY`: private SSH key for the deploy user +- `DEPLOY_PATH`: optional, defaults to `/opt/genesis` +- `GHCR_USERNAME`: GHCR username for the server-side `docker login` +- `GHCR_PAT`: GHCR token with package read access +- `GENESIS_ENV`: complete `.env` file contents for production + +Suggested `GENESIS_ENV` contents: + +```env +GENESIS_WEBUI_PORT=8000 +OPENAI_API_KEY= +ANTHROPIC_API_KEY= +E2B_API_KEY= +``` + +## Server Prerequisites + +The target server needs: + +- Docker Engine +- Docker Compose v2 +- outbound access to `ghcr.io` +- open port for `GENESIS_WEBUI_PORT` + +## First-Time Server Bootstrap + +```bash +mkdir -p /opt/genesis +cd /opt/genesis +``` + +After that, the workflow can manage updates. + +## Manual Deploy + +If you want to deploy without GitHub Actions: + +```bash +docker build -t ghcr.io/georgepearse/genesis:latest . +docker push ghcr.io/georgepearse/genesis:latest +docker compose -f docker-compose.prod.yml pull +docker compose -f docker-compose.prod.yml up -d +``` + +## Current Limitations + +- ClickHouse is still part of the runtime stack, so this is a multi-container deployment. +- Production secrets are injected through `.env`; there is no secret manager integration yet. +- Rust should not replace Python in deployment until it owns the API and frontend contract end-to-end. diff --git a/genesis/webui/backend_server.py b/genesis/webui/backend_server.py index eb1b1f9..d2e9a2b 100755 --- a/genesis/webui/backend_server.py +++ b/genesis/webui/backend_server.py @@ -17,7 +17,8 @@ from fastapi import FastAPI, HTTPException, Query from fastapi.middleware.cors import CORSMiddleware -from fastapi.responses import JSONResponse +from fastapi.responses import FileResponse, JSONResponse +from fastapi.staticfiles import StaticFiles import clickhouse_connect import uvicorn @@ -48,6 +49,7 @@ # ClickHouse client (will be initialized on startup) ch_client = None +FRONTEND_DIST_DIR = Path(__file__).parent / "frontend" / "dist" RUN_ID_RE = re.compile(r"^[A-Za-z0-9._:-]+$") PROGRAM_ID_RE = re.compile(r"^[A-Za-z0-9-]+$") TASK_RE = re.compile(r"^[A-Za-z0-9_.:-]+$") @@ -123,7 +125,9 @@ async def startup_event(): @app.get("/") async def root(): - """Health check endpoint.""" + """Serve the built frontend when available, otherwise return service health.""" + if FRONTEND_DIST_DIR.exists(): + return FileResponse(FRONTEND_DIST_DIR / "index.html") return { "service": "Genesis WebUI API", "status": "running", @@ -131,6 +135,19 @@ async def root(): } +if FRONTEND_DIST_DIR.exists(): + assets_dir = FRONTEND_DIST_DIR / "assets" + if assets_dir.exists(): + app.mount("/assets", StaticFiles(directory=assets_dir), name="frontend-assets") + + favicon_path = FRONTEND_DIST_DIR / "favicon.ico" + if favicon_path.exists(): + + @app.get("/favicon.ico", include_in_schema=False) + async def favicon(): + return FileResponse(favicon_path) + + @app.get("/api/experiments") async def list_experiments( limit: int = Query(50, ge=1, le=500), task: Optional[str] = None @@ -695,6 +712,22 @@ async def database_info(): raise HTTPException(status_code=500, detail=str(e)) +@app.get("/{full_path:path}", include_in_schema=False) +async def frontend_fallback(full_path: str): + """Serve SPA routes from the bundled frontend build.""" + if not FRONTEND_DIST_DIR.exists(): + raise HTTPException(status_code=404, detail="Not Found") + + if full_path.startswith(("api/", "docs", "openapi.json")): + raise HTTPException(status_code=404, detail="Not Found") + + candidate = FRONTEND_DIST_DIR / full_path + if full_path and candidate.is_file(): + return FileResponse(candidate) + + return FileResponse(FRONTEND_DIST_DIR / "index.html") + + def main(): """Run the server.""" port = int(os.getenv("GENESIS_WEBUI_PORT", 8000)) diff --git a/genesis/webui/frontend/src/services/api.ts b/genesis/webui/frontend/src/services/api.ts index 4d0a3bb..21a3fec 100644 --- a/genesis/webui/frontend/src/services/api.ts +++ b/genesis/webui/frontend/src/services/api.ts @@ -1,6 +1,6 @@ import type { DatabaseInfo, Program, MetaFile, MetaContent } from '../types'; -const API_BASE = 'http://localhost:8000'; +const API_BASE = import.meta.env.VITE_API_BASE ?? ''; export async function listDatabases(): Promise { const response = await fetch(`${API_BASE}/list_databases`); diff --git a/mkdocs.yml b/mkdocs.yml index 601bd50..d69fd68 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -61,6 +61,7 @@ nav: - Guides: - Configuration: configuration.md - Available LLMs: available_llms.md + - Deployment: deployment.md - Logging & Analytics: logging.md - ClickHouse Integration: clickhouse.md - WebUI: webui.md