Files
proxmox-snapshot-service/pvesnap/config.py
T
duffyduckandClaude Opus 5.5 74be5aee59 Platz vor jedem Snapshot pruefen, bei Mangel alte loeschen, per Mail warnen
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>
2026-11-01 10:27:21 +01:00

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))