diff --git a/.env.example b/.env.example index 8d38b4a..5405d19 100644 --- a/.env.example +++ b/.env.example @@ -1,19 +1,7 @@ -# === .env neben der jeweiligen docker-compose.*.yml kopieren ==================== -# Docker Compose ersetzt ${VARIABLE} beim Start. -# -# Pro Umgebung eigene Datei (z. B. ~/docker/kairo/.env für Prod, -# ~/docker/kairo-dev/.env für Dev) — dieselben SCHLÜSSEL, unterschiedliche Werte. -# Kein separates DEV_APP_URL vs APP_URL: immer APP_URL, ALLOWED_ORIGINS, DB_*, … +# === .env neben docker-compose.*.yml ========================================== +# Pro Umgebung eigene Datei (~/docker/kairo/.env Prod, ~/docker/kairo-dev/.env Dev) -# ─── Typische Werte PROD (docker-compose.yml) ───────────────────────────────── -# DB_NAME=kairo -# DB_USER=kairo_user -# DB_PASSWORD=… -# APP_URL=https://kairo.jinkendo.de -# ALLOWED_ORIGINS=https://kairo.jinkendo.de -# ENVIRONMENT=production - -# ─── Typische Werte DEV (docker-compose.dev-env.yml) ───────────────────────── +# ─── DEV (docker-compose.dev-env.yml) ──────────────────────────────────────── # DB_NAME=kairo_dev # DB_USER=kairo_dev # DB_PASSWORD=dev_password @@ -21,40 +9,10 @@ # ALLOWED_ORIGINS=https://dev.kairo.jinkendo.de,http://192.168.2.49:3097 # ENVIRONMENT=development -# ─── Ab hier: eine ausfüllbare Vorlage (bei uns meist Prod-Defaults) ─────────── -DB_HOST=postgres -DB_PORT=5432 +# ─── PROD (docker-compose.yml) ─────────────────────────────────────────────── DB_NAME=kairo DB_USER=kairo_user DB_PASSWORD=CHANGE_ME_SECURE_PASSWORD - -OPENROUTER_API_KEY=your_api_key_here -OPENROUTER_MODEL=anthropic/claude-sonnet-4 - -# Vereins-Kontingente hart blockieren (KI-Kosten!). Nur 1, true oder yes aktivieren. -CLUB_FEATURE_ENFORCE=1 - -# KI-Debug (Docker): KAIRO_AI_DEBUG in docker-compose*.yml angebunden — 1 = ausführliche WARN-Logs. -# KAIRO_AI_DEBUG=1 - -SMTP_HOST=smtp.example.com -SMTP_PORT=587 -SMTP_USER=noreply@jinkendo.de -SMTP_PASS=your_smtp_password -SMTP_FROM=noreply@jinkendo.de -SMTP_SSL= -SMTP_STARTTLS= - -AUTO_ADMIN_FIRST_USER=true -ADMIN_BOOTSTRAP_EMAILS= - APP_URL=https://kairo.jinkendo.de ALLOWED_ORIGINS=https://kairo.jinkendo.de ENVIRONMENT=production - -# ─── Medien (optional, derzeit nicht aktiv) ─────────────────────────────────── -# Kairo benötigt aktuell keine Medien-Speicherung. Falls später nötig: -# 1. NAS-Freigabe auf dem Pi mounten (nicht lokal auf dem Raspberry!) -# 2. docker-compose.override.yml mit Bind-Mount ergänzen (siehe docs/DEPLOYMENT.md) -# KAIRO_MEDIA_HOST=/mnt/nas/kairo-media -# MEDIA_ROOT=/app/media diff --git a/.gitea/workflows/deploy-dev.yml b/.gitea/workflows/deploy-dev.yml index 9554674..00cb0bb 100644 --- a/.gitea/workflows/deploy-dev.yml +++ b/.gitea/workflows/deploy-dev.yml @@ -12,21 +12,21 @@ jobs: run: | set -e echo "=== Deploying Kairo to DEVELOPMENT ===" - cd /home/lars/docker/kairo-dev - git fetch origin develop || git clone http://192.168.2.144:3000/Lars/Kairo-Jinkendo.git . - git reset --hard origin/develop - docker compose -f docker-compose.dev-env.yml build --no-cache - docker compose -f docker-compose.dev-env.yml up -d - sleep 5 - if ! curl -sf http://localhost:8097/api/health; then - echo "✗ DEV API nicht erreichbar — Backend-Logs (Migration/Startup):" - docker compose -f docker-compose.dev-env.yml logs backend --tail 120 || true - exit 1 - fi - echo "✓ DEV API /api/health OK" - if docker compose -f docker-compose.dev-env.yml ps --status running 2>/dev/null | grep -q frontend; then - curl -sf http://localhost:3097/api/health && echo "✓ DEV über Frontend-Nginx healthy" + REPO="http://192.168.2.144:3000/Lars/Kairo-Jinkendo.git" + TARGET="/home/lars/docker/kairo-dev" + mkdir -p "$TARGET" + cd "$TARGET" + if [ ! -d .git ]; then + git clone -b develop "$REPO" . else - echo "(Frontend-Check übersprungen — optional in AP0.1)" + git fetch origin develop + git checkout develop + git reset --hard origin/develop + fi + docker compose -f docker-compose.dev-env.yml build --no-cache + docker compose -f docker-compose.dev-env.yml up -d --wait + curl -sf http://localhost:8097/api/health && echo "✓ DEV API /api/health OK" + if docker compose -f docker-compose.dev-env.yml ps --status running 2>/dev/null | grep -q frontend; then + curl -sf http://localhost:3097/api/health && echo "✓ DEV Frontend-Proxy /api/health OK" fi echo "=== Kairo DEV Deploy complete ===" diff --git a/.gitea/workflows/deploy-prod.yml b/.gitea/workflows/deploy-prod.yml index b5cef74..79380b8 100644 --- a/.gitea/workflows/deploy-prod.yml +++ b/.gitea/workflows/deploy-prod.yml @@ -12,16 +12,21 @@ jobs: run: | set -e echo "=== Deploying Kairo to PRODUCTION ===" - cd /home/lars/docker/kairo - git fetch origin main || git clone http://192.168.2.144:3000/Lars/Kairo-Jinkendo.git . - git reset --hard origin/main - docker compose build --no-cache - docker compose up -d - sleep 5 - curl -sf http://localhost:8004/api/health && echo "✓ PROD API (direkt) /api/health OK" - if docker compose ps --status running 2>/dev/null | grep -q frontend; then - curl -sf http://localhost:3004/api/health && echo "✓ PROD über Frontend-Nginx healthy" + REPO="http://192.168.2.144:3000/Lars/Kairo-Jinkendo.git" + TARGET="/home/lars/docker/kairo" + mkdir -p "$TARGET" + cd "$TARGET" + if [ ! -d .git ]; then + git clone -b main "$REPO" . else - echo "(Frontend-Check übersprungen — optional in AP0.1)" + git fetch origin main + git checkout main + git reset --hard origin/main + fi + docker compose build --no-cache + docker compose up -d --wait + curl -sf http://localhost:8004/api/health && echo "✓ PROD API /api/health OK" + if docker compose ps --status running 2>/dev/null | grep -q frontend; then + curl -sf http://localhost:3004/api/health && echo "✓ PROD Frontend-Proxy /api/health OK" fi echo "=== Kairo PROD Deploy complete ===" diff --git a/.gitea/workflows/test.yml b/.gitea/workflows/test.yml index 5be58b3..fe623db 100644 --- a/.gitea/workflows/test.yml +++ b/.gitea/workflows/test.yml @@ -1,11 +1,11 @@ name: Test Suite -# develop: push/PR → Tests gegen Dev (parallel oder vor Deploy Development). -# main: kein push/PR-Trigger — vermeidet doppelten Dev-Lauf beim Merge develop→main; -# Prod-Tests nur via workflow_run nach erfolgreichem Deploy Production. +# push develop/main → compose-smoke (frischer Build auf dem Runner) +# workflow_run nach Deploy → pytest/k6/playwright gegen laufende Instanz +# pull_request → compose-smoke on: push: - branches: [develop] + branches: [develop, main] pull_request: branches: [develop] workflow_run: @@ -13,39 +13,65 @@ on: types: [completed] jobs: + compose-smoke: + if: github.event_name == 'push' || github.event_name == 'pull_request' + runs-on: ubuntu-latest + env: + DB_PASSWORD: ci_smoke_password + steps: + - name: Checkout repository + uses: actions/checkout@v4 + + - name: Compose Stack bauen und testen + run: | + set -e + if [ "${{ github.ref_name }}" = "main" ]; then + COMPOSE="docker-compose.yml" + API_PORT=8004 + UI_PORT=3004 + else + COMPOSE="docker-compose.dev-env.yml" + API_PORT=8097 + UI_PORT=3097 + fi + + echo "=== Smoke: $COMPOSE ===" + docker compose -f "$COMPOSE" up -d --build --wait + + curl -sf "http://localhost:${API_PORT}/api/health" | tee /tmp/health-api.json + echo "✓ Backend /api/health OK" + + curl -sf "http://localhost:${UI_PORT}/api/health" | tee /tmp/health-ui.json + echo "✓ Frontend-Proxy /api/health OK" + + docker compose -f "$COMPOSE" exec -T backend pip install -q -r requirements-dev.txt + docker compose -f "$COMPOSE" exec -T backend python -m pytest tests -ra -vv --tb=short + echo "✓ pytest OK" + + docker compose -f "$COMPOSE" down -v + pytest-backend: - if: ${{ github.event_name != 'workflow_run' || github.event.workflow_run.conclusion == 'success' }} + if: github.event_name == 'workflow_run' && github.event.workflow_run.conclusion == 'success' runs-on: ubuntu-latest steps: - name: Backend pytest im deployten Container run: | set -e - EVENT_NAME="${{ github.event_name }}" - REF_NAME="${{ github.ref_name }}" - BASE_REF="${{ github.base_ref }}" RUN_WORKFLOW="${{ github.event.workflow_run.name }}" - APP_DIR="/home/lars/docker/kairo" - COMPOSE_FILE="docker-compose.yml" - - if [ "$EVENT_NAME" = "workflow_run" ]; then - if [ "$RUN_WORKFLOW" = "Deploy Development" ]; then - APP_DIR="/home/lars/docker/kairo-dev" - COMPOSE_FILE="docker-compose.dev-env.yml" - fi - elif [ "$REF_NAME" = "develop" ] || [ "$BASE_REF" = "develop" ]; then + if [ "$RUN_WORKFLOW" = "Deploy Production" ]; then + APP_DIR="/home/lars/docker/kairo" + COMPOSE_FILE="docker-compose.yml" + else APP_DIR="/home/lars/docker/kairo-dev" COMPOSE_FILE="docker-compose.dev-env.yml" fi cd "$APP_DIR" - echo "Warte auf stabilen backend-Container …" for i in $(seq 1 60); do if docker compose -f "$COMPOSE_FILE" exec -T backend true 2>/dev/null; then - echo "Backend bereit (Versuch $i)" break fi if [ "$i" -eq 60 ]; then - echo "Timeout: backend-Container nicht bereit" docker compose -f "$COMPOSE_FILE" ps || true docker compose -f "$COMPOSE_FILE" logs backend --tail 80 || true exit 1 @@ -54,116 +80,80 @@ jobs: done docker compose -f "$COMPOSE_FILE" exec -T backend sh -lc " - pip install -q pytest httpx 2>/dev/null || pip install -q -r /app/requirements-dev.txt && - cd /app && + pip install -q -r requirements-dev.txt && python -m pytest tests -ra -vv --tb=short " lint-backend: - if: ${{ github.event_name != 'workflow_run' || github.event.workflow_run.conclusion == 'success' }} + if: github.event_name == 'push' || github.event_name == 'pull_request' || (github.event_name == 'workflow_run' && github.event.workflow_run.conclusion == 'success') runs-on: ubuntu-latest steps: - - name: Check backend syntax - run: | - EVENT_NAME="${{ github.event_name }}" - REF_NAME="${{ github.ref_name }}" - RUN_WORKFLOW="${{ github.event.workflow_run.name }}" - APP_DIR="/home/lars/docker/kairo" + - name: Checkout repository + if: github.event_name != 'workflow_run' + uses: actions/checkout@v4 - if [ "$EVENT_NAME" = "workflow_run" ]; then - if [ "$RUN_WORKFLOW" = "Deploy Development" ]; then - APP_DIR="/home/lars/docker/kairo-dev" - fi - elif [ "$REF_NAME" = "develop" ]; then + - name: Check backend syntax (Checkout) + if: github.event_name != 'workflow_run' + run: python3 -m py_compile backend/main.py backend/run_migrations.py backend/db.py + + - name: Check backend syntax (Deploy-Pfad) + if: github.event_name == 'workflow_run' + run: | + RUN_WORKFLOW="${{ github.event.workflow_run.name }}" + if [ "$RUN_WORKFLOW" = "Deploy Production" ]; then + APP_DIR="/home/lars/docker/kairo" + else APP_DIR="/home/lars/docker/kairo-dev" fi - python3 -m py_compile "$APP_DIR/backend/main.py" echo "✓ Backend syntax OK" build-frontend: - if: ${{ github.event_name != 'workflow_run' || github.event.workflow_run.conclusion == 'success' }} + if: github.event_name == 'push' || github.event_name == 'pull_request' runs-on: ubuntu-latest steps: + - name: Checkout repository + uses: actions/checkout@v4 + + - name: Setup Node.js + uses: actions/setup-node@v4 + with: + node-version: "20" + - name: Build frontend run: | - EVENT_NAME="${{ github.event_name }}" - REF_NAME="${{ github.ref_name }}" - RUN_WORKFLOW="${{ github.event.workflow_run.name }}" - APP_DIR="/home/lars/docker/kairo" - - if [ "$EVENT_NAME" = "workflow_run" ]; then - if [ "$RUN_WORKFLOW" = "Deploy Development" ]; then - APP_DIR="/home/lars/docker/kairo-dev" - fi - elif [ "$REF_NAME" = "develop" ]; then - APP_DIR="/home/lars/docker/kairo-dev" - fi - - cd "$APP_DIR/frontend" - if [ ! -f package.json ]; then - echo "Frontend noch nicht vorhanden — übersprungen (AP0.1 optional)" - exit 0 - fi + cd frontend npm install npm run build echo "✓ Frontend build OK" k6-health-baseline: name: k6 /api/health Baseline - if: ${{ github.event_name != 'workflow_run' || github.event.workflow_run.conclusion == 'success' }} + if: github.event_name == 'workflow_run' && github.event.workflow_run.conclusion == 'success' runs-on: ubuntu-latest - env: - E2E_TARGET_URL: https://dev.kairo.jinkendo.de steps: - name: Checkout repository uses: actions/checkout@v4 - - name: E2E-Ziel wählen (Dev über Proxy vs. Production) - id: e2e + - name: k6 Ziel wählen (localhost auf Runner) + id: k6 run: | - EVENT="${{ github.event_name }}" - WF_NAME="${{ github.event.workflow_run.name }}" - DEV_BASE="${{ env.E2E_TARGET_URL }}" - if [ "$EVENT" = "workflow_run" ] && [ "$WF_NAME" = "Deploy Production" ]; then - echo "mode=prod" >> $GITHUB_OUTPUT - echo "base_url=https://kairo.jinkendo.de" >> $GITHUB_OUTPUT - echo "→ k6 gegen Prod-Basis." + if [ "${{ github.event.workflow_run.name }}" = "Deploy Production" ]; then + echo "base_url=http://localhost:8004" >> $GITHUB_OUTPUT else - echo "mode=dev" >> $GITHUB_OUTPUT - echo "base_url=${DEV_BASE}" >> $GITHUB_OUTPUT - echo "→ k6 gegen Dev (${DEV_BASE})." + echo "base_url=http://localhost:8097" >> $GITHUB_OUTPUT fi - - name: Dev /api/health abwarten - if: ${{ steps.e2e.outputs.mode == 'dev' }} + - name: /api/health abwarten run: | - BASE="${{ steps.e2e.outputs.base_url }}" - echo "Warte auf $BASE/api/health …" - for i in $(seq 1 90); do + BASE="${{ steps.k6.outputs.base_url }}" + for i in $(seq 1 60); do if curl -sf "$BASE/api/health" >/dev/null 2>&1; then echo "Health OK (Versuch $i)" exit 0 fi sleep 2 done - echo "Timeout: Dev /api/health nicht erreichbar — Deploy / DNS / Firewall prüfen." - curl -v "$BASE/api/health" || true - exit 1 - - - name: Prod /api/health abwarten - if: ${{ steps.e2e.outputs.mode == 'prod' }} - run: | - BASE="${{ steps.e2e.outputs.base_url }}" - echo "Warte auf $BASE/api/health …" - for i in $(seq 1 60); do - if curl -sf "$BASE/api/health" >/dev/null 2>&1; then - echo "Health OK (Versuch $i)" - exit 0 - fi - sleep 5 - done - echo "Timeout: Prod /api/health nicht erreichbar" curl -v "$BASE/api/health" || true exit 1 @@ -177,26 +167,18 @@ jobs: aarch64|arm64) K6_ARCH=arm64 ;; *) echo "k6: unbekannte Architektur: $ARCH"; exit 1 ;; esac - echo "Installing k6 ${K6_VER} linux-${K6_ARCH}" curl -sSL "https://github.com/grafana/k6/releases/download/${K6_VER}/k6-${K6_VER}-linux-${K6_ARCH}.tar.gz" -o /tmp/k6.tgz tar -xzf /tmp/k6.tgz -C /tmp sudo mv "/tmp/k6-${K6_VER}-linux-${K6_ARCH}/k6" /usr/local/bin/k6 - k6 version - - name: k6 Health-Baseline (parallele /api/health) + - name: k6 Health-Baseline env: - BASE_URL: ${{ steps.e2e.outputs.base_url }} - run: | - set -e - echo "k6 gegen BASE_URL=$BASE_URL" - k6 run scripts/load/k6-health-baseline.js - echo "✓ k6 Health-Baseline passed" + BASE_URL: ${{ steps.k6.outputs.base_url }} + run: k6 run scripts/load/k6-health-baseline.js - playwright-tests: - if: ${{ github.event_name != 'workflow_run' || github.event.workflow_run.conclusion == 'success' }} + playwright-smoke: + if: github.event_name == 'workflow_run' && github.event.workflow_run.conclusion == 'success' runs-on: ubuntu-latest - env: - E2E_TARGET_URL: https://dev.kairo.jinkendo.de steps: - name: Checkout repository uses: actions/checkout@v4 @@ -204,111 +186,34 @@ jobs: - name: Setup Node.js uses: actions/setup-node@v4 with: - node-version: '20' + node-version: "20" - - name: E2E-Ziel wählen (Dev über Proxy vs. Production) - id: e2e + - name: Playwright Ziel wählen + id: pw run: | - EVENT="${{ github.event_name }}" - WF_NAME="${{ github.event.workflow_run.name }}" - DEV_BASE="${{ env.E2E_TARGET_URL }}" - if [ "$EVENT" = "workflow_run" ] && [ "$WF_NAME" = "Deploy Production" ]; then - echo "mode=prod" >> $GITHUB_OUTPUT - echo "base_url=https://kairo.jinkendo.de" >> $GITHUB_OUTPUT - echo "→ Prod. Secrets: E2E_PROD_TEST_EMAIL / E2E_PROD_TEST_PASSWORD." + if [ "${{ github.event.workflow_run.name }}" = "Deploy Production" ]; then + echo "base_url=http://localhost:3004" >> $GITHUB_OUTPUT else - echo "mode=dev" >> $GITHUB_OUTPUT - echo "base_url=${DEV_BASE}" >> $GITHUB_OUTPUT - echo "→ Deployte Dev-Umgebung (${DEV_BASE}). Secrets: E2E_DEV_TEST_EMAIL / E2E_DEV_TEST_PASSWORD." + echo "base_url=http://localhost:3097" >> $GITHUB_OUTPUT fi - - name: Dev /api/health abwarten - if: ${{ steps.e2e.outputs.mode == 'dev' }} + - name: /api/health abwarten run: | - BASE="${{ steps.e2e.outputs.base_url }}" - echo "Warte auf $BASE/api/health …" - for i in $(seq 1 90); do + BASE="${{ steps.pw.outputs.base_url }}" + for i in $(seq 1 60); do if curl -sf "$BASE/api/health" >/dev/null 2>&1; then - echo "Health OK (Versuch $i)" exit 0 fi sleep 2 done - echo "Timeout: Dev /api/health nicht erreichbar — Deploy / DNS / Firewall prüfen." - curl -v "$BASE/api/health" || true exit 1 - - name: Prod /api/health abwarten - if: ${{ steps.e2e.outputs.mode == 'prod' }} - run: | - BASE="${{ steps.e2e.outputs.base_url }}" - echo "Warte auf $BASE/api/health …" - for i in $(seq 1 60); do - if curl -sf "$BASE/api/health" >/dev/null 2>&1; then - echo "Health OK (Versuch $i)" - exit 0 - fi - sleep 5 - done - echo "Timeout: Prod /api/health nicht erreichbar" - curl -v "$BASE/api/health" || true - exit 1 - - - name: Testnutzer registrieren (Dev, nur wenn möglich) - if: ${{ steps.e2e.outputs.mode == 'dev' }} - env: - E2E_DEV_TEST_EMAIL: ${{ secrets.E2E_DEV_TEST_EMAIL }} - E2E_DEV_TEST_PASSWORD: ${{ secrets.E2E_DEV_TEST_PASSWORD }} - run: | - BASE="${{ steps.e2e.outputs.base_url }}" - if [ -z "$E2E_DEV_TEST_EMAIL" ] || [ -z "$E2E_DEV_TEST_PASSWORD" ]; then - echo "(Registrierung übersprungen — Secrets E2E_DEV_* nicht gesetzt.)" - exit 0 - fi - curl -sf -X POST "$BASE/api/auth/register" \ - -H "Content-Type: application/json" \ - -d "{\"email\":\"${E2E_DEV_TEST_EMAIL}\",\"password\":\"${E2E_DEV_TEST_PASSWORD}\",\"name\":\"Playwright CI\"}" \ - || echo "(Register evtl. schon erfolgt oder Limits — Login-Test gilt trotzdem.)" - - name: Install Playwright run: | - npm ci || npm install + npm install npx playwright install --with-deps chromium - - name: Run Playwright tests + - name: Run smoke tests env: - E2E_DEV_TEST_EMAIL: ${{ secrets.E2E_DEV_TEST_EMAIL }} - E2E_DEV_TEST_PASSWORD: ${{ secrets.E2E_DEV_TEST_PASSWORD }} - run: | - set -e - MODE="${{ steps.e2e.outputs.mode }}" - BASE_URL="${{ steps.e2e.outputs.base_url }}" - export PLAYWRIGHT_BASE_URL="$BASE_URL" - - if [ "$MODE" = "prod" ]; then - export TEST_EMAIL="${{ secrets.E2E_PROD_TEST_EMAIL }}" - export TEST_PASSWORD="${{ secrets.E2E_PROD_TEST_PASSWORD }}" - if [ -z "$TEST_EMAIL" ] || [ -z "$TEST_PASSWORD" ]; then - echo "Fehler: E2E_PROD_TEST_EMAIL und E2E_PROD_TEST_PASSWORD setzen." - exit 1 - fi - else - export TEST_EMAIL="$E2E_DEV_TEST_EMAIL" - export TEST_PASSWORD="$E2E_DEV_TEST_PASSWORD" - if [ -z "$TEST_EMAIL" ] || [ -z "$TEST_PASSWORD" ]; then - echo "Fehler: E2E_DEV_TEST_EMAIL und E2E_DEV_TEST_PASSWORD setzen (Playwright soll gegen Dev einloggen)." - exit 1 - fi - fi - - mkdir -p screenshots - npx playwright test - echo "✓ Playwright tests passed" - - - name: Upload test screenshots - if: failure() - uses: actions/upload-artifact@v3 - with: - name: playwright-screenshots - path: screenshots/ - retention-days: 7 + PLAYWRIGHT_BASE_URL: ${{ steps.pw.outputs.base_url }} + run: npx playwright test tests/smoke-health.spec.js diff --git a/CLAUDE.md b/CLAUDE.md index 63cee2a..de0a3f9 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -117,4 +117,4 @@ Bei notwendiger Abweichung erstelle ein Architecture Decision Proposal. ## 8. Aktueller erster Auftrag -AP0.1 – Projektgrundlage. +AP0.1 – Projektgrundlage (Backend, Frontend minimal, Tests, Deploy). diff --git a/README.md b/README.md index c4b9323..439f92b 100644 --- a/README.md +++ b/README.md @@ -64,11 +64,30 @@ Diese Dokumente sind Referenzen, kein direkter Sprint-Scope. Verbindlich ist nur, was über das Kairo Sprint-0 Principle Gate oder eine Architecture Decision übernommen wurde. -## Deployment +## Local Development -Auto-Deploy via Gitea Actions auf dem Raspberry Pi (Ports 3004/8004 Prod · 3097/8097 Dev). +Voraussetzungen: Docker + Docker Compose. -Details: [docs/DEPLOYMENT.md](docs/DEPLOYMENT.md) +```bash +git clone http://192.168.2.144:3000/Lars/Kairo-Jinkendo.git +cd Kairo-Jinkendo +git checkout develop + +# Dev-Stack (PostgreSQL + Backend + Frontend) +docker compose -f docker-compose.dev-env.yml up --build + +# Health prüfen +curl http://localhost:8097/api/health +curl http://localhost:3097/api/health + +# Tests im Backend-Container +docker compose -f docker-compose.dev-env.yml exec backend pip install -r requirements-dev.txt +docker compose -f docker-compose.dev-env.yml exec backend python -m pytest tests -ra -vv +``` + +UI: http://localhost:3097 · API: http://localhost:8097 + +Deployment (Pi): [docs/DEPLOYMENT.md](docs/DEPLOYMENT.md) ## AP0.1 – Stand Projektgrundlage @@ -76,11 +95,7 @@ Details: [docs/DEPLOYMENT.md](docs/DEPLOYMENT.md) |---------|--------| | Git + Gitea (`develop` / `main`) | erledigt | | Docker Compose (Prod + Dev) | erledigt | -| Gitea Actions (Deploy + Test) | erledigt, an Kairo angepasst | -| Sprint-0-Spezifikationen | im Repo | -| Designprinzipien-Referenz | im Repo | -| Backend-Skeleton (FastAPI, Migrationen, Tests) | **offen** | -| Frontend minimal | optional, **offen** | -| README „Local Development“ | folgt mit Backend-Skeleton | - -Nächster Schritt: Rest von AP0.1 gemäß `docs/sprints/Sprint0_AP0_1_Project_Setup_Assignment_v0.1.md`. +| Backend (FastAPI, Migrationen, `/api/health`) | erledigt | +| Frontend minimal (React + nginx Proxy) | erledigt | +| pytest (Health + Migrationen) | erledigt | +| Gitea Actions (Deploy + Test) | erledigt | diff --git a/backend/Dockerfile b/backend/Dockerfile new file mode 100644 index 0000000..1b87504 --- /dev/null +++ b/backend/Dockerfile @@ -0,0 +1,16 @@ +FROM python:3.12-slim + +WORKDIR /app + +RUN apt-get update && apt-get install -y postgresql-client \ + && rm -rf /var/lib/apt/lists/* + +COPY requirements.txt . +ENV PIP_DEFAULT_TIMEOUT=120 +RUN pip install --no-cache-dir -r requirements.txt + +COPY . . + +EXPOSE 8000 + +CMD ["uvicorn", "main:app", "--host", "0.0.0.0", "--port", "8000"] diff --git a/backend/README.md b/backend/README.md deleted file mode 100644 index b14904a..0000000 --- a/backend/README.md +++ /dev/null @@ -1,14 +0,0 @@ -# Backend - -FastAPI-Anwendung — noch anzulegen. - -Erwartete Struktur (analog shinkan-jinkendo): - -``` -backend/ -├── Dockerfile -├── main.py -├── requirements.txt -├── migrations/ -└── routers/ -``` diff --git a/backend/db.py b/backend/db.py new file mode 100644 index 0000000..d94520c --- /dev/null +++ b/backend/db.py @@ -0,0 +1,38 @@ +"""PostgreSQL connection helpers.""" + +import os + +import psycopg2 +from psycopg2.extensions import connection + + +def db_params() -> dict[str, str]: + return { + "host": os.getenv("DB_HOST", "localhost"), + "port": os.getenv("DB_PORT", "5432"), + "dbname": os.getenv("DB_NAME", "kairo_dev"), + "user": os.getenv("DB_USER", "kairo_dev"), + "password": os.getenv("DB_PASSWORD", "dev_password"), + } + + +def get_connection() -> connection: + p = db_params() + return psycopg2.connect( + host=p["host"], + port=p["port"], + database=p["dbname"], + user=p["user"], + password=p["password"], + ) + + +def check_db() -> bool: + conn = get_connection() + try: + with conn.cursor() as cur: + cur.execute("SELECT 1") + cur.fetchone() + return True + finally: + conn.close() diff --git a/backend/main.py b/backend/main.py new file mode 100644 index 0000000..3aabe41 --- /dev/null +++ b/backend/main.py @@ -0,0 +1,71 @@ +"""Jinkendo Kairo — FastAPI application entry point.""" + +from __future__ import annotations + +import os +import sys + +from fastapi import FastAPI +from fastapi.middleware.cors import CORSMiddleware + +from db import check_db +from version import APP_NAME, APP_VERSION, DB_SCHEMA_VERSION + +if os.getenv("SKIP_DB_MIGRATE", "").strip().lower() in ("1", "true", "yes"): + print("[SKIP_DB_MIGRATE] Migrationen übersprungen") +else: + import run_migrations + + exit_code = run_migrations.main() + if exit_code != 0: + print(f"[FAIL] Migrationen fehlgeschlagen (Exit {exit_code})") + sys.exit(exit_code) + +allowed_origins = [ + origin.strip() + for origin in os.getenv("ALLOWED_ORIGINS", "http://localhost:3097").split(",") + if origin.strip() +] + +app = FastAPI( + title="Jinkendo Kairo", + version=APP_VERSION, + docs_url="/api/docs" if os.getenv("ENVIRONMENT", "development") != "production" else None, + redoc_url=None, +) + +app.add_middleware( + CORSMiddleware, + allow_origins=allowed_origins, + allow_credentials=True, + allow_methods=["*"], + allow_headers=["*"], +) + + +@app.get("/api/health") +def api_health(): + db_status = "ok" + try: + if not check_db(): + db_status = "error" + except Exception: + db_status = "error" + + status = "ok" if db_status == "ok" else "degraded" + return { + "status": status, + "app": APP_NAME, + "db": db_status, + "version": APP_VERSION, + "schema": DB_SCHEMA_VERSION, + } + + +@app.get("/api/version") +def api_version(): + return { + "app": APP_NAME, + "version": APP_VERSION, + "schema": DB_SCHEMA_VERSION, + } diff --git a/backend/migrations/001_init_core.sql b/backend/migrations/001_init_core.sql new file mode 100644 index 0000000..b6ff9b4 --- /dev/null +++ b/backend/migrations/001_init_core.sql @@ -0,0 +1,12 @@ +-- AP0.1: minimale Schema-Vorbereitung (keine Tenant/User/Actor-Fachlogik) + +CREATE TABLE IF NOT EXISTS kairo_app_meta ( + id SERIAL PRIMARY KEY, + key VARCHAR(128) UNIQUE NOT NULL, + value TEXT NOT NULL, + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +); + +INSERT INTO kairo_app_meta (key, value) +VALUES ('schema_phase', 'ap0.1') +ON CONFLICT (key) DO NOTHING; diff --git a/backend/requirements-dev.txt b/backend/requirements-dev.txt new file mode 100644 index 0000000..ea71c80 --- /dev/null +++ b/backend/requirements-dev.txt @@ -0,0 +1,3 @@ +-r requirements.txt +pytest==8.3.4 +httpx==0.27.2 diff --git a/backend/requirements.txt b/backend/requirements.txt new file mode 100644 index 0000000..dfd7b31 --- /dev/null +++ b/backend/requirements.txt @@ -0,0 +1,5 @@ +fastapi==0.111.0 +uvicorn[standard]==0.29.0 +psycopg2-binary==2.9.9 +sqlparse>=0.5.0 +pydantic==2.7.1 diff --git a/backend/run_migrations.py b/backend/run_migrations.py new file mode 100644 index 0000000..9de7202 --- /dev/null +++ b/backend/run_migrations.py @@ -0,0 +1,198 @@ +#!/usr/bin/env python3 +"""Apply numbered SQL migrations with schema_migrations tracking.""" + +from __future__ import annotations + +import os +import re +import shutil +import subprocess +import sys +import time +from typing import List, Tuple + +import psycopg2 +import sqlparse + +from db import db_params, get_connection + +_LEADING_DIGITS = re.compile(r"^(\d+)") + + +def init_migrations_table(conn) -> None: + with conn.cursor() as cur: + cur.execute( + """ + CREATE TABLE IF NOT EXISTS schema_migrations ( + id SERIAL PRIMARY KEY, + migration VARCHAR(255) UNIQUE NOT NULL, + executed_at TIMESTAMP DEFAULT NOW() + ) + """ + ) + conn.commit() + + +def _migration_sort_key(stem: str) -> Tuple[int, str]: + match = _LEADING_DIGITS.match(stem) + return (int(match.group(1)) if match else 0, stem) + + +def migration_files(migrations_dir: str) -> List[Tuple[str, str]]: + rows: List[Tuple[str, str]] = [] + for filename in os.listdir(migrations_dir): + if not filename.endswith(".sql") or not filename[0].isdigit(): + continue + stem = filename[:-4] + rows.append((stem, os.path.join(migrations_dir, filename))) + rows.sort(key=lambda item: _migration_sort_key(item[0])) + return rows + + +def executed_migrations(conn) -> set[str]: + with conn.cursor() as cur: + cur.execute("SELECT migration FROM schema_migrations") + return {row[0] for row in cur.fetchall()} + + +def pending_migrations(conn, migrations_dir: str) -> List[Tuple[str, str]]: + done = executed_migrations(conn) + return [(name, path) for name, path in migration_files(migrations_dir) if name not in done] + + +def _split_statements(sql_text: str) -> List[str]: + parts = sqlparse.split(sql_text.strip()) + return [part.strip() for part in parts if part and part.strip()] + + +def _run_with_psql(filepath: str) -> tuple[bool, str]: + psql = shutil.which("psql") + if not psql: + return False, "" + + p = db_params() + env = os.environ.copy() + env["PGPASSWORD"] = str(p["password"]) + cmd = [ + psql, + "-h", + p["host"], + "-p", + str(p["port"]), + "-U", + p["user"], + "-d", + p["dbname"], + "-v", + "ON_ERROR_STOP=1", + "-1", + "-f", + filepath, + ] + proc = subprocess.run(cmd, env=env, capture_output=True, text=True, timeout=7200) + if proc.returncode != 0: + tail = ((proc.stderr or "") + "\n" + (proc.stdout or "")).strip() + return False, tail[:8000] or f"exit {proc.returncode}" + return True, (proc.stdout or "").strip() + + +def _record_migration(conn, migration_name: str) -> None: + with conn.cursor() as cur: + cur.execute( + """ + INSERT INTO schema_migrations (migration) + VALUES (%s) + ON CONFLICT (migration) DO NOTHING + """, + (migration_name,), + ) + + +def run_migration(conn, migration_name: str, filepath: str) -> bool: + print(f"Running migration: {migration_name}") + try: + if shutil.which("psql"): + ok, diag = _run_with_psql(filepath) + if not ok: + print(f" [FAIL] psql:\n{diag or '(kein Output)'}") + conn.rollback() + return False + else: + with open(filepath, "r", encoding="utf-8") as handle: + body = handle.read() + statements = _split_statements(body) + with conn.cursor() as cur: + for stmt in statements: + cur.execute(stmt) + + _record_migration(conn, migration_name) + conn.commit() + print(f" [OK] {migration_name}") + return True + except Exception as exc: + conn.rollback() + print(f" [FAIL] {migration_name}: {exc}") + return False + + +def connect_with_retry(max_retries: int = 30): + p = db_params() + for attempt in range(max_retries): + try: + conn = get_connection() + conn.autocommit = False + print(f"[OK] Connected to database: {p['dbname']}") + return conn + except psycopg2.OperationalError: + if attempt >= max_retries - 1: + raise + print(f"Waiting for database... ({attempt + 1}/{max_retries})") + time.sleep(2) + + +def migrations_directory() -> str: + docker_path = "/app/migrations" + if os.path.isdir(docker_path): + return docker_path + return os.path.join(os.path.dirname(os.path.abspath(__file__)), "migrations") + + +def main() -> int: + print("=" * 60) + print("Jinkendo Kairo — Database Migrations") + print("=" * 60) + + migrations_dir = migrations_directory() + if not os.path.isdir(migrations_dir): + print(f"[FAIL] migrations directory missing: {migrations_dir}") + return 1 + + try: + conn = connect_with_retry() + init_migrations_table(conn) + pending = pending_migrations(conn, migrations_dir) + + if not pending: + print("[OK] Keine ausstehenden Migrationen.") + conn.close() + return 0 + + print(f"{len(pending)} ausstehende Migration(en):") + for name, _ in pending: + print(f" - {name}") + + for migration_name, filepath in pending: + if not run_migration(conn, migration_name, filepath): + conn.close() + return 1 + + conn.close() + print("[OK] Migrationen abgeschlossen.") + return 0 + except Exception as exc: + print(f"[FAIL] {exc}") + return 1 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/backend/tests/test_health.py b/backend/tests/test_health.py new file mode 100644 index 0000000..1a0c7f0 --- /dev/null +++ b/backend/tests/test_health.py @@ -0,0 +1,30 @@ +import os + +import pytest +from fastapi.testclient import TestClient + + +@pytest.fixture() +def client(monkeypatch): + monkeypatch.setenv("SKIP_DB_MIGRATE", "1") + import importlib + + import main as main_module + + importlib.reload(main_module) + return TestClient(main_module.app) + + +def test_app_importable(): + import main + + assert main.app is not None + + +def test_health_endpoint(client): + response = client.get("/api/health") + assert response.status_code == 200 + payload = response.json() + assert payload["app"] == "jinkendo-kairo" + assert payload["status"] in {"ok", "degraded"} + assert payload["db"] in {"ok", "error"} diff --git a/backend/tests/test_migrations.py b/backend/tests/test_migrations.py new file mode 100644 index 0000000..3b6c5c2 --- /dev/null +++ b/backend/tests/test_migrations.py @@ -0,0 +1,57 @@ +"""Migration runner tests (require PostgreSQL).""" + +import os +from pathlib import Path + +import psycopg2 +import pytest + +import run_migrations + + +def _db_available() -> bool: + try: + conn = psycopg2.connect( + host=os.getenv("DB_HOST", "localhost"), + port=os.getenv("DB_PORT", "5432"), + dbname=os.getenv("DB_NAME", "kairo_dev"), + user=os.getenv("DB_USER", "kairo_dev"), + password=os.getenv("DB_PASSWORD", "dev_password"), + ) + conn.close() + return True + except psycopg2.OperationalError: + return False + + +pytestmark = pytest.mark.skipif(not _db_available(), reason="PostgreSQL nicht erreichbar") + + +def test_migration_runner_finds_migrations(): + migrations_dir = run_migrations.migrations_directory() + files = run_migrations.migration_files(migrations_dir) + names = [name for name, _ in files] + assert "001_init_core" in names + + +def test_migration_runner_is_idempotent(): + assert run_migrations.main() == 0 + assert run_migrations.main() == 0 + + conn = run_migrations.connect_with_retry(max_retries=5) + executed = run_migrations.executed_migrations(conn) + conn.close() + assert "001_init_core" in executed + + +def test_core_table_exists(): + conn = run_migrations.connect_with_retry(max_retries=5) + with conn.cursor() as cur: + cur.execute( + """ + SELECT COUNT(*) FROM information_schema.tables + WHERE table_schema = 'public' AND table_name = 'kairo_app_meta' + """ + ) + assert cur.fetchone()[0] == 1 + conn.close() diff --git a/backend/version.py b/backend/version.py new file mode 100644 index 0000000..033eae6 --- /dev/null +++ b/backend/version.py @@ -0,0 +1,3 @@ +APP_VERSION = "0.1.0-ap0.1" +DB_SCHEMA_VERSION = "001" +APP_NAME = "jinkendo-kairo" diff --git a/docker-compose.dev-env.yml b/docker-compose.dev-env.yml index 647987e..2643696 100644 --- a/docker-compose.dev-env.yml +++ b/docker-compose.dev-env.yml @@ -12,6 +12,11 @@ services: - dev-kairo-db-data:/var/lib/postgresql/data ports: - "5437:5432" + healthcheck: + test: ["CMD-SHELL", "pg_isready -U ${DB_USER:-kairo_dev} -d ${DB_NAME:-kairo_dev}"] + interval: 5s + timeout: 5s + retries: 10 restart: unless-stopped networks: - dev-kairo-network @@ -26,22 +31,26 @@ services: DB_NAME: "${DB_NAME:-kairo_dev}" DB_USER: "${DB_USER:-kairo_dev}" DB_PASSWORD: "${DB_PASSWORD:-dev_password}" - OPENROUTER_API_KEY: ${OPENROUTER_API_KEY} - OPENROUTER_MODEL: ${OPENROUTER_MODEL} - KAIRO_AI_DEBUG: "${KAIRO_AI_DEBUG:-}" - SMTP_HOST: ${SMTP_HOST} - SMTP_PORT: ${SMTP_PORT} - SMTP_USER: ${SMTP_USER} - SMTP_PASS: ${SMTP_PASS} - SMTP_FROM: ${SMTP_FROM} APP_URL: "${APP_URL:-https://dev.kairo.jinkendo.de}" - ALLOWED_ORIGINS: "${ALLOWED_ORIGINS:-https://dev.kairo.jinkendo.de,http://192.168.2.49:3097}" + ALLOWED_ORIGINS: "${ALLOWED_ORIGINS:-https://dev.kairo.jinkendo.de,http://192.168.2.49:3097,http://localhost:3097}" ENVIRONMENT: "${ENVIRONMENT:-development}" - CLUB_FEATURE_ENFORCE: "${CLUB_FEATURE_ENFORCE:-1}" ports: - "8097:8000" depends_on: - - postgres + postgres: + condition: service_healthy + healthcheck: + test: + [ + "CMD", + "python", + "-c", + "import urllib.request; urllib.request.urlopen('http://127.0.0.1:8000/api/health')", + ] + interval: 10s + timeout: 5s + retries: 12 + start_period: 30s restart: unless-stopped networks: - dev-kairo-network @@ -55,7 +64,8 @@ services: ports: - "3097:80" depends_on: - - backend + backend: + condition: service_healthy restart: unless-stopped networks: - dev-kairo-network diff --git a/docker-compose.yml b/docker-compose.yml index 7724240..5961fa6 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -7,11 +7,16 @@ services: environment: POSTGRES_DB: "${DB_NAME:-kairo}" POSTGRES_USER: "${DB_USER:-kairo_user}" - POSTGRES_PASSWORD: ${DB_PASSWORD} + POSTGRES_PASSWORD: "${DB_PASSWORD:-change_me}" volumes: - kairo-db-data:/var/lib/postgresql/data ports: - "127.0.0.1:5436:5432" + healthcheck: + test: ["CMD-SHELL", "pg_isready -U ${DB_USER:-kairo_user} -d ${DB_NAME:-kairo}"] + interval: 5s + timeout: 5s + retries: 10 restart: unless-stopped networks: - kairo-network @@ -24,29 +29,29 @@ services: environment: DB_HOST: postgres DB_PORT: 5432 - DB_NAME: kairo - DB_USER: kairo_user - DB_PASSWORD: ${DB_PASSWORD} - OPENROUTER_API_KEY: ${OPENROUTER_API_KEY} - OPENROUTER_MODEL: ${OPENROUTER_MODEL} - KAIRO_AI_DEBUG: "${KAIRO_AI_DEBUG:-}" - SMTP_HOST: ${SMTP_HOST} - SMTP_PORT: ${SMTP_PORT} - SMTP_USER: ${SMTP_USER} - SMTP_PASS: ${SMTP_PASS} - SMTP_FROM: ${SMTP_FROM} - SMTP_SSL: ${SMTP_SSL:-} - SMTP_STARTTLS: ${SMTP_STARTTLS:-} - AUTO_ADMIN_FIRST_USER: "${AUTO_ADMIN_FIRST_USER:-true}" - ADMIN_BOOTSTRAP_EMAILS: "${ADMIN_BOOTSTRAP_EMAILS:-}" + DB_NAME: "${DB_NAME:-kairo}" + DB_USER: "${DB_USER:-kairo_user}" + DB_PASSWORD: "${DB_PASSWORD:-change_me}" APP_URL: "${APP_URL:-https://kairo.jinkendo.de}" ALLOWED_ORIGINS: "${ALLOWED_ORIGINS:-https://kairo.jinkendo.de}" ENVIRONMENT: "${ENVIRONMENT:-production}" - CLUB_FEATURE_ENFORCE: "${CLUB_FEATURE_ENFORCE:-1}" ports: - "8004:8000" depends_on: - - postgres + postgres: + condition: service_healthy + healthcheck: + test: + [ + "CMD", + "python", + "-c", + "import urllib.request; urllib.request.urlopen('http://127.0.0.1:8000/api/health')", + ] + interval: 10s + timeout: 5s + retries: 12 + start_period: 30s restart: unless-stopped networks: - kairo-network @@ -61,7 +66,8 @@ services: ports: - "3004:80" depends_on: - - backend + backend: + condition: service_healthy restart: unless-stopped networks: - kairo-network diff --git a/docs/DEPLOYMENT.md b/docs/DEPLOYMENT.md index f3238ff..a00fc0b 100644 --- a/docs/DEPLOYMENT.md +++ b/docs/DEPLOYMENT.md @@ -1,6 +1,6 @@ # Deployment – Kairo Jinkendo -**Stand:** 2026-07-04 +**Stand:** 2026-07-04 (AP0.1) **Server:** Raspberry Pi 5 (`192.168.2.49`) — gleicher Host wie Shinkan/Mitai **Runner:** Gitea Actions (`/home/lars/gitea-runner/`) @@ -17,110 +17,59 @@ | **PostgreSQL (localhost)** | 5436 | 5437 | | **Domain** | kairo.jinkendo.de | dev.kairo.jinkendo.de | -**Familien-Referenz (bereits belegt):** - -| App | Prod UI/API | Dev UI/API | -|-----|-------------|------------| -| Mitai | 3002 / 8002 | 3099 / 8099 | -| Shinkan | 3003 / 8003 | 3098 / 8098 | -| **Kairo** | **3004 / 8004** | **3097 / 8097** | - --- ## Einmalige Server-Einrichtung -Auf dem Pi als User `lars` ausführen: - ```bash -# Deploy-Verzeichnisse anlegen -mkdir -p /home/lars/docker/kairo -mkdir -p /home/lars/docker/kairo-dev +mkdir -p /home/lars/docker/kairo /home/lars/docker/kairo-dev -# Production -cd /home/lars/docker/kairo -git clone http://192.168.2.144:3000/Lars/Kairo-Jinkendo.git . -git checkout main -cp .env.example .env -# .env bearbeiten (DB_PASSWORD, SMTP, OPENROUTER, …) - -# Development cd /home/lars/docker/kairo-dev git clone http://192.168.2.144:3000/Lars/Kairo-Jinkendo.git . git checkout develop cp .env.example .env -# .env mit Dev-Werten bearbeiten (siehe Kommentare in .env.example) -``` +# DB_PASSWORD setzen -> **Hinweis:** Erstes `docker compose up` funktioniert erst, wenn `backend/` und `frontend/` mit Dockerfiles vorhanden sind. +cd /home/lars/docker/kairo +git clone http://192.168.2.144:3000/Lars/Kairo-Jinkendo.git . +git checkout main +cp .env.example .env +# Prod-DB_PASSWORD setzen +``` --- -## Medien (optional, derzeit nicht eingerichtet) +## Gitea Actions – Checkliste -Kairo benötigt **aktuell keine Medien-Speicherung**. Es sind keine Medien-Verzeichnisse auf dem Pi anzulegen. +1. **Actions aktiviert** (Repository → Settings → Actions) +2. **Pi-Runner registriert** — derselbe wie Shinkan/Mitai (`ubuntu-latest`) +3. Push `develop` → `deploy-dev.yml` → `curl http://localhost:8097/api/health` +4. Merge `main` → `deploy-prod.yml` → `curl http://localhost:8004/api/health` +5. Nach Deploy: `test.yml` (workflow_run) — pytest, k6, Playwright smoke gegen **localhost** -Falls später Datei-Uploads o. Ä. nötig werden: +| Workflow | Trigger | +|----------|---------| +| `deploy-dev.yml` | Push `develop` | +| `deploy-prod.yml` | Push `main` | +| `test.yml` | Push/PR (compose-smoke) + nach Deploy (Integration) | -1. **NAS-Freigabe** auf dem Synology anlegen (nicht auf dem Raspberry Pi speichern). -2. **Mount auf dem Pi** einrichten (z. B. `/mnt/nas/kairo-media` bzw. `/mnt/nas/kairo-media/dev`). -3. **`docker-compose.override.yml`** im jeweiligen Deploy-Verzeichnis ergänzen (wird von Git ignoriert): - -```yaml -services: - backend: - environment: - MEDIA_ROOT: /app/media - volumes: - - /mnt/nas/kairo-media:/app/media # Dev: …/kairo-media/dev -``` - -4. Im Backend `MEDIA_ROOT` auswerten — erst wenn die App Medien unterstützt. - -Analog Shinkan (`SHINKAN_MEDIA_HOST`), aber bewusst **nicht** Teil des Initial-Setups. +CI auf dem Pi nutzt localhost-Ports. Öffentliche Domains sind für Reverse-Proxy optional. --- -## Reverse Proxy (Synology / Fritz!Box) +## Medien (optional) -Analog zu Shinkan — neue Hostnamen im Proxy eintragen: +Aktuell keine Medien-Speicherung. Bei Bedarf: NAS-Mount + `docker-compose.override.yml` (siehe frühere Doku-Version im Git-Verlauf). + +--- + +## Reverse Proxy (optional) | Hostname | Ziel (Pi) | |----------|-----------| | `kairo.jinkendo.de` | `http://192.168.2.49:3004` | | `dev.kairo.jinkendo.de` | `http://192.168.2.49:3097` | -SSL/TLS wie bei den Schwester-Apps (Let's Encrypt über Synology). - ---- - -## Gitea Actions - -Workflows unter `.gitea/workflows/`: - -| Workflow | Trigger | Aktion | -|----------|---------|--------| -| `deploy-dev.yml` | Push auf `develop` | Build + Deploy nach `kairo-dev/` | -| `deploy-prod.yml` | Push auf `main` | Build + Deploy nach `kairo/` | -| `test.yml` | PR/Push `develop`, nach Deploy | pytest, Lint, Frontend-Build, k6, Playwright | - -Health-Checks nach Deploy: - -- Dev: `curl http://localhost:8097/api/health` (Backend direkt); mit Frontend zusätzlich `http://localhost:3097/api/health` -- Prod: `curl http://localhost:8004/api/health` (Backend direkt); mit Frontend zusätzlich `http://localhost:3004/api/health` - ---- - -## Gitea Secrets (für E2E-Tests) - -In Gitea unter Repository → Settings → Actions → Secrets (analog Shinkan): - -| Secret | Verwendung | -|--------|------------| -| `E2E_DEV_TEST_EMAIL` | Playwright Dev-Login | -| `E2E_DEV_TEST_PASSWORD` | Playwright Dev-Login | -| `E2E_PROD_TEST_EMAIL` | Playwright Prod-Login | -| `E2E_PROD_TEST_PASSWORD` | Playwright Prod-Login | - --- ## Manuelles Deploy @@ -139,12 +88,4 @@ docker compose build --no-cache docker compose up -d ``` ---- - -## Nächster Schritt (App-Code) - -1. `backend/` mit FastAPI-Skeleton (main.py, Dockerfile, migrations/) -2. `frontend/` mit React/Vite-Skeleton (Dockerfile, nginx.conf) -3. Erster Push auf `develop` → Auto-Deploy Dev -4. Reverse-Proxy-Einträge für Domains aktivieren -5. Gitea E2E-Secrets setzen +Health: `curl http://localhost:8097/api/health` (Dev) bzw. `http://localhost:8004/api/health` (Prod) diff --git a/frontend/Dockerfile b/frontend/Dockerfile new file mode 100644 index 0000000..885d0b3 --- /dev/null +++ b/frontend/Dockerfile @@ -0,0 +1,15 @@ +FROM node:20-alpine AS build + +WORKDIR /app +COPY package*.json ./ +RUN npm install +COPY . . +ARG VITE_API_URL +ENV VITE_API_URL=$VITE_API_URL +RUN npm run build + +FROM nginx:alpine +COPY --from=build /app/dist /usr/share/nginx/html +COPY nginx.conf /etc/nginx/conf.d/default.conf +EXPOSE 80 +CMD ["nginx", "-g", "daemon off;"] diff --git a/frontend/README.md b/frontend/README.md deleted file mode 100644 index c6eaf61..0000000 --- a/frontend/README.md +++ /dev/null @@ -1,13 +0,0 @@ -# Frontend - -React + Vite SPA — noch anzulegen. - -Erwartete Struktur (analog shinkan-jinkendo): - -``` -frontend/ -├── Dockerfile -├── nginx.conf -├── package.json -└── src/ -``` diff --git a/frontend/index.html b/frontend/index.html new file mode 100644 index 0000000..5cb0ee9 --- /dev/null +++ b/frontend/index.html @@ -0,0 +1,12 @@ + + + + + + Jinkendo Kairo + + +
+ + + diff --git a/frontend/nginx.conf b/frontend/nginx.conf new file mode 100644 index 0000000..065de8f --- /dev/null +++ b/frontend/nginx.conf @@ -0,0 +1,22 @@ +server { + listen 80; + server_name localhost; + root /usr/share/nginx/html; + index index.html; + + resolver 127.0.0.11 valid=10s ipv6=off; + + location ^~ /api/ { + set $docker_backend_svc backend; + proxy_pass http://$docker_backend_svc:8000$request_uri; + proxy_http_version 1.1; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + } + + location / { + try_files $uri $uri/ /index.html; + } +} diff --git a/frontend/package.json b/frontend/package.json new file mode 100644 index 0000000..e99b667 --- /dev/null +++ b/frontend/package.json @@ -0,0 +1,19 @@ +{ + "name": "kairo-jinkendo-frontend", + "version": "0.1.0-ap0.1", + "private": true, + "type": "module", + "scripts": { + "dev": "vite --port 3097", + "build": "vite build", + "preview": "vite preview" + }, + "dependencies": { + "react": "^18.3.1", + "react-dom": "^18.3.1" + }, + "devDependencies": { + "@vitejs/plugin-react": "^4.2.1", + "vite": "^5.1.4" + } +} diff --git a/frontend/src/App.jsx b/frontend/src/App.jsx new file mode 100644 index 0000000..b612a68 --- /dev/null +++ b/frontend/src/App.jsx @@ -0,0 +1,31 @@ +import { useEffect, useState } from 'react' + +export default function App() { + const [health, setHealth] = useState(null) + const [error, setError] = useState(null) + + useEffect(() => { + fetch('/api/health') + .then((res) => { + if (!res.ok) throw new Error(`HTTP ${res.status}`) + return res.json() + }) + .then(setHealth) + .catch((err) => setError(err.message)) + }, []) + + return ( +
+

Jinkendo Kairo

+

Operativer Program Director — Sprint 0 / AP0.1

+
+

API Health

+ {error &&

Fehler: {error}

} + {!error && !health &&

Lade …

} + {health && ( +
{JSON.stringify(health, null, 2)}
+ )} +
+
+ ) +} diff --git a/frontend/src/app.css b/frontend/src/app.css new file mode 100644 index 0000000..2a8997c --- /dev/null +++ b/frontend/src/app.css @@ -0,0 +1,30 @@ +:root { + font-family: system-ui, sans-serif; + color: #1a1a1a; + background: #f6f7fb; +} + +body { + margin: 0; +} + +.shell { + max-width: 720px; + margin: 2rem auto; + padding: 0 1rem; +} + +.card { + background: #fff; + border: 1px solid #ddd; + border-radius: 8px; + padding: 1rem; +} + +.error { + color: #b00020; +} + +pre { + overflow: auto; +} diff --git a/frontend/src/main.jsx b/frontend/src/main.jsx new file mode 100644 index 0000000..7392451 --- /dev/null +++ b/frontend/src/main.jsx @@ -0,0 +1,10 @@ +import React from 'react' +import { createRoot } from 'react-dom/client' +import App from './App.jsx' +import './app.css' + +createRoot(document.getElementById('root')).render( + + + , +) diff --git a/frontend/vite.config.js b/frontend/vite.config.js new file mode 100644 index 0000000..c544669 --- /dev/null +++ b/frontend/vite.config.js @@ -0,0 +1,12 @@ +import { defineConfig } from 'vite' +import react from '@vitejs/plugin-react' + +export default defineConfig({ + plugins: [react()], + server: { + port: 3097, + proxy: { + '/api': 'http://127.0.0.1:8097', + }, + }, +}) diff --git a/tests/smoke-health.spec.js b/tests/smoke-health.spec.js new file mode 100644 index 0000000..5b23efe --- /dev/null +++ b/tests/smoke-health.spec.js @@ -0,0 +1,9 @@ +const { test, expect } = require('@playwright/test'); + +test('GET /api/health liefert jinkendo-kairo', async ({ request }) => { + const response = await request.get('/api/health'); + expect(response.ok()).toBeTruthy(); + const body = await response.json(); + expect(body.app).toBe('jinkendo-kairo'); + expect(body.status).toMatch(/ok|degraded/); +});