diff --git a/sunbeam-python/sunbeam/storage/backends/necv/__init__.py b/sunbeam-python/sunbeam/storage/backends/necv/__init__.py new file mode 100644 index 000000000..c82953f5c --- /dev/null +++ b/sunbeam-python/sunbeam/storage/backends/necv/__init__.py @@ -0,0 +1,4 @@ +# SPDX-FileCopyrightText: 2026 - Canonical Ltd +# SPDX-License-Identifier: Apache-2.0 + +"""NEC V backend for Sunbeam storage.""" diff --git a/sunbeam-python/sunbeam/storage/backends/necv/backend.py b/sunbeam-python/sunbeam/storage/backends/necv/backend.py new file mode 100644 index 000000000..a225820b8 --- /dev/null +++ b/sunbeam-python/sunbeam/storage/backends/necv/backend.py @@ -0,0 +1,298 @@ +# SPDX-FileCopyrightText: 2026 - Canonical Ltd +# SPDX-License-Identifier: Apache-2.0 + +"""VStorage backend implementation using base step classes.""" + +import logging +from enum import StrEnum +from typing import Annotated + +from pydantic import Field +from rich.console import Console + +from sunbeam.core.manifest import StorageBackendConfig +from sunbeam.storage.base import StorageBackendBase + +LOG = logging.getLogger(__name__) +console = Console() + + +class Protocol(StrEnum): + """Enumeration of valid protocol types.""" + + FC = "fc" + ISCSI = "iscsi" + + +class NecvConfig(StorageBackendConfig): + """Configuration model for VStorage backend. + + This model includes ALL configuration options for the backend. + Additional configuration can be managed dynamically through the charm. + """ + + # Mandatory connection parameters + san_ip: Annotated[ + str, Field(description="Storage array management IP address or hostname") + ] + + # Optional backend configuration + protocol: Annotated[ + Protocol | None, + Field(description="Protocol selector: fc, iscsi."), + ] = None + nec_v_storage_id: Annotated[ + str | None, Field(description="Product number of the storage system.") + ] = None + nec_v_pools: Annotated[ + str | None, Field(description="Pool number[s] or pool name[s] of the DP pool.") + ] = None + nec_v_snap_pool: Annotated[ + str | None, Field(description="Pool number or pool name of the snapshot pool.") + ] = None + nec_v_ldev_range: Annotated[ + str | None, + Field( + description=( + "Range of the LDEV numbers in the format of " + "'xxxx-yyyy' that can be used by the driver." + ) + ), + ] = None + nec_v_target_ports: Annotated[ + str | None, + Field( + description=( + "IDs of the storage ports used to attach volumes " + "to the controller node." + ) + ), + ] = None + nec_v_compute_target_ports: Annotated[ + str | None, + Field( + description=( + "IDs of the storage ports used to attach volumes to compute nodes." + ) + ), + ] = None + nec_v_group_create: Annotated[ + bool | None, + Field( + description=( + "If True, the driver will create host groups or iSCSI " + "targets on storage ports as needed." + ) + ), + ] = None + nec_v_group_delete: Annotated[ + bool | None, + Field( + description=( + "If True, the driver will delete host groups or iSCSI " + "targets on storage ports as needed." + ) + ), + ] = None + nec_v_copy_speed: Annotated[ + int | None, + Field( + description=( + "Copy speed of storage system. 1 or 2 indicates low speed, " + "3 indicates middle speed, and a value between 4 and 15 " + "indicates high speed." + ) + ), + ] = None + nec_v_copy_check_interval: Annotated[ + int | None, + Field( + description=( + "Interval in seconds to check copying status during a volume copy." + ) + ), + ] = None + nec_v_async_copy_check_interval: Annotated[ + int | None, + Field( + description=( + "Interval in seconds to check asynchronous copying status " + "during copy pair deletion or data restoration." + ) + ), + ] = None + nec_v_manage_drs_volumes: Annotated[ + bool | None, + Field( + description=( + "If true, the driver creates a driver-managed vClone " + "parent for each non-cloned DRS volume." + ) + ), + ] = None + nec_v_rest_disable_io_wait: Annotated[ + bool | None, + Field( + description=( + "It may take time to detach volume after I/O. This option " + "allows detaching volume to complete immediately." + ) + ), + ] = None + nec_v_rest_tcp_keepalive: Annotated[ + bool | None, + Field(description="Enables or disables use of REST API tcp keepalive"), + ] = None + nec_v_discard_zero_page: Annotated[ + bool | None, + Field(description="Enable or disable zero page reclamation in a DP-VOL."), + ] = None + nec_v_lun_timeout: Annotated[ + int | None, + Field(description="Maximum wait time in seconds for adding a LUN to complete."), + ] = None + nec_v_lun_retry_interval: Annotated[ + int | None, + Field(description="Retry interval in seconds for REST API adding a LUN."), + ] = None + nec_v_restore_timeout: Annotated[ + int | None, + Field( + description=( + "Maximum wait time in seconds for the restore operation to complete." + ) + ), + ] = None + nec_v_state_transition_timeout: Annotated[ + int | None, + Field( + description=( + "Maximum wait time in seconds for a volume transition to complete." + ) + ), + ] = None + nec_v_lock_timeout: Annotated[ + int | None, + Field(description="Maximum wait time in seconds for storage to be unlocked."), + ] = None + nec_v_rest_timeout: Annotated[ + int | None, + Field( + description=( + "Maximum wait time in seconds for REST API execution to complete." + ) + ), + ] = None + nec_v_extend_timeout: Annotated[ + int | None, + Field( + description=( + "Maximum wait time in seconds for a volume extension to complete." + ) + ), + ] = None + nec_v_exec_retry_interval: Annotated[ + int | None, + Field(description="Retry interval in seconds for REST API execution."), + ] = None + nec_v_rest_connect_timeout: Annotated[ + int | None, + Field( + description=( + "Maximum wait time in seconds for REST API connection to complete." + ) + ), + ] = None + nec_v_rest_job_api_response_timeout: Annotated[ + int | None, + Field(description="Maximum wait time in seconds for a response from REST API."), + ] = None + nec_v_rest_get_api_response_timeout: Annotated[ + int | None, + Field( + description=( + "Maximum wait time in seconds for a response " + "against GET method of REST API." + ) + ), + ] = None + nec_v_rest_server_busy_timeout: Annotated[ + int | None, + Field(description="Maximum wait time in seconds when REST API returns busy."), + ] = None + nec_v_rest_keep_session_loop_interval: Annotated[ + int | None, + Field(description="Loop interval in seconds for keeping REST API session."), + ] = None + nec_v_rest_another_ldev_mapped_retry_timeout: Annotated[ + int | None, + Field( + description="Retry time in seconds when new LUN allocation request fails." + ), + ] = None + nec_v_rest_tcp_keepidle: Annotated[ + int | None, + Field( + description="Wait time in seconds for sending a first TCP keepalive packet." + ), + ] = None + nec_v_rest_tcp_keepintvl: Annotated[ + int | None, + Field( + description="Interval of transmissions in seconds for TCP keepalive packet." + ), + ] = None + nec_v_rest_tcp_keepcnt: Annotated[ + int | None, + Field(description="Maximum number of transmissions for TCP keepalive packet."), + ] = None + nec_v_host_mode_options: Annotated[ + str | None, Field(description="Host mode option for host group or iSCSI target") + ] = None + nec_v_zoning_request: Annotated[ + bool | None, + Field( + description=( + "If True, the driver will configure FC zoning between " + "the server and storage system when FC zoning manager " + "is enabled." + ) + ), + ] = None + + +class NecvBackend(StorageBackendBase): + """VStorage backend implementation.""" + + backend_type = "necv" + display_name = "VStorage" + generally_available = True + + @property + def charm_name(self) -> str: + """Return the charm application name.""" + return "cinder-volume-necv" + + @property + def charm_channel(self) -> str: + """Return the default charm channel.""" + return "latest/edge" + + @property + def charm_revision(self) -> str | None: + """Return a pinned charm revision, if any.""" + return None + + @property + def charm_base(self) -> str: + """Return the target base for this charm.""" + return "ubuntu@24.04" + + @property + def supports_ha(self) -> bool: + """Whether this backend supports HA deployments.""" + return False + + def config_type(self) -> type[StorageBackendConfig]: + """Return the configuration model type for this backend.""" + return NecvConfig diff --git a/sunbeam-python/tests/unit/sunbeam/storage/backends/conftest.py b/sunbeam-python/tests/unit/sunbeam/storage/backends/conftest.py index 80d3d8ef5..08af69ae0 100644 --- a/sunbeam-python/tests/unit/sunbeam/storage/backends/conftest.py +++ b/sunbeam-python/tests/unit/sunbeam/storage/backends/conftest.py @@ -8,6 +8,7 @@ from sunbeam.storage.backends.dellpowerstore.backend import DellPowerstoreBackend from sunbeam.storage.backends.dellsc.backend import DellSCBackend from sunbeam.storage.backends.hitachi.backend import HitachiBackend +from sunbeam.storage.backends.necv.backend import NecvBackend from sunbeam.storage.backends.purestorage.backend import PureStorageBackend @@ -29,19 +30,26 @@ def dellsc_backend(): return DellSCBackend() +@pytest.fixture +def necv_backend(): + """Provide an NEC V backend instance.""" + return NecvBackend() + + @pytest.fixture def dellpowerstore_backend(): """Provide a Dell PowerStore backend instance.""" return DellPowerstoreBackend() -@pytest.fixture(params=["hitachi", "purestorage", "dellsc", "dellpowerstore"]) +@pytest.fixture(params=["hitachi", "purestorage", "dellsc", "necv", "dellpowerstore"]) def any_backend(request): """Parametrized fixture that provides each backend type.""" backends = { "hitachi": HitachiBackend(), "purestorage": PureStorageBackend(), "dellsc": DellSCBackend(), + "necv": NecvBackend(), "dellpowerstore": DellPowerstoreBackend(), } return backends[request.param] diff --git a/sunbeam-python/tests/unit/sunbeam/storage/backends/test_common.py b/sunbeam-python/tests/unit/sunbeam/storage/backends/test_common.py index def51a0e8..92fdcecc8 100644 --- a/sunbeam-python/tests/unit/sunbeam/storage/backends/test_common.py +++ b/sunbeam-python/tests/unit/sunbeam/storage/backends/test_common.py @@ -157,13 +157,18 @@ def backend(self, any_backend): def test_all_backends_have_unique_types( - hitachi_backend, purestorage_backend, dellsc_backend, dellpowerstore_backend + hitachi_backend, + purestorage_backend, + dellsc_backend, + necv_backend, + dellpowerstore_backend, ): """Test that all backends have unique type identifiers.""" backends = [ hitachi_backend, purestorage_backend, dellsc_backend, + necv_backend, dellpowerstore_backend, ] types = [b.backend_type for b in backends] @@ -173,13 +178,18 @@ def test_all_backends_have_unique_types( def test_all_backends_have_unique_charm_names( - hitachi_backend, purestorage_backend, dellsc_backend, dellpowerstore_backend + hitachi_backend, + purestorage_backend, + dellsc_backend, + necv_backend, + dellpowerstore_backend, ): """Test that all backends have unique charm names.""" backends = [ hitachi_backend, purestorage_backend, dellsc_backend, + necv_backend, dellpowerstore_backend, ] charm_names = [b.charm_name for b in backends] @@ -196,6 +206,7 @@ def test_all_backends_have_unique_charm_names( ("hitachi", "hitachi"), ("purestorage", "purestorage"), ("dellsc", "dellsc"), + ("necv", "necv"), ("dellpowerstore", "dellpowerstore"), ], ) @@ -211,6 +222,7 @@ def test_backend_types_match_expected(any_backend, backend_type, expected_type): ("hitachi", "cinder-volume-hitachi"), ("purestorage", "cinder-volume-purestorage"), ("dellsc", "cinder-volume-dellsc"), + ("necv", "cinder-volume-necv"), ("dellpowerstore", "cinder-volume-dellpowerstore"), ], ) diff --git a/sunbeam-python/tests/unit/sunbeam/storage/backends/test_necv.py b/sunbeam-python/tests/unit/sunbeam/storage/backends/test_necv.py new file mode 100644 index 000000000..32dd92ade --- /dev/null +++ b/sunbeam-python/tests/unit/sunbeam/storage/backends/test_necv.py @@ -0,0 +1,58 @@ +# SPDX-FileCopyrightText: 2026 - Canonical Ltd +# SPDX-License-Identifier: Apache-2.0 + +"""Tests for NEC V backend.""" + +import pytest +from pydantic import ValidationError + +from tests.unit.sunbeam.storage.backends.test_common import BaseBackendTests + + +class TestNecvBackend(BaseBackendTests): + """Tests for NEC V backend.""" + + @pytest.fixture + def backend(self, necv_backend): + """Provide NEC V backend instance.""" + return necv_backend + + def test_backend_type_is_necv(self, backend): + """Test that backend type is 'necv'.""" + assert backend.backend_type == "necv" + + def test_charm_name_is_necv_charm(self, backend): + """Test that charm name is cinder-volume-necv.""" + assert backend.charm_name == "cinder-volume-necv" + + def test_config_has_required_fields(self, backend): + """Test that NEC V config has required fields.""" + fields = backend.config_type().model_fields + for field in ("san_ip", "protocol"): + assert field in fields, f"Required field {field} not found in config" + + +class TestNecvConfigValidation: + """Test NEC V config validation behavior.""" + + def test_protocol_rejects_invalid_value(self, necv_backend): + """Test that protocol rejects values other than fc/iscsi.""" + config_class = necv_backend.config_type() + with pytest.raises(ValidationError): + config_class.model_validate( + { + "san-ip": "192.168.1.1", + "protocol": "nvme", + } + ) + + def test_protocol_accepts_fc(self, necv_backend): + """Test that protocol accepts fc.""" + config_class = necv_backend.config_type() + config = config_class.model_validate( + { + "san-ip": "192.168.1.1", + "protocol": "fc", + } + ) + assert config.protocol == "fc"