diff --git a/sunbeam-python/sunbeam/storage/backends/opene/__init__.py b/sunbeam-python/sunbeam/storage/backends/opene/__init__.py new file mode 100644 index 000000000..eb04fdef5 --- /dev/null +++ b/sunbeam-python/sunbeam/storage/backends/opene/__init__.py @@ -0,0 +1,4 @@ +# SPDX-FileCopyrightText: 2026 - Canonical Ltd +# SPDX-License-Identifier: Apache-2.0 + +"""Open-E Jovian backend for Sunbeam storage.""" diff --git a/sunbeam-python/sunbeam/storage/backends/opene/backend.py b/sunbeam-python/sunbeam/storage/backends/opene/backend.py new file mode 100644 index 000000000..a7dc6c6c3 --- /dev/null +++ b/sunbeam-python/sunbeam/storage/backends/opene/backend.py @@ -0,0 +1,101 @@ +# SPDX-FileCopyrightText: 2026 - Canonical Ltd +# SPDX-License-Identifier: Apache-2.0 + +"""Jovian 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 + +LOG = logging.getLogger(__name__) +console = Console() + + +class BlockSize(StrEnum): + """Enumeration of valid block sizes.""" + + K16 = "16K" + K32 = "32K" + K64 = "64K" + K128 = "128K" + K256 = "256K" + K512 = "512K" + M1 = "1M" + + +class OpeneConfig(StorageBackendConfig): + """Configuration model for Jovian 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 + san_hosts: Annotated[ + str | None, Field(description="IP address of Open-E JovianDSS SA") + ] = None + jovian_recovery_delay: Annotated[ + int | None, Field(description="Time before HA cluster failure.") + ] = None + jovian_ignore_tpath: Annotated[ + str | None, Field(description="List of multipath ip addresses to ignore.") + ] = None + chap_password_len: Annotated[ + int, + Field(description="Length of the random string for CHAP password."), + ] + jovian_pool: Annotated[ + str | None, Field(description="JovianDSS pool that holds all cinder volumes") + ] = None + jovian_block_size: Annotated[ + BlockSize | None, Field(description="Block size for new volume") + ] = None + + +class OpeneBackend(StorageBackendBase): + """Jovian iSCSI backend implementation.""" + + backend_type = "opene" + display_name = "Jovian iSCSI" + generally_available = True + + @property + def charm_name(self) -> str: + """Return the charm application name.""" + return "cinder-volume-opene" + + @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 OpeneConfig diff --git a/sunbeam-python/tests/unit/sunbeam/storage/backends/conftest.py b/sunbeam-python/tests/unit/sunbeam/storage/backends/conftest.py index 80d3d8ef5..9bc7dc312 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.opene.backend import OpeneBackend from sunbeam.storage.backends.purestorage.backend import PureStorageBackend @@ -29,19 +30,26 @@ def dellsc_backend(): return DellSCBackend() +@pytest.fixture +def opene_backend(): + """Provide an Open-E backend instance.""" + return OpeneBackend() + + @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", "opene", "dellpowerstore"]) def any_backend(request): """Parametrized fixture that provides each backend type.""" backends = { "hitachi": HitachiBackend(), "purestorage": PureStorageBackend(), "dellsc": DellSCBackend(), + "opene": OpeneBackend(), "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..c797c264d 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, + opene_backend, + dellpowerstore_backend, ): """Test that all backends have unique type identifiers.""" backends = [ hitachi_backend, purestorage_backend, dellsc_backend, + opene_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, + opene_backend, + dellpowerstore_backend, ): """Test that all backends have unique charm names.""" backends = [ hitachi_backend, purestorage_backend, dellsc_backend, + opene_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"), + ("opene", "opene"), ("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"), + ("opene", "cinder-volume-opene"), ("dellpowerstore", "cinder-volume-dellpowerstore"), ], ) diff --git a/sunbeam-python/tests/unit/sunbeam/storage/backends/test_opene.py b/sunbeam-python/tests/unit/sunbeam/storage/backends/test_opene.py new file mode 100644 index 000000000..51f2c1e0d --- /dev/null +++ b/sunbeam-python/tests/unit/sunbeam/storage/backends/test_opene.py @@ -0,0 +1,115 @@ +# SPDX-FileCopyrightText: 2026 - Canonical Ltd +# SPDX-License-Identifier: Apache-2.0 + +"""Tests for Open-E backend.""" + +import pytest +from pydantic import ValidationError + +from tests.unit.sunbeam.storage.backends.test_common import BaseBackendTests + + +class TestOpeneBackend(BaseBackendTests): + """Tests for Open-E backend.""" + + @pytest.fixture + def backend(self, opene_backend): + """Provide Open-E backend instance.""" + return opene_backend + + def test_backend_type_is_opene(self, backend): + """Test that backend type is 'opene'.""" + assert backend.backend_type == "opene" + + def test_charm_name_is_opene_charm(self, backend): + """Test that charm name is cinder-volume-opene.""" + assert backend.charm_name == "cinder-volume-opene" + + def test_config_has_required_fields(self, backend): + """Test that Open-E config has required fields.""" + fields = backend.config_type().model_fields + for field in ("san_ip", "protocol", "chap_password_len"): + assert field in fields, f"Required field {field} not found in config" + + def test_chap_password_len_is_numeric(self, backend): + """Test that CHAP password length field is configured as int.""" + config_class = backend.config_type() + field = config_class.model_fields.get("chap_password_len") + assert field is not None + assert field.annotation is int + + +class TestOpeneConfigValidation: + """Test Open-E config validation behavior.""" + + def test_protocol_rejects_invalid_value(self, opene_backend): + """Test that protocol rejects values other than iscsi.""" + config_class = opene_backend.config_type() + with pytest.raises(ValidationError): + config_class.model_validate( + { + "san-ip": "192.168.1.1", + "chap-password-len": 16, + "protocol": "fc", + } + ) + + def test_protocol_accepts_iscsi(self, opene_backend): + """Test that protocol accepts iscsi.""" + config_class = opene_backend.config_type() + config = config_class.model_validate( + { + "san-ip": "192.168.1.1", + "chap-password-len": 16, + "protocol": "iscsi", + } + ) + assert config.protocol == "iscsi" + + def test_jovian_block_size_accepts_valid_value(self, opene_backend): + """Test that jovian_block_size accepts valid enum values.""" + config_class = opene_backend.config_type() + config = config_class.model_validate( + { + "san-ip": "192.168.1.1", + "chap-password-len": 16, + "protocol": "iscsi", + "jovian-block-size": "16K", + } + ) + assert str(config.jovian_block_size) == "16K" + + def test_jovian_block_size_rejects_invalid_value(self, opene_backend): + """Test that jovian_block_size rejects invalid values.""" + config_class = opene_backend.config_type() + with pytest.raises(ValidationError): + config_class.model_validate( + { + "san-ip": "192.168.1.1", + "chap-password-len": 16, + "protocol": "iscsi", + "jovian-block-size": "8K", + } + ) + + def test_optional_fields_round_trip_with_kebab_aliases(self, opene_backend): + """Test optional fields are parsed/serialized via kebab-case aliases.""" + config_class = opene_backend.config_type() + config = config_class.model_validate( + { + "san-ip": "192.168.1.1", + "chap-password-len": 16, + "protocol": "iscsi", + "san-hosts": "10.0.0.10", + "jovian-recovery-delay": 20, + "jovian-ignore-tpath": "10.0.0.11,10.0.0.12", + "jovian-pool": "pool-1", + "jovian-block-size": "32K", + } + ) + dumped = config.model_dump(by_alias=True) + assert dumped["san-hosts"] == "10.0.0.10" + assert dumped["jovian-recovery-delay"] == 20 + assert dumped["jovian-ignore-tpath"] == "10.0.0.11,10.0.0.12" + assert dumped["jovian-pool"] == "pool-1" + assert dumped["jovian-block-size"] == "32K"