Merge "VAST driver: Add VIP pool extra specs and list pagination"

This commit is contained in:
Zuul authored and Gerrit Code Review committed 2026-10-03 09:33:55 +00:00
commit 802a447485
9 files changed
+1403 -75

No files matched your search

@@ -1,6 +1,6 @@
====================================
=====================
Vastdata Share Driver
====================================
=====================
VAST Share Driver integrates OpenStack with
`VAST Data <https://www.vastdata.com>`__'s Storage System.
@@ -44,7 +44,7 @@ All configuration options for this driver are documented in the
VAST Share Driver configuration example
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
The following example shows parameters in the ``manila.conf`` file
that are used to configure VAST Share Driver.
@@ -52,6 +52,13 @@ They include two options under ``[DEFAULT]`` and parameters under ``[vast]``.
Note that a real ``manila.conf`` file would also include
other parameters that are not specific to VAST Share Driver.
.. note::
The ``vast_vippool_name`` parameter can be omitted from ``manila.conf``
if you plan to specify different VIP pools per share type using the
``vast:vippool_name`` extra spec. See the Multitenancy support section
for more details.
.. code-block:: ini
[DEFAULT]
@@ -77,7 +84,7 @@ changes to take effect.
Pre-configurations for share support
--------------------------------------------------
------------------------------------
To create a file share, you need to:
@@ -94,8 +101,75 @@ Create an NFS share:
openstack share create NFS ${size} --name ${share_name} --share-type ${share_type_name}
Multitenancy support via Share Types
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
The VAST Share Driver supports multitenancy by allowing different share types
to use different VIP pools. This enables tenant isolation and provides
flexibility in network configuration.
VIP Pool Configuration
----------------------
The VIP pool can be specified in two ways:
1. **Global default** in ``manila.conf`` using ``vast_vippool_name``
2. **Per share type** using the ``vast:vippool_name`` extra spec
When both are specified, the share type extra spec takes precedence over
the configuration file setting.
Creating Share Types with Different VIP Pools
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
You can create multiple share types, each using a different VIP pool for
multitenancy:
.. code-block:: console
# Tenant A with dedicated VIP pool
openstack share type create vast-tenant-a false \
--extra-specs share_backend_name=vast \
--extra-specs vast:vippool_name=vippool-tenant-a
# Tenant B with dedicated VIP pool
openstack share type create vast-tenant-b false \
--extra-specs share_backend_name=vast \
--extra-specs vast:vippool_name=vippool-tenant-b
Creating Shares with Specific VIP Pools
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
When creating a share, specify the share type to determine which VIP pool
will be used:
.. code-block:: console
# Create a share for Tenant A (uses vippool-tenant-a)
openstack share create NFS 100 \
--name tenant-a-share \
--share-type vast-tenant-a
# Create a share for Tenant B (uses vippool-tenant-b)
openstack share create NFS 50 \
--name tenant-b-share \
--share-type vast-tenant-b
This approach enables:
- **Network isolation** between different tenants or projects
- **Service differentiation** with different network configurations per tenant or environment
- **Flexible deployment** without modifying ``manila.conf``
.. note::
If ``vast_vippool_name`` is not specified in ``manila.conf`` and a share
is created without a share type that specifies ``vast:vippool_name``,
the share creation will fail with an error indicating that a VIP pool
must be specified.
Pre-Configurations for Snapshot support
--------------------------------------------------
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
The share type must have the following parameter specified:
@@ -117,7 +191,7 @@ Or you can add it to an existing share type:
To snapshot a share and create share from the snapshot
------------------------------------------------------
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
Create a share using a share type with snapshot_support=True.
Then, create a snapshot of the share using the command:
+70 -14
View File
@@ -63,7 +63,8 @@ OPTS = [
),
cfg.StrOpt(
"vast_vippool_name",
help="Name of Virtual IP pool"
help="Default name of Virtual IP pool. Can be overridden per share "
"using share type extra spec 'vast:vippool_name'."
),
cfg.StrOpt(
"vast_root_export",
@@ -104,9 +105,21 @@ MANILA_TO_VAST_ACCESS_LEVEL = {
driver_util.verbose_driver_trace
)
class VASTShareDriver(driver.ShareDriver):
"""Driver for the VastData Filesystem."""
"""Driver for the VastData Filesystem.
VERSION = "1.0" # driver version
Version history::
1.0 - Initial version.
- Support for NFS shares.
- Support for snapshots (create, delete, revert).
- Support for share creation from snapshots.
- Support for share access rules (IP-based).
- Support for share resize (extend/shrink).
1.1 - Added multi-tenancy support.
- Support for per-share VIP pool selection via extra specs.
"""
VERSION = "1.1"
def __init__(self, *args, **kwargs):
super().__init__(False, *args, config_opts=[OPTS], **kwargs)
@@ -116,10 +129,7 @@ class VASTShareDriver(driver.ShareDriver):
backend_name = self.configuration.safe_get("share_backend_name")
root_export = self.configuration.vast_root_export
vip_pool_name = self.configuration.safe_get("vast_vippool_name")
if not vip_pool_name:
raise exception.VastDriverException(
reason="vast_vippool_name must be set"
)
self._backend_name = backend_name or self.__class__.__name__
self._vippool_name = vip_pool_name
self._root_export = "/" + root_export.strip("/")
@@ -276,9 +286,14 @@ class VASTShareDriver(driver.ShareDriver):
self._resize_share(share, new_size)
def create_snapshot(self, context, snapshot, share_server=None):
"""Is called to create snapshot."""
"""Is called to create a snapshot."""
path = self._to_volume_path(snapshot["share_instance_id"])
self.rest.snapshots.create(path=path, name=snapshot["name"])
tenant_id = self._get_tenant_id_for_share(snapshot["share"])
self.rest.snapshots.create(
name=snapshot["name"],
path=path,
tenant_id=tenant_id,
)
def delete_snapshot(self, context, snapshot, share_server=None):
"""Is called to remove share."""
@@ -316,6 +331,33 @@ class VASTShareDriver(driver.ShareDriver):
share_id=share['id'])
self.rest.quotas.update(quota.id, hard_limit=requested_capacity)
def _get_vip_pool_for_share(self, share):
# Get extra specs from share type
extra_specs = driver_util.get_share_extra_specs_params(share)
# Use vippool_name from extra specs if provided, otherwise use config
vippool_name = extra_specs.get('vippool_name') or self._vippool_name
if not vippool_name:
raise exception.VastDriverException(
reason="VIP pool name must be specified either in manila.conf "
"(vast_vippool_name) or in share type extra specs "
"(vast:vippool_name)"
)
LOG.debug(
"Using VIP pool '%s' for share %s (from %s)",
vippool_name, share["id"],
"extra_specs" if extra_specs.get('vippool_name') else "config"
)
return self.rest.vip_pools.one(
name=vippool_name,
fail_if_missing=True,
)
def _get_tenant_id_for_share(self, share):
return self._get_vip_pool_for_share(share).tenant_id
def _ensure_share(self, share):
share_proto = share["share_proto"]
if share_proto != "NFS":
@@ -325,15 +367,26 @@ class VASTShareDriver(driver.ShareDriver):
)
)
vips = self.rest.vip_pools.vips(pool_name=self._vippool_name)
vippool = self._get_vip_pool_for_share(share)
vips = driver_util.generate_ip_range(vippool.ip_ranges)
if not vips:
raise exception.VastDriverException(
reason=f"Pool {vippool.name} has no available vips"
)
share_id = share["id"]
requested_capacity = share["size"] * units.Gi
path = self._to_volume_path(share_id)
policy = self.rest.view_policies.ensure(name=share_id)
policy = self.rest.view_policies.ensure(
name=share_id,
tenant_id=vippool.tenant_id,
)
quota = self.rest.quotas.ensure(
name=share_id, path=path,
create_dir=True, hard_limit=requested_capacity
name=share_id,
path=path,
create_dir=True,
hard_limit=requested_capacity,
tenant_id=vippool.tenant_id,
)
if quota.hard_limit != requested_capacity:
raise exception.VastDriverException(
@@ -341,7 +394,10 @@ class VASTShareDriver(driver.ShareDriver):
f" (requested={requested_capacity}, exists={quota.hard_limit})"
)
view = self.rest.views.ensure(
name=share_id, path=path, policy_id=policy.id
name=share_id,
path=path,
policy_id=policy.id,
tenant_id=vippool.tenant_id,
)
if view.policy != share_id:
self.rest.views.update(view.id, policy_id=policy.id)
@@ -12,6 +12,7 @@
# 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 copy
import ipaddress
import types
@@ -19,10 +20,80 @@ from oslo_config import cfg
from oslo_log import log
from oslo_utils import timeutils
from manila.share import share_types
CONF = cfg.CONF
LOG = log.getLogger(__name__)
# VAST driver namespace for extra specs
VAST_EXTRA_SPEC_NAMESPACE = 'vast'
# Supported VAST extra specs options
VAST_EXTRA_SPECS_OPTS = {
'vippool_name': None,
}
def get_share_extra_specs_params(share):
"""Return the VAST-specific parameters from share extra specs.
Args:
share: The share object containing share_type_id
Returns:
dict: Dictionary of VAST-specific options extracted from extra specs
"""
specs = share_types.get_extra_specs_from_share(share)
return get_opts_from_specs(specs)
def get_opts_from_specs(specs):
"""Parse extra specs and extract VAST-specific options.
This function extracts options with the 'vast:' namespace prefix.
For example: vast:vippool_name=pool-1
Args:
specs: Dictionary of extra specs from a share type
Returns:
dict: Dictionary of parsed VAST options
"""
opts = copy.deepcopy(VAST_EXTRA_SPECS_OPTS)
for key, value in specs.items():
# Get the scope (namespace), if using scope format
scope = None
key_split = key.split(':')
# Skip invalid format (more than 2 parts)
if len(key_split) not in (1, 2):
continue
if len(key_split) == 1:
# No namespace, skip for VAST-specific options
continue
else:
scope = key_split[0]
option_key = key_split[1]
# Normalize to lowercase for comparison
if scope:
scope = scope.lower()
if option_key:
option_key = option_key.lower()
# Extract options with 'vast:' namespace
if scope == VAST_EXTRA_SPEC_NAMESPACE and option_key in opts:
opts[option_key] = value
LOG.debug(
"Found VAST extra spec: %s:%s = %s",
scope, option_key, value
)
return opts
class Bunch(dict):
# from https://github.com/real-easypy/easypy
+91 -28
View File
@@ -22,14 +22,26 @@ from oslo_log import log as logging
from oslo_utils import versionutils
from packaging import version as packaging_version
import requests
from requests import cookies
from manila import exception
from manila.share.drivers.vastdata import driver_util
from manila.share.drivers.vastdata.rest_iterator import DEFAULT_PAGE_SIZE
from manila.share.drivers.vastdata.rest_iterator import ResourceIterator
import manila.utils as manila_utils
LOG = logging.getLogger(__name__)
class NoCookiesJar(cookies.RequestsCookieJar):
def set(self, name, value, **kwargs):
return None
def set_cookie(self, cookie, *args, **kwargs):
return
class Session(requests.Session):
def __init__(
@@ -47,6 +59,7 @@ class Session(requests.Session):
self.username = username
self.password = password
self.token = api_token
self.cookies = NoCookiesJar()
self.headers["Accept"] = "application/json"
self.headers["Content-Type"] = "application/json"
self.headers["User-Agent"] = (
@@ -126,6 +139,7 @@ class Session(requests.Session):
raise exception.VastApiException(reason=str(exc))
ret = ret.json() if ret.content else {}
if ret and log_result:
formatted_response = textwrap.indent(
pprint.pformat(ret), prefix="| "
@@ -190,12 +204,17 @@ class VastResource(ABC):
self.rest = rest # For intercommunication between resources.
self.session = rest.session
def iter(self, page_size=DEFAULT_PAGE_SIZE, **params):
"""Create an iterator over list results (handles VMS pagination)."""
return ResourceIterator(
resource=self, initial_params=params, page_size=page_size)
def list(self, **params):
"""Get list of entries with optional filtering params"""
return self.session.get(self.resource_name, params=params)
"""Get a list of entries with optional filtering params."""
return self.iter(**params).all()
def create(self, **params):
"""Create new entry with provided params"""
"""Create a new entry with provided params"""
return self.session.post(self.resource_name, data=params)
def update(self, entry_id, **params):
@@ -204,51 +223,76 @@ class VastResource(ABC):
f"{self.resource_name}/{entry_id}", data=params
)
def delete(self, name):
"""Delete entry by name. Skip if entry not found."""
entry = self.one(name)
def delete(self, **params):
"""Delete entry by provided params. Skip if entry not found."""
entry = self.one(**params)
if not entry:
resource = self.__class__.__name__.lower()
serialized_params = json.dumps(params, separators=(",", ":"))
LOG.warning(
f"{resource} {name} not found on VAST, skipping delete"
"%r not found for params %s, skipping delete",
resource, serialized_params
)
return
return self.session.delete(f"{self.resource_name}/{entry.id}")
return self.delete_by_id(entry["id"])
def one(self, name):
"""Get single entry by name.
def delete_by_id(self, entry_id, **params):
"""Delete entry by id"""
return self.session.delete(
f"{self.resource_name}/{entry_id}",
**params,
)
Raise exception if multiple entries found.
"""
entries = self.list(name=name)
def one(self, *, fail_if_missing=False, **params):
"""Retrieve a single entry by provided filter parameters.
Args:
fail_if_missing: If True, raise exception when entry not found
**params: Filter parameters (keyword-only)
Raises exception If no entry is found and `fail_if_missing` is True,
or if multiple entries are found.
"""
entries = self.list(**params)
resource = self.__class__.__name__.lower()
if not entries:
if fail_if_missing:
serialized_params = json.dumps(params, separators=(",", ":"))
raise exception.VastDriverException(
reason=f"No {resource!r} "
f"found for params {serialized_params}"
)
return
if len(entries) > 1:
resource = self.__class__.__name__.lower() + "s"
serialized_params = json.dumps(params, separators=(",", ":"))
raise exception.VastDriverException(
reason=f"Too many {resource} found with name {name}"
reason=f"Too many '{resource}s' "
f"found for params {serialized_params}: {entries}"
)
return entries[0]
def ensure(self, name, **params):
entry = self.one(name)
entry = self.one(name=name)
if not entry:
entry = self.create(name=name, **params)
return entry
def get(self, entry_id, **params):
"""Get a single entry by id"""
return self.session.get(
f"{self.resource_name}/{entry_id}",
params=params,
)
class View(VastResource):
resource_name = "views"
def create(self, name, path, policy_id):
data = dict(
name=name,
path=path,
policy_id=policy_id,
create_dir=True,
protocols=["NFS"],
)
return super().create(**data)
def create(self, **params):
# Set defaults for view creation
params.setdefault("create_dir", True)
params.setdefault("protocols", ["NFS"])
return super().create(**params)
class ViewPolicy(VastResource):
@@ -280,6 +324,13 @@ class Quota(VastResource):
class VipPool(VastResource):
resource_name = "vippools"
@cachetools.cached(cache=cachetools.TTLCache(ttl=60 * 30, maxsize=128))
def one(self, **params):
# Cache results to avoid repeated REST API calls for the same pool.
# The vippool is frequently accessed (e.g., to resolve tenant IDs
# for shares and snapshots), so heavy usage of this method is expected.
return super().one(**params)
def vips(self, pool_name):
"""Get list of ip addresses from vip pool"""
vippool = self.one(name=pool_name)
@@ -298,6 +349,16 @@ class VipPool(VastResource):
class Snapshots(VastResource):
resource_name = "snapshots"
def create(self, name, path, tenant_id=None):
data = dict(name=name, path=path)
if tenant_id:
data["tenant_id"] = tenant_id
return super().create(**data)
class Versions(VastResource):
resource_name = "versions"
class Folders(VastResource):
resource_name = "folders"
@@ -349,11 +410,13 @@ class RestApi:
self.vip_pools = VipPool(self)
self.snapshots = Snapshots(self)
self.folders = Folders(self)
self.versions = Versions(self)
# Refresh auth token to avoid initial "forbidden" status error.
self.session.refresh_auth_token()
if not api_token:
# Refresh auth token to avoid initial "forbidden" status error.
self.session.refresh_auth_token()
@cachetools.cached(cache=cachetools.TTLCache(ttl=60 * 60, maxsize=1))
def get_sw_version(self):
"""Software version of cluster Rest API interacts with"""
return self.session.versions(status="success")[0].sys_version
return self.versions.list(status="success")[0].sys_version
@@ -0,0 +1,148 @@
# Copyright 2026 VAST Data 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.
"""Resource iterator for automatic VMS pagination handling.
Handles both paginated responses ({results, count, next, previous}) and
plain list responses.
"""
import urllib.parse
from manila import exception
from manila.share.drivers.vastdata import driver_util
DEFAULT_PAGE_SIZE = 1000
class ResourceIterator:
"""Iterate VAST API list results across paginated and non-paginated shapes.
Newer VMS versions return list endpoints as::
{"count": N, "next": "...", "previous": "...", "results": [...]}
Older versions may return a bare list. This iterator normalizes both so
callers always consume lists of objects, and follows ``next`` until all
pages are fetched.
"""
def __init__(self, resource, initial_params=None,
page_size=DEFAULT_PAGE_SIZE):
self.resource = resource
self.session = resource.session
self.initial_params = dict(initial_params or {})
self._page_size = page_size
if self._page_size > 0 and 'page_size' not in self.initial_params:
self.initial_params['page_size'] = self._page_size
self._initialized = False
self._current = []
self._next_url = None
self._previous_url = None
self._total_count = -1
self._current_page = 0
def _fetch_page(self, url=None, params=None):
if url:
# Keep api_method path-only; pass query via params so slash-join in
# request() cannot corrupt values (e.g. path__startswith=/foo).
parsed = urllib.parse.urlparse(url)
path = parsed.path or ""
if path.startswith("/api/"):
path = path[len("/api/"):]
path = path.strip("/")
query_params = dict(
urllib.parse.parse_qsl(parsed.query, keep_blank_values=True)
)
response = self.session.request(
"GET", path, params=query_params or None
)
else:
response = self.session.get(
self.resource.resource_name,
params=params or self.initial_params,
)
return self._process_response(response)
def _process_response(self, response):
if (isinstance(response, driver_util.Bunch) and
'results' in response and 'count' in response):
self._current = list(response.results or [])
self._total_count = response.count or 0
self._next_url = response.get('next')
self._previous_url = response.get('previous')
elif isinstance(response, (list, tuple)):
self._current = list(response)
self._total_count = len(self._current)
self._next_url = None
self._previous_url = None
elif isinstance(response, driver_util.Bunch):
self._current = [response]
self._total_count = 1
self._next_url = None
self._previous_url = None
else:
raise exception.VastApiException(
reason=(
"Unexpected response type in ResourceIterator: "
f"{type(response)}"
)
)
return self._current
def next(self):
if not self._initialized:
self._fetch_page(params=self.initial_params)
self._initialized = True
return self._current
if not self.has_next():
return []
self._current_page += 1
return self._fetch_page(url=self._next_url)
def has_next(self):
if not self._initialized:
return True
return bool(self._next_url)
def count(self):
return self._total_count
def all(self):
"""Fetch all pages and return every record as one list."""
all_records = []
if not self._initialized:
all_records.extend(self.next())
else:
all_records.extend(self._current)
while self.has_next():
all_records.extend(self.next())
return all_records
def __iter__(self):
return self
def __next__(self):
if not self._initialized:
return self.next()
if not self.has_next():
raise StopIteration
return self.next()
@@ -84,7 +84,10 @@ class VASTShareDriverTestCase(unittest.TestCase):
execute=mock.MagicMock(), configuration=self.fake_conf
)
self._driver.do_setup(self._context)
m_auth_token.assert_called_once()
if self.fake_conf.vast_api_token:
m_auth_token.assert_not_called()
else:
m_auth_token.assert_called_once()
def test_do_setup(self):
session = self._driver.rest.session
@@ -94,7 +97,7 @@ class VASTShareDriverTestCase(unittest.TestCase):
self.assertFalse(session.ssl_verify)
self.assertEqual(session.base_url, "https://test:443/api")
@ddt.data("vast_mgmt_user", "vast_vippool_name", "vast_mgmt_host")
@ddt.data("vast_mgmt_user", "vast_mgmt_host")
def test_do_setup_missing_required_fields(self, missing_field):
self.fake_conf.set_default(missing_field, None)
_driver = driver.VASTShareDriver(
@@ -123,7 +126,7 @@ class VASTShareDriverTestCase(unittest.TestCase):
self.assertEqual(result["share_backend_name"], "vast")
self.assertEqual(result["driver_handles_share_servers"], False)
self.assertEqual(result["vendor_name"], "VAST STORAGE")
self.assertEqual(result["driver_version"], "1.0")
self.assertEqual(result["driver_version"], "1.1")
self.assertEqual(result["storage_protocol"], "NFS")
self.assertEqual(result["total_capacity_gb"], 471.1061706542969)
self.assertEqual(result["free_capacity_gb"], 450.2256333641708)
@@ -158,7 +161,15 @@ class VASTShareDriverTestCase(unittest.TestCase):
)
)
@ddt.unpack
def test_create_shares(self, capacity, proto, policy):
@mock.patch('manila.share.share_types.get_extra_specs_from_share')
def test_create_shares(
self,
capacity,
proto,
policy,
mock_get_extra_specs,
):
mock_get_extra_specs.return_value = {}
share = fake_share.fake_share(share_proto=proto)
mock_rest = self._create_mocked_rest_api()
mock_rest.view_policies.ensure.return_value = driver_util.Bunch(id=1)
@@ -168,7 +179,11 @@ class VASTShareDriverTestCase(unittest.TestCase):
mock_rest.views.ensure.return_value = driver_util.Bunch(
id=3, policy=policy
)
mock_rest.vip_pools.vips.return_value = ["1.1.1.0", "1.1.1.1"]
# Mock vip_pools.one() to return pool with ip_ranges and tenant_id
mock_rest.vip_pools.one.return_value = driver_util.Bunch(
ip_ranges=[["1.1.1.0", "1.1.1.1"]],
tenant_id=123
)
with mock.patch.object(self._driver, "rest", mock_rest):
if proto != "NFS":
with self.assertRaises(exception.InvalidShare) as exc:
@@ -187,20 +202,26 @@ class VASTShareDriverTestCase(unittest.TestCase):
else:
location = self._driver.create_share(self._context, share)
mock_rest.vip_pools.vips.assert_called_once_with(
pool_name="vippool"
mock_rest.vip_pools.one.assert_called_once_with(
name="vippool",
fail_if_missing=True,
)
mock_rest.view_policies.ensure.assert_called_once_with(
name="fakeid"
name="fakeid",
tenant_id=123,
)
mock_rest.quotas.ensure.assert_called_once_with(
name="fakeid",
path="/fake/manila-fakeid",
create_dir=True,
hard_limit=capacity,
tenant_id=123,
)
mock_rest.views.ensure.assert_called_once_with(
name="fakeid", path="/fake/manila-fakeid", policy_id=1
name="fakeid",
path="/fake/manila-fakeid",
policy_id=1,
tenant_id=123,
)
self.assertListEqual(
location,
@@ -592,15 +613,47 @@ class VASTShareDriverTestCase(unittest.TestCase):
self._driver.shrink_share(share, 9.7)
self._driver.shrink_share(share, 10)
def test_create_snapshot(self):
@mock.patch('manila.share.share_types.get_extra_specs_from_share')
def test_create_snapshot(self, mock_get_extra_specs):
mock_get_extra_specs.return_value = {}
share = fake_share.fake_share(share_proto="NFS")
snapshot = driver_util.Bunch(
name="fakesnap", share_instance_id="fakeid"
name="fakesnap", share_instance_id="fakeid", share=share
)
mock_rest = self._create_mocked_rest_api()
mock_rest.vip_pools.one.return_value = driver_util.Bunch(
tenant_id=123
)
with mock.patch.object(self._driver, "rest", mock_rest):
self._driver.create_snapshot(self._context, snapshot, None)
mock_rest.snapshots.create.assert_called_once_with(
path="/fake/manila-fakeid", name="fakesnap"
path="/fake/manila-fakeid", name="fakesnap", tenant_id=123
)
@mock.patch('manila.share.share_types.get_extra_specs_from_share')
def test_create_snapshot_with_custom_vippool(self, mock_get_extra_specs):
"""Test snapshot creation with custom vippool from extra specs."""
mock_get_extra_specs.return_value = {
'vast:vippool_name': 'custom-vippool'
}
share = fake_share.fake_share(share_proto="NFS")
snapshot = driver_util.Bunch(
name="fakesnap", share_instance_id="fakeid", share=share
)
mock_rest = self._create_mocked_rest_api()
mock_rest.vip_pools.one.return_value = driver_util.Bunch(
tenant_id=456
)
with mock.patch.object(self._driver, "rest", mock_rest):
self._driver.create_snapshot(self._context, snapshot, None)
# Verify that custom-vippool was used
mock_rest.vip_pools.one.assert_called_once_with(
name="custom-vippool",
fail_if_missing=True,
)
mock_rest.snapshots.create.assert_called_once_with(
path="/fake/manila-fakeid", name="fakesnap", tenant_id=456
)
def test_delete_snapshot(self):
@@ -616,7 +669,9 @@ class VASTShareDriverTestCase(unittest.TestCase):
self.assertEqual(self._driver.get_network_allocations_number(), 0)
@ddt.data([], ['fake/path/1', 'fake/path'])
def test_ensure_shares(self, fake_export_locations):
@mock.patch('manila.share.share_types.get_extra_specs_from_share')
def test_ensure_shares(self, fake_export_locations, mock_get_extra_specs):
mock_get_extra_specs.return_value = {}
mock_rest = self._create_mocked_rest_api()
mock_rest.view_policies.ensure.return_value = driver_util.Bunch(id=1)
mock_rest.quotas.ensure.return_value = driver_util.Bunch(
@@ -634,7 +689,10 @@ class VASTShareDriverTestCase(unittest.TestCase):
)
for _id, share_id in enumerate(["123", "456", "789"], 1)
]
mock_rest.vip_pools.vips.return_value = ["1.1.1.0", "1.1.1.1"]
mock_rest.vip_pools.one.return_value = driver_util.Bunch(
ip_ranges=[["1.1.1.0", "1.1.1.1"]],
tenant_id=123
)
with mock.patch.object(self._driver, "rest", mock_rest):
locations = self._driver.ensure_shares(self._context, shares)
@@ -670,6 +728,281 @@ class VASTShareDriverTestCase(unittest.TestCase):
{'vast_vippool_name': 'vippool', 'vast_mgmt_host': 'test'}
)
@mock.patch('manila.share.share_types.get_extra_specs_from_share')
def test_create_share_with_extra_specs_vippool(self, mock_get_specs):
"""Test that vippool_name from extra specs overrides config."""
share = fake_share.fake_share(
share_proto="NFS",
share_type_id="fake-type-id",
)
mock_get_specs.return_value = {
'vast:vippool_name': 'custom-vippool'
}
mock_rest = self._create_mocked_rest_api()
mock_rest.view_policies.ensure.return_value = driver_util.Bunch(id=1)
mock_rest.quotas.ensure.return_value = driver_util.Bunch(
id=2, hard_limit=1073741824
)
mock_rest.views.ensure.return_value = driver_util.Bunch(
id=3, policy="fakeid"
)
mock_rest.vip_pools.one.return_value = driver_util.Bunch(
ip_ranges=[["2.2.2.0", "2.2.2.1"]],
tenant_id=456
)
with mock.patch.object(self._driver, "rest", mock_rest):
location = self._driver.create_share(self._context, share)
# Verify that custom-vippool was used instead of config's vippool
mock_rest.vip_pools.one.assert_called_once_with(
name="custom-vippool",
fail_if_missing=True,
)
self.assertEqual(len(location), 2)
self.assertEqual(
location[0]['path'], '2.2.2.0:/fake/manila-fakeid'
)
self.assertEqual(
location[1]['path'], '2.2.2.1:/fake/manila-fakeid'
)
@mock.patch('manila.share.share_types.get_extra_specs_from_share')
def test_create_share_without_extra_specs_uses_config(
self, mock_get_specs
):
"""Test that config vippool_name is used when no extra specs."""
share = fake_share.fake_share(
share_proto="NFS",
share_type_id="fake-type-id",
)
mock_get_specs.return_value = {}
mock_rest = self._create_mocked_rest_api()
mock_rest.view_policies.ensure.return_value = driver_util.Bunch(id=1)
mock_rest.quotas.ensure.return_value = driver_util.Bunch(
id=2, hard_limit=1073741824
)
mock_rest.views.ensure.return_value = driver_util.Bunch(
id=3, policy="fakeid"
)
mock_rest.vip_pools.one.return_value = driver_util.Bunch(
ip_ranges=[["1.1.1.0", "1.1.1.1"]],
tenant_id=123
)
with mock.patch.object(self._driver, "rest", mock_rest):
self._driver.create_share(self._context, share)
# Verify that config's vippool was used
mock_rest.vip_pools.one.assert_called_once_with(
name="vippool",
fail_if_missing=True,
)
@mock.patch('manila.share.share_types.get_extra_specs_from_share')
def test_create_share_without_vippool_raises_error(self, mock_get_specs):
"""Test that error is raised when vippool_name is not available."""
mock_get_specs.return_value = {}
# Create driver without vippool_name in config
self.fake_conf.set_default("vast_vippool_name", None)
_driver = driver.VASTShareDriver(
execute=mock.MagicMock(), configuration=self.fake_conf
)
_driver.do_setup(self._context)
# Create share without vippool in config or extra specs
share = fake_share.fake_share(
share_proto="NFS",
)
with self.assertRaises(exception.VastDriverException) as exc:
_driver._ensure_share(share)
self.assertIn(
"VIP pool name must be specified", str(exc.exception)
)
@mock.patch('manila.share.share_types.get_extra_specs_from_share')
def test_create_share_with_empty_vip_pool_raises_error(
self,
mock_get_specs,
):
"""Test that error is raised when vip pool has no available IPs."""
mock_get_specs.return_value = {}
share = fake_share.fake_share(share_proto="NFS")
mock_rest = self._create_mocked_rest_api()
# Mock vip_pools.one() to return pool with empty ip_ranges
mock_rest.vip_pools.one.return_value = driver_util.Bunch(
name="vippool",
ip_ranges=[], # No IP ranges
tenant_id=123
)
with mock.patch.object(self._driver, "rest", mock_rest):
with self.assertRaises(exception.VastDriverException) as exc:
self._driver.create_share(self._context, share)
self.assertIn("has no available vips", str(exc.exception))
@mock.patch('manila.share.share_types.get_extra_specs_from_share')
def test_get_vip_pool_for_share_from_extra_specs(self, mock_get_specs):
"""Test _get_vip_pool_for_share using extra specs."""
mock_get_specs.return_value = {'vast:vippool_name': 'custom-pool'}
share = fake_share.fake_share(share_proto="NFS")
mock_rest = self._create_mocked_rest_api()
mock_vippool = driver_util.Bunch(
name="custom-pool",
ip_ranges=[["1.1.1.1", "1.1.1.2"]],
tenant_id=456
)
mock_rest.vip_pools.one.return_value = mock_vippool
with mock.patch.object(self._driver, "rest", mock_rest):
result = self._driver._get_vip_pool_for_share(share)
self.assertEqual(result, mock_vippool)
mock_rest.vip_pools.one.assert_called_once_with(
name="custom-pool",
fail_if_missing=True,
)
@mock.patch('manila.share.share_types.get_extra_specs_from_share')
def test_get_vip_pool_for_share_from_config(self, mock_get_specs):
"""Test _get_vip_pool_for_share using config default."""
mock_get_specs.return_value = {}
share = fake_share.fake_share(share_proto="NFS")
mock_rest = self._create_mocked_rest_api()
mock_vippool = driver_util.Bunch(
name="vippool",
ip_ranges=[["2.2.2.1", "2.2.2.2"]],
tenant_id=789
)
mock_rest.vip_pools.one.return_value = mock_vippool
with mock.patch.object(self._driver, "rest", mock_rest):
result = self._driver._get_vip_pool_for_share(share)
self.assertEqual(result, mock_vippool)
mock_rest.vip_pools.one.assert_called_once_with(
name="vippool",
fail_if_missing=True,
)
@mock.patch('manila.share.share_types.get_extra_specs_from_share')
def test_get_tenant_id_for_share(self, mock_get_specs):
"""Test _get_tenant_id_for_share returns correct tenant_id."""
mock_get_specs.return_value = {}
share = fake_share.fake_share(share_proto="NFS")
mock_rest = self._create_mocked_rest_api()
mock_rest.vip_pools.one.return_value = driver_util.Bunch(
name="vippool",
tenant_id=999
)
with mock.patch.object(self._driver, "rest", mock_rest):
tenant_id = self._driver._get_tenant_id_for_share(share)
self.assertEqual(tenant_id, 999)
class TestExtraSpecsParsing(unittest.TestCase):
"""Test extra specs parsing functions."""
def test_get_opts_from_specs_with_vast_namespace(self):
"""Test parsing extra specs with vast: namespace."""
specs = {
'vast:vippool_name': 'pool-1',
'capabilities:dedupe': 'true',
'other:setting': 'value',
}
opts = driver_util.get_opts_from_specs(specs)
self.assertEqual(opts['vippool_name'], 'pool-1')
def test_get_opts_from_specs_case_insensitive(self):
"""Test that namespace and key are case insensitive."""
specs = {
'VAST:VipPool_Name': 'pool-1',
'Vast:VIPPOOL_NAME': 'pool-2', # Should be ignored, first one wins
}
opts = driver_util.get_opts_from_specs(specs)
# First matching key should be used
self.assertIn(opts['vippool_name'], ['pool-1', 'pool-2'])
def test_get_opts_from_specs_without_namespace(self):
"""Test that options without namespace are ignored."""
specs = {
'vippool_name': 'pool-1',
'other_setting': 'value',
}
opts = driver_util.get_opts_from_specs(specs)
self.assertIsNone(opts['vippool_name'])
def test_get_opts_from_specs_with_invalid_format(self):
"""Test that invalid format keys are skipped."""
specs = {
'vast:vippool_name:extra': 'pool-1', # Too many colons
'vast:vippool_name': 'pool-2',
}
opts = driver_util.get_opts_from_specs(specs)
self.assertEqual(opts['vippool_name'], 'pool-2')
def test_get_opts_from_specs_empty(self):
"""Test parsing empty specs returns defaults."""
specs = {}
opts = driver_util.get_opts_from_specs(specs)
self.assertIsNone(opts['vippool_name'])
def test_get_opts_from_specs_with_empty_scope(self):
"""Test that keys with empty scope are handled correctly."""
specs = {
':vippool_name': 'pool-1', # Empty scope
'vast:vippool_name': 'pool-2',
}
opts = driver_util.get_opts_from_specs(specs)
# Empty scope should be skipped, so pool-2 should be used
self.assertEqual(opts['vippool_name'], 'pool-2')
def test_get_opts_from_specs_with_empty_option_key(self):
"""Test that keys with empty option_key are handled correctly."""
specs = {
'vast:': 'some-value', # Empty option_key
'vast:vippool_name': 'pool-1',
}
opts = driver_util.get_opts_from_specs(specs)
# Empty option_key should be skipped, so pool-1 should be used
self.assertEqual(opts['vippool_name'], 'pool-1')
@mock.patch('manila.share.share_types.get_extra_specs_from_share')
def test_get_share_extra_specs_params(self, mock_get_specs):
"""Test get_share_extra_specs_params function."""
mock_get_specs.return_value = {
'vast:vippool_name': 'pool-1',
}
share = {'share_type_id': 'fake-type-id'}
opts = driver_util.get_share_extra_specs_params(share)
self.assertEqual(opts['vippool_name'], 'pool-1')
mock_get_specs.assert_called_once_with(share)
@mock.patch('manila.share.share_types.get_extra_specs_from_share')
def test_get_share_extra_specs_params_no_extra_specs(self, mock_get_specs):
"""Test get_share_extra_specs_params with no extra specs."""
mock_get_specs.return_value = {}
share = {'share_type_id': 'fake-type-id'}
opts = driver_util.get_share_extra_specs_params(share)
# Should return default VAST extra specs when no vast: specs provided
self.assertEqual(opts, {'vippool_name': None})
mock_get_specs.assert_called_once_with(share)
class TestPolicyPayloadFromRules(unittest.TestCase):
def test_policy_payload_from_rules_update(self):
+319 -13
View File
@@ -194,16 +194,180 @@ class TestSession(unittest.TestCase):
self.session.__getattr__(attr)(**params)
mock_request.assert_called_once_with("get", attr, params=params)
@mock.patch("requests.Session.request")
def test_request_with_pagination_envelope(self, mock_request):
"""Session returns the raw paginated envelope.
Unwrapping happens in list()/ResourceIterator.
"""
paginated_response = {
"count": 2,
"results": [
{"id": 1, "name": "Item 1"},
{"id": 2, "name": "Item 2"},
],
"next": None,
"previous": None,
}
mock_response = mock.MagicMock()
mock_response.status_code = 200
mock_response.content = b"response content"
mock_response.json.return_value = paginated_response
mock_response.raise_for_status.return_value = None
mock_request.return_value = mock_response
result = self.session.request("GET", "test_method", log_result=False)
self.assertIsInstance(result, driver_util.Bunch)
self.assertEqual(result["count"], 2)
self.assertEqual(len(result["results"]), 2)
@mock.patch("requests.Session.request")
def test_request_without_pagination_envelope(self, mock_request):
"""Test that non-paginated responses work as before."""
# Mock a non-paginated response (flat list)
non_paginated_response = [
{"id": 1, "name": "Item 1"},
{"id": 2, "name": "Item 2"},
]
mock_response = mock.MagicMock()
mock_response.status_code = 200
mock_response.content = b"response content"
mock_response.json.return_value = non_paginated_response
mock_response.raise_for_status.return_value = None
mock_request.return_value = mock_response
result = self.session.request("GET", "test_method", log_result=False)
# Non-paginated responses should work as before - list of Bunches
self.assertIsInstance(result, list)
self.assertEqual(len(result), 2)
self.assertIsInstance(result[0], driver_util.Bunch)
self.assertEqual(result[0]["id"], 1)
self.assertEqual(result[1]["id"], 2)
@mock.patch("requests.Session.request")
def test_request_with_single_dict(self, mock_request):
"""Test that single dict responses work correctly."""
# Mock a single dict response (not pagination envelope)
dict_response = {"id": 1, "name": "Single Item"}
mock_response = mock.MagicMock()
mock_response.status_code = 200
mock_response.content = b"response content"
mock_response.json.return_value = dict_response
mock_response.raise_for_status.return_value = None
mock_request.return_value = mock_response
result = self.session.request("GET", "test_method", log_result=False)
# Should return a Bunch with the dict data
self.assertIsInstance(result, driver_util.Bunch)
self.assertEqual(result["id"], 1)
self.assertEqual(result["name"], "Single Item")
class TestVastResource(unittest.TestCase):
def setUp(self):
self.mock_rest = mock.MagicMock()
self.vast_resource = vast_rest.VastResource(self.mock_rest)
self.vast_resource.resource_name = "testresources"
def test_list_with_filtering_params(self):
self.mock_rest.session.get.return_value = []
self.vast_resource.list(name="test")
self.mock_rest.session.get.assert_called_with(
self.vast_resource.resource_name, params={"name": "test"}
self.vast_resource.resource_name,
params={"name": "test", "page_size": 1000},
)
def test_list_unwraps_paginated_envelope(self):
self.mock_rest.session.get.return_value = driver_util.Bunch.from_dict({
"count": 2,
"next": None,
"previous": None,
"results": [
{"id": 1, "name": "a"},
{"id": 2, "name": "b"},
],
})
result = self.vast_resource.list()
self.assertEqual(len(result), 2)
self.assertEqual(result[0]["name"], "a")
def test_list_raises_on_unexpected_response_type(self):
self.mock_rest.session.get.return_value = "not-a-list"
self.assertRaises(
manila_exception.VastApiException, self.vast_resource.list)
def test_list_follows_next_pages(self):
page1 = driver_util.Bunch.from_dict({
"count": 3,
"next": "https://host/api/testresources/?page=2",
"previous": None,
"results": [{"id": 1}, {"id": 2}],
})
page2 = driver_util.Bunch.from_dict({
"count": 3,
"next": None,
"previous": "https://host/api/testresources/?page=1",
"results": [{"id": 3}],
})
self.mock_rest.session.get.return_value = page1
self.mock_rest.session.request.return_value = page2
result = self.vast_resource.list()
self.assertEqual([item["id"] for item in result], [1, 2, 3])
self.mock_rest.session.request.assert_called_once_with(
"GET", "testresources", params={"page": "2"},
)
def test_list_pagination_next_passes_query_via_params(self):
"""Follow-up pages must keep filters in params=, not embedded in path.
Regression: embedding next's query in api_method let request() append
'/' onto path__startswith (/foo → /foo/), truncating multi-page lists.
"""
page1 = driver_util.Bunch.from_dict({
"count": 4,
"next": (
"https://host/api/v1/views/"
"?fields=path&page=2&page_size=1000"
"&path__startswith=%2Fbright-flea"
),
"previous": None,
"results": [
{"path": "/bright-flea/a"},
{"path": "/bright-flea/b"},
],
})
page2 = driver_util.Bunch.from_dict({
"count": 4,
"next": None,
"previous": None,
"results": [
{"path": "/bright-flea/c"},
{"path": "/bright-flea/d"},
],
})
self.mock_rest.session.get.return_value = page1
self.mock_rest.session.request.return_value = page2
result = self.vast_resource.list(
path__startswith="/bright-flea", fields="path", page_size=2
)
self.assertEqual(len(result), 4)
self.mock_rest.session.request.assert_called_once_with(
"GET",
"v1/views",
params={
"fields": "path",
"page": "2",
"page_size": "1000",
"path__startswith": "/bright-flea",
},
)
def test_create_with_provided_params(self):
@@ -221,21 +385,20 @@ class TestVastResource(unittest.TestCase):
def test_delete_when_entry_not_found(self):
self.vast_resource.one = mock.MagicMock(return_value=None)
self.vast_resource.delete("test")
self.vast_resource.delete(name="test")
self.mock_rest.session.delete.assert_not_called()
def test_delete_when_entry_found(self):
mock_entry = mock.MagicMock()
mock_entry.id = "1"
mock_entry = {"id": "1", "name": "test"}
self.vast_resource.one = mock.MagicMock(return_value=mock_entry)
self.vast_resource.delete("test")
self.vast_resource.delete(name="test")
self.mock_rest.session.delete.assert_called_with(
f"{self.vast_resource.resource_name}/{mock_entry.id}"
f"{self.vast_resource.resource_name}/1"
)
def test_one_when_no_entries_found(self):
self.vast_resource.list = mock.MagicMock(return_value=[])
result = self.vast_resource.one("test")
result = self.vast_resource.one(name="test")
self.assertIsNone(result)
def test_one_when_multiple_entries_found(self):
@@ -243,14 +406,19 @@ class TestVastResource(unittest.TestCase):
return_value=[mock.MagicMock(), mock.MagicMock()]
)
with self.assertRaises(manila_exception.VastDriverException):
self.vast_resource.one("test")
self.vast_resource.one(name="test")
def test_one_when_single_entry_found(self):
mock_entry = mock.MagicMock()
self.vast_resource.list = mock.MagicMock(return_value=[mock_entry])
result = self.vast_resource.one("test")
result = self.vast_resource.one(name="test")
self.assertEqual(result, mock_entry)
def test_one_not_found_with_fail_if_missing(self):
self.vast_resource.list = mock.MagicMock(return_value=[])
with self.assertRaises(manila_exception.VastDriverException):
self.vast_resource.one(name="test", fail_if_missing=True)
def test_ensure_when_entry_not_found(self):
self.vast_resource.one = mock.MagicMock(return_value=None)
mock_entry = mock.MagicMock()
@@ -264,6 +432,15 @@ class TestVastResource(unittest.TestCase):
result = self.vast_resource.ensure("test", size=10)
self.assertEqual(result, mock_entry)
def test_get_entry_by_id(self):
"""Test getting a single entry by id."""
entry_id = "123"
self.vast_resource.get(entry_id, param1="value1")
self.mock_rest.session.get.assert_called_with(
f"{self.vast_resource.resource_name}/{entry_id}",
params={"param1": "value1"}
)
class ViewTest(unittest.TestCase):
@mock.patch(
@@ -282,7 +459,7 @@ class ViewTest(unittest.TestCase):
True,
"1.0"
)
rest_api.views.create("test-view", "/test", 1)
rest_api.views.create(name="test-view", path="/test", policy_id=1)
self.assertEqual(("views",), mock_session.call_args.args)
self.assertDictEqual(
@@ -298,6 +475,44 @@ class ViewTest(unittest.TestCase):
mock_session.call_args.kwargs,
)
@mock.patch(
"manila.share.drivers.vastdata.rest.Session.refresh_auth_token",
mock.MagicMock()
)
def test_view_create_with_tenant_id(self):
with mock.patch(
"manila.share.drivers.vastdata.rest.Session.post"
) as mock_session:
rest_api = vast_rest.RestApi(
"host",
"username",
"password",
"",
True,
"1.0"
)
rest_api.views.create(
name="test-view",
path="/test",
policy_id=1,
tenant_id=123,
)
self.assertEqual(("views",), mock_session.call_args.args)
self.assertDictEqual(
{
"data": {
"name": "test-view",
"path": "/test",
"policy_id": 1,
"create_dir": True,
"protocols": ["NFS"],
"tenant_id": 123,
}
},
mock_session.call_args.kwargs,
)
@mock.patch(
"manila.share.drivers.vastdata.rest.Session.refresh_auth_token",
@@ -509,14 +724,88 @@ class VipPoolTest(unittest.TestCase):
vips = self.rest_api.vip_pools.vips("test-vip")
self.assertListEqual(vips, expected)
def test_one_with_fail_if_missing(self):
"""Test that one() raises exception when fail_if_missing=True."""
with mock.patch(
"manila.share.drivers.vastdata.rest.Session.get",
return_value=[]
):
with self.assertRaises(
manila_exception.VastDriverException
) as exc:
self.rest_api.vip_pools.one(
name="nonexistent-pool",
fail_if_missing=True
)
self.assertIn("No 'vippool' found", str(exc.exception))
def test_vippool_one_caching(self):
"""Test that VipPool.one() caches results."""
vippool = driver_util.Bunch(
name="test-pool",
tenant_id=123,
ip_ranges=[["1.1.1.1", "1.1.1.2"]]
)
# Clear any existing cache
if hasattr(self.rest_api.vip_pools.one, 'cache'):
self.rest_api.vip_pools.one.cache.clear()
with mock.patch(
"manila.share.drivers.vastdata.rest.Session.get",
return_value=[vippool]
) as mock_get:
# First call - should hit the API
result1 = self.rest_api.vip_pools.one(name="test-pool")
self.assertEqual(result1.name, "test-pool")
self.assertEqual(result1.tenant_id, 123)
self.assertEqual(mock_get.call_count, 1)
# Second call with same params - should use cache
result2 = self.rest_api.vip_pools.one(name="test-pool")
self.assertEqual(result2.name, "test-pool")
self.assertEqual(result2.tenant_id, 123)
# Call count should still be 1 (cached)
self.assertEqual(mock_get.call_count, 1)
# Call with different params - should hit the API again
self.rest_api.vip_pools.one(name="other-pool")
self.assertEqual(mock_get.call_count, 2)
def test_vippool_one_cache_ttl(self):
"""Test that VipPool.one() cache expires after TTL."""
vippool1 = driver_util.Bunch(
name="test-pool",
tenant_id=123
)
driver_util.Bunch(
name="test-pool",
tenant_id=456 # Different tenant_id
)
# Clear any existing cache
if hasattr(self.rest_api.vip_pools.one, 'cache'):
self.rest_api.vip_pools.one.cache.clear()
with mock.patch(
"manila.share.drivers.vastdata.rest.Session.get"
) as mock_get:
# First call returns tenant_id 123
mock_get.return_value = [vippool1]
result1 = self.rest_api.vip_pools.one(name="test-pool")
self.assertEqual(result1.tenant_id, 123)
# Immediate second call should use cache (tenant_id still 123)
result2 = self.rest_api.vip_pools.one(name="test-pool")
self.assertEqual(result2.tenant_id, 123)
self.assertEqual(mock_get.call_count, 1)
class TestRestApi(unittest.TestCase):
@mock.patch("manila.share.drivers.vastdata.rest.Session")
def test_get_sw_version(self, mock_session):
mock_session.return_value.versions.return_value = [
mock.MagicMock(sys_version="1.0")
]
rest_api = vast_rest.RestApi(
"host",
"username",
@@ -525,5 +814,22 @@ class TestRestApi(unittest.TestCase):
True,
"1.0",
)
rest_api.versions.list = mock.MagicMock(return_value=[
mock.MagicMock(sys_version="1.0")
])
version = rest_api.get_sw_version()
self.assertEqual(version, "1.0")
def test_api_token_initialization(self):
rest_api = vast_rest.RestApi(
"host",
"",
"",
"xxxxxxxxx",
True,
"1.0",
)
self.assertEqual(
rest_api.session.headers["authorization"],
"Api-Token xxxxxxxxx",
)
@@ -0,0 +1,266 @@
# Copyright 2026 VAST Data 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.
from unittest import mock
from manila import exception as manila_exception
from manila.share.drivers.vastdata import driver_util
from manila.share.drivers.vastdata.rest_iterator import DEFAULT_PAGE_SIZE
from manila.share.drivers.vastdata.rest_iterator import ResourceIterator
from manila import test
class TestResourceIterator(test.TestCase):
def setUp(self):
super(TestResourceIterator, self).setUp()
self.mock_session = mock.MagicMock()
self.mock_resource = mock.MagicMock()
self.mock_resource.session = self.mock_session
self.mock_resource.resource_name = "testresources"
def _make_iterator(self, initial_params=None, page_size=None):
kwargs = {"resource": self.mock_resource}
if initial_params is not None:
kwargs["initial_params"] = initial_params
if page_size is not None:
kwargs["page_size"] = page_size
return ResourceIterator(**kwargs)
def test_init_sets_default_page_size(self):
iterator = self._make_iterator(initial_params={"name": "foo"})
self.assertEqual(
{
"name": "foo",
"page_size": DEFAULT_PAGE_SIZE,
},
iterator.initial_params,
)
def test_init_preserves_explicit_page_size_param(self):
iterator = self._make_iterator(
initial_params={"page_size": 50}, page_size=100)
self.assertEqual({"page_size": 50}, iterator.initial_params)
def test_init_skips_page_size_when_disabled(self):
iterator = self._make_iterator(
initial_params={"name": "foo"}, page_size=0)
self.assertEqual({"name": "foo"}, iterator.initial_params)
def test_process_paginated_response(self):
iterator = self._make_iterator()
response = driver_util.Bunch.from_dict({
"count": 2,
"next": "https://host/api/testresources/?page=2",
"previous": None,
"results": [{"id": 1}, {"id": 2}],
})
result = iterator._process_response(response)
self.assertEqual([{"id": 1}, {"id": 2}], result)
self.assertEqual(2, iterator.count())
self.assertEqual(
"https://host/api/testresources/?page=2", iterator._next_url)
iterator._initialized = True
self.assertTrue(iterator.has_next())
def test_process_bare_list_response(self):
iterator = self._make_iterator()
result = iterator._process_response([{"id": 1}, {"id": 2}])
self.assertEqual([{"id": 1}, {"id": 2}], result)
self.assertEqual(2, iterator.count())
self.assertIsNone(iterator._next_url)
iterator._initialized = True
self.assertFalse(iterator.has_next())
def test_process_single_bunch_response(self):
iterator = self._make_iterator()
response = driver_util.Bunch(id=1, name="solo")
result = iterator._process_response(response)
self.assertEqual([response], result)
self.assertEqual(1, iterator.count())
self.assertIsNone(iterator._next_url)
iterator._initialized = True
self.assertFalse(iterator.has_next())
def test_process_unexpected_response_type(self):
iterator = self._make_iterator()
self.assertRaises(
manila_exception.VastApiException,
iterator._process_response,
"not-valid",
)
def test_process_empty_results(self):
iterator = self._make_iterator()
response = driver_util.Bunch.from_dict({
"count": 0,
"next": None,
"previous": None,
"results": None,
})
result = iterator._process_response(response)
self.assertEqual([], result)
self.assertEqual(0, iterator.count())
self.assertIsNone(iterator._next_url)
iterator._initialized = True
self.assertFalse(iterator.has_next())
def test_next_fetches_first_page(self):
page = driver_util.Bunch.from_dict({
"count": 1,
"next": None,
"previous": None,
"results": [{"id": 1}],
})
self.mock_session.get.return_value = page
iterator = self._make_iterator(initial_params={"name": "x"})
result = iterator.next()
self.assertEqual([{"id": 1}], result)
self.mock_session.get.assert_called_once_with(
"testresources",
params={"name": "x", "page_size": DEFAULT_PAGE_SIZE},
)
self.assertTrue(iterator._initialized)
def test_next_follows_next_url(self):
page1 = driver_util.Bunch.from_dict({
"count": 3,
"next": "https://host/api/testresources/?page=2&page_size=2",
"previous": None,
"results": [{"id": 1}, {"id": 2}],
})
page2 = driver_util.Bunch.from_dict({
"count": 3,
"next": None,
"previous": None,
"results": [{"id": 3}],
})
self.mock_session.get.return_value = page1
self.mock_session.request.return_value = page2
iterator = self._make_iterator()
first = iterator.next()
second = iterator.next()
third = iterator.next()
self.assertEqual([{"id": 1}, {"id": 2}], first)
self.assertEqual([{"id": 3}], second)
self.assertEqual([], third)
self.mock_session.request.assert_called_once_with(
"GET",
"testresources",
params={"page": "2", "page_size": "2"},
)
def test_fetch_page_strips_api_prefix_and_keeps_query_params(self):
page = driver_util.Bunch.from_dict({
"count": 1,
"next": None,
"previous": None,
"results": [{"path": "/bright-flea/a"}],
})
self.mock_session.request.return_value = page
iterator = self._make_iterator()
iterator._initialized = True
iterator._next_url = (
"https://host/api/v1/views/"
"?fields=path&page=2&path__startswith=%2Fbright-flea"
)
result = iterator.next()
self.assertEqual([{"path": "/bright-flea/a"}], result)
self.mock_session.request.assert_called_once_with(
"GET",
"v1/views",
params={
"fields": "path",
"page": "2",
"path__startswith": "/bright-flea",
},
)
def test_all_aggregates_pages(self):
page1 = driver_util.Bunch.from_dict({
"count": 3,
"next": "https://host/api/testresources/?page=2",
"previous": None,
"results": [{"id": 1}, {"id": 2}],
})
page2 = driver_util.Bunch.from_dict({
"count": 3,
"next": None,
"previous": None,
"results": [{"id": 3}],
})
self.mock_session.get.return_value = page1
self.mock_session.request.return_value = page2
iterator = self._make_iterator()
self.assertEqual(
[{"id": 1}, {"id": 2}, {"id": 3}], iterator.all())
def test_all_after_partial_iteration(self):
page1 = driver_util.Bunch.from_dict({
"count": 3,
"next": "https://host/api/testresources/?page=2",
"previous": None,
"results": [{"id": 1}, {"id": 2}],
})
page2 = driver_util.Bunch.from_dict({
"count": 3,
"next": None,
"previous": None,
"results": [{"id": 3}],
})
self.mock_session.get.return_value = page1
self.mock_session.request.return_value = page2
iterator = self._make_iterator()
iterator.next()
self.assertEqual(
[{"id": 1}, {"id": 2}, {"id": 3}], iterator.all())
def test_has_next_before_initialization(self):
iterator = self._make_iterator()
self.assertTrue(iterator.has_next())
def test_count_before_fetch(self):
iterator = self._make_iterator()
self.assertEqual(-1, iterator.count())
def test_python_iterator_protocol(self):
page1 = driver_util.Bunch.from_dict({
"count": 3,
"next": "https://host/api/testresources/?page=2",
"previous": None,
"results": [{"id": 1}],
})
page2 = driver_util.Bunch.from_dict({
"count": 3,
"next": None,
"previous": None,
"results": [{"id": 2}],
})
self.mock_session.get.return_value = page1
self.mock_session.request.return_value = page2
iterator = self._make_iterator()
pages = list(iterator)
self.assertEqual([[{"id": 1}], [{"id": 2}]], pages)
self.assertRaises(StopIteration, next, iterator)
@@ -0,0 +1,11 @@
---
features:
- |
The VAST Manila driver enhances multitenancy via share types using
namespaced extra specs. Administrators can specify different VIP pools
per share type using the ``vast:vippool_name`` extra spec, enabling
network isolation between different tenants or projects. This allows
multiple share types to use different VIP pools while sharing the same
Manila backend. The configuration parameter ``vast_vippool_name`` in
``manila.conf`` now serves as a default fallback and is no longer
strictly required when VIP pools are specified via share type extra specs.