diff --git a/sunbeam-python/sunbeam/storage/backends/nexenta/__init__.py b/sunbeam-python/sunbeam/storage/backends/nexenta/__init__.py new file mode 100644 index 000000000..5e310cfbb --- /dev/null +++ b/sunbeam-python/sunbeam/storage/backends/nexenta/__init__.py @@ -0,0 +1,4 @@ +# SPDX-FileCopyrightText: 2026 - Canonical Ltd +# SPDX-License-Identifier: Apache-2.0 + +"""Nexenta backend for Sunbeam storage.""" diff --git a/sunbeam-python/sunbeam/storage/backends/nexenta/backend.py b/sunbeam-python/sunbeam/storage/backends/nexenta/backend.py new file mode 100644 index 000000000..0fd268517 --- /dev/null +++ b/sunbeam-python/sunbeam/storage/backends/nexenta/backend.py @@ -0,0 +1,332 @@ +# SPDX-FileCopyrightText: 2026 - Canonical Ltd +# SPDX-License-Identifier: Apache-2.0 + +"""Nexenta iSCSI backend implementation using base step classes.""" + +import logging +from enum import StrEnum +from typing import Annotated, Literal + +from pydantic import Field +from rich.console import Console + +from sunbeam.core.manifest import StorageBackendConfig +from sunbeam.storage.base import StorageBackendBase +from sunbeam.storage.models import SecretDictField + +LOG = logging.getLogger(__name__) +console = Console() + + +class RestProtocol(StrEnum): + """Enumeration of valid REST protocol types.""" + + HTTP = "http" + HTTPS = "https" + AUTO = "auto" + + +class DatasetCompression(StrEnum): + """Enumeration of valid dataset compression types.""" + + ON = "on" + OFF = "off" + GZIP = "gzip" + GZIP_1 = "gzip-1" + GZIP_2 = "gzip-2" + GZIP_3 = "gzip-3" + GZIP_4 = "gzip-4" + GZIP_5 = "gzip-5" + GZIP_6 = "gzip-6" + GZIP_7 = "gzip-7" + GZIP_8 = "gzip-8" + GZIP_9 = "gzip-9" + LZJB = "lzjb" + ZLE = "zle" + LZ4 = "lz4" + + +class DatasetDedup(StrEnum): + """Enumeration of valid dataset deduplication types.""" + + ON = "on" + OFF = "off" + SHA256 = "sha256" + VERIFY = "verify" + SHA256_VERIFY = "sha256, verify" + + +class NexentaConfig(StorageBackendConfig): + """Configuration model for Nexenta iSCSI backend. + + This model includes ALL configuration options for the backend. + Additional configuration can be managed dynamically through the charm. + """ + + san_ip: Annotated[ + str, Field(description="Storage array management IP address or hostname") + ] + protocol: Annotated[ + Literal["iscsi"] | None, + Field(description="Protocol selector: iscsi."), + ] = None + nexenta_rest_password: Annotated[ + str, + Field(description="Password to connect to NexentaEdge"), + SecretDictField(field="nexenta-rest-password"), + ] + nexenta_rest_protocol: Annotated[ + RestProtocol | None, + Field( + description="Use http or https for NexentaStor management REST API connection" # noqa: E501 + ), + ] = None + nexenta_nbd_symlinks_dir: Annotated[ + str | None, + Field( + description="NexentaEdge logical path of directory to store symbolic links to NBDs" # noqa: E501 + ), + ] = None + nexenta_rest_user: Annotated[ + str | None, + Field(description="User name to connect to NexentaEdge"), + ] = None + nexenta_lun_container: Annotated[ + str | None, + Field(description="NexentaEdge logical path of bucket for LUNs"), + ] = None + nexenta_iscsi_service: Annotated[ + str | None, + Field(description="NexentaEdge iSCSI service name"), + ] = None + nexenta_iops_limit: Annotated[ + int | None, + Field(description="NexentaEdge iSCSI LUN object IOPS limit"), + ] = None + nexenta_chunksize: Annotated[ + int | None, + Field(description="NexentaEdge iSCSI LUN object chunk size"), + ] = None + nexenta_replication_count: Annotated[ + int | None, + Field(description="NexentaEdge iSCSI LUN object replication count"), + ] = None + nexenta_host: Annotated[ + str | None, + Field(description="IP address of NexentaStor Appliance"), + ] = None + nexenta_rest_connect_timeout: Annotated[ + str | None, + Field( + description=( + "Specifies the time limit (in seconds), within which the " + "connection to NexentaStor management REST API server must be " + "established" + ) + ), + ] = "30" + nexenta_rest_read_timeout: Annotated[ + str | None, + Field( + description=( + "Specifies the time limit (in seconds), within which " + "NexentaStor management REST API server must send a response" + ) + ), + ] = "300" + nexenta_rest_backoff_factor: Annotated[ + str | None, + Field( + description=( + "Specifies the backoff factor to apply between connection " + "attempts to NexentaStor management REST API server" + ) + ), + ] = "0.5" + nexenta_rest_retry_count: Annotated[ + int | None, + Field( + description=( + "Specifies the number of times to repeat NexentaStor " + "management REST API call in case of connection errors and " + "NexentaStor appliance EBUSY or ENOENT errors" + ) + ), + ] = 3 + nexenta_use_https: Annotated[ + bool | None, + Field( + description=( + "Use HTTP secure protocol for NexentaStor management REST API " + "connections" + ) + ), + ] = True + nexenta_lu_writebackcache_disabled: Annotated[ + bool | None, + Field(description="Postponed write to backing store or not"), + ] = False + nexenta_iscsi_target_portal_groups: Annotated[ + str | None, + Field(description="NexentaStor target portal groups"), + ] = None + nexenta_iscsi_target_portals: Annotated[ + str | None, + Field( + description=( + "Comma separated list of portals for NexentaStor5, in format " + "of IP1:port1,IP2:port2. Port is optional, default=3260. " + "Example: 10.10.10.1:3267,10.10.1.2" + ) + ), + ] = None + nexenta_iscsi_target_host_group: Annotated[ + str | None, + Field(description="Group of hosts which are allowed to access volumes"), + ] = "all" + nexenta_iscsi_target_portal_port: Annotated[ + int | None, + Field(description="Nexenta appliance iSCSI target portal port"), + ] = 3260 + nexenta_luns_per_target: Annotated[ + int | None, + Field(description="Amount of LUNs per iSCSI target"), + ] = 100 + nexenta_volume: Annotated[ + str | None, + Field(description="NexentaStor pool name that holds all volumes"), + ] = "cinder" + nexenta_target_prefix: Annotated[ + str | None, + Field(description="iqn prefix for NexentaStor iSCSI targets"), + ] = "iqn.1986-03.com.sun:02:cinder" + nexenta_target_group_prefix: Annotated[ + str | None, + Field(description="Prefix for iSCSI target groups on NexentaStor"), + ] = "cinder" + nexenta_host_group_prefix: Annotated[ + str | None, + Field(description="Prefix for iSCSI host groups on NexentaStor"), + ] = "cinder" + nexenta_volume_group: Annotated[ + str | None, + Field(description="Volume group for NexentaStor5 iSCSI"), + ] = "iscsi" + nexenta_shares_config: Annotated[ + str | None, + Field(description="File with the list of available nfs shares"), + ] = "/etc/cinder/nfs_shares" + nexenta_mount_point_base: Annotated[ + str | None, + Field(description="Base directory that contains NFS share mount points"), + ] = "$state_path/mnt" + nexenta_sparsed_volumes: Annotated[ + bool | None, + Field( + description=( + "Enables or disables the creation of volumes as sparsed files " + "that take no space. If disabled (False), volume is created as " + "a regular file, which takes a long time." + ) + ), + ] = True + nexenta_qcow2_volumes: Annotated[ + bool | None, + Field(description="Create volumes as QCOW2 files rather than raw files"), + ] = False + nexenta_nms_cache_volroot: Annotated[ + bool | None, + Field( + description="If set True cache NexentaStor appliance volroot option value." + ), + ] = True + nexenta_dataset_compression: Annotated[ + DatasetCompression | None, + Field(description="Compression value for new ZFS folders."), + ] = None + nexenta_dataset_dedup: Annotated[ + DatasetDedup | None, + Field(description="Deduplication value for new ZFS folders."), + ] = None + nexenta_folder: Annotated[ + str | None, + Field(description="A folder where cinder created datasets will reside."), + ] = None + nexenta_dataset_description: Annotated[ + str | None, + Field(description="Human-readable description for the folder."), + ] = None + nexenta_blocksize: Annotated[ + int | None, + Field(description="Block size for datasets"), + ] = 4096 + nexenta_ns5_blocksize: Annotated[ + int | None, + Field(description="Block size for datasets"), + ] = 32 + nexenta_sparse: Annotated[ + bool | None, + Field(description="Enables or disables the creation of sparse datasets"), + ] = False + nexenta_origin_snapshot_template: Annotated[ + str | None, + Field(description="Template string to generate origin name of clone"), + ] = "origin-snapshot-%s" + nexenta_group_snapshot_template: Annotated[ + str | None, + Field(description="Template string to generate group snapshot name"), + ] = "group-snapshot-%s" + nexenta_rrmgr_compression: Annotated[ + int | None, + Field( + description=( + "Enable stream compression, level 1..9. 1 - gives best speed; " + "9 - gives best compression." + ) + ), + ] = 0 + nexenta_rrmgr_tcp_buf_size: Annotated[ + int | None, + Field(description="TCP Buffer size in KiloBytes."), + ] = 4096 + nexenta_rrmgr_connections: Annotated[ + int | None, + Field(description="Number of TCP connections."), + ] = 2 + + +class NexentaBackend(StorageBackendBase): + """Nexenta iSCSI backend implementation.""" + + backend_type = "nexenta" + display_name = "Nexenta iSCSI" + generally_available = True + + @property + def charm_name(self) -> str: + """Return the charm application name.""" + return "cinder-volume-nexenta" + + @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 NexentaConfig diff --git a/sunbeam-python/tests/unit/sunbeam/storage/backends/conftest.py b/sunbeam-python/tests/unit/sunbeam/storage/backends/conftest.py index 80d3d8ef5..2d4d90e0b 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.nexenta.backend import NexentaBackend from sunbeam.storage.backends.purestorage.backend import PureStorageBackend @@ -29,19 +30,28 @@ def dellsc_backend(): return DellSCBackend() +@pytest.fixture +def nexenta_backend(): + """Provide a Nexenta backend instance.""" + return NexentaBackend() + + @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", "nexenta", "dellpowerstore"] +) def any_backend(request): """Parametrized fixture that provides each backend type.""" backends = { "hitachi": HitachiBackend(), "purestorage": PureStorageBackend(), "dellsc": DellSCBackend(), + "nexenta": NexentaBackend(), "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..88068b526 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, + nexenta_backend, + dellpowerstore_backend, ): """Test that all backends have unique type identifiers.""" backends = [ hitachi_backend, purestorage_backend, dellsc_backend, + nexenta_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, + nexenta_backend, + dellpowerstore_backend, ): """Test that all backends have unique charm names.""" backends = [ hitachi_backend, purestorage_backend, dellsc_backend, + nexenta_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"), + ("nexenta", "nexenta"), ("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"), + ("nexenta", "cinder-volume-nexenta"), ("dellpowerstore", "cinder-volume-dellpowerstore"), ], ) diff --git a/sunbeam-python/tests/unit/sunbeam/storage/backends/test_nexenta.py b/sunbeam-python/tests/unit/sunbeam/storage/backends/test_nexenta.py new file mode 100644 index 000000000..e4e1a1a9c --- /dev/null +++ b/sunbeam-python/tests/unit/sunbeam/storage/backends/test_nexenta.py @@ -0,0 +1,70 @@ +# SPDX-FileCopyrightText: 2026 - Canonical Ltd +# SPDX-License-Identifier: Apache-2.0 + +"""Tests for Nexenta backend.""" + +import pytest +from pydantic import ValidationError + +from sunbeam.storage.models import SecretDictField +from tests.unit.sunbeam.storage.backends.test_common import BaseBackendTests + + +class TestNexentaBackend(BaseBackendTests): + """Tests for Nexenta backend.""" + + @pytest.fixture + def backend(self, nexenta_backend): + """Provide Nexenta backend instance.""" + return nexenta_backend + + def test_backend_type_is_nexenta(self, backend): + """Test that backend type is 'nexenta'.""" + assert backend.backend_type == "nexenta" + + def test_charm_name_is_nexenta_charm(self, backend): + """Test that charm name is cinder-volume-nexenta.""" + assert backend.charm_name == "cinder-volume-nexenta" + + def test_config_has_required_fields(self, backend): + """Test that Nexenta config has required fields.""" + fields = backend.config_type().model_fields + for field in ("san_ip", "protocol", "nexenta_rest_password"): + assert field in fields, f"Required field {field} not found in config" + + def test_password_is_marked_secret(self, backend): + """Test that Nexenta REST password is marked as secret.""" + config_class = backend.config_type() + field = config_class.model_fields.get("nexenta_rest_password") + assert field is not None + assert any(isinstance(m, SecretDictField) for m in field.metadata), ( + "nexenta_rest_password should be marked as secret" + ) + + +class TestNexentaConfigValidation: + """Test Nexenta config validation behavior.""" + + def test_protocol_rejects_invalid_value(self, nexenta_backend): + """Test that protocol rejects values other than iscsi.""" + config_class = nexenta_backend.config_type() + with pytest.raises(ValidationError): + config_class.model_validate( + { + "san-ip": "192.168.1.1", + "nexenta-rest-password": "secret", + "protocol": "fc", + } + ) + + def test_protocol_accepts_iscsi(self, nexenta_backend): + """Test that protocol accepts iscsi.""" + config_class = nexenta_backend.config_type() + config = config_class.model_validate( + { + "san-ip": "192.168.1.1", + "nexenta-rest-password": "secret", + "protocol": "iscsi", + } + ) + assert config.protocol == "iscsi"