bifroest/app/cli.py
2026-10-27 08:34:17 +01:00

179 lines
7.9 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"""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 drei Nutzern. Mehrfach ausführbar."""
from app.identity.models import Team, TeamMembership, User
db = Database(settings.database_url)
people = [("anna", "Anna Admin", True), ("ben", "Ben Entwickler", False), ("clara", "Clara Fachbereich", 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 anna (Admin), ben und clara 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"], metavar="name",
help="bestellservice, mcp (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
# 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()