diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..f8dbe02 --- /dev/null +++ b/.dockerignore @@ -0,0 +1,16 @@ +.git +.gitea +**/.venv +**/__pycache__ +**/*.pyc +backend/data +backend/.env +frontend/node_modules +frontend/dist +local-backups +transfer +tmp +temp +docs +*.md +!frontend/README.md diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..7b21422 --- /dev/null +++ b/.env.example @@ -0,0 +1,27 @@ +# Compose secrets on the Pi. Copy to /home/lars/docker/kansho/.env and +# /home/lars/docker/kansho-dev/.env. Never commit the filled file. +# Local Windows development keeps using backend/.env (SQLite, ports 5188/8018). + +# ─── DEV (docker-compose.dev-env.yml) ──────────────────────────────────────── +# DB_NAME=kansho_dev +# DB_USER=kansho_dev +# DB_PASSWORD=dev_password_change_me +# APP_URL=https://dev.kansho.jinkendo.de +# ALLOWED_ORIGINS=https://dev.kansho.jinkendo.de,http://192.168.2.49:3096 +# KANSHO_ENV=development +# KANSHO_FRONTEND_PORT=3096 +# KANSHO_BACKEND_PORT=8096 +# KANSHO_PROVIDER_KEY= +# KANSHO_DETECT_PROVIDER_KEY= + +# ─── PROD (docker-compose.yml) ─────────────────────────────────────────────── +DB_NAME=kansho +DB_USER=kansho +DB_PASSWORD=CHANGE_ME_SECURE_PASSWORD +APP_URL=https://kansho.jinkendo.de +ALLOWED_ORIGINS=https://kansho.jinkendo.de +KANSHO_ENV=production +KANSHO_FRONTEND_PORT=3006 +KANSHO_BACKEND_PORT=8005 +KANSHO_PROVIDER_KEY= +KANSHO_DETECT_PROVIDER_KEY= diff --git a/.gitea/workflows/deploy-dev.yml b/.gitea/workflows/deploy-dev.yml new file mode 100644 index 0000000..7bb7275 --- /dev/null +++ b/.gitea/workflows/deploy-dev.yml @@ -0,0 +1,40 @@ +name: Deploy Development + +on: + push: + branches: [develop] + +jobs: + deploy: + runs-on: ubuntu-latest + steps: + - name: Deploy Kanshō to development + run: | + set -e + echo "=== Deploying Kanshō to DEVELOPMENT ===" + REPO="http://192.168.2.144:3000/Lars/Kansho.git" + TARGET="/home/lars/docker/kansho-dev" + mkdir -p "$TARGET" + cd "$TARGET" + if [ ! -d .git ]; then + git clone -b develop "$REPO" . + else + git fetch origin develop + git checkout develop + git reset --hard origin/develop + fi + docker compose -f docker-compose.dev-env.yml build --no-cache backend frontend + if ! docker compose -f docker-compose.dev-env.yml up -d --wait; then + echo "compose up --wait failed — backend logs:" + docker compose -f docker-compose.dev-env.yml logs backend --tail 150 || true + docker compose -f docker-compose.dev-env.yml ps || true + exit 1 + fi + if ! curl -sf http://localhost:8096/api/health; then + echo "DEV API not reachable — backend logs:" + docker compose -f docker-compose.dev-env.yml logs backend --tail 150 || true + exit 1 + fi + echo "DEV API /api/health OK" + curl -sf http://localhost:3096/api/health && echo "DEV frontend proxy /api/health OK" + echo "=== Kanshō DEV deploy complete ===" diff --git a/.gitea/workflows/deploy-prod.yml b/.gitea/workflows/deploy-prod.yml new file mode 100644 index 0000000..eb22639 --- /dev/null +++ b/.gitea/workflows/deploy-prod.yml @@ -0,0 +1,40 @@ +name: Deploy Production + +on: + push: + branches: [main] + +jobs: + deploy: + runs-on: ubuntu-latest + steps: + - name: Deploy Kanshō to production + run: | + set -e + echo "=== Deploying Kanshō to PRODUCTION ===" + REPO="http://192.168.2.144:3000/Lars/Kansho.git" + TARGET="/home/lars/docker/kansho" + mkdir -p "$TARGET" + cd "$TARGET" + if [ ! -d .git ]; then + git clone -b main "$REPO" . + else + git fetch origin main + git checkout main + git reset --hard origin/main + fi + docker compose build --no-cache backend frontend + if ! docker compose up -d --wait; then + echo "compose up --wait failed — backend logs:" + docker compose logs backend --tail 150 || true + docker compose ps || true + exit 1 + fi + if ! curl -sf http://localhost:8005/api/health; then + echo "PROD API not reachable — backend logs:" + docker compose logs backend --tail 150 || true + exit 1 + fi + echo "PROD API /api/health OK" + curl -sf http://localhost:3006/api/health && echo "PROD frontend proxy /api/health OK" + echo "=== Kanshō PROD deploy complete ===" diff --git a/.gitea/workflows/test.yml b/.gitea/workflows/test.yml new file mode 100644 index 0000000..a54a3d0 --- /dev/null +++ b/.gitea/workflows/test.yml @@ -0,0 +1,67 @@ +name: Test Suite + +on: + push: + branches: [develop, main] + pull_request: + branches: [develop] + +jobs: + backend-sqlite: + runs-on: ubuntu-latest + steps: + - name: Backend tests against isolated SQLite + run: | + set -e + EVENT_REF="${{ github.ref_name }}" + BASE_REF="${{ github.base_ref }}" + APP_DIR="/home/lars/docker/kansho" + COMPOSE_FILE="docker-compose.yml" + if [ "$EVENT_REF" = "develop" ] || [ "$BASE_REF" = "develop" ]; then + APP_DIR="/home/lars/docker/kansho-dev" + COMPOSE_FILE="docker-compose.dev-env.yml" + fi + echo "tests against ${APP_DIR} (${COMPOSE_FILE})" + cd "$APP_DIR" + for i in $(seq 1 60); do + if docker compose -f "$COMPOSE_FILE" exec -T backend true 2>/dev/null; then + echo "backend ready (attempt $i)" + break + fi + if [ "$i" -eq 60 ]; then + echo "timeout waiting for backend" + docker compose -f "$COMPOSE_FILE" ps || true + exit 1 + fi + sleep 5 + done + docker compose -f "$COMPOSE_FILE" exec -T \ + -e KANSHO_DB_BACKEND=sqlite \ + -e KANSHO_DB_PATH=/tmp/kansho-ci.sqlite \ + -e KANSHO_PROVIDER_KEY= \ + -e KANSHO_DETECT_PROVIDER_KEY= \ + -e PYTHONUTF8=1 \ + backend sh -lc ' + set -e + rm -f /tmp/kansho-ci.sqlite + for test in tests/test_*.py; do + echo "=== $test ===" + python "$test" + done + ' + + frontend-build: + runs-on: ubuntu-latest + steps: + - name: Frontend production build on deploy checkout + run: | + set -e + EVENT_REF="${{ github.ref_name }}" + BASE_REF="${{ github.base_ref }}" + APP_DIR="/home/lars/docker/kansho" + if [ "$EVENT_REF" = "develop" ] || [ "$BASE_REF" = "develop" ]; then + APP_DIR="/home/lars/docker/kansho-dev" + fi + cd "$APP_DIR/frontend" + npm ci + npm run build diff --git a/.gitignore b/.gitignore index c73daa6..32cec1a 100644 --- a/.gitignore +++ b/.gitignore @@ -38,7 +38,7 @@ Thumbs.db *.py[cod] .pytest_cache/ -# Test / local data +# Local data (never commit) tmp/ temp/ data/local/ @@ -47,7 +47,5 @@ local-backups/ *.kansho-backup.zip *.db -# One-time self-hosted Gitea transport of the laptop backup (2026-09-07). -# Not a product datastore. Remove transfer/ after restore. -!transfer/ -!transfer/*.zip +# Personal journal zip was a one-time Gitea transport. Do not re-add. +transfer/ diff --git a/README.md b/README.md index 748abde..964ff57 100644 --- a/README.md +++ b/README.md @@ -91,14 +91,9 @@ Restore bestätigt ausdrücklich, legt vorher ein Sicherheitsbackup an und über ## Transfer auf die Heim-Umgebung -Persönliche Daten liegen **nicht** beim Provider. Der einmalige Laptop-Transport (kein Stick/NAS auf diesem Gerät) läuft über das selbst gehostete Gitea: +Persönliche Daten liegen **nicht** beim Provider. Der einmalige Laptop-Transport lief über das selbst gehostete Gitea. Nach dem Restore auf dem Heimrechner `transfer/` aus dem Arbeitsbaum entfernen (Historie behält das Zip). -1. `git pull` muss `transfer/kansho-laptop-20260907.zip` enthalten. -2. `.\scripts\dev-setup.ps1`, `backend/.env` aus der Example plus Keys. -3. Restore: `.\scripts\backup-local.ps1 restore -Archive .\transfer\kansho-laptop-20260907.zip -Confirm -Replace` -4. Docker/Postgres sind Welle 2, nicht der erste Restore. - -Details: `docs/architecture/technical/environment_handover.md`, `transfer/README.md`. +Docker/Postgres, Gitea-Deploy und Cutover: `docs/DEPLOYMENT.md` und `docs/architecture/technical/runtime_and_deploy.md`. ## Lokal weiterarbeiten diff --git a/backend/.env.example b/backend/.env.example index 03b9d9e..622812c 100644 --- a/backend/.env.example +++ b/backend/.env.example @@ -22,3 +22,11 @@ KANSHO_DETECT_PROVIDER_KEY= # Nur Tests # KANSHO_FAKE_PROVIDER=1 # KANSHO_FAKE_DETECT=1 + +# Docker/Server only. Local Windows stays SQLite (default). +# KANSHO_DB_BACKEND=postgres +# DB_HOST=postgres +# DB_NAME=kansho +# DB_USER=kansho +# DB_PASSWORD= + diff --git a/backend/Dockerfile b/backend/Dockerfile new file mode 100644 index 0000000..400ee1f --- /dev/null +++ b/backend/Dockerfile @@ -0,0 +1,18 @@ +FROM python:3.12-slim + +WORKDIR /app + +RUN apt-get update && apt-get install -y --no-install-recommends libpq5 \ + && rm -rf /var/lib/apt/lists/* + +COPY requirements.txt . +ENV PIP_DEFAULT_TIMEOUT=120 +RUN pip install --no-cache-dir -r requirements.txt + +COPY . . +RUN chmod +x /app/startup.sh \ + && mkdir -p /app/data/media + +EXPOSE 8000 + +CMD ["/app/startup.sh"] diff --git a/backend/db.py b/backend/db.py index 77c6a22..442798a 100644 --- a/backend/db.py +++ b/backend/db.py @@ -1,4 +1,8 @@ -"""SQLite persistence for the local frame. PostgreSQL remains the later target.""" +"""Persistence for the local frame. + +SQLite is the Windows/test engine. PostgreSQL 16 is the Docker/server engine. +Switch at connect time via KANSHO_DB_BACKEND / DB_HOST; stores keep SQLite-shaped SQL. +""" from __future__ import annotations import json @@ -6,14 +10,49 @@ import os import sqlite3 from contextlib import contextmanager from pathlib import Path +from typing import Any -DATA_DIR = Path(__file__).resolve().parent / "data" +from sql_compat import ( + adapt_sql, + postgres_connect_kwargs, + rewrite_catalog_sql, + split_sql, + sqlite_schema_to_postgres, + use_postgres, +) + +_env_data = (os.environ.get("KANSHO_DATA_DIR") or "").strip() +DATA_DIR = Path(_env_data) if _env_data else Path(__file__).resolve().parent / "data" SCHEMA_PATH = Path(__file__).resolve().parent / "schema.sql" SEED_PATH = Path(__file__).resolve().parent / "config" / "platform_seed.json" PROMPTS_SEED_PATH = Path(__file__).resolve().parent / "config" / "prompts.seed.json" _env_db = os.environ.get("KANSHO_DB_PATH") DB_PATH = Path(_env_db) if _env_db else DATA_DIR / "kansho.sqlite" +SQLITE_HISTORY_MIGRATIONS = ( + "001_frame", + "002_platform", + "003_dialogue_memory", + "004_mvp_journal", + "005_provider_settings", + "006_writing_profile_dialogue_style", + "007_journal_day_scratch", + "008_journal_source_refs", + "009_conversation_signals", + "010_profile_governance", + "011_profile_review", + "012_profile_shell", + "013_journal_generate_narration", + "014_identity_registry", + "015_journal_generation_settings", + "016_generation_instruction_fragments", + "017_generation_guidelines", + "018_debug_runs", + "019_debug_run_placement", + "020_voice_style_context", + "021_voice_legacy_immutable", +) + _PROFILE_COLUMNS = { "status": "TEXT NOT NULL DEFAULT 'active'", "tier_id": "TEXT NOT NULL DEFAULT 'local'", @@ -88,7 +127,61 @@ _IDENTITY_MAPPING_COLUMNS = { } -def _connect() -> sqlite3.Connection: +class PostgresCompat: + """sqlite3-like execute/commit surface over psycopg, with SQL translation.""" + + def __init__(self, raw: Any): + self._raw = raw + + def execute(self, sql: str, params: Any = None): + special = rewrite_catalog_sql(sql) + if special: + adapted, extra = special + args = extra if extra is not None else params + else: + adapted = adapt_sql(sql) + args = params + if args is None: + return self._raw.execute(adapted) + return self._raw.execute(adapted, args) + + def executemany(self, sql: str, seq_of_params: Any): + adapted = adapt_sql(sql) + cursor = None + for params in seq_of_params: + cursor = self._raw.execute(adapted, params) + return cursor + + def executescript(self, script: str): + for statement in split_sql(sqlite_schema_to_postgres(script)): + self._raw.execute(statement) + return self + + def commit(self): + self._raw.commit() + + def rollback(self): + self._raw.rollback() + + def close(self): + self._raw.close() + + +def _connect_postgres() -> PostgresCompat: + import psycopg + from psycopg.rows import dict_row + + kwargs = postgres_connect_kwargs() + if "conninfo" in kwargs: + raw = psycopg.connect(kwargs["conninfo"], row_factory=dict_row) + else: + raw = psycopg.connect(row_factory=dict_row, **kwargs) + return PostgresCompat(raw) + + +def _connect(): + if use_postgres(): + return _connect_postgres() DB_PATH.parent.mkdir(parents=True, exist_ok=True) conn = sqlite3.connect(DB_PATH, timeout=30) conn.row_factory = sqlite3.Row @@ -109,32 +202,32 @@ def get_db(): conn.close() -def row_to_dict(row: sqlite3.Row | None) -> dict | None: +def row_to_dict(row: Any) -> dict | None: if row is None: return None return dict(row) -def _column_names(conn: sqlite3.Connection, table: str) -> set[str]: +def _column_names(conn: Any, table: str) -> set[str]: rows = conn.execute(f"PRAGMA table_info({table})").fetchall() return {row["name"] for row in rows} -def _ensure_columns(conn: sqlite3.Connection, table: str, columns: dict[str, str]) -> None: +def _ensure_columns(conn: Any, table: str, columns: dict[str, str]) -> None: existing = _column_names(conn, table) for name, ddl in columns.items(): if name not in existing: conn.execute(f"ALTER TABLE {table} ADD COLUMN {name} {ddl}") -def _mark(conn: sqlite3.Connection, migration_id: str) -> None: +def _mark(conn: Any, migration_id: str) -> None: conn.execute( "INSERT OR IGNORE INTO schema_migrations (id) VALUES (?)", (migration_id,), ) -def _seed_platform(conn: sqlite3.Connection) -> None: +def _seed_platform(conn: Any) -> None: seed = json.loads(SEED_PATH.read_text(encoding="utf-8")) for tier in seed.get("tiers", []): conn.execute( @@ -171,7 +264,7 @@ def _seed_platform(conn: sqlite3.Connection) -> None: ) -def _seed_prompts(conn: sqlite3.Connection) -> None: +def _seed_prompts(conn: Any) -> None: """Prompts come from JSON/DB, never from Python string literals. Untouched system prompts (template == default_template) receive the seed. @@ -246,7 +339,7 @@ def _parse_legacy_ids(raw: str | None) -> list[str]: def _insert_source_refs( - conn: sqlite3.Connection, + conn: Any, table: str, owner_col: str, owner_id: str, @@ -275,7 +368,7 @@ def _insert_source_refs( ) -def migrate_journal_source_refs(conn: sqlite3.Connection) -> None: +def migrate_journal_source_refs(conn: Any) -> None: """Copy JSON id lists into relational source-ref tables without touching Source.""" existing = { row["name"] @@ -323,7 +416,7 @@ def migrate_journal_source_refs(conn: sqlite3.Connection) -> None: ) -def _migrate_writing_profile_dialogue_style(conn: sqlite3.Connection) -> None: +def _migrate_writing_profile_dialogue_style(conn: Any) -> None: """Existing DBs keep the old CHECK; recreate so dialogue_style is a valid source kind.""" row = row_to_dict( conn.execute( @@ -354,7 +447,7 @@ def _migrate_writing_profile_dialogue_style(conn: sqlite3.Connection) -> None: ) -def _migrate_writing_profile_shell(conn: sqlite3.Connection) -> None: +def _migrate_writing_profile_shell(conn: Any) -> None: """Layers stay; inferred count-facets are not the profile. Manual style keys become traits.""" from writing_profile_schema import LEGACY_STYLE_KEYS, coerce_slug, facet_layer, normalize_facet_key @@ -449,7 +542,31 @@ def _migrate_writing_profile_shell(conn: sqlite3.Connection) -> None: ) +def _seed_runtime(conn: Any) -> None: + _seed_platform(conn) + _seed_prompts(conn) + from provider_settings import seed_provider_settings + from journal_generation_policy import backfill_missing_settings, seed_generation_instructions + + seed_provider_settings(conn) + seed_generation_instructions(conn) + backfill_missing_settings(conn) + seed_generation_instructions(conn) + seed_generation_instructions(conn) + + def init_db() -> None: + if use_postgres(): + from db_init import ensure_postgres_ready + + ensure_postgres_ready() + with get_db() as conn: + _seed_runtime(conn) + from writing_profile_store import bootstrap_from_existing + + bootstrap_from_existing() + return + schema = SCHEMA_PATH.read_text(encoding="utf-8") with get_db() as conn: conn.executescript(schema) @@ -474,35 +591,16 @@ def init_db() -> None: from identity_store import migrate_legacy_identity_rows migrate_legacy_identity_rows(conn) - _mark(conn, "001_frame") - _mark(conn, "002_platform") - _mark(conn, "003_dialogue_memory") - _mark(conn, "004_mvp_journal") - _mark(conn, "005_provider_settings") - _mark(conn, "006_writing_profile_dialogue_style") - _mark(conn, "007_journal_day_scratch") - _mark(conn, "008_journal_source_refs") - _mark(conn, "009_conversation_signals") - _mark(conn, "010_profile_governance") - _mark(conn, "011_profile_review") - _mark(conn, "012_profile_shell") - _mark(conn, "013_journal_generate_narration") - _mark(conn, "014_identity_registry") + for migration_id in SQLITE_HISTORY_MIGRATIONS: + _mark(conn, migration_id) from journal_generation_policy import backfill_missing_settings, seed_generation_instructions _ensure_columns(conn, "generation_guidelines", _GUIDELINE_COLUMNS) seed_generation_instructions(conn) backfill_missing_settings(conn) - _mark(conn, "015_journal_generation_settings") - _mark(conn, "016_generation_instruction_fragments") - _mark(conn, "017_generation_guidelines") - _mark(conn, "018_debug_runs") _ensure_columns(conn, "debug_runs", _DEBUG_RUN_COLUMNS) - _mark(conn, "019_debug_run_placement") seed_generation_instructions(conn) - _mark(conn, "020_voice_style_context") seed_generation_instructions(conn) - _mark(conn, "021_voice_legacy_immutable") from writing_profile_store import bootstrap_from_existing bootstrap_from_existing() diff --git a/backend/db_init.py b/backend/db_init.py new file mode 100644 index 0000000..9b4eeb6 --- /dev/null +++ b/backend/db_init.py @@ -0,0 +1,156 @@ +#!/usr/bin/env python3 +"""Wait for PostgreSQL, load schema.sql, apply numbered SQL migrations. Fail-fast.""" +from __future__ import annotations + +import os +import re +import sys +import time +from pathlib import Path + +from sql_compat import postgres_connect_kwargs, split_sql, sqlite_schema_to_postgres, use_postgres + +SCHEMA_PATH = Path(__file__).resolve().parent / "schema.sql" +MIGRATIONS_DIR = Path(__file__).resolve().parent / "migrations" +SQLITE_HISTORY_MIGRATIONS = ( + "001_frame", + "002_platform", + "003_dialogue_memory", + "004_mvp_journal", + "005_provider_settings", + "006_writing_profile_dialogue_style", + "007_journal_day_scratch", + "008_journal_source_refs", + "009_conversation_signals", + "010_profile_governance", + "011_profile_review", + "012_profile_shell", + "013_journal_generate_narration", + "014_identity_registry", + "015_journal_generation_settings", + "016_generation_instruction_fragments", + "017_generation_guidelines", + "018_debug_runs", + "019_debug_run_placement", + "020_voice_style_context", + "021_voice_legacy_immutable", +) +_LEADING_DIGITS = re.compile(r"^(\d{3})_.*\.sql$") + + +def _connect_raw(): + import psycopg + + kwargs = postgres_connect_kwargs() + if "conninfo" in kwargs: + return psycopg.connect(kwargs["conninfo"], autocommit=False) + return psycopg.connect(autocommit=False, **kwargs) + + +def wait_for_postgres(max_retries: int = 30) -> None: + print("Checking PostgreSQL connection...") + last_error = None + for attempt in range(1, max_retries + 1): + try: + conn = _connect_raw() + conn.close() + print("PostgreSQL ready") + return + except Exception as exc: # noqa: BLE001 — fail-fast after retries + last_error = exc + print(f" waiting ({attempt}/{max_retries})") + time.sleep(2) + print(f"PostgreSQL not ready: {last_error}") + sys.exit(1) + + +def _table_exists(conn, name: str) -> bool: + row = conn.execute( + """ + SELECT 1 FROM information_schema.tables + WHERE table_schema = 'public' AND table_name = %s + """, + (name,), + ).fetchone() + return row is not None + + +def _load_schema(conn) -> None: + sql = sqlite_schema_to_postgres(SCHEMA_PATH.read_text(encoding="utf-8")) + for statement in split_sql(sql): + conn.execute(statement) + print("Schema loaded from schema.sql (Postgres dialect)") + + +def _applied_ids(conn) -> set[str]: + rows = conn.execute("SELECT id FROM schema_migrations").fetchall() + return {row[0] for row in rows} + + +def _record(conn, migration_id: str) -> None: + conn.execute( + "INSERT INTO schema_migrations (id) VALUES (%s) ON CONFLICT (id) DO NOTHING", + (migration_id,), + ) + + +def _migration_files() -> list[tuple[str, Path]]: + if not MIGRATIONS_DIR.is_dir(): + return [] + rows: list[tuple[str, Path]] = [] + for path in sorted(MIGRATIONS_DIR.iterdir()): + match = _LEADING_DIGITS.match(path.name) + if not match or path.suffix != ".sql": + continue + rows.append((path.stem, path)) + return rows + + +def apply_schema_and_migrations() -> None: + conn = _connect_raw() + try: + if not _table_exists(conn, "schema_migrations"): + _load_schema(conn) + conn.commit() + applied = _applied_ids(conn) + if "001_frame" not in applied: + for migration_id in SQLITE_HISTORY_MIGRATIONS: + _record(conn, migration_id) + conn.commit() + print("Recorded SQLite history 001-021 as applied (greenfield Postgres schema)") + applied = _applied_ids(conn) + for stem, path in _migration_files(): + if stem in applied: + continue + sql = path.read_text(encoding="utf-8") + for statement in split_sql(sql): + conn.execute(statement) + _record(conn, stem) + conn.commit() + print(f"Applied migration {stem}") + except Exception as exc: + conn.rollback() + print(f"Database initialization failed: {exc}") + raise + finally: + conn.close() + + +def ensure_postgres_ready() -> None: + if not use_postgres(): + return + wait_for_postgres() + try: + apply_schema_and_migrations() + except Exception: + sys.exit(1) + + +def main() -> None: + os.environ.setdefault("KANSHO_DB_BACKEND", "postgres") + ensure_postgres_ready() + print("db_init complete") + + +if __name__ == "__main__": + main() diff --git a/backend/main.py b/backend/main.py index 21fca30..823dddf 100644 --- a/backend/main.py +++ b/backend/main.py @@ -2,6 +2,8 @@ from env_loader import load_env_file load_env_file() +import os + from fastapi import FastAPI from fastapi.middleware.cors import CORSMiddleware @@ -10,11 +12,32 @@ import data_layer_dialogue # noqa: F401 from routers import admin, auth, dialogue, generation_instructions, journal, placeholders, prompts, subscription, users from version import APP_VERSION + +def allowed_origins() -> list[str]: + origins = [ + "http://localhost:5188", + "http://127.0.0.1:5188", + "https://kansho.jinkendo.de", + "https://dev.kansho.jinkendo.de", + ] + extra = (os.environ.get("ALLOWED_ORIGINS") or os.environ.get("KANSHO_ALLOWED_ORIGINS") or "").strip() + if extra: + origins.extend(item.strip() for item in extra.split(",") if item.strip()) + app_url = (os.environ.get("APP_URL") or "").strip().rstrip("/") + if app_url: + origins.append(app_url) + seen: list[str] = [] + for item in origins: + if item not in seen: + seen.append(item) + return seen + + app = FastAPI(title="Kanshō", version=APP_VERSION) app.add_middleware( CORSMiddleware, - allow_origins=["http://localhost:5188", "http://127.0.0.1:5188"], + allow_origins=allowed_origins(), allow_credentials=True, allow_methods=["*"], allow_headers=["*"], diff --git a/backend/migrations/022_postgres_runtime_baseline.sql b/backend/migrations/022_postgres_runtime_baseline.sql new file mode 100644 index 0000000..1fb39eb --- /dev/null +++ b/backend/migrations/022_postgres_runtime_baseline.sql @@ -0,0 +1,7 @@ +-- Postgres runtime baseline for Kanshō. +-- Greenfield schema is backend/schema.sql translated at startup +-- (datetime('now') → CURRENT_TIMESTAMP::text). Historical SQLite +-- inline migrations 001-021 are recorded as applied, not replayed. +-- Future incremental DDL belongs in 023_*.sql and later. + +SELECT 1; diff --git a/backend/requirements.txt b/backend/requirements.txt index f279dc7..e3fe970 100644 --- a/backend/requirements.txt +++ b/backend/requirements.txt @@ -3,3 +3,4 @@ uvicorn[standard]==0.34.0 bcrypt==4.2.1 httpx==0.28.1 python-multipart==0.0.20 +psycopg[binary]==3.2.6 diff --git a/backend/sql_compat.py b/backend/sql_compat.py new file mode 100644 index 0000000..84ac0c3 --- /dev/null +++ b/backend/sql_compat.py @@ -0,0 +1,179 @@ +"""Translate SQLite-oriented SQL so the same stores can run against PostgreSQL. + +Local Windows development and tests keep SQLite. Docker/Server set +KANSHO_DB_BACKEND=postgres. Identity, keys and journal bodies are not logged here. +""" +from __future__ import annotations + +import os +import re + +_INSERT_OR_IGNORE = re.compile(r"INSERT\s+OR\s+IGNORE\s+INTO", re.IGNORECASE) +_ON_CONFLICT_PAREN = re.compile(r"ON\s+CONFLICT\s*\(", re.IGNORECASE) +_PRAGMA_TABLE = re.compile( + r"^\s*PRAGMA\s+table_info\(\s*['\"]?(\w+)['\"]?\s*\)\s*;?\s*$", + re.IGNORECASE, +) +_SQLITE_MASTER_TABLES = re.compile( + r"^\s*SELECT\s+name\s+FROM\s+sqlite_master\s+WHERE\s+type\s*=\s*'table'\s*;?\s*$", + re.IGNORECASE, +) +_SQLITE_MASTER_SQL = re.compile( + r"^\s*SELECT\s+sql\s+FROM\s+sqlite_master\s+WHERE\s+type\s*=\s*'table'\s+AND\s+name\s*=\s*\?\s*;?\s*$", + re.IGNORECASE, +) + + +def use_postgres() -> bool: + """Decide the engine at connect time, not at import time. + + Explicit KANSHO_DB_BACKEND wins. KANSHO_DB_PATH without an explicit postgres + backend keeps tests on isolated SQLite even inside a Compose container. + """ + raw = (os.environ.get("KANSHO_DB_BACKEND") or "").strip().lower() + if raw in {"sqlite", "sqlite3"}: + return False + if raw in {"postgres", "postgresql", "pg"}: + return True + if (os.environ.get("KANSHO_DB_PATH") or "").strip(): + return False + url = (os.environ.get("DATABASE_URL") or "").strip().lower() + if url.startswith("postgres"): + return True + if (os.environ.get("DB_HOST") or "").strip(): + return True + return False + + +def postgres_connect_kwargs() -> dict: + url = (os.environ.get("DATABASE_URL") or "").strip() + if url: + return {"conninfo": url} + host = (os.environ.get("DB_HOST") or "postgres").strip() or "postgres" + port = int((os.environ.get("DB_PORT") or "5432").strip() or "5432") + dbname = (os.environ.get("DB_NAME") or "kansho").strip() or "kansho" + user = (os.environ.get("DB_USER") or "kansho").strip() or "kansho" + password = os.environ.get("DB_PASSWORD") or "" + return { + "host": host, + "port": port, + "dbname": dbname, + "user": user, + "password": password, + } + + +def sqlite_schema_to_postgres(sql: str) -> str: + return sql.replace("datetime('now')", "CURRENT_TIMESTAMP::text") + + +def replace_placeholders(sql: str) -> str: + """Replace SQLite `?` placeholders with psycopg `%s`, ignoring quoted text.""" + out: list[str] = [] + i = 0 + in_single = False + in_double = False + while i < len(sql): + ch = sql[i] + if ch == "'" and not in_double: + if in_single and i + 1 < len(sql) and sql[i + 1] == "'": + out.append("''") + i += 2 + continue + in_single = not in_single + out.append(ch) + i += 1 + continue + if ch == '"' and not in_single: + in_double = not in_double + out.append(ch) + i += 1 + continue + if ch == "?" and not in_single and not in_double: + out.append("%s") + i += 1 + continue + out.append(ch) + i += 1 + return "".join(out) + + +def adapt_sql(sql: str) -> str: + """Make a SQLite-shaped statement runnable on PostgreSQL.""" + sql = sqlite_schema_to_postgres(sql) + sql = _ON_CONFLICT_PAREN.sub("ON CONFLICT (", sql) + used_ignore = bool(_INSERT_OR_IGNORE.search(sql)) + sql = _INSERT_OR_IGNORE.sub("INSERT INTO", sql) + if used_ignore and re.search(r"ON\s+CONFLICT", sql, re.IGNORECASE) is None: + stripped = sql.rstrip() + ended = stripped.endswith(";") + if ended: + stripped = stripped[:-1].rstrip() + stripped = f"{stripped} ON CONFLICT DO NOTHING" + sql = stripped + (";" if ended else "") + return replace_placeholders(sql) + + +def rewrite_catalog_sql(sql: str) -> tuple[str, tuple | None] | None: + """Rewrite SQLite catalog queries. Returns (sql, extra_params) or None.""" + match = _PRAGMA_TABLE.match(sql) + if match: + return ( + """ + SELECT column_name AS name + FROM information_schema.columns + WHERE table_schema = 'public' AND table_name = %s + ORDER BY ordinal_position + """, + (match.group(1).lower(),), + ) + if _SQLITE_MASTER_TABLES.match(sql): + return ( + """ + SELECT tablename AS name + FROM pg_catalog.pg_tables + WHERE schemaname = 'public' + """, + None, + ) + if _SQLITE_MASTER_SQL.match(sql): + return ("SELECT NULL AS sql WHERE FALSE", None) + return None + + +def split_sql(script: str) -> list[str]: + """Split a SQL script into statements, respecting quotes.""" + statements: list[str] = [] + buf: list[str] = [] + in_single = False + in_double = False + i = 0 + while i < len(script): + ch = script[i] + if ch == "'" and not in_double: + if in_single and i + 1 < len(script) and script[i + 1] == "'": + buf.append("''") + i += 2 + continue + in_single = not in_single + buf.append(ch) + i += 1 + continue + if ch == '"' and not in_single: + in_double = not in_double + buf.append(ch) + i += 1 + continue + if ch == ";" and not in_single and not in_double: + stmt = "".join(buf).strip() + if stmt: + statements.append(stmt) + buf = [] + i += 1 + continue + buf.append(ch) + i += 1 + tail = "".join(buf).strip() + if tail: + statements.append(tail) + return statements diff --git a/backend/sqlite_to_postgres.py b/backend/sqlite_to_postgres.py new file mode 100644 index 0000000..db1d308 --- /dev/null +++ b/backend/sqlite_to_postgres.py @@ -0,0 +1,226 @@ +#!/usr/bin/env python3 +"""One-shot SQLite → PostgreSQL copy for the laptop/home journal instance. + +Probe on a copy, never on the only backup. Does not send data to providers. +Media files are copied separately into the destination media root. +""" +from __future__ import annotations + +import argparse +import os +import shutil +import sqlite3 +import sys +from pathlib import Path + +from sql_compat import postgres_connect_kwargs + +SCHEMA_PATH = Path(__file__).resolve().parent / "schema.sql" +DEFAULT_SQLITE = Path(__file__).resolve().parent / "data" / "kansho.sqlite" +DEFAULT_MEDIA = Path(__file__).resolve().parent / "data" / "media" + +# Parent tables first — matches schema.sql CREATE order. +TABLES = [ + "schema_migrations", + "tiers", + "features", + "tier_limits", + "profiles", + "sessions", + "user_feature_restrictions", + "user_feature_usage", + "ai_prompts", + "usage_sessions", + "conversations", + "messages", + "threads", + "conversation_threads", + "spaces", + "thread_spaces", + "derived_records", + "handoffs", + "external_refs", + "re_grounding_events", + "identity_mappings", + "identity_review_proposals", + "journal_days", + "journal_drafts", + "journal_entries", + "journal_entry_versions", + "journal_draft_source_refs", + "journal_entry_version_source_refs", + "media_assets", + "writing_profiles", + "writing_profile_sources", + "writing_profile_facets", + "writing_profile_traits", + "writing_profile_trait_refs", + "writing_profile_suggestions", + "interaction_profiles", + "interaction_preferences", + "interaction_suggestions", + "writing_profile_evidence", + "writing_profile_reviews", + "writing_profile_versions", + "provider_settings", + "journal_generation_selection", + "generation_guidelines", + "app_settings", + "debug_runs", +] + + +def _sqlite_path() -> Path: + env = (os.environ.get("KANSHO_DB_PATH") or "").strip() + if env: + return Path(env) + data_dir = (os.environ.get("KANSHO_DATA_DIR") or "").strip() + if data_dir: + return Path(data_dir) / "kansho.sqlite" + return DEFAULT_SQLITE + + +def _media_root() -> Path: + env = (os.environ.get("KANSHO_MEDIA_ROOT") or "").strip() + if env: + return Path(env) + data_dir = (os.environ.get("KANSHO_DATA_DIR") or "").strip() + if data_dir: + return Path(data_dir) / "media" + return DEFAULT_MEDIA + + +def _connect_pg(): + import psycopg + + kwargs = postgres_connect_kwargs() + if "conninfo" in kwargs: + return psycopg.connect(kwargs["conninfo"]) + return psycopg.connect(**kwargs) + + +def _pg_columns(conn, table: str) -> list[str]: + rows = conn.execute( + """ + SELECT column_name + FROM information_schema.columns + WHERE table_schema = 'public' AND table_name = %s + ORDER BY ordinal_position + """, + (table,), + ).fetchall() + return [row[0] for row in rows] + + +def _sqlite_rows(sqlite_path: Path, table: str) -> list[dict]: + conn = sqlite3.connect(str(sqlite_path)) + conn.row_factory = sqlite3.Row + try: + rows = conn.execute(f"SELECT * FROM {table}").fetchall() + return [dict(row) for row in rows] + except sqlite3.OperationalError: + return [] + finally: + conn.close() + + +def _copy_media(src: Path, dest: Path) -> int: + if not src.is_dir(): + print(f"No media directory at {src}") + return 0 + dest.mkdir(parents=True, exist_ok=True) + count = 0 + for path in src.rglob("*"): + if not path.is_file(): + continue + relative = path.relative_to(src) + target = dest / relative + target.parent.mkdir(parents=True, exist_ok=True) + shutil.copy2(path, target) + count += 1 + return count + + +def migrate(sqlite_path: Path, media_src: Path, media_dest: Path, replace: bool) -> dict[str, tuple[int, int]]: + if not sqlite_path.is_file(): + raise SystemExit(f"SQLite not found: {sqlite_path}") + + pg = _connect_pg() + stats: dict[str, tuple[int, int]] = {} + try: + existing = pg.execute("SELECT COUNT(*) FROM profiles").fetchone()[0] + if existing and not replace: + raise SystemExit( + f"PostgreSQL already has {existing} profiles. Pass --replace after a probe copy, not on the only backup." + ) + if existing and replace: + print(f"Replacing {existing} existing Postgres profiles") + for table in reversed(TABLES): + pg.execute(f"TRUNCATE TABLE {table} CASCADE") + + for table in TABLES: + rows = _sqlite_rows(sqlite_path, table) + columns = _pg_columns(pg, table) + if not columns: + print(f" skip {table} (missing in Postgres schema)") + stats[table] = (len(rows), 0) + continue + if not rows: + print(f" {table}: empty") + stats[table] = (0, 0) + continue + usable = [col for col in columns if col in rows[0]] + placeholders = ", ".join(["%s"] * len(usable)) + col_sql = ", ".join(usable) + sql = f"INSERT INTO {table} ({col_sql}) VALUES ({placeholders})" + values = [tuple(row.get(col) for col in usable) for row in rows] + with pg.cursor() as cur: + cur.executemany(sql, values) + count = pg.execute(f"SELECT COUNT(*) FROM {table}").fetchone()[0] + print(f" {table}: {len(rows)} → {count}") + stats[table] = (len(rows), int(count)) + pg.commit() + except Exception: + pg.rollback() + raise + finally: + pg.close() + + copied = _copy_media(media_src, media_dest) + print(f"Media files copied: {copied} → {media_dest}") + return stats + + +def verify(stats: dict[str, tuple[int, int]]) -> None: + failed = False + print("\nVerification") + for table, (src, dest) in stats.items(): + mark = "ok" if src == dest else "MISMATCH" + if src != dest: + failed = True + print(f" {mark:8} {table:36} sqlite={src:5} postgres={dest:5}") + if failed: + raise SystemExit("Row counts do not match") + print("Row counts match") + + +def main() -> None: + parser = argparse.ArgumentParser(description="Copy Kanshō SQLite data into PostgreSQL") + parser.add_argument("--sqlite", type=Path, default=_sqlite_path()) + parser.add_argument("--media-src", type=Path, default=_media_root()) + parser.add_argument("--media-dest", type=Path, default=Path(os.environ.get("KANSHO_MEDIA_DEST") or "/app/data/media")) + parser.add_argument("--confirm", action="store_true", help="Required. Refuse a silent copy.") + parser.add_argument("--replace", action="store_true", help="Truncate Postgres tables first") + args = parser.parse_args() + if not args.confirm: + raise SystemExit("Refusing to copy personal data without --confirm") + print(f"SQLite source: {args.sqlite}") + print(f"Media source: {args.media_src}") + print(f"Media dest: {args.media_dest}") + stats = migrate(args.sqlite, args.media_src, args.media_dest, args.replace) + verify(stats) + print("Import complete. Check entry/message counts and one media GET before Prod.") + + +if __name__ == "__main__": + main() diff --git a/backend/startup.sh b/backend/startup.sh new file mode 100644 index 0000000..05c076d --- /dev/null +++ b/backend/startup.sh @@ -0,0 +1,7 @@ +#!/bin/sh +set -e + +echo "=== Kanshō backend startup ===" +python /app/db_init.py +echo "=== starting uvicorn ===" +exec uvicorn main:app --host 0.0.0.0 --port 8000 diff --git a/backend/tests/test_sql_compat.py b/backend/tests/test_sql_compat.py new file mode 100644 index 0000000..bc385a2 --- /dev/null +++ b/backend/tests/test_sql_compat.py @@ -0,0 +1,75 @@ +"""SQL dialect adapter for the SQLite/Postgres dual backend.""" +from __future__ import annotations + +import os +import re +import sys +from pathlib import Path + +ROOT = Path(__file__).resolve().parents[1] +sys.path.insert(0, str(ROOT)) + +from sql_compat import adapt_sql, split_sql, sqlite_schema_to_postgres, use_postgres + + +def expect(ok: bool, message: str) -> None: + if not ok: + raise SystemExit(f"FAIL: {message}") + print(f"OK {message}") + + +def main() -> None: + os.environ.pop("KANSHO_DB_BACKEND", None) + os.environ.pop("KANSHO_DB_PATH", None) + os.environ.pop("DB_HOST", None) + os.environ.pop("DATABASE_URL", None) + expect(use_postgres() is False, "default engine is sqlite") + + os.environ["KANSHO_DB_PATH"] = "/tmp/isolated.sqlite" + os.environ["DB_HOST"] = "postgres" + expect(use_postgres() is False, "KANSHO_DB_PATH keeps tests on sqlite") + os.environ["KANSHO_DB_BACKEND"] = "postgres" + expect(use_postgres() is True, "explicit postgres backend wins") + os.environ["KANSHO_DB_BACKEND"] = "sqlite" + expect(use_postgres() is False, "explicit sqlite backend wins") + os.environ.pop("KANSHO_DB_BACKEND", None) + os.environ.pop("KANSHO_DB_PATH", None) + expect(use_postgres() is True, "DB_HOST alone selects postgres") + os.environ.pop("DB_HOST", None) + + translated = sqlite_schema_to_postgres("created TEXT NOT NULL DEFAULT (datetime('now'))") + expect("CURRENT_TIMESTAMP::text" in translated, "schema datetime default") + expect("datetime('now')" not in translated, "no sqlite datetime left in schema") + + ignore = adapt_sql("INSERT OR IGNORE INTO tiers (id) VALUES (?)") + expect("INSERT INTO tiers" in ignore, "insert or ignore becomes insert") + expect("ON CONFLICT DO NOTHING" in ignore, "conflict do nothing appended") + expect("%s" in ignore and "?" not in ignore, "placeholders converted") + + upsert = adapt_sql( + "INSERT INTO app_settings (key, value) VALUES (?, ?) ON CONFLICT(key) DO UPDATE SET value = excluded.value" + ) + expect("ON CONFLICT (key)" in upsert, "on conflict parenthesis spacing") + expect(upsert.count("ON CONFLICT") == 1, "existing on conflict not duplicated") + expect("%s" in upsert, "upsert placeholders") + + quoted = adapt_sql("SELECT '?' AS q, name FROM profiles WHERE id = ?") + expect("'?'" in quoted, "question mark inside quotes kept") + expect(quoted.endswith("%s") or quoted.rstrip().endswith("%s"), "trailing placeholder converted") + + parts = split_sql("CREATE TABLE a (id TEXT); CREATE TABLE b (id TEXT);") + expect(parts == ["CREATE TABLE a (id TEXT)", "CREATE TABLE b (id TEXT)"], "split two statements") + schema = (ROOT / "schema.sql").read_text(encoding="utf-8") + pg_schema = sqlite_schema_to_postgres(schema) + expect("datetime('now')" not in pg_schema, "translated schema has no sqlite datetime") + expect("CURRENT_TIMESTAMP::text" in pg_schema, "translated schema uses timestamp text default") + expect(len(split_sql(pg_schema)) > 40, "schema splits into many statements") + from sqlite_to_postgres import TABLES + + tables = re.findall(r"CREATE TABLE IF NOT EXISTS (\w+)", schema) + expect(tables == list(TABLES), "import table order matches schema.sql") + print("sql compat: OK") + + +if __name__ == "__main__": + main() diff --git a/docker-compose.dev-env.yml b/docker-compose.dev-env.yml new file mode 100644 index 0000000..d68ecd9 --- /dev/null +++ b/docker-compose.dev-env.yml @@ -0,0 +1,84 @@ +# Server development stack: /home/lars/docker/kansho-dev +# No fixed container_name — Compose prefixes with the project name. + +services: + postgres: + image: postgres:16-alpine + environment: + POSTGRES_DB: "${DB_NAME:-kansho_dev}" + POSTGRES_USER: "${DB_USER:-kansho_dev}" + POSTGRES_PASSWORD: "${DB_PASSWORD:-dev_password_change_me}" + volumes: + - dev-kansho-db-data:/var/lib/postgresql/data + healthcheck: + test: ["CMD-SHELL", "pg_isready -U ${DB_USER:-kansho_dev} -d ${DB_NAME:-kansho_dev}"] + interval: 5s + timeout: 5s + retries: 10 + restart: unless-stopped + networks: + - dev-kansho-network + + backend: + build: + context: ./backend + dockerfile: Dockerfile + environment: + KANSHO_DB_BACKEND: postgres + KANSHO_ENV: "${KANSHO_ENV:-development}" + KANSHO_DATA_DIR: /app/data + KANSHO_MEDIA_ROOT: /app/data/media + DB_HOST: postgres + DB_PORT: "5432" + DB_NAME: "${DB_NAME:-kansho_dev}" + DB_USER: "${DB_USER:-kansho_dev}" + DB_PASSWORD: "${DB_PASSWORD:-dev_password_change_me}" + APP_URL: "${APP_URL:-https://dev.kansho.jinkendo.de}" + ALLOWED_ORIGINS: "${ALLOWED_ORIGINS:-https://dev.kansho.jinkendo.de,http://192.168.2.49:3096,http://localhost:3096}" + KANSHO_PROVIDER_KEY: "${KANSHO_PROVIDER_KEY:-}" + KANSHO_DETECT_PROVIDER_KEY: "${KANSHO_DETECT_PROVIDER_KEY:-}" + volumes: + - dev-kansho-media:/app/data/media + ports: + - "${KANSHO_BACKEND_PORT:-8096}:8000" + depends_on: + 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: 18 + start_period: 90s + restart: unless-stopped + networks: + - dev-kansho-network + + frontend: + build: + context: ./frontend + dockerfile: Dockerfile + args: + VITE_API_URL: "" + ports: + - "${KANSHO_FRONTEND_PORT:-3096}:80" + depends_on: + backend: + condition: service_healthy + restart: unless-stopped + networks: + - dev-kansho-network + +volumes: + dev-kansho-db-data: + dev-kansho-media: + +networks: + dev-kansho-network: + driver: bridge diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..ef24387 --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,87 @@ +# Production stack on the Raspberry Pi: /home/lars/docker/kansho +# Secrets live in the host .env next to this file, never in Git. + +services: + postgres: + image: postgres:16-alpine + container_name: kansho-db-prod + environment: + POSTGRES_DB: "${DB_NAME:-kansho}" + POSTGRES_USER: "${DB_USER:-kansho}" + POSTGRES_PASSWORD: "${DB_PASSWORD:?set DB_PASSWORD in .env}" + volumes: + - kansho-db-data:/var/lib/postgresql/data + healthcheck: + test: ["CMD-SHELL", "pg_isready -U ${DB_USER:-kansho} -d ${DB_NAME:-kansho}"] + interval: 5s + timeout: 5s + retries: 10 + restart: unless-stopped + networks: + - kansho-network + + backend: + build: + context: ./backend + dockerfile: Dockerfile + container_name: kansho-api + environment: + KANSHO_DB_BACKEND: postgres + KANSHO_ENV: "${KANSHO_ENV:-production}" + KANSHO_DATA_DIR: /app/data + KANSHO_MEDIA_ROOT: /app/data/media + DB_HOST: postgres + DB_PORT: "5432" + DB_NAME: "${DB_NAME:-kansho}" + DB_USER: "${DB_USER:-kansho}" + DB_PASSWORD: "${DB_PASSWORD:?set DB_PASSWORD in .env}" + APP_URL: "${APP_URL:-https://kansho.jinkendo.de}" + ALLOWED_ORIGINS: "${ALLOWED_ORIGINS:-https://kansho.jinkendo.de}" + KANSHO_PROVIDER_KEY: "${KANSHO_PROVIDER_KEY:-}" + KANSHO_DETECT_PROVIDER_KEY: "${KANSHO_DETECT_PROVIDER_KEY:-}" + volumes: + - kansho-media:/app/data/media + ports: + - "${KANSHO_BACKEND_PORT:-8005}:8000" + depends_on: + 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: 40s + restart: unless-stopped + networks: + - kansho-network + + frontend: + build: + context: ./frontend + dockerfile: Dockerfile + args: + VITE_API_URL: "" + container_name: kansho-ui + ports: + - "${KANSHO_FRONTEND_PORT:-3006}:80" + depends_on: + backend: + condition: service_healthy + restart: unless-stopped + networks: + - kansho-network + +volumes: + kansho-db-data: + kansho-media: + +networks: + kansho-network: + driver: bridge diff --git a/docs/DEPLOYMENT.md b/docs/DEPLOYMENT.md new file mode 100644 index 0000000..7d72425 --- /dev/null +++ b/docs/DEPLOYMENT.md @@ -0,0 +1,134 @@ +# Deployment – Kanshō + +**Stand:** 2026-09-07 +**Server:** Raspberry Pi 5 (`192.168.2.49`) — gleicher Host wie Mitai/Shinkan/Kairo, **eigene** Compose-Projekte +**Runner:** Gitea Actions (`/home/lars/gitea-runner/`) +**Repo:** `http://192.168.2.144:3000/Lars/Kansho.git` + +Kanonische Runtime-Entscheidungen: `docs/architecture/technical/runtime_and_deploy.md`. +Datenübernahme SQLite → Postgres: `backend/sqlite_to_postgres.py` (Probe auf Kopie, `--confirm`). + +--- + +## Port- und Pfad-Übersicht + +| | Production | Development | +|---|------------|-------------| +| **Git-Branch** | `main` | `develop` | +| **Server-Verzeichnis** | `/home/lars/docker/kansho` | `/home/lars/docker/kansho-dev` | +| **Frontend-Port** | 3006 | 3096 | +| **Backend-Port** | 8005 | 8096 | +| **PostgreSQL** | nur Compose-Netz, DB `kansho` | nur Compose-Netz, DB `kansho_dev` | +| **Domain** | kansho.jinkendo.de | dev.kansho.jinkendo.de | +| **Compose** | `docker-compose.yml` | `docker-compose.dev-env.yml` | + +Prod-Frontend ist **3006**, nicht 3005: auf dem Pi lauscht Bookstack bereits auf `0.0.0.0:3005`. Dev 3096/8096 und Prod-API 8005 waren frei. + +Lokal ohne Docker bleibt Windows: Frontend **5188**, Backend **8018**, SQLite unter `backend/data/`. + +--- + +## Einmalige Server-Einrichtung + +```bash +mkdir -p /home/lars/docker/kansho /home/lars/docker/kansho-dev + +cd /home/lars/docker/kansho-dev +git clone http://192.168.2.144:3000/Lars/Kansho.git . +git checkout develop +cp .env.example .env +# DB_PASSWORD und Provider-Keys setzen + +cd /home/lars/docker/kansho +git clone http://192.168.2.144:3000/Lars/Kansho.git . +git checkout main +cp .env.example .env +# Prod-DB_PASSWORD und Provider-Keys setzen +``` + +Host-nginx: `nginx/kansho.conf` und `nginx/kansho-dev.conf` nach `/etc/nginx/sites-available/`, dann `nginx/certbot-setup.sh`. DNS für beide Hostnamen auf den Reverse-Proxy. + +Gitea: Actions aktivieren. Derselbe Pi-Runner wie die Schwesterprodukte (`ubuntu-latest`). + +Watchtower bleibt aus. + +--- + +## Gitea Actions + +| Workflow | Trigger | Zweck | +|----------|---------|--------| +| `deploy-dev.yml` | Push `develop` | Deploy nach `/home/lars/docker/kansho-dev`, Health `localhost:8096` | +| `deploy-prod.yml` | Push `main` | Deploy nach `/home/lars/docker/kansho`, Health `localhost:8005` | +| `test.yml` | Push `develop`/`main`, PR `develop` | Backend-Tests isoliert SQLite im Container plus Frontend-Build. Keine Live-Provider-Keys. | + +Prod nur über Merge `develop` → `main`, kein direkter Prod-Schreibzugriff. + +--- + +## Datenübernahme (persönlich, Klasse A/B) + +1. Phase-A-SQLite auf dem Heimrechner ist führend, bis der Dev-Import grün ist. +2. Schema auf der Zielinstanz durch normalen Container-Start anlegen (leeres Postgres). +3. Import auf **Dev zuerst**: + +```bash +# Zip oder laufende SQLite + Medien in den Dev-Container legen, dann: +docker compose -f docker-compose.dev-env.yml exec -T backend \ + python sqlite_to_postgres.py --confirm \ + --sqlite /tmp/kansho.sqlite \ + --media-src /tmp/media \ + --media-dest /app/data/media +``` + +Windows-Vorbereitung (Kopie, nicht das einzige Backup): + +```powershell +.\scripts\backup-local.ps1 create +# Archiv prüfen, dann SQLite+Medien auf den Pi kopieren — nicht nach Gitea. +``` + +4. Abnahme Dev: Profilanzahl, Journal-Days/Entries, Messages, ein Medien-GET, Gateway fail-closed, `KANSHO_ENV=production` ohne Klartext-Detect. +5. Erst dann derselbe Import nach Prod (`--confirm`, leeres Volume). `--replace` nur auf einer bewussten Kopie. + +`debug.persist_traces` nach dem Import prüfen. + +--- + +## Postgres-Backup (Server) + +Kein Ersatz durch `backup-local.ps1` (das bleibt der SQLite-Weg). + +```bash +# Dump +cd /home/lars/docker/kansho +docker compose exec -T postgres pg_dump -U kansho kansho > "/home/lars/backups/kansho-$(date -u +%Y%m%d).sql" + +# Medienvolume +docker run --rm -v kansho_kansho-media:/src -v /home/lars/backups:/dst alpine \ + tar -C /src -czf /dst/kansho-media-$(date -u +%Y%m%d).tgz . +``` + +Restore-Übung vor dem ersten Prod-Dialogbestand nach dem Cutover wiederholen. Dump und Medien nicht an Provider und nicht in öffentliche Remotes. + +--- + +## Manuelles Deploy + +```bash +# Development +cd /home/lars/docker/kansho-dev +git fetch origin develop && git reset --hard origin/develop +docker compose -f docker-compose.dev-env.yml build --no-cache backend frontend +docker compose -f docker-compose.dev-env.yml up -d --wait +curl -sf http://localhost:8096/api/health + +# Production +cd /home/lars/docker/kansho +git fetch origin main && git reset --hard origin/main +docker compose build --no-cache backend frontend +docker compose up -d --wait +curl -sf http://localhost:8005/api/health +``` + +Laptop-Checkout nach erfolgreichem Heim-Restore und Dev-Import nur noch Archiv. Keine parallelen Schreibzugriffe auf denselben persönlichen Bestand. diff --git a/docs/architecture/technical/data_architecture.md b/docs/architecture/technical/data_architecture.md index 72569ed..691364b 100644 --- a/docs/architecture/technical/data_architecture.md +++ b/docs/architecture/technical/data_architecture.md @@ -79,6 +79,15 @@ Kein Thread-State-Machine-Kapitel in v0.1. Die fachlich genannten Zustände (`Op Entsprechen den Interview-Leitfragen Phase F1/F4 und werden hier nicht vorab beantwortet: kanonische IDs, Backlinks, welche Strukturen nur in Obsidian existieren, Schema für Reflection Memory und Knowledge Delta. +## 6a. Persistenzgrenzen Server vs. lokal (2026-09-07) + +Additiv, ohne das Fachschema vorzuziehen: + +- Windows-Checkout und `test-mvp.ps1`: SQLite unter `backend/data/` (gitignoriert). +- Docker auf dem Pi: PostgreSQL 16, eigene Instanz je Umgebung, Medien im Compose-Volume `kansho-media` / `dev-kansho-media`. +- Identity-Mappings bleiben Klasse A in der Local Trusted Zone (jetzt: Pi-Postgres, nicht Provider). +- Kein Shared-Schema mit Mitai. + ## 7. Querverweise - `memory_storage_and_offline.md`, `integrations_technical.md`, `privacy_gateway.md`, `platform_extensibility.md` diff --git a/docs/architecture/technical/documentation_index.md b/docs/architecture/technical/documentation_index.md index ba4039e..a259251 100644 --- a/docs/architecture/technical/documentation_index.md +++ b/docs/architecture/technical/documentation_index.md @@ -137,7 +137,7 @@ Kanonisches Home **wie** der Slice gebaut ist: `mvp_implementation.md`. Fachlich 6. `../../work_orders/home_environment_setup.md` 7. fachlich: `../functional/guardrails.md` (Datenklassen beim Kopieren von Backup und `.env`) -Arbeitsaufträge liegen unter `docs/work_orders/`. Sie ersetzen keine kanonischen Kapitel. +Arbeitsaufträge liegen unter `docs/work_orders/`. Sie ersetzen keine kanonischen Kapitel. Betrieb auf dem Pi: `docs/DEPLOYMENT.md`. ## 4. Ladeprinzip diff --git a/docs/architecture/technical/environment_handover.md b/docs/architecture/technical/environment_handover.md index f1746c5..dd5e37c 100644 --- a/docs/architecture/technical/environment_handover.md +++ b/docs/architecture/technical/environment_handover.md @@ -155,13 +155,12 @@ Entschieden (nicht neu verhandeln): - Kein Auto-Rollback der Datenbank - Kanshō-Ports **nicht** Mitai `3002`/`8002`/`3099`/`8099` kopieren -Offen, vor Compose festlegen: +Offen, vor Compose festlegen — **beantwortet 2026-09-07:** -1. Läuft Kanshō auf demselben Pi wie Mitai, als **eigenes** Compose-Projekt? -2. Eigenes Postgres (bevorzugte Richtung: ja, eigene Instanz/DB `kansho`, kein Shared-Schema mit Mitai)? -3. Dev-Domain / Prod-Domain / Host-Pfade (Hypothese: `dev.kansho.jinkendo.de` / `kansho.jinkendo.de`)? -4. Dev-Ports auf dem Pi, lokal bleiben 5188/8018 für den Windows-Checkout ohne Docker. -5. Ob Welle 2 zuerst Compose **mit SQLite-Volume** als Zwischenstand nutzt oder direkt Postgres + Migrationsadapter. +1. Läuft Kanshō auf demselben Pi wie Mitai, als **eigenes** Compose-Projekt? **Ja.** `/home/lars/docker/kansho` und `kansho-dev`. +2. Eigenes Postgres? **Ja**, `kansho` / `kansho_dev`, kein Shared-Schema. +3. Dev-Domain / Prod-Domain / Host-Pfade? **`dev.kansho.jinkendo.de` / `kansho.jinkendo.de`**, Ports 3096/8096 und 3006/8005. Lokal bleiben 5188/8018. Prod-UI nicht 3005 (Bookstack auf dem Pi). +4. Welle 2 SQLite im Volume oder direkt Postgres? **Direkt Postgres.** SQLite bleibt lokal und für Tests. Empfohlene Reihenfolge in Welle 2: @@ -173,6 +172,8 @@ Empfohlene Reihenfolge in Welle 2: 6. Gitea-Workflows erst, wenn Runner-Pfade existieren (`runtime_and_deploy.md` §4). 7. Watchtower später prüfen, nicht in diesem Transfer. +Diese Schritte sind im Repository angelegt (`docker-compose*.yml`, `.gitea/workflows/`, `backend/db_init.py`, `backend/sqlite_to_postgres.py`). Pi-Bootstrap und Cutover: `docs/DEPLOYMENT.md`. + --- ## 7. Datenschutz beim Transfer diff --git a/docs/architecture/technical/mvp_implementation.md b/docs/architecture/technical/mvp_implementation.md index f00f5eb..3049806 100644 --- a/docs/architecture/technical/mvp_implementation.md +++ b/docs/architecture/technical/mvp_implementation.md @@ -37,7 +37,7 @@ Lokal, eine Instanz, ein Profil-Nutzer nach Setup: Proxy: Vite leitet `/api` an 8018. CORS erlaubt `localhost:5188`. -**Abweichung vom technischen Zielrahmen:** `product_frame_and_stack.md` nennt PostgreSQL. Der Slice läuft auf SQLite. Lokal zulässig; Prod-Pfad offen. Transfer der Urlaubsinstanz: zuerst SQLite-Restore auf dem Heimrechner, danach Compose/Postgres. Siehe `environment_handover.md` und `runtime_and_deploy.md` §7.2. +**Abweichung vom technischen Zielrahmen (lokal):** `product_frame_and_stack.md` nennt PostgreSQL. Der Slice auf dem Windows-Checkout bleibt SQLite (`5188`/`8018`). **Additiv 2026-09-07:** Docker/Server nutzt PostgreSQL 16 (`KANSHO_DB_BACKEND=postgres`). Stores behalten SQLite-förmiges SQL; `sql_compat.py` übersetzt zur Laufzeit. Nummerierte Dateien ab `backend/migrations/022_*.sql`. Siehe `runtime_and_deploy.md` und `docs/DEPLOYMENT.md`. Health: `GET /api/health`. @@ -101,6 +101,8 @@ Prompts in der DB, nicht im Anwendungscode: `mvp.dialogue_turn`, `mvp.journal_re Schema: `backend/schema.sql`. Isolation über `profile_id`. +**Additiv 2026-09-07:** Dieselbe `schema.sql` ist Greenfield für Postgres (Dialekt nur `datetime('now')` → `CURRENT_TIMESTAMP::text` in `db_init.py`). SQLite-History 001–021 wird auf leerem Postgres als applied erfasst, nicht als ALTER nachgespielt. Persönliche Daten kommen nicht über den Alltagspfad, sondern über `sqlite_to_postgres.py`. + **Rahmen / Layer 0:** `profiles`, Auth-`sessions`, `usage_sessions`, `conversations`, `messages`, Thread-/Space-Hüllen, `derived_records` (ungenutzt für Journal-Writes), `identity_mappings`, Prompt-/Feature-/Provider-Tabellen. **Additiv 2026-08-28:** `app_settings` (Instanzschalter, derzeit `debug.persist_traces`) und `debug_runs` (Admin-Testspur je Schritt, Profil-isoliert, keine Mapping-Tabelle). **Journal-Slice:** diff --git a/docs/architecture/technical/product_frame_and_stack.md b/docs/architecture/technical/product_frame_and_stack.md index 6a9220d..4886de1 100644 --- a/docs/architecture/technical/product_frame_and_stack.md +++ b/docs/architecture/technical/product_frame_and_stack.md @@ -110,14 +110,14 @@ Mitai-Prompt-Engine-Muster (ein Executor, Registry, Admin-Konfiguration) wird ü | Referenz | Mitai-Rahmen weitgehend | entschieden | | Fachliche IA / Startroute | nicht durch den Rahmen vorwegnehmen | offen (Fach-Arbeitsstand) | | Mandantenmodell | keines | entschieden | -| Konkrete Ports/Domains | nicht aus Mitai kopieren | offen | +| Konkrete Ports/Domains | lokal 5188/8018; Server 3006/8005 und 3096/8096; `kansho.jinkendo.de` | entschieden 2026-09-07 | | Gemeinsame UI-Bibliothek der Familie | eigene Kopie des Musters, kein Shared-Package vorausgesetzt | bevorzugte Richtung | ## 9. Offene Fragen 1. Soll der Rahmen später in ein gemeinsames Jinkendo-Paket extrahiert werden oder als kopiertes Muster in Kanshō leben? -2. Welche Domain und welche Host-Ports gelten für Dev/Prod? -3. Wird Nginx als eigener Container beibehalten oder das Vite-Preview nur für lokale Entwicklung genutzt? +2. ~~Welche Domain und welche Host-Ports gelten für Dev/Prod?~~ **Entschieden 2026-09-07:** `kansho.jinkendo.de` / `dev.kansho.jinkendo.de`, Ports 3006/8005 und 3096/8096. Prod-Frontend nicht 3005 (Bookstack). +3. ~~Wird Nginx als eigener Container beibehalten oder das Vite-Preview nur für lokale Entwicklung genutzt?~~ **Entschieden:** Frontend-Nginx im Compose-Container (Prod/Dev-Server); Vite nur lokal. ## 10. Querverweise diff --git a/docs/architecture/technical/runtime_and_deploy.md b/docs/architecture/technical/runtime_and_deploy.md index 7b0f354..6785cef 100644 --- a/docs/architecture/technical/runtime_and_deploy.md +++ b/docs/architecture/technical/runtime_and_deploy.md @@ -20,7 +20,7 @@ Muster der Familie: zwei Umgebungen, zwei Branches. | Development | `develop` | Auto-Deploy nach Push | | Production | `main` | Auto-Deploy nach Merge | -**Status: entschieden** als Betriebsmuster. Konkrete Domains, Host-Pfade und **Produktions-Ports** sind **offen** und werden nicht aus Mitai (`3002`/`8002`, `3099`/`8099`, `bodytrack/`) kopiert. +**Status: entschieden** als Betriebsmuster. Host, Pfade, Domains und Ports sind seit 2026-09-07 festgelegt (gleicher Raspberry Pi wie die Schwesterprodukte, eigene Compose-Projekte, keine Mitai-`bodytrack/`-Pfade). Lokale Entwicklung (ohne Docker) verwendet eigene Ports, nicht die Vite-/FastAPI-Defaults und nicht die Ports anderer lokaler Repos: @@ -31,7 +31,19 @@ Lokale Entwicklung (ohne Docker) verwendet eigene Ports, nicht die Vite-/FastAPI Nicht verwenden: 5173, 5174, 4000, 4001, 8000. -Hypothese für spätere Benennung: `kansho.jinkendo.de` / `dev.kansho.jinkendo.de`, analog zur Foundation-Tabelle. Nicht festgelegt. +Server (Raspberry Pi 5, `192.168.2.49`): + +| | Development | Production | +|---|---|---| +| Branch | `develop` | `main` | +| Host-Pfad | `/home/lars/docker/kansho-dev` | `/home/lars/docker/kansho` | +| Domain | `dev.kansho.jinkendo.de` | `kansho.jinkendo.de` | +| Frontend-Port | 3096 | 3006 | +| Backend-Port | 8096 | 8005 | +| Postgres | eigene Instanz `kansho_dev`, nicht nach außen | eigene Instanz `kansho`, nicht nach außen | +| Compose | `docker-compose.dev-env.yml` | `docker-compose.yml` | + +Operative Schritte: `docs/DEPLOYMENT.md`. Gitea intern: `http://192.168.2.144:3000/Lars/Kansho.git`. ## 2. Container @@ -69,9 +81,9 @@ push/PR nach Tests → test.yml (pytest, Frontend-Build) merge in main → deploy-prod.yml ``` -**Status: bevorzugte Richtung.** Workflow-Dateien erst anlegen, wenn Code und Runner-Pfade existieren. +**Status: entschieden.** Workflows liegen unter `.gitea/workflows/` (`deploy-dev.yml`, `deploy-prod.yml`, `test.yml`). Muster analog Kairo: `git reset --hard`, `build --no-cache`, Health `GET /api/health`. -Kanshō-Repo liegt bereits auf Gitea (`Lars/Kansho`). HTTPS-Push ist eingerichtet. +Kanshō-Repo liegt auf Gitea (`Lars/Kansho`). HTTPS-Push ist eingerichtet. Der Pi-Runner (`ubuntu-latest`) ist derselbe wie bei Mitai/Shinkan/Kairo. ## 5. Was Deploy nicht übernimmt @@ -86,17 +98,20 @@ Kanshō-Repo liegt bereits auf Gitea (`Lars/Kansho`). HTTPS-Push ist eingerichte |---|---|---| | Compose + Postgres + nummerierte SQL-Migrationen | ja | entschieden | | develop → Dev, main → Prod | ja | entschieden | -| Ports/Domains/Hostpfade | Prod offen; lokal Frontend 5188 / Backend 8018 | lokal festgelegt, Prod offen | -| Gitea Workflows | Mitai-Muster | bevorzugte Richtung | +| Ports/Domains/Hostpfade | lokal 5188/8018; Server 3096/8096 und 3006/8005; `*.kansho.jinkendo.de` | entschieden 2026-09-07 | +| Host | gleicher Raspberry Pi wie Mitai/Shinkan/Kairo, eigene Compose-Projekte `kansho` / `kansho-dev` | entschieden 2026-09-07 | +| Postgres | eigene Instanz je Umgebung (`kansho` / `kansho_dev`), kein Shared-Schema | entschieden 2026-09-07 | +| Gitea Workflows | `.gitea/workflows/` analog Kairo | entschieden 2026-09-07 | | Auto-Rollback | nein | verworfen | -| Urlaubs-Laptop ohne Docker/SQLite | Ist bis Transfer Welle 1 | dokumentiert 2026-09-07 | +| Dual-Backend | SQLite lokal/Tests; Postgres im Container | entschieden 2026-09-07 | +| Urlaubs-Laptop ohne Docker/SQLite | Phase A auf dem Heimrechner restored | dokumentiert 2026-09-07 | | Transfer vor Postgres | SQLite-Restore auf dem Heimrechner zuerst | entschieden als Reihenfolge | ## 7. Offene Fragen -1. Läuft Kanshō auf demselben Raspberry-Pi-Host wie Mitai, mit eigenen Compose-Projekten? -2. Gemeinsames oder separates Postgres? -3. Backup-Rhythmus und Restore-Übung vor erstem persönlichen Dialogdatenbestand. Die Urlaubs-SQLite-Restore auf dem Heimrechner (Welle 1) ist diese Übung für den bestehenden Datenbestand; Produktions-Backup für Postgres bleibt offen. +1. ~~Läuft Kanshō auf demselben Raspberry-Pi-Host wie Mitai, mit eigenen Compose-Projekten?~~ **Entschieden:** ja, Pi `192.168.2.49`, Pfade `/home/lars/docker/kansho` und `kansho-dev`. +2. ~~Gemeinsames oder separates Postgres?~~ **Entschieden:** eigene Instanz je Umgebung, kein Shared-Schema mit Mitai. +3. Backup-Rhythmus nach dem Prod-Cutover. Die Urlaubs-SQLite-Restore auf dem Heimrechner (Welle 1) ist die Übung für den bestehenden Datenbestand. Postgres-Dump plus Medien: `docs/DEPLOYMENT.md` und `scripts/backup-postgres.sh`. Erste Restore-Übung auf Dev vor dem Prod-Import. ### 7.1 Lokales Backup für die Urlaubs-Testphase (2026-08-25) @@ -111,15 +126,15 @@ Kein Ersatz für Produktions-Backup, Docker oder Verschlüsselung. Die Urlaubsinstanz lief ohne Docker auf SQLite (`backend/data/kansho.sqlite`, Ports 5188/8018). -**Reihenfolge entschieden:** zuerst Kontinuität (Clone + SQLite-Restore + Smoke), danach Betriebsrahmen (Compose, Postgres 16, Gitea-Runner). Postgres bleibt Ziel; `backend/db.py` ist weiterhin SQLite. Ein direkter Sprung „Laptop-SQLite nach Prod-Postgres“ ohne Zwischen-Restore ist verworfen. +**Reihenfolge entschieden:** zuerst Kontinuität (Clone + SQLite-Restore + Smoke), danach Betriebsrahmen (Compose, Postgres 16, Gitea-Runner). Dual-Backend: SQLite lokal/Tests, Postgres im Container (`KANSHO_DB_BACKEND=postgres`). Ein direkter Sprung „Laptop-SQLite nach Prod-Postgres“ ohne Zwischen-Restore ist verworfen. Import: `backend/sqlite_to_postgres.py --confirm`, zuerst Dev, dann Prod. **Additiv 2026-09-07 (Transportweg):** Auf dem Urlaubs-Laptop dürfen weder USB-Stick noch NAS verbunden werden. Der einzige Weg für Datenbank und persönliche Journaldaten ist das **selbst gehostete** Gitea (`gitea.stommer.de`, nur eigene Infrastruktur). Das ist eine einmalige Transportentscheidung, kein Produktregelwechsel: Provider, OpenRouter und öffentliche Remotes bleiben ausgeschlossen. Provider-Keys und `backend/.env` bleiben **außerhalb** Git. -Aktuelles Transportartefakt: `transfer/kansho-laptop-20260907.zip` (SHA256 `EBF7455D5688588E8C6E50C05D16200CE191D79FB7CEACDBA9C25460B7497B07`). Nach erfolgreichem Restore auf dem Zielrechner das Verzeichnis `transfer/` aus dem Arbeitsbaum entfernen. +Transportartefakt 2026-09-07: `transfer/kansho-laptop-20260907.zip` (SHA256 `EBF7455D5688588E8C6E50C05D16200CE191D79FB7CEACDBA9C25460B7497B07`). Nach erfolgreichem Restore auf dem Heimrechner das Verzeichnis `transfer/` aus dem Arbeitsbaum entfernen. Die Git-Historie behält das Zip. -Kanonisches Session-Handover: `environment_handover.md`. Aufträge: `../../work_orders/laptop_closeout.md`, `../../work_orders/home_environment_setup.md`. +Kanonisches Session-Handover: `environment_handover.md`. Aufträge: `../../work_orders/laptop_closeout.md`, `../../work_orders/home_environment_setup.md`. Betrieb: `docs/DEPLOYMENT.md`. -Noch nicht im Repository: Dockerfiles, Compose, `.gitea/workflows/`, Postgres-Adapter. Mitai unter `C:\dev\mitai` bleibt Muster, nicht Quelle für Ports oder `bodytrack/`-Pfade. +Compose, Dockerfiles, `.gitea/workflows/` und der Postgres-Adapter liegen im Repository. Mitai unter `C:\Dev\mitai-jinkendo` bleibt Muster, nicht Quelle für Ports oder `bodytrack/`-Pfade. ## 8. Querverweise diff --git a/docs/work_orders/home_environment_setup.md b/docs/work_orders/home_environment_setup.md index e4bea0a..8e8cb71 100644 --- a/docs/work_orders/home_environment_setup.md +++ b/docs/work_orders/home_environment_setup.md @@ -156,3 +156,9 @@ Nach Phase A zwingend, nach B/C erneut: 3. Ob Daten SQLite oder Postgres führen 4. Restore-Nachweis (Anzahlen, ein Medienbeispiel ohne Inhalt zu zitieren) 5. Offene Punkte für die nächste Session + +## Stand 2026-09-07 (Heimrechner) + +Phase A: Restore aus `transfer/kansho-laptop-20260907.zip` nach `backend/data/` (1 Profil, 2 Spaces inkl. `Kroatien 2026`, 17 Days/Entries, 20 Conversations, 379 Messages, 70 Mediendateien). Health `GET /api/health` ok. `backend/.env` neu aus Example (Keys vom Nutzer nachzutragen). `transfer/` danach aus dem Tree genommen. + +Phase B–D im Repository: Compose, Dual-Backend, Workflows, `docs/DEPLOYMENT.md`. Pi-Verzeichnisse und Host-nginx sind einmalig auf `192.168.2.49` anzulegen. Persönlicher Postgres-Import zuerst nach Dev, dann Prod. diff --git a/frontend/Dockerfile b/frontend/Dockerfile new file mode 100644 index 0000000..c78eabc --- /dev/null +++ b/frontend/Dockerfile @@ -0,0 +1,15 @@ +FROM node:20-alpine AS build + +WORKDIR /app +COPY package.json package-lock.json ./ +RUN npm ci +COPY . . +ARG VITE_API_URL= +ENV VITE_API_URL=$VITE_API_URL +RUN npm run build + +FROM nginx:alpine +COPY --from=build /app/dist /usr/share/nginx/html +COPY nginx.conf /etc/nginx/conf.d/default.conf +EXPOSE 80 +CMD ["nginx", "-g", "daemon off;"] diff --git a/frontend/nginx.conf b/frontend/nginx.conf new file mode 100644 index 0000000..b200b9c --- /dev/null +++ b/frontend/nginx.conf @@ -0,0 +1,26 @@ +server { + listen 80; + server_name localhost; + root /usr/share/nginx/html; + index index.html; + + resolver 127.0.0.11 valid=10s ipv6=off; + client_max_body_size 80m; + + 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; + proxy_connect_timeout 60s; + proxy_send_timeout 600s; + proxy_read_timeout 600s; + } + + location / { + try_files $uri $uri/ /index.html; + } +} diff --git a/nginx/certbot-setup.sh b/nginx/certbot-setup.sh new file mode 100644 index 0000000..f91443d --- /dev/null +++ b/nginx/certbot-setup.sh @@ -0,0 +1,23 @@ +#!/bin/bash +# Let's Encrypt for kansho.jinkendo.de and dev.kansho.jinkendo.de +# Prerequisites: host nginx installed, ports 80/443 reachable, DNS A/AAAA in place. + +set -e + +EMAIL="${CERTBOT_EMAIL:-lars@stommer.de}" + +echo "=== Let's Encrypt for Kanshō ===" + +sudo apt-get update +sudo apt-get install -y certbot python3-certbot-nginx + +sudo certbot --nginx \ + -d kansho.jinkendo.de \ + -d dev.kansho.jinkendo.de \ + --email "$EMAIL" \ + --agree-tos \ + --non-interactive \ + --redirect + +echo "Check renewal: sudo certbot renew --dry-run" +echo "Timer: sudo systemctl status certbot.timer" diff --git a/nginx/kansho-dev.conf b/nginx/kansho-dev.conf new file mode 100644 index 0000000..2aa20d8 --- /dev/null +++ b/nginx/kansho-dev.conf @@ -0,0 +1,50 @@ +# Kanshō development vhost. Install as /etc/nginx/sites-available/kansho-dev. + +server { + listen 80; + server_name dev.kansho.jinkendo.de; + + location /.well-known/acme-challenge/ { + root /var/www/certbot; + } + + location / { + return 301 https://$host$request_uri; + } +} + +server { + listen 443 ssl http2; + server_name dev.kansho.jinkendo.de; + + ssl_certificate /etc/letsencrypt/live/dev.kansho.jinkendo.de/fullchain.pem; + ssl_certificate_key /etc/letsencrypt/live/dev.kansho.jinkendo.de/privkey.pem; + include /etc/letsencrypt/options-ssl-nginx.conf; + ssl_dhparam /etc/letsencrypt/ssl-dhparams.pem; + + add_header Strict-Transport-Security "max-age=31536000; includeSubDomains" always; + add_header X-Frame-Options "SAMEORIGIN" always; + add_header X-Content-Type-Options "nosniff" always; + add_header Referrer-Policy "strict-origin-when-cross-origin" always; + + client_max_body_size 80m; + + location /api/ { + proxy_pass http://127.0.0.1:8096; + 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; + proxy_read_timeout 600s; + } + + location / { + proxy_pass http://127.0.0.1:3096; + proxy_http_version 1.1; + proxy_set_header Host $host; + proxy_set_header Upgrade $http_upgrade; + proxy_set_header Connection "upgrade"; + proxy_cache_bypass $http_upgrade; + } +} diff --git a/nginx/kansho.conf b/nginx/kansho.conf new file mode 100644 index 0000000..aac26ed --- /dev/null +++ b/nginx/kansho.conf @@ -0,0 +1,54 @@ +# Kanshō – Host nginx (outside Compose) +# Install as /etc/nginx/sites-available/kansho and symlink into sites-enabled. +# TLS via nginx/certbot-setup.sh. Reverse-proxy targets are the Pi publish ports. + +server { + listen 80; + server_name kansho.jinkendo.de; + + location /.well-known/acme-challenge/ { + root /var/www/certbot; + } + + location / { + return 301 https://$host$request_uri; + } +} + +server { + listen 443 ssl http2; + server_name kansho.jinkendo.de; + + ssl_certificate /etc/letsencrypt/live/kansho.jinkendo.de/fullchain.pem; + ssl_certificate_key /etc/letsencrypt/live/kansho.jinkendo.de/privkey.pem; + include /etc/letsencrypt/options-ssl-nginx.conf; + ssl_dhparam /etc/letsencrypt/ssl-dhparams.pem; + + add_header Strict-Transport-Security "max-age=31536000; includeSubDomains" always; + add_header X-Frame-Options "SAMEORIGIN" always; + add_header X-Content-Type-Options "nosniff" always; + add_header Referrer-Policy "strict-origin-when-cross-origin" always; + + client_max_body_size 80m; + gzip on; + gzip_types text/plain text/css application/json application/javascript text/xml application/xml; + + location /api/ { + proxy_pass http://127.0.0.1:8005; + 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; + proxy_read_timeout 600s; + } + + location / { + proxy_pass http://127.0.0.1:3006; + proxy_http_version 1.1; + proxy_set_header Host $host; + proxy_set_header Upgrade $http_upgrade; + proxy_set_header Connection "upgrade"; + proxy_cache_bypass $http_upgrade; + } +} diff --git a/scripts/backup-postgres.sh b/scripts/backup-postgres.sh new file mode 100644 index 0000000..6ef14d0 --- /dev/null +++ b/scripts/backup-postgres.sh @@ -0,0 +1,25 @@ +#!/bin/sh +# Postgres dump + note for the media volume. Run on the Pi next to Compose. +# Usage: ./scripts/backup-postgres.sh [prod|dev] +set -e +ENV_NAME="${1:-prod}" +STAMP=$(date -u +%Y%m%d-%H%M%S) +BACKUP_ROOT="${KANSHO_PG_BACKUP_DIR:-$HOME/backups/kansho}" +mkdir -p "$BACKUP_ROOT" + +if [ "$ENV_NAME" = "dev" ]; then + APP_DIR="${KANSHO_DEV_DIR:-/home/lars/docker/kansho-dev}" + COMPOSE="docker compose -f docker-compose.dev-env.yml" + DB_USER="${DB_USER:-kansho_dev}" + DB_NAME="${DB_NAME:-kansho_dev}" +else + APP_DIR="${KANSHO_PROD_DIR:-/home/lars/docker/kansho}" + COMPOSE="docker compose" + DB_USER="${DB_USER:-kansho}" + DB_NAME="${DB_NAME:-kansho}" +fi + +cd "$APP_DIR" +$COMPOSE exec -T postgres pg_dump -U "$DB_USER" "$DB_NAME" > "$BACKUP_ROOT/kansho-$ENV_NAME-$STAMP.sql" +echo "Wrote $BACKUP_ROOT/kansho-$ENV_NAME-$STAMP.sql" +echo "Copy the media volume separately; see docs/DEPLOYMENT.md." diff --git a/scripts/sqlite-to-postgres.ps1 b/scripts/sqlite-to-postgres.ps1 new file mode 100644 index 0000000..1e2489f --- /dev/null +++ b/scripts/sqlite-to-postgres.ps1 @@ -0,0 +1,29 @@ +# Wrapper for backend/sqlite_to_postgres.py (personal data, require -Confirm). +[CmdletBinding()] +param( + [string]$Sqlite, + [string]$MediaSrc, + [string]$MediaDest, + [switch]$Confirm, + [switch]$Replace +) + +$ErrorActionPreference = "Stop" +Set-StrictMode -Version Latest + +$root = Split-Path -Parent $PSScriptRoot +$py = Join-Path $root "backend\.venv\Scripts\python.exe" +if (-not (Test-Path $py)) { + $py = "python" +} + +$env:PYTHONUTF8 = "1" +$argsList = @((Join-Path $root "backend\sqlite_to_postgres.py")) +if ($Sqlite) { $argsList += @("--sqlite", $Sqlite) } +if ($MediaSrc) { $argsList += @("--media-src", $MediaSrc) } +if ($MediaDest) { $argsList += @("--media-dest", $MediaDest) } +if ($Confirm) { $argsList += "--confirm" } +if ($Replace) { $argsList += "--replace" } + +& $py @argsList +exit $LASTEXITCODE diff --git a/transfer/README.md b/transfer/README.md deleted file mode 100644 index 5c05d51..0000000 --- a/transfer/README.md +++ /dev/null @@ -1,30 +0,0 @@ ---- -title: "Kanshō – Einmaliger Laptop-Datentransfer über Gitea" -status: "Transportartefakt, nach Restore entfernen" -date: "2026-09-07" ---- - -# Einmaliger Datentransfer (Laptop 2026-09-07) - -Dieses Verzeichnis ist **kein** Produkt-Datenspeicher. Es existiert, weil auf dem Urlaubs-Laptop weder USB-Stick noch NAS verbunden werden darf. Der Transport läuft über das selbst gehostete Gitea (`gitea.stommer.de`), das nur in der eigenen Infrastruktur erreichbar ist. - -## Inhalt - -- `kansho-laptop-20260907.zip` – SQLite (Backup-API) + Journalmedien, Manifest, Checksummen -- **Nicht enthalten:** `backend/.env`, Provider-Keys, TLS-Zertifikate - -Das Archiv enthält persönliche, **unverschlüsselte** Journalinhalte, Identity-Mappings und ggf. Debug-Spuren. Es ist Klasse A/B. Nicht an Provider, nicht öffentlich klonen, nicht in fremde Remotes spiegeln. - -## Restore auf dem Zielrechner - -Backend aus. Danach: - -```powershell -.\scripts\backup-local.ps1 restore -Archive .\transfer\kansho-laptop-20260907.zip -Confirm -Replace -``` - -`.env` neu anlegen aus `backend/.env.example` und denselben Provider-Keys (Passwortmanager / OpenRouter). Das Admin-Login steckt im SQLite-Hash, nicht in der `.env`. - -## Nach erfolgreichem Restore - -Dieses Verzeichnis aus dem Arbeitsbaum entfernen und in einem eigenen Commit nach Gitea schieben. Die Git-Historie behält die Datei; das ist bewusst der Preis dieses Transportwegs. diff --git a/transfer/kansho-laptop-20260907.zip b/transfer/kansho-laptop-20260907.zip deleted file mode 100644 index 3a4cbe9..0000000 Binary files a/transfer/kansho-laptop-20260907.zip and /dev/null differ