zpbs-backup 0.11.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,3 @@
1
+ """ZFS property-driven Proxmox Backup Server backup tool."""
2
+
3
+ __version__ = "0.11.0"
zpbs_backup/backup.py ADDED
@@ -0,0 +1,600 @@
1
+ """Backup orchestration logic."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import fnmatch
6
+ import subprocess
7
+ import sys
8
+ from collections.abc import Callable
9
+ from dataclasses import dataclass, field
10
+ from datetime import datetime
11
+ from enum import Enum
12
+ from typing import NamedTuple, TextIO
13
+
14
+ from .config import PBSConfig, get_hostname
15
+ from .pbs import PBSClient
16
+ from .retention import DEFAULT_RETENTION, RetentionPolicy, parse_retention
17
+ from .scheduler import format_last_backup, is_backup_due
18
+ from .zfs import (
19
+ PROP_RETENTION,
20
+ Dataset,
21
+ InvalidPropertyError,
22
+ discover_datasets,
23
+ enabled_datasets,
24
+ get_all_datasets,
25
+ get_latest_snapshot_creation,
26
+ get_written_bytes,
27
+ )
28
+
29
+ SKEW_MARGIN_SECONDS = 60
30
+
31
+
32
+ def failure_message(result: subprocess.CompletedProcess[str]) -> str:
33
+ """Describe why a proxmox-backup-client command failed.
34
+
35
+ Prefers what the client itself said. A CompletedProcess always has a
36
+ stderr attribute, so testing for one tells you nothing about whether it
37
+ holds text — the value is None whenever output was not captured. When
38
+ the client printed nothing at all, fall back to the exit status, which
39
+ is still more use than an empty string.
40
+
41
+ Args:
42
+ result: The CompletedProcess of the failed command
43
+
44
+ Returns:
45
+ A non-empty, human-readable error message
46
+ """
47
+ for stream in (result.stderr, result.stdout):
48
+ if stream and stream.strip():
49
+ return stream.strip()
50
+
51
+ return f"proxmox-backup-client exited {result.returncode} with no output"
52
+
53
+
54
+ class SkipCause(Enum):
55
+ """Why a dataset was not backed up on this run.
56
+
57
+ A single skipped count cannot tell a dataset that merely is not due yet
58
+ from one that has silently lost its mountpoint. The label value is what
59
+ reaches Prometheus, so it is part of the metric contract — rename one
60
+ and the dashboards and alert rules reading it go blind.
61
+ """
62
+
63
+ NOT_DUE = "not_due"
64
+ UNCHANGED = "unchanged"
65
+ NO_MOUNTPOINT = "no_mountpoint"
66
+ NOT_MOUNTED = "not_mounted"
67
+
68
+
69
+ @dataclass
70
+ class BackupResult:
71
+ """Result of a single backup operation."""
72
+
73
+ dataset: Dataset
74
+ success: bool
75
+ skipped: bool = False
76
+ skip_reason: str | None = None
77
+ skip_cause: SkipCause | None = None
78
+ error: str | None = None
79
+ duration_seconds: float = 0.0
80
+ # Start time of the backup snapshot this run created; None unless a real
81
+ # backup succeeded.
82
+ backup_time: datetime | None = None
83
+
84
+
85
+ class PlannedDataset(NamedTuple):
86
+ """One dataset's place in a run: back it up, or skip it and why."""
87
+
88
+ dataset: Dataset
89
+ should_backup: bool
90
+ skip_reason: str | None = None
91
+ skip_cause: SkipCause | None = None
92
+ # Newest snapshot of this dataset on the backup server before the run.
93
+ last_backup: datetime | None = None
94
+
95
+
96
+ @dataclass
97
+ class BackupSummary:
98
+ """Summary of a backup run."""
99
+
100
+ results: list[BackupResult] = field(default_factory=list)
101
+ # Newest snapshot time on the backup server per dataset name, after this
102
+ # run. A dataset with no snapshot is absent.
103
+ last_backup_times: dict[str, datetime] = field(default_factory=dict)
104
+ # Unrecognised locally set zpbs properties per dataset name; only
105
+ # datasets that have any are present.
106
+ unknown_properties: dict[str, list[str]] = field(default_factory=dict)
107
+ start_time: datetime = field(default_factory=datetime.now)
108
+ end_time: datetime | None = None
109
+
110
+ @property
111
+ def total(self) -> int:
112
+ return len(self.results)
113
+
114
+ @property
115
+ def successful(self) -> int:
116
+ return sum(1 for r in self.results if r.success and not r.skipped)
117
+
118
+ @property
119
+ def failed(self) -> int:
120
+ return sum(1 for r in self.results if not r.success and not r.skipped)
121
+
122
+ @property
123
+ def skipped(self) -> int:
124
+ return sum(1 for r in self.results if r.skipped)
125
+
126
+ @property
127
+ def skipped_by_cause(self) -> dict[SkipCause, int]:
128
+ """Count skipped datasets per cause, every cause always present.
129
+
130
+ Causes with no datasets still report zero: a series that vanishes
131
+ when its count drops to nothing goes stale in Prometheus rather
132
+ than reading zero, which is the opposite of what an alert needs.
133
+ """
134
+ counts = {cause: 0 for cause in SkipCause}
135
+ for result in self.results:
136
+ if result.skipped and result.skip_cause is not None:
137
+ counts[result.skip_cause] += 1
138
+ return counts
139
+
140
+ @property
141
+ def duration_seconds(self) -> float:
142
+ if self.end_time is None:
143
+ return 0.0
144
+ return (self.end_time - self.start_time).total_seconds()
145
+
146
+
147
+ class BackupOrchestrator:
148
+ """Orchestrates backup operations for discovered datasets."""
149
+
150
+ def __init__(
151
+ self,
152
+ config: PBSConfig,
153
+ dry_run: bool = False,
154
+ force: bool = False,
155
+ change_detection_mode: str | None = None,
156
+ output: TextIO | None = None,
157
+ ):
158
+ self.config = config
159
+ self.output = output or sys.stdout
160
+ self.client = PBSClient(config, output=self.output)
161
+ self.hostname = get_hostname()
162
+ self.dry_run = dry_run
163
+ self.force = force
164
+ # A run-wide mode from the command line; when None, each dataset's
165
+ # zpbs:change-detection property decides.
166
+ self.change_detection_mode = change_detection_mode
167
+ self._progress_callback: Callable[[str], None] | None = None
168
+ self.skip_unchanged_safe: bool = True
169
+ self.unknown_properties: dict[str, list[str]] = {}
170
+
171
+ def set_progress_callback(self, callback: Callable[[str], None]) -> None:
172
+ """Set a callback for progress updates."""
173
+ self._progress_callback = callback
174
+
175
+ def _log(self, message: str) -> None:
176
+ """Log a message to output."""
177
+ print(message, file=self.output)
178
+ if self._progress_callback:
179
+ self._progress_callback(message)
180
+
181
+ def discover(self, pattern: str | None = None) -> list[Dataset]:
182
+ """Discover datasets to back up.
183
+
184
+ Args:
185
+ pattern: Optional glob pattern to filter datasets
186
+
187
+ Returns:
188
+ List of datasets sorted by priority
189
+ """
190
+ all_datasets = get_all_datasets()
191
+
192
+ # Typos such as zpbs:backups are inert, so report them for every
193
+ # dataset, not only the ones being backed up.
194
+ self.unknown_properties = {
195
+ ds.name: ds.unknown_properties for ds in all_datasets if ds.unknown_properties
196
+ }
197
+
198
+ datasets = enabled_datasets(all_datasets)
199
+
200
+ if pattern:
201
+ datasets = [ds for ds in datasets if fnmatch.fnmatch(ds.name, pattern)]
202
+
203
+ return datasets
204
+
205
+ def _reject_misconfigured(
206
+ self, datasets: list[Dataset], summary: BackupSummary
207
+ ) -> list[Dataset]:
208
+ """Drop datasets whose zpbs properties cannot be acted on.
209
+
210
+ Each one is recorded as a failure naming the dataset and the
211
+ property, so it reaches the run output, syslog and the notification
212
+ email. The remaining datasets still run: one typo must not cost a
213
+ night of backups.
214
+
215
+ Args:
216
+ datasets: The discovered datasets
217
+ summary: The run summary, extended with a failure per rejection
218
+
219
+ Returns:
220
+ The datasets safe to proceed with
221
+ """
222
+ usable = []
223
+
224
+ for dataset in datasets:
225
+ errors = dataset.property_errors()
226
+ if not errors:
227
+ usable.append(dataset)
228
+ continue
229
+
230
+ message = "; ".join(str(e) for e in errors)
231
+ self._log(f"Skipping {dataset.name}: {message}")
232
+ summary.results.append(
233
+ BackupResult(dataset=dataset, success=False, error=message)
234
+ )
235
+
236
+ return usable
237
+
238
+ def plan(self, datasets: list[Dataset]) -> list[PlannedDataset]:
239
+ """Plan which datasets need to be backed up.
240
+
241
+ Args:
242
+ datasets: List of datasets to consider
243
+
244
+ Returns:
245
+ One PlannedDataset per dataset, carrying the skip cause when it
246
+ is not being backed up
247
+ """
248
+ plan = []
249
+
250
+ for ds in datasets:
251
+ backup_id = ds.get_backup_id(self.hostname, self.config.backup_id_separator)
252
+ namespace = ds.namespace or ds.get_auto_namespace(self.hostname)
253
+ # Fetched even under --force: the per-dataset freshness metric
254
+ # needs it, and it is the only server call plan() makes.
255
+ last_backup = self.client.get_last_backup_time(backup_id, namespace)
256
+
257
+ if self.force:
258
+ plan.append(PlannedDataset(ds, True, last_backup=last_backup))
259
+ continue
260
+
261
+ if not is_backup_due(ds.schedule, last_backup):
262
+ reason = f"not due (last: {format_last_backup(last_backup)})"
263
+ plan.append(PlannedDataset(ds, False, reason, SkipCause.NOT_DUE, last_backup)
264
+ )
265
+ continue
266
+
267
+ unchanged_reason = self._unchanged_skip_reason(ds, last_backup)
268
+ if unchanged_reason is not None:
269
+ plan.append(
270
+ PlannedDataset(
271
+ ds, False, unchanged_reason, SkipCause.UNCHANGED, last_backup
272
+ )
273
+ )
274
+ continue
275
+
276
+ plan.append(PlannedDataset(ds, True, last_backup=last_backup))
277
+
278
+ return plan
279
+
280
+ def _unchanged_skip_reason(
281
+ self, ds: Dataset, last_backup: datetime | None
282
+ ) -> str | None:
283
+ """Return a skip reason if the dataset is provably unchanged since last backup.
284
+
285
+ Skip when:
286
+ - clock skew with PBS is within the safety margin (set in run()),
287
+ - a previous successful backup exists,
288
+ - `zfs get written` is 0 (live FS identical to most recent snapshot),
289
+ - the most recent snapshot was created at least SKEW_MARGIN_SECONDS
290
+ before the last backup (so it must have been captured by that backup).
291
+ """
292
+ if not self.skip_unchanged_safe:
293
+ return None
294
+ if last_backup is None:
295
+ return None
296
+ written = get_written_bytes(ds.name)
297
+ snap_creation = get_latest_snapshot_creation(ds.name)
298
+ if written != 0 or snap_creation is None:
299
+ return None
300
+ if snap_creation + SKEW_MARGIN_SECONDS > last_backup.timestamp():
301
+ return None
302
+ snap_str = datetime.fromtimestamp(snap_creation).strftime("%Y-%m-%d %H:%M")
303
+ return f"unchanged (written=0, latest snap @ {snap_str})"
304
+
305
+ def _preflight_skew_check(self) -> None:
306
+ """Measure clock skew against PBS and decide if skip-unchanged is safe.
307
+
308
+ On error or excessive skew, disables skip-unchanged for this run and falls
309
+ back to schedule-only behavior. Never blocks the run.
310
+ """
311
+ if self.force:
312
+ return
313
+ skew = self.client.get_clock_skew_seconds()
314
+ if skew is None:
315
+ self._log(
316
+ "Warning: could not measure clock skew with PBS; "
317
+ "disabling skip-unchanged optimization for this run"
318
+ )
319
+ self.skip_unchanged_safe = False
320
+ return
321
+ abs_skew = abs(skew)
322
+ direction = "ahead of" if skew > 0 else "behind"
323
+ if abs_skew < 5:
324
+ return
325
+ if abs_skew < SKEW_MARGIN_SECONDS:
326
+ self._log(
327
+ f"Clock skew: PBS is {abs_skew:.1f}s {direction} local "
328
+ f"(within {SKEW_MARGIN_SECONDS}s safety margin)"
329
+ )
330
+ return
331
+ self._log(
332
+ f"Warning: clock skew of {abs_skew:.1f}s ({direction} local) exceeds "
333
+ f"{SKEW_MARGIN_SECONDS}s safety margin — disabling skip-unchanged "
334
+ f"optimization for this run. Check NTP on both hosts."
335
+ )
336
+ self.skip_unchanged_safe = False
337
+
338
+ def backup_dataset(self, dataset: Dataset) -> BackupResult:
339
+ """Back up a single dataset.
340
+
341
+ Args:
342
+ dataset: The dataset to back up
343
+
344
+ Returns:
345
+ BackupResult with outcome
346
+ """
347
+ start_time = datetime.now()
348
+ backup_id = dataset.get_backup_id(self.hostname, self.config.backup_id_separator)
349
+ namespace = dataset.namespace or dataset.get_auto_namespace(self.hostname)
350
+ mountpoint = dataset.mountpoint
351
+
352
+ self._log(f"Backing up {dataset.name} -> {backup_id}")
353
+
354
+ if not mountpoint:
355
+ self._log(" Skipped: no mountpoint (mountpoint=none or legacy)")
356
+ return BackupResult(
357
+ dataset=dataset,
358
+ success=True,
359
+ skipped=True,
360
+ skip_reason="no mountpoint",
361
+ skip_cause=SkipCause.NO_MOUNTPOINT,
362
+ )
363
+
364
+ if not dataset.mounted:
365
+ reason = "not mounted"
366
+ if not dataset.canmount:
367
+ reason += " (canmount=off)"
368
+ self._log(f" Skipped: {reason}")
369
+ return BackupResult(
370
+ dataset=dataset,
371
+ success=True,
372
+ skipped=True,
373
+ skip_reason=reason,
374
+ skip_cause=SkipCause.NOT_MOUNTED,
375
+ )
376
+
377
+ if self.dry_run:
378
+ self._log(f" [DRY-RUN] Would backup {mountpoint}")
379
+ return BackupResult(
380
+ dataset=dataset,
381
+ success=True,
382
+ skipped=False,
383
+ duration_seconds=0.0,
384
+ )
385
+
386
+ # Ensure namespace exists
387
+ if namespace:
388
+ self.client.create_namespace(namespace)
389
+
390
+ # Run backup
391
+ result = self.client.backup(
392
+ backup_id=backup_id,
393
+ source_path=mountpoint,
394
+ namespace=namespace,
395
+ change_detection_mode=(
396
+ self.change_detection_mode or dataset.change_detection_mode
397
+ ),
398
+ dry_run=self.dry_run,
399
+ )
400
+
401
+ duration = (datetime.now() - start_time).total_seconds()
402
+
403
+ if result.returncode == 0:
404
+ self._log(f" Completed in {duration:.1f}s")
405
+ return BackupResult(
406
+ dataset=dataset,
407
+ success=True,
408
+ duration_seconds=duration,
409
+ backup_time=start_time,
410
+ )
411
+ else:
412
+ error = failure_message(result)
413
+ self._log(f" FAILED: {error}")
414
+ return BackupResult(
415
+ dataset=dataset,
416
+ success=False,
417
+ error=error,
418
+ duration_seconds=duration,
419
+ )
420
+
421
+ def run(self, pattern: str | None = None) -> BackupSummary:
422
+ """Run backups for all discovered datasets.
423
+
424
+ Args:
425
+ pattern: Optional glob pattern to filter datasets
426
+
427
+ Returns:
428
+ BackupSummary with results
429
+ """
430
+ summary = BackupSummary(start_time=datetime.now())
431
+
432
+ datasets = self.discover(pattern)
433
+ summary.unknown_properties = self.unknown_properties
434
+ for name, props in self.unknown_properties.items():
435
+ self._log(f"Warning: {name} has unknown zpbs properties: {', '.join(props)}")
436
+
437
+ if not datasets:
438
+ self._log("No datasets found with zpbs:backup=true")
439
+ summary.end_time = datetime.now()
440
+ return summary
441
+
442
+ datasets = self._reject_misconfigured(datasets, summary)
443
+
444
+ self._preflight_skew_check()
445
+
446
+ plan = self.plan(datasets)
447
+
448
+ self._log(f"Found {len(datasets)} dataset(s) to process")
449
+ if self.dry_run:
450
+ self._log("[DRY-RUN MODE]")
451
+
452
+ for dataset, should_backup, skip_reason, skip_cause, last_backup in plan:
453
+ if last_backup is not None:
454
+ summary.last_backup_times[dataset.name] = last_backup
455
+
456
+ if not should_backup:
457
+ self._log(f"Skipping {dataset.name}: {skip_reason}")
458
+ summary.results.append(
459
+ BackupResult(
460
+ dataset=dataset,
461
+ success=True,
462
+ skipped=True,
463
+ skip_reason=skip_reason,
464
+ skip_cause=skip_cause,
465
+ )
466
+ )
467
+ else:
468
+ result = self.backup_dataset(dataset)
469
+ summary.results.append(result)
470
+ if result.backup_time is not None:
471
+ summary.last_backup_times[dataset.name] = result.backup_time
472
+
473
+ summary.end_time = datetime.now()
474
+
475
+ # Log summary
476
+ self._log("")
477
+ self._log(
478
+ f"Backup complete: {summary.successful} succeeded, "
479
+ f"{summary.failed} failed, {summary.skipped} skipped"
480
+ )
481
+ self._log(f"Total duration: {summary.duration_seconds:.1f}s")
482
+
483
+ return summary
484
+
485
+
486
+ def get_retention_policy(dataset: Dataset) -> RetentionPolicy:
487
+ """Get the retention policy for a dataset.
488
+
489
+ An unset zpbs:retention takes the built-in default. A value that is set
490
+ but unparseable is an error: pruning deletes snapshots, so falling back
491
+ to a default here would apply a policy nobody chose.
492
+
493
+ Args:
494
+ dataset: The dataset
495
+
496
+ Returns:
497
+ RetentionPolicy for this dataset
498
+
499
+ Raises:
500
+ InvalidPropertyError: If zpbs:retention is set but cannot be parsed
501
+ """
502
+ if dataset.retention:
503
+ try:
504
+ return parse_retention(dataset.retention)
505
+ except ValueError as e:
506
+ raise InvalidPropertyError(
507
+ dataset.name,
508
+ PROP_RETENTION,
509
+ dataset.retention,
510
+ f"counts with d/w/m/y suffixes such as 7d,4w,6m,1y ({e})",
511
+ ) from None
512
+ return DEFAULT_RETENTION
513
+
514
+
515
+ class PruneOrchestrator:
516
+ """Orchestrates prune operations."""
517
+
518
+ def __init__(
519
+ self,
520
+ config: PBSConfig,
521
+ dry_run: bool = False,
522
+ output: TextIO | None = None,
523
+ ):
524
+ self.config = config
525
+ self.output = output or sys.stdout
526
+ self.client = PBSClient(config, output=self.output)
527
+ self.hostname = get_hostname()
528
+ self.dry_run = dry_run
529
+
530
+ def _log(self, message: str) -> None:
531
+ print(message, file=self.output)
532
+
533
+ def prune_dataset(self, dataset: Dataset) -> bool:
534
+ """Apply retention policy to a dataset's backups.
535
+
536
+ Args:
537
+ dataset: The dataset to prune
538
+
539
+ Returns:
540
+ True if successful
541
+ """
542
+ backup_id = dataset.get_backup_id(self.hostname, self.config.backup_id_separator)
543
+ namespace = dataset.namespace or dataset.get_auto_namespace(self.hostname)
544
+
545
+ try:
546
+ policy = get_retention_policy(dataset)
547
+ except InvalidPropertyError as e:
548
+ self._log(f"Refusing to prune {dataset.name}: {e}")
549
+ return False
550
+
551
+ self._log(f"Pruning {dataset.name} ({backup_id})")
552
+
553
+ result = self.client.prune(
554
+ backup_type="host",
555
+ backup_id=backup_id,
556
+ keep_daily=policy.keep_daily,
557
+ keep_weekly=policy.keep_weekly,
558
+ keep_monthly=policy.keep_monthly,
559
+ keep_yearly=policy.keep_yearly,
560
+ namespace=namespace,
561
+ dry_run=self.dry_run,
562
+ )
563
+
564
+ return result.returncode == 0
565
+
566
+ def run(self, pattern: str | None = None) -> tuple[int, int]:
567
+ """Prune all discovered datasets.
568
+
569
+ Args:
570
+ pattern: Optional glob pattern to filter datasets
571
+
572
+ Returns:
573
+ Tuple of (success_count, failure_count)
574
+ """
575
+ datasets = discover_datasets()
576
+
577
+ if pattern:
578
+ datasets = [ds for ds in datasets if fnmatch.fnmatch(ds.name, pattern)]
579
+
580
+ if not datasets:
581
+ self._log("No datasets found with zpbs:backup=true")
582
+ return 0, 0
583
+
584
+ self._log(f"Found {len(datasets)} dataset(s) to prune")
585
+ if self.dry_run:
586
+ self._log("[DRY-RUN MODE]")
587
+
588
+ success = 0
589
+ failed = 0
590
+
591
+ for dataset in datasets:
592
+ if self.prune_dataset(dataset):
593
+ success += 1
594
+ else:
595
+ failed += 1
596
+
597
+ self._log("")
598
+ self._log(f"Prune complete: {success} succeeded, {failed} failed")
599
+
600
+ return success, failed