cinder-zfs-driver 0.9.0__tar.gz

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,61 @@
1
+ Metadata-Version: 2.3
2
+ Name: cinder-zfs-driver
3
+ Version: 0.9.0
4
+ Summary: Cinder ZFS driver for FreeBSD
5
+ Author: joshiggins
6
+ Author-email: joshiggins <josh@joshh.info>
7
+ License: Apache-2.0
8
+ Requires-Python: >=3.12
9
+ Description-Content-Type: text/markdown
10
+
11
+ # cinder-zfs-driver
12
+
13
+ A Cinder volume driver for FreeBSD that stores volumes as ZFS zvols.
14
+
15
+ ## Status
16
+
17
+ ### Features
18
+
19
+ - Volume create, delete, extend, clone and revert to snapshot
20
+ - Snapshots are ZFS snapshots, with deferred destroy while clones depend on them
21
+ - Glance image download to a volume and volume upload to an image
22
+ - Local attach to a nova-bhyve instance on the same host
23
+ - Remote iSCSI volume export served by CTL
24
+ - Sparse or fully reserved zvols on disk
25
+
26
+ ### Roadmap
27
+
28
+ - Remote iSCSI attach / os-brick support
29
+ - Snapshot export over iSCSI
30
+ - Per-initiator ACLs, mutual CHAP and multipath portals
31
+ - Volume migration between two backends using `zfs send` / `zfs receive`
32
+ - Add support for NVMe over Fabrics remote attach
33
+
34
+ ### Limitations
35
+
36
+ - No remote attach / os-brick support so nova and the cinder backend must run on the same host
37
+ - Remote snapshot connections are refused
38
+
39
+ ## Host requirements
40
+
41
+ `zfs allow` delegation is needed for the dataset holding volumes.
42
+
43
+ Remote export needs ctld running against the driver's config file:
44
+
45
+ ```sh
46
+ sysrc ctld_flags="-f /var/db/cinder/ctl.conf" ctld_enable=YES
47
+ service ctld start
48
+ ```
49
+
50
+ ## Configuration
51
+
52
+ ```ini
53
+ [zfs]
54
+ volume_driver = cinder_zfs_driver.driver.ZFSVolumeDriver
55
+ volume_backend_name = ZFS
56
+ zfs_dataset = zroot/cinder
57
+ zfs_sparse_volumes = true
58
+ use_chap_auth = true
59
+ # zfs_ctld_config = /var/db/cinder/ctl.conf
60
+ # zfs_local_hosts = alias1,alias2
61
+ ```
@@ -0,0 +1,51 @@
1
+ # cinder-zfs-driver
2
+
3
+ A Cinder volume driver for FreeBSD that stores volumes as ZFS zvols.
4
+
5
+ ## Status
6
+
7
+ ### Features
8
+
9
+ - Volume create, delete, extend, clone and revert to snapshot
10
+ - Snapshots are ZFS snapshots, with deferred destroy while clones depend on them
11
+ - Glance image download to a volume and volume upload to an image
12
+ - Local attach to a nova-bhyve instance on the same host
13
+ - Remote iSCSI volume export served by CTL
14
+ - Sparse or fully reserved zvols on disk
15
+
16
+ ### Roadmap
17
+
18
+ - Remote iSCSI attach / os-brick support
19
+ - Snapshot export over iSCSI
20
+ - Per-initiator ACLs, mutual CHAP and multipath portals
21
+ - Volume migration between two backends using `zfs send` / `zfs receive`
22
+ - Add support for NVMe over Fabrics remote attach
23
+
24
+ ### Limitations
25
+
26
+ - No remote attach / os-brick support so nova and the cinder backend must run on the same host
27
+ - Remote snapshot connections are refused
28
+
29
+ ## Host requirements
30
+
31
+ `zfs allow` delegation is needed for the dataset holding volumes.
32
+
33
+ Remote export needs ctld running against the driver's config file:
34
+
35
+ ```sh
36
+ sysrc ctld_flags="-f /var/db/cinder/ctl.conf" ctld_enable=YES
37
+ service ctld start
38
+ ```
39
+
40
+ ## Configuration
41
+
42
+ ```ini
43
+ [zfs]
44
+ volume_driver = cinder_zfs_driver.driver.ZFSVolumeDriver
45
+ volume_backend_name = ZFS
46
+ zfs_dataset = zroot/cinder
47
+ zfs_sparse_volumes = true
48
+ use_chap_auth = true
49
+ # zfs_ctld_config = /var/db/cinder/ctl.conf
50
+ # zfs_local_hosts = alias1,alias2
51
+ ```
@@ -0,0 +1,25 @@
1
+ [project]
2
+ name = "cinder-zfs-driver"
3
+ version = "0.9.0"
4
+ description = "Cinder ZFS driver for FreeBSD"
5
+ readme = "README.md"
6
+ requires-python = ">=3.12"
7
+ dependencies = []
8
+
9
+ [[project.authors]]
10
+ name = "joshiggins"
11
+ email = "josh@joshh.info"
12
+
13
+ [project.license]
14
+ text = "Apache-2.0"
15
+
16
+ [build-system]
17
+ requires = ["uv_build>=0.11.28,<0.12.0"]
18
+ build-backend = "uv_build"
19
+
20
+ [tool.uv.build-backend]
21
+ module-name = [
22
+ "cinder_zfs_driver",
23
+ "cinder.zfs",
24
+ ]
25
+ namespace = true
@@ -0,0 +1,19 @@
1
+ [project]
2
+ name = "cinder-zfs-driver"
3
+ version = "0.9.0"
4
+ description = "Cinder ZFS driver for FreeBSD"
5
+ readme = "README.md"
6
+ authors = [
7
+ { name = "joshiggins", email = "josh@joshh.info" }
8
+ ]
9
+ license = {text = "Apache-2.0"}
10
+ requires-python = ">=3.12"
11
+ dependencies = []
12
+
13
+ [build-system]
14
+ requires = ["uv_build>=0.11.28,<0.12.0"]
15
+ build-backend = "uv_build"
16
+
17
+ [tool.uv.build-backend]
18
+ module-name = ["cinder_zfs_driver", "cinder.zfs"]
19
+ namespace = true
@@ -0,0 +1 @@
1
+ """Namespace shim for the cinder-zfs-driver privsep entrypoints."""
@@ -0,0 +1,27 @@
1
+ """Privileged ctld reload."""
2
+
3
+ import os
4
+ import signal
5
+
6
+ import cinder.privsep
7
+ from oslo_log import log as logging
8
+
9
+ from cinder_zfs_driver.ctl import CTLD_PIDFILE
10
+
11
+ LOG = logging.getLogger(__name__)
12
+
13
+
14
+ @cinder.privsep.sys_admin_pctxt.entrypoint
15
+ def reload_ctld():
16
+ """Resolve ctld's pid from its pidfile and send it SIGHUP."""
17
+ try:
18
+ with open(CTLD_PIDFILE) as handle:
19
+ pid = int(handle.read().strip())
20
+ except (OSError, ValueError) as err:
21
+ raise RuntimeError(
22
+ f'ctld is not running (no usable {CTLD_PIDFILE}). Set '
23
+ 'ctld_enable=YES and ctld_flags="-f <zfs_ctld_config>" in '
24
+ 'rc.conf and start the service.'
25
+ ) from err
26
+ LOG.info('reloading ctld (pid %d)', pid)
27
+ os.kill(pid, signal.SIGHUP)
@@ -0,0 +1 @@
1
+ """Cinder volume driver for ZFS on FreeBSD."""
@@ -0,0 +1,129 @@
1
+ """ctl.conf rendering and the export table."""
2
+
3
+ import dataclasses
4
+ import os
5
+ import re
6
+ import tempfile
7
+
8
+ from oslo_concurrency import lockutils
9
+ from oslo_log import log as logging
10
+
11
+ LOG = logging.getLogger(__name__)
12
+
13
+ # root ctld parses the file, so a value failing these is refused, not escaped
14
+ VOLUME_NAME_RE = re.compile(r'^volume-[0-9a-f-]{36}$')
15
+ IQN_RE = re.compile(r'^[A-Za-z0-9.:-]+$')
16
+ CHAP_RE = re.compile(r'^[A-Za-z0-9_.-]+$')
17
+ DEVICE_PATH_RE = re.compile(r'^/dev/zvol/[A-Za-z0-9/_.-]+$')
18
+ LISTEN_RE = re.compile(r'^[A-Za-z0-9.\[\]:-]+:[0-9]+$')
19
+
20
+ PORTAL_GROUP = 'cinder'
21
+ CTLD_PIDFILE = '/var/run/ctld.pid'
22
+
23
+
24
+ def checked(value, pattern, what):
25
+ """Return the value if it matches the pattern."""
26
+ if value is None or not pattern.match(value):
27
+ raise ValueError(f'refusing to render {what} {value!r} into ctl.conf')
28
+ return value
29
+
30
+
31
+ @dataclasses.dataclass(frozen=True)
32
+ class Export:
33
+ """One volume exported as an iSCSI target with a single LUN."""
34
+
35
+ volume_name: str
36
+ iqn: str
37
+ device_path: str
38
+ chap_username: str | None = None
39
+ chap_password: str | None = None
40
+
41
+ def __post_init__(self):
42
+ """Validate every field against its pattern."""
43
+ checked(self.volume_name, VOLUME_NAME_RE, 'volume name')
44
+ checked(self.iqn, IQN_RE, 'target IQN')
45
+ checked(self.device_path, DEVICE_PATH_RE, 'device path')
46
+ if (self.chap_username is None) != (self.chap_password is None):
47
+ raise ValueError('CHAP username and password go together')
48
+ if self.chap_username is not None:
49
+ checked(self.chap_username, CHAP_RE, 'CHAP username')
50
+ checked(self.chap_password, CHAP_RE, 'CHAP password')
51
+
52
+
53
+ def render(exports, listen):
54
+ """Return the complete ctl.conf text for these exports, sorted by IQN."""
55
+ checked(listen, LISTEN_RE, 'listen address')
56
+ lines = [
57
+ '# Generated by cinder-zfs-driver; do not edit. Exports are',
58
+ '# rebuilt from the cinder database on every change.',
59
+ '',
60
+ f'portal-group "{PORTAL_GROUP}" {{',
61
+ ' discovery-auth-group no-authentication',
62
+ f' listen {listen}',
63
+ '}',
64
+ ]
65
+ for export in sorted(exports, key=lambda e: e.iqn):
66
+ lines += ['', f'target "{export.iqn}" {{']
67
+ if export.chap_username is not None:
68
+ lines.append(
69
+ f' chap "{export.chap_username}" "{export.chap_password}"'
70
+ )
71
+ else:
72
+ lines.append(' auth-group no-authentication')
73
+ lines += [
74
+ f' portal-group "{PORTAL_GROUP}"',
75
+ f' lun 0 {{ path "{export.device_path}" }}',
76
+ '}',
77
+ ]
78
+ return '\n'.join(lines) + '\n'
79
+
80
+
81
+ class CtlTable:
82
+ """The set of current exports, mirrored to a config file and to ctld."""
83
+
84
+ def __init__(self, path, listen, reload):
85
+ """Record the config path, listen address and reload callable."""
86
+ self._path = path
87
+ self._listen = listen
88
+ self._reload = reload
89
+ self._exports = {}
90
+
91
+ @lockutils.synchronized('cinder-zfs-ctl')
92
+ def add(self, export):
93
+ """Publish an export."""
94
+ self._exports[export.volume_name] = export
95
+ self._sync()
96
+
97
+ @lockutils.synchronized('cinder-zfs-ctl')
98
+ def remove(self, volume_name):
99
+ """Withdraw an export."""
100
+ self._exports.pop(volume_name, None)
101
+ self._sync()
102
+
103
+ def _sync(self):
104
+ """Rewrite the file and reload ctld if the content changed."""
105
+ text = render(self._exports.values(), self._listen)
106
+ try:
107
+ with open(self._path) as handle:
108
+ if handle.read() == text:
109
+ return
110
+ except OSError:
111
+ pass
112
+ directory = os.path.dirname(self._path)
113
+ handle, staged = tempfile.mkstemp(dir=directory, prefix='.ctl.conf.')
114
+ try:
115
+ with os.fdopen(handle, 'w') as f:
116
+ f.write(text)
117
+ os.chmod(staged, 0o640)
118
+ os.rename(staged, self._path)
119
+ except OSError:
120
+ try:
121
+ os.unlink(staged)
122
+ except OSError:
123
+ pass
124
+ raise
125
+ LOG.info(
126
+ 'wrote %(count)d export(s) to %(path)s',
127
+ {'count': len(self._exports), 'path': self._path},
128
+ )
129
+ self._reload()
@@ -0,0 +1,662 @@
1
+ """Cinder volume driver for ZFS on FreeBSD."""
2
+
3
+ import functools
4
+ import os
5
+ import socket
6
+
7
+ from cinder import exception, interface
8
+ from cinder.common import constants
9
+ from cinder.i18n import _
10
+ from cinder.image import image_utils
11
+ from cinder.volume import configuration, driver, volume_utils
12
+ from oslo_concurrency import processutils
13
+ from oslo_config import cfg
14
+ from oslo_log import log as logging
15
+ from oslo_utils import units
16
+
17
+ from cinder_zfs_driver import ctl
18
+
19
+ LOG = logging.getLogger(__name__)
20
+
21
+ # keep this in sync with nova-bhyve
22
+ LOCAL_VOLUME_TYPE = 'zfs_local'
23
+ MAX_PROMOTIONS = 32
24
+ ZVOL_DEV_DIR = '/dev/zvol'
25
+
26
+ zfs_opts = [
27
+ cfg.StrOpt(
28
+ 'zfs_dataset',
29
+ default='cinder/volumes',
30
+ help='Dataset under which volume zvols are created.',
31
+ ),
32
+ cfg.BoolOpt(
33
+ 'zfs_sparse_volumes',
34
+ default=True,
35
+ help='Create zvols with zfs create -s.',
36
+ ),
37
+ cfg.StrOpt(
38
+ 'zfs_ctld_config',
39
+ default='/var/db/cinder/ctl.conf',
40
+ help='Path of the ctld configuration file this driver writes.',
41
+ ),
42
+ cfg.ListOpt(
43
+ 'zfs_local_hosts',
44
+ default=[],
45
+ help='Additional connector host names treated as this node.',
46
+ ),
47
+ ]
48
+
49
+ CONF = cfg.CONF
50
+ CONF.register_opts(zfs_opts, group=configuration.SHARED_CONF_GROUP)
51
+
52
+
53
+ def describe(value):
54
+ """Return a short identifying string for a driver call argument."""
55
+ if isinstance(value, (list, tuple)):
56
+ text = '[{}]'.format(', '.join(describe(i) for i in value))
57
+ else:
58
+ try:
59
+ for attr in ('id', 'request_id'):
60
+ ident = getattr(value, attr, None)
61
+ if ident is not None:
62
+ return f'{type(value).__name__}<{ident}>'
63
+ text = repr(value)
64
+ except Exception:
65
+ return type(value).__name__
66
+ if len(text) > 120:
67
+ return text[:120] + '...'
68
+ return text
69
+
70
+
71
+ def local_names(extra):
72
+ """Return every name this node answers to in lowercase."""
73
+ names = {socket.gethostname(), socket.getfqdn()}
74
+ names |= {name.split('.')[0] for name in set(names)}
75
+ return {name.lower() for name in names if name} | {
76
+ name.lower() for name in extra
77
+ }
78
+
79
+
80
+ def log_call(method):
81
+ """Wrap a driver method so each call is logged before the body runs."""
82
+
83
+ @functools.wraps(method)
84
+ def wrapper(self, *args, **kwargs):
85
+ """Log the call, then run the wrapped method."""
86
+ described = [describe(arg) for arg in args]
87
+ described += [f'{key}={describe(val)}' for key, val in kwargs.items()]
88
+ LOG.info('%s(%s)', method.__name__, ', '.join(described))
89
+ return method(self, *args, **kwargs)
90
+
91
+ return wrapper
92
+
93
+
94
+ @interface.volumedriver
95
+ class ZFSVolumeDriver(driver.VolumeDriver):
96
+ """Cinder volume driver backed by ZFS zvols."""
97
+
98
+ VERSION = '0.9.0'
99
+
100
+ CI_WIKI_NAME = 'StackBSD_ZFS_CI'
101
+
102
+ def __init__(self, *args, **kwargs):
103
+ """Initialize the driver, register options and node names."""
104
+ super().__init__(*args, **kwargs)
105
+ self.configuration.append_config_values(zfs_opts)
106
+ self.hostname = socket.gethostname()
107
+ self.backend_name = (
108
+ self.configuration.safe_get('volume_backend_name') or 'ZFS'
109
+ )
110
+ self.protocol = constants.ISCSI
111
+ # computed once, getfqdn can block on DNS
112
+ self._node_names = local_names(self.configuration.zfs_local_hosts)
113
+ self._ctl_table = None
114
+
115
+ @classmethod
116
+ def get_driver_options(cls):
117
+ """Return the options this driver consumes."""
118
+ additional_opts = cls._get_oslo_driver_opts(
119
+ 'volume_backend_name',
120
+ 'reserved_percentage',
121
+ 'max_over_subscription_ratio',
122
+ 'volume_dd_blocksize',
123
+ 'target_prefix',
124
+ 'target_ip_address',
125
+ 'target_port',
126
+ 'use_chap_auth',
127
+ 'chap_username',
128
+ 'chap_password',
129
+ )
130
+ return zfs_opts + additional_opts
131
+
132
+ def _dataset(self, volume):
133
+ """Return the dataset name of a volume's zvol."""
134
+ return f'{self.configuration.zfs_dataset}/{volume.name}'
135
+
136
+ def _snapshot_dataset(self, snapshot):
137
+ """Return the dataset name of a snapshot."""
138
+ dataset = self.configuration.zfs_dataset
139
+ return f'{dataset}/{snapshot.volume_name}@{snapshot.name}'
140
+
141
+ def _exists(self, dataset):
142
+ """Report whether a dataset or snapshot is present."""
143
+ try:
144
+ self._execute('zfs', 'list', '-H', '-o', 'name', dataset)
145
+ except processutils.ProcessExecutionError as err:
146
+ if 'does not exist' in (err.stderr or ''):
147
+ return False
148
+ raise
149
+ return True
150
+
151
+ def _dependent_clones(self, dataset):
152
+ """Return the clones holding snapshots of a volume."""
153
+ out, _err = self._execute(
154
+ 'zfs',
155
+ 'list',
156
+ '-H',
157
+ '-r',
158
+ '-t',
159
+ 'snapshot',
160
+ '-o',
161
+ 'clones',
162
+ dataset,
163
+ )
164
+ clones = []
165
+ for line in out.splitlines():
166
+ value = line.strip()
167
+ if value and value != '-':
168
+ clones += value.split(',')
169
+ return clones
170
+
171
+ def _promote_dependent_clones(self, dataset):
172
+ """Move snapshots shared with clones onto those clones."""
173
+ for _attempt in range(MAX_PROMOTIONS):
174
+ clones = self._dependent_clones(dataset)
175
+ if not clones:
176
+ return
177
+ self._execute('zfs', 'promote', clones[0])
178
+ raise exception.VolumeDriverException(
179
+ message=_(
180
+ '%(dataset)s still has dependent clones after '
181
+ '%(count)d promotions'
182
+ )
183
+ % {'dataset': dataset, 'count': MAX_PROMOTIONS}
184
+ )
185
+
186
+ @log_call
187
+ def do_setup(self, context):
188
+ """Nothing to set up."""
189
+ pass
190
+
191
+ @log_call
192
+ def check_for_setup_error(self):
193
+ """Check the dataset and the prerequisites for remote export."""
194
+ dataset = self.configuration.zfs_dataset
195
+ try:
196
+ self._execute('zfs', 'list', '-H', '-o', 'name', dataset)
197
+ except processutils.ProcessExecutionError as err:
198
+ raise exception.VolumeDriverException(
199
+ message=_(
200
+ 'Dataset %(dataset)s is not usable by this '
201
+ 'service: %(error)s'
202
+ )
203
+ % {'dataset': dataset, 'error': err.stderr or err}
204
+ ) from err
205
+ if not os.path.exists(ctl.CTLD_PIDFILE):
206
+ LOG.warning(
207
+ 'ctld is not running; remote export needs it started '
208
+ 'with -f %s',
209
+ self.configuration.zfs_ctld_config,
210
+ )
211
+ config_dir = os.path.dirname(self.configuration.zfs_ctld_config)
212
+ if not os.access(config_dir, os.W_OK):
213
+ LOG.warning(
214
+ '%s is not writable; remote export needs it', config_dir
215
+ )
216
+
217
+ def local_path(self, volume):
218
+ """Return the device path of a volume's zvol."""
219
+ return os.path.join(ZVOL_DEV_DIR, self._dataset(volume))
220
+
221
+ def _provisioned_capacity_gb(self):
222
+ """Return the total volsize of the zvols under the dataset."""
223
+ out, _err = self._execute(
224
+ 'zfs',
225
+ 'list',
226
+ '-Hp',
227
+ '-r',
228
+ '-t',
229
+ 'volume',
230
+ '-o',
231
+ 'volsize',
232
+ self.configuration.zfs_dataset,
233
+ )
234
+ return round(sum(int(line) for line in out.split()) / units.Gi, 2)
235
+
236
+ def _update_volume_stats(self):
237
+ """Refresh the stats dict from the dataset's space accounting."""
238
+ dataset = self.configuration.zfs_dataset
239
+ sparse = self.configuration.zfs_sparse_volumes
240
+ out, _err = self._execute(
241
+ 'zfs', 'list', '-Hp', '-o', 'available,used', dataset
242
+ )
243
+ available, used = (int(value) for value in out.split())
244
+ location_info = f'ZFSVolumeDriver:{self.hostname}:{dataset}'
245
+ pool = {
246
+ 'pool_name': self.backend_name,
247
+ 'total_capacity_gb': round((available + used) / units.Gi, 2),
248
+ 'free_capacity_gb': round(available / units.Gi, 2),
249
+ 'provisioned_capacity_gb': self._provisioned_capacity_gb(),
250
+ 'reserved_percentage': self.configuration.reserved_percentage,
251
+ 'max_over_subscription_ratio': (
252
+ self.configuration.max_over_subscription_ratio
253
+ ),
254
+ 'thin_provisioning_support': sparse,
255
+ 'thick_provisioning_support': not sparse,
256
+ 'location_info': location_info,
257
+ 'QoS_support': False,
258
+ 'multiattach': False,
259
+ 'filter_function': self.get_filter_function(),
260
+ 'goodness_function': self.get_goodness_function(),
261
+ 'backend_state': 'up',
262
+ }
263
+ self._stats = {
264
+ 'volume_backend_name': self.backend_name,
265
+ 'vendor_name': 'StackBSD',
266
+ 'driver_version': self.VERSION,
267
+ 'storage_protocol': self.protocol,
268
+ 'pools': [pool],
269
+ }
270
+ LOG.debug(
271
+ '%(free)s GB free of %(total)s GB on %(pool)s',
272
+ {
273
+ 'free': pool['free_capacity_gb'],
274
+ 'total': pool['total_capacity_gb'],
275
+ 'pool': self.backend_name,
276
+ },
277
+ )
278
+
279
+ @log_call
280
+ def create_volume(self, volume):
281
+ """Create the volume's zvol."""
282
+ command = ['zfs', 'create']
283
+ if self.configuration.zfs_sparse_volumes:
284
+ command.append('-s')
285
+ command += ['-V', f'{volume.size}G', self._dataset(volume)]
286
+ self._execute(*command)
287
+
288
+ @log_call
289
+ def delete_volume(self, volume):
290
+ """Destroy the volume's zvol after promoting dependent clones."""
291
+ dataset = self._dataset(volume)
292
+ if not self._exists(dataset):
293
+ return
294
+ self._promote_dependent_clones(dataset)
295
+ self._execute('zfs', 'destroy', '-r', dataset)
296
+
297
+ @log_call
298
+ def create_cloned_volume(self, volume, src_vref):
299
+ """Create the volume as a clone of a snapshot of the source."""
300
+ snapshot = f'{self._dataset(src_vref)}@{volume.name}'
301
+ self._execute('zfs', 'snapshot', snapshot)
302
+ try:
303
+ self._execute('zfs', 'clone', snapshot, self._dataset(volume))
304
+ except processutils.ProcessExecutionError:
305
+ self._execute('zfs', 'destroy', '-d', snapshot)
306
+ raise
307
+ if volume.size > src_vref.size:
308
+ self.extend_volume(volume, volume.size)
309
+
310
+ @log_call
311
+ def create_volume_from_snapshot(self, volume, snapshot):
312
+ """Create the volume as a clone of the snapshot."""
313
+ self._execute(
314
+ 'zfs',
315
+ 'clone',
316
+ self._snapshot_dataset(snapshot),
317
+ self._dataset(volume),
318
+ )
319
+ if volume.size > snapshot.volume_size:
320
+ self.extend_volume(volume, volume.size)
321
+
322
+ @log_call
323
+ def extend_volume(self, volume, new_size):
324
+ """Grow the zvol to the new size."""
325
+ self._execute(
326
+ 'zfs', 'set', f'volsize={new_size}G', self._dataset(volume)
327
+ )
328
+
329
+ @log_call
330
+ def revert_to_snapshot(self, context, volume, snapshot):
331
+ """Roll the zvol back to the snapshot."""
332
+ self._execute('zfs', 'rollback', self._snapshot_dataset(snapshot))
333
+
334
+ def snapshot_revert_use_temp_snapshot(self):
335
+ """Report that revert needs no temporary snapshot."""
336
+ return False
337
+
338
+ @log_call
339
+ def create_snapshot(self, snapshot):
340
+ """Create the snapshot."""
341
+ self._execute('zfs', 'snapshot', self._snapshot_dataset(snapshot))
342
+
343
+ @log_call
344
+ def delete_snapshot(self, snapshot):
345
+ """Destroy the snapshot, deferring while clones depend on it."""
346
+ dataset = self._snapshot_dataset(snapshot)
347
+ if not self._exists(dataset):
348
+ return
349
+ self._execute('zfs', 'destroy', '-d', dataset)
350
+
351
+ def _is_local_connector(self, connector):
352
+ """Report whether the connector's host names this node."""
353
+ host = ((connector or {}).get('host') or '').lower()
354
+ if not host:
355
+ return False
356
+ return (
357
+ host in self._node_names or host.split('.')[0] in self._node_names
358
+ )
359
+
360
+ def _ctl(self):
361
+ """Return the CTL export table, building it on first use."""
362
+ if self._ctl_table is None:
363
+ from cinder.zfs import privsep as zfs_privsep
364
+
365
+ self._ctl_table = ctl.CtlTable(
366
+ self.configuration.zfs_ctld_config,
367
+ self._portal(),
368
+ zfs_privsep.reload_ctld,
369
+ )
370
+ return self._ctl_table
371
+
372
+ def _portal(self):
373
+ """Return the configured portal address as ip:port."""
374
+ conf = self.configuration
375
+ return f'{conf.target_ip_address}:{conf.target_port}'
376
+
377
+ @staticmethod
378
+ def _chap_from(provider_auth):
379
+ """Split a 'CHAP <user> <secret>' provider_auth, or (None, None)."""
380
+ if not provider_auth:
381
+ return None, None
382
+ method, username, password = provider_auth.split()
383
+ if method != 'CHAP':
384
+ raise exception.VolumeBackendAPIException(
385
+ data=_('unsupported provider_auth method %s') % method
386
+ )
387
+ return username, password
388
+
389
+ @log_call
390
+ def ensure_export(self, context, volume):
391
+ """Rebuild a remote export from the volume's provider fields."""
392
+ if not volume.provider_location:
393
+ return
394
+ username, password = self._chap_from(volume.provider_auth)
395
+ self._ctl().add(
396
+ ctl.Export(
397
+ volume_name=volume.name,
398
+ iqn=volume.provider_location.split()[1],
399
+ device_path=self.local_path(volume),
400
+ chap_username=username,
401
+ chap_password=password,
402
+ )
403
+ )
404
+
405
+ @log_call
406
+ def create_export(self, context, volume, connector):
407
+ """Export the volume as an iSCSI target for a remote connector."""
408
+ if self._is_local_connector(connector):
409
+ return None
410
+ if self.configuration.use_chap_auth:
411
+ username = (
412
+ self.configuration.chap_username
413
+ or volume_utils.generate_username()
414
+ )
415
+ password = (
416
+ self.configuration.chap_password
417
+ or volume_utils.generate_password()
418
+ )
419
+ provider_auth = f'CHAP {username} {password}'
420
+ else:
421
+ username = password = provider_auth = None
422
+ iqn = self.configuration.target_prefix + volume.name
423
+ self._ctl().add(
424
+ ctl.Export(
425
+ volume_name=volume.name,
426
+ iqn=iqn,
427
+ device_path=self.local_path(volume),
428
+ chap_username=username,
429
+ chap_password=password,
430
+ )
431
+ )
432
+ return {
433
+ 'provider_location': f'{self._portal()},1 {iqn} 0',
434
+ 'provider_auth': provider_auth,
435
+ }
436
+
437
+ @log_call
438
+ def remove_export(self, context, volume):
439
+ """Withdraw the volume's iSCSI export."""
440
+ if not volume.provider_location:
441
+ return
442
+ self._ctl().remove(volume.name)
443
+
444
+ @log_call
445
+ def create_export_snapshot(self, context, snapshot, connector):
446
+ """Nothing to export."""
447
+ pass
448
+
449
+ @log_call
450
+ def remove_export_snapshot(self, context, snapshot):
451
+ """Nothing to withdraw."""
452
+ pass
453
+
454
+ @log_call
455
+ def initialize_connection(self, volume, connector, **kwargs):
456
+ """Return the local device node or the exported iSCSI target."""
457
+ if self._is_local_connector(connector):
458
+ return {
459
+ 'driver_volume_type': LOCAL_VOLUME_TYPE,
460
+ 'data': {
461
+ 'device_path': self.local_path(volume),
462
+ 'volume_id': volume.id,
463
+ },
464
+ }
465
+ if not volume.provider_location:
466
+ raise exception.VolumeBackendAPIException(
467
+ data=_('volume %s has no export to connect to') % volume.name
468
+ )
469
+ portal_field, iqn, lun = volume.provider_location.split()
470
+ data = {
471
+ 'target_discovered': False,
472
+ 'target_iqn': iqn,
473
+ 'target_portal': portal_field.split(',')[0],
474
+ 'target_lun': int(lun),
475
+ 'volume_id': volume.id,
476
+ 'discard': True,
477
+ }
478
+ username, password = self._chap_from(volume.provider_auth)
479
+ if username is not None:
480
+ data['auth_method'] = 'CHAP'
481
+ data['auth_username'] = username
482
+ data['auth_password'] = password
483
+ return {'driver_volume_type': 'iscsi', 'data': data}
484
+
485
+ @log_call
486
+ def terminate_connection(self, volume, connector, **kwargs):
487
+ """Nothing per-connection to undo."""
488
+ pass
489
+
490
+ @log_call
491
+ def initialize_connection_snapshot(self, snapshot, connector, **kwargs):
492
+ """Return the local device node of the snapshot's zvol."""
493
+ if not self._is_local_connector(connector):
494
+ raise exception.VolumeBackendAPIException(
495
+ data=_(
496
+ 'snapshot %(name)s can only be attached on %(storage)s, '
497
+ 'not from %(requester)s'
498
+ )
499
+ % {
500
+ 'name': snapshot.name,
501
+ 'storage': self.hostname,
502
+ 'requester': (connector or {}).get('host'),
503
+ }
504
+ )
505
+ return {
506
+ 'driver_volume_type': LOCAL_VOLUME_TYPE,
507
+ 'data': {
508
+ 'device_path': os.path.join(
509
+ ZVOL_DEV_DIR, self._snapshot_dataset(snapshot)
510
+ ),
511
+ 'volume_id': snapshot.id,
512
+ },
513
+ }
514
+
515
+ @log_call
516
+ def terminate_connection_snapshot(self, snapshot, connector, **kwargs):
517
+ """Nothing per-connection to undo."""
518
+ pass
519
+
520
+ @log_call
521
+ def validate_connector(self, connector):
522
+ """Accept any connector."""
523
+ pass
524
+
525
+ @log_call
526
+ def clone_image(
527
+ self, context, volume, image_location, image_meta, image_service
528
+ ):
529
+ """Decline to clone the image."""
530
+ return None, False
531
+
532
+ @log_call
533
+ def copy_image_to_volume(
534
+ self, context, volume, image_service, image_id, disable_sparse=False
535
+ ):
536
+ """Fetch the image raw onto the volume's device node."""
537
+ image_utils.fetch_to_raw(
538
+ context,
539
+ image_service,
540
+ image_id,
541
+ self.local_path(volume),
542
+ self.configuration.volume_dd_blocksize,
543
+ size=volume.size,
544
+ disable_sparse=disable_sparse,
545
+ )
546
+
547
+ @log_call
548
+ def copy_volume_to_image(self, context, volume, image_service, image_meta):
549
+ """Upload the volume's device node as an image."""
550
+ volume_utils.upload_volume(
551
+ context, image_service, image_meta, self.local_path(volume), volume
552
+ )
553
+
554
+ @log_call
555
+ def migrate_volume(self, context, volume, host):
556
+ """Report the volume as migrated in place."""
557
+ return True, None
558
+
559
+ @log_call
560
+ def update_migrated_volume(
561
+ self, ctxt, volume, new_volume, original_volume_status
562
+ ):
563
+ """Report that the backend volume kept the new volume's identity."""
564
+ return {
565
+ '_name_id': new_volume.name_id,
566
+ 'provider_location': new_volume.provider_location,
567
+ }
568
+
569
+ @log_call
570
+ def retype(self, context, volume, new_type, diff, host):
571
+ """Report the retype as handled in place."""
572
+ return True, None
573
+
574
+ @log_call
575
+ def accept_transfer(self, context, volume, new_user, new_project):
576
+ """Nothing to change on ownership transfer."""
577
+ pass
578
+
579
+ @log_call
580
+ def manage_existing(self, volume, existing_ref):
581
+ """Accept the existing zvol as-is."""
582
+ pass
583
+
584
+ @log_call
585
+ def manage_existing_get_size(self, volume, existing_ref):
586
+ """Report the smallest size Cinder accepts."""
587
+ return 1
588
+
589
+ @log_call
590
+ def get_manageable_volumes(
591
+ self, cinder_volumes, marker, limit, offset, sort_keys, sort_dirs
592
+ ):
593
+ """Report no manageable volumes."""
594
+ return []
595
+
596
+ @log_call
597
+ def unmanage(self, volume):
598
+ """Leave the zvol in place."""
599
+ pass
600
+
601
+ @log_call
602
+ def manage_existing_snapshot(self, snapshot, existing_ref):
603
+ """Accept the existing snapshot as-is."""
604
+ pass
605
+
606
+ @log_call
607
+ def manage_existing_snapshot_get_size(self, snapshot, existing_ref):
608
+ """Report the smallest size Cinder accepts."""
609
+ return 1
610
+
611
+ @log_call
612
+ def get_manageable_snapshots(
613
+ self, cinder_snapshots, marker, limit, offset, sort_keys, sort_dirs
614
+ ):
615
+ """Report no manageable snapshots."""
616
+ return []
617
+
618
+ @log_call
619
+ def unmanage_snapshot(self, snapshot):
620
+ """Leave the snapshot in place."""
621
+ pass
622
+
623
+ @log_call
624
+ def create_group(self, context, group):
625
+ """Accept the group without backend state."""
626
+ pass
627
+
628
+ @log_call
629
+ def delete_group(self, context, group, volumes):
630
+ """Delete the group without backend state."""
631
+ return None, None
632
+
633
+ @log_call
634
+ def update_group(
635
+ self, context, group, add_volumes=None, remove_volumes=None
636
+ ):
637
+ """Accept membership changes without backend state."""
638
+ return None, None, None
639
+
640
+ @log_call
641
+ def create_group_from_src(
642
+ self,
643
+ context,
644
+ group,
645
+ volumes,
646
+ group_snapshot=None,
647
+ snapshots=None,
648
+ source_group=None,
649
+ source_vols=None,
650
+ ):
651
+ """Create the group without backend state."""
652
+ return None, None
653
+
654
+ @log_call
655
+ def create_group_snapshot(self, context, group_snapshot, snapshots):
656
+ """Create the group snapshot without backend state."""
657
+ return None, None
658
+
659
+ @log_call
660
+ def delete_group_snapshot(self, context, group_snapshot, snapshots):
661
+ """Delete the group snapshot without backend state."""
662
+ return None, None