Merge "VAST driver: Add VIP pool extra specs and list pagination"
This commit is contained in:
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:
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -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.
|
||||
Reference in new issue
Block a user