From 6dd412c6e18bdb0270944e0449d86d0b51822174 Mon Sep 17 00:00:00 2001 From: alientim Date: Fri, 14 Aug 2026 01:00:15 +0200 Subject: [PATCH] Import: 'database is locked' bei LDAP-/DHCP-Import behoben; Wartungs-Update mit Einzelschritten + Fehler-Log MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit _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/.error geschrieben (ueberschreibt eine vorherige aus einem frueheren Fehlschlag desselben Clients). --- srv/tesm/app.py | 117 +++++++++++++++++++++++++++++++++++++++--------- 1 file changed, 96 insertions(+), 21 deletions(-) diff --git a/srv/tesm/app.py b/srv/tesm/app.py index 6a3ddc6..fbe93af 100644 --- a/srv/tesm/app.py +++ b/srv/tesm/app.py @@ -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):