Compare commits
3 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 0832ef777c | |||
| 6a082cf77c | |||
| 46a8f50c34 |
+14
-3
@@ -1,14 +1,25 @@
|
||||
# Rippy-Umgebung — nach .env kopieren und Werte eintragen.
|
||||
# Die .env liegt NUR auf der VM (gitignored), nie im Repo.
|
||||
|
||||
# PostgreSQL (intern; Compose nutzt aktuell rippy/rippy — Härtung folgt)
|
||||
# PostgreSQL (intern). Default rippy; für eine exponierte Umgebung hier ein starkes
|
||||
# Passwort setzen — Compose nutzt diesen Wert jetzt wirklich (DB + App-Verbindung).
|
||||
POSTGRES_PASSWORD=rippy
|
||||
|
||||
# Optisches Laufwerk (Host-Geräteknoten) — je Rechner UNTERSCHIEDLICH!
|
||||
# Der Worker braucht den SCSI-CD-ROM-Knoten (meist /dev/sr0) UND den passenden
|
||||
# generischen sg-Knoten des Laufwerks. Die sg-Nummer variiert je Host — auf DIESER
|
||||
# Maschine ermitteln: lsscsi -g (oder: ls -l /dev/sg*). Hier den HOST-Pfad des
|
||||
# Blu-ray/DVD-Laufwerks eintragen; der Default passt für den Ursprungs-Host.
|
||||
OPTICAL_SR=/dev/sr0
|
||||
OPTICAL_SG=/dev/sg1
|
||||
|
||||
# (JWT/Auth wurde am 24.07.2026 komplett entfernt — Rippy ist Heimnetz-only,
|
||||
# Commander-Entscheid, siehe KONZEPT.md Abschnitt 10.)
|
||||
# Commander-Entscheid, siehe KONZEPT.md Abschnitt 10. Härtung für exponierte
|
||||
# Netze: siehe README, Abschnitt "Härtung für fremde/exponierte Netze".)
|
||||
|
||||
# Metadaten-APIs
|
||||
# TMDB (Pflicht für Metadaten-Lookup): kostenlos auf themoviedb.org
|
||||
# TMDB (empfohlen für Metadaten-Lookup; ohne Key startet Rippy trotzdem, Key auch
|
||||
# später im UI setzbar): kostenlos auf themoviedb.org
|
||||
TMDB_API_KEY=
|
||||
# TVDb (optional, Serien-Fallback)
|
||||
THETVDB_API_KEY=
|
||||
|
||||
@@ -21,20 +21,23 @@ ersten Mal automatisch (API-Keys, Verarbeitung, erkannte Hardware).
|
||||
|
||||
### Laufwerk anpassen
|
||||
|
||||
Standard ist `/dev/sr0` (+ `/dev/sg1` für den Worker — MakeMKV spricht
|
||||
Laufwerke über die SCSI-Generic-Schicht an). Andere Geräte? Lege eine
|
||||
`docker-compose.override.yml` an:
|
||||
Der Worker braucht **zwei** Host-Geräteknoten: den CD-ROM-Knoten (meist
|
||||
`/dev/sr0`) und den passenden **SCSI-Generic-Knoten** (`/dev/sgN`) — MakeMKV
|
||||
spricht Laufwerke über die sg-Schicht an. Die sg-Nummer ist **je Host anders**
|
||||
(auf frischen Systemen oft `/dev/sg0`).
|
||||
|
||||
```yaml
|
||||
services:
|
||||
api:
|
||||
devices: ["/dev/sr1:/dev/sr0"]
|
||||
worker:
|
||||
devices: ["/dev/sr1:/dev/sr0", "/dev/sg2:/dev/sg1"]
|
||||
Ermitteln, welche Knoten dein Blu-ray/DVD-Laufwerk hat:
|
||||
```bash
|
||||
lsscsi -g # zeigt Modell + zugehörigen /dev/sgN
|
||||
# oder: ls -la /sys/class/scsi_generic/
|
||||
```
|
||||
|
||||
Welche sg-Nummer dein Laufwerk hat, verrät `lsscsi -g` oder
|
||||
`ls -la /sys/class/scsi_generic/`.
|
||||
Dann in der `.env` eintragen (Defaults passen für den Ursprungs-Host):
|
||||
```bash
|
||||
OPTICAL_SR=/dev/sr0 # CD-ROM-Knoten deines Laufwerks
|
||||
OPTICAL_SG=/dev/sg0 # sg-Knoten deines Laufwerks
|
||||
```
|
||||
`docker compose up -d` nutzt diese Werte. (Für Feineinstellungen geht weiterhin
|
||||
eine `docker-compose.override.yml` mit eigenen `devices:`-Einträgen.)
|
||||
|
||||
**Laufwerk in einer VM?** Per USB-Passthrough anhand der Vendor-ID
|
||||
durchreichen (Proxmox: `qm set <vmid> -usb0 host=xxxx:yyyy,usb3=1`) —
|
||||
@@ -150,6 +153,9 @@ bleibt All-in-one.
|
||||
| `THETVDB_API_KEY` | optional | Serien-Fallback |
|
||||
| `MAKEMKV_APP_KEY` | optional | MakeMKV-Beta-Key (Forum); DVDs gehen ohne — bequemer: im UI unter Einstellungen → System pflegen |
|
||||
| `MAKEMKV_URL_BASE` | optional | alternative Download-Quelle für den Image-Build |
|
||||
| `OPTICAL_SR` | je Host | Host-Pfad des CD-ROM-Knotens (Default `/dev/sr0`) — siehe „Laufwerk anpassen" |
|
||||
| `OPTICAL_SG` | je Host | Host-Pfad des sg-Knotens des Laufwerks (Default `/dev/sg1`; oft `/dev/sg0`) |
|
||||
| `POSTGRES_PASSWORD` | optional | DB-Passwort (Default `rippy`) — für exponierte Umgebungen ein starkes Passwort setzen |
|
||||
|
||||
UI-Einstellungen (Wizard/Settings) überstimmen die Env-Variablen.
|
||||
|
||||
@@ -165,7 +171,16 @@ Voraussetzungen auf dem Ziel-Host:
|
||||
In einer VM: per **USB-Passthrough** (Vendor-ID) durchreichen, NICHT
|
||||
als emuliertes CD-ROM — siehe „Laufwerk anpassen" oben.
|
||||
3. Ablage-Basis anlegen: `mkdir -p /srv/rippy/media` (oder Pfad in
|
||||
`docker-compose.yml` anpassen — muss ein rshared-fähiger Bind sein).
|
||||
`docker-compose.yml` anpassen). Der Bind nutzt **shared/slave-Propagation**
|
||||
(fürs In-UI-Einhängen von NAS-Freigaben). Auf den meisten systemd-Linux-Hosts
|
||||
ist `/` schon `rshared`, dann ist nichts zu tun. Bricht `docker compose up`
|
||||
mit *„path … is mounted on / but it is not a shared mount"* ab, einmalig:
|
||||
```bash
|
||||
sudo mount --bind /srv/rippy/media /srv/rippy/media
|
||||
sudo mount --make-rshared /srv/rippy/media
|
||||
```
|
||||
(reboot-fest als systemd-`.mount`-Unit persistieren.) Braucht einen klassischen
|
||||
Linux-Docker-Host mit `SYS_ADMIN` — nicht Docker-Desktop/rootless/Podman.
|
||||
|
||||
Dann wie im Schnellstart: klonen, `.env` füllen, `docker compose up -d
|
||||
--build`, `http://<host>` öffnen — der Einrichtungs-Assistent führt durch
|
||||
@@ -175,6 +190,25 @@ docker compose up -d --build`.
|
||||
Nicht mitnehmen musst du: Gitea, Arcane, den CI-Runner — das ist die
|
||||
Entwicklungs-Infrastruktur DIESER Installation, nicht Teil von Rippy.
|
||||
|
||||
### Härtung für fremde/exponierte Netze
|
||||
|
||||
Rippy ist bewusst **Heimnetz-only**: Es hat **keine Authentifizierung**, und
|
||||
Redis + PostgreSQL veröffentlichen Ports auf allen Interfaces (für Remote-Worker).
|
||||
Im vertrauten LAN ist das ein akzeptierter Kompromiss — **exponiere Rippy niemals
|
||||
ungeschützt an ein unsicheres oder öffentliches Netz.** Wer es weiter absichern will:
|
||||
|
||||
- **Zugriff kapseln:** API (`:8000`) und UI hinter einen Reverse-Proxy mit Auth
|
||||
(Basic-/Forward-Auth) legen oder nur über VPN erreichbar machen; Port `8000`
|
||||
nicht auf `0.0.0.0` binden.
|
||||
- **DB/Broker abschotten:** `POSTGRES_PASSWORD` in der `.env` auf ein starkes
|
||||
Passwort setzen; die Ports `5432`/`6379` nur an ein internes/VPN-Interface binden
|
||||
(z. B. `"10.0.0.5:5432:5432"`) statt an `0.0.0.0`; Redis mit `--requirepass
|
||||
<geheim>` starten und das Passwort in `REDIS_URL` ergänzen.
|
||||
- **CORS:** `main.py` erlaubt aktuell `*` (nötig, weil UI und API getrennte Ports
|
||||
sind) — hinter einem Proxy auf die echte UI-Herkunft einschränken.
|
||||
- **NAS-Zugangsdaten** liegen bewusst im Klartext in der DB — ein weiterer Grund,
|
||||
den DB-Port nie offen ins unsichere Netz zu hängen.
|
||||
|
||||
## Entwicklung
|
||||
|
||||
CI („Ampel") läuft bei jedem Push: Ruff, pytest, Vite-Build. Es gibt genau
|
||||
|
||||
@@ -1,27 +1,48 @@
|
||||
#!/usr/bin/env bash
|
||||
# Deploy für Rippy — deployt den aktuellen 'main'-Stand auf die VM.
|
||||
# Single Source of Truth ist seit 24.07.2026 'main' (kein stable-Branch mehr);
|
||||
# die Ampel prüft nur noch, befördert aber nichts. Vor dem Deploy also selbst
|
||||
# sicherstellen, dass die Ampel für den zu deployenden Commit GRÜN ist.
|
||||
# Deploy fuer Rippy — deployt den aktuellen 'main'-Stand auf die Ziel-VM.
|
||||
# Single Source of Truth ist 'main' (kein stable-Branch); die Ampel prueft nur,
|
||||
# befoerdert nichts. Vor dem Deploy selbst sicherstellen, dass die Ampel fuer den
|
||||
# zu deployenden Commit GRUEN ist.
|
||||
#
|
||||
# Nutzt einen EIGENEN Klon (~/notfall-rippy) und fasst Arcanes
|
||||
# Projektverzeichnis (~/projects/rippy) NIE an — 22.07. gelernt: ein git-Klon
|
||||
# im Arcane-Verzeichnis kollidiert mit dessen Sync (read-only .git-Objekte).
|
||||
# Die .env gehört Arcane und wird nur GELESEN (für compose-Interpolation).
|
||||
# Generisch/portabel: Ziel-Host, Repo-URL und .env-Pfad kommen aus der Umgebung —
|
||||
# KEINE festen Adressen im Repo. Fuer eine Erst-Installation auf einem eigenen Host
|
||||
# reicht ohnehin der einfache Weg (siehe README):
|
||||
# git clone <repo> && cd rippy && cp .env.example .env (Werte eintragen)
|
||||
# docker compose up -d --build
|
||||
#
|
||||
# Dieses Skript ist nur die bequeme Remote-Variante: es SSHt zur VM, nutzt dort
|
||||
# einen EIGENEN Klon (Default ~/notfall-rippy) und fasst ein evtl. vorhandenes
|
||||
# Projektverzeichnis NIE an (git-Klon dort kollidiert mit dessen Sync). Die .env
|
||||
# gehoert der VM und wird nur GELESEN (fuer compose-Interpolation).
|
||||
#
|
||||
# Konfiguration ueber Umgebungsvariablen:
|
||||
# RIPPY_VM Ziel-Host fuer ssh, z.B. user@10.0.0.5 (PFLICHT)
|
||||
# RIPPY_REPO_URL Git-Repo-URL (branch main) (PFLICHT)
|
||||
# RIPPY_ENV_SRC Pfad zur .env auf der VM (Default: ~/rippy/.env)
|
||||
# RIPPY_CLONE Arbeits-Klon auf der VM (Default: ~/notfall-rippy)
|
||||
# Optionales Argument $1: nur einen Dienst neu bauen, z.B. ./deploy.sh api
|
||||
set -euo pipefail
|
||||
|
||||
VM="arcane@192.168.178.162"
|
||||
DIENST="${1:-}" # optional: nur einen Dienst neu bauen, z.B. ./deploy.sh api
|
||||
VM="${RIPPY_VM:?RIPPY_VM setzen, z.B. RIPPY_VM=user@10.0.0.5}"
|
||||
REPO_URL="${RIPPY_REPO_URL:?RIPPY_REPO_URL setzen (Git-URL des Rippy-Repos)}"
|
||||
ENV_SRC="${RIPPY_ENV_SRC:-}" # leer -> Remote-Default ~/rippy/.env
|
||||
CLONE="${RIPPY_CLONE:-}" # leer -> Remote-Default ~/notfall-rippy
|
||||
DIENST="${1:-}"
|
||||
|
||||
ssh "$VM" 'bash -s' <<REMOTE
|
||||
ssh "$VM" bash -s -- "$REPO_URL" "$ENV_SRC" "$CLONE" "$DIENST" <<'REMOTE'
|
||||
set -euo pipefail
|
||||
if [ ! -d ~/notfall-rippy/.git ]; then
|
||||
git clone --branch main https://git.tobisniceshomelab.ddnsfree.com/Hitonabi/rippy.git ~/notfall-rippy
|
||||
REPO_URL="$1"
|
||||
ENV_SRC="${2:-$HOME/rippy/.env}"
|
||||
CLONE="${3:-$HOME/notfall-rippy}"
|
||||
DIENST="$4"
|
||||
|
||||
if [ ! -d "$CLONE/.git" ]; then
|
||||
git clone --branch main "$REPO_URL" "$CLONE"
|
||||
fi
|
||||
cd ~/notfall-rippy
|
||||
cd "$CLONE"
|
||||
git fetch origin main
|
||||
git reset --hard origin/main
|
||||
cp ~/projects/rippy/.env .env 2>/dev/null || echo "WARNUNG: keine .env in Arcanes Projektverzeichnis"
|
||||
cp "$ENV_SRC" .env 2>/dev/null || echo "WARNUNG: keine .env unter $ENV_SRC gefunden"
|
||||
docker compose -p rippy up -d --build $DIENST
|
||||
docker compose -p rippy ps --format 'table {{.Name}}\t{{.Status}}'
|
||||
REMOTE
|
||||
|
||||
+12
-11
@@ -11,7 +11,7 @@ services:
|
||||
context: .
|
||||
dockerfile: docker/api/Dockerfile
|
||||
environment:
|
||||
- DATABASE_URL=postgresql://rippy:rippy@postgres:5432/rippy
|
||||
- DATABASE_URL=postgresql://rippy:${POSTGRES_PASSWORD:-rippy}@postgres:5432/rippy
|
||||
- REDIS_URL=redis://redis:6379/0
|
||||
- TMDB_API_KEY=${TMDB_API_KEY:-}
|
||||
- THETVDB_API_KEY=${THETVDB_API_KEY:-}
|
||||
@@ -48,7 +48,7 @@ services:
|
||||
propagation: rshared
|
||||
- temp:/app/temp
|
||||
devices:
|
||||
- /dev/sr0:/dev/sr0
|
||||
- ${OPTICAL_SR:-/dev/sr0}:/dev/sr0
|
||||
networks:
|
||||
- rippy-net
|
||||
depends_on:
|
||||
@@ -74,7 +74,7 @@ services:
|
||||
# Einstellungen → System meldet, wenn es eine neuere Version gibt.
|
||||
MAKEMKV_VERSION: ${MAKEMKV_VERSION:-1.18.4}
|
||||
environment:
|
||||
- DATABASE_URL=postgresql://rippy:rippy@postgres:5432/rippy
|
||||
- DATABASE_URL=postgresql://rippy:${POSTGRES_PASSWORD:-rippy}@postgres:5432/rippy
|
||||
- REDIS_URL=redis://redis:6379/0
|
||||
- RIP_OUTPUT_DIR=/app/media
|
||||
- MAKEMKV_APP_KEY=${MAKEMKV_APP_KEY}
|
||||
@@ -93,13 +93,14 @@ services:
|
||||
propagation: rslave
|
||||
- temp:/app/temp
|
||||
devices:
|
||||
- /dev/sr0:/dev/sr0
|
||||
# MakeMKV spricht Laufwerke über die SCSI-Generic-Schicht an — ohne den
|
||||
# sg-Knoten findet es „keine usable optical drives" (Deploy-Befund 23.07.).
|
||||
# In dieser VM: sg0 = Systemplatte, sg1 = das BD-Laufwerk. Achtung: nach
|
||||
# einem USB-Reconnect zur Laufzeit kann die sg-Nummer wandern → Container
|
||||
# neu starten.
|
||||
- /dev/sg1:/dev/sg1
|
||||
# Host-Geräteknoten via .env (OPTICAL_SR/OPTICAL_SG) — je Rechner ANDERS!
|
||||
# MakeMKV spricht Laufwerke über die SCSI-Generic-Schicht an; ohne den
|
||||
# passenden sg-Knoten findet es „keine usable optical drives". Den sg-Knoten
|
||||
# des Laufwerks auf DIESEM Host ermitteln (lsscsi -g) und in die .env eintragen.
|
||||
# Achtung: nach USB-Reconnect zur Laufzeit kann die sg-Nummer wandern →
|
||||
# Container neu starten. Defaults (sr0/sg1) passen für den Ursprungs-Host.
|
||||
- ${OPTICAL_SR:-/dev/sr0}:/dev/sr0
|
||||
- ${OPTICAL_SG:-/dev/sg1}:/dev/sg1
|
||||
networks:
|
||||
- rippy-net
|
||||
depends_on:
|
||||
@@ -132,7 +133,7 @@ services:
|
||||
image: postgres:16-alpine
|
||||
environment:
|
||||
- POSTGRES_USER=rippy
|
||||
- POSTGRES_PASSWORD=rippy
|
||||
- POSTGRES_PASSWORD=${POSTGRES_PASSWORD:-rippy}
|
||||
- POSTGRES_DB=rippy
|
||||
# Veröffentlicht (Befund 24.07.): Remote-Worker (GPU-Maschine, künftiger
|
||||
# Windows-Worker) verbinden sich über <rippy-host>:5432/6379 — ohne
|
||||
|
||||
@@ -14,12 +14,14 @@ def validate_config() -> Settings:
|
||||
try:
|
||||
settings = Settings()
|
||||
|
||||
# TMDB API Key ist Pflicht (gemäß KONZEPT.md)
|
||||
# TMDB-Key ist EMPFOHLEN, nicht zwingend: main.py fängt diesen Fehler ab und
|
||||
# gibt nur eine Warnung aus — die API startet auch ohne Key, und der Key lässt
|
||||
# sich jederzeit im UI (Einstellungen → System) nachtragen.
|
||||
if not settings.tmdb_api_key:
|
||||
raise ConfigValidationError(
|
||||
"TMDB_API_KEY ist erforderlich für Metadaten-Lookup.\n"
|
||||
"Hole dir einen免费en Key auf https://www.themoviedb.org/\n"
|
||||
"Setze ihn als Umgebungsvariable: TMDB_API_KEY=dein_key"
|
||||
"TMDB_API_KEY ist nicht gesetzt (empfohlen für Metadaten-Lookup).\n"
|
||||
"Kostenlosen Key auf https://www.themoviedb.org/ holen und als\n"
|
||||
"Umgebungsvariable TMDB_API_KEY setzen — oder später im UI eintragen."
|
||||
)
|
||||
|
||||
return settings
|
||||
|
||||
+7
-2
@@ -47,11 +47,16 @@ async def startup_event():
|
||||
except ConfigValidationError as e:
|
||||
print(f"⚠️ Konfigurations-Warnung: {e}")
|
||||
|
||||
# Gespeicherte Netzwerk-Speicherziele wiederherstellen
|
||||
# Gespeicherte Netzwerk-Speicherziele wiederherstellen — NICHT-BLOCKIEREND.
|
||||
# Ein zickiger/langsamer Netz-Mount (der CIFS-Schreibtest in mounten() kann im
|
||||
# Kernel haengen, wait_for_response) darf den API-Start NIE blockieren. Vorfall
|
||||
# 24.07.: ~5 min "Waiting for application startup", kein Endpoint bedient, bis der
|
||||
# soft-Mount per Timeout abbrach. Darum im Hintergrund: die Mounts stellen sich
|
||||
# her, sobald der Server antwortet, ohne die API auszubremsen.
|
||||
def remount():
|
||||
for meldung in mount_verwaltung.alle_remounten():
|
||||
db.add_log("info", "mounts", meldung)
|
||||
await asyncio.to_thread(remount)
|
||||
asyncio.create_task(asyncio.to_thread(remount))
|
||||
|
||||
asyncio.create_task(disc_watcher())
|
||||
# MakeMKV-Beta-Key automatisch aktuell halten (wechselt ~monatlich, laeuft zum
|
||||
|
||||
+29
-1
@@ -73,6 +73,30 @@ def schreibtest(pfad: str) -> bool:
|
||||
return False
|
||||
|
||||
|
||||
def _stale_mounts_loesen(ziel: str) -> int:
|
||||
"""Loest ALLE (evtl. gestapelten/toten) Mounts an `ziel` per lazy umount.
|
||||
|
||||
Warum: mounten() faellt bei einem TOTEN Mount (os.path.ismount wirft OSError)
|
||||
auf den echten `mount` durch — der stapelt dann auf die Leiche. Vorfall 24.07.:
|
||||
ueber viele Neustarts 12 Schichten, die tote oberste blockierte jeden Zugriff
|
||||
(ls-Timeout, obwohl SMB-445 offen). Erst alle Schichten loesen macht das
|
||||
Re-Mounten idempotent. `umount -l` (lazy) haengt nicht an einem toten CIFS.
|
||||
Rueckgabe: Zahl der geloesten Schichten.
|
||||
"""
|
||||
geloest = 0
|
||||
for _ in range(20): # harte Obergrenze gegen Endlosschleife
|
||||
try:
|
||||
ergebnis = subprocess.run(
|
||||
["umount", "-l", ziel], capture_output=True, timeout=10
|
||||
)
|
||||
except (OSError, subprocess.TimeoutExpired):
|
||||
break
|
||||
if ergebnis.returncode != 0:
|
||||
break # nichts (mehr) gemountet
|
||||
geloest += 1
|
||||
return geloest
|
||||
|
||||
|
||||
def uebersetze_smb_fehler(fehler: str, mit_credentials: bool) -> str:
|
||||
"""Pure Funktion (testbar): NT_STATUS-Kauderwelsch → handelbarer Klartext.
|
||||
|
||||
@@ -155,7 +179,11 @@ def mounten(name: str, typ: str, quelle: str, optionen: str = "",
|
||||
if os.path.ismount(ziel):
|
||||
return schreibtest(ziel)
|
||||
except OSError:
|
||||
pass # toter Mount → als „nicht gemountet" behandeln und neu mounten
|
||||
pass # toter Mount → unten erst lösen, dann frisch mounten (kein Stapeln)
|
||||
|
||||
# Idempotent: etwaige (auch gestapelte/tote) Alt-Mounts erst lösen, damit der
|
||||
# folgende mount NICHT auf eine Leiche stapelt (Vorfall 24.07.: 12 Schichten).
|
||||
_stale_mounts_loesen(ziel)
|
||||
|
||||
creds_datei = None
|
||||
try:
|
||||
|
||||
@@ -42,3 +42,18 @@ def test_worker_task_name_passt_zum_celery_client():
|
||||
|
||||
quelle = inspect.getsource(celery_client.start_rip)
|
||||
assert '"worker.tasks.rip_disc"' in quelle
|
||||
|
||||
|
||||
def test_remount_blockiert_den_api_start_nicht():
|
||||
"""Regression (Vorfall 24.07.): ein haengender Netz-Mount (CIFS-Schreibtest kann
|
||||
im Kernel haengen, wait_for_response) darf den API-Start NICHT blockieren. remount
|
||||
muss als Hintergrund-Task laufen (create_task), nicht direkt awaited werden."""
|
||||
import inspect
|
||||
|
||||
import main
|
||||
|
||||
quelle = inspect.getsource(main.startup_event)
|
||||
assert "create_task(asyncio.to_thread(remount))" in quelle, \
|
||||
"remount muss als Hintergrund-Task laufen (nicht blockierend)"
|
||||
assert "await asyncio.to_thread(remount)" not in quelle, \
|
||||
"remount darf nicht mehr direkt awaited werden (blockiert sonst den Start)"
|
||||
|
||||
@@ -39,3 +39,33 @@ def test_unbekannter_fehler_bleibt_erhalten_und_gekappt():
|
||||
def test_validiere_name_bleibt_streng():
|
||||
assert validiere_name("nas-filme")
|
||||
assert not validiere_name("NAS Filme")
|
||||
|
||||
|
||||
def test_stale_mounts_loesen_loest_bis_nichts_mehr_geht(monkeypatch):
|
||||
"""Löst gestapelte Schichten per lazy umount, bis umount nichts mehr findet
|
||||
(returncode != 0), und meldet die Zahl der gelösten Schichten."""
|
||||
import types
|
||||
|
||||
import mounts
|
||||
|
||||
aufrufe = []
|
||||
|
||||
def fake_run(cmd, **kwargs):
|
||||
aufrufe.append(cmd)
|
||||
rc = 0 if len(aufrufe) <= 3 else 1 # 3 Schichten lösen, dann leer
|
||||
return types.SimpleNamespace(returncode=rc, stdout=b"", stderr=b"")
|
||||
|
||||
monkeypatch.setattr(mounts.subprocess, "run", fake_run)
|
||||
assert mounts._stale_mounts_loesen("/app/media/x") == 3
|
||||
assert all(cmd[:2] == ["umount", "-l"] for cmd in aufrufe)
|
||||
|
||||
|
||||
def test_mounten_loest_stale_vor_dem_mount():
|
||||
"""Regression (Vorfall 24.07.): mounten() muss Alt-Mounts LÖSEN, bevor es neu
|
||||
mountet — sonst stapelt es auf eine Mount-Leiche (12 Schichten, ls-Timeout)."""
|
||||
import inspect
|
||||
|
||||
import mounts
|
||||
|
||||
quelle = inspect.getsource(mounts.mounten)
|
||||
assert "_stale_mounts_loesen(ziel)" in quelle
|
||||
|
||||
@@ -1,6 +0,0 @@
|
||||
FROM postgres:16
|
||||
|
||||
COPY init.sql /docker-entrypoint-initdb.d/
|
||||
|
||||
EXPOSE 5432
|
||||
|
||||
@@ -1,3 +0,0 @@
|
||||
CREATE DATABASE rippy;
|
||||
CREATE USER rippy WITH PASSWORD 'rippy123';
|
||||
GRANT ALL PRIVILEGES ON DATABASE rippy TO rippy;
|
||||
@@ -1,5 +0,0 @@
|
||||
FROM redis:7-alpine
|
||||
|
||||
COPY redis.conf /usr/local/etc/redis/redis.conf
|
||||
|
||||
CMD ["redis-server", "/usr/local/etc/redis/redis.conf"]
|
||||
@@ -1,6 +0,0 @@
|
||||
appendonly yes
|
||||
appendfsync everysec
|
||||
timeout 0
|
||||
tcp-keepalive 300
|
||||
loglevel notice
|
||||
databases 16
|
||||
+15
-1
@@ -1,14 +1,25 @@
|
||||
import { useState } from 'react'
|
||||
import { useEffect, useState } from 'react'
|
||||
import { LayoutDashboard, BookOpen, FileText, Settings as SettingsIcon } from 'lucide-react'
|
||||
import Dashboard from './pages/Dashboard'
|
||||
import SettingsPage from './pages/Settings'
|
||||
import LogsPage from './pages/Logs'
|
||||
import AnleitungPage from './pages/Anleitung'
|
||||
import FirstRunWizard from './components/FirstRunWizard'
|
||||
import { api } from './lib/api'
|
||||
|
||||
type Page = 'dashboard' | 'anleitung' | 'logs' | 'settings'
|
||||
|
||||
export default function App() {
|
||||
const [currentPage, setCurrentPage] = useState<Page>('dashboard')
|
||||
// First-Run: solange der Einrichtungs-Assistent nicht abgeschlossen ist, zeigen wir
|
||||
// ihn statt des Dashboards. null = /setup noch nicht geprüft (kein Aufblitzen).
|
||||
const [setupDone, setSetupDone] = useState<boolean | null>(null)
|
||||
|
||||
useEffect(() => {
|
||||
api.get('/setup')
|
||||
.then((r) => setSetupDone(Boolean(r.data?.done)))
|
||||
.catch(() => setSetupDone(true)) // API/Setup nicht erreichbar → nicht blockieren
|
||||
}, [])
|
||||
|
||||
const navItems: { id: Page; label: string; icon: any }[] = [
|
||||
{ id: 'dashboard', label: 'Dashboard', icon: LayoutDashboard },
|
||||
@@ -17,6 +28,9 @@ export default function App() {
|
||||
{ id: 'settings', label: 'Einstellungen', icon: SettingsIcon },
|
||||
]
|
||||
|
||||
if (setupDone === null) return null // kurzer Moment, bis /setup geantwortet hat
|
||||
if (!setupDone) return <FirstRunWizard onDone={() => setSetupDone(true)} />
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-[#080b11] text-slate-100 font-sans selection:bg-amber-500/30 selection:text-amber-300">
|
||||
{/* Top Header Navigation Bar */}
|
||||
|
||||
@@ -1,52 +0,0 @@
|
||||
# Rippy udev-Setup
|
||||
|
||||
## Problem
|
||||
Rippy findet kein Laufwerk, obwohl eines am Host angeschlossen ist.
|
||||
|
||||
## Ursache
|
||||
Die `docker-compose.yml` nutzte anonyme Volumes für `/dev/disc`, `/dev/cdrom`, `/dev/dvd`:
|
||||
```yaml
|
||||
volumes:
|
||||
- disc:/dev/disc # ❌ Anonymes Volume, leer
|
||||
```
|
||||
|
||||
## Lösung
|
||||
Direkte Mounts vom Host in den Worker-Container:
|
||||
|
||||
### 1. docker-compose.yml anpassen
|
||||
```yaml
|
||||
volumes:
|
||||
- /dev/cdrom:/dev/cdrom:ro
|
||||
- /dev/dvd:/dev/dvd:ro
|
||||
- /dev/sr0:/dev/sr0:ro
|
||||
- disc:/dev/disc # ✅ Shared-Mount für udev-Symlinks
|
||||
```
|
||||
|
||||
### 2. Worker-Dockerfile erweitern
|
||||
- `isoinfo` für Disc-Typ-Erkennung
|
||||
- `udev` für Geräteverwaltung
|
||||
|
||||
### 3. udev-Regel installieren
|
||||
Kopiere `99-disc-ripper.rules` nach `/etc/udev/rules.d/`
|
||||
|
||||
### 4. udev-Daemon ausführen
|
||||
Der Daemon (`udev_daemon.py`) läuft im Worker-Container und erstellt Symlinks mit UUID/Serial.
|
||||
|
||||
## Installation auf Arcane VM
|
||||
|
||||
```bash
|
||||
cd /path/to/rippy/udev
|
||||
bash install-arcane.sh
|
||||
```
|
||||
|
||||
## Testen
|
||||
1. Leg eine CD/DVD ein
|
||||
2. Logs prüfen: `docker logs -f rippy-worker`
|
||||
3. Symlinks: `docker exec rippy-worker ls -la /dev/disc`
|
||||
4. Devices: `docker exec rippy-worker ls -la /dev/cdrom /dev/dvd /dev/sr0`
|
||||
|
||||
## Dateien
|
||||
- `99-disc-ripper.rules` — udev-Regel für Einwurf/Auswurf
|
||||
- `udev_daemon.py` — Daemon für Disc-Erkennung
|
||||
- `rippy-udev.service` — systemd-Service (optional)
|
||||
- `install-arcane.sh` — Installations-Skript für Arcane VM
|
||||
@@ -1,50 +0,0 @@
|
||||
#!/bin/bash
|
||||
# Installations-Skript für Arcane VM (Debian 12)
|
||||
# Auf der Arcane VM ausführen
|
||||
|
||||
set -e
|
||||
|
||||
echo "=== Rippy udev-Setup für Arcane VM ==="
|
||||
|
||||
# 1. udev-Regel installieren
|
||||
echo "Installiere udev-Regel..."
|
||||
cp 99-disc-ripper.rules /etc/udev/rules.d/
|
||||
echo "✅ /etc/udev/rules.d/99-disc-ripper.rules installiert"
|
||||
|
||||
# 2. udev-Daemon in Worker-Container kopieren
|
||||
echo "Kopiere udev-Daemon in Worker-Container..."
|
||||
docker cp udev_daemon.py rippy-worker:/app/udev/
|
||||
docker cp 99-disc-ripper.rules rippy-worker:/etc/udev/rules.d/
|
||||
docker cp rippy-udev.service rippy-worker:/etc/systemd/system/
|
||||
echo "✅ udev-Daemon und Regeln in Container kopiert"
|
||||
|
||||
# 3. udev-Regel neu laden (im Host)
|
||||
echo "Lade udev-Regeln im Host neu..."
|
||||
sudo udevadm control --reload-rules
|
||||
sudo udevadm trigger --subsystem-match=block
|
||||
echo "✅ udev-Regeln im Host neu geladen"
|
||||
|
||||
# 4. udev-Regel in Container neu laden
|
||||
echo "Lade udev-Regeln im Container neu..."
|
||||
docker exec -i rippy-worker udevadm control --reload-rules
|
||||
docker exec -i rippy-worker udevadm trigger --subsystem-match=block
|
||||
echo "✅ udev-Regeln im Container neu geladen"
|
||||
|
||||
# 5. Service aktivieren (optional - wenn im Container als systemd läuft)
|
||||
if docker exec -i rippy-worker which systemctl > /dev/null 2>&1; then
|
||||
echo "Aktiviere systemd-Service..."
|
||||
docker exec -i rippy-worker systemctl daemon-reload
|
||||
docker exec -i rippy-worker systemctl enable rippy-udev
|
||||
echo "✅ systemd-Service aktiviert"
|
||||
fi
|
||||
|
||||
echo "=== Setup abgeschlossen ==="
|
||||
echo ""
|
||||
echo "Testen:"
|
||||
echo " 1. Leg eine CD/DVD ein"
|
||||
echo " 2. Logs prüfen: docker logs -f rippy-worker"
|
||||
echo " 3. Symlinks: docker exec rippy-worker ls -la /dev/disc"
|
||||
echo " 4. Devices: docker exec rippy-worker ls -la /dev/cdrom /dev/dvd /dev/sr0"
|
||||
echo ""
|
||||
echo "Hinweis: /dev/disc ist ein Shared-Mount, der udev-Daemon erstellt Symlinks"
|
||||
echo " mit UUID/Serial für jedes erkannte Laufwerk."
|
||||
@@ -1,43 +0,0 @@
|
||||
#!/bin/bash
|
||||
# Installations-Skript für Rippy udev-Regel + Service
|
||||
# Auf dem Host (Arcane VM / Proxmox LXC) ausführen
|
||||
|
||||
set -e
|
||||
|
||||
echo "=== Rippy udev-Setup ==="
|
||||
|
||||
# Prüfen ob wir root sind
|
||||
if [ "$EUID" -ne 0 ]; then
|
||||
echo "❌ Bitte als root ausführen (sudo bash install-udev.sh)"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# 1. udev-Regel installieren
|
||||
echo "Installiere udev-Regel..."
|
||||
cp 99-disc-ripper.rules /etc/udev/rules.d/
|
||||
echo "✅ /etc/udev/rules.d/99-disc-ripper.rules installiert"
|
||||
|
||||
# 2. udev-Daemon in den Container kopieren
|
||||
echo "Kopiere udev-Daemon in Worker-Container..."
|
||||
docker cp udev_daemon.py rippy-worker:/app/udev/
|
||||
docker cp 99-disc-ripper.rules rippy-worker:/etc/udev/rules.d/
|
||||
docker cp rippy-udev.service rippy-worker:/etc/systemd/system/
|
||||
echo "✅ udev-Daemon und Regeln in Container kopiert"
|
||||
|
||||
# 3. udev-Regel neu laden
|
||||
echo "Lade udev-Regeln neu..."
|
||||
sudo udevadm control --reload-rules
|
||||
sudo udevadm trigger --subsystem-match=block
|
||||
echo "✅ udev-Regeln neu geladen"
|
||||
|
||||
# 4. Service aktivieren (optional - wenn im Container als systemd läuft)
|
||||
if docker exec -i rippy-worker which systemctl > /dev/null 2>&1; then
|
||||
echo "Aktiviere systemd-Service..."
|
||||
docker exec -i rippy-worker systemctl daemon-reload
|
||||
docker exec -i rippy-worker systemctl enable rippy-udev
|
||||
echo "✅ systemd-Service aktiviert"
|
||||
fi
|
||||
|
||||
echo "=== Setup abgeschlossen ==="
|
||||
echo "Teste Disc-Einwurf: Leg eine CD/DVD ein"
|
||||
echo "Prüfe Logs: docker logs -f rippy-worker"
|
||||
@@ -1,29 +0,0 @@
|
||||
# systemd-Service für Rippy udev-Daemon
|
||||
# Diese Datei muss nach /etc/systemd/system/rippy-udev.service kopiert werden
|
||||
|
||||
[Unit]
|
||||
Description=Rippy Disc Detection Daemon
|
||||
After=dev-disc.device
|
||||
Requires=dev-disc.device
|
||||
|
||||
[Service]
|
||||
Type=simple
|
||||
ExecStart=/usr/bin/python3 /app/udev_daemon.py
|
||||
Restart=always
|
||||
RestartSec=5
|
||||
WorkingDirectory=/app
|
||||
User=root
|
||||
Group=root
|
||||
|
||||
# Environment
|
||||
Environment=REDIS_URL=redis://redis:6379/0
|
||||
Environment=PATH=/usr/local/bin:/usr/bin:/bin
|
||||
|
||||
# Security Hardening
|
||||
NoNewPrivileges=yes
|
||||
ProtectSystem=strict
|
||||
ProtectHome=read-only
|
||||
PrivateTmp=yes
|
||||
|
||||
[Install]
|
||||
WantedBy=multi-user.target
|
||||
@@ -1,160 +0,0 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
udev-Daemon für Rippy
|
||||
Erkennt Disc-Einwurf/-auswurf und erstellt Celery-Jobs.
|
||||
"""
|
||||
|
||||
import os
|
||||
import sys
|
||||
import subprocess
|
||||
import redis
|
||||
import json
|
||||
|
||||
REDIS_URL = os.getenv("REDIS_URL", "redis://localhost:6379/0")
|
||||
DISC_DEVICE_PATH = "/dev/disc"
|
||||
|
||||
# Redis verbinden
|
||||
def get_redis_client():
|
||||
try:
|
||||
return redis.from_url(REDIS_URL)
|
||||
except Exception as e:
|
||||
print(f"Fehler beim Verbinden mit Redis: {e}")
|
||||
sys.exit(1)
|
||||
|
||||
|
||||
def get_device_info(device_name: str) -> dict:
|
||||
"""Ermittle Device-Info via udevadm."""
|
||||
try:
|
||||
result = subprocess.run(
|
||||
["udevadm", "info", "-q", "property", "-n", f"/dev/{device_name}"],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=5
|
||||
)
|
||||
|
||||
info = {}
|
||||
for line in result.stdout.strip().split('\n'):
|
||||
if '=' in line:
|
||||
key, value = line.split('=', 1)
|
||||
info[key] = value
|
||||
|
||||
return info
|
||||
except Exception as e:
|
||||
print(f"Fehler beim Auslesen von Device {device_name}: {e}")
|
||||
return {}
|
||||
|
||||
|
||||
def create_device_symlink(device_name: str, info: dict) -> str:
|
||||
"""Erstelle Symlink mit UUID/Serial-Nummer."""
|
||||
serial = info.get("ID_SERIAL", "unknown")
|
||||
model = info.get("ID_MODEL", "unknown")
|
||||
|
||||
symlink_name = f"{model}_{serial}".replace(" ", "_")
|
||||
symlink_path = f"{DISC_DEVICE_PATH}/{symlink_name}"
|
||||
|
||||
os.makedirs(DISC_DEVICE_PATH, exist_ok=True)
|
||||
|
||||
# Alten Symlink entfernen falls vorhanden
|
||||
if os.path.exists(symlink_path):
|
||||
os.unlink(symlink_path)
|
||||
|
||||
# Neuen Symlink erstellen
|
||||
os.symlink(f"/dev/{device_name}", symlink_path)
|
||||
|
||||
return symlink_path
|
||||
|
||||
|
||||
def delete_device_symlink(device_name: str):
|
||||
"""Lösche Symlink beim Auswurf."""
|
||||
symlink_path = f"{DISC_DEVICE_PATH}/{device_name}"
|
||||
if os.path.exists(symlink_path):
|
||||
os.unlink(symlink_path)
|
||||
|
||||
|
||||
def detect_disc_type(device_path: str) -> str:
|
||||
"""Erkenne Disc-Typ (CD/DVD/Blu-ray)."""
|
||||
try:
|
||||
result = subprocess.run(
|
||||
["isoinfo", "-d", "-i", device_path],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=10
|
||||
)
|
||||
|
||||
output = result.stdout.lower()
|
||||
|
||||
if "rock ridge" in output or "joliet" in output:
|
||||
if "blu-ray" in output:
|
||||
return "bluray"
|
||||
return "dvd"
|
||||
elif "cda" in output:
|
||||
return "cd"
|
||||
|
||||
return "unknown"
|
||||
except Exception:
|
||||
return "unknown"
|
||||
|
||||
|
||||
def create_job(device_path: str, disc_type: str):
|
||||
"""Erstelle Celery-Job für Disc-Erkennung."""
|
||||
try:
|
||||
client = get_redis_client()
|
||||
|
||||
job_data = {
|
||||
"device_path": device_path,
|
||||
"disc_type": disc_type,
|
||||
"timestamp": str(int(subprocess.check_output(["date", "+%s"]).decode().strip())),
|
||||
"status": "pending"
|
||||
}
|
||||
|
||||
client.lpush("rippy:jobs", json.dumps(job_data))
|
||||
print(f"Job erstellt: {disc_type} -> {device_path}")
|
||||
|
||||
except Exception as e:
|
||||
print(f"Fehler beim Erstellen des Jobs: {e}")
|
||||
|
||||
|
||||
def handle_add(device_name: str):
|
||||
"""Handle Disc-Einwurf."""
|
||||
print(f"Disc-Einwurf erkannt: {device_name}")
|
||||
|
||||
info = get_device_info(device_name)
|
||||
if not info:
|
||||
print("Konnte Device-Info nicht auslesen")
|
||||
return
|
||||
|
||||
symlink_path = create_device_symlink(device_name, info)
|
||||
print(f"Symlink erstellt: {symlink_path}")
|
||||
|
||||
disc_type = detect_disc_type(symlink_path)
|
||||
print(f"Disc-Typ erkannt: {disc_type}")
|
||||
|
||||
create_job(symlink_path, disc_type)
|
||||
|
||||
|
||||
def handle_remove(device_name: str):
|
||||
"""Handle Disc-Auswurf."""
|
||||
print(f"Disc-Auswurf erkannt: {device_name}")
|
||||
delete_device_symlink(device_name)
|
||||
print(f"Symlink gelöscht: /dev/disc/{device_name}")
|
||||
|
||||
|
||||
def main():
|
||||
"""Hauptfunktion."""
|
||||
if len(sys.argv) < 3:
|
||||
print("Usage: udev_daemon.py <action> <device>")
|
||||
sys.exit(1)
|
||||
|
||||
action = sys.argv[1]
|
||||
device = sys.argv[2]
|
||||
|
||||
if action == "add":
|
||||
handle_add(device)
|
||||
elif action == "remove":
|
||||
handle_remove(device)
|
||||
else:
|
||||
print(f"Unbekannte Aktion: {action}")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
Reference in New Issue
Block a user