feat(daemon): V2-4 (Teil 3) — rippyd laeuft unter Windows, ohne Docker
Ampel / ampel (push) Failing after 46s

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 <noreply@anthropic.com>
This commit is contained in:
Hitonabi
2026-08-28 10:34:51 +02:00
co-authored by Claude Opus 5
parent 9d95c95d79
commit e5254c23f0
2 changed files with 384 additions and 0 deletions
+285
View File
@@ -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())
+99
View File
@@ -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"]