AP0.1: Backend, Frontend minimal, Tests und CI fuer Dev/Prod-Deploy.
Some checks failed
Deploy Development / deploy (push) Successful in 41s
Test Suite / compose-smoke (push) Failing after 3s
Test Suite / pytest-backend (push) Has been skipped
Test Suite / lint-backend (push) Successful in 1s
Test Suite / build-frontend (push) Successful in 1m7s
Test Suite / k6 /api/health Baseline (push) Has been skipped
Test Suite / playwright-smoke (push) Has been skipped

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
Lars 2026-07-04 19:15:43 +02:00
parent 1ce20d8bdb
commit 0e5bf72dee
30 changed files with 828 additions and 422 deletions

View File

@ -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

View File

@ -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 ==="

View File

@ -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 ==="

View File

@ -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 }}"
if [ "$RUN_WORKFLOW" = "Deploy Production" ]; then
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
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
- name: Checkout repository
if: github.event_name != 'workflow_run'
uses: actions/checkout@v4
- 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: |
EVENT_NAME="${{ github.event_name }}"
REF_NAME="${{ github.ref_name }}"
RUN_WORKFLOW="${{ github.event.workflow_run.name }}"
if [ "$RUN_WORKFLOW" = "Deploy Production" ]; then
APP_DIR="/home/lars/docker/kairo"
if [ "$EVENT_NAME" = "workflow_run" ]; then
if [ "$RUN_WORKFLOW" = "Deploy Development" ]; then
else
APP_DIR="/home/lars/docker/kairo-dev"
fi
elif [ "$REF_NAME" = "develop" ]; then
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

View File

@ -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).

View File

@ -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 |

16
backend/Dockerfile Normal file
View File

@ -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"]

View File

@ -1,14 +0,0 @@
# Backend
FastAPI-Anwendung — noch anzulegen.
Erwartete Struktur (analog shinkan-jinkendo):
```
backend/
├── Dockerfile
├── main.py
├── requirements.txt
├── migrations/
└── routers/
```

38
backend/db.py Normal file
View File

@ -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()

71
backend/main.py Normal file
View File

@ -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,
}

View File

@ -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;

View File

@ -0,0 +1,3 @@
-r requirements.txt
pytest==8.3.4
httpx==0.27.2

5
backend/requirements.txt Normal file
View File

@ -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

198
backend/run_migrations.py Normal file
View File

@ -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())

View File

@ -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"}

View File

@ -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()

3
backend/version.py Normal file
View File

@ -0,0 +1,3 @@
APP_VERSION = "0.1.0-ap0.1"
DB_SCHEMA_VERSION = "001"
APP_NAME = "jinkendo-kairo"

View File

@ -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

View File

@ -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

View File

@ -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)

15
frontend/Dockerfile Normal file
View File

@ -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;"]

View File

@ -1,13 +0,0 @@
# Frontend
React + Vite SPA — noch anzulegen.
Erwartete Struktur (analog shinkan-jinkendo):
```
frontend/
├── Dockerfile
├── nginx.conf
├── package.json
└── src/
```

12
frontend/index.html Normal file
View File

@ -0,0 +1,12 @@
<!doctype html>
<html lang="de">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Jinkendo Kairo</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.jsx"></script>
</body>
</html>

22
frontend/nginx.conf Normal file
View File

@ -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;
}
}

19
frontend/package.json Normal file
View File

@ -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"
}
}

31
frontend/src/App.jsx Normal file
View File

@ -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 (
<main className="shell">
<h1>Jinkendo Kairo</h1>
<p>Operativer Program Director Sprint 0 / AP0.1</p>
<section className="card">
<h2>API Health</h2>
{error && <p className="error">Fehler: {error}</p>}
{!error && !health && <p>Lade </p>}
{health && (
<pre>{JSON.stringify(health, null, 2)}</pre>
)}
</section>
</main>
)
}

30
frontend/src/app.css Normal file
View File

@ -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;
}

10
frontend/src/main.jsx Normal file
View File

@ -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(
<React.StrictMode>
<App />
</React.StrictMode>,
)

12
frontend/vite.config.js Normal file
View File

@ -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',
},
},
})

View File

@ -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/);
});