Volle Ceph-Pools legen alle VMs darauf lahm. Vor jedem Snapshot gilt jetzt je Storage des Gastes: frei >= sofortiger Verbrauch + space_min_free (Vorgabe 20 %). Die Reserve bleibt also auch nach dem Snapshot frei. Reicht es nicht, wird der aelteste pvesnap-Snapshot des Gastes geloescht und neu geprueft, bis es reicht. Sind nur noch die neuesten space_keep (Vorgabe 7) uebrig, entfaellt der Snapshot und der naechste Gast kommt dran. Gezaehlt wird je Gast ueber alle Gruppen, anders als bei der Vorhaltezeit. Auf dem Testhost haette eine Grenze je Gruppe nie gegriffen: keep_count = 5 liegt unter 7, und 13 GiB steckten in einer Gruppe, die es nicht mehr gibt. Sofortiger Verbrauch: Dicke ZFS-Zvols muessen ihre Reservierung auch nach dem Snapshot garantieren, das kostet alles seit dem letzten Snapshot Geschriebene. Exakt ist das refreservation - usedbyrefreservation; die Werte kommen per zfs get, bei anderen Nodes per ssh ueber die IP aus dem Clusterstatus, weil Node-Namen nicht ueberall aufloesen. Dazu kommt der RAM bei vmstate. --dry-run zeigt die Rechnung je Storage. Ceph und ZFS geben Platz erst im Hintergrund frei. Deshalb wird nach jedem Loeschen gewartet, solange der Platz waechst, und erst nach space_settle (Vorgabe 30s) ohne Zuwachs weitergemacht. Ohne das wurde auf Ceph ein Snapshot zu viel geloescht. Laesst sich der Platz nicht feststellen, wird nichts geloescht und der Snapshot trotzdem versucht. Neu ist [mail]: eine Alarm-Mail je Gruppe und Lauf, wenn ein Snapshot wegen Platzmangels ausfaellt. STARTTLS oder SSL/TLS mit Zertifikatspruefung, ohne Rueckfall auf Klartext. Einzurichten im Editor (Taste m) oder von Hand, Test mit pvesnap mail-test. Liegt ein Passwort in der Datei, setzt der Editor die Rechte auf 600, auch fuer die .bak. Das Handbuch beschreibt, wie man Ceph (Ratios) und ZFS (Notreserve) frueher "voll" sagen laesst und was im Notfall zu tun ist. Ausserdem war "pvesnap run --dry-run" ueberall falsch: Die Option ist global und muss vor dem Unterbefehl stehen. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
194 lines
6.5 KiB
Python
194 lines
6.5 KiB
Python
"""Kleine Hilfsfunktionen fuer pvesnap (Dauer-Parsing, Slugs, Listen)."""
|
|
|
|
from __future__ import annotations
|
|
|
|
import re
|
|
import unicodedata
|
|
|
|
_DURATION_UNITS = {
|
|
"s": 1, "sec": 1, "secs": 1, "second": 1, "seconds": 1,
|
|
"sek": 1, "sekunde": 1, "sekunden": 1,
|
|
"m": 60, "min": 60, "mins": 60, "minute": 60, "minutes": 60, "minuten": 60,
|
|
"h": 3600, "hour": 3600, "hours": 3600, "std": 3600, "stunde": 3600, "stunden": 3600,
|
|
"d": 86400, "day": 86400, "days": 86400, "t": 86400, "tag": 86400, "tage": 86400,
|
|
"w": 604800, "week": 604800, "weeks": 604800, "woche": 604800, "wochen": 604800,
|
|
"mo": 2592000, "month": 2592000, "months": 2592000, "monat": 2592000, "monate": 2592000,
|
|
"y": 31536000, "year": 31536000, "years": 31536000,
|
|
"j": 31536000, "jahr": 31536000, "jahre": 31536000,
|
|
}
|
|
|
|
_DURATION_TOKEN = re.compile(r"(\d+(?:[.,]\d+)?)\s*([a-zäöü]*)")
|
|
|
|
_NEVER = {"0", "never", "nie", "unbegrenzt", "unlimited", "off", "aus", "keine", "-"}
|
|
|
|
|
|
def parse_duration(text, default_unit="s"):
|
|
"""'2d12h' -> 216000. 0 / 'nie' / 'unbegrenzt' -> 0 (= keine Begrenzung)."""
|
|
if text is None:
|
|
raise ValueError("leere Zeitangabe")
|
|
s = str(text).strip().lower()
|
|
if not s:
|
|
raise ValueError("leere Zeitangabe")
|
|
if s in _NEVER:
|
|
return 0
|
|
|
|
total = 0.0
|
|
pos = 0
|
|
found = False
|
|
for match in _DURATION_TOKEN.finditer(s):
|
|
if match.start() != pos:
|
|
raise ValueError("ungueltige Zeitangabe: %r" % text)
|
|
pos = match.end()
|
|
value = float(match.group(1).replace(",", "."))
|
|
unit = match.group(2) or default_unit
|
|
if unit not in _DURATION_UNITS:
|
|
raise ValueError("unbekannte Zeiteinheit %r in %r" % (unit, text))
|
|
total += value * _DURATION_UNITS[unit]
|
|
found = True
|
|
|
|
if not found or pos != len(s):
|
|
raise ValueError("ungueltige Zeitangabe: %r" % text)
|
|
return int(round(total))
|
|
|
|
|
|
def format_duration(seconds, zero="unbegrenzt"):
|
|
"""216000 -> '2d12h'. Ergebnis ist wieder von parse_duration lesbar."""
|
|
seconds = int(seconds or 0)
|
|
if seconds <= 0:
|
|
return zero
|
|
parts = []
|
|
for unit, size in (("w", 604800), ("d", 86400), ("h", 3600), ("m", 60), ("s", 1)):
|
|
if seconds >= size:
|
|
count, seconds = divmod(seconds, size)
|
|
parts.append("%d%s" % (count, unit))
|
|
return "".join(parts)
|
|
|
|
|
|
_FREE_SPACE = re.compile(r"^(\d+(?:[.,]\d+)?)\s*(%|[kmgt])?(?:i?b)?$")
|
|
_FREE_SPACE_UNITS = {"k": 1024, "m": 1024 ** 2, "g": 1024 ** 3, "t": 1024 ** 4}
|
|
|
|
|
|
def parse_free_space(text):
|
|
"""'10 %' -> '10%', '20gib' -> '20G'. 0 / 'aus' -> '0' (= keine Pruefung)."""
|
|
value = str(text if text is not None else "").strip().lower()
|
|
if value in _NEVER:
|
|
return "0"
|
|
match = _FREE_SPACE.match(value)
|
|
if not match:
|
|
raise ValueError("ungueltige Platzangabe: %r (erwartet z.B. 10%% oder 20G)" % text)
|
|
number = float(match.group(1).replace(",", "."))
|
|
unit = match.group(2) or "g"
|
|
if unit == "%" and number >= 100:
|
|
raise ValueError("Platzangabe muss unter 100%% liegen: %r" % text)
|
|
if not number:
|
|
return "0"
|
|
return "%g%s" % (number, unit.upper())
|
|
|
|
|
|
def free_space_bytes(spec, total):
|
|
"""Rechnet eine Angabe von parse_free_space in Bytes um."""
|
|
value = parse_free_space(spec)
|
|
if value == "0":
|
|
return 0
|
|
if value.endswith("%"):
|
|
return int(total * float(value[:-1]) / 100)
|
|
return int(float(value[:-1]) * _FREE_SPACE_UNITS[value[-1].lower()])
|
|
|
|
|
|
def slugify(text, maxlen=20):
|
|
"""Gruppenname -> kurzer, PVE-tauglicher Bezeichner (nur a-z0-9, beginnt mit Buchstabe)."""
|
|
# Umlaute vor der Normalisierung ersetzen - NFKD wuerde sie sonst zerlegen.
|
|
lowered = str(text).lower()
|
|
for umlaut, replacement in (("ä", "ae"), ("ö", "oe"), ("ü", "ue"), ("ß", "ss")):
|
|
lowered = lowered.replace(umlaut, replacement)
|
|
ascii_only = unicodedata.normalize("NFKD", lowered).encode("ascii", "ignore").decode("ascii")
|
|
slug = re.sub(r"[^a-z0-9]+", "", ascii_only)
|
|
if not slug:
|
|
slug = "grp"
|
|
if not slug[0].isalpha():
|
|
slug = "g" + slug
|
|
return slug[:maxlen]
|
|
|
|
|
|
_TRUE = {"1", "yes", "y", "true", "on", "ja", "j", "an", "ein", "enabled", "aktiv"}
|
|
_FALSE = {"0", "no", "n", "false", "off", "nein", "aus", "disabled", "inaktiv", ""}
|
|
|
|
|
|
def parse_bool(text, default=None):
|
|
if isinstance(text, bool):
|
|
return text
|
|
value = str(text).strip().lower()
|
|
if value in _TRUE:
|
|
return True
|
|
if value in _FALSE:
|
|
return default if (value == "" and default is not None) else False
|
|
raise ValueError("ungueltiger Ja/Nein-Wert: %r" % text)
|
|
|
|
|
|
def format_bool(value):
|
|
return "yes" if value else "no"
|
|
|
|
|
|
def parse_list(text):
|
|
"""Kommagetrennte (oder per Semikolon/Whitespace getrennte) Liste -> Liste von Strings."""
|
|
if not text:
|
|
return []
|
|
if isinstance(text, (list, tuple)):
|
|
items = list(text)
|
|
else:
|
|
items = re.split(r"[,;\s]+", str(text))
|
|
return [item.strip() for item in items if item and item.strip()]
|
|
|
|
|
|
def parse_vmid_list(text):
|
|
"""'100,101,105-108' -> [100, 101, 105, 106, 107, 108]."""
|
|
result = []
|
|
for token in parse_list(text):
|
|
match = re.match(r"^(\d+)\s*-\s*(\d+)$", token)
|
|
if match:
|
|
start, end = int(match.group(1)), int(match.group(2))
|
|
if end < start:
|
|
start, end = end, start
|
|
if end - start > 100000:
|
|
raise ValueError("VMID-Bereich zu gross: %r" % token)
|
|
result.extend(range(start, end + 1))
|
|
elif token.isdigit():
|
|
result.append(int(token))
|
|
else:
|
|
raise ValueError("ungueltige VMID: %r" % token)
|
|
return sorted(set(result))
|
|
|
|
|
|
def format_vmid_list(vmids):
|
|
"""[100,101,102,105] -> '100-102,105' (Umkehrung von parse_vmid_list)."""
|
|
ids = sorted(set(int(v) for v in vmids))
|
|
if not ids:
|
|
return ""
|
|
chunks = []
|
|
start = previous = ids[0]
|
|
for vmid in ids[1:]:
|
|
if vmid == previous + 1:
|
|
previous = vmid
|
|
continue
|
|
chunks.append((start, previous))
|
|
start = previous = vmid
|
|
chunks.append((start, previous))
|
|
out = []
|
|
for begin, end in chunks:
|
|
if end - begin >= 2:
|
|
out.append("%d-%d" % (begin, end))
|
|
else:
|
|
out.extend(str(v) for v in range(begin, end + 1))
|
|
return ",".join(out)
|
|
|
|
|
|
def truncate(text, width):
|
|
text = str(text)
|
|
if width <= 0:
|
|
return ""
|
|
if len(text) <= width:
|
|
return text
|
|
if width <= 1:
|
|
return text[:width]
|
|
return text[: width - 1] + "…"
|