diff --git a/sunbeam-python/sunbeam/storage/backends/dellunity/__init__.py b/sunbeam-python/sunbeam/storage/backends/dellunity/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/sunbeam-python/sunbeam/storage/backends/dellunity/backend.py b/sunbeam-python/sunbeam/storage/backends/dellunity/backend.py new file mode 100644 index 000000000..c6001998a --- /dev/null +++ b/sunbeam-python/sunbeam/storage/backends/dellunity/backend.py @@ -0,0 +1,113 @@ +# SPDX-FileCopyrightText: 2025 - Canonical Ltd +# SPDX-License-Identifier: Apache-2.0 + +"""Dell Unity 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 +from sunbeam.storage.models import SecretDictField + +LOG = logging.getLogger(__name__) +console = Console() + + +class Protocol(StrEnum): + """Enumeration of valid protocol types.""" + + ISCSI = "iscsi" + FC = "fc" + + +class DellunityConfig(StorageBackendConfig): + """Configuration model for Dell Unity 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="IP address of SAN controller")] + san_login: Annotated[ + str, + Field(description="Username for SAN controller"), + SecretDictField(field="san-login"), + ] + san_password: Annotated[ + str, + Field(description="Password for SAN controller"), + SecretDictField(field="san-password"), + ] + + # Optional backend configuration + protocol: Annotated[ + Protocol | None, + Field(description="Protocol selector: iscsi, fc."), + ] = None + unity_storage_pool_names: Annotated[ + str | None, + Field(description="A comma-separated list of storage pool names to be used."), + ] = None + unity_io_ports: Annotated[ + str | None, + Field(description="A comma-separated list of iSCSI or FC ports to be used."), + ] = None + remove_empty_host: Annotated[ + bool | None, + Field( + description=( + "To remove the host from Unity when the last LUN is detached from it." + ) + ), + ] = None + san_thin_provision: Annotated[ + bool | None, + Field(description="Use thin provisioning for SAN volumes?"), + ] = None + use_multipath_for_image_xfer: Annotated[ + bool | None, + Field(description="Enable multipathing for image transfer operations."), + ] = None + + +class DellunityBackend(StorageBackendBase): + """Dell Unity backend implementation.""" + + backend_type = "dellunity" + display_name = "Dell Unity" + generally_available = False + + @property + def charm_name(self) -> str: + """Return the charm name for Dell Unity.""" + return "cinder-volume-dellunity" + + @property + def charm_channel(self) -> str: + """Return the default charm channel.""" + return "latest/edge" + + @property + def charm_revision(self) -> str | None: + """Return the pinned charm revision, if any.""" + return None + + @property + def charm_base(self) -> str: + """Return the charm base OS.""" + return "ubuntu@24.04" + + @property + def supports_ha(self) -> bool: + """Return whether this backend supports high availability.""" + return False + + def config_type(self) -> type[StorageBackendConfig]: + """Return the configuration model class for Dell Unity.""" + return DellunityConfig diff --git a/sunbeam-python/sunbeam/storage/backends/huawei/__init__.py b/sunbeam-python/sunbeam/storage/backends/huawei/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/sunbeam-python/sunbeam/storage/backends/huawei/backend.py b/sunbeam-python/sunbeam/storage/backends/huawei/backend.py new file mode 100644 index 000000000..59cb927c7 --- /dev/null +++ b/sunbeam-python/sunbeam/storage/backends/huawei/backend.py @@ -0,0 +1,135 @@ +# SPDX-FileCopyrightText: 2025 - Canonical Ltd +# SPDX-License-Identifier: Apache-2.0 + +"""Huawei OceanStor Dorado 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 +from sunbeam.storage.models import SecretDictField + +LOG = logging.getLogger(__name__) +console = Console() + + +class Protocol(StrEnum): + """Enumeration of valid protocol types.""" + + ISCSI = "iscsi" + FC = "fc" + + +class HuaweiConfig(StorageBackendConfig): + """Configuration model for Huawei OceanStor Dorado 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="IP address or hostname of the Huawei OceanStor storage array" + ), + ] + san_login: Annotated[ + str, + Field(description="Username for Huawei storage array REST API"), + SecretDictField(field="san-login"), + ] + san_password: Annotated[ + str, + Field(description="Password for Huawei storage array REST API"), + SecretDictField(field="san-password"), + ] + + # Optional backend configuration + protocol: Annotated[ + Protocol | None, + Field(description="Protocol selector: iscsi, fc."), + ] = None + + cinder_huawei_conf_file: Annotated[ + str | None, + Field(description="The configuration file for the Cinder Huawei driver."), + ] = None + + hypermetro_devices: Annotated[ + str | None, + Field(description="The remote device hypermetro will use."), + ] = None + + metro_san_user: Annotated[ + str | None, + Field(description="The remote metro device san user."), + ] = None + + metro_san_password: Annotated[ + str | None, + Field( + description=( + "The remote metro device san password" + " (only needed for HyperMetro replication)." + ) + ), + SecretDictField(field="metro-san-password"), + ] = None + + metro_domain_name: Annotated[ + str | None, + Field(description="The remote metro device domain name."), + ] = None + + metro_san_address: Annotated[ + str | None, + Field(description="The remote metro device request url."), + ] = None + + metro_storage_pools: Annotated[ + str | None, + Field(description="The remote metro device pool names."), + ] = None + + +class HuaweiBackend(StorageBackendBase): + """Huawei OceanStor Dorado backend implementation.""" + + backend_type = "huawei" + display_name = "Huawei OceanStor Dorado" + generally_available = False + + @property + def charm_name(self) -> str: + """Return the charm name for Huawei OceanStor Dorado.""" + return "cinder-volume-huawei" + + @property + def charm_channel(self) -> str: + """Return the default charm channel.""" + return "latest/edge" + + @property + def charm_revision(self) -> str | None: + """Return the pinned charm revision, if any.""" + return None + + @property + def charm_base(self) -> str: + """Return the charm base OS.""" + return "ubuntu@24.04" + + @property + def supports_ha(self) -> bool: + """Return whether this backend supports high availability.""" + return False + + def config_type(self) -> type[StorageBackendConfig]: + """Return the configuration model class for Huawei OceanStor Dorado.""" + return HuaweiConfig diff --git a/sunbeam-python/tests/unit/sunbeam/storage/backends/conftest.py b/sunbeam-python/tests/unit/sunbeam/storage/backends/conftest.py index 6c24b939f..015ad374d 100644 --- a/sunbeam-python/tests/unit/sunbeam/storage/backends/conftest.py +++ b/sunbeam-python/tests/unit/sunbeam/storage/backends/conftest.py @@ -11,10 +11,12 @@ from sunbeam.storage.backends.dellpowerstore.backend import DellPowerstoreBackend from sunbeam.storage.backends.dellpowervault.backend import DellPowerVaultBackend from sunbeam.storage.backends.dellsc.backend import DellSCBackend +from sunbeam.storage.backends.dellunity.backend import DellunityBackend from sunbeam.storage.backends.fujitsueternusdx.backend import FujitsueternusdxBackend from sunbeam.storage.backends.hitachi.backend import HitachiBackend from sunbeam.storage.backends.hpe3par.backend import HPEthreeparBackend from sunbeam.storage.backends.hpexp.backend import HpexpBackend +from sunbeam.storage.backends.huawei.backend import HuaweiBackend from sunbeam.storage.backends.ibmflashsystemcommon.backend import ( IbmflashsystemcommonBackend, ) @@ -66,6 +68,18 @@ def dellsc_backend(): return DellSCBackend() +@pytest.fixture +def dellunity_backend(): + """Provide a Dell Unity backend instance.""" + return DellunityBackend() + + +@pytest.fixture +def huawei_backend(): + """Provide a Huawei OceanStor Dorado backend instance.""" + return HuaweiBackend() + + @pytest.fixture def datacore_backend(): """Provide a DataCore backend instance.""" @@ -309,6 +323,8 @@ def hpe3par_backend(): "solidfire": SolidFireBackend, "hpe3par": HPEthreeparBackend, "infinidat": InfinidatBackend, + "dellunity": DellunityBackend, + "huawei": HuaweiBackend, } diff --git a/sunbeam-python/tests/unit/sunbeam/storage/backends/test_dellunity.py b/sunbeam-python/tests/unit/sunbeam/storage/backends/test_dellunity.py new file mode 100644 index 000000000..71a4e0956 --- /dev/null +++ b/sunbeam-python/tests/unit/sunbeam/storage/backends/test_dellunity.py @@ -0,0 +1,193 @@ +# SPDX-FileCopyrightText: 2025 - Canonical Ltd +# SPDX-License-Identifier: Apache-2.0 + +"""Tests for Dell Unity backend.""" + +import pytest + +from tests.unit.sunbeam.storage.backends.test_common import BaseBackendTests + + +class TestDellunityBackend(BaseBackendTests): + """Tests for Dell Unity backend. + + Inherits all generic tests from BaseBackendTests and adds + backend-specific tests. + """ + + @pytest.fixture + def backend(self, dellunity_backend): + """Provide Dell Unity backend instance.""" + return dellunity_backend + + # Backend-specific tests + + def test_backend_type_is_dellunity(self, backend): + """Test that backend type is 'dellunity'.""" + assert backend.backend_type == "dellunity" + + def test_display_name_mentions_dell(self, backend): + """Test that display name mentions Dell.""" + assert "dell" in backend.display_name.lower() + + def test_display_name_mentions_unity(self, backend): + """Test that display name mentions Unity.""" + assert "unity" in backend.display_name.lower() + + def test_charm_name_is_dellunity_charm(self, backend): + """Test that charm name is cinder-volume-dellunity.""" + assert backend.charm_name == "cinder-volume-dellunity" + + def test_dellunity_config_has_required_fields(self, backend): + """Test that Dell Unity config has all required fields.""" + config_class = backend.config_type() + fields = config_class.model_fields + + required_fields = [ + "san_ip", + "san_login", + "san_password", + ] + for field in required_fields: + assert field in fields, f"Required field {field} not found in config" + + def test_dellunity_san_credentials_are_secret(self, backend): + """Test that SAN credentials are properly marked as secrets.""" + from sunbeam.storage.models import SecretDictField + + config_class = backend.config_type() + + username_field = config_class.model_fields.get("san_login") + assert username_field is not None + has_secret_marker = any( + isinstance(m, SecretDictField) for m in username_field.metadata + ) + assert has_secret_marker, "san_login should be marked as secret" + + password_field = config_class.model_fields.get("san_password") + assert password_field is not None + has_secret_marker = any( + isinstance(m, SecretDictField) for m in password_field.metadata + ) + assert has_secret_marker, "san_password should be marked as secret" + + def test_dellunity_san_ip_is_required(self, backend): + """Test that san_ip is a required field.""" + config_class = backend.config_type() + ip_field = config_class.model_fields.get("san_ip") + assert ip_field is not None + assert ip_field.is_required(), "san_ip should be a required field" + + def test_dellunity_protocol_is_optional(self, backend): + """Test that protocol field is optional and accepts iscsi or fc.""" + config_class = backend.config_type() + + # Config without protocol should succeed + config = config_class.model_validate( + { + "san-ip": "192.168.1.1", + "san-login": "admin", + "san-password": "secret", + } + ) + assert config.protocol is None + + # Test valid config with iscsi + config_iscsi = config_class.model_validate( + { + "san-ip": "192.168.1.1", + "san-login": "admin", + "san-password": "secret", + "protocol": "iscsi", + } + ) + assert config_iscsi.protocol == "iscsi" + + # Test valid config with fc + config_fc = config_class.model_validate( + { + "san-ip": "192.168.1.1", + "san-login": "admin", + "san-password": "secret", + "protocol": "fc", + } + ) + assert config_fc.protocol == "fc" + + def test_dellunity_optional_fields_default_to_none(self, backend): + """Test that optional fields default to None when omitted.""" + config_class = backend.config_type() + + config = config_class.model_validate( + { + "san-ip": "192.168.1.1", + "san-login": "admin", + "san-password": "secret", + } + ) + + assert config.protocol is None + assert config.unity_storage_pool_names is None + assert config.unity_io_ports is None + assert config.remove_empty_host is None + assert config.san_thin_provision is None + assert config.use_multipath_for_image_xfer is None + assert config.volume_backend_name is None + assert config.backend_availability_zone is None + + def test_dellunity_unity_specific_fields_exist(self, backend): + """Test that Dell Unity-specific optional fields are present.""" + config_class = backend.config_type() + fields = config_class.model_fields + + unity_fields = [ + "unity_storage_pool_names", + "unity_io_ports", + "remove_empty_host", + "san_thin_provision", + "use_multipath_for_image_xfer", + ] + for field in unity_fields: + assert field in fields, f"Dell Unity field {field} not found" + + def test_dellunity_ha_not_supported(self, backend): + """Test that Dell Unity backend does not support HA.""" + assert backend.supports_ha is False + + def test_dellunity_charm_base_is_ubuntu(self, backend): + """Test that charm base is ubuntu@24.04.""" + assert backend.charm_base == "ubuntu@24.04" + + +class TestDellunityConfigValidation: + """Test Dell Unity config validation behaviour.""" + + def test_protocol_rejects_invalid_values(self, dellunity_backend): + """Test that protocol field rejects values other than iscsi/fc.""" + from pydantic import ValidationError + + config_class = dellunity_backend.config_type() + + with pytest.raises(ValidationError): + config_class.model_validate( + { + "san-ip": "192.168.1.1", + "san-login": "admin", + "san-password": "secret", + "protocol": "nfs", + } + ) + + def test_missing_required_san_ip_raises(self, dellunity_backend): + """Test that omitting san_ip raises a validation error.""" + from pydantic import ValidationError + + config_class = dellunity_backend.config_type() + + with pytest.raises(ValidationError): + config_class.model_validate( + { + "san-login": "admin", + "san-password": "secret", + } + ) diff --git a/sunbeam-python/tests/unit/sunbeam/storage/backends/test_huawei.py b/sunbeam-python/tests/unit/sunbeam/storage/backends/test_huawei.py new file mode 100644 index 000000000..d679cfe9c --- /dev/null +++ b/sunbeam-python/tests/unit/sunbeam/storage/backends/test_huawei.py @@ -0,0 +1,230 @@ +# SPDX-FileCopyrightText: 2025 - Canonical Ltd +# SPDX-License-Identifier: Apache-2.0 + +"""Tests for Huawei OceanStor Dorado backend.""" + +import pytest + +from tests.unit.sunbeam.storage.backends.test_common import BaseBackendTests + + +class TestHuaweiBackend(BaseBackendTests): + """Tests for Huawei OceanStor Dorado backend. + + Inherits all generic tests from BaseBackendTests and adds + backend-specific tests. + """ + + @pytest.fixture + def backend(self, huawei_backend): + """Provide Huawei OceanStor Dorado backend instance.""" + return huawei_backend + + # Backend-specific tests + + def test_backend_type_is_huawei(self, backend): + """Test that backend type is 'huawei'.""" + assert backend.backend_type == "huawei" + + def test_display_name_mentions_huawei(self, backend): + """Test that display name mentions Huawei.""" + assert "huawei" in backend.display_name.lower() + + def test_charm_name_is_huawei_charm(self, backend): + """Test that charm name is cinder-volume-huawei.""" + assert backend.charm_name == "cinder-volume-huawei" + + def test_huawei_config_has_required_fields(self, backend): + """Test that Huawei config has all required fields.""" + config_class = backend.config_type() + fields = config_class.model_fields + + required_fields = [ + "san_ip", + "san_login", + "san_password", + ] + for field in required_fields: + assert field in fields, f"Required field {field} not found in config" + + def test_huawei_san_credentials_are_secret(self, backend): + """Test that SAN credentials are properly marked as secrets.""" + from sunbeam.storage.models import SecretDictField + + config_class = backend.config_type() + + username_field = config_class.model_fields.get("san_login") + assert username_field is not None + has_secret_marker = any( + isinstance(m, SecretDictField) for m in username_field.metadata + ) + assert has_secret_marker, "san_login should be marked as secret" + + password_field = config_class.model_fields.get("san_password") + assert password_field is not None + has_secret_marker = any( + isinstance(m, SecretDictField) for m in password_field.metadata + ) + assert has_secret_marker, "san_password should be marked as secret" + + def test_huawei_metro_san_password_is_secret(self, backend): + """Test that metro_san_password is properly marked as a secret.""" + from sunbeam.storage.models import SecretDictField + + config_class = backend.config_type() + + metro_pass_field = config_class.model_fields.get("metro_san_password") + assert metro_pass_field is not None + has_secret_marker = any( + isinstance(m, SecretDictField) for m in metro_pass_field.metadata + ) + assert has_secret_marker, "metro_san_password should be marked as secret" + + def test_huawei_san_ip_is_required(self, backend): + """Test that san_ip is a required field.""" + config_class = backend.config_type() + ip_field = config_class.model_fields.get("san_ip") + assert ip_field is not None + assert ip_field.is_required(), "san_ip should be a required field" + + def test_huawei_protocol_is_optional(self, backend): + """Test that protocol field is optional and accepts iscsi or fc.""" + config_class = backend.config_type() + + # Config without protocol should succeed + config = config_class.model_validate( + { + "san-ip": "192.168.1.1", + "san-login": "admin", + "san-password": "secret", + } + ) + assert config.protocol is None + + # Test valid config with iscsi + config_iscsi = config_class.model_validate( + { + "san-ip": "192.168.1.1", + "san-login": "admin", + "san-password": "secret", + "protocol": "iscsi", + } + ) + assert config_iscsi.protocol == "iscsi" + + # Test valid config with fc + config_fc = config_class.model_validate( + { + "san-ip": "192.168.1.1", + "san-login": "admin", + "san-password": "secret", + "protocol": "fc", + } + ) + assert config_fc.protocol == "fc" + + def test_huawei_optional_fields_default_to_none(self, backend): + """Test that optional fields default to None when omitted.""" + config_class = backend.config_type() + + config = config_class.model_validate( + { + "san-ip": "192.168.1.1", + "san-login": "admin", + "san-password": "secret", + } + ) + + assert config.protocol is None + assert config.cinder_huawei_conf_file is None + assert config.hypermetro_devices is None + assert config.metro_san_user is None + assert config.metro_san_password is None + assert config.metro_domain_name is None + assert config.metro_san_address is None + assert config.metro_storage_pools is None + assert config.volume_backend_name is None + assert config.backend_availability_zone is None + + def test_huawei_hypermetro_fields_exist(self, backend): + """Test that HyperMetro replication fields are present in config.""" + config_class = backend.config_type() + fields = config_class.model_fields + + hypermetro_fields = [ + "hypermetro_devices", + "metro_san_user", + "metro_san_password", + "metro_domain_name", + "metro_san_address", + "metro_storage_pools", + ] + for field in hypermetro_fields: + assert field in fields, f"HyperMetro field {field} not found" + + def test_huawei_ha_not_supported(self, backend): + """Test that Huawei backend does not support HA.""" + assert backend.supports_ha is False + + def test_huawei_charm_base_is_ubuntu(self, backend): + """Test that charm base is ubuntu@24.04.""" + assert backend.charm_base == "ubuntu@24.04" + + +class TestHuaweiConfigValidation: + """Test Huawei OceanStor Dorado config validation behaviour.""" + + def test_protocol_rejects_invalid_values(self, huawei_backend): + """Test that protocol field rejects values other than iscsi/fc.""" + from pydantic import ValidationError + + config_class = huawei_backend.config_type() + + with pytest.raises(ValidationError): + config_class.model_validate( + { + "san-ip": "192.168.1.1", + "san-login": "admin", + "san-password": "secret", + "protocol": "nfs", + } + ) + + def test_missing_required_san_ip_raises(self, huawei_backend): + """Test that omitting san_ip raises a validation error.""" + from pydantic import ValidationError + + config_class = huawei_backend.config_type() + + with pytest.raises(ValidationError): + config_class.model_validate( + { + "san-login": "admin", + "san-password": "secret", + } + ) + + def test_hypermetro_config_fields_accepted(self, huawei_backend): + """Test that HyperMetro fields are accepted in config.""" + config_class = huawei_backend.config_type() + + config = config_class.model_validate( + { + "san-ip": "192.168.1.1", + "san-login": "admin", + "san-password": "secret", + "hypermetro-devices": "device1", + "metro-san-user": "metro_admin", + "metro-san-password": "metro_secret", + "metro-domain-name": "HyperMetroDomain", + "metro-san-address": "https://192.168.2.1:8080", + "metro-storage-pools": "pool1,pool2", + } + ) + + assert config.hypermetro_devices == "device1" + assert config.metro_san_user == "metro_admin" + assert config.metro_san_password == "metro_secret" + assert config.metro_domain_name == "HyperMetroDomain" + assert config.metro_san_address == "https://192.168.2.1:8080" + assert config.metro_storage_pools == "pool1,pool2"