Restore a guest from a backup in the info screen
r on the info screen restores the backup under the cursor of the backup list. - New guest (default): the next free VMID from `get cluster nextid`, editable. The original is left untouched. An id already used by a listed guest is refused. - Overwrite: replaces the guest itself. The guest must be stopped; its live status is read at apply and again right before the call. The VMID must be typed to confirm, then an "Overwrite" confirm follows. --storage is always passed. It is preselected to the storage of the guest's rootfs or first disk, and the choices are the node's storages that can hold CT (rootdir) or VM (images) disks. Start after restore is off by default, because a copy keeps the original's MAC address and IP. Safety: - cli.restore refuses force (overwrite) for any VMID other than the guest's own (explicit raise). A hypothesis property checks that no other VMID ever reaches the CLI with force. - The volid and storage are validated before they become arguments. - A backup of the other guest type is refused. A reused VMID can carry a qemu backup for a CT or the other way round, and whether PVE rejects a mismatched archive before removing the old guest is not verified. - An earlier guest's backup (visible with a) is labelled with that guest's name in the dialog and the confirm. - r before the info screen has loaded says "still loading". - Restore gets a 1-hour CLI timeout. Not run live: a live test would create a guest that then has to be deleted, which the issue reserves for the user's approval. A read-only check opened the dialog for CT 104 (next id 105, local-lvm preselected) and closed it without any call. Closes #15 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit was merged in pull request #32.
This commit is contained in:
1 parent
262441916c
commit
0df928ab11
6 files changed
+862
-3
No files matched your search
+183
-3
@@ -10,6 +10,7 @@ from textual import work
|
||||
from textual.app import App, ComposeResult, SuspendNotSupported
|
||||
from textual.binding import Binding
|
||||
from textual.containers import Horizontal, Vertical, VerticalScroll
|
||||
from textual.content import Content
|
||||
from textual.screen import ModalScreen
|
||||
from textual.widgets import (
|
||||
Button,
|
||||
@@ -21,6 +22,8 @@ from textual.widgets import (
|
||||
Label,
|
||||
Log,
|
||||
OptionList,
|
||||
RadioButton,
|
||||
RadioSet,
|
||||
Select,
|
||||
Static,
|
||||
)
|
||||
@@ -702,6 +705,83 @@ class UpdatesScreen(ModalScreen):
|
||||
self.query_one("#updates_note", Static).update("\n".join(lines))
|
||||
|
||||
|
||||
class RestoreScreen(DialogScreen):
|
||||
"""Restore one backup; dismisses with {overwrite, vmid, storage, start}, or None."""
|
||||
|
||||
BINDINGS = [("escape", "dismiss(None)", "Cancel")] # noqa: RUF012 - Textual idiom
|
||||
|
||||
def __init__(
|
||||
self, guest: dict, backup: dict, storages: list[str], chosen: str, newid: int
|
||||
) -> None:
|
||||
super().__init__()
|
||||
self.guest, self.backup = guest, backup
|
||||
self.storages, self.chosen, self.newid = storages, chosen, newid
|
||||
|
||||
def compose(self) -> ComposeResult:
|
||||
who = f"{self.guest['name']} ({self.guest['vmid']})"
|
||||
with self.box(f"Restore {_backup_label(self.backup, self.guest)}"):
|
||||
with RadioSet(id="target"):
|
||||
yield RadioButton(Content("New guest"), value=True, id="new")
|
||||
yield RadioButton(Content(f"Overwrite {who}"), id="overwrite")
|
||||
with Horizontal(classes="row"):
|
||||
yield Label("New VMID")
|
||||
yield Input(str(self.newid), id="newid")
|
||||
with Horizontal(classes="row"):
|
||||
yield Label("Storage")
|
||||
yield Select(
|
||||
[(s, s) for s in self.storages],
|
||||
value=self.chosen,
|
||||
allow_blank=False,
|
||||
id="storage",
|
||||
)
|
||||
yield Checkbox("Start after restore", id="start")
|
||||
with Horizontal(classes="row"):
|
||||
yield Label("Confirm")
|
||||
yield Input(placeholder="type the VMID to overwrite", id="confirm")
|
||||
yield Static(
|
||||
"A new copy keeps the original's MAC address and network settings "
|
||||
"(same static IP): leave Start off until they are changed.",
|
||||
markup=False,
|
||||
)
|
||||
with Horizontal(classes="buttons"):
|
||||
yield Button("Apply", id="apply", variant="primary")
|
||||
yield Button("Cancel", id="cancel")
|
||||
|
||||
def on_button_pressed(self, event: Button.Pressed) -> None:
|
||||
if event.button.id == "cancel":
|
||||
self.dismiss(None)
|
||||
return
|
||||
overwrite = self.query_one("#overwrite", RadioButton).value
|
||||
if overwrite:
|
||||
vmid = self.guest["vmid"]
|
||||
if self.query_one("#confirm", Input).value != str(vmid):
|
||||
self.app.notify(
|
||||
f"Type {vmid} to confirm the overwrite", severity="error"
|
||||
)
|
||||
return
|
||||
else:
|
||||
text = self.query_one("#newid", Input).value.strip()
|
||||
if (
|
||||
not text.isdecimal()
|
||||
or int(text) <= 0
|
||||
or str(int(text)) in self.app.guests
|
||||
):
|
||||
self.app.notify(
|
||||
"The new VMID must be a positive number not used by a guest",
|
||||
severity="error",
|
||||
)
|
||||
return
|
||||
vmid = int(text)
|
||||
self.dismiss(
|
||||
{
|
||||
"overwrite": overwrite,
|
||||
"vmid": vmid,
|
||||
"storage": self.query_one("#storage", Select).value,
|
||||
"start": self.query_one("#start", Checkbox).value,
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
class InfoScreen(ModalScreen):
|
||||
"""Backups, backup job and network of one guest (read-only), plus backup now."""
|
||||
|
||||
@@ -719,6 +799,7 @@ class InfoScreen(ModalScreen):
|
||||
("D", "delete_snapshot", "Delete snapshot"),
|
||||
("l", "tasks", "Tasks"),
|
||||
("f", "fstrim", "fstrim"),
|
||||
("r", "restore", "Restore backup"),
|
||||
]
|
||||
DEFAULT_CSS = """
|
||||
InfoScreen DataTable { height: auto; max-height: 12; }
|
||||
@@ -902,11 +983,13 @@ class InfoScreen(ModalScreen):
|
||||
note or ("" if snaps else "No snapshots")
|
||||
)
|
||||
|
||||
def _visible_backups(self) -> list[dict]:
|
||||
name = self.guest["name"]
|
||||
return [b for b in self.backups if self.show_all or not _earlier_guest(b, name)]
|
||||
|
||||
def _show_backups(self) -> None:
|
||||
name = self.guest["name"]
|
||||
shown = [
|
||||
b for b in self.backups if self.show_all or not _earlier_guest(b, name)
|
||||
]
|
||||
shown = self._visible_backups()
|
||||
table = self.query_one("#backups", DataTable).clear()
|
||||
for b in shown:
|
||||
_add_plain(
|
||||
@@ -1065,6 +1148,95 @@ class InfoScreen(ModalScreen):
|
||||
),
|
||||
)
|
||||
|
||||
def action_restore(self) -> None:
|
||||
if not self.config: # the storage preselection needs the loaded config
|
||||
self.app.notify("Info is still loading; try again in a moment")
|
||||
return
|
||||
shown = self._visible_backups()
|
||||
row = self.query_one("#backups", DataTable).cursor_row
|
||||
if not 0 <= row < len(shown):
|
||||
self.app.notify("No backups")
|
||||
elif not self._refuse_if_busy():
|
||||
self._open_restore(shown[row])
|
||||
|
||||
@work(thread=True)
|
||||
def _open_restore(self, backup: dict) -> None:
|
||||
try:
|
||||
newid = cli.next_id()
|
||||
storages = cli.restore_storages(self.guest)
|
||||
except Exception as exc: # noqa: BLE001 - surface every failure, never kill the app
|
||||
self.app.call_from_thread(self.app.notify, _message(exc), severity="error")
|
||||
return
|
||||
if not storages:
|
||||
self.app.call_from_thread(
|
||||
self.app.notify,
|
||||
"No storage can hold restored disks",
|
||||
severity="warning",
|
||||
)
|
||||
return
|
||||
disks = cli.disks(self.config, cli.kind(self.guest))
|
||||
key = "rootfs" if "rootfs" in disks else next(iter(disks), None)
|
||||
chosen = str(self.config.get(key, "")).split(":")[0]
|
||||
if chosen not in storages:
|
||||
chosen = storages[0]
|
||||
self.app.call_from_thread(
|
||||
self.app.push_screen,
|
||||
RestoreScreen(self.guest, backup, storages, chosen, newid),
|
||||
lambda values: self._restore_chosen(backup, values) if values else None,
|
||||
)
|
||||
|
||||
def _restore_chosen(self, backup: dict, values: dict) -> None:
|
||||
guest, vmid, volid = self.guest, values["vmid"], backup["volid"]
|
||||
who = f"{guest['name']} ({guest['vmid']})"
|
||||
what = (
|
||||
f"the {_backup_label(backup, self.guest)} onto storage {values['storage']}"
|
||||
)
|
||||
start = bool(values["start"])
|
||||
text = f"{'Start' if start else 'Do not start'} the guest after the restore."
|
||||
|
||||
def job() -> None:
|
||||
if values["overwrite"] and cli.status(guest) != "stopped":
|
||||
raise cli.CliError(f"{who} must be stopped to be overwritten")
|
||||
cli.restore(
|
||||
guest, volid, vmid, values["storage"], values["overwrite"], start
|
||||
)
|
||||
|
||||
def confirmed(ok: bool) -> None:
|
||||
if ok:
|
||||
label = "Overwrite" if values["overwrite"] else "Restore"
|
||||
self.app.start_job(guest, label, job)
|
||||
|
||||
if values["overwrite"]:
|
||||
self._confirm_overwrite(
|
||||
ConfirmScreen(
|
||||
f"Overwrite {who} with {what}? Its current disks and configuration "
|
||||
f"are replaced and this cannot be undone. {text}",
|
||||
"Overwrite",
|
||||
),
|
||||
confirmed,
|
||||
)
|
||||
else:
|
||||
self.app.push_screen(
|
||||
ConfirmScreen(f"Restore {what} as new guest {vmid}? {text}", "Restore"),
|
||||
confirmed,
|
||||
)
|
||||
|
||||
@work(thread=True)
|
||||
def _confirm_overwrite(self, screen: ConfirmScreen, confirmed) -> None:
|
||||
try: # live status: the guest table's status may be stale
|
||||
stopped = cli.status(self.guest) == "stopped"
|
||||
except Exception as exc: # noqa: BLE001 - surface every failure, never kill the app
|
||||
self.app.call_from_thread(self.app.notify, _message(exc), severity="error")
|
||||
return
|
||||
if stopped:
|
||||
self.app.call_from_thread(self.app.push_screen, screen, confirmed)
|
||||
else:
|
||||
self.app.call_from_thread(
|
||||
self.app.notify,
|
||||
"Stop the guest before overwriting it from a backup",
|
||||
severity="error",
|
||||
)
|
||||
|
||||
def action_fstrim(self) -> None:
|
||||
who = f"{self.guest['name']} ({self.guest['vmid']})"
|
||||
if not self.config: # running/agent are only known once the screen has loaded
|
||||
@@ -1133,6 +1305,14 @@ def _ip_changed(old: str | None, new: str | None) -> bool:
|
||||
return any(before.get(k) != after.get(k) for k in ("ip", "gw", "ip6", "gw6"))
|
||||
|
||||
|
||||
def _backup_label(backup: dict, guest: dict) -> str:
|
||||
"""'<date> backup of <name> (<vmid>)', naming a deleted earlier guest when it is one."""
|
||||
label = f"{_when(backup.get('ctime'))} backup of {guest['name']} ({guest['vmid']})"
|
||||
if _earlier_guest(backup, guest["name"]):
|
||||
label += f" - earlier guest: {backup.get('notes')}"
|
||||
return label
|
||||
|
||||
|
||||
def _earlier_guest(backup: dict, name: str) -> bool:
|
||||
"""VMIDs are reused: notes (the job's {{guestname}}) naming another guest mean a deleted one."""
|
||||
notes = backup.get("notes") or ""
|
||||
|
||||
@@ -180,6 +180,67 @@ def backup_storages(guest: dict) -> list[str]:
|
||||
]
|
||||
|
||||
|
||||
def next_id() -> int:
|
||||
return int(json.loads(run("get", "cluster", "nextid", "-o", "json")))
|
||||
|
||||
|
||||
_STORAGE = r"[A-Za-z0-9][A-Za-z0-9_.-]*"
|
||||
_VOLID = re.compile(rf"{_STORAGE}:backup/vzdump-(lxc|qemu)-[0-9]+-\S+")
|
||||
|
||||
|
||||
def valid_volid(volid: str) -> str:
|
||||
"""The volid is a positional argument: refuse anything that is not a vzdump volume."""
|
||||
if not _VOLID.fullmatch(volid):
|
||||
raise ValueError(f"not a backup volume id: {volid!r}")
|
||||
return volid
|
||||
|
||||
|
||||
def restore_storages(guest: dict) -> list[str]:
|
||||
"""Enabled, active storages on the guest's node that hold its disks, in API order."""
|
||||
content = "images" if guest["type"] == "qemu" else "rootdir"
|
||||
items = json.loads(run("get", "node", "storages", guest["node"], "-o", "json"))
|
||||
return [
|
||||
s["storage"]
|
||||
for s in items
|
||||
if content in (s.get("content") or "").split(",")
|
||||
and s.get("enabled")
|
||||
and s.get("active")
|
||||
]
|
||||
|
||||
|
||||
def restore(
|
||||
guest: dict,
|
||||
volid: str,
|
||||
vmid: int,
|
||||
storage: str,
|
||||
force: bool = False,
|
||||
start: bool = False,
|
||||
) -> None:
|
||||
"""Restore `volid` as `vmid`; `force` (overwrite) only for the guest's own VMID."""
|
||||
_positive(vmid, "vmid")
|
||||
if force and vmid != guest["vmid"]:
|
||||
raise ValueError(
|
||||
"refusing to overwrite a guest other than the one restored from"
|
||||
)
|
||||
valid_volid(volid)
|
||||
backup_type = re.search(r":backup/vzdump-(lxc|qemu)-", volid).group(1)
|
||||
if backup_type != _api_type(guest): # a reused VMID can carry the other guest type
|
||||
raise ValueError(
|
||||
f"a {backup_type} backup cannot be restored as this {kind(guest)}"
|
||||
)
|
||||
if not re.fullmatch(_STORAGE, storage):
|
||||
raise ValueError(f"invalid storage name: {storage!r}")
|
||||
opts = ["--storage", storage]
|
||||
if force:
|
||||
opts += ["--force", "1"]
|
||||
if start:
|
||||
opts += ["--start", "1"]
|
||||
run(
|
||||
"do", "restore", kind(guest), guest["node"], str(vmid), volid, *opts,
|
||||
"-y", "--wait", timeout=3600,
|
||||
) # fmt: skip
|
||||
|
||||
|
||||
def backup_jobs() -> list[dict]:
|
||||
jobs = json.loads(run("get", "cluster", "backup-jobs", "-o", "json"))
|
||||
for job in jobs: # the CLI returns prune-backups as a JSON string
|
||||
|
||||
Reference in new issue
Block a user