virt-back 0.2.5__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,665 @@
1
+ #!python
2
+
3
+ ############################################
4
+ # Program: virt-back.py
5
+ # Author: russell.ballestrini.net
6
+ # Date: Fri Mar 18 15:56:59 EDT 2011
7
+ # License: Public Domain
8
+ ############################################
9
+
10
+ DESCRIPTION = """A backup utility for QEMU, KVM, XEN, and Virtualbox guests.
11
+ Virt-back is a python application that uses the libvirt api to safely
12
+ shutdown, gzip, and restart guests. The backup process logs to syslog
13
+ for auditing and virt-back works great with cron for scheduling outages.
14
+ Virt-back has been placed in the public domain and
15
+ the latest version may be downloaded here:
16
+ https://git.unturf.com/python/virt-back
17
+ """
18
+
19
+ """The variable doms represents a list of libvirt dom objects.
20
+ Use the Domfetcher class to acquire a list of dom objects."""
21
+
22
+ import libvirt
23
+ import tarfile
24
+ import syslog
25
+ import re
26
+ from time import sleep
27
+ from datetime import date
28
+ from sys import exit
29
+ import argparse
30
+ from os import path, remove
31
+ from shutil import move, copy2
32
+ import subprocess
33
+
34
+ try:
35
+ from operator import methodcaller
36
+ except ImportError:
37
+
38
+ def methodcaller(name, *args, **kwargs):
39
+ def caller(obj):
40
+ return getattr(obj, name)(*args, **kwargs)
41
+
42
+ return caller
43
+
44
+
45
+ class Domfetcher(object):
46
+ """Abstract libvirt API, supply methods to return dom object lists"""
47
+
48
+ def __init__(self, uri=None):
49
+ """Connect to hypervisor uri with read write access"""
50
+ # register logit as the error handler
51
+ libvirt.registerErrorHandler(logit, "libvirt error")
52
+
53
+ try:
54
+ self.c = libvirt.open(uri)
55
+ except:
56
+ self.c = None
57
+ logit("libvirt error", "Failed to open connection to the hypervisor")
58
+
59
+ def get_running_doms(self):
60
+ """Return a list of running dom objects"""
61
+ doms = []
62
+ for id in self.c.listDomainsID(): # loop over the running ids
63
+ dom = self.c.lookupByID(id) # fetch dom object by id
64
+ if "Domain-" not in dom.name(): # prevent actions on the Xen hypervisor
65
+ doms.append(dom) # append dom object to doms
66
+ return doms
67
+
68
+ def get_shutoff_doms(self):
69
+ """Return a list of all shutoff but defined dom objects"""
70
+ return [self.c.lookupByName(name) for name in self.c.listDefinedDomains()]
71
+
72
+ def get_doms_by_names(self, guest_names):
73
+ """Accept a list of guest_names, return a list of related dom objects"""
74
+ doms = []
75
+ for name in guest_names:
76
+ try:
77
+ dom = self.c.lookupByName(name)
78
+ doms.append(dom)
79
+ except libvirt.libvirtError:
80
+ pass # logit reg'd as libvirt error handler
81
+ return doms
82
+
83
+ def get_all_doms(self):
84
+ """Return a list of all dom objects"""
85
+ return self.get_running_doms() + self.get_shutoff_doms()
86
+
87
+
88
+ def invoke(doms, method):
89
+ """Pattern to invoke shutdown, destroy, and start on a list of doms"""
90
+ f = methodcaller(method)
91
+ for dom in doms:
92
+ try:
93
+ logit(method, "invoking %s on %s" % (method, dom.name()))
94
+ retcode = f(dom)
95
+ if retcode: # log retcode
96
+ logit(
97
+ method,
98
+ "{0} returned {1} on {2}".format(method, retcode, dom.name()),
99
+ )
100
+ except libvirt.libvirtError:
101
+ pass
102
+
103
+
104
+ def backup(doms):
105
+ """Accept a list of dom objects, run backup procedure on each"""
106
+ for dom in doms:
107
+ recreate = dom.isActive()
108
+
109
+ if dom.isActive(): # if dom is active, shutdown
110
+ shutdown([dom])
111
+
112
+ if dom.isActive(): # if dom is active, error
113
+ logit(
114
+ "error",
115
+ "unable to shutdown or destroy %s and BACKUP FAILED!" % dom.name(),
116
+ )
117
+ continue # skip to the next dom
118
+
119
+ xml = dom.XMLDesc(0)
120
+ xmlfile = path.join(options.backpath, dom.name() + ".xml")
121
+ with open(xmlfile, "w") as f:
122
+ f.write(xml)
123
+
124
+ # Updated regular expression to match file= and dev= within <disk> elements
125
+ disklist = re.findall(
126
+ r"<disk.*?<source (?:file|dev)='(.*?)'.*?</disk>", xml, re.DOTALL
127
+ )
128
+
129
+ logit("backup", "invoking backup for " + dom.name())
130
+
131
+ for disk_source in disklist:
132
+ if disk_source.endswith(".iso"):
133
+ logit(
134
+ "backup", "skipping ISO file %s for %s" % (disk_source, dom.name())
135
+ )
136
+ continue
137
+
138
+ if is_zfs_dataset(disk_source):
139
+ # Handle ZFS dataset
140
+ zfs_dataset = (
141
+ disk_source[len("/dev/zvol/") :]
142
+ if disk_source.startswith("/dev/zvol/")
143
+ else disk_source
144
+ )
145
+ zfs_snapshot_base = f"{zfs_dataset}@backup-{TODAY}"
146
+ zfs_snapshot = zfs_snapshot_base
147
+ suffix = 1
148
+
149
+ # Increment snapshot name until an available name is found
150
+ while (
151
+ subprocess.run(
152
+ ["zfs", "list", zfs_snapshot],
153
+ stdout=subprocess.PIPE,
154
+ stderr=subprocess.PIPE,
155
+ ).returncode
156
+ == 0
157
+ ):
158
+ zfs_snapshot = f"{zfs_snapshot_base}-{suffix}"
159
+ suffix += 1
160
+
161
+ zfs_file = path.join(options.backpath, f"{dom.name()}-{TODAY}.zfs")
162
+ if not options.nogzip:
163
+ zfs_file += ".gz"
164
+
165
+ # Create ZFS snapshot
166
+ logit(
167
+ "backup", f"creating ZFS snapshot {zfs_snapshot} for {dom.name()}"
168
+ )
169
+ try:
170
+ subprocess.run(["zfs", "snapshot", zfs_snapshot], check=True)
171
+ except subprocess.CalledProcessError as e:
172
+ logit("error", f"Failed to create ZFS snapshot {zfs_snapshot}: {e}")
173
+ continue
174
+
175
+ # Send ZFS snapshot to file with optional compression
176
+ logit(
177
+ "backup",
178
+ f"sending ZFS snapshot {zfs_snapshot} to {zfs_file} for {dom.name()}",
179
+ )
180
+ try:
181
+ with open(zfs_file, "wb") as f:
182
+ if options.nogzip:
183
+ subprocess.run(
184
+ ["zfs", "send", zfs_snapshot], stdout=f, check=True
185
+ )
186
+ else:
187
+ send_proc = subprocess.Popen(
188
+ ["zfs", "send", zfs_snapshot], stdout=subprocess.PIPE
189
+ )
190
+ gzip_proc = subprocess.Popen(
191
+ ["gzip"], stdin=send_proc.stdout, stdout=f
192
+ )
193
+ send_proc.stdout.close() # Allow send_proc to receive a SIGPIPE if gzip_proc exits
194
+ gzip_proc.communicate()
195
+ except subprocess.CalledProcessError as e:
196
+ logit("error", f"Failed to send ZFS snapshot {zfs_snapshot}: {e}")
197
+ continue
198
+
199
+ # Prune old @backup-* snapshots, keep newest `retention`
200
+ prune_zfs_snapshots(zfs_dataset, options.retention)
201
+ else:
202
+ # Handle QCOW2 or other file-based disk
203
+ logit("backup", f"{disk_source} is not a ZFS dataset")
204
+ disk_file = disk_source.split("/")[-1]
205
+ disk_dest = path.join(options.backpath, disk_file)
206
+
207
+ logit(
208
+ "backup",
209
+ "copying %s to %s for %s" % (disk_source, disk_dest, dom.name()),
210
+ )
211
+ copy2(disk_source, disk_dest)
212
+
213
+ if recreate: # if true, start guest after backup
214
+ create([dom]) # start dom
215
+
216
+ ext, tarmode = ".tar.gz", "w:gz"
217
+ if options.nogzip:
218
+ ext, tarmode = ".tar", "w"
219
+
220
+ tarfilename = dom.name() + ext
221
+ if options.tardate:
222
+ tarfilename = dom.name() + "-" + TODAY + ext
223
+
224
+ tarpath = path.join(options.backpath, tarfilename)
225
+
226
+ if path.isfile(tarpath): # if file exists, run rotate
227
+ logit("backup", "rotating backup files for " + dom.name())
228
+ rotate(tarpath, options.retention)
229
+
230
+ logit("backup", "archiving files for %s to %s" % (dom.name(), tarpath))
231
+ tar = tarfile.open(tarpath, tarmode)
232
+
233
+ logit("backup", "archiving %s for %s" % (xmlfile, dom.name()))
234
+ tar.add(xmlfile) # add xml to tar
235
+ remove(xmlfile) # cleanup tmp files
236
+
237
+ for disk_source in disklist:
238
+ if disk_source.endswith(".iso"):
239
+ continue
240
+
241
+ if is_zfs_dataset(disk_source):
242
+ zfs_file = path.join(options.backpath, f"{dom.name()}-{TODAY}.zfs")
243
+ if not options.nogzip:
244
+ zfs_file += ".gz"
245
+ if path.isfile(zfs_file):
246
+ logit("backup", "archiving %s for %s" % (zfs_file, dom.name()))
247
+ tar.add(zfs_file) # add zfs snapshot to tar
248
+ remove(zfs_file) # cleanup tmp files
249
+ else:
250
+ logit(
251
+ "error",
252
+ f"ZFS snapshot file {zfs_file} not found for {dom.name()}",
253
+ )
254
+ else:
255
+ disk_file = disk_source.split("/")[-1]
256
+ disk_dest = path.join(options.backpath, disk_file)
257
+ logit("backup", "archiving %s for %s" % (disk_dest, dom.name()))
258
+ tar.add(disk_dest) # add img to tar
259
+ remove(disk_dest) # cleanup tmp files
260
+
261
+ tar.close()
262
+
263
+ logit("backup", "finished backup for " + dom.name())
264
+
265
+
266
+ def is_zfs_dataset(disk_source):
267
+ """Check if the disk source is a ZFS dataset"""
268
+ logit("backup", f"checking if {disk_source} is a ZFS dataset")
269
+ try:
270
+ # Extract the ZFS dataset name from the device path
271
+ if disk_source.startswith("/dev/zvol/"):
272
+ zfs_dataset = disk_source[len("/dev/zvol/") :]
273
+ else:
274
+ zfs_dataset = disk_source
275
+
276
+ result = subprocess.run(
277
+ ["zfs", "list", zfs_dataset], stdout=subprocess.PIPE, stderr=subprocess.PIPE
278
+ )
279
+ is_zfs = result.returncode == 0
280
+ logit("backup", f"{zfs_dataset} is {'a' if is_zfs else 'not a'} ZFS dataset")
281
+ return is_zfs
282
+ except Exception as e:
283
+ logit("error", f"Error checking ZFS dataset: {e}")
284
+ return False
285
+
286
+
287
+ def prune_zfs_snapshots(zfs_dataset, retention):
288
+ """List @backup-* snapshots on dataset, destroy oldest until count <= retention.
289
+
290
+ Only touches snapshots virt-back created (@backup-* prefix). Never destroys
291
+ user or TrueNAS periodic snapshots. Never destroys more than necessary —
292
+ `retention` snapshots always survive as the recovery floor.
293
+ """
294
+ if retention < 1:
295
+ logit("error", f"retention must be >= 1, got {retention} — skipping prune")
296
+ return
297
+
298
+ try:
299
+ result = subprocess.run(
300
+ ["zfs", "list", "-t", "snapshot", "-H", "-o", "name",
301
+ "-s", "creation", zfs_dataset],
302
+ stdout=subprocess.PIPE, stderr=subprocess.PIPE, check=True, text=True,
303
+ )
304
+ except subprocess.CalledProcessError as e:
305
+ logit("error", f"Failed to list snapshots for {zfs_dataset}: {e}")
306
+ return
307
+
308
+ prefix = f"{zfs_dataset}@backup-"
309
+ snaps = [line for line in result.stdout.splitlines() if line.startswith(prefix)]
310
+
311
+ surplus = len(snaps) - retention
312
+ if surplus <= 0:
313
+ return
314
+
315
+ for snap in snaps[:surplus]:
316
+ logit("backup", f"pruning old ZFS snapshot {snap} (retention={retention})")
317
+ try:
318
+ subprocess.run(["zfs", "destroy", snap], check=True)
319
+ except subprocess.CalledProcessError as e:
320
+ logit("error", f"Failed to destroy snapshot {snap}: {e}")
321
+
322
+
323
+ def shutdown(doms, wait=180):
324
+ """Accept a list of dom objects, attempt to shutdown the active ones"""
325
+ # get all running guests from list and invoke shutdown
326
+ invoke(get_all_running(doms), "shutdown")
327
+
328
+ """loop until all guests are shut off or destroy
329
+ all active guests if wait timer is reached."""
330
+
331
+ secs = 10
332
+ wait /= secs # divide wait by secs
333
+
334
+ wait = int(wait)
335
+
336
+ for i in range(0, wait + 1):
337
+
338
+ # if all doms are shut off, leave loop
339
+ if check_all_shutoff(doms):
340
+ break
341
+ else:
342
+ logit(
343
+ "shutdown",
344
+ "waited "
345
+ + str(i * secs)
346
+ + " seconds for "
347
+ + ", ".join(dom.name() for dom in get_all_running(doms))
348
+ + " to shut off",
349
+ )
350
+
351
+ # if the wait time is reached, destroy all active doms
352
+ if i == wait:
353
+ invoke(get_all_running(doms), "destroy")
354
+
355
+ sleep(secs)
356
+
357
+
358
+ def create(doms):
359
+ """Accept a list of dom objects, attempt to start the inactive ones"""
360
+ # get all shutoff guests from list and invoke create
361
+ invoke(get_all_shutoff(doms), "create")
362
+
363
+
364
+ def reboot(doms):
365
+ """Accept a list of dom objects, attempt to shutdown then start"""
366
+ shutdown(doms)
367
+ create(doms)
368
+
369
+
370
+ def info(doms):
371
+ """Accept a list of dom objects, attempt to display info for all"""
372
+ # invoke( doms, 'name' )
373
+ # invoke( doms, 'info')
374
+ if check_all_running(doms):
375
+ print("NOTE: All guests are running")
376
+ if check_all_shutoff(doms):
377
+ print("NOTE: All guests are shut off")
378
+
379
+ print("")
380
+ print("running guests: " + ", ".join([dom.name() for dom in get_all_running(doms)]))
381
+ print("shutoff guests: " + ", ".join([dom.name() for dom in get_all_shutoff(doms)]))
382
+ print("")
383
+ print(
384
+ "DomName".ljust(16)
385
+ + "Memory MB".rjust(12)
386
+ + "vCPUs".rjust(8)
387
+ + "CPUtime ms".rjust(18)
388
+ )
389
+ print("======================================================")
390
+ for dom in doms:
391
+ name = dom.name()
392
+ rams = str(dom.info()[2] / 1024) + "/" + str(dom.info()[1] / 1024)
393
+ cpus = str(dom.info()[3])
394
+ time = str(dom.info()[4] / 1000000)
395
+ print(name.ljust(16) + rams.rjust(12) + cpus.rjust(8) + time.rjust(18))
396
+
397
+
398
+ def check_all_running(doms):
399
+ """Accept a list of dom objects, check if all guest dom are active"""
400
+ if sum([dom.isActive() for dom in doms]) == len(doms):
401
+ return True
402
+ return False
403
+
404
+
405
+ def check_all_shutoff(doms):
406
+ """Accept a list of dom objects, check if all guest dom are shut off"""
407
+ if sum([dom.isActive() for dom in doms]):
408
+ return False
409
+ return True
410
+
411
+
412
+ def get_all_running(doms):
413
+ """Accept a list of dom objects, return a list of running dom objects"""
414
+ return [dom for dom in doms if dom.isActive()]
415
+
416
+
417
+ def get_all_shutoff(doms):
418
+ """Accept a list of dom objects, return a list of shutoff dom objects"""
419
+ return [dom for dom in doms if not dom.isActive()]
420
+
421
+
422
+ def logit(context, message, quiet=False):
423
+ """syslog and error handler"""
424
+ if type(message) is tuple:
425
+ message = message[2] # libvirt message is a tuple
426
+
427
+ try:
428
+ quiet = options.quiet
429
+ except NameError:
430
+ pass
431
+
432
+ if quiet:
433
+ pass
434
+ else:
435
+ print(context + ": " + message)
436
+
437
+ syslog.openlog("virt-back", 0, syslog.LOG_LOCAL3)
438
+ syslog.syslog(message)
439
+ syslog.closelog()
440
+
441
+
442
+ def rotate(target, retention=3):
443
+ """file rotation routine"""
444
+ for i in range(retention - 2, 0, -1): # count backwards
445
+ old_name = "%s.%s" % (target, i)
446
+ new_name = "%s.%s" % (target, i + 1)
447
+ try:
448
+ move(old_name, new_name)
449
+ except IOError:
450
+ pass
451
+ move(target, target + ".1")
452
+
453
+
454
+ def getoptions():
455
+ """Fetch cli args, parse and map to python, test sanity"""
456
+
457
+ # create an argument parser object
458
+ parser = argparse.ArgumentParser(description=DESCRIPTION)
459
+
460
+ parser.add_argument(
461
+ "-q",
462
+ "--quiet",
463
+ dest="quiet",
464
+ action="store_true",
465
+ default=False,
466
+ help="prevent output to stdout",
467
+ )
468
+
469
+ parser.add_argument(
470
+ "-d",
471
+ "--date",
472
+ dest="tardate",
473
+ action="store_true",
474
+ default=False,
475
+ help="append date to tar filename [default: no date]",
476
+ )
477
+
478
+ parser.add_argument(
479
+ "-g",
480
+ "--no-gzip",
481
+ dest="nogzip",
482
+ action="store_true",
483
+ default=False,
484
+ help="do not gzip the resulting tar file",
485
+ )
486
+
487
+ parser.add_argument(
488
+ "-a",
489
+ "--retention",
490
+ dest="retention",
491
+ metavar="amount",
492
+ default=3,
493
+ type=int,
494
+ help="backups to retain [default: 3]",
495
+ )
496
+
497
+ parser.add_argument(
498
+ "-p",
499
+ "--path",
500
+ dest="backpath",
501
+ metavar="'PATH'",
502
+ default="/KVMBACK",
503
+ help="backup path [default: '/KVMBACK']",
504
+ )
505
+
506
+ parser.add_argument(
507
+ "-u", "--uri", dest="uri", metavar="'URI'", help="optional hypervisor uri"
508
+ )
509
+
510
+ # Actions for info testing: These options display info/ test a list of guests only.
511
+ info_group = parser.add_argument_group(
512
+ "Actions for info testing",
513
+ "These options display info or test a list of guests.",
514
+ )
515
+
516
+ info_group.add_argument(
517
+ "-i",
518
+ "--info",
519
+ dest="info",
520
+ action="store_true",
521
+ default=False,
522
+ help="info/test a list of guests (space delimited dom names)",
523
+ )
524
+
525
+ info_group.add_argument(
526
+ "--info-all",
527
+ dest="infoall",
528
+ action="store_true",
529
+ default=False,
530
+ help="attempt to show info on ALL guests",
531
+ )
532
+
533
+ # WARNING: Dangerous options below, option grouping for scary actions
534
+ action_group = parser.add_argument_group(
535
+ "Actions for a list of dom names",
536
+ "WARNING: These options WILL bring down guests!",
537
+ )
538
+
539
+ action_group.add_argument(
540
+ "-b",
541
+ "--backup",
542
+ dest="backup",
543
+ action="store_true",
544
+ default=False,
545
+ help="backup a list of guests (space delimited dom names)",
546
+ )
547
+
548
+ action_group.add_argument(
549
+ "-r",
550
+ "--reboot",
551
+ dest="reboot",
552
+ action="store_true",
553
+ default=False,
554
+ help="reboot a list of guests (space delimited dom names)",
555
+ )
556
+
557
+ action_group.add_argument(
558
+ "-s",
559
+ "--shutdown",
560
+ dest="shutdown",
561
+ action="store_true",
562
+ default=False,
563
+ help="shutdown a list of guests (space delimited dom names)",
564
+ )
565
+
566
+ action_group.add_argument(
567
+ "-c",
568
+ "--create",
569
+ dest="create",
570
+ action="store_true",
571
+ default=False,
572
+ help="start a list of guests (space delimited dom names)",
573
+ )
574
+
575
+ all_group = parser.add_argument_group(
576
+ "Actions for all doms", "WARNING: These options WILL bring down ALL guests!"
577
+ )
578
+
579
+ all_group.add_argument(
580
+ "--backup-all",
581
+ dest="backupall",
582
+ action="store_true",
583
+ default=False,
584
+ help="attempt to shutdown, backup, and start ALL guests",
585
+ )
586
+
587
+ all_group.add_argument(
588
+ "--reboot-all",
589
+ dest="rebootall",
590
+ action="store_true",
591
+ default=False,
592
+ help="attempt to shutdown and then start ALL guests",
593
+ )
594
+
595
+ all_group.add_argument(
596
+ "--shutdown-all",
597
+ dest="shutdownall",
598
+ action="store_true",
599
+ default=False,
600
+ help="attempt to shutdown ALL guests",
601
+ )
602
+
603
+ all_group.add_argument(
604
+ "--create-all",
605
+ dest="createall",
606
+ action="store_true",
607
+ default=False,
608
+ help="attempt to start ALL guests",
609
+ )
610
+
611
+ # parse options and args
612
+ options, unknown_args = parser.parse_known_args()
613
+
614
+ # the actionsum should be 1 to continue, bool math ftw
615
+ actions = [
616
+ options.backup,
617
+ options.reboot,
618
+ options.shutdown,
619
+ options.create,
620
+ options.info,
621
+ options.backupall,
622
+ options.rebootall,
623
+ options.shutdownall,
624
+ options.createall,
625
+ options.infoall,
626
+ ]
627
+ actionsum = sum(actions)
628
+
629
+ if actionsum == 1:
630
+ guest_names = unknown_args
631
+ return options, guest_names
632
+ else:
633
+ exit(
634
+ "\nYou must have 1 action, no more, no less.\n\nRun 'virt-back --help' for help.\n"
635
+ )
636
+
637
+
638
+ if __name__ == "__main__":
639
+ TODAY = str(date.today())
640
+ # Get the list of options and list of guest_names from cli
641
+ options, guest_names = getoptions()
642
+ # connect to hypervisor with Domfetcher (read-only)
643
+ domfetcher = Domfetcher(options.uri)
644
+
645
+ if (
646
+ options.backup
647
+ or options.reboot
648
+ or options.shutdown
649
+ or options.create
650
+ or options.info
651
+ ):
652
+ doms = domfetcher.get_doms_by_names(guest_names)
653
+ else:
654
+ doms = domfetcher.get_all_doms()
655
+
656
+ if options.backup or options.backupall:
657
+ backup(doms)
658
+ if options.reboot or options.rebootall:
659
+ reboot(doms)
660
+ if options.shutdown or options.shutdownall:
661
+ shutdown(doms)
662
+ if options.create or options.createall:
663
+ create(doms)
664
+ if options.info or options.infoall:
665
+ info(doms)