"""Kommandozeile: aiw migrate | seed-demo | demo | web | worker.""" import argparse import signal import sys import threading from pathlib import Path from alembic import command from alembic.config import Config from sqlalchemy import select from app.core import logs from app.core.config import Settings, get_settings from app.core.database import Database ROOT = Path(__file__).parent def alembic_config(settings: Settings) -> Config: config = Config() config.set_main_option("script_location", str(ROOT / "migrations")) config.set_main_option("sqlalchemy.url", settings.database_url.replace("%", "%%")) return config def migrate(settings: Settings) -> None: command.upgrade(alembic_config(settings), "head") def seed_demo(settings: Settings) -> None: """Demo-Daten: ein Team mit vier Nutzern, benannt nach ihrer Rolle. Mehrfach ausführbar.""" from app.identity.models import Team, TeamMembership, User db = Database(settings.database_url) people = [("admin", "Admin", True), ("entwicklung", "Entwicklung", False), ("fachbereich", "Fachbereich", False), ("freigabe", "Freigabe", False)] with db.session() as session: team = session.scalar(select(Team).where(Team.slug == "demo")) if team is None: team = Team(slug="demo", name="Demo-Team") session.add(team) session.flush() for username, name, admin in people: user = session.scalar(select(User).where(User.username == username)) if user is None: user = User(username=username, display_name=name, is_admin=admin) session.add(user) session.flush() if session.get(TeamMembership, (team.id, user.id)) is None: session.add(TeamMembership(team_id=team.id, user_id=user.id)) # Zweites Team, um die Trennung auszuprobieren other = session.scalar(select(Team).where(Team.slug == "andere")) if other is None: session.add(Team(slug="andere", name="Anderes Team")) db.dispose() print("Demo-Team mit admin (Plattform-Admin), entwicklung, fachbereich und freigabe angelegt. " "Passwort: AIW_DEMO_PASSWORD") def create_admin_user(settings: Settings, username: str) -> None: """Erster Zugang zu einer leeren Installation; setzt bei bestehendem Nutzer ein neues Einmal-Passwort.""" from app import audit from app.identity import passwords from app.identity.models import User from app.identity.service import revoke_sessions db = Database(settings.database_url) secret = passwords.one_time() with db.session() as session: user = session.scalar(select(User).where(User.username == username.strip().lower())) if user is None: user = User(username=username.strip().lower(), display_name=username.strip()) session.add(user) user.is_admin, user.active = True, True user.password_hash, user.must_change_password = passwords.hash_password(secret), True user.failed_logins, user.locked_until = 0, None session.flush() revoke_sessions(session, user.id) audit.record(session, "user.admin_bootstrap", target_kind="user", target_id=user.id) db.dispose() print(f"Plattform-Admin {user.username}, Einmal-Passwort: {secret}") def run_worker(settings: Settings) -> None: import app.approvals.runner import app.chat.runner # noqa: F401 – registriert den Handler für Chat-Runs from app.tasks.worker import Worker db = Database(settings.database_url) stop = threading.Event() for sig in (signal.SIGINT, signal.SIGTERM): signal.signal(sig, lambda *_: stop.set()) Worker(db, settings).run_forever(stop) db.dispose() def run_backup(settings: Settings, args: argparse.Namespace) -> int: from app import backup db = Database(settings.database_url) try: if args.command == "backup": result = backup.backup(db, settings, args.target) print(f"{result.archive}: {result.files} Dateien, {result.bytes / 1048576:.1f} MB") if result.missing: print(f"Achtung: {len(result.missing)} Uploads ohne Datei (IDs {result.missing[:20]})") return 1 return 0 if args.command == "restore": result = backup.restore(db, settings, args.archive, force=args.force) print(f"{result.restored} zurückgespielt, {result.unchanged} unverändert") if result.unknown: print(f"{len(result.unknown)} übersprungen (Upload in der Datenbank nicht mehr vorhanden)") if result.conflicts: print(f"{len(result.conflicts)} mit anderem Inhalt vorhanden, nicht überschrieben (--force): " + ", ".join(result.conflicts[:10])) return 1 return 0 result = backup.check(db, settings) for name, state in result.checks.items(): print(f"{'OK ' if state == 'ok' else 'FEHLER'} {name}: {state}") for name, items in result.details.items(): if items and name not in ("secrets_total",): print(f" {name}: {items[:20]}{' …' if len(items) > 20 else ''}") return 0 if result.ok else 1 finally: db.dispose() def main() -> None: parser = argparse.ArgumentParser(prog="aiw") sub = parser.add_subparsers(dest="command", required=True) sub.add_parser("migrate", help="Datenbank auf den neuesten Stand bringen") sub.add_parser("seed-demo", help="Demo-Team und Demo-Nutzer anlegen") demo = sub.add_parser("demo", help="Demo-Workspaces mit Repository, Daten, Dokumenten und Beispielfragen") demo.add_argument("names", nargs="*", choices=["bestellservice", "mcp", "chat"], metavar="name", help="bestellservice, mcp, chat (ohne Angabe: alle)") demo.add_argument("--reset", action="store_true", help="vorhandene Demo-Workspaces löschen und neu anlegen") web = sub.add_parser("web", help="Web-Prozess starten") web.add_argument("--host", default="127.0.0.1") web.add_argument("--port", type=int, default=8000) web.add_argument("--reload", action="store_true") sub.add_parser("worker", help="Worker-Prozess starten") sub.add_parser("gen-key", help="Neuen Master-Key für AIW_SECRET_KEY erzeugen") backup = sub.add_parser("backup", help="Uploads mit Prüfsummen in ein Archiv sichern") backup.add_argument("target", type=Path, help="Zielverzeichnis für uploads-.tar.gz") restore = sub.add_parser("restore", help="Uploads aus einem Archiv zurückspielen") restore.add_argument("archive", type=Path) restore.add_argument("--force", action="store_true", help="abweichende vorhandene Dateien überschreiben") sub.add_parser("check", help="Datenbank, Migrationen, Uploads und Master-Key prüfen (Exit-Code 1 bei Problemen)") create_admin = sub.add_parser("create-admin", help="Plattform-Admin anlegen oder zurücksetzen (Einmal-Passwort)") create_admin.add_argument("username") args = parser.parse_args() settings = get_settings() logs.setup(settings.log_level, settings.log_format) if args.command == "migrate": migrate(settings) elif args.command == "seed-demo": seed_demo(settings) elif args.command == "demo": from app import demo as demo_workspace sys.exit(demo_workspace.run(settings, args.names, args.reset)) elif args.command == "web": import uvicorn # Logging wie oben eingerichtet; die Zugriffszeile schreibt die App selbst (mit Request-ID) uvicorn.run("app.main:create_app", factory=True, host=args.host, port=args.port, reload=args.reload, log_config=None, access_log=False) elif args.command == "worker": run_worker(settings) elif args.command == "create-admin": create_admin_user(settings, args.username) elif args.command in ("backup", "restore", "check"): sys.exit(run_backup(settings, args)) elif args.command == "gen-key": from app.core.crypto import generate_key print(f"AIW_SECRET_KEY={generate_key()}") if __name__ == "__main__": main()