From e5254c23f090312ed79e609831e46aec2d4abe3c Mon Sep 17 00:00:00 2001 From: Hitonabi Date: Fri, 28 Aug 2026 10:34:51 +0200 Subject: [PATCH] =?UTF-8?q?feat(daemon):=20V2-4=20(Teil=203)=20=E2=80=94?= =?UTF-8?q?=20rippyd=20laeuft=20unter=20Windows,=20ohne=20Docker?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit WAS: `rippyd` startet Rippy als EINEN Prozess — SQLite statt Postgres, LocalQueue statt Celery/Redis, In-Process-Bus statt Redis Pub/Sub, UI von FastAPI statt von nginx. Dieselbe API, dasselbe UI, derselbe Rip-Code. GEMESSEN (Windows 11, frische Python-3.12-Umgebung, ohne Docker/Postgres/Redis): bereit nach 1 s /api/health 200 /api/settings 200 /api/devices 200 /api/logs 200 /api/jobs 200 /api/capabilities 200 /api/health/vorraete 200 / (Weboberflaeche) 200 Ereignis-Waechter gesund: true, alter_sekunden: 0.5 SSE-Strom liefert `event: snapshot` mit vollem Zustand SQLite angelegt, WAL aktiv, alle Tabellen da ZWEI FEHLER, DIE NUR DER ECHTE LAUF ZEIGT: 1. STARLETTE REICHT LIFESPAN NICHT AN MOUNT-UNTERANWENDUNGEN WEITER. Das UI ruft /api/..., im Docker-Betrieb entfernt der nginx das Praefix. Ohne nginx haengt die API als Unteranwendung unter /api — und ihr startup_event lief nie. Folge: db.init_db() nicht ausgefuehrt, "no such table: settings", HTTP 500 auf /jobs, /settings, /logs, /capabilities, und der SSE-Strom blieb stumm. /api/health antwortete trotzdem mit 200, weil die Route keine Datenbank braucht — der Fehlschlag sah also aus wie "laeuft". Behoben ueber einen eigenen lifespan, der den der Unteranwendung mit ausloest. 2. EIN print() HAT DEN GANZEN START UMGEBRACHT. UnicodeEncodeError: 'charmap' codec can't encode characters -> ERROR: Application startup failed. Exiting. Windows-Konsolen arbeiten mit cp1252; Rippys Meldungen sind deutsch und voller Umlaute und Warnzeichen. Das Bittere: Es war nicht die WARNUNG, die den Start verhindert hat, sondern der VERSUCH, sie auszugeben. Behoben mit encoding="utf-8" UND errors="replace" — eine Ausgabe darf unter keinen Umstaenden etwas abbrechen koennen. BEFUND ZUR HARDWARE (kein Codefehler): Nach dem Auswurf-Test hat sich das USB-Laufwerk vom Bus getrennt. Windows kennt es nur noch als Karteileiche (Get-PnpDevice -Class CDROM -> Status "Unknown", Win32_CDROMDrive leer), weder ein Laufwerksbuchstabe noch \.\CdRom0..4 sind da. Die leere Geraeteliste ist damit die RICHTIGE Antwort. Bei bus-versorgten USB-Laufwerken ist das bekannt — der Auswurf zieht kurz mehr Strom. GEMESSEN: ruff sauber, 440 Tests gruen + 15 uebersprungen. Co-Authored-By: Claude Opus 5 --- src/rippy/daemon.py | 285 +++++++++++++++++++++++++++++++++++++++ src/rippy/test_daemon.py | 99 ++++++++++++++ 2 files changed, 384 insertions(+) create mode 100644 src/rippy/daemon.py create mode 100644 src/rippy/test_daemon.py diff --git a/src/rippy/daemon.py b/src/rippy/daemon.py new file mode 100644 index 0000000..a7810f3 --- /dev/null +++ b/src/rippy/daemon.py @@ -0,0 +1,285 @@ +"""`rippyd` — Rippy als EIN Prozess, ohne Docker, ohne Postgres, ohne Redis. + +## Was hier zusammenkommt + +Alles, was die Etappen V2-1 bis V2-3 vorbereitet haben: + + Store -> SQLite (V2-2) statt PostgreSQL + Queue -> LocalQueue (V2-2) statt Celery/Redis + Bus -> In-Process (V2-3) statt Redis Pub/Sub + Drives -> der Treiber dieser Maschine (V2-1/V2-4) + UI -> von FastAPI ausgeliefert statt von nginx + +Dieselbe API, dasselbe UI, derselbe Rip-Code wie im Docker-Betrieb. Was sich +unterscheidet, ist allein die Verdrahtung — genau das war der Punkt. + +## Warum das UI unter `/` und die API unter `/api` haengt + +Das UI ruft seine Endpunkte als `/api/jobs`, `/api/events` usw. auf. Im +Docker-Betrieb entfernt der nginx dieses Praefix und reicht an die API weiter +(`ui/nginx.conf`). Ohne nginx muss jemand anderes das tun. + +Statt das UI umzubauen — 30+ Aufrufstellen, und die Docker-Variante muesste +weiter funktionieren — haengt hier die bestehende App als Unteranwendung unter +`/api`. Ein `mount()`, und beide Betriebsarten sehen fuer den Browser identisch +aus. + +## Was `--profil` bedeutet + + standalone alles in einem Prozess (Windows-App, Headless-Linux) + api nur die Weboberflaeche, Arbeit machen andere Knoten + node nur Arbeit, keine Weboberflaeche + +Fuer V2-4 zaehlt `standalone`; die anderen beiden kommen mit V2-5. +""" + +import argparse +import os +import sys + + +def ausgabe_auf_utf8_stellen() -> None: + """stdout/stderr auf UTF-8 zwingen — sonst stirbt Rippy an einer Log-Zeile. + + ## Der Vorfall (28.08.2026, erster Standalone-Start unter Windows) + + Der Start brach ab mit: + + File "docker/api/main.py", line 81, in startup_event + print(f"⚠️ Konfigurations-Warnung: {e}") + UnicodeEncodeError: 'charmap' codec can't encode characters + ERROR: Application startup failed. Exiting. + + Windows-Konsolen arbeiten standardmaessig mit cp1252. Rippys Meldungen + sind durchgehend deutsch und enthalten Umlaute, Anfuehrungszeichen und + Warnzeichen — unter Linux nie ein Thema, unter Windows toedlich. + + Das Bittere daran: Es war nicht die WARNUNG, die den Start verhindert hat, + sondern der VERSUCH, sie auszugeben. Eine Meldung ueber ein kleines Problem + hat das ganze Programm umgebracht. + + Deshalb zwei Dinge, nicht eins: + + * `encoding="utf-8"` — damit die Zeichen ueberhaupt durchgehen. + * `errors="replace"` — damit ein Zeichen, das trotzdem klemmt, ein + Fragezeichen wird und NICHT eine Ausnahme. Eine Ausgabe darf unter + keinen Umstaenden etwas abbrechen koennen. + """ + for strom in (sys.stdout, sys.stderr): + umstellen = getattr(strom, "reconfigure", None) + if umstellen is None: + continue # umgeleitet, kein TextIOWrapper + try: + umstellen(encoding="utf-8", errors="replace") + except (ValueError, OSError): + pass # dann eben nicht — aber nie werfen + + +ausgabe_auf_utf8_stellen() + + +def _api_pfad() -> str: + """Wo liegen die API-Module (main.py, db.py, mounts.py, …)? + + Drei Faelle, und alle drei kommen wirklich vor: + + * **Aus dem Repo gestartet** — `docker/api` neben `src/`. + * **Im PyInstaller-Paket** — alles unter `sys._MEIPASS`. + * **Installiert** — neben dem Programm. + + Der Umweg ist noetig, weil die API-Module sich flach importieren + (`import db`, `import mounts`). Das aufzuloesen ist der naechste + Umbauschritt; bis dahin wird der Pfad hier gesetzt statt an drei Stellen + geraten. + """ + gebuendelt = getattr(sys, "_MEIPASS", None) + if gebuendelt: + return os.path.join(gebuendelt, "api") + + hier = os.path.dirname(os.path.abspath(__file__)) # …/src/rippy + repo = os.path.dirname(os.path.dirname(hier)) # …/ + kandidaten = [ + os.path.join(repo, "docker", "api"), + os.path.join(os.path.dirname(sys.executable), "api"), + ] + for pfad in kandidaten: + if os.path.isfile(os.path.join(pfad, "main.py")): + return pfad + raise RuntimeError( + "Die API-Module wurden nicht gefunden. Gesucht wurde in:\n " + + "\n ".join(kandidaten) + + "\nOhne sie kann Rippy nicht starten." + ) + + +def _ui_pfad() -> str: + """Wo liegen die gebauten UI-Dateien? Leerer String = kein UI mitgeliefert.""" + gebuendelt = getattr(sys, "_MEIPASS", None) + if gebuendelt: + pfad = os.path.join(gebuendelt, "ui") + return pfad if os.path.isdir(pfad) else "" + + hier = os.path.dirname(os.path.abspath(__file__)) + repo = os.path.dirname(os.path.dirname(hier)) + for pfad in (os.path.join(repo, "docker", "ui", "dist"), + os.path.join(os.path.dirname(sys.executable), "ui")): + if os.path.isdir(pfad): + return pfad + return "" + + +def argumente(argv=None) -> argparse.Namespace: + p = argparse.ArgumentParser( + prog="rippyd", + description="Rippy als eigenstaendiger Dienst — ohne Docker.", + ) + p.add_argument("--profil", default=None, + choices=["standalone", "api", "node"], + help="Betriebsart (Vorgabe: standalone)") + p.add_argument("--config", default=None, + help="Pfad zur rippy.toml") + p.add_argument("--host", default=None) + p.add_argument("--port", type=int, default=None) + p.add_argument("--db", default=None, + help="Pfad zur SQLite-Datei (ueberschreibt die Konfiguration)") + p.add_argument("--kein-ui", action="store_true", + help="Weboberflaeche nicht ausliefern") + return p.parse_args(argv) + + +def flags_aus_argumenten(args) -> dict: + """Nur das, was WIRKLICH angegeben wurde — sonst schlaegt ein + argparse-Vorgabewert die Konfigurationsdatei, und niemand versteht, + warum der Eintrag dort nicht greift.""" + flags: dict = {} + if args.profil: + flags["profil"] = args.profil + server = {} + if args.host: + server["host"] = args.host + if args.port: + server["port"] = args.port + if args.kein_ui: + server["ui"] = False + if server: + flags["server"] = server + if args.db: + flags["store"] = {"treiber": "sqlite", "pfad": args.db} + return flags + + +def einstellungen_laden(args): + from rippy import config + + datei = args.config or os.environ.get("RIPPY_CONFIG") or standard_konfigpfad() + werte = config.laden(datei if os.path.isfile(datei) else None, + flags=flags_aus_argumenten(args)) + unbekannt = config.unbekannte_schluessel( + config.aus_datei(datei) if os.path.isfile(datei) else {}) + return werte, datei, unbekannt + + +def standard_konfigpfad() -> str: + if os.name == "nt": + basis = os.environ.get("PROGRAMDATA") or os.path.expanduser("~") + return os.path.join(basis, "Rippy", "rippy.toml") + return "/etc/rippy/rippy.toml" + + +def anwendung_bauen(werte: dict): + """Baut die ASGI-Anwendung: API unter /api, UI unter /. + + Gibt `(app, ui_pfad)` zurueck — `ui_pfad` ist leer, wenn kein UI + ausgeliefert wird. + """ + sys.path.insert(0, _api_pfad()) + + from contextlib import asynccontextmanager + + from fastapi import FastAPI + + import main as rippy_api # noqa: E402 + + @asynccontextmanager + async def lebenszyklus(_): + """Den Start der EINGEHAENGTEN Anwendung mit ausloesen. + + ⚠️ Starlette reicht Lifespan-Ereignisse NICHT an `mount()`-Unteranwen- + dungen weiter. Ohne diese Zeilen laeuft `startup_event()` in main.py + nie — und dann fehlt alles, was dort passiert: + + * `db.init_db()` legt die Tabellen an. Ohne das antwortet /settings + mit `sqlite3.OperationalError: no such table: settings`. + * Der Ereignis-Waechter startet nicht, der SSE-Strom bleibt stumm. + * Gespeicherte Netzwerk-Ziele werden nicht wieder eingehaengt. + + Am 28.08.2026 beim ersten Standalone-Start genau so gemessen: HTTP 200 + auf /api/health (die Route braucht keine Datenbank), HTTP 500 auf + /api/jobs und /api/settings, und der Ereignis-Strom lieferte in sechs + Sekunden nichts. + """ + async with rippy_api.app.router.lifespan_context(rippy_api.app): + yield + + aussen = FastAPI(title="Rippy", version="2.0", lifespan=lebenszyklus) + aussen.mount("/api", rippy_api.app) + + ui = _ui_pfad() if werte.get("server", {}).get("ui", True) else "" + if ui: + from fastapi.staticfiles import StaticFiles + + # html=True laesst StaticFiles bei unbekannten Pfaden index.html + # ausliefern — noetig, weil das UI seine Ansichten selbst verwaltet + # (im Docker-Betrieb macht das `try_files $uri /index.html`). + aussen.mount("/", StaticFiles(directory=ui, html=True), name="ui") + return aussen, ui + + +def starten(argv=None) -> int: + args = argumente(argv) + werte, konfigdatei, unbekannt = einstellungen_laden(args) + + from rippy import config, drives, store + + # ⚠️ VOR dem Import der API: main.py legt beim Start Tabellen an, und der + # Store muss bis dahin auf der richtigen Datenbank stehen. Andersherum + # laege die SQLite-Datei am Vorgabeort und die Konfiguration waere + # wirkungslos — ohne dass irgendwo etwas stuende. + url = config.datenbank_url(werte) + store.verbinden(url) + os.environ.setdefault("DATABASE_URL", url) + + print("Rippy startet") + print(f" Profil {werte.get('profil')}") + print(f" Konfiguration {konfigdatei if os.path.isfile(konfigdatei) else '(keine — Vorgaben)'}") + print(f" Datenbank {url}") + print(f" Laufwerke {drives.treiber().__name__}") + for schluessel in unbekannt: + # Ein stillschweigend ignorierter Schluessel ist dieselbe Fehlerklasse + # wie ein verschluckter Fehler: sieht gesund aus, wirkt aber nicht. + print(f" ⚠️ Unbekannter Eintrag in der Konfiguration: {schluessel}") + + app, ui = anwendung_bauen(werte) + print(f" Oberflaeche {ui or '(nicht mitgeliefert)'}") + + host = werte["server"]["host"] + port = werte["server"]["port"] + print(f" Adresse http://{'localhost' if host in ('0.0.0.0', '') else host}:{port}") + print() + + import uvicorn + + uvicorn.run(app, host=host, port=port, log_level="info") + return 0 + + +def main() -> int: + try: + return starten() + except KeyboardInterrupt: + print("\nRippy beendet.") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/src/rippy/test_daemon.py b/src/rippy/test_daemon.py new file mode 100644 index 0000000..b98cdcc --- /dev/null +++ b/src/rippy/test_daemon.py @@ -0,0 +1,99 @@ +"""Der Daemon-Start — die Teile, die ohne laufenden Server pruefbar sind. + +Nicht geprueft wird hier, dass uvicorn hochkommt; das ist ein Rauchtest fuer +den Betrieb. Geprueft wird, was davor entschieden wird — und genau dort +sitzen die Fehler, die man spaeter nicht mehr findet: + +* Ein argparse-Vorgabewert, der die Konfigurationsdatei schlaegt. +* Eine Datenbank, die am falschen Ort landet, weil der Store erst NACH dem + Import der API umgestellt wird. +""" + +import os + +from rippy import config, daemon + + +# ── Argumente ─────────────────────────────────────────────────────────── +def test_ohne_argumente_bleibt_alles_offen(): + """DIE Falle bei argparse: Ein Vorgabewert im Parser sieht aus wie eine + Angabe des Nutzers und schlaegt damit die Konfigurationsdatei. Der + Eintrag dort waere wirkungslos, und niemand wuesste warum. + """ + assert daemon.flags_aus_argumenten(daemon.argumente([])) == {} + + +def test_port_kommt_als_flag_durch(): + flags = daemon.flags_aus_argumenten(daemon.argumente(["--port", "9000"])) + assert flags == {"server": {"port": 9000}} + + +def test_mehrere_angaben_landen_im_richtigen_abschnitt(): + flags = daemon.flags_aus_argumenten( + daemon.argumente(["--host", "127.0.0.1", "--port", "8080", + "--profil", "standalone", "--kein-ui"])) + assert flags["profil"] == "standalone" + assert flags["server"] == {"host": "127.0.0.1", "port": 8080, "ui": False} + + +def test_db_flag_stellt_auf_sqlite_um(): + flags = daemon.flags_aus_argumenten(daemon.argumente(["--db", "C:/tmp/r.db"])) + assert flags["store"] == {"treiber": "sqlite", "pfad": "C:/tmp/r.db"} + + +def test_flag_schlaegt_die_konfigurationsdatei(): + """Die Praezedenz aus config.py — hier am echten Daemon-Weg nachgeprueft.""" + werte = config.laden( + umgebung={"RIPPY_SERVER__PORT": "5000"}, + flags=daemon.flags_aus_argumenten(daemon.argumente(["--port", "9999"])), + ) + assert werte["server"]["port"] == 9999 + + +# ── Pfade ─────────────────────────────────────────────────────────────── +def test_api_pfad_wird_gefunden(): + """Ohne die API-Module startet gar nichts. Wenn sich die Verzeichnis- + struktur aendert, soll das HIER auffallen und nicht beim Nutzer.""" + pfad = daemon._api_pfad() + assert os.path.isfile(os.path.join(pfad, "main.py")) + + +def test_ui_pfad_ist_entweder_da_oder_ehrlich_leer(): + """Leerer String heisst „kein UI mitgeliefert" — das ist ein gueltiger + Zustand (Kopfloser Betrieb). Was NICHT passieren darf: ein Pfad, den es + nicht gibt; dann liefe StaticFiles ins Leere.""" + pfad = daemon._ui_pfad() + assert pfad == "" or os.path.isdir(pfad) + + +def test_standard_konfigpfad_passt_zur_plattform(): + pfad = daemon.standard_konfigpfad() + if os.name == "nt": + assert pfad.endswith(os.path.join("Rippy", "rippy.toml")) + else: + assert pfad == "/etc/rippy/rippy.toml" + + +# ── Konfiguration einlesen ────────────────────────────────────────────── +def test_fehlende_konfigdatei_ist_kein_fehler(tmp_path, monkeypatch): + monkeypatch.delenv("RIPPY_CONFIG", raising=False) + args = daemon.argumente(["--config", str(tmp_path / "gibt-es-nicht.toml")]) + werte, datei, unbekannt = daemon.einstellungen_laden(args) + assert werte["server"]["port"] == 7788 # Vorgabe + assert unbekannt == [] + + +def test_unbekannte_eintraege_werden_gemeldet(tmp_path, monkeypatch): + """Sie werden NICHT uebernommen (config.py) — aber sie muessen sichtbar + sein. Ein Tippfehler, der stillschweigend wirkungslos bleibt, kostet + Stunden.""" + if config.tomllib is None: + import pytest + pytest.skip("tomllib gibt es erst ab Python 3.11") + datei = tmp_path / "rippy.toml" + datei.write_text("[server]\nport = 8080\nprot = 9090\n", encoding="utf-8") + monkeypatch.delenv("RIPPY_CONFIG", raising=False) + werte, _, unbekannt = daemon.einstellungen_laden( + daemon.argumente(["--config", str(datei)])) + assert werte["server"]["port"] == 8080 + assert unbekannt == ["server.prot"]