initial base setup #1
50
.env.example
50
.env.example
|
|
@ -1,19 +1,7 @@
|
||||||
# === .env neben der jeweiligen docker-compose.*.yml kopieren ====================
|
# === .env neben docker-compose.*.yml ==========================================
|
||||||
# Docker Compose ersetzt ${VARIABLE} beim Start.
|
# Pro Umgebung eigene Datei (~/docker/kairo/.env Prod, ~/docker/kairo-dev/.env Dev)
|
||||||
#
|
|
||||||
# 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_*, …
|
|
||||||
|
|
||||||
# ─── Typische Werte PROD (docker-compose.yml) ─────────────────────────────────
|
# ─── DEV (docker-compose.dev-env.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) ─────────────────────────
|
|
||||||
# DB_NAME=kairo_dev
|
# DB_NAME=kairo_dev
|
||||||
# DB_USER=kairo_dev
|
# DB_USER=kairo_dev
|
||||||
# DB_PASSWORD=dev_password
|
# DB_PASSWORD=dev_password
|
||||||
|
|
@ -21,40 +9,10 @@
|
||||||
# ALLOWED_ORIGINS=https://dev.kairo.jinkendo.de,http://192.168.2.49:3097
|
# ALLOWED_ORIGINS=https://dev.kairo.jinkendo.de,http://192.168.2.49:3097
|
||||||
# ENVIRONMENT=development
|
# ENVIRONMENT=development
|
||||||
|
|
||||||
# ─── Ab hier: eine ausfüllbare Vorlage (bei uns meist Prod-Defaults) ───────────
|
# ─── PROD (docker-compose.yml) ───────────────────────────────────────────────
|
||||||
DB_HOST=postgres
|
|
||||||
DB_PORT=5432
|
|
||||||
DB_NAME=kairo
|
DB_NAME=kairo
|
||||||
DB_USER=kairo_user
|
DB_USER=kairo_user
|
||||||
DB_PASSWORD=CHANGE_ME_SECURE_PASSWORD
|
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
|
APP_URL=https://kairo.jinkendo.de
|
||||||
ALLOWED_ORIGINS=https://kairo.jinkendo.de
|
ALLOWED_ORIGINS=https://kairo.jinkendo.de
|
||||||
ENVIRONMENT=production
|
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
|
|
||||||
|
|
|
||||||
|
|
@ -12,21 +12,21 @@ jobs:
|
||||||
run: |
|
run: |
|
||||||
set -e
|
set -e
|
||||||
echo "=== Deploying Kairo to DEVELOPMENT ==="
|
echo "=== Deploying Kairo to DEVELOPMENT ==="
|
||||||
cd /home/lars/docker/kairo-dev
|
REPO="http://192.168.2.144:3000/Lars/Kairo-Jinkendo.git"
|
||||||
git fetch origin develop || git clone http://192.168.2.144:3000/Lars/Kairo-Jinkendo.git .
|
TARGET="/home/lars/docker/kairo-dev"
|
||||||
git reset --hard origin/develop
|
mkdir -p "$TARGET"
|
||||||
docker compose -f docker-compose.dev-env.yml build --no-cache
|
cd "$TARGET"
|
||||||
docker compose -f docker-compose.dev-env.yml up -d
|
if [ ! -d .git ]; then
|
||||||
sleep 5
|
git clone -b develop "$REPO" .
|
||||||
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"
|
|
||||||
else
|
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
|
fi
|
||||||
echo "=== Kairo DEV Deploy complete ==="
|
echo "=== Kairo DEV Deploy complete ==="
|
||||||
|
|
|
||||||
|
|
@ -12,16 +12,21 @@ jobs:
|
||||||
run: |
|
run: |
|
||||||
set -e
|
set -e
|
||||||
echo "=== Deploying Kairo to PRODUCTION ==="
|
echo "=== Deploying Kairo to PRODUCTION ==="
|
||||||
cd /home/lars/docker/kairo
|
REPO="http://192.168.2.144:3000/Lars/Kairo-Jinkendo.git"
|
||||||
git fetch origin main || git clone http://192.168.2.144:3000/Lars/Kairo-Jinkendo.git .
|
TARGET="/home/lars/docker/kairo"
|
||||||
git reset --hard origin/main
|
mkdir -p "$TARGET"
|
||||||
docker compose build --no-cache
|
cd "$TARGET"
|
||||||
docker compose up -d
|
if [ ! -d .git ]; then
|
||||||
sleep 5
|
git clone -b main "$REPO" .
|
||||||
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"
|
|
||||||
else
|
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
|
fi
|
||||||
echo "=== Kairo PROD Deploy complete ==="
|
echo "=== Kairo PROD Deploy complete ==="
|
||||||
|
|
|
||||||
|
|
@ -1,11 +1,11 @@
|
||||||
name: Test Suite
|
name: Test Suite
|
||||||
|
|
||||||
# develop: push/PR → Tests gegen Dev (parallel oder vor Deploy Development).
|
# push develop/main → compose-smoke (frischer Build auf dem Runner)
|
||||||
# main: kein push/PR-Trigger — vermeidet doppelten Dev-Lauf beim Merge develop→main;
|
# workflow_run nach Deploy → pytest/k6/playwright gegen laufende Instanz
|
||||||
# Prod-Tests nur via workflow_run nach erfolgreichem Deploy Production.
|
# pull_request → compose-smoke
|
||||||
on:
|
on:
|
||||||
push:
|
push:
|
||||||
branches: [develop]
|
branches: [develop, main]
|
||||||
pull_request:
|
pull_request:
|
||||||
branches: [develop]
|
branches: [develop]
|
||||||
workflow_run:
|
workflow_run:
|
||||||
|
|
@ -13,39 +13,65 @@ on:
|
||||||
types: [completed]
|
types: [completed]
|
||||||
|
|
||||||
jobs:
|
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:
|
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
|
runs-on: ubuntu-latest
|
||||||
steps:
|
steps:
|
||||||
- name: Backend pytest im deployten Container
|
- name: Backend pytest im deployten Container
|
||||||
run: |
|
run: |
|
||||||
set -e
|
set -e
|
||||||
EVENT_NAME="${{ github.event_name }}"
|
|
||||||
REF_NAME="${{ github.ref_name }}"
|
|
||||||
BASE_REF="${{ github.base_ref }}"
|
|
||||||
RUN_WORKFLOW="${{ github.event.workflow_run.name }}"
|
RUN_WORKFLOW="${{ github.event.workflow_run.name }}"
|
||||||
|
if [ "$RUN_WORKFLOW" = "Deploy Production" ]; then
|
||||||
APP_DIR="/home/lars/docker/kairo"
|
APP_DIR="/home/lars/docker/kairo"
|
||||||
COMPOSE_FILE="docker-compose.yml"
|
COMPOSE_FILE="docker-compose.yml"
|
||||||
|
else
|
||||||
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
|
|
||||||
APP_DIR="/home/lars/docker/kairo-dev"
|
APP_DIR="/home/lars/docker/kairo-dev"
|
||||||
COMPOSE_FILE="docker-compose.dev-env.yml"
|
COMPOSE_FILE="docker-compose.dev-env.yml"
|
||||||
fi
|
fi
|
||||||
|
|
||||||
cd "$APP_DIR"
|
cd "$APP_DIR"
|
||||||
echo "Warte auf stabilen backend-Container …"
|
|
||||||
for i in $(seq 1 60); do
|
for i in $(seq 1 60); do
|
||||||
if docker compose -f "$COMPOSE_FILE" exec -T backend true 2>/dev/null; then
|
if docker compose -f "$COMPOSE_FILE" exec -T backend true 2>/dev/null; then
|
||||||
echo "Backend bereit (Versuch $i)"
|
|
||||||
break
|
break
|
||||||
fi
|
fi
|
||||||
if [ "$i" -eq 60 ]; then
|
if [ "$i" -eq 60 ]; then
|
||||||
echo "Timeout: backend-Container nicht bereit"
|
|
||||||
docker compose -f "$COMPOSE_FILE" ps || true
|
docker compose -f "$COMPOSE_FILE" ps || true
|
||||||
docker compose -f "$COMPOSE_FILE" logs backend --tail 80 || true
|
docker compose -f "$COMPOSE_FILE" logs backend --tail 80 || true
|
||||||
exit 1
|
exit 1
|
||||||
|
|
@ -54,116 +80,80 @@ jobs:
|
||||||
done
|
done
|
||||||
|
|
||||||
docker compose -f "$COMPOSE_FILE" exec -T backend sh -lc "
|
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 &&
|
pip install -q -r requirements-dev.txt &&
|
||||||
cd /app &&
|
|
||||||
python -m pytest tests -ra -vv --tb=short
|
python -m pytest tests -ra -vv --tb=short
|
||||||
"
|
"
|
||||||
|
|
||||||
lint-backend:
|
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
|
runs-on: ubuntu-latest
|
||||||
steps:
|
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: |
|
run: |
|
||||||
EVENT_NAME="${{ github.event_name }}"
|
|
||||||
REF_NAME="${{ github.ref_name }}"
|
|
||||||
RUN_WORKFLOW="${{ github.event.workflow_run.name }}"
|
RUN_WORKFLOW="${{ github.event.workflow_run.name }}"
|
||||||
|
if [ "$RUN_WORKFLOW" = "Deploy Production" ]; then
|
||||||
APP_DIR="/home/lars/docker/kairo"
|
APP_DIR="/home/lars/docker/kairo"
|
||||||
|
else
|
||||||
if [ "$EVENT_NAME" = "workflow_run" ]; then
|
|
||||||
if [ "$RUN_WORKFLOW" = "Deploy Development" ]; then
|
|
||||||
APP_DIR="/home/lars/docker/kairo-dev"
|
APP_DIR="/home/lars/docker/kairo-dev"
|
||||||
fi
|
fi
|
||||||
elif [ "$REF_NAME" = "develop" ]; then
|
|
||||||
APP_DIR="/home/lars/docker/kairo-dev"
|
|
||||||
fi
|
|
||||||
|
|
||||||
python3 -m py_compile "$APP_DIR/backend/main.py"
|
python3 -m py_compile "$APP_DIR/backend/main.py"
|
||||||
echo "✓ Backend syntax OK"
|
echo "✓ Backend syntax OK"
|
||||||
|
|
||||||
build-frontend:
|
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
|
runs-on: ubuntu-latest
|
||||||
steps:
|
steps:
|
||||||
|
- name: Checkout repository
|
||||||
|
uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- name: Setup Node.js
|
||||||
|
uses: actions/setup-node@v4
|
||||||
|
with:
|
||||||
|
node-version: "20"
|
||||||
|
|
||||||
- name: Build frontend
|
- name: Build frontend
|
||||||
run: |
|
run: |
|
||||||
EVENT_NAME="${{ github.event_name }}"
|
cd frontend
|
||||||
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
|
|
||||||
npm install
|
npm install
|
||||||
npm run build
|
npm run build
|
||||||
echo "✓ Frontend build OK"
|
echo "✓ Frontend build OK"
|
||||||
|
|
||||||
k6-health-baseline:
|
k6-health-baseline:
|
||||||
name: k6 /api/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
|
runs-on: ubuntu-latest
|
||||||
env:
|
|
||||||
E2E_TARGET_URL: https://dev.kairo.jinkendo.de
|
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout repository
|
- name: Checkout repository
|
||||||
uses: actions/checkout@v4
|
uses: actions/checkout@v4
|
||||||
|
|
||||||
- name: E2E-Ziel wählen (Dev über Proxy vs. Production)
|
- name: k6 Ziel wählen (localhost auf Runner)
|
||||||
id: e2e
|
id: k6
|
||||||
run: |
|
run: |
|
||||||
EVENT="${{ github.event_name }}"
|
if [ "${{ github.event.workflow_run.name }}" = "Deploy Production" ]; then
|
||||||
WF_NAME="${{ github.event.workflow_run.name }}"
|
echo "base_url=http://localhost:8004" >> $GITHUB_OUTPUT
|
||||||
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."
|
|
||||||
else
|
else
|
||||||
echo "mode=dev" >> $GITHUB_OUTPUT
|
echo "base_url=http://localhost:8097" >> $GITHUB_OUTPUT
|
||||||
echo "base_url=${DEV_BASE}" >> $GITHUB_OUTPUT
|
|
||||||
echo "→ k6 gegen Dev (${DEV_BASE})."
|
|
||||||
fi
|
fi
|
||||||
|
|
||||||
- name: Dev /api/health abwarten
|
- name: /api/health abwarten
|
||||||
if: ${{ steps.e2e.outputs.mode == 'dev' }}
|
|
||||||
run: |
|
run: |
|
||||||
BASE="${{ steps.e2e.outputs.base_url }}"
|
BASE="${{ steps.k6.outputs.base_url }}"
|
||||||
echo "Warte auf $BASE/api/health …"
|
for i in $(seq 1 60); do
|
||||||
for i in $(seq 1 90); do
|
|
||||||
if curl -sf "$BASE/api/health" >/dev/null 2>&1; then
|
if curl -sf "$BASE/api/health" >/dev/null 2>&1; then
|
||||||
echo "Health OK (Versuch $i)"
|
echo "Health OK (Versuch $i)"
|
||||||
exit 0
|
exit 0
|
||||||
fi
|
fi
|
||||||
sleep 2
|
sleep 2
|
||||||
done
|
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
|
curl -v "$BASE/api/health" || true
|
||||||
exit 1
|
exit 1
|
||||||
|
|
||||||
|
|
@ -177,26 +167,18 @@ jobs:
|
||||||
aarch64|arm64) K6_ARCH=arm64 ;;
|
aarch64|arm64) K6_ARCH=arm64 ;;
|
||||||
*) echo "k6: unbekannte Architektur: $ARCH"; exit 1 ;;
|
*) echo "k6: unbekannte Architektur: $ARCH"; exit 1 ;;
|
||||||
esac
|
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
|
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
|
tar -xzf /tmp/k6.tgz -C /tmp
|
||||||
sudo mv "/tmp/k6-${K6_VER}-linux-${K6_ARCH}/k6" /usr/local/bin/k6
|
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:
|
env:
|
||||||
BASE_URL: ${{ steps.e2e.outputs.base_url }}
|
BASE_URL: ${{ steps.k6.outputs.base_url }}
|
||||||
run: |
|
run: k6 run scripts/load/k6-health-baseline.js
|
||||||
set -e
|
|
||||||
echo "k6 gegen BASE_URL=$BASE_URL"
|
|
||||||
k6 run scripts/load/k6-health-baseline.js
|
|
||||||
echo "✓ k6 Health-Baseline passed"
|
|
||||||
|
|
||||||
playwright-tests:
|
playwright-smoke:
|
||||||
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
|
runs-on: ubuntu-latest
|
||||||
env:
|
|
||||||
E2E_TARGET_URL: https://dev.kairo.jinkendo.de
|
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout repository
|
- name: Checkout repository
|
||||||
uses: actions/checkout@v4
|
uses: actions/checkout@v4
|
||||||
|
|
@ -204,111 +186,34 @@ jobs:
|
||||||
- name: Setup Node.js
|
- name: Setup Node.js
|
||||||
uses: actions/setup-node@v4
|
uses: actions/setup-node@v4
|
||||||
with:
|
with:
|
||||||
node-version: '20'
|
node-version: "20"
|
||||||
|
|
||||||
- name: E2E-Ziel wählen (Dev über Proxy vs. Production)
|
- name: Playwright Ziel wählen
|
||||||
id: e2e
|
id: pw
|
||||||
run: |
|
run: |
|
||||||
EVENT="${{ github.event_name }}"
|
if [ "${{ github.event.workflow_run.name }}" = "Deploy Production" ]; then
|
||||||
WF_NAME="${{ github.event.workflow_run.name }}"
|
echo "base_url=http://localhost:3004" >> $GITHUB_OUTPUT
|
||||||
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."
|
|
||||||
else
|
else
|
||||||
echo "mode=dev" >> $GITHUB_OUTPUT
|
echo "base_url=http://localhost:3097" >> $GITHUB_OUTPUT
|
||||||
echo "base_url=${DEV_BASE}" >> $GITHUB_OUTPUT
|
|
||||||
echo "→ Deployte Dev-Umgebung (${DEV_BASE}). Secrets: E2E_DEV_TEST_EMAIL / E2E_DEV_TEST_PASSWORD."
|
|
||||||
fi
|
fi
|
||||||
|
|
||||||
- name: Dev /api/health abwarten
|
- name: /api/health abwarten
|
||||||
if: ${{ steps.e2e.outputs.mode == 'dev' }}
|
|
||||||
run: |
|
run: |
|
||||||
BASE="${{ steps.e2e.outputs.base_url }}"
|
BASE="${{ steps.pw.outputs.base_url }}"
|
||||||
echo "Warte auf $BASE/api/health …"
|
for i in $(seq 1 60); do
|
||||||
for i in $(seq 1 90); do
|
|
||||||
if curl -sf "$BASE/api/health" >/dev/null 2>&1; then
|
if curl -sf "$BASE/api/health" >/dev/null 2>&1; then
|
||||||
echo "Health OK (Versuch $i)"
|
|
||||||
exit 0
|
exit 0
|
||||||
fi
|
fi
|
||||||
sleep 2
|
sleep 2
|
||||||
done
|
done
|
||||||
echo "Timeout: Dev /api/health nicht erreichbar — Deploy / DNS / Firewall prüfen."
|
|
||||||
curl -v "$BASE/api/health" || true
|
|
||||||
exit 1
|
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
|
- name: Install Playwright
|
||||||
run: |
|
run: |
|
||||||
npm ci || npm install
|
npm install
|
||||||
npx playwright install --with-deps chromium
|
npx playwright install --with-deps chromium
|
||||||
|
|
||||||
- name: Run Playwright tests
|
- name: Run smoke tests
|
||||||
env:
|
env:
|
||||||
E2E_DEV_TEST_EMAIL: ${{ secrets.E2E_DEV_TEST_EMAIL }}
|
PLAYWRIGHT_BASE_URL: ${{ steps.pw.outputs.base_url }}
|
||||||
E2E_DEV_TEST_PASSWORD: ${{ secrets.E2E_DEV_TEST_PASSWORD }}
|
run: npx playwright test tests/smoke-health.spec.js
|
||||||
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
|
|
||||||
|
|
|
||||||
|
|
@ -117,4 +117,4 @@ Bei notwendiger Abweichung erstelle ein Architecture Decision Proposal.
|
||||||
|
|
||||||
## 8. Aktueller erster Auftrag
|
## 8. Aktueller erster Auftrag
|
||||||
|
|
||||||
AP0.1 – Projektgrundlage.
|
AP0.1 – Projektgrundlage (Backend, Frontend minimal, Tests, Deploy).
|
||||||
|
|
|
||||||
37
README.md
37
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.
|
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
|
## AP0.1 – Stand Projektgrundlage
|
||||||
|
|
||||||
|
|
@ -76,11 +95,7 @@ Details: [docs/DEPLOYMENT.md](docs/DEPLOYMENT.md)
|
||||||
|---------|--------|
|
|---------|--------|
|
||||||
| Git + Gitea (`develop` / `main`) | erledigt |
|
| Git + Gitea (`develop` / `main`) | erledigt |
|
||||||
| Docker Compose (Prod + Dev) | erledigt |
|
| Docker Compose (Prod + Dev) | erledigt |
|
||||||
| Gitea Actions (Deploy + Test) | erledigt, an Kairo angepasst |
|
| Backend (FastAPI, Migrationen, `/api/health`) | erledigt |
|
||||||
| Sprint-0-Spezifikationen | im Repo |
|
| Frontend minimal (React + nginx Proxy) | erledigt |
|
||||||
| Designprinzipien-Referenz | im Repo |
|
| pytest (Health + Migrationen) | erledigt |
|
||||||
| Backend-Skeleton (FastAPI, Migrationen, Tests) | **offen** |
|
| Gitea Actions (Deploy + Test) | erledigt |
|
||||||
| 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`.
|
|
||||||
|
|
|
||||||
16
backend/Dockerfile
Normal file
16
backend/Dockerfile
Normal 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"]
|
||||||
|
|
@ -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
38
backend/db.py
Normal 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
71
backend/main.py
Normal 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,
|
||||||
|
}
|
||||||
12
backend/migrations/001_init_core.sql
Normal file
12
backend/migrations/001_init_core.sql
Normal 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;
|
||||||
3
backend/requirements-dev.txt
Normal file
3
backend/requirements-dev.txt
Normal file
|
|
@ -0,0 +1,3 @@
|
||||||
|
-r requirements.txt
|
||||||
|
pytest==8.3.4
|
||||||
|
httpx==0.27.2
|
||||||
5
backend/requirements.txt
Normal file
5
backend/requirements.txt
Normal 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
198
backend/run_migrations.py
Normal 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())
|
||||||
30
backend/tests/test_health.py
Normal file
30
backend/tests/test_health.py
Normal 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"}
|
||||||
57
backend/tests/test_migrations.py
Normal file
57
backend/tests/test_migrations.py
Normal 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
3
backend/version.py
Normal file
|
|
@ -0,0 +1,3 @@
|
||||||
|
APP_VERSION = "0.1.0-ap0.1"
|
||||||
|
DB_SCHEMA_VERSION = "001"
|
||||||
|
APP_NAME = "jinkendo-kairo"
|
||||||
|
|
@ -12,6 +12,11 @@ services:
|
||||||
- dev-kairo-db-data:/var/lib/postgresql/data
|
- dev-kairo-db-data:/var/lib/postgresql/data
|
||||||
ports:
|
ports:
|
||||||
- "5437:5432"
|
- "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
|
restart: unless-stopped
|
||||||
networks:
|
networks:
|
||||||
- dev-kairo-network
|
- dev-kairo-network
|
||||||
|
|
@ -26,22 +31,26 @@ services:
|
||||||
DB_NAME: "${DB_NAME:-kairo_dev}"
|
DB_NAME: "${DB_NAME:-kairo_dev}"
|
||||||
DB_USER: "${DB_USER:-kairo_dev}"
|
DB_USER: "${DB_USER:-kairo_dev}"
|
||||||
DB_PASSWORD: "${DB_PASSWORD:-dev_password}"
|
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}"
|
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}"
|
ENVIRONMENT: "${ENVIRONMENT:-development}"
|
||||||
CLUB_FEATURE_ENFORCE: "${CLUB_FEATURE_ENFORCE:-1}"
|
|
||||||
ports:
|
ports:
|
||||||
- "8097:8000"
|
- "8097:8000"
|
||||||
depends_on:
|
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
|
restart: unless-stopped
|
||||||
networks:
|
networks:
|
||||||
- dev-kairo-network
|
- dev-kairo-network
|
||||||
|
|
@ -55,7 +64,8 @@ services:
|
||||||
ports:
|
ports:
|
||||||
- "3097:80"
|
- "3097:80"
|
||||||
depends_on:
|
depends_on:
|
||||||
- backend
|
backend:
|
||||||
|
condition: service_healthy
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
networks:
|
networks:
|
||||||
- dev-kairo-network
|
- dev-kairo-network
|
||||||
|
|
|
||||||
|
|
@ -7,11 +7,16 @@ services:
|
||||||
environment:
|
environment:
|
||||||
POSTGRES_DB: "${DB_NAME:-kairo}"
|
POSTGRES_DB: "${DB_NAME:-kairo}"
|
||||||
POSTGRES_USER: "${DB_USER:-kairo_user}"
|
POSTGRES_USER: "${DB_USER:-kairo_user}"
|
||||||
POSTGRES_PASSWORD: ${DB_PASSWORD}
|
POSTGRES_PASSWORD: "${DB_PASSWORD:-change_me}"
|
||||||
volumes:
|
volumes:
|
||||||
- kairo-db-data:/var/lib/postgresql/data
|
- kairo-db-data:/var/lib/postgresql/data
|
||||||
ports:
|
ports:
|
||||||
- "127.0.0.1:5436:5432"
|
- "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
|
restart: unless-stopped
|
||||||
networks:
|
networks:
|
||||||
- kairo-network
|
- kairo-network
|
||||||
|
|
@ -24,29 +29,29 @@ services:
|
||||||
environment:
|
environment:
|
||||||
DB_HOST: postgres
|
DB_HOST: postgres
|
||||||
DB_PORT: 5432
|
DB_PORT: 5432
|
||||||
DB_NAME: kairo
|
DB_NAME: "${DB_NAME:-kairo}"
|
||||||
DB_USER: kairo_user
|
DB_USER: "${DB_USER:-kairo_user}"
|
||||||
DB_PASSWORD: ${DB_PASSWORD}
|
DB_PASSWORD: "${DB_PASSWORD:-change_me}"
|
||||||
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:-}"
|
|
||||||
APP_URL: "${APP_URL:-https://kairo.jinkendo.de}"
|
APP_URL: "${APP_URL:-https://kairo.jinkendo.de}"
|
||||||
ALLOWED_ORIGINS: "${ALLOWED_ORIGINS:-https://kairo.jinkendo.de}"
|
ALLOWED_ORIGINS: "${ALLOWED_ORIGINS:-https://kairo.jinkendo.de}"
|
||||||
ENVIRONMENT: "${ENVIRONMENT:-production}"
|
ENVIRONMENT: "${ENVIRONMENT:-production}"
|
||||||
CLUB_FEATURE_ENFORCE: "${CLUB_FEATURE_ENFORCE:-1}"
|
|
||||||
ports:
|
ports:
|
||||||
- "8004:8000"
|
- "8004:8000"
|
||||||
depends_on:
|
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
|
restart: unless-stopped
|
||||||
networks:
|
networks:
|
||||||
- kairo-network
|
- kairo-network
|
||||||
|
|
@ -61,7 +66,8 @@ services:
|
||||||
ports:
|
ports:
|
||||||
- "3004:80"
|
- "3004:80"
|
||||||
depends_on:
|
depends_on:
|
||||||
- backend
|
backend:
|
||||||
|
condition: service_healthy
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
networks:
|
networks:
|
||||||
- kairo-network
|
- kairo-network
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
# Deployment – Kairo Jinkendo
|
# 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
|
**Server:** Raspberry Pi 5 (`192.168.2.49`) — gleicher Host wie Shinkan/Mitai
|
||||||
**Runner:** Gitea Actions (`/home/lars/gitea-runner/`)
|
**Runner:** Gitea Actions (`/home/lars/gitea-runner/`)
|
||||||
|
|
||||||
|
|
@ -17,110 +17,59 @@
|
||||||
| **PostgreSQL (localhost)** | 5436 | 5437 |
|
| **PostgreSQL (localhost)** | 5436 | 5437 |
|
||||||
| **Domain** | kairo.jinkendo.de | dev.kairo.jinkendo.de |
|
| **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
|
## Einmalige Server-Einrichtung
|
||||||
|
|
||||||
Auf dem Pi als User `lars` ausführen:
|
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
# Deploy-Verzeichnisse anlegen
|
mkdir -p /home/lars/docker/kairo /home/lars/docker/kairo-dev
|
||||||
mkdir -p /home/lars/docker/kairo
|
|
||||||
mkdir -p /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
|
cd /home/lars/docker/kairo-dev
|
||||||
git clone http://192.168.2.144:3000/Lars/Kairo-Jinkendo.git .
|
git clone http://192.168.2.144:3000/Lars/Kairo-Jinkendo.git .
|
||||||
git checkout develop
|
git checkout develop
|
||||||
cp .env.example .env
|
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).
|
CI auf dem Pi nutzt localhost-Ports. Öffentliche Domains sind für Reverse-Proxy optional.
|
||||||
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.
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 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) |
|
| Hostname | Ziel (Pi) |
|
||||||
|----------|-----------|
|
|----------|-----------|
|
||||||
| `kairo.jinkendo.de` | `http://192.168.2.49:3004` |
|
| `kairo.jinkendo.de` | `http://192.168.2.49:3004` |
|
||||||
| `dev.kairo.jinkendo.de` | `http://192.168.2.49:3097` |
|
| `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
|
## Manuelles Deploy
|
||||||
|
|
@ -139,12 +88,4 @@ docker compose build --no-cache
|
||||||
docker compose up -d
|
docker compose up -d
|
||||||
```
|
```
|
||||||
|
|
||||||
---
|
Health: `curl http://localhost:8097/api/health` (Dev) bzw. `http://localhost:8004/api/health` (Prod)
|
||||||
|
|
||||||
## 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
|
|
||||||
|
|
|
||||||
15
frontend/Dockerfile
Normal file
15
frontend/Dockerfile
Normal 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;"]
|
||||||
|
|
@ -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
12
frontend/index.html
Normal 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
22
frontend/nginx.conf
Normal 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
19
frontend/package.json
Normal 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
31
frontend/src/App.jsx
Normal 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
30
frontend/src/app.css
Normal 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
10
frontend/src/main.jsx
Normal 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
12
frontend/vite.config.js
Normal 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',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
9
tests/smoke-health.spec.js
Normal file
9
tests/smoke-health.spec.js
Normal 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/);
|
||||||
|
});
|
||||||
Loading…
Reference in New Issue
Block a user