From ee352dcf03a5fdc4a9d029c7c01f318e32029fef Mon Sep 17 00:00:00 2001 From: mikhail Date: Tue, 24 Feb 2015 08:00:43 -0800 Subject: [PATCH] Nexenta Edge iSCSI backend driver Nexenta Edge project supports iSCSI block level storage. This patch implements a driver for Nexenta Edge iSCSI backend. DocImpact Implements: blueprint nexentaedge-iscsi-volume-driver Change-Id: I82c215ba85e9d49723e792d88d86553b3a75d3ac --- cinder/opts.py | 4 + cinder/tests/unit/test_nexenta_edge.py | 169 ++++++++++ cinder/volume/drivers/nexenta/__init__.py | 0 .../drivers/nexenta/nexentaedge/__init__.py | 0 .../drivers/nexenta/nexentaedge/iscsi.py | 303 ++++++++++++++++++ .../drivers/nexenta/nexentaedge/jsonrpc.py | 100 ++++++ tests-py3.txt | 1 + 7 files changed, 577 insertions(+) create mode 100644 cinder/tests/unit/test_nexenta_edge.py create mode 100644 cinder/volume/drivers/nexenta/__init__.py create mode 100644 cinder/volume/drivers/nexenta/nexentaedge/__init__.py create mode 100644 cinder/volume/drivers/nexenta/nexentaedge/iscsi.py create mode 100644 cinder/volume/drivers/nexenta/nexentaedge/jsonrpc.py diff --git a/cinder/opts.py b/cinder/opts.py index 8d65f80314b..712a80347b5 100644 --- a/cinder/opts.py +++ b/cinder/opts.py @@ -119,6 +119,8 @@ from cinder.volume.drivers.lenovo import lenovo_common as \ from cinder.volume.drivers import lvm as cinder_volume_drivers_lvm from cinder.volume.drivers.netapp import options as \ cinder_volume_drivers_netapp_options +from cinder.volume.drivers.nexenta.nexentaedge import iscsi as \ + cinder_volume_drivers_nexenta_nexentaedge_iscsi from cinder.volume.drivers import nfs as cinder_volume_drivers_nfs from cinder.volume.drivers import nimble as cinder_volume_drivers_nimble from cinder.volume.drivers.prophetstor import options as \ @@ -287,6 +289,8 @@ def list_opts(): cinder_volume_drivers_prophetstor_options.DPL_OPTS, cinder_volume_drivers_hitachi_hbsdiscsi.volume_opts, cinder_volume_manager.volume_manager_opts, + cinder_volume_drivers_nexenta_nexentaedge_iscsi. + nexenta_edge_opts, cinder_volume_drivers_ibm_flashsystemiscsi. flashsystem_iscsi_opts, cinder_volume_drivers_ibm_flashsystemcommon.flashsystem_opts, diff --git a/cinder/tests/unit/test_nexenta_edge.py b/cinder/tests/unit/test_nexenta_edge.py new file mode 100644 index 00000000000..a6426cd4888 --- /dev/null +++ b/cinder/tests/unit/test_nexenta_edge.py @@ -0,0 +1,169 @@ +# +# Copyright 2015 Nexenta Systems, Inc. +# All Rights Reserved. +# +# Licensed under the Apache License, Version 2.0 (the "License"); you may +# not use this file except in compliance with the License. You may obtain +# a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, WITHOUT +# WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the +# License for the specific language governing permissions and limitations +# under the License. + +import mock + +from cinder import context +from cinder import test +from cinder.volume import configuration as conf +from cinder.volume.drivers.nexenta.nexentaedge import iscsi + +NEDGE_URL = 'service/isc/iscsi' +NEDGE_BUCKET = 'c/t/bk' +NEDGE_SERVICE = 'isc' +NEDGE_BLOCKSIZE = 4096 +NEDGE_CHUNKSIZE = 16384 + +MOCK_VOL = { + 'id': 'vol1', + 'name': 'vol1', + 'size': 1 +} +MOCK_VOL2 = { + 'id': 'vol2', + 'name': 'vol2', + 'size': 1 +} +MOCK_SNAP = { + 'id': 'snap1', + 'name': 'snap1', + 'volume_name': 'vol1' +} +NEW_VOL_SIZE = 2 +ISCSI_TARGET_NAME = 'iscsi_target_name' +ISCSI_TARGET_STATUS = 'Target 1: ' + ISCSI_TARGET_NAME + + +class TestNexentaEdgeISCSIDriver(test.TestCase): + + def setUp(self): + super(TestNexentaEdgeISCSIDriver, self).setUp() + self.cfg = mock.Mock(spec=conf.Configuration) + self.cfg.nexenta_client_address = '0.0.0.0' + self.cfg.nexenta_rest_address = '0.0.0.0' + self.cfg.nexenta_rest_port = 8080 + self.cfg.nexenta_rest_protocol = 'http' + self.cfg.nexenta_iscsi_target_portal_port = 3260 + self.cfg.nexenta_rest_user = 'admin' + self.cfg.nexenta_rest_password = 'admin' + self.cfg.nexenta_lun_container = NEDGE_BUCKET + self.cfg.nexenta_iscsi_service = NEDGE_SERVICE + self.cfg.nexenta_blocksize = NEDGE_BLOCKSIZE + self.cfg.nexenta_chunksize = NEDGE_CHUNKSIZE + + mock_exec = mock.Mock() + mock_exec.return_value = ('', '') + self.driver = iscsi.NexentaEdgeISCSIDriver(execute=mock_exec, + configuration=self.cfg) + self.api_patcher = mock.patch('cinder.volume.drivers.nexenta.' + 'nexentaedge.jsonrpc.' + 'NexentaEdgeJSONProxy.__call__') + self.mock_api = self.api_patcher.start() + + self.mock_api.return_value = { + 'data': {'value': ISCSI_TARGET_STATUS} + } + self.driver.do_setup(context.get_admin_context()) + + self.addCleanup(self.api_patcher.stop) + + def test_check_do_setup(self): + self.assertEqual(ISCSI_TARGET_NAME, self.driver.target_name) + + def test_create_volume(self): + self.driver.create_volume(MOCK_VOL) + self.mock_api.assert_called_with(NEDGE_URL, { + 'objectPath': NEDGE_BUCKET + '/' + MOCK_VOL['id'], + 'volSizeMB': MOCK_VOL['size'] * 1024, + 'blockSize': NEDGE_BLOCKSIZE, + 'chunkSize': NEDGE_CHUNKSIZE + }) + + def test_create_volume_fail(self): + self.mock_api.side_effect = RuntimeError + self.assertRaises(RuntimeError, self.driver.create_volume, MOCK_VOL) + + def test_delete_volume(self): + self.driver.delete_volume(MOCK_VOL) + self.mock_api.assert_called_with(NEDGE_URL, { + 'objectPath': NEDGE_BUCKET + '/' + MOCK_VOL['id'] + }) + + def test_delete_volume_fail(self): + self.mock_api.side_effect = RuntimeError + self.assertRaises(RuntimeError, self.driver.delete_volume, MOCK_VOL) + + def test_extend_volume(self): + self.driver.extend_volume(MOCK_VOL, NEW_VOL_SIZE) + self.mock_api.assert_called_with(NEDGE_URL + '/resize', { + 'objectPath': NEDGE_BUCKET + '/' + MOCK_VOL['id'], + 'newSizeMB': NEW_VOL_SIZE * 1024 + }) + + def test_extend_volume_fail(self): + self.mock_api.side_effect = RuntimeError + self.assertRaises(RuntimeError, self.driver.extend_volume, + MOCK_VOL, NEW_VOL_SIZE) + + def test_create_snapshot(self): + self.driver.create_snapshot(MOCK_SNAP) + self.mock_api.assert_called_with(NEDGE_URL + '/snapshot', { + 'objectPath': NEDGE_BUCKET + '/' + MOCK_VOL['id'], + 'snapName': MOCK_SNAP['id'] + }) + + def test_create_snapshot_fail(self): + self.mock_api.side_effect = RuntimeError + self.assertRaises(RuntimeError, self.driver.create_snapshot, MOCK_SNAP) + + def test_delete_snapshot(self): + self.driver.delete_snapshot(MOCK_SNAP) + self.mock_api.assert_called_with(NEDGE_URL + '/snapshot', { + 'objectPath': NEDGE_BUCKET + '/' + MOCK_VOL['id'], + 'snapName': MOCK_SNAP['id'] + }) + + def test_delete_snapshot_fail(self): + self.mock_api.side_effect = RuntimeError + self.assertRaises(RuntimeError, self.driver.delete_snapshot, MOCK_SNAP) + + def test_create_volume_from_snapshot(self): + self.driver.create_volume_from_snapshot(MOCK_VOL2, MOCK_SNAP) + self.mock_api.assert_called_with(NEDGE_URL + '/snapshot/clone', { + 'objectPath': NEDGE_BUCKET + '/' + MOCK_SNAP['volume_name'], + 'clonePath': NEDGE_BUCKET + '/' + MOCK_VOL2['id'], + 'snapName': MOCK_SNAP['id'] + }) + + def test_create_volume_from_snapshot_fail(self): + self.mock_api.side_effect = RuntimeError + self.assertRaises(RuntimeError, + self.driver.create_volume_from_snapshot, + MOCK_VOL2, MOCK_SNAP) + + def test_create_cloned_volume(self): + self.driver.create_cloned_volume(MOCK_VOL2, MOCK_VOL) + self.mock_api.assert_called_with(NEDGE_URL, { + 'objectPath': NEDGE_BUCKET + '/' + MOCK_VOL2['id'], + 'volSizeMB': MOCK_VOL2['size'] * 1024, + 'blockSize': NEDGE_BLOCKSIZE, + 'chunkSize': NEDGE_CHUNKSIZE + }) + + def test_create_cloned_volume_fail(self): + self.mock_api.side_effect = RuntimeError + self.assertRaises(RuntimeError, self.driver.create_cloned_volume, + MOCK_VOL2, MOCK_VOL) diff --git a/cinder/volume/drivers/nexenta/__init__.py b/cinder/volume/drivers/nexenta/__init__.py new file mode 100644 index 00000000000..e69de29bb2d diff --git a/cinder/volume/drivers/nexenta/nexentaedge/__init__.py b/cinder/volume/drivers/nexenta/nexentaedge/__init__.py new file mode 100644 index 00000000000..e69de29bb2d diff --git a/cinder/volume/drivers/nexenta/nexentaedge/iscsi.py b/cinder/volume/drivers/nexenta/nexentaedge/iscsi.py new file mode 100644 index 00000000000..6bf4c670799 --- /dev/null +++ b/cinder/volume/drivers/nexenta/nexentaedge/iscsi.py @@ -0,0 +1,303 @@ +# Copyright 2015 Nexenta Systems, Inc. +# All Rights Reserved. +# +# Licensed under the Apache License, Version 2.0 (the "License"); you may +# not use this file except in compliance with the License. You may obtain +# a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, WITHOUT +# WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the +# License for the specific language governing permissions and limitations +# under the License. + +import json + +from oslo_config import cfg +from oslo_log import log as logging +from oslo_utils import excutils +from oslo_utils import units + +from cinder import exception +from cinder.i18n import _, _LE +from cinder.volume import driver +from cinder.volume.drivers.nexenta.nexentaedge import jsonrpc + + +nexenta_edge_opts = [ + cfg.StrOpt('nexenta_rest_address', + default='', + help='IP address of NexentaEdge management REST API endpoint'), + cfg.IntOpt('nexenta_rest_port', + default=8080, + help='HTTP port to connect to NexentaEdge REST API endpoint'), + cfg.StrOpt('nexenta_rest_protocol', + default='auto', + help='Use http or https for REST connection (default auto)'), + cfg.IntOpt('nexenta_iscsi_target_portal_port', + default=3260, + help='NexentaEdge target portal port'), + cfg.StrOpt('nexenta_rest_user', + default='admin', + help='User name to connect to NexentaEdge'), + cfg.StrOpt('nexenta_rest_password', + default='nexenta', + help='Password to connect to NexentaEdge', + secret=True), + cfg.StrOpt('nexenta_lun_container', + default='', + help='NexentaEdge logical path of bucket for LUNs'), + cfg.StrOpt('nexenta_iscsi_service', + default='', + help='NexentaEdge iSCSI service name'), + cfg.StrOpt('nexenta_client_address', + default='', + help='NexentaEdge iSCSI Gateway client ' + 'address for non-VIP service'), + cfg.StrOpt('nexenta_blocksize', + default=4096, + help='NexentaEdge iSCSI LUN block size'), + cfg.StrOpt('nexenta_chunksize', + default=16384, + help='NexentaEdge iSCSI LUN object chunk size') +] + +CONF = cfg.CONF +CONF.register_opts(nexenta_edge_opts) + +LOG = logging.getLogger(__name__) + + +class NexentaEdgeISCSIDriver(driver.ISCSIDriver): + """Executes volume driver commands on NexentaEdge cluster. + + Version history: + 1.0.0 - Initial driver version. + """ + + VERSION = '1.0.0' + + def __init__(self, *args, **kwargs): + super(NexentaEdgeISCSIDriver, self).__init__(*args, **kwargs) + if self.configuration: + self.configuration.append_config_values(nexenta_edge_opts) + self.restapi_protocol = self.configuration.nexenta_rest_protocol + self.restapi_host = self.configuration.nexenta_rest_address + self.restapi_port = self.configuration.nexenta_rest_port + self.restapi_user = self.configuration.nexenta_rest_user + self.restapi_password = self.configuration.nexenta_rest_password + self.iscsi_service = self.configuration.nexenta_iscsi_service + self.bucket_path = self.configuration.nexenta_lun_container + self.blocksize = self.configuration.nexenta_blocksize + self.chunksize = self.configuration.nexenta_chunksize + self.cluster, self.tenant, self.bucket = self.bucket_path.split('/') + self.bucket_url = ('clusters/' + self.cluster + '/tenants/' + + self.tenant + '/buckets/' + self.bucket) + self.iscsi_target_port = (self.configuration. + nexenta_iscsi_target_portal_port) + self.target_vip = None + + @property + def backend_name(self): + backend_name = None + if self.configuration: + backend_name = self.configuration.safe_get('volume_backend_name') + if not backend_name: + backend_name = self.__class__.__name__ + return backend_name + + def do_setup(self, context): + if self.restapi_protocol == 'auto': + protocol, auto = 'http', True + else: + protocol, auto = self.restapi_protocol, False + + try: + self.restapi = jsonrpc.NexentaEdgeJSONProxy( + protocol, self.restapi_host, self.restapi_port, '/', + self.restapi_user, self.restapi_password, auto=auto) + + rsp = self.restapi.get('service/' + + self.iscsi_service + '/iscsi/status') + data_keys = rsp['data'][list(rsp['data'].keys())[0]] + self.target_name = data_keys.split('\n', 1)[0].split(' ')[2] + + rsp = self.restapi.get('service/' + self.iscsi_service) + if 'X-VIPS' in rsp['data']: + vips = json.loads(rsp['data']['X-VIPS']) + if len(vips[0]) == 1: + self.target_vip = vips[0][0]['ip'].split('/', 1)[0] + else: + self.target_vip = vips[0][1]['ip'].split('/', 1)[0] + else: + self.target_vip = self.configuration.safe_get( + 'nexenta_client_address') + if not self.target_vip: + LOG.error(_LE('No VIP configured for service %s'), + self.iscsi_service) + raise exception.VolumeBackendAPIException( + _('No service VIP configured and ' + 'no nexenta_client_address')) + except exception.VolumeBackendAPIException: + with excutils.save_and_reraise_exception(): + LOG.exception(_LE('Error verifying iSCSI service %(serv)s on ' + 'host %(hst)s'), {'serv': self.iscsi_service, + 'hst': self.restapi_host}) + + def check_for_setup_error(self): + try: + self.restapi.get(self.bucket_url + '/objects/') + except exception.VolumeBackendAPIException: + with excutils.save_and_reraise_exception(): + LOG.exception(_LE('Error verifying LUN container %(bkt)s'), + {'bkt': self.bucket_path}) + + def _get_lun_number(self, volname): + try: + rsp = self.restapi.put( + 'service/' + self.iscsi_service + '/iscsi/number', + { + 'objectPath': self.bucket_path + '/' + volname + }) + except exception.VolumeBackendAPIException: + with excutils.save_and_reraise_exception(): + LOG.exception(_LE('Error retrieving LUN %(vol)s number'), + {'vol': volname}) + + return rsp['data'] + + def _get_target_address(self, volname): + return self.target_vip + + def _get_provider_location(self, volume): + return '%(host)s:%(port)s,1 %(name)s %(number)s' % { + 'host': self._get_target_address(volume['name']), + 'port': self.iscsi_target_port, + 'name': self.target_name, + 'number': self._get_lun_number(volume['name']) + } + + def create_volume(self, volume): + try: + self.restapi.post('service/' + self.iscsi_service + '/iscsi', { + 'objectPath': self.bucket_path + '/' + volume['name'], + 'volSizeMB': int(volume['size']) * units.Ki, + 'blockSize': self.blocksize, + 'chunkSize': self.chunksize + }) + except exception.VolumeBackendAPIException: + with excutils.save_and_reraise_exception(): + LOG.exception(_LE('Error creating volume')) + + def delete_volume(self, volume): + try: + self.restapi.delete('service/' + self.iscsi_service + + '/iscsi', {'objectPath': self.bucket_path + + '/' + volume['name']}) + except exception.VolumeBackendAPIException: + with excutils.save_and_reraise_exception(): + LOG.exception(_LE('Error deleting volume')) + + def extend_volume(self, volume, new_size): + try: + self.restapi.put('service/' + self.iscsi_service + '/iscsi/resize', + {'objectPath': self.bucket_path + + '/' + volume['name'], + 'newSizeMB': new_size * units.Ki}) + except exception.VolumeBackendAPIException: + with excutils.save_and_reraise_exception(): + LOG.exception(_LE('Error extending volume')) + + def create_volume_from_snapshot(self, volume, snapshot): + try: + self.restapi.put( + 'service/' + self.iscsi_service + '/iscsi/snapshot/clone', + { + 'objectPath': self.bucket_path + '/' + + snapshot['volume_name'], + 'clonePath': self.bucket_path + '/' + volume['name'], + 'snapName': snapshot['name'] + }) + except exception.VolumeBackendAPIException: + with excutils.save_and_reraise_exception(): + LOG.exception(_LE('Error cloning volume')) + + def create_snapshot(self, snapshot): + try: + self.restapi.post( + 'service/' + self.iscsi_service + '/iscsi/snapshot', + { + 'objectPath': self.bucket_path + '/' + + snapshot['volume_name'], + 'snapName': snapshot['name'] + }) + except exception.VolumeBackendAPIException: + with excutils.save_and_reraise_exception(): + LOG.exception(_LE('Error creating snapshot')) + + def delete_snapshot(self, snapshot): + try: + self.restapi.delete( + 'service/' + self.iscsi_service + '/iscsi/snapshot', + { + 'objectPath': self.bucket_path + '/' + + snapshot['volume_name'], + 'snapName': snapshot['name'] + }) + except exception.VolumeBackendAPIException: + with excutils.save_and_reraise_exception(): + LOG.exception(_LE('Error deleting snapshot')) + + def create_cloned_volume(self, volume, src_vref): + vol_url = (self.bucket_url + '/objects/' + + src_vref['name'] + '/clone') + clone_body = { + 'tenant_name': self.tenant, + 'bucket_name': self.bucket, + 'object_name': volume['name'] + } + try: + self.restapi.post(vol_url, clone_body) + self.restapi.post('service/' + self.iscsi_service + '/iscsi', { + 'objectPath': self.bucket_path + '/' + volume['name'], + 'volSizeMB': int(src_vref['size']) * units.Ki, + 'blockSize': self.blocksize, + 'chunkSize': self.chunksize + }) + except exception.VolumeBackendAPIException: + with excutils.save_and_reraise_exception(): + LOG.exception(_LE('Error creating cloned volume')) + + def create_export(self, context, volume, connector=None): + return {'provider_location': self._get_provider_location(volume)} + + def ensure_export(self, context, volume): + pass + + def remove_export(self, context, volume): + pass + + def local_path(self, volume): + raise NotImplementedError + + def get_volume_stats(self, refresh=False): + location_info = '%(driver)s:%(host)s:%(bucket)s' % { + 'driver': self.__class__.__name__, + 'host': self._get_target_address(None), + 'bucket': self.bucket_path + } + return { + 'vendor_name': 'Nexenta', + 'driver_version': self.VERSION, + 'storage_protocol': 'iSCSI', + 'reserved_percentage': 0, + 'total_capacity_gb': 'unknown', + 'free_capacity_gb': 'unknown', + 'QoS_support': False, + 'volume_backend_name': self.backend_name, + 'location_info': location_info, + 'iscsi_target_portal_port': self.iscsi_target_port, + 'restapi_url': self.restapi.url + } diff --git a/cinder/volume/drivers/nexenta/nexentaedge/jsonrpc.py b/cinder/volume/drivers/nexenta/nexentaedge/jsonrpc.py new file mode 100644 index 00000000000..10d2e833a8b --- /dev/null +++ b/cinder/volume/drivers/nexenta/nexentaedge/jsonrpc.py @@ -0,0 +1,100 @@ +# Copyright 2015 Nexenta Systems, Inc. +# All Rights Reserved. +# +# Licensed under the Apache License, Version 2.0 (the "License"); you may +# not use this file except in compliance with the License. You may obtain +# a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, WITHOUT +# WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the +# License for the specific language governing permissions and limitations +# under the License. + +import json +import requests +import socket + +from oslo_log import log as logging + +from cinder import exception +from cinder.i18n import _ +from cinder.utils import retry + +LOG = logging.getLogger(__name__) +socket.setdefaulttimeout(100) + + +class NexentaEdgeJSONProxy(object): + + retry_exc_tuple = ( + requests.exceptions.ConnectionError, + requests.exceptions.ConnectTimeout + ) + + def __init__(self, protocol, host, port, path, user, password, auto=False, + method=None): + self.protocol = protocol.lower() + self.host = host + self.port = port + self.path = path + self.user = user + self.password = password + self.auto = auto + self.method = method + + @property + def url(self): + return '%s://%s:%s%s' % (self.protocol, + self.host, self.port, self.path) + + def __getattr__(self, name): + if not self.method: + method = name + else: + raise exception.VolumeDriverException( + _("Wrong resource call syntax")) + return NexentaEdgeJSONProxy( + self.protocol, self.host, self.port, self.path, + self.user, self.password, self.auto, method) + + def __hash__(self): + return self.url.__hash___() + + def __repr__(self): + return 'HTTP JSON proxy: %s' % self.url + + @retry(retry_exc_tuple, interval=1, retries=6) + def __call__(self, *args): + self.path += args[0] + data = None + if len(args) > 1: + data = json.dumps(args[1]) + + auth = ('%s:%s' % (self.user, self.password)).encode('base64')[:-1] + headers = { + 'Content-Type': 'application/json', + 'Authorization': 'Basic %s' % auth + } + + LOG.debug('Sending JSON data: %s', self.url) + + if self.method == 'get': + req = requests.get(self.url, headers=headers) + if self.method == 'post': + req = requests.post(self.url, data=data, headers=headers) + if self.method == 'put': + req = requests.put(self.url, data=data, headers=headers) + if self.method == 'delete': + req = requests.delete(self.url, data=data, headers=headers) + + rsp = req.json() + req.close() + + LOG.debug('Got response: %s', rsp) + if rsp.get('response') is None: + raise exception.VolumeBackendAPIException( + _('Error response: %s') % rsp) + return rsp.get('response') diff --git a/tests-py3.txt b/tests-py3.txt index d071dda3fc4..febfce0d2d3 100644 --- a/tests-py3.txt +++ b/tests-py3.txt @@ -92,6 +92,7 @@ cinder.tests.unit.test_misc cinder.tests.unit.test_netapp cinder.tests.unit.test_netapp_nfs cinder.tests.unit.test_netapp_ssc +cinder.tests.unit.test_nexenta_edge cinder.tests.unit.test_nfs cinder.tests.unit.test_nimble cinder.tests.unit.test_prophetstor_dpl