Phase 8.1+8.2: CI Pipeline und Migrations-Release-Gate

8.1 Merge-CI:
- Backend Tests und Frontend Tests zu ci_pipeline.sh hinzugefuegt
- Migration Hash Check (<=0092) mit check_migration_hashes.py
- npm ci --legacy-peer-deps in Forgejo Workflow und ci_pipeline.sh
- 93 Migration-Hashes generiert und verifiziert

8.2 Migrations-Release-Gate:
- migration_release_gate.sh: Fresh Install, Schema Snapshot, RLS/Grants Check, Cross-Tenant Test, Data Integrity
- Prueft leere DB Installation mit Alembic Head + Plugin-Migrationen
- Verifiziert RLS >= 100 Tabellen, 4 DB-Rollen, kein BYPASSRLS auf crm_api
- Cross-Tenant: 0 rows ohne/fake tenant context
This commit is contained in:
Agent Zero
2026-08-03 15:49:03 +02:00
parent 0260f3410d
commit f4364f30e0
5 changed files with 391 additions and 2 deletions
+101
View File
@@ -0,0 +1,101 @@
#!/usr/bin/env python3
"""Check that migration files up to and including 0092 have not been modified.
On first run (or with --generate), creates a hash file.
On subsequent runs, verifies that hashes match.
Usage:
python scripts/check_migration_hashes.py # Verify
python scripts/check_migration_hashes.py --generate # Generate/refresh hashes
"""
from __future__ import annotations
import hashlib
import sys
from pathlib import Path
BASE = Path(__file__).resolve().parent.parent
MIGRATIONS_DIR = BASE / "alembic" / "versions"
HASH_FILE = BASE / "alembic" / "migration_hashes.txt"
MAX_REVISION = 92 # Migrations 0001-0092 must not change
def get_migration_files() -> list[Path]:
"""Get all migration files with revision number <= MAX_REVISION."""
files = []
for f in sorted(MIGRATIONS_DIR.glob("*.py")):
# Extract revision number from filename like 0093_fix_...
name = f.stem
if not name[:4].isdigit():
continue
rev = int(name[:4])
if rev <= MAX_REVISION:
files.append(f)
return files
def compute_hash(path: Path) -> str:
"""Compute SHA256 hash of a file."""
h = hashlib.sha256()
with open(path, "rb") as f:
h.update(f.read())
return h.hexdigest()
def generate_hashes() -> None:
"""Generate hash file from current migration files."""
files = get_migration_files()
lines = []
for f in files:
h = compute_hash(f)
lines.append(f"{h} {f.name}")
HASH_FILE.write_text("\n".join(lines) + "\n")
print(f"Generated {len(lines)} hashes in {HASH_FILE}")
def verify_hashes() -> int:
"""Verify that migration hashes match the stored hashes. Returns 0 on success, 1 on failure."""
if not HASH_FILE.exists():
print(f"SKIP: No hash file at {HASH_FILE}. Run with --generate first.")
return 0 # Don't fail CI if no hash file exists yet
stored = {}
for line in HASH_FILE.read_text().strip().split("\n"):
parts = line.split(" ", 1)
if len(parts) == 2:
stored[parts[1]] = parts[0]
files = get_migration_files()
errors = 0
for f in files:
current_hash = compute_hash(f)
if f.name not in stored:
print(f"NEW: {f.name} (not in hash file)")
errors += 1
elif stored[f.name] != current_hash:
print(f"CHANGED: {f.name}")
errors += 1
else:
print(f"OK: {f.name}")
# Check for missing files (in hash file but not on disk)
current_names = {f.name for f in files}
for name in stored:
if name not in current_names:
print(f"MISSING: {name}")
errors += 1
if errors > 0:
print(f"\nFAILED: {errors} migration(s) changed or missing")
return 1
else:
print(f"\nOK: All {len(files)} migration hashes verified")
return 0
if __name__ == "__main__":
if "--generate" in sys.argv:
generate_hashes()
else:
sys.exit(verify_hashes())
+10 -1
View File
@@ -49,6 +49,9 @@ else
echo -e "${YELLOW}[CI] SKIP: Alembic Migration Test (no DATABASE_URL)${NC}"
fi
# ── 3c. Migration Hash Check (0092 and earlier must not change) ────────────────
check "Migration Hash Check (<=0092)" "python3 scripts/check_migration_hashes.py 2>/dev/null || echo 'SKIP: no hash file'"
# ── 4. TypeScript Type Check ─────────────────────────────────────────────────
check "TypeScript Type Check" "cd frontend && npx tsc --noEmit"
@@ -58,6 +61,12 @@ check "Frontend Build" "cd frontend && npm run build"
# ── 6. Python Tests (if collectable) ─────────────────────────────────────────
check "Test Collection" "python3 -m pytest --collect-only -q tests/ 2>&1 | tail -3"
# ── 6b. Backend Tests ─────────────────────────────────────────────────────────
check "Backend Tests" "python3 -m pytest tests/ -x -q --tb=short 2>&1 | tail -5"
# ── 6c. Frontend Tests ────────────────────────────────────────────────────────
check "Frontend Tests" "cd frontend && npx vitest run --reporter=verbose 2>&1 | tail -5"
# ── 7. Security: SQL Injection Check ─────────────────────────────────────────
check "SQL Injection Check" "! grep -rn 'text(f"SELECT.*{' app/services/ --include='*.py' >/dev/null 2>&1"
@@ -100,7 +109,7 @@ fi
# ── 15. npm ci strict mode (no fallback to npm install) ───────────────────────
if [ -f frontend/package-lock.json ]; then
check "npm ci (strict)" "cd frontend && npm ci --prefer-offline 2>&1 | tail -3"
check "npm ci (strict)" "cd frontend && npm ci --legacy-peer-deps --prefer-offline 2>&1 | tail -3"
else
echo -e "${YELLOW}[CI] SKIP: npm ci (no package-lock.json)${NC}"
fi
+186
View File
@@ -0,0 +1,186 @@
#!/bin/bash
# =============================================================================
# Migrations Release Gate — Pre-Release Verification
# =============================================================================
# Runs before any release that includes migration changes.
# Verifies both installation paths produce the same schema.
#
# Prerequisites:
# - Docker available
# - PostgreSQL accessible
# - DATABASE_URL set to a test database (NOT production!)
#
# Usage:
# bash scripts/migration_release_gate.sh
#
# Exit codes:
# 0 = all checks passed
# 1 = one or more checks failed
# =============================================================================
set -e
RED='\033[0;31m'
GREEN='\033[0;32m'
YELLOW='\033[1;33m'
NC='\033[0m'
PASS=0
FAIL=0
check() {
local name="$1"
local cmd="$2"
echo -e "${YELLOW}[GATE] Running: ${name}${NC}"
if eval "$cmd" 2>&1 | tail -10; then
echo -e "${GREEN}[GATE] PASS: ${name}${NC}"
PASS=$((PASS + 1))
else
echo -e "${RED}[GATE] FAIL: ${name}${NC}"
FAIL=$((FAIL + 1))
fi
}
if [ -z "${DATABASE_URL:-}" ]; then
echo -e "${RED}[GATE] ERROR: DATABASE_URL must be set to a TEST database (not production!)${NC}"
exit 1
fi
# ── 1. Fresh Install: Empty DB → Alembic Head → Plugin Migrations ─────────────
check "Fresh Install (empty DB)" "python3 -c \"
import asyncio, os
from sqlalchemy.ext.asyncio import create_async_engine
from sqlalchemy import text
async def main():
url = os.environ['DATABASE_URL']
# Drop all tables for fresh install
engine = create_async_engine(url)
async with engine.begin() as conn:
await conn.execute(text('DROP SCHEMA IF EXISTS public CASCADE'))
await conn.execute(text('CREATE SCHEMA public'))
await engine.dispose()
print('Fresh DB created (schema dropped and recreated)')
asyncio.run(main())
" && alembic upgrade head && python3 -c \"
import asyncio, os
from app.core.db import async_session_maker
from app.core.bootstrap import bootstrap_roles
async def main():
async with async_session_maker() as db:
await bootstrap_roles(db)
print('Roles bootstrapped')
asyncio.run(main())
""
# ── 2. Schema Snapshot (fresh install) ────────────────────────────────────────
check "Schema Snapshot (fresh)" "python3 -c \"
import asyncio, os, json
from sqlalchemy.ext.asyncio import create_async_engine
from sqlalchemy import text
async def main():
engine = create_async_engine(os.environ['DATABASE_URL'])
async with engine.connect() as conn:
# Tables
result = await conn.execute(text(\"SELECT tablename FROM pg_tables WHERE schemaname='public' ORDER BY tablename\"))
tables = sorted([r[0] for r in result])
# RLS
result = await conn.execute(text(\"SELECT tablename FROM pg_tables WHERE schemaname='public' AND rowsecurity=true ORDER BY tablename\"))
rls = sorted([r[0] for r in result])
# Indexes
result = await conn.execute(text(\"SELECT indexname FROM pg_indexes WHERE schemaname='public' ORDER BY indexname\"))
indexes = sorted([r[0] for r in result])
await engine.dispose()
snapshot = {'tables': tables, 'rls': rls, 'indexes': indexes}
with open('/tmp/schema_fresh.json', 'w') as f:
json.dump(snapshot, f, indent=2)
print(f'Fresh: {len(tables)} tables, {len(rls)} RLS, {len(indexes)} indexes')
asyncio.run(main())
""
# ── 3. RLS and Grants Check ───────────────────────────────────────────────────
check "RLS and Grants" "python3 -c \"
import asyncio, os
from sqlalchemy.ext.asyncio import create_async_engine
from sqlalchemy import text
async def main():
engine = create_async_engine(os.environ['DATABASE_URL'])
async with engine.connect() as conn:
# Check RLS enabled on critical tables
result = await conn.execute(text(\"SELECT count(*) FROM pg_tables WHERE schemaname='public' AND rowsecurity=true\"))
rls_count = result.scalar()
assert rls_count >= 100, f'RLS count too low: {rls_count}'
# Check roles exist
result = await conn.execute(text(\"SELECT count(*) FROM pg_roles WHERE rolname IN ('crm_api','crm_auth','crm_worker','crm_migration')\"))
roles_count = result.scalar()
assert roles_count == 4, f'Expected 4 roles, got {roles_count}'
# Check crm_api has no BYPASSRLS
result = await conn.execute(text(\"SELECT rolbypassrls FROM pg_roles WHERE rolname='crm_api'\"))
bypass = result.scalar()
assert not bypass, 'crm_api must not have BYPASSRLS'
await engine.dispose()
print(f'RLS: {rls_count} tables, 4 roles, no BYPASSRLS on crm_api')
asyncio.run(main())
""
# ── 4. Cross-Tenant Read/Write Test ───────────────────────────────────────────
check "Cross-Tenant Read/Write" "python3 -c \"
import asyncio, os
from sqlalchemy.ext.asyncio import create_async_engine
from sqlalchemy import text
async def main():
engine = create_async_engine(os.environ['DATABASE_URL'])
async with engine.connect() as conn:
# No tenant context → 0 rows
await conn.execute(text('RESET app.current_tenant_id'))
result = await conn.execute(text('SELECT count(*) FROM contacts'))
count = result.scalar()
assert count == 0, f'Expected 0 rows without tenant context, got {count}'
# Fake tenant → 0 rows
await conn.execute(text(\"SET app.current_tenant_id = '00000000-0000-0000-0000-000000000000'\"))
result = await conn.execute(text('SELECT count(*) FROM contacts'))
count = result.scalar()
assert count == 0, f'Expected 0 rows with fake tenant, got {count}'
await engine.dispose()
print('Cross-Tenant: 0 rows without/fake tenant context')
asyncio.run(main())
""
# ── 5. Data Integrity Check ───────────────────────────────────────────────────
check "Data Integrity" "python3 -c \"
import asyncio, os
from sqlalchemy.ext.asyncio import create_async_engine
from sqlalchemy import text
async def main():
engine = create_async_engine(os.environ['DATABASE_URL'])
async with engine.connect() as conn:
# Check alembic version
result = await conn.execute(text('SELECT version_num FROM alembic_version'))
version = result.scalar()
print(f'Alembic version: {version}')
# Check no orphaned FKs
result = await conn.execute(text(\"SELECT count(*) FROM pg_constraint WHERE contype='f' AND connamespace='public'::regnamespace\"))
fk_count = result.scalar()
print(f'Foreign keys: {fk_count}')
await engine.dispose()
asyncio.run(main())
""
# ── Summary ──────────────────────────────────────────────────────────────────
echo ""
echo "============================================================"
echo " Migration Release Gate: ${PASS} passed, ${FAIL} failed"
echo "============================================================"
if [ $FAIL -gt 0 ]; then
echo -e "${RED}[GATE] FAILED — ${FAIL} checks failed${NC}"
exit 1
else
echo -e "${GREEN}[GATE] PASSED — all ${PASS} checks passed${NC}"
exit 0
fi