Idempotentes Data-Seed-System neben Schema-Migrationen einfuehren.
Some checks failed
Deploy Development / deploy (push) Failing after 36s
Test Suite / pytest-backend (push) Failing after 0s
Test Suite / k6 /api/health Baseline (push) Has been skipped
Test Suite / playwright-smoke (push) Has been skipped
Test Suite / lint-backend (push) Successful in 2s
Test Suite / compose-smoke (push) Has been skipped
Some checks failed
Deploy Development / deploy (push) Failing after 36s
Test Suite / pytest-backend (push) Failing after 0s
Test Suite / k6 /api/health Baseline (push) Has been skipped
Test Suite / playwright-smoke (push) Has been skipped
Test Suite / lint-backend (push) Successful in 2s
Test Suite / compose-smoke (push) Has been skipped
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
parent
2f631f419a
commit
7a6eb3abe8
|
|
@ -62,7 +62,8 @@ jobs:
|
||||||
|
|
||||||
docker compose -f "$COMPOSE_FILE" exec -T backend sh -lc "
|
docker compose -f "$COMPOSE_FILE" exec -T backend sh -lc "
|
||||||
pip install -q -r requirements-dev.txt &&
|
pip install -q -r requirements-dev.txt &&
|
||||||
python -m pytest tests -ra -vv --tb=short
|
python -m pytest tests -ra -vv --tb=short &&
|
||||||
|
python run_seeds.py --only seed_001_cleanup_pytest_artifacts --force
|
||||||
"
|
"
|
||||||
echo "✓ pytest OK"
|
echo "✓ pytest OK"
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -103,7 +103,10 @@ Interaktive API-Doku (Dev): http://localhost:8097/api/docs
|
||||||
|
|
||||||
### Auth (AP0.2)
|
### Auth (AP0.2)
|
||||||
|
|
||||||
Bootstrap (nur wenn noch kein User existiert — `.env` setzen):
|
**Ersteinrichtung** (nur wenn noch kein User existiert):
|
||||||
|
|
||||||
|
- UI-Registrierung unter http://localhost:3097 (Tab „Registrieren“), oder
|
||||||
|
- Bootstrap per `.env` (wird als Data-Seed beim Start ausgeführt):
|
||||||
|
|
||||||
```env
|
```env
|
||||||
KAIRO_BOOTSTRAP_ADMIN_EMAIL=admin@kairo.local
|
KAIRO_BOOTSTRAP_ADMIN_EMAIL=admin@kairo.local
|
||||||
|
|
@ -111,6 +114,8 @@ KAIRO_BOOTSTRAP_ADMIN_PASSWORD=…
|
||||||
KAIRO_BOOTSTRAP_TENANT_SLUG=default
|
KAIRO_BOOTSTRAP_TENANT_SLUG=default
|
||||||
```
|
```
|
||||||
|
|
||||||
|
Migrationen & idempotente Data-Seeds: [docs/MIGRATIONS.md](docs/MIGRATIONS.md)
|
||||||
|
|
||||||
| Endpoint | Methode | Auth | Beschreibung |
|
| Endpoint | Methode | Auth | Beschreibung |
|
||||||
|----------|---------|------|--------------|
|
|----------|---------|------|--------------|
|
||||||
| `/api/auth/setup-status` | GET | — | Ob Erstregistrierung offen ist |
|
| `/api/auth/setup-status` | GET | — | Ob Erstregistrierung offen ist |
|
||||||
|
|
|
||||||
|
|
@ -21,9 +21,15 @@ else:
|
||||||
print(f"[FAIL] Migrationen fehlgeschlagen (Exit {exit_code})")
|
print(f"[FAIL] Migrationen fehlgeschlagen (Exit {exit_code})")
|
||||||
sys.exit(exit_code)
|
sys.exit(exit_code)
|
||||||
|
|
||||||
import bootstrap
|
if os.getenv("SKIP_SEEDS", "").strip().lower() not in ("1", "true", "yes"):
|
||||||
|
import run_seeds
|
||||||
|
|
||||||
bootstrap.bootstrap_admin_if_needed()
|
exit_code = run_seeds.main()
|
||||||
|
if exit_code != 0:
|
||||||
|
print(f"[FAIL] Seeds fehlgeschlagen (Exit {exit_code})")
|
||||||
|
sys.exit(exit_code)
|
||||||
|
else:
|
||||||
|
print("[SKIP_SEEDS] Data-Seeds übersprungen")
|
||||||
|
|
||||||
allowed_origins = [
|
allowed_origins = [
|
||||||
origin.strip()
|
origin.strip()
|
||||||
|
|
|
||||||
8
backend/migrations/003_data_seeds_tracking.sql
Normal file
8
backend/migrations/003_data_seeds_tracking.sql
Normal file
|
|
@ -0,0 +1,8 @@
|
||||||
|
-- Tracking für idempotente Data-Seeds (Checksum-basiert, bei Änderung erneut ausführbar)
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS data_seeds (
|
||||||
|
seed_name VARCHAR(255) PRIMARY KEY,
|
||||||
|
checksum VARCHAR(64) NOT NULL,
|
||||||
|
executed_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||||
|
last_run_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||||
|
);
|
||||||
273
backend/run_seeds.py
Normal file
273
backend/run_seeds.py
Normal file
|
|
@ -0,0 +1,273 @@
|
||||||
|
#!/usr/bin/env python3
|
||||||
|
"""Apply idempotent data seeds with checksum-based tracking and re-run on change."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import hashlib
|
||||||
|
import importlib.util
|
||||||
|
import os
|
||||||
|
import re
|
||||||
|
import sys
|
||||||
|
import time
|
||||||
|
from typing import Callable, List, Optional, Tuple
|
||||||
|
|
||||||
|
import psycopg2
|
||||||
|
import sqlparse
|
||||||
|
|
||||||
|
from db import db_params, get_connection
|
||||||
|
|
||||||
|
_SEED_PREFIX = re.compile(r"^seed_(\d+)_(.+)$")
|
||||||
|
_DEV_MARKER = ".dev."
|
||||||
|
|
||||||
|
|
||||||
|
def is_production() -> bool:
|
||||||
|
return os.getenv("ENVIRONMENT", "development").strip().lower() == "production"
|
||||||
|
|
||||||
|
|
||||||
|
def seeds_directory() -> str:
|
||||||
|
docker_path = "/app/seeds"
|
||||||
|
if os.path.isdir(docker_path):
|
||||||
|
return docker_path
|
||||||
|
return os.path.join(os.path.dirname(os.path.abspath(__file__)), "seeds")
|
||||||
|
|
||||||
|
|
||||||
|
def init_data_seeds_table(conn) -> None:
|
||||||
|
with conn.cursor() as cur:
|
||||||
|
cur.execute(
|
||||||
|
"""
|
||||||
|
CREATE TABLE IF NOT EXISTS data_seeds (
|
||||||
|
seed_name VARCHAR(255) PRIMARY KEY,
|
||||||
|
checksum VARCHAR(64) NOT NULL,
|
||||||
|
executed_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||||
|
last_run_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||||
|
)
|
||||||
|
"""
|
||||||
|
)
|
||||||
|
conn.commit()
|
||||||
|
|
||||||
|
|
||||||
|
def _seed_sort_key(filename: str) -> Tuple[int, str]:
|
||||||
|
stem = filename
|
||||||
|
for suffix in (".sql", ".py"):
|
||||||
|
if stem.endswith(suffix):
|
||||||
|
stem = stem[: -len(suffix)]
|
||||||
|
break
|
||||||
|
match = _SEED_PREFIX.match(stem)
|
||||||
|
if match:
|
||||||
|
return (int(match.group(1)), stem)
|
||||||
|
return (0, stem)
|
||||||
|
|
||||||
|
|
||||||
|
def _seed_stem(filename: str) -> str:
|
||||||
|
if filename.endswith(".dev.sql"):
|
||||||
|
return filename[: -len(".dev.sql")]
|
||||||
|
if filename.endswith(".sql"):
|
||||||
|
return filename[: -len(".sql")]
|
||||||
|
if filename.endswith(".py"):
|
||||||
|
return filename[: -len(".py")]
|
||||||
|
return filename
|
||||||
|
|
||||||
|
|
||||||
|
def seed_files(seeds_dir: str) -> List[Tuple[str, str, str]]:
|
||||||
|
"""Return (seed_name, filepath, kind) sorted by numeric prefix."""
|
||||||
|
rows: List[Tuple[str, str, str]] = []
|
||||||
|
if not os.path.isdir(seeds_dir):
|
||||||
|
return rows
|
||||||
|
|
||||||
|
for filename in os.listdir(seeds_dir):
|
||||||
|
if filename.startswith("_") or not filename.startswith("seed_"):
|
||||||
|
continue
|
||||||
|
if filename.endswith(".sql"):
|
||||||
|
kind = "sql"
|
||||||
|
elif filename.endswith(".py"):
|
||||||
|
kind = "py"
|
||||||
|
else:
|
||||||
|
continue
|
||||||
|
stem = _seed_stem(filename)
|
||||||
|
rows.append((stem, os.path.join(seeds_dir, filename), kind))
|
||||||
|
|
||||||
|
rows.sort(key=lambda item: _seed_sort_key(item[0]))
|
||||||
|
return rows
|
||||||
|
|
||||||
|
|
||||||
|
def seed_applies_in_environment(filename: str) -> bool:
|
||||||
|
if _DEV_MARKER in filename and is_production():
|
||||||
|
return False
|
||||||
|
return True
|
||||||
|
|
||||||
|
|
||||||
|
def file_checksum(filepath: str) -> str:
|
||||||
|
digest = hashlib.sha256()
|
||||||
|
with open(filepath, "rb") as handle:
|
||||||
|
for chunk in iter(lambda: handle.read(65536), b""):
|
||||||
|
digest.update(chunk)
|
||||||
|
return digest.hexdigest()
|
||||||
|
|
||||||
|
|
||||||
|
def applied_seed_checksum(conn, seed_name: str) -> Optional[str]:
|
||||||
|
with conn.cursor() as cur:
|
||||||
|
cur.execute("SELECT checksum FROM data_seeds WHERE seed_name = %s", (seed_name,))
|
||||||
|
row = cur.fetchone()
|
||||||
|
return row[0] if row else None
|
||||||
|
|
||||||
|
|
||||||
|
def record_seed(conn, seed_name: str, checksum: str) -> None:
|
||||||
|
with conn.cursor() as cur:
|
||||||
|
cur.execute(
|
||||||
|
"""
|
||||||
|
INSERT INTO data_seeds (seed_name, checksum, executed_at, last_run_at)
|
||||||
|
VALUES (%s, %s, NOW(), NOW())
|
||||||
|
ON CONFLICT (seed_name) DO UPDATE SET
|
||||||
|
checksum = EXCLUDED.checksum,
|
||||||
|
last_run_at = NOW()
|
||||||
|
""",
|
||||||
|
(seed_name, checksum),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
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_sql_seed(conn, filepath: str) -> None:
|
||||||
|
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)
|
||||||
|
|
||||||
|
|
||||||
|
def run_python_seed(filepath: str) -> None:
|
||||||
|
module_name = f"kairo_seed_{hashlib.md5(filepath.encode()).hexdigest()[:12]}"
|
||||||
|
spec = importlib.util.spec_from_file_location(module_name, filepath)
|
||||||
|
if spec is None or spec.loader is None:
|
||||||
|
raise RuntimeError(f"Seed-Modul nicht ladbar: {filepath}")
|
||||||
|
module = importlib.util.module_from_spec(spec)
|
||||||
|
spec.loader.exec_module(module)
|
||||||
|
run_fn: Callable[[], None] | None = getattr(module, "run", None)
|
||||||
|
if run_fn is None:
|
||||||
|
raise RuntimeError(f"Seed {filepath} hat keine run()-Funktion")
|
||||||
|
run_fn()
|
||||||
|
|
||||||
|
|
||||||
|
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 run_seed(conn, seed_name: str, filepath: str, kind: str) -> tuple[bool, object]:
|
||||||
|
print(f"Running seed: {seed_name}")
|
||||||
|
try:
|
||||||
|
if kind == "sql":
|
||||||
|
run_sql_seed(conn, filepath)
|
||||||
|
else:
|
||||||
|
conn.commit()
|
||||||
|
conn.close()
|
||||||
|
run_python_seed(filepath)
|
||||||
|
conn = connect_with_retry(max_retries=5)
|
||||||
|
|
||||||
|
checksum = file_checksum(filepath)
|
||||||
|
record_seed(conn, seed_name, checksum)
|
||||||
|
conn.commit()
|
||||||
|
print(f" [OK] {seed_name}")
|
||||||
|
return True, conn
|
||||||
|
except Exception as exc:
|
||||||
|
try:
|
||||||
|
conn.rollback()
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
print(f" [FAIL] {seed_name}: {exc}")
|
||||||
|
return False, conn
|
||||||
|
|
||||||
|
|
||||||
|
def pending_seeds(
|
||||||
|
conn,
|
||||||
|
seeds_dir: str,
|
||||||
|
*,
|
||||||
|
only: Optional[List[str]] = None,
|
||||||
|
force: bool = False,
|
||||||
|
) -> List[Tuple[str, str, str]]:
|
||||||
|
selected: List[Tuple[str, str, str]] = []
|
||||||
|
for seed_name, filepath, kind in seed_files(seeds_dir):
|
||||||
|
filename = os.path.basename(filepath)
|
||||||
|
if not seed_applies_in_environment(filename):
|
||||||
|
print(f" [SKIP] {seed_name} (nur Nicht-Prod)")
|
||||||
|
continue
|
||||||
|
if only and seed_name not in only:
|
||||||
|
continue
|
||||||
|
checksum = file_checksum(filepath)
|
||||||
|
if force or applied_seed_checksum(conn, seed_name) != checksum:
|
||||||
|
selected.append((seed_name, filepath, kind))
|
||||||
|
return selected
|
||||||
|
|
||||||
|
|
||||||
|
def main(argv: Optional[List[str]] = None) -> int:
|
||||||
|
argv = argv if argv is not None else sys.argv[1:]
|
||||||
|
only: Optional[List[str]] = None
|
||||||
|
force = False
|
||||||
|
|
||||||
|
idx = 0
|
||||||
|
while idx < len(argv):
|
||||||
|
arg = argv[idx]
|
||||||
|
if arg == "--only" and idx + 1 < len(argv):
|
||||||
|
only = [part.strip() for part in argv[idx + 1].split(",") if part.strip()]
|
||||||
|
idx += 2
|
||||||
|
continue
|
||||||
|
if arg == "--force":
|
||||||
|
force = True
|
||||||
|
idx += 1
|
||||||
|
continue
|
||||||
|
print(f"[FAIL] Unbekanntes Argument: {arg}")
|
||||||
|
return 1
|
||||||
|
|
||||||
|
print("=" * 60)
|
||||||
|
print("Jinkendo Kairo — Data Seeds")
|
||||||
|
print("=" * 60)
|
||||||
|
|
||||||
|
seeds_dir = seeds_directory()
|
||||||
|
if not os.path.isdir(seeds_dir):
|
||||||
|
print(f"[OK] Kein seeds-Verzeichnis ({seeds_dir}) — nichts zu tun.")
|
||||||
|
return 0
|
||||||
|
|
||||||
|
try:
|
||||||
|
conn = connect_with_retry()
|
||||||
|
init_data_seeds_table(conn)
|
||||||
|
to_run = pending_seeds(conn, seeds_dir, only=only, force=force)
|
||||||
|
|
||||||
|
if not to_run:
|
||||||
|
print("[OK] Alle Seeds aktuell — nichts auszuführen.")
|
||||||
|
conn.close()
|
||||||
|
return 0
|
||||||
|
|
||||||
|
print(f"{len(to_run)} Seed(s) ausstehend:")
|
||||||
|
for seed_name, _, _ in to_run:
|
||||||
|
print(f" - {seed_name}")
|
||||||
|
|
||||||
|
for seed_name, filepath, kind in to_run:
|
||||||
|
ok, conn = run_seed(conn, seed_name, filepath, kind)
|
||||||
|
if not ok:
|
||||||
|
conn.close()
|
||||||
|
return 1
|
||||||
|
|
||||||
|
conn.close()
|
||||||
|
print("[OK] Seeds abgeschlossen.")
|
||||||
|
return 0
|
||||||
|
except Exception as exc:
|
||||||
|
print(f"[FAIL] {exc}")
|
||||||
|
return 1
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
sys.exit(main())
|
||||||
32
backend/seeds/seed_001_cleanup_pytest_artifacts.dev.sql
Normal file
32
backend/seeds/seed_001_cleanup_pytest_artifacts.dev.sql
Normal file
|
|
@ -0,0 +1,32 @@
|
||||||
|
-- Dev/CI: pytest-Artefakte entfernen (idempotent, sicher mehrfach ausführbar).
|
||||||
|
-- Nur in Nicht-Prod-Umgebungen (Dateiname *.dev.sql).
|
||||||
|
|
||||||
|
-- Abhängigkeiten vor User-Löschung (human actors: user_id NOT NULL constraint)
|
||||||
|
DELETE FROM actors
|
||||||
|
WHERE user_id IN (SELECT id FROM users WHERE email ~* '@example\.com$');
|
||||||
|
|
||||||
|
DELETE FROM tenant_memberships
|
||||||
|
WHERE user_id IN (SELECT id FROM users WHERE email ~* '@example\.com$');
|
||||||
|
|
||||||
|
DELETE FROM sessions
|
||||||
|
WHERE user_id IN (SELECT id FROM users WHERE email ~* '@example\.com$');
|
||||||
|
|
||||||
|
DELETE FROM audit_log
|
||||||
|
WHERE user_id IN (SELECT id FROM users WHERE email ~* '@example\.com$');
|
||||||
|
|
||||||
|
DELETE FROM users WHERE email ~* '@example\.com$';
|
||||||
|
|
||||||
|
-- Verwaiste pytest-Tenants (Factory-Slug t-{10 hex}), ohne Memberships
|
||||||
|
DELETE FROM actors a
|
||||||
|
USING tenants t
|
||||||
|
WHERE a.tenant_id = t.id
|
||||||
|
AND t.slug ~ '^t-[a-f0-9]{10}$'
|
||||||
|
AND NOT EXISTS (
|
||||||
|
SELECT 1 FROM tenant_memberships tm WHERE tm.tenant_id = t.id
|
||||||
|
);
|
||||||
|
|
||||||
|
DELETE FROM tenants t
|
||||||
|
WHERE t.slug ~ '^t-[a-f0-9]{10}$'
|
||||||
|
AND NOT EXISTS (
|
||||||
|
SELECT 1 FROM tenant_memberships tm WHERE tm.tenant_id = t.id
|
||||||
|
);
|
||||||
9
backend/seeds/seed_002_bootstrap_admin.py
Normal file
9
backend/seeds/seed_002_bootstrap_admin.py
Normal file
|
|
@ -0,0 +1,9 @@
|
||||||
|
"""Bootstrap portal admin from KAIRO_BOOTSTRAP_* when no users exist."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
|
||||||
|
def run() -> None:
|
||||||
|
from bootstrap import bootstrap_admin_if_needed
|
||||||
|
|
||||||
|
bootstrap_admin_if_needed()
|
||||||
|
|
@ -7,6 +7,7 @@ import os
|
||||||
import pytest
|
import pytest
|
||||||
|
|
||||||
os.environ.setdefault("SKIP_DB_MIGRATE", "1")
|
os.environ.setdefault("SKIP_DB_MIGRATE", "1")
|
||||||
|
os.environ.setdefault("SKIP_SEEDS", "1")
|
||||||
os.environ.setdefault("SKIP_BOOTSTRAP", "1")
|
os.environ.setdefault("SKIP_BOOTSTRAP", "1")
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -37,6 +38,16 @@ def _run_migrations():
|
||||||
assert run_migrations.main() == 0
|
assert run_migrations.main() == 0
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture(scope="session", autouse=True)
|
||||||
|
def _seed_hygiene():
|
||||||
|
"""Vor/nach Tests pytest-Artefakte aus der geteilten Dev-DB entfernen."""
|
||||||
|
import run_seeds
|
||||||
|
|
||||||
|
run_seeds.main(["--only", "seed_001_cleanup_pytest_artifacts"])
|
||||||
|
yield
|
||||||
|
run_seeds.main(["--only", "seed_001_cleanup_pytest_artifacts"])
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture()
|
@pytest.fixture()
|
||||||
def client():
|
def client():
|
||||||
import importlib
|
import importlib
|
||||||
|
|
|
||||||
|
|
@ -33,6 +33,7 @@ def test_migration_runner_finds_migrations():
|
||||||
names = [name for name, _ in files]
|
names = [name for name, _ in files]
|
||||||
assert "001_init_core" in names
|
assert "001_init_core" in names
|
||||||
assert "002_auth_identity_tenant_actor" in names
|
assert "002_auth_identity_tenant_actor" in names
|
||||||
|
assert "003_data_seeds_tracking" in names
|
||||||
|
|
||||||
|
|
||||||
def test_migration_runner_is_idempotent():
|
def test_migration_runner_is_idempotent():
|
||||||
|
|
@ -44,6 +45,7 @@ def test_migration_runner_is_idempotent():
|
||||||
conn.close()
|
conn.close()
|
||||||
assert "001_init_core" in executed
|
assert "001_init_core" in executed
|
||||||
assert "002_auth_identity_tenant_actor" in executed
|
assert "002_auth_identity_tenant_actor" in executed
|
||||||
|
assert "003_data_seeds_tracking" in executed
|
||||||
|
|
||||||
|
|
||||||
def test_core_table_exists():
|
def test_core_table_exists():
|
||||||
|
|
|
||||||
37
backend/tests/test_seeds.py
Normal file
37
backend/tests/test_seeds.py
Normal file
|
|
@ -0,0 +1,37 @@
|
||||||
|
"""Data seed runner tests (require PostgreSQL)."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import uuid
|
||||||
|
|
||||||
|
import run_seeds
|
||||||
|
from tests.factories import create_user
|
||||||
|
|
||||||
|
|
||||||
|
def test_seed_runner_finds_seeds():
|
||||||
|
seeds_dir = run_seeds.seeds_directory()
|
||||||
|
names = [name for name, _, _ in run_seeds.seed_files(seeds_dir)]
|
||||||
|
assert "seed_001_cleanup_pytest_artifacts" in names
|
||||||
|
assert "seed_002_bootstrap_admin" in names
|
||||||
|
|
||||||
|
|
||||||
|
def test_cleanup_seed_removes_example_com_users():
|
||||||
|
suffix = uuid.uuid4().hex[:8]
|
||||||
|
user = create_user(email=f"seed-test-{suffix}@example.com")
|
||||||
|
|
||||||
|
assert run_seeds.main(["--only", "seed_001_cleanup_pytest_artifacts", "--force"]) == 0
|
||||||
|
|
||||||
|
from db import get_connection
|
||||||
|
|
||||||
|
conn = get_connection()
|
||||||
|
try:
|
||||||
|
with conn.cursor() as cur:
|
||||||
|
cur.execute("SELECT COUNT(*) FROM users WHERE id = %s", (user["id"],))
|
||||||
|
assert cur.fetchone()[0] == 0
|
||||||
|
finally:
|
||||||
|
conn.close()
|
||||||
|
|
||||||
|
|
||||||
|
def test_cleanup_seed_is_idempotent():
|
||||||
|
assert run_seeds.main(["--only", "seed_001_cleanup_pytest_artifacts"]) == 0
|
||||||
|
assert run_seeds.main(["--only", "seed_001_cleanup_pytest_artifacts"]) == 0
|
||||||
|
|
@ -1,3 +1,3 @@
|
||||||
APP_VERSION = "0.2.0-ap0.2"
|
APP_VERSION = "0.2.0-ap0.2"
|
||||||
DB_SCHEMA_VERSION = "002"
|
DB_SCHEMA_VERSION = "003"
|
||||||
APP_NAME = "jinkendo-kairo"
|
APP_NAME = "jinkendo-kairo"
|
||||||
|
|
|
||||||
51
docs/MIGRATIONS.md
Normal file
51
docs/MIGRATIONS.md
Normal file
|
|
@ -0,0 +1,51 @@
|
||||||
|
# Migrationen & Data-Seeds
|
||||||
|
|
||||||
|
Kairo trennt **Schema-Migrationen** (einmalig) von **Data-Seeds** (idempotent, bei Änderung erneut ausführbar).
|
||||||
|
|
||||||
|
## Schema-Migrationen
|
||||||
|
|
||||||
|
| | |
|
||||||
|
|---|---|
|
||||||
|
| **Pfad** | `backend/migrations/NNN_beschreibung.sql` |
|
||||||
|
| **Tracking** | Tabelle `schema_migrations` |
|
||||||
|
| **Ausführung** | Beim Backend-Start (`run_migrations.py`), überspringbar via `SKIP_DB_MIGRATE=1` |
|
||||||
|
| **Regel** | Jede Datei wird **genau einmal** angewendet. Änderungen → neue nummerierte Datei. |
|
||||||
|
|
||||||
|
## Data-Seeds
|
||||||
|
|
||||||
|
| | |
|
||||||
|
|---|---|
|
||||||
|
| **Pfad** | `backend/seeds/seed_NNN_beschreibung.sql` oder `.py` |
|
||||||
|
| **Tracking** | Tabelle `data_seeds` (Name + SHA256-Checksum) |
|
||||||
|
| **Ausführung** | Nach Schema-Migrationen (`run_seeds.py`), überspringbar via `SKIP_SEEDS=1` |
|
||||||
|
| **Regel** | Seed läuft erneut, wenn die Datei **geändert** wurde (Checksum abweicht) oder `--force` gesetzt ist. |
|
||||||
|
|
||||||
|
### Umgebungsfilter
|
||||||
|
|
||||||
|
Dateien mit **`.dev.`** im Namen (z. B. `seed_001_….dev.sql`) laufen **nicht in Production** (`ENVIRONMENT=production`).
|
||||||
|
|
||||||
|
### Seeds manuell ausführen
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker compose -f docker-compose.dev-env.yml exec backend python run_seeds.py
|
||||||
|
docker compose -f docker-compose.dev-env.yml exec backend python run_seeds.py --only seed_001_cleanup_pytest_artifacts --force
|
||||||
|
```
|
||||||
|
|
||||||
|
## Aktuelle Seeds
|
||||||
|
|
||||||
|
| Seed | Typ | Zweck |
|
||||||
|
|------|-----|--------|
|
||||||
|
| `seed_001_cleanup_pytest_artifacts` | SQL (dev) | Entfernt pytest/CI-User (`*@example.com`) und verwaiste Test-Tenants |
|
||||||
|
| `seed_002_bootstrap_admin` | Python | Legt Systemadmin aus `KAIRO_BOOTSTRAP_*` an, wenn noch kein User existiert |
|
||||||
|
|
||||||
|
## Neuen Seed anlegen
|
||||||
|
|
||||||
|
1. Datei `backend/seeds/seed_NNN_kurzname.sql` oder `.py` anlegen (Python: `def run() -> None:`).
|
||||||
|
2. SQL idempotent halten (`DELETE … WHERE …`, `INSERT … ON CONFLICT`, etc.).
|
||||||
|
3. Nur Dev/CI: `.dev.sql` / `.dev.py` Suffix verwenden.
|
||||||
|
4. Nach Deploy prüfen: `python run_seeds.py` — bei geänderter Datei wird der Seed automatisch erneut ausgeführt.
|
||||||
|
|
||||||
|
## Tests & CI
|
||||||
|
|
||||||
|
- pytest setzt `SKIP_SEEDS=1` beim App-Import; Cleanup läuft über Fixture + nach CI-pytest.
|
||||||
|
- Geteilte Dev-DB wird nach jedem Test-Lauf bereinigt (`run_seeds.py --only seed_001_cleanup_pytest_artifacts --force`).
|
||||||
Loading…
Reference in New Issue
Block a user