Restore a guest from a backup in the info screen
CI / pytest and ruff (pull_request) Successful in 7m25s
CI / pytest and ruff (push) Successful in 6m56s

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:
DuckbootandClaude Opus 5.5 committed 2026-11-03 01:32:05 +01:00
1 parent 262441916c
commit 0df928ab11
6 files changed
+862 -3

No files matched your search

+183 -3
View File
@@ -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 ""
+61
View File
@@ -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