Das eingebaute Austauschlaufwerk faellt weg
Es tat dasselbe wie die Transfer-Laufwerke, nur schlechter: namenlos, nicht vorher befuellbar, nicht wiederverwendbar, und es starb mit der Maschine. Zwei Mechanismen fuer dieselbe Sache sind schlechter als einer. Entfernt: --exchange, --exchange-storage, --exchange-fs, --exchange-dir, die Unterbefehle pull und release, das Formatieren und Einhaengen auf dem Host samt Mount-Merkliste. Was davon gebraucht wurde, steckt jetzt in transfer.py - dort aber benannt, dauerhaft und im laufenden Betrieb wechselbar. Aeltere Eintraege in der Merkliste stoeren nicht: ihr Austauschlaufwerk ist ein gewoehnliches Volume der Maschine und wird beim Verwerfen ohnehin von Proxmox mitgeloescht. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5
parent
0a92ec0984
commit
f2de07ecfd
+4
-307
@@ -43,8 +43,6 @@ log = logging.getLogger("pvesnap.recovery")
|
||||
TAG = "pvesnap-recovery"
|
||||
STATE_DIR = "/var/lib/pvesnap"
|
||||
REGISTRY = os.path.join(STATE_DIR, "recovery.json")
|
||||
EXCHANGE_ROOT = os.path.join(STATE_DIR, "exchange")
|
||||
EXCHANGE_MOUNT = "/run/pvesnap/exchange"
|
||||
CONF_ROOT = "/etc/pve/nodes"
|
||||
|
||||
QEMU_DISK_KEY = re.compile(r"^(ide|sata|scsi|virtio)\d+$")
|
||||
@@ -65,35 +63,6 @@ DROP_KEYS = {
|
||||
"replicate", "vmstate", "runningmachine", "runningcpu",
|
||||
}
|
||||
|
||||
DEFAULT_EXCHANGE_SIZE = "10G"
|
||||
DEFAULT_EXCHANGE_FS = "ext4"
|
||||
EXCHANGE_LABEL = "PVESNAP"
|
||||
EXCHANGE_GUEST_PATH = "/mnt/pvesnap"
|
||||
|
||||
MKFS = {
|
||||
"ext4": ["mkfs.ext4", "-q", "-m", "0", "-L", EXCHANGE_LABEL],
|
||||
"ext3": ["mkfs.ext3", "-q", "-m", "0", "-L", EXCHANGE_LABEL],
|
||||
"xfs": ["mkfs.xfs", "-q", "-L", EXCHANGE_LABEL],
|
||||
"vfat": ["mkfs.vfat", "-n", EXCHANGE_LABEL],
|
||||
"exfat": ["mkfs.exfat", "-L", EXCHANGE_LABEL],
|
||||
}
|
||||
|
||||
README = """Austauschlaufwerk von pvesnap-recovery
|
||||
|
||||
Dieses Laufwerk gehoert nicht zur urspruenglichen Maschine. Es ist leer
|
||||
angelegt worden, damit Daten aus diesem Gast wieder herauskommen - der Gast
|
||||
hat ja kein Netzwerk.
|
||||
|
||||
1. Hier hineinschreiben, z.B.
|
||||
mysqldump --all-databases > /mnt/pvesnap/dump.sql
|
||||
2. Gast herunterfahren
|
||||
3. Auf dem Proxmox-Host: pvesnap-recovery pull <VMID>
|
||||
|
||||
Danach liegen die Dateien auf dem Host und lassen sich mit
|
||||
pvesnap-explorer oder ganz normal per scp abholen.
|
||||
"""
|
||||
|
||||
|
||||
class RecoveryError(Exception):
|
||||
"""Eine Wiederherstellung liess sich nicht einrichten."""
|
||||
|
||||
@@ -474,10 +443,6 @@ class Spec:
|
||||
node: str = "" # leer = Node des Originals
|
||||
net: str = "" # leer = Vorgabe des Modus; none|down|on
|
||||
resume: object = None # None = RAM-Zustand nehmen, wenn vorhanden
|
||||
exchange: str = "" # Groesse, z.B. "10G"; leer = keins
|
||||
exchange_storage: str = ""
|
||||
exchange_fs: str = DEFAULT_EXCHANGE_FS
|
||||
exchange_dir: str = "" # nur LXC: vorhandenes Host-Verzeichnis
|
||||
transfers: list = field(default_factory=list) # Namen vorhandener Laufwerke
|
||||
memory: int = 0 # 0 = wie im Snapshot
|
||||
cores: int = 0
|
||||
@@ -504,8 +469,6 @@ class Plan:
|
||||
vmstate: str = ""
|
||||
vmstate_bytes: int = 0
|
||||
resume: bool = False
|
||||
exchange_kind: str = "" # "disk" | "bind" | ""
|
||||
exchange_detail: str = ""
|
||||
transfers: list = field(default_factory=list) # [transfer.Volume]
|
||||
warnings: list = field(default_factory=list)
|
||||
# Was nicht nur unschoen, sondern gefaehrlich ist. Hierfuer genuegt ein
|
||||
@@ -533,7 +496,6 @@ class Instance:
|
||||
created: int = 0
|
||||
volumes: list = field(default_factory=list)
|
||||
protected: list = field(default_factory=list) # [[volid, snapname]]
|
||||
exchange: dict = field(default_factory=dict)
|
||||
transfers: list = field(default_factory=list)
|
||||
resumed: bool = False
|
||||
status: str = ""
|
||||
@@ -559,14 +521,14 @@ class Instance:
|
||||
def to_dict(self):
|
||||
return {k: getattr(self, k) for k in
|
||||
("vmid", "type", "node", "name", "source", "source_node", "snapshot",
|
||||
"mode", "created", "volumes", "protected", "exchange", "transfers",
|
||||
"mode", "created", "volumes", "protected", "transfers",
|
||||
"resumed")}
|
||||
|
||||
@classmethod
|
||||
def from_dict(cls, data):
|
||||
known = {k: data.get(k) for k in
|
||||
("vmid", "type", "node", "name", "source", "source_node", "snapshot",
|
||||
"mode", "created", "volumes", "protected", "exchange", "transfers",
|
||||
"mode", "created", "volumes", "protected", "transfers",
|
||||
"resumed") if data.get(k) is not None}
|
||||
known["vmid"] = int(known.get("vmid") or 0)
|
||||
return cls(**known)
|
||||
@@ -770,37 +732,6 @@ def plan(proxmox, guest, snapname, spec):
|
||||
"gleicher IP und gleicher Identitaet im selben Netz geben Chaos - "
|
||||
"erst das Original stoppen." % guest.label)
|
||||
|
||||
# -- Austauschlaufwerk -------------------------------------------------
|
||||
if spec.exchange_dir:
|
||||
if guest.type != "lxc":
|
||||
raise RecoveryError("--exchange-dir gibt es nur fuer Container. Bei "
|
||||
"virtuellen Maschinen laesst sich kein Host-"
|
||||
"Verzeichnis durchreichen - dort --exchange <groesse> "
|
||||
"verwenden.")
|
||||
if not os.path.isdir(spec.exchange_dir):
|
||||
raise RecoveryError("Verzeichnis gibt es nicht: %s" % spec.exchange_dir)
|
||||
result.exchange_kind = "bind"
|
||||
result.exchange_detail = "%s -> %s (im Gast)" % (spec.exchange_dir,
|
||||
EXCHANGE_GUEST_PATH)
|
||||
elif spec.exchange:
|
||||
if guest.type == "lxc":
|
||||
result.exchange_kind = "bind"
|
||||
result.exchange_detail = "%s/%d -> %s (im Gast)" % (
|
||||
EXCHANGE_ROOT, result.newid, EXCHANGE_GUEST_PATH)
|
||||
else:
|
||||
size_kb = parse_size_kb(spec.exchange)
|
||||
storage = spec.exchange_storage or result.disks[0][1].split(":", 1)[0]
|
||||
if spec.exchange_fs not in MKFS:
|
||||
raise RecoveryError("Dateisystem %r kenne ich nicht (%s)"
|
||||
% (spec.exchange_fs, ", ".join(sorted(MKFS))))
|
||||
if not have(MKFS[spec.exchange_fs][0]):
|
||||
raise RecoveryError("%s ist nicht installiert - anderes Dateisystem "
|
||||
"waehlen oder Paket nachinstallieren."
|
||||
% MKFS[spec.exchange_fs][0])
|
||||
result.exchange_kind = "disk"
|
||||
result.exchange_detail = "%s auf %s, %s, im Gast als weitere Platte" % (
|
||||
human_bytes(size_kb * 1024), storage, spec.exchange_fs)
|
||||
|
||||
# -- Transfer-Laufwerke ------------------------------------------------
|
||||
for name in spec.transfers:
|
||||
volume = transfer.get(name) # wirft, wenn es das nicht gibt
|
||||
@@ -917,38 +848,6 @@ def create(proxmox, plan_, progress=None):
|
||||
new_config[key] = value
|
||||
|
||||
# -- Austauschlaufwerk ---------------------------------------------
|
||||
exchange = {}
|
||||
if plan_.exchange_kind == "disk":
|
||||
storage = spec.exchange_storage or plan_.disks[0][1].split(":", 1)[0]
|
||||
size_kb = parse_size_kb(spec.exchange)
|
||||
step("Lege Austauschlaufwerk an (%s, %s)"
|
||||
% (human_bytes(size_kb * 1024), spec.exchange_fs))
|
||||
volid = alloc_volume(storage, plan_.newid, size_kb)
|
||||
created.append(volid)
|
||||
_format_exchange(volid, spec.exchange_fs)
|
||||
slot = _free_disk_slot(new_config)
|
||||
drive = "%s,backup=0" % volid
|
||||
exchange = {"kind": "disk", "volid": volid, "key": slot,
|
||||
"fs": spec.exchange_fs, "drive": drive,
|
||||
"pending": bool(plan_.resume)}
|
||||
# Mit geladenem Arbeitsspeicher wacht der Gast in einem Zustand auf,
|
||||
# in dem es diese Platte nicht gab - er wuerde sie nie bemerken.
|
||||
# Deshalb kommt sie erst nach dem Fortsetzen dazu, per Hotplug.
|
||||
if not plan_.resume:
|
||||
new_config[slot] = drive
|
||||
elif plan_.exchange_kind == "bind":
|
||||
host_dir = spec.exchange_dir or os.path.join(EXCHANGE_ROOT,
|
||||
str(plan_.newid))
|
||||
if not spec.exchange_dir:
|
||||
os.makedirs(host_dir, exist_ok=True)
|
||||
made_dirs.append(host_dir)
|
||||
_prepare_bind_dir(host_dir, str(config.get("unprivileged")) == "1")
|
||||
step("Reiche %s in den Container durch" % host_dir)
|
||||
slot = _free_mp_slot(new_config)
|
||||
new_config[slot] = "%s,mp=%s" % (host_dir, EXCHANGE_GUEST_PATH)
|
||||
exchange = {"kind": "bind", "path": host_dir, "key": slot,
|
||||
"own": not spec.exchange_dir}
|
||||
|
||||
# -- Transfer-Laufwerke --------------------------------------------
|
||||
attached = []
|
||||
for volume in plan_.transfers:
|
||||
@@ -996,7 +895,7 @@ def create(proxmox, plan_, progress=None):
|
||||
name=str(new_config.get("name") or new_config.get("hostname") or ""),
|
||||
source=guest.vmid, source_node=guest.node, snapshot=plan_.snapshot,
|
||||
mode=spec.mode, created=int(time.time()), volumes=created,
|
||||
protected=protected, exchange=exchange, resumed=plan_.resume,
|
||||
protected=protected, resumed=plan_.resume,
|
||||
transfers=attached)
|
||||
_registry_add(instance)
|
||||
return instance
|
||||
@@ -1103,29 +1002,6 @@ def _attach_pending(proxmox, instance, step=None):
|
||||
entry["pending"] = False
|
||||
_registry_add(instance)
|
||||
|
||||
exchange = instance.exchange or {}
|
||||
if not exchange.get("pending"):
|
||||
return " ".join(notes)
|
||||
key, drive = exchange.get("key"), exchange.get("drive")
|
||||
if not key or not drive:
|
||||
return ""
|
||||
if step:
|
||||
step("Stecke Austauschlaufwerk als %s an" % key)
|
||||
try:
|
||||
proxmox.set_guest_config(instance.guest, {key: drive})
|
||||
config = proxmox.guest_config(instance.guest)
|
||||
except ProxmoxError as exc:
|
||||
notes.append("Das Austauschlaufwerk liess sich nicht anstecken: %s" % exc)
|
||||
return " ".join(notes)
|
||||
|
||||
if key not in config:
|
||||
# Proxmox hat es nur vorgemerkt - im Gast taucht es dann nicht auf.
|
||||
notes.append("Das Austauschlaufwerk ist eingetragen, aber nicht "
|
||||
"angesteckt worden (Hotplug fuer Platten ist bei dieser "
|
||||
"Maschine aus). Es erscheint erst nach einem Neustart.")
|
||||
else:
|
||||
exchange["pending"] = False
|
||||
_registry_add(instance)
|
||||
return " ".join(notes)
|
||||
|
||||
|
||||
@@ -1312,93 +1188,8 @@ def _copy_state(volid, newid):
|
||||
# Austauschlaufwerk formatieren
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def _format_exchange(volid, fstype):
|
||||
with _block_device(volid) as device:
|
||||
run(MKFS[fstype] + [device], timeout=900)
|
||||
_place_readme(device, fstype)
|
||||
|
||||
|
||||
class _block_device:
|
||||
"""Ein Volume als echtes Blockgeraet - noetig, weil `pvesm path` bei Ceph
|
||||
nur eine qemu-Adresse (rbd:pool/image:...) liefert, kein /dev/..."""
|
||||
|
||||
def __init__(self, volid):
|
||||
self.volid = volid
|
||||
self.device = ""
|
||||
self.mapped = False
|
||||
|
||||
def __enter__(self):
|
||||
storage_op("activate", self.volid, timeout=300)
|
||||
path = volume_path(self.volid)
|
||||
if path.startswith("rbd:"):
|
||||
self.device = self._map_rbd(path)
|
||||
self.mapped = True
|
||||
else:
|
||||
self.device = path
|
||||
if not os.path.exists(self.device):
|
||||
raise RecoveryError("%s ist nicht als Geraet aufgetaucht" % self.device)
|
||||
return self.device
|
||||
|
||||
def __exit__(self, *_exc):
|
||||
if self.mapped and self.device:
|
||||
run(["rbd", "unmap", self.device], check=False, timeout=120)
|
||||
return False
|
||||
|
||||
@staticmethod
|
||||
def _map_rbd(path):
|
||||
# rbd:data/vm-9802-disk-1:conf=/etc/pve/ceph.conf:id=admin:keyring=...
|
||||
fields = path[4:].split(":")
|
||||
image = fields[0]
|
||||
args = ["rbd", "map", image]
|
||||
for field_ in fields[1:]:
|
||||
key, _, value = field_.partition("=")
|
||||
if key == "id":
|
||||
args += ["--id", value]
|
||||
elif key == "keyring":
|
||||
args += ["--keyring", value]
|
||||
elif key == "conf":
|
||||
args += ["--conf", value]
|
||||
elif key == "mon_host":
|
||||
args += ["-m", value.replace(";", ",")]
|
||||
output = run(args, timeout=180)
|
||||
for line in output.splitlines():
|
||||
if line.strip().startswith("/dev/"):
|
||||
return line.strip()
|
||||
raise RecoveryError("rbd map lieferte keinen Geraetenamen: %s" % output)
|
||||
|
||||
|
||||
def _place_readme(device, fstype):
|
||||
"""Kurze Anleitung auf das leere Laufwerk legen."""
|
||||
mountpoint = os.path.join(EXCHANGE_MOUNT, "format-%d" % os.getpid())
|
||||
try:
|
||||
os.makedirs(mountpoint, exist_ok=True)
|
||||
run(["mount", "-t", fstype, device, mountpoint], timeout=120)
|
||||
except (RecoveryError, OSError) as exc:
|
||||
log.debug("Anleitung nicht ablegbar: %s", exc)
|
||||
return
|
||||
try:
|
||||
# Ohne das gehoert das Wurzelverzeichnis root, und ein gewoehnlicher
|
||||
# Benutzer im Gast kann nichts hineinschreiben - was sich anfuehlt wie
|
||||
# ein schreibgeschuetzter Datentraeger, aber keiner ist.
|
||||
try:
|
||||
os.chmod(mountpoint, 0o777)
|
||||
except OSError:
|
||||
pass
|
||||
with open(os.path.join(mountpoint, "LIESMICH.txt"), "w",
|
||||
encoding="utf-8") as handle:
|
||||
handle.write(README)
|
||||
except OSError as exc:
|
||||
log.debug("LIESMICH.txt nicht schreibbar: %s", exc)
|
||||
finally:
|
||||
run(["umount", mountpoint], check=False, timeout=120)
|
||||
try:
|
||||
os.rmdir(mountpoint)
|
||||
except OSError:
|
||||
pass
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Uebersicht, Verwerfen, Austausch abholen
|
||||
# Uebersicht und Verwerfen
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def list_instances(proxmox, refresh=True):
|
||||
@@ -1632,99 +1423,5 @@ def destroy(proxmox, instance, progress=None, keep_snapshot_protection=False):
|
||||
except transfer.TransferError as exc:
|
||||
log.warning("%s: %s", name, exc)
|
||||
|
||||
exchange = instance.exchange or {}
|
||||
if exchange.get("kind") == "bind" and exchange.get("own") and exchange.get("path"):
|
||||
step("Entferne Austauschverzeichnis %s" % exchange["path"])
|
||||
shutil.rmtree(exchange["path"], ignore_errors=True)
|
||||
|
||||
_registry_remove(instance.vmid)
|
||||
step("Fertig.")
|
||||
|
||||
|
||||
def pull(proxmox, instance):
|
||||
"""Austauschlaufwerk auf dem Host verfuegbar machen; liefert den Pfad."""
|
||||
exchange = instance.exchange or {}
|
||||
if not exchange:
|
||||
raise RecoveryError("%s hat kein Austauschlaufwerk." % instance.label)
|
||||
|
||||
if exchange.get("kind") == "bind":
|
||||
path = exchange.get("path") or ""
|
||||
if not os.path.isdir(path):
|
||||
raise RecoveryError("Austauschverzeichnis gibt es nicht: %s" % path)
|
||||
return path
|
||||
|
||||
volid = exchange.get("volid")
|
||||
if not volid:
|
||||
raise RecoveryError("Zu %s ist kein Austauschlaufwerk vermerkt."
|
||||
% instance.label)
|
||||
|
||||
status = proxmox.guest_status(instance.guest)
|
||||
if status.get("status") == "running":
|
||||
raise RecoveryError("%s laeuft noch. Erst herunterfahren - sonst schreiben "
|
||||
"Gast und Host gleichzeitig auf dasselbe Dateisystem."
|
||||
% instance.label)
|
||||
|
||||
mountpoint = os.path.join(EXCHANGE_MOUNT, str(instance.vmid))
|
||||
if os.path.ismount(mountpoint):
|
||||
return mountpoint
|
||||
os.makedirs(mountpoint, exist_ok=True)
|
||||
|
||||
holder = _block_device(volid)
|
||||
device = holder.__enter__()
|
||||
try:
|
||||
fstype = exchange.get("fs") or "auto"
|
||||
run(["mount", "-o", "ro", "-t", fstype, device, mountpoint], timeout=120)
|
||||
except RecoveryError:
|
||||
holder.__exit__()
|
||||
raise
|
||||
_remember_mount(instance.vmid, mountpoint, device, holder.mapped)
|
||||
return mountpoint
|
||||
|
||||
|
||||
def release(vmid):
|
||||
"""Ein mit pull() eingehaengtes Austauschlaufwerk wieder loesen."""
|
||||
entry = _mounts().pop(str(vmid), None)
|
||||
_save_mounts(_mounts_without(vmid))
|
||||
if not entry:
|
||||
return False
|
||||
run(["umount", entry["mountpoint"]], check=False, timeout=120)
|
||||
if entry.get("mapped"):
|
||||
run(["rbd", "unmap", entry["device"]], check=False, timeout=120)
|
||||
try:
|
||||
os.rmdir(entry["mountpoint"])
|
||||
except OSError:
|
||||
pass
|
||||
return True
|
||||
|
||||
|
||||
_MOUNTS = os.path.join("/run/pvesnap", "recovery-mounts.json")
|
||||
|
||||
|
||||
def _mounts():
|
||||
try:
|
||||
with open(_MOUNTS, "r", encoding="utf-8") as handle:
|
||||
data = json.load(handle)
|
||||
return data if isinstance(data, dict) else {}
|
||||
except (OSError, ValueError):
|
||||
return {}
|
||||
|
||||
|
||||
def _mounts_without(vmid):
|
||||
data = _mounts()
|
||||
data.pop(str(vmid), None)
|
||||
return data
|
||||
|
||||
|
||||
def _save_mounts(data):
|
||||
try:
|
||||
os.makedirs(os.path.dirname(_MOUNTS), exist_ok=True)
|
||||
with open(_MOUNTS, "w", encoding="utf-8") as handle:
|
||||
json.dump(data, handle, indent=1)
|
||||
except OSError as exc:
|
||||
log.debug("Mount-Merkliste nicht schreibbar: %s", exc)
|
||||
|
||||
|
||||
def _remember_mount(vmid, mountpoint, device, mapped):
|
||||
data = _mounts()
|
||||
data[str(vmid)] = {"mountpoint": mountpoint, "device": device, "mapped": mapped}
|
||||
_save_mounts(data)
|
||||
|
||||
Reference in New Issue
Block a user