diff --git a/sunbeam-python/sunbeam/storage/backends/ibmibmstorage/__init__.py b/sunbeam-python/sunbeam/storage/backends/ibmibmstorage/__init__.py new file mode 100644 index 000000000..f451cbcb4 --- /dev/null +++ b/sunbeam-python/sunbeam/storage/backends/ibmibmstorage/__init__.py @@ -0,0 +1,4 @@ +# SPDX-FileCopyrightText: 2026 - Canonical Ltd +# SPDX-License-Identifier: Apache-2.0 + +"""IBMStorage backend for Sunbeam storage.""" diff --git a/sunbeam-python/sunbeam/storage/backends/ibmibmstorage/backend.py b/sunbeam-python/sunbeam/storage/backends/ibmibmstorage/backend.py new file mode 100644 index 000000000..032f496c5 --- /dev/null +++ b/sunbeam-python/sunbeam/storage/backends/ibmibmstorage/backend.py @@ -0,0 +1,161 @@ +# SPDX-FileCopyrightText: 2026 - Canonical Ltd +# SPDX-License-Identifier: Apache-2.0 + +"""IBMStorage backend implementation using base step classes.""" + +import logging +from enum import StrEnum +from typing import Annotated, Literal + +from pydantic import Field, model_validator +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 ConnectionType(StrEnum): + """Enumeration of valid connection types.""" + + FIBRE_CHANNEL = "fibre_channel" + ISCSI = "iscsi" + + +class Chap(StrEnum): + """Enumeration of CHAP authentication modes.""" + + DISABLED = "disabled" + ENABLED = "enabled" + + +class IbmibmstorageConfig(StorageBackendConfig): + """Configuration model for IBMStorage 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[ + Literal["fc", "iscsi"] | None, + Field(description="Protocol selector: fc, iscsi."), + ] = None + ds8k_devadd_unitadd_mapping: Annotated[ + str | None, + Field(description="Mapping between IODevice address and unit address."), + ] = None + ds8k_ssid_prefix: Annotated[ + str | None, + Field(description="Set the first two digits of SSID."), + ] = "FF" + lss_range_for_cg: Annotated[ + str | None, + Field(description="Reserve LSSs for consistency group."), + ] = None + ds8k_host_type: Annotated[ + str | None, + Field( + description=( + 'DS8K host type identifier. Use "auto" for automatic host ' + "type selection, or provide a value supported by the array." + ) + ), + ] = "auto" + proxy: Annotated[ + str | None, + Field(description="Proxy driver that connects to the IBM Storage Array"), + ] = "cinder.volume.drivers.ibm.ibm_storage.proxy.IBMStorageProxy" + connection_type: Annotated[ + ConnectionType | None, + Field(description="Connection type to the IBM Storage Array"), + ] = None + chap: Annotated[ + Chap | None, + Field( + description="CHAP authentication mode, effective only for iscsi (disabled|enabled)" # noqa: E501 + ), + ] = None + management_ips: Annotated[ + str | None, + Field(description="List of Management IP addresses (separated by commas)"), + ] = None + san_thin_provision: Annotated[ + bool | None, + Field(description="Use thin provisioning for SAN volumes?"), + ] = True + use_multipath_for_image_xfer: Annotated[ + bool | None, + Field(description="Enable multipathing for image transfer operations."), + ] = True + + @model_validator(mode="after") + def validate_protocol_connection_consistency(self): + """Ensure protocol and connection_type do not conflict when both set.""" + if self.protocol is None or self.connection_type is None: + return self + + expected = ( + ConnectionType.FIBRE_CHANNEL + if self.protocol == "fc" + else ConnectionType.ISCSI + ) + if self.connection_type != expected: + raise ValueError( + "protocol and connection_type must be consistent " + "(fc<->fibre_channel, iscsi<->iscsi)" + ) + return self + + +class IbmibmstorageBackend(StorageBackendBase): + """IBMStorage backend implementation.""" + + backend_type = "ibmibmstorage" + display_name = "IBMStorage" + generally_available = True + + @property + def charm_name(self) -> str: + """Return the charm application name.""" + return "cinder-volume-ibmibmstorage" + + @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 IbmibmstorageConfig diff --git a/sunbeam-python/tests/unit/sunbeam/storage/backends/conftest.py b/sunbeam-python/tests/unit/sunbeam/storage/backends/conftest.py index 80d3d8ef5..5dd6614ba 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.ibmibmstorage.backend import IbmibmstorageBackend from sunbeam.storage.backends.purestorage.backend import PureStorageBackend @@ -29,19 +30,28 @@ def dellsc_backend(): return DellSCBackend() +@pytest.fixture +def ibmibmstorage_backend(): + """Provide an IBMStorage backend instance.""" + return IbmibmstorageBackend() + + @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", "ibmibmstorage", "dellpowerstore"] +) def any_backend(request): """Parametrized fixture that provides each backend type.""" backends = { "hitachi": HitachiBackend(), "purestorage": PureStorageBackend(), "dellsc": DellSCBackend(), + "ibmibmstorage": IbmibmstorageBackend(), "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..66d4870b8 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, + ibmibmstorage_backend, + dellpowerstore_backend, ): """Test that all backends have unique type identifiers.""" backends = [ hitachi_backend, purestorage_backend, dellsc_backend, + ibmibmstorage_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, + ibmibmstorage_backend, + dellpowerstore_backend, ): """Test that all backends have unique charm names.""" backends = [ hitachi_backend, purestorage_backend, dellsc_backend, + ibmibmstorage_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"), + ("ibmibmstorage", "ibmibmstorage"), ("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"), + ("ibmibmstorage", "cinder-volume-ibmibmstorage"), ("dellpowerstore", "cinder-volume-dellpowerstore"), ], ) diff --git a/sunbeam-python/tests/unit/sunbeam/storage/backends/test_ibmibmstorage.py b/sunbeam-python/tests/unit/sunbeam/storage/backends/test_ibmibmstorage.py new file mode 100644 index 000000000..dc689b00d --- /dev/null +++ b/sunbeam-python/tests/unit/sunbeam/storage/backends/test_ibmibmstorage.py @@ -0,0 +1,139 @@ +# SPDX-FileCopyrightText: 2026 - Canonical Ltd +# SPDX-License-Identifier: Apache-2.0 + +"""Tests for IBMStorage backend.""" + +import pytest +from pydantic import ValidationError + +from sunbeam.storage.models import SecretDictField +from tests.unit.sunbeam.storage.backends.test_common import BaseBackendTests + + +class TestIbmibmstorageBackend(BaseBackendTests): + """Tests for IBMStorage backend.""" + + @pytest.fixture + def backend(self, ibmibmstorage_backend): + """Provide IBMStorage backend instance.""" + return ibmibmstorage_backend + + def test_backend_type_is_ibmibmstorage(self, backend): + """Test that backend type is 'ibmibmstorage'.""" + assert backend.backend_type == "ibmibmstorage" + + def test_charm_name_is_ibmibmstorage_charm(self, backend): + """Test that charm name is cinder-volume-ibmibmstorage.""" + assert backend.charm_name == "cinder-volume-ibmibmstorage" + + def test_config_has_expected_fields(self, backend): + """Test that IBMStorage config exposes expected fields.""" + fields = backend.config_type().model_fields + for field in ("san_ip", "san_login", "san_password", "protocol"): + assert field in fields, f"Expected field {field} not found in config" + + def test_san_credentials_are_secret(self, backend): + """Test that SAN login and password are marked as secrets.""" + config_class = backend.config_type() + for field_name in ("san_login", "san_password"): + field = config_class.model_fields.get(field_name) + assert field is not None + assert any(isinstance(m, SecretDictField) for m in field.metadata), ( + f"{field_name} should be marked as secret" + ) + + +class TestIbmibmstorageConfigValidation: + """Test IBMStorage config validation behavior.""" + + def test_protocol_rejects_invalid_value(self, ibmibmstorage_backend): + """Test that protocol rejects values other than fc/iscsi.""" + config_class = ibmibmstorage_backend.config_type() + with pytest.raises(ValidationError): + config_class.model_validate( + { + "san-ip": "192.168.1.1", + "san-login": "admin", + "san-password": "secret", + "protocol": "nvme", + } + ) + + def test_protocol_accepts_fc(self, ibmibmstorage_backend): + """Test that protocol accepts fc.""" + config_class = ibmibmstorage_backend.config_type() + config = config_class.model_validate( + { + "san-ip": "192.168.1.1", + "san-login": "admin", + "san-password": "secret", + "protocol": "fc", + } + ) + assert config.protocol == "fc" + + def test_connection_type_accepts_fibre_channel(self, ibmibmstorage_backend): + """Test that connection_type accepts fibre_channel.""" + config_class = ibmibmstorage_backend.config_type() + config = config_class.model_validate( + { + "san-ip": "192.168.1.1", + "san-login": "admin", + "san-password": "secret", + "connection-type": "fibre_channel", + } + ) + assert config.connection_type == "fibre_channel" + + def test_connection_type_rejects_invalid_value(self, ibmibmstorage_backend): + """Test that connection_type rejects invalid values.""" + config_class = ibmibmstorage_backend.config_type() + with pytest.raises(ValidationError): + config_class.model_validate( + { + "san-ip": "192.168.1.1", + "san-login": "admin", + "san-password": "secret", + "connection-type": "fc", + } + ) + + def test_chap_accepts_enabled(self, ibmibmstorage_backend): + """Test that chap accepts enabled.""" + config_class = ibmibmstorage_backend.config_type() + config = config_class.model_validate( + { + "san-ip": "192.168.1.1", + "san-login": "admin", + "san-password": "secret", + "chap": "enabled", + } + ) + assert config.chap == "enabled" + + def test_chap_rejects_invalid_value(self, ibmibmstorage_backend): + """Test that chap rejects invalid values.""" + config_class = ibmibmstorage_backend.config_type() + with pytest.raises(ValidationError): + config_class.model_validate( + { + "san-ip": "192.168.1.1", + "san-login": "admin", + "san-password": "secret", + "chap": "auto", + } + ) + + def test_protocol_and_connection_type_must_match(self, ibmibmstorage_backend): + """Test that protocol and connection_type must be consistent.""" + config_class = ibmibmstorage_backend.config_type() + with pytest.raises(ValidationError): + config_class.model_validate( + { + "san-ip": "192.168.1.1", + "san-login": "admin", + "san-password": "secret", + "protocol": "fc", + "connection-type": "iscsi", + } + )