195 lines
8.9 KiB
Python
195 lines
8.9 KiB
Python
"""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-<zeit>.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
|
||
# Start und Abbruch immer auf stderr, unabhängig von AIW_LOG_LEVEL: sonst endet ein Fehlstart still
|
||
print(f"aiw web: starte auf http://{args.host}:{args.port} (Log-Level {settings.log_level})", file=sys.stderr)
|
||
try:
|
||
# 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)
|
||
except SystemExit as exit:
|
||
if exit.code:
|
||
print(f"aiw web: Start fehlgeschlagen (Exit-Code {exit.code}), z. B. Port {args.port} belegt oder "
|
||
"Fehler beim Laden der App. Details mit AIW_LOG_LEVEL=DEBUG.", file=sys.stderr)
|
||
raise
|
||
elif args.command == "worker":
|
||
port = settings.worker_http_port or "aus"
|
||
print(f"aiw worker: starte (Health-Port {port}, Log-Level {settings.log_level})", file=sys.stderr)
|
||
try:
|
||
run_worker(settings)
|
||
except Exception as error:
|
||
print(f"aiw worker: abgebrochen: {type(error).__name__}: {error}", file=sys.stderr)
|
||
raise
|
||
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()
|