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>
677 lines
26 KiB
Python
677 lines
26 KiB
Python
"""Einlesen und Schreiben der pvesnap-INI-Datei."""
|
|
|
|
from __future__ import annotations
|
|
|
|
import configparser
|
|
import os
|
|
import re
|
|
from dataclasses import dataclass, field, replace
|
|
|
|
from .util import (format_bool, format_duration, format_vmid_list, parse_bool,
|
|
parse_duration, parse_free_space, parse_list, parse_vmid_list,
|
|
slugify)
|
|
|
|
DEFAULT_CONFIG_PATH = "/etc/pvesnap/pvesnap.conf"
|
|
|
|
GROUP_SECTION_RE = re.compile(r"^(?:group|gruppe)\s*[:\.]\s*(.+)$", re.IGNORECASE)
|
|
|
|
SCHEDULE_KINDS = ("hourly", "daily", "weekly", "monthly", "yearly")
|
|
|
|
WEEKDAYS = {
|
|
"mon": 0, "monday": 0, "mo": 0, "montag": 0,
|
|
"tue": 1, "tuesday": 1, "di": 1, "dienstag": 1, "tues": 1,
|
|
"wed": 2, "wednesday": 2, "mi": 2, "mittwoch": 2,
|
|
"thu": 3, "thursday": 3, "do": 3, "donnerstag": 3, "thur": 3, "thurs": 3,
|
|
"fri": 4, "friday": 4, "fr": 4, "freitag": 4,
|
|
"sat": 5, "saturday": 5, "sa": 5, "samstag": 5,
|
|
"sun": 6, "sunday": 6, "so": 6, "sonntag": 6,
|
|
}
|
|
|
|
WEEKDAY_NAMES = ["Mo", "Di", "Mi", "Do", "Fr", "Sa", "So"]
|
|
|
|
DEFAULT_DESCRIPTION = (
|
|
"pvesnap | Gruppe: {group} | erstellt: {datetime} | "
|
|
"Vorhaltezeit: {keep_time} | max: {keep_count}"
|
|
)
|
|
|
|
|
|
MAIL_PORTS = {"starttls": 587, "ssl": 465, "none": 25}
|
|
|
|
_MAIL_SECURITY = {"starttls": "starttls", "tls": "starttls", "ssl": "ssl", "smtps": "ssl",
|
|
"none": "none", "keine": "none", "off": "none", "aus": "none", "no": "none"}
|
|
|
|
|
|
class ConfigError(Exception):
|
|
"""Fehler beim Lesen oder Pruefen der Konfiguration."""
|
|
|
|
|
|
def parse_weekday(text):
|
|
value = str(text).strip().lower()
|
|
if value in WEEKDAYS:
|
|
return WEEKDAYS[value]
|
|
if value.isdigit():
|
|
number = int(value)
|
|
if 0 <= number <= 6:
|
|
return number
|
|
if number == 7:
|
|
return 6
|
|
raise ValueError("ungueltiger Wochentag: %r" % text)
|
|
|
|
|
|
def parse_time_of_day(text):
|
|
"""'02:30' -> (2, 30). Auch '2', '2:5', '02:30:00' werden akzeptiert."""
|
|
value = str(text).strip()
|
|
match = re.match(r"^(\d{1,2})(?::(\d{1,2}))?(?::(\d{1,2}))?$", value)
|
|
if not match:
|
|
raise ValueError("ungueltige Uhrzeit: %r (erwartet HH:MM)" % text)
|
|
hour = int(match.group(1))
|
|
minute = int(match.group(2) or 0)
|
|
if hour > 23 or minute > 59:
|
|
raise ValueError("ungueltige Uhrzeit: %r" % text)
|
|
return hour, minute
|
|
|
|
|
|
@dataclass
|
|
class Group:
|
|
"""Eine Snapshot-Gruppe: Zeitplan + Vorhaltezeit + VM-Auswahl."""
|
|
|
|
name: str
|
|
enabled: bool = True
|
|
|
|
# --- Zeitplan -------------------------------------------------------
|
|
interval: int = 0 # Sekunden; > 0 = intervallbasiert
|
|
schedule: str = "" # hourly/daily/weekly/monthly/yearly
|
|
at: str = "00:00" # Uhrzeit fuer daily/weekly/monthly/yearly
|
|
minute: int = 0 # Minute fuer schedule = hourly
|
|
day_of_week: int = 0 # 0 = Montag (weekly)
|
|
day_of_month: int = 1 # 1..31 (monthly/yearly)
|
|
month: int = 1 # 1..12 (yearly)
|
|
align: bool = True # Intervalle an der Uhr ausrichten
|
|
|
|
# --- Vorhaltezeit ---------------------------------------------------
|
|
keep_count: int = 0 # max. Anzahl je VM (0 = unbegrenzt)
|
|
keep_time: int = 0 # max. Alter in Sekunden (0 = unbegrenzt)
|
|
keep_min: int = 0 # so viele bleiben immer stehen
|
|
|
|
# --- Platz ----------------------------------------------------------
|
|
space_min_free: str = "20%" # so viel muss vorher frei sein (0 = aus)
|
|
space_keep: int = 7 # so viele bleiben je Gast bei Platzmangel
|
|
|
|
# --- Auswahl der Gaeste ---------------------------------------------
|
|
all: bool = False
|
|
vmids: list = field(default_factory=list)
|
|
names: list = field(default_factory=list) # Glob-Muster
|
|
tags: list = field(default_factory=list)
|
|
pools: list = field(default_factory=list)
|
|
types: list = field(default_factory=list) # qemu / lxc
|
|
exclude_vmids: list = field(default_factory=list)
|
|
exclude_names: list = field(default_factory=list)
|
|
exclude_tags: list = field(default_factory=list)
|
|
|
|
# --- Snapshot-Optionen ----------------------------------------------
|
|
vmstate: bool = False # RAM mitsichern (nur QEMU, nur laufend)
|
|
skip_stopped: bool = False # gestoppte Gaeste ueberspringen
|
|
description: str = "" # Vorlage; leer = globale Vorlage
|
|
|
|
@property
|
|
def slug(self):
|
|
"""Kurzform des Gruppennamens, taucht im Snapshot-Namen auf."""
|
|
return slugify(self.name)
|
|
|
|
def selects_anything(self):
|
|
return bool(self.all or self.vmids or self.names or self.tags or self.pools)
|
|
|
|
def validate(self):
|
|
problems = []
|
|
if not self.name.strip():
|
|
problems.append("Gruppe ohne Namen")
|
|
if self.interval <= 0 and not self.schedule:
|
|
problems.append("[%s] weder 'interval' noch 'schedule' gesetzt" % self.name)
|
|
if self.interval > 0 and self.schedule:
|
|
problems.append("[%s] 'interval' und 'schedule' schliessen sich aus" % self.name)
|
|
if self.schedule and self.schedule not in SCHEDULE_KINDS:
|
|
problems.append("[%s] unbekannter Zeitplan %r (erlaubt: %s)"
|
|
% (self.name, self.schedule, ", ".join(SCHEDULE_KINDS)))
|
|
if self.interval > 0 and self.interval < 60:
|
|
problems.append("[%s] 'interval' muss mindestens 60s betragen" % self.name)
|
|
if not self.selects_anything():
|
|
problems.append("[%s] keine VMs ausgewaehlt (all/vmids/names/tags/pools)" % self.name)
|
|
if self.keep_count <= 0 and self.keep_time <= 0:
|
|
problems.append("[%s] weder 'keep_count' noch 'keep_time' gesetzt - "
|
|
"Snapshots wuerden nie geloescht" % self.name)
|
|
for kind in self.types:
|
|
if kind not in ("qemu", "lxc"):
|
|
problems.append("[%s] unbekannter Typ %r (erlaubt: qemu, lxc)" % (self.name, kind))
|
|
try:
|
|
parse_time_of_day(self.at)
|
|
except ValueError as exc:
|
|
problems.append("[%s] %s" % (self.name, exc))
|
|
if not 1 <= self.day_of_month <= 31:
|
|
problems.append("[%s] 'day_of_month' muss zwischen 1 und 31 liegen" % self.name)
|
|
return problems
|
|
|
|
|
|
@dataclass
|
|
class GlobalConfig:
|
|
prefix: str = "auto"
|
|
check_interval: int = 60
|
|
state_file: str = "/var/lib/pvesnap/state.json"
|
|
log_level: str = "INFO"
|
|
log_file: str = ""
|
|
dry_run: bool = False
|
|
task_timeout: int = 900
|
|
run_on_start: bool = False
|
|
description: str = DEFAULT_DESCRIPTION
|
|
lock_file: str = "/run/pvesnap.lock"
|
|
retries: int = 2 # Wiederholungen bei belegten Sperren
|
|
retry_delay: int = 60 # Wartezeit dazwischen
|
|
pause_between: int = 0 # Pause zwischen zwei Gaesten
|
|
space_settle: int = 30 # Geduld, bis geloeschter Platz frei wird
|
|
|
|
def validate(self):
|
|
problems = []
|
|
if self.retries < 0:
|
|
problems.append("[global] 'retries' darf nicht negativ sein")
|
|
if self.retry_delay < 1:
|
|
problems.append("[global] 'retry_delay' muss mindestens 1 Sekunde betragen")
|
|
if self.pause_between < 0:
|
|
problems.append("[global] 'pause_between' darf nicht negativ sein")
|
|
if not re.match(r"^[A-Za-z][A-Za-z0-9]{0,15}$", self.prefix):
|
|
problems.append("[global] 'prefix' muss mit einem Buchstaben beginnen und darf "
|
|
"nur Buchstaben/Ziffern enthalten (max. 16 Zeichen)")
|
|
if self.check_interval < 5:
|
|
problems.append("[global] 'check_interval' muss mindestens 5 Sekunden betragen")
|
|
if self.log_level.upper() not in ("DEBUG", "INFO", "WARNING", "ERROR"):
|
|
problems.append("[global] unbekannter 'log_level': %s" % self.log_level)
|
|
return problems
|
|
|
|
|
|
@dataclass
|
|
class MailConfig:
|
|
"""SMTP-Zugang fuer den Alarm bei Platzmangel."""
|
|
|
|
enabled: bool = False
|
|
server: str = ""
|
|
port: int = 0 # 0 = passend zu 'security'
|
|
security: str = "starttls" # starttls | ssl | none
|
|
tls_verify: bool = True # Zertifikat des Servers pruefen
|
|
user: str = ""
|
|
password: str = ""
|
|
sender: str = ""
|
|
recipients: list = field(default_factory=list)
|
|
|
|
@property
|
|
def effective_port(self):
|
|
return self.port or MAIL_PORTS.get(self.security, 25)
|
|
|
|
def problems(self):
|
|
problems = []
|
|
if not self.server:
|
|
problems.append("[mail] kein 'server' angegeben")
|
|
if not self.sender:
|
|
problems.append("[mail] kein 'sender' angegeben")
|
|
if not self.recipients:
|
|
problems.append("[mail] keine 'recipients' angegeben")
|
|
if self.security not in MAIL_PORTS:
|
|
problems.append("[mail] unbekannte 'security': %s (erlaubt: %s)"
|
|
% (self.security, ", ".join(MAIL_PORTS)))
|
|
if not 0 <= self.port <= 65535:
|
|
problems.append("[mail] 'port' muss zwischen 1 und 65535 liegen")
|
|
if self.password and not self.user:
|
|
problems.append("[mail] 'password' ohne 'user'")
|
|
if self.user and self.security == "none":
|
|
problems.append("[mail] Anmeldung ohne Verschluesselung wuerde das Passwort "
|
|
"im Klartext senden - 'security' auf starttls oder ssl stellen")
|
|
return problems
|
|
|
|
|
|
@dataclass
|
|
class Config:
|
|
globals: GlobalConfig = field(default_factory=GlobalConfig)
|
|
mail: MailConfig = field(default_factory=MailConfig)
|
|
groups: list = field(default_factory=list)
|
|
defaults: dict = field(default_factory=dict)
|
|
path: str = DEFAULT_CONFIG_PATH
|
|
|
|
def group(self, name):
|
|
for group in self.groups:
|
|
if group.name.lower() == str(name).lower():
|
|
return group
|
|
return None
|
|
|
|
def validate(self):
|
|
from .naming import effective_slug
|
|
|
|
problems = list(self.globals.validate())
|
|
if self.mail.enabled:
|
|
problems.extend(self.mail.problems())
|
|
seen = {}
|
|
for group in self.groups:
|
|
problems.extend(group.validate())
|
|
# Der Kurzname wird im Snapshot-Namen ggf. gekuerzt - Kollisionen
|
|
# muessen also auf der gekuerzten Form geprueft werden.
|
|
slug = effective_slug(self.globals.prefix, group.slug)
|
|
if slug in seen:
|
|
problems.append("Gruppen %r und %r ergeben denselben Kurznamen %r - "
|
|
"bitte unterscheidbarer benennen" % (seen[slug], group.name, slug))
|
|
seen[slug] = group.name
|
|
return problems
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Lesen
|
|
# ---------------------------------------------------------------------------
|
|
|
|
_GROUP_KEYS = {
|
|
"enabled", "interval", "schedule", "at", "minute", "day_of_week", "day_of_month",
|
|
"month", "align", "keep_count", "keep_time", "keep_min", "all", "vmids", "names",
|
|
"tags", "pools", "types", "exclude_vmids", "exclude_names", "exclude_tags",
|
|
"vmstate", "skip_stopped", "description", "space_min_free", "space_keep",
|
|
}
|
|
|
|
# Deutsche Schreibweisen als Synonyme, damit die INI lesbar bleibt.
|
|
_KEY_ALIASES = {
|
|
"aktiv": "enabled", "aktiviert": "enabled",
|
|
"intervall": "interval",
|
|
"zeitplan": "schedule",
|
|
"uhrzeit": "at", "zeit": "at",
|
|
"wochentag": "day_of_week",
|
|
"monatstag": "day_of_month",
|
|
"monat": "month",
|
|
"anzahl": "keep_count", "max_anzahl": "keep_count", "behalte_anzahl": "keep_count",
|
|
"vorhaltezeit": "keep_time", "behalte_zeit": "keep_time", "max_alter": "keep_time",
|
|
"mindestens": "keep_min",
|
|
"mindestens_frei": "space_min_free",
|
|
"platzmangel_behalten": "space_keep",
|
|
"alle": "all",
|
|
"namen": "names",
|
|
"typen": "types",
|
|
"beschreibung": "description",
|
|
"ausschluss_vmids": "exclude_vmids",
|
|
"ausschluss_namen": "exclude_names",
|
|
"gestoppte_ueberspringen": "skip_stopped",
|
|
"verschluesselung": "security",
|
|
"benutzer": "user",
|
|
"passwort": "password",
|
|
"absender": "sender",
|
|
"empfaenger": "recipients",
|
|
}
|
|
|
|
|
|
# Kommentar am Zeilenende abschneiden ("keep_time = 7d # eine Woche").
|
|
# Absichtlich nicht ueber configparser: in Beschreibungs-Vorlagen soll ein '#'
|
|
# erhalten bleiben, deshalb wird dieser Schnitt dort nicht angewandt.
|
|
_INLINE_COMMENT = re.compile(r"\s+[#;].*$", re.DOTALL)
|
|
|
|
# Schluessel, deren Wert unangetastet bleibt (freier Text).
|
|
_VERBATIM_KEYS = {"description", "description_template", "password"}
|
|
|
|
|
|
def _strip_comment(value):
|
|
return _INLINE_COMMENT.sub("", str(value)).strip()
|
|
|
|
|
|
def _canonical_key(key):
|
|
key = key.strip().lower().replace("-", "_")
|
|
return _KEY_ALIASES.get(key, key)
|
|
|
|
|
|
def _normalize_section(section):
|
|
return {_canonical_key(k): v for k, v in section.items()}
|
|
|
|
|
|
def load_config(path=DEFAULT_CONFIG_PATH):
|
|
"""Liest die INI-Datei und gibt eine geprueft-parsierte Config zurueck."""
|
|
if not os.path.exists(path):
|
|
raise ConfigError("Konfigurationsdatei nicht gefunden: %s" % path)
|
|
|
|
parser = configparser.ConfigParser(interpolation=None)
|
|
parser.optionxform = str # Gross-/Kleinschreibung selbst behandeln
|
|
try:
|
|
with open(path, "r", encoding="utf-8") as handle:
|
|
parser.read_file(handle)
|
|
except (configparser.Error, OSError) as exc:
|
|
raise ConfigError("Konfiguration nicht lesbar (%s): %s" % (path, exc))
|
|
|
|
config = Config(path=path)
|
|
errors = []
|
|
|
|
# [global]
|
|
for name in parser.sections():
|
|
if name.strip().lower() in ("global", "allgemein", "main"):
|
|
config.globals = _parse_global(_normalize_section(parser[name]), errors)
|
|
break
|
|
|
|
# [mail]
|
|
for name in parser.sections():
|
|
if name.strip().lower() in ("mail", "smtp"):
|
|
config.mail = _parse_mail(_normalize_section(parser[name]), errors)
|
|
break
|
|
|
|
# [defaults] - Vorgabewerte fuer alle Gruppen
|
|
defaults = {}
|
|
for name in parser.sections():
|
|
if name.strip().lower() in ("defaults", "default", "vorgaben", "standard"):
|
|
defaults = _normalize_section(parser[name])
|
|
break
|
|
config.defaults = dict(defaults)
|
|
|
|
# [group:NAME]
|
|
for name in parser.sections():
|
|
match = GROUP_SECTION_RE.match(name.strip())
|
|
if not match:
|
|
continue
|
|
group_name = match.group(1).strip()
|
|
merged = dict(defaults)
|
|
merged.update(_normalize_section(parser[name]))
|
|
config.groups.append(_parse_group(group_name, merged, errors))
|
|
|
|
if errors:
|
|
raise ConfigError("Fehler in %s:\n - %s" % (path, "\n - ".join(errors)))
|
|
return config
|
|
|
|
|
|
def _parse_global(section, errors):
|
|
result = GlobalConfig()
|
|
|
|
def take(key, parser_fn, target=None):
|
|
if key not in section:
|
|
return
|
|
raw = section[key] if key in _VERBATIM_KEYS else _strip_comment(section[key])
|
|
try:
|
|
setattr(result, target or key, parser_fn(raw))
|
|
except ValueError as exc:
|
|
errors.append("[global] %s: %s" % (key, exc))
|
|
|
|
take("prefix", lambda v: str(v).strip())
|
|
take("check_interval", lambda v: parse_duration(v, default_unit="s"))
|
|
take("state_file", lambda v: str(v).strip())
|
|
take("log_level", lambda v: str(v).strip().upper())
|
|
take("log_file", lambda v: str(v).strip())
|
|
take("lock_file", lambda v: str(v).strip())
|
|
take("dry_run", parse_bool)
|
|
take("task_timeout", lambda v: parse_duration(v, default_unit="s"))
|
|
take("run_on_start", parse_bool)
|
|
take("description", lambda v: str(v).strip())
|
|
take("retries", lambda v: int(str(v).strip()))
|
|
take("retry_delay", lambda v: parse_duration(v, default_unit="s"))
|
|
take("pause_between", lambda v: parse_duration(v, default_unit="s"))
|
|
take("space_settle", lambda v: parse_duration(v, default_unit="s"))
|
|
# Synonyme
|
|
if "description_template" in section:
|
|
result.description = str(section["description_template"]).strip()
|
|
if "testlauf" in section:
|
|
try:
|
|
result.dry_run = parse_bool(section["testlauf"])
|
|
except ValueError as exc:
|
|
errors.append("[global] testlauf: %s" % exc)
|
|
|
|
for key in section:
|
|
if key not in ("prefix", "check_interval", "state_file", "log_level", "log_file",
|
|
"lock_file", "dry_run", "task_timeout", "run_on_start", "description",
|
|
"description_template", "testlauf", "retries", "retry_delay",
|
|
"pause_between", "space_settle"):
|
|
errors.append("[global] unbekannter Schluessel: %s" % key)
|
|
return result
|
|
|
|
|
|
def _parse_mail_security(text):
|
|
value = str(text).strip().lower()
|
|
if value not in _MAIL_SECURITY:
|
|
raise ValueError("erlaubt sind starttls, ssl und none, nicht %r" % text)
|
|
return _MAIL_SECURITY[value]
|
|
|
|
|
|
def _parse_mail(section, errors):
|
|
result = MailConfig()
|
|
parsers = {
|
|
"enabled": parse_bool,
|
|
"server": lambda v: str(v).strip(),
|
|
"port": lambda v: int(str(v).strip()),
|
|
"security": _parse_mail_security,
|
|
"tls_verify": parse_bool,
|
|
"user": lambda v: str(v).strip(),
|
|
"password": lambda v: str(v),
|
|
"sender": lambda v: str(v).strip(),
|
|
"recipients": parse_list,
|
|
}
|
|
for key, raw in section.items():
|
|
if key not in parsers:
|
|
errors.append("[mail] unbekannter Schluessel: %s" % key)
|
|
continue
|
|
try:
|
|
value = raw if key in _VERBATIM_KEYS else _strip_comment(raw)
|
|
setattr(result, key, parsers[key](value))
|
|
except ValueError as exc:
|
|
errors.append("[mail] %s: %s" % (key, exc))
|
|
return result
|
|
|
|
|
|
def _parse_group(name, section, errors):
|
|
group = Group(name=name)
|
|
prefix = "[group:%s]" % name
|
|
|
|
def take(key, parser_fn, target=None):
|
|
if key not in section:
|
|
return
|
|
raw = section[key] if key in _VERBATIM_KEYS else _strip_comment(section[key])
|
|
try:
|
|
setattr(group, target or key, parser_fn(raw))
|
|
except ValueError as exc:
|
|
errors.append("%s %s: %s" % (prefix, key, exc))
|
|
|
|
take("enabled", parse_bool)
|
|
take("interval", lambda v: parse_duration(v, default_unit="m"))
|
|
take("schedule", lambda v: str(v).strip().lower())
|
|
take("at", lambda v: "%02d:%02d" % parse_time_of_day(v))
|
|
take("minute", lambda v: int(str(v).strip()))
|
|
take("day_of_week", parse_weekday)
|
|
take("day_of_month", lambda v: int(str(v).strip()))
|
|
take("month", lambda v: int(str(v).strip()))
|
|
take("align", parse_bool)
|
|
|
|
take("keep_count", lambda v: max(0, int(str(v).strip())))
|
|
take("keep_time", lambda v: parse_duration(v, default_unit="d"))
|
|
take("keep_min", lambda v: max(0, int(str(v).strip())))
|
|
take("space_min_free", parse_free_space)
|
|
take("space_keep", lambda v: max(0, int(str(v).strip())))
|
|
|
|
take("all", parse_bool)
|
|
take("vmids", parse_vmid_list)
|
|
take("names", parse_list)
|
|
take("tags", lambda v: [t.lower() for t in parse_list(v)])
|
|
take("pools", parse_list)
|
|
take("types", lambda v: [t.lower() for t in parse_list(v)])
|
|
take("exclude_vmids", parse_vmid_list)
|
|
take("exclude_names", parse_list)
|
|
take("exclude_tags", lambda v: [t.lower() for t in parse_list(v)])
|
|
|
|
take("vmstate", parse_bool)
|
|
take("skip_stopped", parse_bool)
|
|
take("description", lambda v: str(v).strip())
|
|
|
|
# Bequemlichkeit: "schedule = 1h" wird als Intervall verstanden.
|
|
if group.schedule and group.schedule not in SCHEDULE_KINDS and not group.interval:
|
|
try:
|
|
group.interval = parse_duration(group.schedule, default_unit="m")
|
|
group.schedule = ""
|
|
except ValueError:
|
|
pass
|
|
|
|
for key in section:
|
|
if key not in _GROUP_KEYS:
|
|
errors.append("%s unbekannter Schluessel: %s" % (prefix, key))
|
|
|
|
return group
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Schreiben (wird vom ncurses-Editor benutzt)
|
|
# ---------------------------------------------------------------------------
|
|
|
|
_HEADER = """\
|
|
# pvesnap - Konfiguration
|
|
#
|
|
# Diese Datei wurde vom Konfigurationseditor geschrieben ("pvesnap config").
|
|
# Sie kann jederzeit auch von Hand bearbeitet werden.
|
|
#
|
|
# Zeitangaben: 30m, 1h, 6h, 2d12h, 1w ... (0 / "nie" = unbegrenzt)
|
|
# Nach Aenderungen: systemctl reload pvesnap
|
|
"""
|
|
|
|
|
|
def _group_to_lines(group, globals_):
|
|
lines = ["[group:%s]" % group.name]
|
|
lines.append("enabled = %s" % format_bool(group.enabled))
|
|
if group.schedule:
|
|
lines.append("schedule = %s" % group.schedule)
|
|
if group.schedule == "hourly":
|
|
lines.append("minute = %d" % group.minute)
|
|
else:
|
|
lines.append("at = %s" % group.at)
|
|
if group.schedule == "weekly":
|
|
lines.append("day_of_week = %s" % WEEKDAY_NAMES[group.day_of_week].lower())
|
|
if group.schedule in ("monthly", "yearly"):
|
|
lines.append("day_of_month = %d" % group.day_of_month)
|
|
if group.schedule == "yearly":
|
|
lines.append("month = %d" % group.month)
|
|
else:
|
|
lines.append("interval = %s" % format_duration(group.interval, zero="1h"))
|
|
lines.append("align = %s" % format_bool(group.align))
|
|
|
|
lines.append("keep_count = %d" % group.keep_count)
|
|
lines.append("keep_time = %s" % format_duration(group.keep_time, zero="0"))
|
|
if group.keep_min:
|
|
lines.append("keep_min = %d" % group.keep_min)
|
|
lines.append("space_min_free = %s" % group.space_min_free)
|
|
lines.append("space_keep = %d" % group.space_keep)
|
|
|
|
if group.all:
|
|
lines.append("all = yes")
|
|
if group.vmids:
|
|
lines.append("vmids = %s" % format_vmid_list(group.vmids))
|
|
if group.names:
|
|
lines.append("names = %s" % ", ".join(group.names))
|
|
if group.tags:
|
|
lines.append("tags = %s" % ", ".join(group.tags))
|
|
if group.pools:
|
|
lines.append("pools = %s" % ", ".join(group.pools))
|
|
if group.types:
|
|
lines.append("types = %s" % ", ".join(group.types))
|
|
if group.exclude_vmids:
|
|
lines.append("exclude_vmids = %s" % format_vmid_list(group.exclude_vmids))
|
|
if group.exclude_names:
|
|
lines.append("exclude_names = %s" % ", ".join(group.exclude_names))
|
|
if group.exclude_tags:
|
|
lines.append("exclude_tags = %s" % ", ".join(group.exclude_tags))
|
|
|
|
if group.vmstate:
|
|
lines.append("vmstate = yes")
|
|
if group.skip_stopped:
|
|
lines.append("skip_stopped = yes")
|
|
if group.description and group.description != globals_.description:
|
|
lines.append("description = %s" % group.description)
|
|
return lines
|
|
|
|
|
|
def _mail_to_lines(mail):
|
|
lines = ["[mail]", "enabled = %s" % format_bool(mail.enabled)]
|
|
lines.append("server = %s" % mail.server)
|
|
if mail.port:
|
|
lines.append("port = %d" % mail.port)
|
|
lines.append("security = %s" % mail.security)
|
|
if not mail.tls_verify:
|
|
lines.append("tls_verify = no")
|
|
if mail.user:
|
|
lines.append("user = %s" % mail.user)
|
|
if mail.password:
|
|
lines.append("password = %s" % mail.password)
|
|
lines.append("sender = %s" % mail.sender)
|
|
lines.append("recipients = %s" % ", ".join(mail.recipients))
|
|
return lines
|
|
|
|
|
|
def dump_config(config):
|
|
"""Erzeugt den kompletten INI-Text zu einer Config."""
|
|
g = config.globals
|
|
lines = [_HEADER, "[global]"]
|
|
lines.append("prefix = %s" % g.prefix)
|
|
lines.append("check_interval = %s" % format_duration(g.check_interval, zero="60s"))
|
|
lines.append("state_file = %s" % g.state_file)
|
|
if g.lock_file != GlobalConfig.lock_file:
|
|
lines.append("lock_file = %s" % g.lock_file)
|
|
lines.append("log_level = %s" % g.log_level)
|
|
if g.log_file:
|
|
lines.append("log_file = %s" % g.log_file)
|
|
lines.append("task_timeout = %s" % format_duration(g.task_timeout, zero="900s"))
|
|
lines.append("retries = %d" % g.retries)
|
|
lines.append("retry_delay = %s" % format_duration(g.retry_delay, zero="60s"))
|
|
lines.append("pause_between = %s" % format_duration(g.pause_between, zero="0"))
|
|
lines.append("space_settle = %s" % format_duration(g.space_settle, zero="0"))
|
|
lines.append("run_on_start = %s" % format_bool(g.run_on_start))
|
|
lines.append("dry_run = %s" % format_bool(g.dry_run))
|
|
lines.append("description = %s" % g.description)
|
|
lines.append("")
|
|
|
|
if config.mail != MailConfig():
|
|
lines.extend(_mail_to_lines(config.mail))
|
|
lines.append("")
|
|
|
|
for group in config.groups:
|
|
lines.extend(_group_to_lines(group, g))
|
|
lines.append("")
|
|
|
|
return "\n".join(lines).rstrip() + "\n"
|
|
|
|
|
|
def _open_private(path, mode):
|
|
"""Oeffnet zum Schreiben und setzt die Rechte, bevor etwas hineinkommt."""
|
|
descriptor = os.open(path, os.O_WRONLY | os.O_CREAT | os.O_TRUNC, mode)
|
|
os.fchmod(descriptor, mode)
|
|
return os.fdopen(descriptor, "w", encoding="utf-8")
|
|
|
|
|
|
def world_readable_secret(config):
|
|
"""Liegt das SMTP-Passwort in einer Datei, die jeder lesen darf?"""
|
|
try:
|
|
return bool(config.mail.password and os.stat(config.path).st_mode & 0o007)
|
|
except OSError:
|
|
return False
|
|
|
|
|
|
def save_config(config, path=None):
|
|
"""Schreibt die Config atomar; legt vorher eine .bak-Kopie an.
|
|
|
|
Die Datei kann das SMTP-Passwort enthalten, und die Kopie ein frueheres -
|
|
deshalb darf sie nur root lesen.
|
|
"""
|
|
target = path or config.path
|
|
directory = os.path.dirname(os.path.abspath(target))
|
|
os.makedirs(directory, exist_ok=True)
|
|
|
|
if os.path.exists(target):
|
|
try:
|
|
with open(target, "r", encoding="utf-8") as handle:
|
|
previous = handle.read()
|
|
with _open_private(target + ".bak", 0o600) as handle:
|
|
handle.write(previous)
|
|
except OSError:
|
|
pass # Backup ist Kuer, kein Muss
|
|
|
|
tmp = target + ".tmp"
|
|
with _open_private(tmp, 0o600 if config.mail.password else 0o640) as handle:
|
|
handle.write(dump_config(config))
|
|
handle.flush()
|
|
os.fsync(handle.fileno())
|
|
os.replace(tmp, target)
|
|
return target
|
|
|
|
|
|
def clone_group(group, new_name):
|
|
return replace(group, name=new_name,
|
|
vmids=list(group.vmids), names=list(group.names),
|
|
tags=list(group.tags), pools=list(group.pools),
|
|
types=list(group.types), exclude_vmids=list(group.exclude_vmids),
|
|
exclude_names=list(group.exclude_names),
|
|
exclude_tags=list(group.exclude_tags))
|