libvirt.py 11.3 KB
Newer Older
Nigel Kukard's avatar
Nigel Kukard committed
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
"""AWIT Backstep LibvirtDomain datasource."""
#
# SPDX-License-Identifier: GPL-3.0-or-later
#
# Copyright (C) 2019-2020, AllWorldIT.
#
# This program is free software: you can redistribute it and/or modify
# it under the terms of the GNU General Public License as published by
# the Free Software Foundation, either version 3 of the License, or
# (at your option) any later version.
#
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
# GNU General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with this program.  If not, see <https://www.gnu.org/licenses/>.

import os
import subprocess  # nosec
from datetime import datetime
from typing import Dict, List, Optional
from xml.etree import ElementTree  # nosec

import libvirt

from ..notifiers import NotifierList
from ..util import chunks
from . import BackupArchive, BackupSet, DatasourceError, DatasourcePluginBase

__VERSION__ = '0.1.0'


class LibvirtBackupArchive(BackupArchive):
    """Libvirt domain class."""

    _snapshot_files: Dict[str, Dict]
    _libvirt_domain: libvirt.virDomain  # noqa: E1101
    _libvirt_domain_name: str
    _notifiers: NotifierList

    def __init__(self, libvirt_conn: libvirt.virConnect, archive_name: str, domain_name: str,  # noqa: E1101
                 notifiers: NotifierList):
        """
        Inititalize the object.

        :param libvirt_conn: Connection to libvirt
        :type libvirt_conn: libvirt.virConnect
        :param archive_name: Backup archive name
        :type archive_name: str
        :param domain_name: Libvirt virtual machine domain name we'll be backing up
        :type domain_name: str
        """

        # Use super to initialize the base class
        super().__init__(name=archive_name, notifiers=notifiers)

        # Loopkup the libvirt domain from the domain name we go
        self._libvirt_domain = libvirt_conn.lookupByName(domain_name)
        # Save the actual domain name in libvirt
        self._libvirt_domain_name = self.libvirt_domain.name()

        # Grab our disks
        self._disks = self._get_disks()
        # Snapshot files, once we're run pre-backup
        self._snapshot_files = {}

        # Output info about the disks we're going to backup
        self.notifiers.info(f'Adding VM "{self.libvirt_domain_name}" disk images to archive "{archive_name}":')
        for _, disk in self.disks:
            self.notifiers.info(
                f'  - ' +
                ', '.join([f'{x}={y}' for x, y in disk.items()])
            )

    def pre_backup(self):
        """Pre-backup method, where we snapshot the disks."""

        print('PRE-BACKUP')

        # Add disks to path list
        for disk_name, disk in self.disks:
            if disk['type'] == 'qcow2':
                self.notifiers.debug(f'VM "{self.libvirt_domain_name}" disk "{disk_name}" has path "{disk["path"]}"')
                self.add_path(disk['path'])

        # FSTRIM the VM
        self._fstrim()

        # Snapshot disks
        self._create_snapshot()

    def post_backup(self):
        """Post-backup method, where we recover the snapshots."""

        # Commit our snapshot afterwards
        self._commit_snapshot()

    def _create_snapshot(self):
        """Create a snapshot."""

        snapshot_name = datetime.now().strftime('awitbackstep-snapshot-%Y%m%d%H%M%S')

        snapshot_args = [
            # We use snapshot-create-as to generate the XML just incase options differe from if we used XML ourselves
            'virsh', 'snapshot-create-as',
            '--domain', self.libvirt_domain_name,
            snapshot_name,
            '--disk-only', '--atomic', '--quiesce', '--no-metadata',
        ]

        # Loop with disks and add them to the snapshot
        for device, disk in self.disks:
            # Make sure disk is qcow2
            if disk['type'] != 'qcow2':
                self.notifiers.warning(f'VM "{self.libvirt_domain_name}" disk "{device}" will NOT be backed up as type '
                                       'is not "qcow2"')
                continue

            # Create a snapshot file
            snapshot_file = f'{disk["path"]}.{snapshot_name}'
            # Build disk spec
            snapshot_args.append('--diskspec')
            snapshot_args.append(f'{device},file={snapshot_file}')
            # Add snapshot file
            self._snapshot_files[device] = snapshot_file

        self.notifiers.info('Snapshotting started')
        self.notifiers.debug(f'Running: {snapshot_args}')
        try:
            # Create process and monitor status
            process = subprocess.Popen(snapshot_args, stdout=subprocess.PIPE, stderr=subprocess.STDOUT)  # nosec
            # Loop with output
            for line in chunks(process.stdout):
                self.notifiers.info(f' - {line}')
        except KeyboardInterrupt:
            self.notifiers.error('Snapshotting failed')
            raise DatasourceError(f'Keyboard interrupt while snapshotting VM "{self.libvirt_domain_name}"') from None
        except subprocess.CalledProcessError as err:
            self.notifiers.error('Snapshotting failed')
            raise DatasourceError(f'Error running "virsh snapshot-create-as" on VM "{self.libvirt_domain_name}", '
                                  f'exited with code {err.returncode}') from None
        except OSError as err:
            self.notifiers.error('Snapshotting failed')
            raise DatasourceError(f'OS error running "virsh snapshot-create-as" on VM "{self.libvirt_domain_name}", '
                                  f'exited with => {err}') from None
        self.notifiers.info('Snapshotting done')

    def _fstrim(self):
        """Notify the domain to start an FSTRIM."""
        self.notifiers.info('FSTRIM started')
        self.libvirt_domain.fSTrim(None, 0)
        self.notifiers.info('FSTRIM done')

    def _commit_snapshot(self):
        """Commit a domain snapshot."""

        # Loop with snapshotted devices
        for device, snapshot_filename in sorted(self._snapshot_files.items()):
            # Try do a commit
            try:
                self.notifiers.info(f'Starting snapshot commit for disk "{device}"')
                commit_args = ['virsh', 'blockcommit', self.libvirt_domain_name, device, '--pivot', '--verbose']
                self.notifiers.debug(f'Running: {commit_args}')
                # Create process and monitor status
                process = subprocess.Popen(commit_args, stdout=subprocess.PIPE, stderr=subprocess.STDOUT)  # nosec
                # Loop with output
                for line in chunks(process.stdout, delim='\n\r'):
                    self.notifiers.info(f' - {line}')
            except KeyboardInterrupt:
                self.notifiers.error('Snapshot commit failed')
                raise DatasourceError(f'Keyboard interrupt during snapshot commit VM "{self.libvirt_domain_name}"') from None
            except subprocess.CalledProcessError as err:
                self.notifiers.error('Snapshot commit failed')
                raise DatasourceError(f'Error running "virsh blockcommit" on VM "{self.libvirt_domain_name}", '
                                      f'exited with code {err.returncode}')
            except OSError as err:
                self.notifiers.error('Snapshot commit failed')
                raise DatasourceError(f'OS error running "virsh blockcommit" on VM "{self.libvirt_domain_name}", '
                                      f'exited with => {err}') from None
            self.notifiers.info(f'Completed snapshot commit for disk "{device}"')

            # Remove dangling snapshot file
            self.notifiers.info(f'Removing snapshot file')
            os.unlink(snapshot_filename)
            del self._snapshot_files[device]

        return 0

    def _get_disks(self):
        """Get all the domains disk info."""
        # Grab root node
        root = ElementTree.fromstring(self.libvirt_domain.XMLDesc())  # nosec

        # Search <disk type='file' device='disk'> entries
        disks = root.findall("./devices/disk[@device='disk']")

        # For every disk get drivers, sources and targets
        drivers = [disk.find('driver').attrib for disk in disks]
        sources = [disk.find('source').attrib for disk in disks]
        targets = [disk.find('target').attrib for disk in disks]

        # Go over drivers, sources and targets
        if len(drivers) != len(sources) != len(targets):
            raise RuntimeError('Drivers, sources and targets lengths are different %s:%s:%s' % (
                len(drivers), len(sources), len(targets)))

        # Create a list of our disks
        disks_info = {}
        for i, source in enumerate(sources):
            disk_info = {}
            disk_info['device'] = targets[i]['dev']
            disk_info['bus'] = targets[i]['bus']
            if 'dev' in source:
                disk_info['path'] = source['dev']
            elif 'file' in source:
                disk_info['path'] = source['file']
            else:
                raise RuntimeError('Failed to find "dev" or "file" in sources[i]')

            disk_info['type'] = drivers[i]['type']
            # Check if we support discard
            if ('discard' in drivers[i]) and (drivers[i]['discard'] == "unmap"):
                disk_info['supports_discard'] = True
            else:
                disk_info['supports_discard'] = False
            # Add disk to list
            disks_info[disk_info['device']] = disk_info

        return disks_info

    @property
    def disks(self):
        """Return a list of our disks."""
        return sorted(self._disks.items())

    @property
    def libvirt_domain(self):
        """Libvirt domain property."""
        return self._libvirt_domain

    @property
    def libvirt_domain_name(self):
        """Libvirt domain name property."""
        return self._libvirt_domain_name


class LibvirtPlugin(DatasourcePluginBase):
    """Libvirt Plugin."""

    _name = 'libvirt'

    def get_backup_set(self, backup_items: List[str], backup_name: Optional[str]) -> BackupSet:
        """Parse the backup items into backup archives and paths."""

        # Create backup set
        backup_set = BackupSet()

        # Work out archive name...
        if backup_name:
            archive_basename = f'{backup_name}-libvirt'
        else:
            archive_basename = 'libvirt'

        # Grab connection to libvirt
        self.notifiers.info('Connecting to libvirtd')
        libvirt_conn = libvirt.open("qemu:///system")  # noqa: E1101
        self.notifiers.info('Connected to libvirtd')

        # Loop with each VM and add the backup archive for it, containing all the disks...
        for vm_name in backup_items:
            archive_name = f'{archive_basename}-{vm_name}'
            # Create the backup archive
            try:
                libvirt_archive = LibvirtBackupArchive(
                    libvirt_conn=libvirt_conn,
                    archive_name=archive_name,
                    domain_name=vm_name,
                    notifiers=self.notifiers
                )
                # Add add it to the set
                backup_set.add_archive(libvirt_archive)

            except libvirt.libvirtError as err:  # noqa: E1101
                self.notifiers.error(f'libvirt error: {err}')

        return backup_set