feat(daemon): V2-4 (Teil 3) — rippyd laeuft unter Windows, ohne Docker
Ampel / ampel (push) Failing after 46s
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:
co-authored by
Claude Opus 5
parent
9d95c95d79
commit
e5254c23f0
@@ -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())
|
||||
@@ -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"]
|
||||
Reference in New Issue
Block a user