mirror of
https://github.com/Joulenap/joulenap.git
synced 2026-08-11 13:21:43 +02:00
feat(jobs): multi-source, cluster-aware backup route cycle
One run now executes one backup route: N source PVEs (each possibly a cluster) onto one PBS target. Wake and power-off stay with the power lease, so the cycle starts with the box awake and never touches its power. - PveClient lists guests cluster-wide via /cluster/resources (identical on a standalone node), tags each with its node and drops templates; vzdump takes a node, and the task endpoints read theirs from the UPID so one client can drive several nodes. - Sources are isolated: a broken one leaves its backup:<pve-id> step failed and the run continues, finishing failed and naming it. GC and verify still run - the box is awake and the other snapshots are real. - The guest tally aggregates across sources; failed guest names keep working. - Cache writes carry real ids: datastore_stats keyed by the target, guest_backups attributing each vmid to the PVE that backed it up. - CycleDeps gains device-shaped connect_pve/connect_pbs beside the config-shaped pair, and RunRecorder.step takes a label. The 0.9 cycle and the flat pve/pbs/backup config sections stay for now: their other consumers (the GC/verify/monitor cycles, the scheduler, the API routers) are ported in the next milestones and delete both halves together.
This commit is contained in:
@@ -27,6 +27,10 @@ class Guest:
|
||||
name: str
|
||||
type: str # "qemu" (VM) or "lxc" (CT)
|
||||
status: str # "running" | "stopped" | ...
|
||||
# Which cluster node holds it. Empty from the per-node listing (the caller knows the
|
||||
# node already); filled in by :meth:`PveClient.list_cluster_guests`, which is what lets
|
||||
# a route group its vzdump calls per node.
|
||||
node: str = ""
|
||||
|
||||
@property
|
||||
def is_ct(self) -> bool:
|
||||
@@ -51,9 +55,12 @@ class PveClient:
|
||||
def __init__(
|
||||
self,
|
||||
host: str,
|
||||
node: str,
|
||||
token_id: str,
|
||||
token_secret: str,
|
||||
# A route's PVE device has no node (nodes are discovered at runtime): the node-scoped
|
||||
# endpoints below are the 0.9 path and the wizard's, and the task endpoints read the
|
||||
# node from the UPID instead.
|
||||
node: str = "",
|
||||
port: int = 8006,
|
||||
verify_tls: bool = False,
|
||||
timeout: float = 30.0,
|
||||
@@ -110,6 +117,32 @@ class PveClient:
|
||||
guests.sort(key=lambda g: g.vmid)
|
||||
return guests
|
||||
|
||||
def list_cluster_guests(self) -> list[Guest]:
|
||||
"""Every VM/CT this endpoint knows about, each tagged with the node holding it.
|
||||
|
||||
One call covers a whole cluster — the endpoint proxies its nodes — and works just as
|
||||
well against a standalone node, which the API simply reports as a one-node cluster.
|
||||
Templates are dropped: they are never backed up, so counting them would inflate the
|
||||
guest tally and naming one explicitly would fail the vzdump task.
|
||||
"""
|
||||
rows = self._api.request("GET", "/cluster/resources", params={"type": "vm"}) or []
|
||||
guests: list[Guest] = []
|
||||
for r in rows:
|
||||
if r.get("template") or r.get("vmid") is None:
|
||||
continue
|
||||
kind = r.get("type") or ""
|
||||
guests.append(
|
||||
Guest(
|
||||
vmid=int(r["vmid"]),
|
||||
name=r.get("name") or f"{kind}-{r['vmid']}",
|
||||
type=kind,
|
||||
status=r.get("status", "unknown"),
|
||||
node=r.get("node", ""),
|
||||
)
|
||||
)
|
||||
guests.sort(key=lambda g: g.vmid)
|
||||
return guests
|
||||
|
||||
# --- backup --------------------------------------------------------------
|
||||
|
||||
def vzdump(
|
||||
@@ -121,10 +154,13 @@ class PveClient:
|
||||
mode: str = "snapshot",
|
||||
prune_backups: str | None = None,
|
||||
bwlimit: int = 0,
|
||||
node: str = "",
|
||||
) -> str:
|
||||
"""Start a vzdump backup; returns the task UPID to poll with :meth:`wait_task`.
|
||||
|
||||
Either pass ``vmids`` (explicit selection) or ``all_guests=True``.
|
||||
Either pass ``vmids`` (explicit selection) or ``all_guests=True``. ``node`` picks the
|
||||
cluster node to run it on (a backup route starts one task per node); it defaults to
|
||||
the client's own node.
|
||||
"""
|
||||
params: dict[str, Any] = {"storage": storage, "mode": mode}
|
||||
if all_guests:
|
||||
@@ -135,12 +171,24 @@ class PveClient:
|
||||
params["prune-backups"] = prune_backups
|
||||
if bwlimit:
|
||||
params["bwlimit"] = bwlimit
|
||||
return self._api.request("POST", f"/nodes/{self.node}/vzdump", data=params)
|
||||
return self._api.request("POST", f"/nodes/{node or self.node}/vzdump", data=params)
|
||||
|
||||
# --- tasks ---------------------------------------------------------------
|
||||
|
||||
def _task_node(self, upid: str) -> str:
|
||||
"""The node a task runs on, read from its own UPID (``UPID:<node>:<pid>:…``).
|
||||
|
||||
One client can drive tasks on several nodes (a cluster route starts one vzdump per
|
||||
node), so the node has to come from the task rather than from the client. Falls back
|
||||
to the client's node if the string isn't a UPID.
|
||||
"""
|
||||
parts = upid.split(":")
|
||||
if len(parts) > 2 and parts[0] == "UPID" and parts[1]:
|
||||
return parts[1]
|
||||
return self.node
|
||||
|
||||
def task_status(self, upid: str) -> dict[str, Any]:
|
||||
return self._api.request("GET", f"/nodes/{self.node}/tasks/{upid}/status")
|
||||
return self._api.request("GET", f"/nodes/{self._task_node(upid)}/tasks/{upid}/status")
|
||||
|
||||
def task_log(self, upid: str, start: int = 0, limit: int = 5000) -> list[LogLine]:
|
||||
"""Fetch task-log lines starting at offset ``start``, as ``(line_no, text)`` pairs.
|
||||
@@ -150,7 +198,7 @@ class PveClient:
|
||||
"""
|
||||
data = self._api.request(
|
||||
"GET",
|
||||
f"/nodes/{self.node}/tasks/{upid}/log",
|
||||
f"/nodes/{self._task_node(upid)}/tasks/{upid}/log",
|
||||
params={"start": start, "limit": limit},
|
||||
)
|
||||
return [(int(e["n"]), e.get("t") or "") for e in (data or [])]
|
||||
@@ -161,7 +209,7 @@ class PveClient:
|
||||
Without this a cancelled backup would keep running on the PVE host after Joulenap
|
||||
stopped watching it, and the next run would collide with it.
|
||||
"""
|
||||
self._api.request("DELETE", f"/nodes/{self.node}/tasks/{upid}")
|
||||
self._api.request("DELETE", f"/nodes/{self._task_node(upid)}/tasks/{upid}")
|
||||
|
||||
def wait_task(
|
||||
self,
|
||||
|
||||
@@ -144,7 +144,9 @@ class RunStep(Base):
|
||||
|
||||
id: Mapped[int] = mapped_column(primary_key=True)
|
||||
run_id: Mapped[int] = mapped_column(ForeignKey("runs.id", ondelete="CASCADE"))
|
||||
name: Mapped[str] = mapped_column(String(16))
|
||||
# A StepName value, optionally suffixed with what it applied to: a backup route records
|
||||
# one step per source PVE, ``backup:pve-alpha``.
|
||||
name: Mapped[str] = mapped_column(String(64))
|
||||
status: Mapped[str] = mapped_column(String(16), default=StepStatus.RUNNING)
|
||||
|
||||
started_at: Mapped[datetime] = mapped_column(UtcDateTime(), default=_utcnow)
|
||||
@@ -188,7 +190,7 @@ class TaskLogLine(Base):
|
||||
|
||||
id: Mapped[int] = mapped_column(primary_key=True)
|
||||
run_id: Mapped[int] = mapped_column(ForeignKey("runs.id", ondelete="CASCADE"))
|
||||
step: Mapped[str] = mapped_column(String(16)) # StepName value (backup/gc/verify)
|
||||
step: Mapped[str] = mapped_column(String(64)) # RunStep.name (e.g. gc, backup:pve-alpha)
|
||||
source: Mapped[str] = mapped_column(String(8)) # "pve" | "pbs"
|
||||
line_no: Mapped[int] = mapped_column() # the task's own 1-based line number
|
||||
text: Mapped[str] = mapped_column(Text)
|
||||
|
||||
@@ -15,7 +15,7 @@ import time
|
||||
from collections.abc import Callable
|
||||
from typing import Any, Protocol
|
||||
|
||||
from ..config import Config
|
||||
from ..config import Config, PbsDevice, Route, RouteGuests, RouteSource
|
||||
from ..connectors.errors import TaskCancelled
|
||||
from ..connectors.pbs import DatastoreStatus
|
||||
from ..connectors.pve import Guest, build_prune_string
|
||||
@@ -63,7 +63,7 @@ def _guest_watcher(summary: GuestSummary, names: dict[int, str]):
|
||||
return watch
|
||||
|
||||
|
||||
def _tailer(recorder: RunRecorder, step: StepName, source: str, watch=None):
|
||||
def _tailer(recorder: RunRecorder, step: str, source: str, watch=None):
|
||||
"""A ``wait_task(on_log=...)`` callback that persists each task-log batch.
|
||||
|
||||
Best-effort: a failure to store a log line must never fail an otherwise-fine backup,
|
||||
@@ -106,7 +106,7 @@ def _wait_or_stop(
|
||||
upid: str,
|
||||
recorder: RunRecorder,
|
||||
deps: CycleDeps,
|
||||
step: StepName,
|
||||
step: str,
|
||||
source: str,
|
||||
watch: Callable[[list[tuple[int, str]]], None] | None = None,
|
||||
) -> None:
|
||||
@@ -652,3 +652,298 @@ def _notify_result(
|
||||
deps.notify(config, recorder.run, datastore, guests, deps.next_run())
|
||||
except Exception as exc:
|
||||
recorder.log(LogLevel.WARN, f"notification failed: {exc}")
|
||||
|
||||
|
||||
# --- v1.0 route cycle --------------------------------------------------------
|
||||
#
|
||||
# One run executes one **backup route**: N source PVEs (each possibly a cluster) -> one PBS
|
||||
# target. Wake and power-off are deliberately absent — ``JobService._execute`` takes a
|
||||
# :class:`~.lease.PowerLease` on the target around the job (M04), so the cycle starts with
|
||||
# the box already awake and ends without touching its power. Everything else below mirrors
|
||||
# the 0.9 cycle above, which it replaces once the flat ``pve:``/``pbs:``/``backup:`` config
|
||||
# sections and their other consumers are gone (M06/M07).
|
||||
|
||||
|
||||
def _find_device(devices, device_id: str):
|
||||
return next((d for d in devices if d.id == device_id), None)
|
||||
|
||||
|
||||
def _guests_by_node(selection: RouteGuests, guests: list[Guest]) -> dict[str, list[int]]:
|
||||
"""Group the guests this source wants by the cluster node holding them — a route runs
|
||||
one vzdump per node, and only on nodes that actually have something to back up."""
|
||||
wanted = None if selection.mode == "all" else set(selection.list)
|
||||
picked: dict[str, list[int]] = {}
|
||||
for guest in guests:
|
||||
if wanted is None or guest.vmid in wanted:
|
||||
picked.setdefault(guest.node, []).append(guest.vmid)
|
||||
return picked
|
||||
|
||||
|
||||
def _route_backup_source(
|
||||
config: Config,
|
||||
route: Route,
|
||||
source: RouteSource,
|
||||
recorder: RunRecorder,
|
||||
deps: CycleDeps,
|
||||
summary: GuestSummary,
|
||||
step,
|
||||
) -> set[int]:
|
||||
"""Back up one source PVE onto the route's target: one vzdump per cluster node.
|
||||
|
||||
Returns the vmids this source covered, so the last-backup cache can attribute each guest
|
||||
to the PVE it came from. Raises on failure — the caller records that against this
|
||||
source's step and moves on to the next source.
|
||||
"""
|
||||
pve = _find_device(config.pves, source.pve)
|
||||
if pve is None:
|
||||
raise CycleAbort(f"source pve '{source.pve}' no longer exists")
|
||||
storage = pve.storages.get(route.target)
|
||||
if not storage:
|
||||
raise CycleAbort(
|
||||
f"pve '{pve.id}' has no storage mapping for pbs '{route.target}' "
|
||||
"(Datacenter > Storage)"
|
||||
)
|
||||
|
||||
prune = build_prune_string(route.retention.model_dump())
|
||||
step_name = f"{StepName.BACKUP.value}:{source.pve}"
|
||||
upids: list[str] = []
|
||||
covered: set[int] = set()
|
||||
|
||||
with deps.connect_pve(pve) as client:
|
||||
# One cluster-wide listing feeds all three needs: the per-node grouping, the guest
|
||||
# tally and the {vmid: name} map the notification names failed guests with.
|
||||
guests = client.list_cluster_guests()
|
||||
names = {g.vmid: g.name for g in guests}
|
||||
per_node = _guests_by_node(source.guests, guests)
|
||||
# "all" stays vzdump's own ``all`` flag rather than the vmids we just listed, so PVE
|
||||
# keeps deciding: a guest marked *exclude from backup* is honoured, and one created
|
||||
# since the listing is still covered.
|
||||
all_guests = source.guests.mode == "all"
|
||||
if not all_guests:
|
||||
missing = sorted(set(source.guests.list) - {g.vmid for g in guests})
|
||||
if missing:
|
||||
recorder.log(
|
||||
LogLevel.WARN,
|
||||
f"pve '{pve.id}': selected guest(s) {missing} are not on it "
|
||||
"(deleted, a template, or migrated away); skipping them",
|
||||
)
|
||||
if not per_node:
|
||||
raise CycleAbort(f"pve '{pve.id}': no guests selected for backup")
|
||||
|
||||
for node, vmids in per_node.items():
|
||||
upid = client.vzdump(
|
||||
storage,
|
||||
node=node,
|
||||
vmids=None if all_guests else vmids,
|
||||
all_guests=all_guests,
|
||||
mode=route.options.mode,
|
||||
prune_backups=prune,
|
||||
bwlimit=route.options.bwlimit,
|
||||
)
|
||||
upids.append(upid)
|
||||
step.detail = ", ".join(upids)
|
||||
# Counted before the wait: a task that dies still set out to back these up.
|
||||
summary.total += len(vmids)
|
||||
done_before = summary.ok
|
||||
_wait_or_stop(
|
||||
client,
|
||||
upid,
|
||||
recorder,
|
||||
deps,
|
||||
step_name,
|
||||
"pve",
|
||||
_guest_watcher(summary, names),
|
||||
)
|
||||
# The task exited OK, so every guest it covered was backed up whatever the log
|
||||
# parse made of it — a vzdump wording change must never report "0/14" on a good
|
||||
# run. Only on success, so a failed node doesn't advertise guests as backed up.
|
||||
summary.ok = done_before + len(vmids)
|
||||
covered |= set(vmids)
|
||||
return covered
|
||||
|
||||
|
||||
def _route_preflight(
|
||||
route: Route, target: PbsDevice, recorder: RunRecorder, deps: CycleDeps
|
||||
) -> None:
|
||||
"""Abort before any vzdump if the target datastore is below the route's free-space floor.
|
||||
|
||||
No-op when ``min_free_percent`` is 0 (the default), so the step only appears when the
|
||||
user opted in. An abort here leaves the PBS on for inspection (the lease's failure
|
||||
policy), matching the other failure paths.
|
||||
"""
|
||||
threshold = route.options.min_free_percent
|
||||
if threshold <= 0:
|
||||
return
|
||||
with recorder.step(StepName.PRECHECK) as step:
|
||||
with deps.connect_pbs(target) as pbs:
|
||||
ds = pbs.datastore_status()
|
||||
_cache_route_datastore(target, recorder, ds)
|
||||
step.detail = f"{ds.avail_pct:.1f}% free ({ds.avail / 1_000_000_000:.0f} GB)"
|
||||
if ds.avail_pct < threshold:
|
||||
raise CycleAbort(
|
||||
f"PBS '{target.id}' datastore {target.datastore!r} only {ds.avail_pct:.1f}% "
|
||||
f"free (need >= {threshold}%); skipping backup"
|
||||
)
|
||||
|
||||
|
||||
def _route_gc_step(target: PbsDevice, recorder: RunRecorder, deps: CycleDeps) -> None:
|
||||
"""Garbage-collect the route's target datastore while the PBS is still awake."""
|
||||
with recorder.step(StepName.GC) as step:
|
||||
with deps.connect_pbs(target) as pbs:
|
||||
upid = pbs.start_gc()
|
||||
step.detail = upid
|
||||
_wait_or_stop(pbs, upid, recorder, deps, StepName.GC.value, "pbs")
|
||||
|
||||
|
||||
def _route_verify_step(
|
||||
target: PbsDevice, recorder: RunRecorder, deps: CycleDeps, *, outdated_after: int | None
|
||||
) -> None:
|
||||
"""Verify snapshots on the route's target. ``outdated_after=None`` -> only never-verified
|
||||
(i.e. this run's new) snapshots; an int -> also re-verify ones older than that many days
|
||||
(0 -> everything), which is what a Verify route will ask for (M06)."""
|
||||
with recorder.step(StepName.VERIFY) as step:
|
||||
with deps.connect_pbs(target) as pbs:
|
||||
if outdated_after is not None and outdated_after <= 0:
|
||||
upid = pbs.start_verify(ignore_verified=False)
|
||||
else:
|
||||
upid = pbs.start_verify(ignore_verified=True, outdated_after=outdated_after)
|
||||
step.detail = upid
|
||||
_wait_or_stop(pbs, upid, recorder, deps, StepName.VERIFY.value, "pbs")
|
||||
|
||||
|
||||
def _cache_route_datastore(
|
||||
target: PbsDevice, recorder: RunRecorder, ds: DatastoreStatus
|
||||
) -> None:
|
||||
"""Persist the target's usage so the UI can show it while the PBS sleeps. Best-effort:
|
||||
a cache-write failure must never fail the run."""
|
||||
try:
|
||||
with session_scope() as session:
|
||||
upsert_datastore_stat(session, target.id, target.datastore, ds.total, ds.used)
|
||||
except Exception as exc:
|
||||
recorder.log(LogLevel.WARN, f"could not cache datastore usage: {exc}")
|
||||
|
||||
|
||||
def _route_read_datastore(
|
||||
target: PbsDevice, recorder: RunRecorder, deps: CycleDeps
|
||||
) -> DatastoreStatus | None:
|
||||
"""Read the target's usage for the notification, while it is still awake. Best-effort:
|
||||
a read failure only costs the notification its usage line."""
|
||||
try:
|
||||
with deps.connect_pbs(target) as pbs:
|
||||
ds = pbs.datastore_status()
|
||||
except Exception as exc:
|
||||
recorder.log(LogLevel.WARN, f"could not read datastore usage: {exc}")
|
||||
return None
|
||||
recorder.log(LogLevel.INFO, f"PBS '{target.id}' datastore {ds.used_pct}% used")
|
||||
_cache_route_datastore(target, recorder, ds)
|
||||
return ds
|
||||
|
||||
|
||||
def _refresh_route_backup_cache(
|
||||
target: PbsDevice,
|
||||
covered: dict[str, set[int]],
|
||||
recorder: RunRecorder,
|
||||
deps: CycleDeps,
|
||||
) -> None:
|
||||
"""Cache each guest's latest snapshot time per (source pve, target pbs), so the dashboard
|
||||
can show last-backup dates once the PBS sleeps again.
|
||||
|
||||
One datastore lists snapshots by vmid with no idea which PVE they came from, so each
|
||||
source claims the vmids it actually backed up. Two PVEs sharing a vmid both get a row
|
||||
with the same time — that is a real PBS namespace collision, not something to fix here.
|
||||
Best-effort: the backup already succeeded, so a read/write failure is logged and dropped.
|
||||
"""
|
||||
try:
|
||||
with deps.connect_pbs(target) as pbs:
|
||||
latest = pbs.latest_backups()
|
||||
cached = 0
|
||||
with session_scope() as session:
|
||||
for pve_id, vmids in covered.items():
|
||||
mine = {vmid: ts for vmid, ts in latest.items() if vmid in vmids}
|
||||
upsert_last_backups(session, pve_id, target.id, mine)
|
||||
cached += len(mine)
|
||||
except Exception as exc:
|
||||
recorder.log(LogLevel.WARN, f"could not refresh last-backup cache: {exc}")
|
||||
return
|
||||
recorder.log(LogLevel.INFO, f"cached last-backup times for {cached} guest(s)")
|
||||
|
||||
|
||||
def run_route_backup(
|
||||
config: Config, route: Route, recorder: RunRecorder, deps: CycleDeps
|
||||
) -> None:
|
||||
"""Execute one backup route, recording each step. Sets the final run status itself.
|
||||
|
||||
Sources run in order and are isolated from each other: one unreachable PVE fails the run
|
||||
but the remaining sources still get their backup, because a shared target and a shared
|
||||
wake window are exactly what makes a multi-source route worth having.
|
||||
"""
|
||||
datastore: DatastoreStatus | None = None
|
||||
# Owned here, not by the per-source step: a guest failing takes the whole vzdump task
|
||||
# down and unwinds that frame, and the failed run is exactly the one whose tally we want.
|
||||
guests = GuestSummary()
|
||||
covered: dict[str, set[int]] = {}
|
||||
failed: list[str] = []
|
||||
try:
|
||||
target = _find_device(config.pbss, route.target)
|
||||
if target is None:
|
||||
raise CycleAbort(f"route '{route.id}': target pbs '{route.target}' no longer exists")
|
||||
|
||||
_route_preflight(route, target, recorder, deps)
|
||||
|
||||
for source in route.sources:
|
||||
# A cancel that lands between sources must not start the next one — the task
|
||||
# waits check the flag themselves, this covers the gaps between them.
|
||||
if deps.cancelled():
|
||||
raise CycleCancelled("Run cancelled")
|
||||
try:
|
||||
with recorder.step(StepName.BACKUP, label=source.pve) as step:
|
||||
covered[source.pve] = _route_backup_source(
|
||||
config, route, source, recorder, deps, guests, step
|
||||
)
|
||||
except CycleCancelled:
|
||||
raise
|
||||
except Exception as exc:
|
||||
# The step row already carries the failure; keep going so one broken source
|
||||
# doesn't cost the others their backup.
|
||||
failed.append(source.pve)
|
||||
recorder.log(LogLevel.ERROR, f"source '{source.pve}' failed: {exc}")
|
||||
recorder.run.guests_ok = guests.ok
|
||||
|
||||
if deps.cancelled():
|
||||
raise CycleCancelled("Run cancelled")
|
||||
|
||||
# GC and verify still run when a source failed: the PBS is awake and the snapshots
|
||||
# the other sources wrote are real.
|
||||
if route.options.gc:
|
||||
_route_gc_step(target, recorder, deps)
|
||||
else:
|
||||
recorder.skip_step(StepName.GC, "GC disabled for this route")
|
||||
|
||||
if deps.cancelled():
|
||||
raise CycleCancelled("Run cancelled")
|
||||
|
||||
if route.options.verify_after:
|
||||
_route_verify_step(target, recorder, deps, outdated_after=None)
|
||||
else:
|
||||
recorder.skip_step(StepName.VERIFY, "verify disabled for this route")
|
||||
|
||||
datastore = _route_read_datastore(target, recorder, deps)
|
||||
_refresh_route_backup_cache(target, covered, recorder, deps)
|
||||
|
||||
if failed:
|
||||
recorder.finish(
|
||||
RunStatus.FAILURE, error=f"backup failed for source(s): {', '.join(failed)}"
|
||||
)
|
||||
else:
|
||||
recorder.finish(RunStatus.SUCCESS)
|
||||
except CycleCancelled:
|
||||
# No notification: the user pressed Stop and is standing at the UI — a "backup
|
||||
# aborted" push would just be noise about their own click.
|
||||
recorder.finish(RunStatus.ABORTED, error="Cancelled by user")
|
||||
return
|
||||
except CycleAbort as exc:
|
||||
recorder.finish(RunStatus.ABORTED, error=str(exc))
|
||||
except Exception as exc: # connector/task failures: the lease leaves the PBS on
|
||||
recorder.finish(RunStatus.FAILURE, error=str(exc))
|
||||
|
||||
_notify_result(config, recorder, deps, datastore, guests)
|
||||
|
||||
@@ -12,7 +12,7 @@ from collections.abc import Callable
|
||||
from dataclasses import dataclass
|
||||
from datetime import datetime
|
||||
|
||||
from ..config import Config
|
||||
from ..config import Config, PbsDevice, PveDevice
|
||||
from ..connectors import net, tls
|
||||
from ..connectors.pbs import DatastoreStatus, PbsClient
|
||||
from ..connectors.power import PbsPower
|
||||
@@ -51,6 +51,37 @@ def _build_pbs(config: Config) -> PbsClient:
|
||||
)
|
||||
|
||||
|
||||
# --- device-shaped connectors (the v1.0 route model) -------------------------
|
||||
# The two above are bound to the 0.9 single-PVE/PBS Config; a route points at devices from
|
||||
# ``pves[]``/``pbss[]`` instead. ponytail: both shapes coexist while the 0.9 cycle, the
|
||||
# wizard and the status routers still run on the flat sections — M07 deletes the pair above.
|
||||
|
||||
|
||||
def _connect_pve(pve: PveDevice) -> PveClient:
|
||||
# No node: a route lists guests cluster-wide and names the node per vzdump call.
|
||||
return PveClient(
|
||||
host=pve.host,
|
||||
token_id=pve.api_token_id,
|
||||
token_secret=pve.api_token_secret,
|
||||
port=pve.port,
|
||||
verify_tls=pve.verify_tls,
|
||||
)
|
||||
|
||||
|
||||
def _connect_pbs(pbs: PbsDevice) -> PbsClient:
|
||||
verify: bool | ssl.SSLContext = False
|
||||
if pbs.fingerprint:
|
||||
verify = tls.pinned_ssl_context(pbs.host, pbs.port, pbs.fingerprint)
|
||||
return PbsClient(
|
||||
host=pbs.host,
|
||||
datastore=pbs.datastore,
|
||||
token_id=pbs.api_token_id,
|
||||
token_secret=pbs.api_token_secret,
|
||||
port=pbs.port,
|
||||
verify=verify,
|
||||
)
|
||||
|
||||
|
||||
def _build_power(config: Config) -> PbsPower:
|
||||
p = config.pbs
|
||||
return PbsPower(host=p.host, user=p.ssh_user, key_path=p.ssh_key_path)
|
||||
@@ -104,6 +135,11 @@ class CycleDeps:
|
||||
# (config, run, datastore, guests, next_at) -> None. Untyped args because the test fakes
|
||||
# take fewer of them; the production implementation is ``_notify`` above.
|
||||
notify: Callable[..., None]
|
||||
# The same two connectors, built from a route's devices instead of the flat 0.9 config.
|
||||
# Defaulted so every existing CycleDeps(...) construction keeps working; the config-shaped
|
||||
# pair above goes away with its last consumers in M07.
|
||||
connect_pve: Callable[[PveDevice], PveClient] = _connect_pve
|
||||
connect_pbs: Callable[[PbsDevice], PbsClient] = _connect_pbs
|
||||
# True once the user has asked to stop the in-flight run. Wired by JobService to its own
|
||||
# cancel event and read live, so the cycle can check it without knowing about the service.
|
||||
# Default: nothing ever cancels (tests and direct callers that don't care).
|
||||
|
||||
@@ -74,17 +74,19 @@ class RunRecorder:
|
||||
self._session.add(LogEvent(run_id=self.run.id, level=level, message=message))
|
||||
self._session.commit()
|
||||
|
||||
def task_log(self, step: StepName, source: str, lines: list[tuple[int, str]]) -> None:
|
||||
def task_log(self, step: str, source: str, lines: list[tuple[int, str]]) -> None:
|
||||
"""Append a batch of raw task-log lines for the live Task-log panel.
|
||||
|
||||
``lines`` is a list of ``(line_no, text)`` pairs from the PVE/PBS task tailer;
|
||||
committed per batch so an in-flight task streams to ``GET /api/tasklog``.
|
||||
committed per batch so an in-flight task streams to ``GET /api/tasklog``. ``step`` is
|
||||
the owning :class:`RunStep`'s name — a plain :class:`StepName` works, and so does the
|
||||
``backup:<pve-id>`` form a multi-source route records.
|
||||
"""
|
||||
for line_no, text in lines:
|
||||
self._session.add(
|
||||
TaskLogLine(
|
||||
run_id=self.run.id,
|
||||
step=step.value,
|
||||
step=step,
|
||||
source=source,
|
||||
line_no=line_no,
|
||||
text=text,
|
||||
@@ -95,20 +97,25 @@ class RunRecorder:
|
||||
# --- steps ---------------------------------------------------------------
|
||||
|
||||
@contextmanager
|
||||
def step(self, name: StepName) -> Iterator[RunStep]:
|
||||
def step(self, name: StepName, label: str | None = None) -> Iterator[RunStep]:
|
||||
"""Run a step: persisted RUNNING on entry, SUCCESS on clean exit, FAILURE (and
|
||||
re-raised) on exception. The yielded row can carry a ``detail`` (e.g. task UPID)."""
|
||||
step = RunStep(run_id=self.run.id, name=name, status=StepStatus.RUNNING)
|
||||
re-raised) on exception. The yielded row can carry a ``detail`` (e.g. task UPID).
|
||||
|
||||
``label`` qualifies a step that happens more than once in a run — a backup route
|
||||
records one per source PVE, named ``backup:pve-alpha``.
|
||||
"""
|
||||
full = f"{name.value}:{label}" if label else name.value
|
||||
step = RunStep(run_id=self.run.id, name=full, status=StepStatus.RUNNING)
|
||||
self._session.add(step)
|
||||
self._session.commit()
|
||||
self.log(LogLevel.INFO, f"{name.value}: started")
|
||||
self.log(LogLevel.INFO, f"{full}: started")
|
||||
try:
|
||||
yield step
|
||||
except Exception as exc:
|
||||
step.status = StepStatus.FAILURE
|
||||
step.finished_at = _utcnow()
|
||||
step.detail = str(exc)
|
||||
self.log(LogLevel.ERROR, f"{name.value}: {exc}")
|
||||
self.log(LogLevel.ERROR, f"{full}: {exc}")
|
||||
self._session.commit()
|
||||
raise
|
||||
else:
|
||||
@@ -118,7 +125,7 @@ class RunRecorder:
|
||||
# setting step.status = FAILURE and returning normally.
|
||||
if step.status == StepStatus.RUNNING:
|
||||
step.status = StepStatus.SUCCESS
|
||||
self.log(LogLevel.OK, f"{name.value}: done")
|
||||
self.log(LogLevel.OK, f"{full}: done")
|
||||
self._session.commit()
|
||||
|
||||
def skip_step(self, name: StepName, detail: str | None = None) -> None:
|
||||
|
||||
+25
-2
@@ -23,6 +23,9 @@ class UnreachablePve:
|
||||
def list_guests(self):
|
||||
raise ConnectorError("connection refused")
|
||||
|
||||
def list_cluster_guests(self):
|
||||
raise ConnectorError("connection refused")
|
||||
|
||||
|
||||
class FakePve:
|
||||
def __init__(
|
||||
@@ -34,7 +37,8 @@ class FakePve:
|
||||
self.guests = guests or []
|
||||
self.fail_task = fail_task
|
||||
self.log_lines = log_lines or []
|
||||
self.vzdump_args: dict | None = None
|
||||
self.vzdump_args: dict | None = None # the last call (0.9 single-vzdump cycle)
|
||||
self.vzdump_calls: list[dict] = [] # every call, in order (a route backs up per node)
|
||||
self.stopped: list[str] = [] # upids passed to stop_task
|
||||
|
||||
def __enter__(self) -> FakePve:
|
||||
@@ -46,6 +50,9 @@ class FakePve:
|
||||
def list_guests(self) -> list[Guest]:
|
||||
return self.guests
|
||||
|
||||
def list_cluster_guests(self) -> list[Guest]:
|
||||
return self.guests
|
||||
|
||||
def vzdump(
|
||||
self,
|
||||
storage,
|
||||
@@ -55,6 +62,7 @@ class FakePve:
|
||||
mode="snapshot",
|
||||
prune_backups=None,
|
||||
bwlimit=0,
|
||||
node="",
|
||||
) -> str:
|
||||
self.vzdump_args = {
|
||||
"storage": storage,
|
||||
@@ -63,8 +71,12 @@ class FakePve:
|
||||
"mode": mode,
|
||||
"prune_backups": prune_backups,
|
||||
"bwlimit": bwlimit,
|
||||
"node": node,
|
||||
}
|
||||
return "UPID:pve:backup"
|
||||
self.vzdump_calls.append(self.vzdump_args)
|
||||
# A real UPID names the node the task runs on, which is how the client routes its
|
||||
# task calls — and how a per-node test tells the tasks apart.
|
||||
return f"UPID:{node or 'pve'}:backup"
|
||||
|
||||
def wait_task(
|
||||
self, upid: str, poll_interval=None, on_log=None, should_cancel=None, **_
|
||||
@@ -237,7 +249,16 @@ def make_deps(
|
||||
pbs_idle: bool | Callable[[], bool] = True,
|
||||
wol=None,
|
||||
notify=None,
|
||||
pves: dict[str, object] | None = None,
|
||||
pbss: dict[str, object] | None = None,
|
||||
) -> tuple[CycleDeps, FakePve, FakePbs, FakePower]:
|
||||
"""Build a :class:`CycleDeps` wired to in-memory fakes.
|
||||
|
||||
``pve``/``pbs``/``power`` serve the 0.9 config-shaped builders. ``pves``/``pbss`` map a
|
||||
*device id* to its fake for the route-shaped ``connect_pve``/``connect_pbs``; a route
|
||||
test with several sources gives one entry per device, and everything else falls back to
|
||||
the single fake so no existing caller changes.
|
||||
"""
|
||||
pve = pve or FakePve()
|
||||
pbs = pbs or FakePbs()
|
||||
power = power or FakePower()
|
||||
@@ -255,5 +276,7 @@ def make_deps(
|
||||
wait_reachable=lambda _c, _cancel=None: wait(),
|
||||
wait_pbs_idle=lambda _c: idle(),
|
||||
notify=notify or (lambda *_a: None),
|
||||
connect_pve=lambda device: (pves or {}).get(device.id, pve),
|
||||
connect_pbs=lambda device: (pbss or {}).get(device.id, pbs),
|
||||
)
|
||||
return deps, pve, pbs, power
|
||||
|
||||
@@ -62,6 +62,58 @@ def test_list_guests_merges_and_sorts():
|
||||
assert guests[1].type == "qemu" and not guests[1].is_ct
|
||||
|
||||
|
||||
def test_list_cluster_guests_tags_each_guest_with_its_node():
|
||||
def handler(request: httpx.Request) -> httpx.Response:
|
||||
assert request.url.path.endswith("/cluster/resources")
|
||||
assert parse_qs(request.url.query.decode())["type"] == ["vm"]
|
||||
return json_data([
|
||||
{"vmid": 200, "name": "mail", "type": "qemu", "status": "running", "node": "n2"},
|
||||
{"vmid": 100, "name": "web", "type": "qemu", "status": "running", "node": "n1"},
|
||||
{"vmid": 101, "name": "db", "type": "lxc", "status": "stopped", "node": "n1"},
|
||||
])
|
||||
|
||||
guests = make_client(handler).list_cluster_guests()
|
||||
assert [(g.vmid, g.node) for g in guests] == [(100, "n1"), (101, "n1"), (200, "n2")]
|
||||
|
||||
|
||||
def test_list_cluster_guests_drops_templates():
|
||||
"""A template is never backed up: counting it would inflate the guest tally, and naming
|
||||
it in an explicit vmid list would fail the vzdump task."""
|
||||
|
||||
def handler(_request: httpx.Request) -> httpx.Response:
|
||||
return json_data([
|
||||
{"vmid": 100, "name": "web", "type": "qemu", "status": "running", "node": "n1"},
|
||||
{"vmid": 900, "name": "tpl", "type": "qemu", "status": "stopped", "node": "n1",
|
||||
"template": 1},
|
||||
])
|
||||
|
||||
assert [g.vmid for g in make_client(handler).list_cluster_guests()] == [100]
|
||||
|
||||
|
||||
def test_task_calls_follow_the_node_named_in_the_upid():
|
||||
"""One client drives tasks on several cluster nodes, so the node comes from the task."""
|
||||
seen: list[str] = []
|
||||
|
||||
def handler(request: httpx.Request) -> httpx.Response:
|
||||
seen.append(request.url.path)
|
||||
return json_data({"status": "stopped", "exitstatus": "OK"})
|
||||
|
||||
client = make_client(handler) # its own node is "pve"
|
||||
client.task_status("UPID:n3:0001:vzdump::")
|
||||
assert seen[-1].startswith("/api2/json/nodes/n3/tasks/")
|
||||
|
||||
|
||||
def test_vzdump_runs_on_the_named_node():
|
||||
seen = {}
|
||||
|
||||
def handler(request: httpx.Request) -> httpx.Response:
|
||||
seen["path"] = request.url.path
|
||||
return json_data("UPID:n2:0001:vzdump::")
|
||||
|
||||
make_client(handler).vzdump(storage="pbs", vmids=[100], node="n2")
|
||||
assert seen["path"] == "/api2/json/nodes/n2/vzdump"
|
||||
|
||||
|
||||
def test_vzdump_builds_params_and_returns_upid():
|
||||
captured = {}
|
||||
|
||||
|
||||
@@ -0,0 +1,465 @@
|
||||
"""The v1.0 backup route cycle: several PVE sources (one a cluster) onto one PBS target.
|
||||
|
||||
Everything here runs through the connector fakes — no real PVE/PBS — and the route cycle
|
||||
itself never touches power: the lease does (see test_lease/test_queue), which is why the
|
||||
end-to-end cases at the bottom go through ``JobService.enqueue``.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import time
|
||||
|
||||
from fakes import FakeBox, FakePbs, FakePve, UnreachablePve, make_deps
|
||||
from sqlalchemy import select
|
||||
|
||||
from app.config import Config, PbsDevice, PveDevice, Route, RouteGuests, RouteSource
|
||||
from app.connectors.pve import Guest
|
||||
from app.core.config_store import ConfigStore
|
||||
from app.db import session_scope
|
||||
from app.db.models import (
|
||||
DatastoreStat,
|
||||
GuestBackup,
|
||||
LogEvent,
|
||||
LogLevel,
|
||||
Run,
|
||||
RunKind,
|
||||
RunStatus,
|
||||
RunTrigger,
|
||||
)
|
||||
from app.jobs.backup_cycle import run_route_backup
|
||||
from app.jobs.recorder import RunRecorder
|
||||
from app.jobs.service import JobService
|
||||
|
||||
# pve-alpha is a cluster: four guests spread over three nodes. pve-beta is a standalone
|
||||
# node. Both back up to the same PBS, each through its own storage name.
|
||||
ALPHA_GUESTS = [
|
||||
Guest(vmid=100, name="web", type="qemu", status="running", node="n1"),
|
||||
Guest(vmid=101, name="db", type="lxc", status="running", node="n1"),
|
||||
Guest(vmid=200, name="mail", type="qemu", status="running", node="n2"),
|
||||
Guest(vmid=300, name="dns", type="lxc", status="stopped", node="n3"),
|
||||
]
|
||||
BETA_GUESTS = [Guest(vmid=500, name="nas", type="qemu", status="running", node="beta")]
|
||||
|
||||
|
||||
def _config(**route_kwargs) -> Config:
|
||||
config = Config()
|
||||
config.pves = [
|
||||
PveDevice(id="pve-alpha", host="192.0.2.10", storages={"pbs1": "pbs-alpha"}),
|
||||
PveDevice(id="pve-beta", host="192.0.2.11", storages={"pbs1": "pbs-beta"}),
|
||||
]
|
||||
config.pbss = [
|
||||
PbsDevice(id="pbs1", host="192.0.2.20", datastore="backup", mac="00:11:22:33:44:55")
|
||||
]
|
||||
sources = route_kwargs.pop(
|
||||
"sources", [RouteSource(pve="pve-alpha"), RouteSource(pve="pve-beta")]
|
||||
)
|
||||
config.routes = [
|
||||
Route(id="nightly", name="Nightly", kind="backup", target="pbs1", sources=sources,
|
||||
**route_kwargs)
|
||||
]
|
||||
return config
|
||||
|
||||
|
||||
def _deps(*, alpha=None, beta=None, pbs=None, notify=None, cancelled=None):
|
||||
alpha = alpha if alpha is not None else FakePve(guests=list(ALPHA_GUESTS))
|
||||
beta = beta if beta is not None else FakePve(guests=list(BETA_GUESTS))
|
||||
pbs = pbs or FakePbs()
|
||||
deps, *_ = make_deps(
|
||||
pves={"pve-alpha": alpha, "pve-beta": beta},
|
||||
pbss={"pbs1": pbs},
|
||||
notify=notify,
|
||||
)
|
||||
if cancelled is not None:
|
||||
deps.cancelled = cancelled
|
||||
return deps, alpha, beta, pbs
|
||||
|
||||
|
||||
def _run(config: Config, deps, route_id: str = "nightly") -> int:
|
||||
route = next(r for r in config.routes if r.id == route_id)
|
||||
with RunRecorder(
|
||||
RunKind.CYCLE, RunTrigger.MANUAL, route_id=route.id, route_name=route.name
|
||||
) as recorder:
|
||||
run_route_backup(config, route, recorder, deps)
|
||||
return recorder.run_id
|
||||
|
||||
|
||||
def _load(run_id: int) -> tuple[str, dict[str, str]]:
|
||||
"""Return (run status, {step name: step status})."""
|
||||
with session_scope() as session:
|
||||
run = session.get(Run, run_id)
|
||||
return run.status, {s.name: s.status for s in run.steps}
|
||||
|
||||
|
||||
def _logs(run_id: int, level: LogLevel | None = None) -> list[str]:
|
||||
with session_scope() as session:
|
||||
rows = session.scalars(select(LogEvent).where(LogEvent.run_id == run_id)).all()
|
||||
return [r.message for r in rows if level is None or r.level == level]
|
||||
|
||||
|
||||
# --- per-node vzdump grouping ------------------------------------------------
|
||||
|
||||
|
||||
def test_cluster_and_standalone_each_get_one_vzdump_per_node(temp_db):
|
||||
config = _config()
|
||||
deps, alpha, beta, _pbs = _deps()
|
||||
|
||||
status, steps = _load(_run(config, deps))
|
||||
|
||||
assert status == RunStatus.SUCCESS
|
||||
# The cluster's three nodes, each once; the standalone node once.
|
||||
assert [c["node"] for c in alpha.vzdump_calls] == ["n1", "n2", "n3"]
|
||||
assert [c["node"] for c in beta.vzdump_calls] == ["beta"]
|
||||
# Each PVE names the same PBS its own way.
|
||||
assert {c["storage"] for c in alpha.vzdump_calls} == {"pbs-alpha"}
|
||||
assert beta.vzdump_calls[0]["storage"] == "pbs-beta"
|
||||
assert steps["backup:pve-alpha"] == "success"
|
||||
assert steps["backup:pve-beta"] == "success"
|
||||
|
||||
|
||||
def test_all_mode_uses_vzdumps_own_all_flag(temp_db):
|
||||
config = _config()
|
||||
deps, alpha, _beta, _pbs = _deps()
|
||||
|
||||
_run(config, deps)
|
||||
|
||||
# Not the vmids we just listed: PVE keeps deciding, so a guest excluded from backup on
|
||||
# the host is still honoured.
|
||||
assert all(c["all_guests"] is True and c["vmids"] is None for c in alpha.vzdump_calls)
|
||||
|
||||
|
||||
def test_include_mode_groups_the_selection_per_node(temp_db):
|
||||
config = _config(
|
||||
sources=[
|
||||
RouteSource(pve="pve-alpha", guests=RouteGuests(mode="include", list=[100, 300])),
|
||||
RouteSource(pve="pve-beta"),
|
||||
]
|
||||
)
|
||||
deps, alpha, _beta, _pbs = _deps()
|
||||
|
||||
status, _steps = _load(_run(config, deps))
|
||||
|
||||
assert status == RunStatus.SUCCESS
|
||||
# n2 holds nothing selected, so it is never woken up with a task.
|
||||
assert [(c["node"], c["vmids"]) for c in alpha.vzdump_calls] == [("n1", [100]), ("n3", [300])]
|
||||
assert all(c["all_guests"] is False for c in alpha.vzdump_calls)
|
||||
|
||||
|
||||
def test_route_options_and_retention_drive_the_vzdump_arguments(temp_db):
|
||||
config = _config()
|
||||
route = config.routes[0]
|
||||
route.options.mode = "stop"
|
||||
route.options.bwlimit = 50_000
|
||||
route.retention.keep_last = 3
|
||||
route.retention.keep_daily = 0
|
||||
route.retention.keep_weekly = 0
|
||||
route.retention.keep_monthly = 0
|
||||
deps, alpha, _beta, _pbs = _deps()
|
||||
|
||||
_run(config, deps)
|
||||
|
||||
call = alpha.vzdump_calls[0]
|
||||
assert call["mode"] == "stop"
|
||||
assert call["bwlimit"] == 50_000
|
||||
assert call["prune_backups"] == "keep-last=3"
|
||||
|
||||
|
||||
def test_selected_guest_that_is_gone_is_warned_about_and_skipped(temp_db):
|
||||
config = _config(
|
||||
sources=[RouteSource(pve="pve-alpha", guests=RouteGuests(mode="include", list=[100, 999]))]
|
||||
)
|
||||
deps, alpha, _beta, _pbs = _deps()
|
||||
|
||||
run_id = _run(config, deps)
|
||||
|
||||
status, _steps = _load(run_id)
|
||||
assert status == RunStatus.SUCCESS
|
||||
assert [c["vmids"] for c in alpha.vzdump_calls] == [[100]]
|
||||
assert any("999" in m for m in _logs(run_id, LogLevel.WARN))
|
||||
|
||||
|
||||
def test_source_left_with_no_guest_fails_that_source(temp_db):
|
||||
config = _config(
|
||||
sources=[RouteSource(pve="pve-alpha", guests=RouteGuests(mode="include", list=[999]))]
|
||||
)
|
||||
deps, alpha, _beta, _pbs = _deps()
|
||||
|
||||
status, steps = _load(_run(config, deps))
|
||||
|
||||
assert status == RunStatus.FAILURE
|
||||
assert steps["backup:pve-alpha"] == "failure"
|
||||
assert alpha.vzdump_calls == []
|
||||
|
||||
|
||||
# --- per-source failure isolation --------------------------------------------
|
||||
|
||||
|
||||
def test_one_broken_source_still_lets_the_others_run(temp_db):
|
||||
config = _config()
|
||||
deps, alpha, _beta, _pbs = _deps(beta=UnreachablePve())
|
||||
|
||||
run_id = _run(config, deps)
|
||||
status, steps = _load(run_id)
|
||||
|
||||
assert status == RunStatus.FAILURE
|
||||
assert steps["backup:pve-alpha"] == "success"
|
||||
assert steps["backup:pve-beta"] == "failure"
|
||||
assert len(alpha.vzdump_calls) == 3 # the healthy source got its full backup
|
||||
with session_scope() as session:
|
||||
assert "pve-beta" in session.get(Run, run_id).error
|
||||
|
||||
|
||||
def test_a_failing_node_task_fails_only_its_source(temp_db):
|
||||
config = _config()
|
||||
deps, _alpha, _beta, _pbs = _deps(beta=FakePve(guests=list(BETA_GUESTS), fail_task=True))
|
||||
|
||||
status, steps = _load(_run(config, deps))
|
||||
|
||||
assert status == RunStatus.FAILURE
|
||||
assert steps["backup:pve-alpha"] == "success"
|
||||
assert steps["backup:pve-beta"] == "failure"
|
||||
assert steps["gc"] == "success" # the PBS is awake and the other source's data is real
|
||||
|
||||
|
||||
# --- guest tally --------------------------------------------------------------
|
||||
|
||||
|
||||
def _summary(config, deps_kwargs=None):
|
||||
seen: dict = {}
|
||||
|
||||
def notify(_config, _run, datastore, guests, _next_at=None):
|
||||
seen["datastore"] = datastore
|
||||
seen["guests"] = guests
|
||||
|
||||
deps, alpha, beta, pbs = _deps(notify=notify, **(deps_kwargs or {}))
|
||||
_run(config, deps)
|
||||
return seen, alpha, beta, pbs
|
||||
|
||||
|
||||
def test_guest_tally_aggregates_across_sources(temp_db):
|
||||
seen, _alpha, _beta, _pbs = _summary(_config())
|
||||
|
||||
assert seen["guests"].total == 5 # 4 on the cluster + 1 standalone
|
||||
assert seen["guests"].ok == 5
|
||||
assert seen["guests"].failed == []
|
||||
|
||||
|
||||
def test_failed_guests_are_named_across_sources(temp_db):
|
||||
alpha = FakePve(
|
||||
guests=list(ALPHA_GUESTS),
|
||||
fail_task=True,
|
||||
log_lines=["INFO: Finished Backup of VM 100", "ERROR: Backup of VM 101 failed - boom"],
|
||||
)
|
||||
seen, _alpha, _beta, _pbs = _summary(_config(), {"alpha": alpha})
|
||||
|
||||
assert seen["guests"].failed == ["db"] # the guest's name, not its vmid
|
||||
# 100 from the failed task's own log (the tally survives the raise) + pve-beta's guest,
|
||||
# which still ran because sources are isolated.
|
||||
assert seen["guests"].ok == 2
|
||||
|
||||
|
||||
# --- GC / verify / preflight --------------------------------------------------
|
||||
|
||||
|
||||
def test_gc_runs_once_per_route(temp_db):
|
||||
config = _config()
|
||||
deps, _alpha, _beta, pbs = _deps()
|
||||
|
||||
_load(_run(config, deps))
|
||||
|
||||
assert pbs.gc_started is True
|
||||
|
||||
|
||||
def test_gc_disabled_skips_the_step(temp_db):
|
||||
config = _config()
|
||||
config.routes[0].options.gc = False
|
||||
deps, _alpha, _beta, pbs = _deps()
|
||||
|
||||
status, steps = _load(_run(config, deps))
|
||||
|
||||
assert status == RunStatus.SUCCESS
|
||||
assert steps["gc"] == "skipped"
|
||||
assert pbs.gc_started is False
|
||||
|
||||
|
||||
def test_verify_after_runs_only_the_new_snapshots(temp_db):
|
||||
config = _config()
|
||||
config.routes[0].options.verify_after = True
|
||||
deps, _alpha, _beta, pbs = _deps()
|
||||
|
||||
status, steps = _load(_run(config, deps))
|
||||
|
||||
assert status == RunStatus.SUCCESS
|
||||
assert steps["verify"] == "success"
|
||||
assert pbs.verify_args == {"ignore_verified": True, "outdated_after": None}
|
||||
|
||||
|
||||
def test_verify_is_skipped_by_default(temp_db):
|
||||
_status, steps = _load(_run(_config(), _deps()[0]))
|
||||
|
||||
assert steps["verify"] == "skipped"
|
||||
|
||||
|
||||
def test_preflight_aborts_before_any_backup_when_the_datastore_is_full(temp_db):
|
||||
config = _config()
|
||||
config.routes[0].options.min_free_percent = 50
|
||||
deps, alpha, _beta, _pbs = _deps(
|
||||
pbs=FakePbs(total=8_000_000_000, used=7_000_000_000, avail=1_000_000_000)
|
||||
)
|
||||
|
||||
status, steps = _load(_run(config, deps))
|
||||
|
||||
assert status == RunStatus.ABORTED
|
||||
assert steps["precheck"] == "failure"
|
||||
assert alpha.vzdump_calls == []
|
||||
|
||||
|
||||
def test_preflight_passes_when_there_is_room(temp_db):
|
||||
config = _config()
|
||||
config.routes[0].options.min_free_percent = 50
|
||||
deps, alpha, _beta, _pbs = _deps()
|
||||
|
||||
status, steps = _load(_run(config, deps))
|
||||
|
||||
assert status == RunStatus.SUCCESS
|
||||
assert steps["precheck"] == "success"
|
||||
assert len(alpha.vzdump_calls) == 3
|
||||
|
||||
|
||||
def test_no_precheck_step_when_the_guard_is_off(temp_db):
|
||||
_status, steps = _load(_run(_config(), _deps()[0]))
|
||||
|
||||
assert "precheck" not in steps
|
||||
|
||||
|
||||
# --- caches -------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_last_backup_cache_is_attributed_to_each_source(temp_db):
|
||||
config = _config()
|
||||
# The datastore holds snapshots for guests of both PVEs; each source claims its own.
|
||||
deps, _alpha, _beta, _pbs = _deps(
|
||||
pbs=FakePbs(snapshots={100: 1_700_000_000, 500: 1_700_000_100})
|
||||
)
|
||||
|
||||
_run(config, deps)
|
||||
|
||||
with session_scope() as session:
|
||||
rows = {(r.pve_id, r.vmid, r.pbs_id) for r in session.scalars(select(GuestBackup))}
|
||||
assert rows == {("pve-alpha", 100, "pbs1"), ("pve-beta", 500, "pbs1")}
|
||||
|
||||
|
||||
def test_datastore_stat_is_keyed_by_the_target_pbs(temp_db):
|
||||
config = _config()
|
||||
deps, _alpha, _beta, _pbs = _deps()
|
||||
|
||||
_run(config, deps)
|
||||
|
||||
with session_scope() as session:
|
||||
row = session.get(DatastoreStat, ("pbs1", "backup"))
|
||||
assert row is not None
|
||||
assert (row.total, row.used) == (8_000_000_000, 2_000_000_000)
|
||||
|
||||
|
||||
def test_a_cache_failure_never_fails_the_run(temp_db):
|
||||
config = _config()
|
||||
|
||||
class NoSnapshots(FakePbs):
|
||||
def latest_backups(self):
|
||||
raise RuntimeError("snapshot listing exploded")
|
||||
|
||||
deps, _alpha, _beta, _pbs = _deps(pbs=NoSnapshots())
|
||||
|
||||
status, _steps = _load(_run(config, deps))
|
||||
|
||||
assert status == RunStatus.SUCCESS
|
||||
|
||||
|
||||
# --- cancellation -------------------------------------------------------------
|
||||
|
||||
|
||||
def test_cancel_stops_the_running_vzdump_and_aborts(temp_db):
|
||||
config = _config()
|
||||
notified: list[object] = []
|
||||
# False at the between-sources check, then True from inside the first task's wait.
|
||||
answers = iter([False])
|
||||
deps, alpha, beta, _pbs = _deps(
|
||||
cancelled=lambda: next(answers, True), notify=lambda *a: notified.append(a)
|
||||
)
|
||||
|
||||
status, steps = _load(_run(config, deps))
|
||||
|
||||
assert status == RunStatus.ABORTED
|
||||
assert alpha.stopped == ["UPID:n1:backup"] # the task it had actually started
|
||||
assert steps["backup:pve-alpha"] == "failure"
|
||||
assert "backup:pve-beta" not in steps # a cancel stops the route, not just one source
|
||||
assert beta.vzdump_calls == []
|
||||
assert notified == [] # the user is standing at the UI; no push about their own click
|
||||
|
||||
|
||||
def test_cancel_before_the_first_source_starts_nothing(temp_db):
|
||||
config = _config()
|
||||
deps, alpha, _beta, _pbs = _deps(cancelled=lambda: True)
|
||||
|
||||
status, steps = _load(_run(config, deps))
|
||||
|
||||
assert status == RunStatus.ABORTED
|
||||
assert alpha.vzdump_calls == []
|
||||
assert steps == {}
|
||||
|
||||
|
||||
# --- end to end through the queue (the lease owns the power) ------------------
|
||||
|
||||
|
||||
def _service(config_setup) -> tuple[JobService, FakeBox, dict]:
|
||||
store = ConfigStore.load_or_create()
|
||||
template = _config()
|
||||
store.config.pves = template.pves
|
||||
store.config.pbss = template.pbss
|
||||
store.config.routes = template.routes
|
||||
fakes = {"alpha": FakePve(guests=list(ALPHA_GUESTS)), "beta": config_setup}
|
||||
deps, *_ = make_deps(
|
||||
pves={"pve-alpha": fakes["alpha"], "pve-beta": fakes["beta"]},
|
||||
pbss={"pbs1": FakePbs()},
|
||||
)
|
||||
# Found asleep, up after the first magic packet — so the wake is observable.
|
||||
box = FakeBox(reachable=[False, True])
|
||||
return JobService(store, deps=deps, lease_deps=box.deps()), box, fakes
|
||||
|
||||
|
||||
def _enqueue_and_drain(service: JobService) -> None:
|
||||
def job(config, recorder, deps) -> None:
|
||||
route = next(r for r in config.routes if r.id == "nightly")
|
||||
run_route_backup(config, route, recorder, deps)
|
||||
|
||||
service.enqueue("nightly", RunTrigger.MANUAL, job)
|
||||
deadline = time.monotonic() + 5
|
||||
while time.monotonic() < deadline:
|
||||
if service.current() is None and not service.pending() and not service.is_running:
|
||||
return
|
||||
time.sleep(0.01)
|
||||
raise AssertionError("queue did not drain")
|
||||
|
||||
|
||||
def test_a_route_run_wakes_the_target_once_and_powers_it_off_once(temp_config, temp_db):
|
||||
service, box, _fakes = _service(FakePve(guests=list(BETA_GUESTS)))
|
||||
|
||||
_enqueue_and_drain(service)
|
||||
|
||||
assert box.wol == ["pbs1"]
|
||||
assert box.poweroffs == ["pbs1"]
|
||||
with session_scope() as session:
|
||||
run = session.scalars(select(Run)).one()
|
||||
assert run.status == RunStatus.SUCCESS
|
||||
assert run.route_id == "nightly"
|
||||
|
||||
|
||||
def test_a_failed_source_leaves_the_pbs_on(temp_config, temp_db):
|
||||
service, box, fakes = _service(UnreachablePve())
|
||||
|
||||
_enqueue_and_drain(service)
|
||||
|
||||
assert box.wol == ["pbs1"]
|
||||
assert box.poweroffs == [] # left on for inspection
|
||||
assert len(fakes["alpha"].vzdump_calls) == 3
|
||||
with session_scope() as session:
|
||||
assert session.scalars(select(Run)).one().status == RunStatus.FAILURE
|
||||
Reference in New Issue
Block a user