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.
- zpbs_backup/__init__.py +3 -0
- zpbs_backup/backup.py +600 -0
- zpbs_backup/cli.py +761 -0
- zpbs_backup/config.py +360 -0
- zpbs_backup/lock.py +60 -0
- zpbs_backup/metrics.py +220 -0
- zpbs_backup/notify.py +248 -0
- zpbs_backup/pbs.py +537 -0
- zpbs_backup/retention.py +143 -0
- zpbs_backup/scheduler.py +175 -0
- zpbs_backup/zfs.py +544 -0
- zpbs_backup-0.11.0.dist-info/METADATA +638 -0
- zpbs_backup-0.11.0.dist-info/RECORD +16 -0
- zpbs_backup-0.11.0.dist-info/WHEEL +4 -0
- zpbs_backup-0.11.0.dist-info/entry_points.txt +2 -0
- zpbs_backup-0.11.0.dist-info/licenses/LICENSE +21 -0
zpbs_backup/__init__.py
ADDED
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
|