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).
This commit is contained in:
2026-08-14 01:00:15 +02:00
parent 3c3fbd8f23
commit 6dd412c6e1
+95 -20
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"
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'
)
return f"sudo -S -p '' bash -c {shlex.quote(inner)}"
script = "\n".join(script_lines)
return f"sudo -S -p '' bash -c {shlex.quote(script)}"
def _resolve_device_ssh_target(conn, device):