Compare commits

...
3 Commits
Author SHA1 Message Date
alientim bc360a0b7f Produktiver WSGI-Server (Gunicorn) statt Flasks Entwicklungsserver
tesm.service startete die App bisher direkt per 'python3 app.py', was
Flasks eingebauten Werkzeug-Entwicklungsserver nutzt -- der meldet bei
jedem Start 'WARNING: This is a development server. Do not use it in a
production deployment.' und ist bewusst nicht für Dauerbetrieb gebaut.

Umgestellt auf Gunicorn, mit zwei bewusst gesetzten Einschraenkungen:
- '--workers 1': die App haelt laufende Wartungs-Jobs/Import-Vorschauen
  nur im Arbeitsspeicher DES EINEN Prozesses (_maintenance_jobs,
  _pending_imports, ...) -- mehrere Worker-Prozesse wuerden diesen
  Zustand auf mehrere, sich nicht kennende Prozesse verteilen.
- '--worker-class gthread --threads 16' statt eines async-Workers: das
  Browser-SSH-Terminal (flask-sock) belegt pro offener WebSocket-
  Verbindung einen blockierenden Thread.
- '--timeout 0': deaktiviert Gunicorns eigenen 30s-Worker-Timeout, der
  sonst auch eine offene, aber gerade inaktive SSH-Terminal-Verbindung
  killen wuerde.

Live auf POETEST verifiziert: keine WARNING-Zeile mehr, genau 1 Master-
+ 1 Worker-Prozess, alle Kernseiten weiterhin 200, und als kritischer
Regressionstest das WebSocket-SSH-Terminal Ende-zu-Ende gegen einen
echten Host erfolgreich getestet (Host-Key/Passwort-Prompt/Login/Shell).

Dabei einen unabhaengigen Altlast-Fehler in POETESTs venv gefunden und
allgemein in install.sh gefixt: './venv/bin/pip' hat eine zur
Venv-Erstellung fest einprogrammierte Shebang-Zeile -- nach der
historischen Umbenennung 'poe_manager' -> 'tesm' zeigte sie auf einen
nicht mehr existierenden Pfad und schlug mit 'cannot execute: required
file not found' fehl. 'python3 -m pip' umgeht das, da nur der (korrekt
referenzierte) venv-Python-Interpreter selbst gebraucht wird -- betrifft
sonst auch jedes kuenftige In-Place-Update auf einem aehnlich
gewachsenen Host.
2026-08-14 09:09:57 +02:00
alientim 9b3051c984 VERSION: 1.0.1
Fortlaufendes Release nach v1.0.0 -- Schema unveraendert (SCHEMA_VERSION
weiterhin 1), bereits installierte v1.0.0-Hosts aktualisieren sich beim
naechsten update.sh-Lauf per In-Place-Update (Datenbank bleibt erhalten).
2026-08-14 01:06:22 +02:00
alientim 6dd412c6e1 Import: 'database is locked' bei LDAP-/DHCP-Import behoben; Wartungs-Update mit Einzelschritten + Fehler-Log
_import_ldap()/_import_dhcp() riefen bislang set_setting() auf, das eine
EIGENE sqlite3-Verbindung oeffnet. import_apply() ruft mehrere
IMPORT_APPLIERS nacheinander auf DERSELBEN, noch offenen Verbindung auf
und committet erst am Ende -- war zu diesem Zeitpunkt (z.B. durch eine
vorher im selben Import verarbeitete Kategorie) bereits eine Schreib-
Transaktion offen, blockierte set_settings()' eigene Verbindung darauf,
bis 'sqlite3.OperationalError: database is locked' auftrat (live in
Produktion reproduziert). Beide Funktionen schreiben jetzt direkt auf
die uebergebene Verbindung. Isoliert reproduziert und verifiziert (alter
Code: Lock-Fehler: neuer Code: fehlerfrei).

Wartungs-Update fuer Linux-Clients (_apt_upgrade_command) außerdem
komplett neu aufgebaut: statt einer einzigen, mit '&&' verketteten
Befehlskette laufen jetzt einzeln benannte Schritte (apt update ->
initramfs MODULES=most dauerhaft in initramfs.conf setzen -> apt upgrade
-> apt full-upgrade -> apt clean -> apt autoremove), jeder mit eigenem
'Schritt N (...) -> erfolgreich/fehlgeschlagen (Exit-Code ...)'-Marker im
Live-Fenster (mit Zeitstempel je Zeile, wie gehabt). Bricht beim ersten
fehlgeschlagenen Schritt sofort ab. Bei einem mit Exit-Code
fehlgeschlagenen Update wird zusaetzlich eine Fehler-Logdatei unter
/var/log/tesm/clientupdate/<clientname>.error geschrieben (ueberschreibt
eine vorherige aus einem frueheren Fehlschlag desselben Clients).
2026-08-14 01:00:15 +02:00
5 changed files with 131 additions and 26 deletions
+10 -2
View File
@@ -6,8 +6,16 @@ After=network.target
Type=simple
User=root
WorkingDirectory=/srv/tesm
# Nutze die virtuelle Umgebung
ExecStart=/srv/tesm/venv/bin/python3 /srv/tesm/app.py
# Produktiver WSGI-Server (gunicorn) statt Flasks eigenem app.run()-
# Entwicklungsserver -- siehe requirements.txt für die ausführliche
# Begründung von "--workers 1" (In-Memory-Zustand) und "--worker-class
# gthread" (blockierende WebSocket-Threads für das Browser-SSH-Terminal).
# "--timeout 0" deaktiviert Gunicorns Worker-Timeout: der greift sonst
# nach 30s auch bei einer offenen, aber gerade inaktiven SSH-Terminal-
# WebSocket-Verbindung und würde den Worker (und damit JEDE Verbindung,
# da nur ein Prozess läuft) killen.
ExecStart=/srv/tesm/venv/bin/gunicorn --workers 1 --worker-class gthread --threads 16 \
--timeout 0 --bind 0.0.0.0:5000 app:app
Restart=always
RestartSec=5
+11 -2
View File
@@ -45,11 +45,20 @@ sudo rsync -a --delete --exclude 'venv' --exclude 'sqlite.db' --exclude 'fernet.
print_status "Application files copied"
# ---- Python venv ----
# "python3 -m pip" statt des "pip"-Skripts im venv selbst: dessen Shebang
# enthält einen zur Erstellungszeit FEST einprogrammierten, absoluten Pfad
# zum venv-Python. Wird das venv-Verzeichnis später umbenannt/verschoben
# (wie hier historisch bei der Umbenennung "poe_manager" -> "tesm"
# passiert), bleibt die alte, nicht mehr existierende Pfadangabe stehen --
# "./venv/bin/pip install ..." schlägt dann mit "cannot execute: required
# file not found" fehl (live auf POETEST reproduziert), obwohl das venv
# selbst voll funktionsfähig ist. "python3 -m pip" braucht dagegen nur den
# (korrekt referenzierten) venv-Python-Interpreter selbst.
step "Setting up Python virtual environment"
cd /srv/tesm
sudo python3 -m venv venv
sudo ./venv/bin/pip install --upgrade pip >>/var/log/tesm-install.log 2>&1
sudo ./venv/bin/pip install -r requirements.txt >>/var/log/tesm-install.log 2>&1
sudo ./venv/bin/python3 -m pip install --upgrade pip >>/var/log/tesm-install.log 2>&1
sudo ./venv/bin/python3 -m pip install -r requirements.txt >>/var/log/tesm-install.log 2>&1
print_status "Virtual environment ready"
# ---- Datenbank ----
+1 -1
View File
@@ -1 +1 @@
1.0.0
1.0.2
+96 -21
View File
@@ -4448,9 +4448,22 @@ def _import_groups(conn, items):
def _import_ldap(conn, data):
# set_setting() bewusst NICHT verwendet: das öffnet eine EIGENE
# sqlite3-Verbindung -- import_apply() ruft mehrere IMPORT_APPLIERS
# nacheinander auf DERSELBEN, bereits offenen "conn" auf und committet
# erst ganz am Ende. Ist zu diesem Zeitpunkt (z.B. durch eine vorher im
# selben Import verarbeitete Kategorie) bereits eine Schreib-Transaktion
# auf "conn" offen, blockiert set_settings()' eigene Verbindung darauf,
# bis der SQLite-Standard-Timeout zuschlägt -- "sqlite3.OperationalError:
# database is locked", live in Produktion reproduziert. Direkt auf der
# übergebenen "conn" schreiben vermeidet die zweite Verbindung komplett.
for key, value in data.get("settings", {}).items():
if key in LDAP_SETTING_KEYS:
set_setting(key, value)
conn.execute(
"INSERT INTO settings (key, value) VALUES (?, ?) "
"ON CONFLICT(key) DO UPDATE SET value=excluded.value",
(key, value),
)
if data.get("bind_username"):
conn.execute(
"INSERT INTO service_accounts (purpose, username, password) VALUES (?, ?, ?) "
@@ -4469,9 +4482,16 @@ def _import_ldap(conn, data):
def _import_dhcp(conn, data):
# set_setting() bewusst NICHT verwendet -- siehe ausführlicher Kommentar
# in _import_ldap() (dieselbe "database is locked"-Falle bei mehreren
# IMPORT_APPLIERS auf derselben offenen Transaktion).
for key, value in data.get("settings", {}).items():
if key in DHCP_DEFAULTS:
set_setting(key, value)
conn.execute(
"INSERT INTO settings (key, value) VALUES (?, ?) "
"ON CONFLICT(key) DO UPDATE SET value=excluded.value",
(key, value),
)
n = 0
# Subnetze additiv (nie löschen/ersetzen) -- ein bestehendes, unabhängig
# vom Ziel-Admin konfiguriertes Subnetz soll ein Import nie entfernen.
@@ -5596,6 +5616,27 @@ def maintenance_status():
_MAINTENANCE_OUTPUT_MAX_LINES = 400
TESM_CLIENT_UPDATE_LOG_DIR = os.path.join(TESM_LOG_DIR, "clientupdate")
def _write_client_update_error_log(name, exit_code, lines):
"""Schreibt bei einem mit Exit-Code fehlgeschlagenen Wartungs-Update
eine eigene Fehler-Logdatei pro Client (überschreibt eine vorherige aus
einem früheren Fehlschlag desselben Clients) -- zusätzlich zur ohnehin
im Job-Status/Live-Fenster sichtbaren Ausgabe, damit ein Fehlschlag
auch ohne offenes Browser-Fenster im Nachhinein auffindbar bleibt."""
try:
os.makedirs(TESM_CLIENT_UPDATE_LOG_DIR, exist_ok=True)
os.chmod(TESM_CLIENT_UPDATE_LOG_DIR, 0o777)
safe_name = secure_filename(name) or "unbenannt"
path = os.path.join(TESM_CLIENT_UPDATE_LOG_DIR, f"{safe_name}.error")
with open(path, "w", encoding="utf-8") as f:
f.write(f"Update fehlgeschlagen: {datetime.now().strftime('%Y-%m-%d %H:%M:%S')}\n")
f.write(f"Exit-Code: {exit_code}\n\n")
f.write("\n".join(lines))
f.write("\n")
except OSError:
app.logger.error("_write_client_update_error_log failed:\n%s", traceback.format_exc())
def _maintenance_run_update(mac, name, host, port, username, password):
@@ -5647,6 +5688,7 @@ def _maintenance_run_update(mac, name, host, port, username, password):
message=f"Update fehlgeschlagen (Exit-Code {result['exit_code']}).",
output="\n".join(lines), finished=finished,
)
_write_client_update_error_log(name, result["exit_code"], lines)
log_action_system("maintenance.update", name, f"fehlgeschlagen: Exit-Code {result['exit_code']}")
@@ -6108,31 +6150,64 @@ def _ssh_terminal_inner(ws):
# ihn möglich sind -- beide Wege teilen sich dieselbe SSH_KNOWN_HOSTS_PATH.
# ---------------------------------------------------------------------------
# Jeder Schritt einzeln benannt (statt nur mit "&&" verkettet), damit nach
# jedem Schritt ein eigener "Schritt N -> erfolgreich/fehlgeschlagen"-Marker
# ins Live-Log geschrieben werden kann (siehe _apt_upgrade_command()) --
# apt/apt-get statt der literal vorgegebenen "apt"-Aufrufe, weil apt-get
# eine stabile, für Skripte gedachte Schnittstelle hat (die apt(8)-Manpage
# warnt ausdrücklich: "the apt command is intended to be pleasant for
# end users and does not need to be backward compatible" -- also nichts
# für Automatisierung).
_LINUX_UPDATE_STEPS = [
("apt update", "apt-get update"),
("initramfs MODULES=most setzen", "sed -i 's/^MODULES=.*/MODULES=most/' /etc/initramfs-tools/initramfs.conf"),
(
"apt upgrade",
'apt-get -y -o Dpkg::Options::="--force-confdef" -o Dpkg::Options::="--force-confold" upgrade',
),
(
"apt full-upgrade",
'apt-get -y -o Dpkg::Options::="--force-confdef" -o Dpkg::Options::="--force-confold" full-upgrade',
),
("apt clean", "apt-get clean"),
("apt autoremove", "apt-get -y autoremove"),
]
def _apt_upgrade_command():
"""Sicheres, nicht-interaktives 'apt update && apt upgrade -y':
"""Baut die Wartungs-Update-Kette aus _LINUX_UPDATE_STEPS zusammen --
jeder Schritt einzeln mit eigenem "=== Schritt N/M: ... ===" und
danach "Schritt N (...) -> erfolgreich"/"-> fehlgeschlagen (Exit-Code
...)" im Live-Log, statt nur einer einzigen Erfolgs-/Fehlermeldung für
die ganze Kette. Bricht beim ersten fehlgeschlagenen Schritt sofort ab
(dessen Exit-Code wird zum Exit-Code des Gesamtkommandos), statt
blind mit dem nächsten Schritt weiterzumachen.
DEBIAN_FRONTEND=noninteractive plus --force-confdef/--force-confold
verhindern, dass ein Paket-Postinst-Skript auf eine Konfig-Datei-
Rückfrage wartet, die nie kommt (das Kommando würde sonst ohne PTY
unbeaufsichtigt hängen bleiben, bis der Timeout zuschlägt).
MODULES=most (initramfs-tools) wird nur für diesen einen Aufruf per
Umgebungsvariable gesetzt, nicht dauerhaft in /etc/initramfs-tools/
initramfs.conf geändert -- falls das Update einen neuen Kernel mitbringt
und dabei ein initramfs neu gebaut wird, landen so vorsorglich ALLE
verfügbaren Treiber darin (statt nur die per "dep" erkannten), damit das
Gerät nach einem Kernel-Wechsel zuverlässig bootet, auch wenn sich z.B.
Storage-/Netzwerk-Hardware nicht sauber per Abhängigkeitserkennung
ermitteln lässt. Am Ende noch "apt-get autoremove"/"apt-get clean", damit
nicht mehr benötigte Kernel/Pakete sowie der heruntergeladene .deb-Cache
nicht auf jedem Client unbegrenzt Platz belegen."""
inner = (
"env DEBIAN_FRONTEND=noninteractive MODULES=most apt-get update && "
'env DEBIAN_FRONTEND=noninteractive MODULES=most apt-get -y '
'-o Dpkg::Options::="--force-confdef" -o Dpkg::Options::="--force-confold" upgrade && '
"env DEBIAN_FRONTEND=noninteractive apt-get -y autoremove && "
"apt-get clean"
)
return f"sudo -S -p '' bash -c {shlex.quote(inner)}"
MODULES=most wird -- anders als zuvor -- dauerhaft in
/etc/initramfs-tools/initramfs.conf gesetzt (nicht nur für diesen einen
Aufruf per Umgebungsvariable): falls apt upgrade/full-upgrade einen
neuen Kernel installiert und dabei ein initramfs neu gebaut wird,
landen so ALLE verfügbaren Treiber darin (statt nur die per "dep"
erkannten), damit das Gerät nach einem Kernel-Wechsel zuverlässig
bootet -- und das bleibt auch für jeden künftigen, unabhängig von
dieser App ausgelösten Kernel-Update/initramfs-Rebuild so bestehen."""
script_lines = ["export DEBIAN_FRONTEND=noninteractive"]
total = len(_LINUX_UPDATE_STEPS)
for i, (label, cmd) in enumerate(_LINUX_UPDATE_STEPS, start=1):
script_lines.append(f"echo '=== Schritt {i}/{total}: {label} ==='")
script_lines.append(cmd)
script_lines.append("_rc=$?")
script_lines.append(
f'if [ "$_rc" -eq 0 ]; then echo "Schritt {i} ({label}) -> erfolgreich"; '
f'else echo "Schritt {i} ({label}) -> fehlgeschlagen (Exit-Code $_rc)"; exit "$_rc"; fi'
)
script = "\n".join(script_lines)
return f"sudo -S -p '' bash -c {shlex.quote(script)}"
def _resolve_device_ssh_target(conn, device):
+13
View File
@@ -7,6 +7,7 @@ Flask==3.1.2
Flask-Bcrypt==1.0.1
Flask-Login==0.6.3
Flask-Sock==0.7.0
gunicorn==23.0.0
itsdangerous==2.2.0
Jinja2==3.1.6
ldap3==2.9.1
@@ -31,3 +32,15 @@ Werkzeug==3.1.3
# ldap3 (reines Python, keine System-Bibliothek wie libldap nötig) für die
# optionale Active-Directory/LDAP-Anmeldung (Systemeinstellungen → LDAP) —
# Search+Bind gegen einen AD-Domain-Controller oder generischen LDAP-Server.
#
# gunicorn: produktiver WSGI-Server für tesm.service (siehe dort) statt
# Flasks eigenem app.run()-Entwicklungsserver ("WARNING: This is a
# development server..."). Läuft bewusst mit genau EINEM Worker-Prozess,
# aber mehreren Threads (--worker-class gthread) -- mehrere Prozesse
# würden den In-Memory-Zustand für laufende Wartungs-Jobs/Import-
# Vorschauen (_maintenance_jobs/_pending_imports/...) auf mehrere,
# voneinander isolierte Prozesse verteilen, wodurch eine Anfrage, die vom
# jeweils ANDEREN Worker beantwortet wird, diesen Zustand nie sehen würde.
# Mehrere Threads (statt eines async-Workers) sind nötig, weil das
# Browser-SSH-Terminal (flask-sock) pro offener WebSocket-Verbindung einen
# blockierenden Thread belegt.