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