From 1c84e3e55e02eaf5343ae99c80c2b14198f26946 Mon Sep 17 00:00:00 2001 From: Ahmad Hassan Date: Thu, 9 Apr 2026 17:04:14 +0500 Subject: [PATCH 1/3] feat: generate macrosan charm --- .../storage/backends/macrosan/__init__.py | 4 + .../storage/backends/macrosan/backend.py | 182 ++++++++++++++++++ .../unit/sunbeam/storage/backends/conftest.py | 10 +- .../sunbeam/storage/backends/test_common.py | 10 +- .../sunbeam/storage/backends/test_macrosan.py | 78 ++++++++ 5 files changed, 279 insertions(+), 5 deletions(-) create mode 100644 sunbeam-python/sunbeam/storage/backends/macrosan/__init__.py create mode 100644 sunbeam-python/sunbeam/storage/backends/macrosan/backend.py create mode 100644 sunbeam-python/tests/unit/sunbeam/storage/backends/test_macrosan.py diff --git a/sunbeam-python/sunbeam/storage/backends/macrosan/__init__.py b/sunbeam-python/sunbeam/storage/backends/macrosan/__init__.py new file mode 100644 index 000000000..c6cfe6c28 --- /dev/null +++ b/sunbeam-python/sunbeam/storage/backends/macrosan/__init__.py @@ -0,0 +1,4 @@ +# SPDX-FileCopyrightText: 2026 - Canonical Ltd +# SPDX-License-Identifier: Apache-2.0 + +"""MacroSAN backend for Sunbeam storage.""" diff --git a/sunbeam-python/sunbeam/storage/backends/macrosan/backend.py b/sunbeam-python/sunbeam/storage/backends/macrosan/backend.py new file mode 100644 index 000000000..17558ae07 --- /dev/null +++ b/sunbeam-python/sunbeam/storage/backends/macrosan/backend.py @@ -0,0 +1,182 @@ +# SPDX-FileCopyrightText: 2026 - Canonical Ltd +# SPDX-License-Identifier: Apache-2.0 +# ruff: noqa: E501 + +"""MacroSAN iSCSI 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 MacrosanConfig(StorageBackendConfig): + """Configuration model for MacroSAN iSCSI 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.") + ] + + macrosan_sdas_password: Annotated[ + str, + Field(description="MacroSAN sdas devices' password"), + SecretDictField(field="macrosan-sdas-password"), + ] + + macrosan_replication_password: Annotated[ + str, + Field(description="MacroSAN replication devices' password"), + SecretDictField(field="macrosan-replication-password"), + ] + + # Optional backend configuration + protocol: Annotated[ + Protocol | None, + Field(description="Protocol selector: iscsi, fc."), + ] = None + + macrosan_sdas_ipaddrs: Annotated[ + str | None, + Field(description="MacroSAN sdas devices' ip addresses"), + ] = None + + macrosan_sdas_username: Annotated[ + str | None, + Field(description="MacroSAN sdas devices' username"), + ] = None + + macrosan_replication_ipaddrs: Annotated[ + str | None, + Field(description="MacroSAN replication devices' ip addresses"), + ] = None + + macrosan_replication_username: Annotated[ + str | None, + Field(description="MacroSAN replication devices' username"), + ] = None + + macrosan_replication_destination_ports: Annotated[ + str | None, + Field(description="Slave device"), + ] = None + + macrosan_pool: Annotated[ + str | None, + Field(description="Pool to use for volume creation"), + ] = None + + macrosan_thin_lun_extent_size: Annotated[ + int | None, + Field(description="Set the thin lun's extent size"), + ] = None + + macrosan_thin_lun_low_watermark: Annotated[ + int | None, + Field(description="Set the thin lun's low watermark"), + ] = None + + macrosan_thin_lun_high_watermark: Annotated[ + int | None, + Field(description="Set the thin lun's high watermark"), + ] = None + + macrosan_force_unmap_itl: Annotated[ + bool | None, + Field(description="Force disconnect while deleting volume"), + ] = None + + macrosan_snapshot_resource_ratio: Annotated[ + str | None, + Field(description="Set snapshot's resource ratio"), + ] = None + + macrosan_log_timing: Annotated[ + bool | None, + Field(description="Whether enable log timing"), + ] = None + + macrosan_fc_use_sp_port_nr: Annotated[ + int | None, + Field( + description="The use_sp_port_nr parameter is the number of online FC ports used by the single-ended memory when the FC connection is established in the switch non-all-pass mode. The maximum is 4" + ), + ] = None + + macrosan_fc_keep_mapped_ports: Annotated[ + bool | None, + Field( + description="In the case of an FC connection, the configuration item associated with the port is maintained." + ), + ] = None + + macrosan_client: Annotated[ + str | None, + Field( + description="Macrosan iscsi_clients list. You can configure multiple clients. You can configure it in this format: (host; client_name; sp1_iscsi_port; sp2_iscsi_port), (host; client_name; sp1_iscsi_port; sp2_iscsi_port) Important warning, Client_name has the following requirements: [a-zA-Z0-9.-_:], the maximum number of characters is 31 E.g: (controller1; device1; eth-1:0; eth-2:0), (controller2; device2; eth-1:0/eth-1:1; eth-2:0/eth-2:1)," + ), + ] = None + + macrosan_client_default: Annotated[ + str | None, + Field( + description="This is the default connection ports' name for iscsi. This default configuration is used when no host related information is obtained.E.g: eth-1:0/eth-1:1; eth-2:0/eth-2:1" + ), + ] = None + + +class MacrosanBackend(StorageBackendBase): + """MacroSAN iSCSI backend implementation.""" + + backend_type = "macrosan" + display_name = "MacroSAN iSCSI" + generally_available = True + + @property + def charm_name(self) -> str: + """Return the charm application name.""" + return "cinder-volume-macrosan" + + @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 MacrosanConfig diff --git a/sunbeam-python/tests/unit/sunbeam/storage/backends/conftest.py b/sunbeam-python/tests/unit/sunbeam/storage/backends/conftest.py index 80d3d8ef5..1af452e5a 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.macrosan.backend import MacrosanBackend from sunbeam.storage.backends.purestorage.backend import PureStorageBackend @@ -29,19 +30,26 @@ def dellsc_backend(): return DellSCBackend() +@pytest.fixture +def macrosan_backend(): + """Provide a MacroSAN backend instance.""" + return MacrosanBackend() + + @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", "macrosan", "dellpowerstore"]) def any_backend(request): """Parametrized fixture that provides each backend type.""" backends = { "hitachi": HitachiBackend(), "purestorage": PureStorageBackend(), "dellsc": DellSCBackend(), + "macrosan": MacrosanBackend(), "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..877f18a21 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,13 @@ 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, macrosan_backend, dellpowerstore_backend ): """Test that all backends have unique type identifiers.""" backends = [ hitachi_backend, purestorage_backend, - dellsc_backend, + dellsc_backend, macrosan_backend, dellpowerstore_backend, ] types = [b.backend_type for b in backends] @@ -173,13 +173,13 @@ 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, macrosan_backend, dellpowerstore_backend ): """Test that all backends have unique charm names.""" backends = [ hitachi_backend, purestorage_backend, - dellsc_backend, + dellsc_backend, macrosan_backend, dellpowerstore_backend, ] charm_names = [b.charm_name for b in backends] @@ -196,6 +196,7 @@ def test_all_backends_have_unique_charm_names( ("hitachi", "hitachi"), ("purestorage", "purestorage"), ("dellsc", "dellsc"), + ("macrosan", "macrosan"), ("dellpowerstore", "dellpowerstore"), ], ) @@ -211,6 +212,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"), + ("macrosan", "cinder-volume-macrosan"), ("dellpowerstore", "cinder-volume-dellpowerstore"), ], ) diff --git a/sunbeam-python/tests/unit/sunbeam/storage/backends/test_macrosan.py b/sunbeam-python/tests/unit/sunbeam/storage/backends/test_macrosan.py new file mode 100644 index 000000000..5f61c051e --- /dev/null +++ b/sunbeam-python/tests/unit/sunbeam/storage/backends/test_macrosan.py @@ -0,0 +1,78 @@ +# SPDX-FileCopyrightText: 2026 - Canonical Ltd +# SPDX-License-Identifier: Apache-2.0 + +"""Tests for MacroSAN backend.""" + +import pytest +from pydantic import ValidationError + +from sunbeam.storage.models import SecretDictField +from tests.unit.sunbeam.storage.backends.test_common import BaseBackendTests + + +class TestMacrosanBackend(BaseBackendTests): + """Tests for MacroSAN backend.""" + + @pytest.fixture + def backend(self, macrosan_backend): + """Provide MacroSAN backend instance.""" + return macrosan_backend + + def test_backend_type_is_macrosan(self, backend): + """Test that backend type is 'macrosan'.""" + assert backend.backend_type == "macrosan" + + def test_charm_name_is_macrosan_charm(self, backend): + """Test that charm name is cinder-volume-macrosan.""" + assert backend.charm_name == "cinder-volume-macrosan" + + def test_config_has_required_fields(self, backend): + """Test that MacroSAN config has required fields.""" + fields = backend.config_type().model_fields + for field in ( + "san_ip", + "macrosan_sdas_password", + "macrosan_replication_password", + "protocol", + ): + assert field in fields, f"Required field {field} not found in config" + + def test_sensitive_fields_are_secrets(self, backend): + """Test that password fields are marked as secrets.""" + config_class = backend.config_type() + for field_name in ("macrosan_sdas_password", "macrosan_replication_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 TestMacrosanConfigValidation: + """Test MacroSAN config validation behavior.""" + + def test_protocol_rejects_invalid_value(self, macrosan_backend): + """Test that protocol rejects values other than iscsi/fc.""" + config_class = macrosan_backend.config_type() + with pytest.raises(ValidationError): + config_class.model_validate( + { + "san-ip": "192.168.1.1", + "macrosan-sdas-password": "secret1", + "macrosan-replication-password": "secret2", + "protocol": "nvme", + } + ) + + def test_protocol_accepts_iscsi(self, macrosan_backend): + """Test that protocol accepts iscsi.""" + config_class = macrosan_backend.config_type() + config = config_class.model_validate( + { + "san-ip": "192.168.1.1", + "macrosan-sdas-password": "secret1", + "macrosan-replication-password": "secret2", + "protocol": "iscsi", + } + ) + assert config.protocol == "iscsi" From a1f37336d26ecf71f8d5a151989378a4eca88da8 Mon Sep 17 00:00:00 2001 From: Ahmad Hassan Date: Thu, 9 Apr 2026 17:27:49 +0500 Subject: [PATCH 2/3] fix: make backend naming protocol-neutral and drop global E501 ignore --- .../storage/backends/macrosan/backend.py | 32 +++++++++++++------ 1 file changed, 23 insertions(+), 9 deletions(-) diff --git a/sunbeam-python/sunbeam/storage/backends/macrosan/backend.py b/sunbeam-python/sunbeam/storage/backends/macrosan/backend.py index 17558ae07..72844f7fa 100644 --- a/sunbeam-python/sunbeam/storage/backends/macrosan/backend.py +++ b/sunbeam-python/sunbeam/storage/backends/macrosan/backend.py @@ -1,8 +1,7 @@ # SPDX-FileCopyrightText: 2026 - Canonical Ltd # SPDX-License-Identifier: Apache-2.0 -# ruff: noqa: E501 -"""MacroSAN iSCSI backend implementation using base step classes.""" +"""MacroSAN backend implementation using base step classes.""" import logging from enum import StrEnum @@ -27,7 +26,7 @@ class Protocol(StrEnum): class MacrosanConfig(StorageBackendConfig): - """Configuration model for MacroSAN iSCSI backend. + """Configuration model for MacroSAN backend. This model includes ALL configuration options for the backend. Additional configuration can be managed dynamically through the charm. @@ -119,37 +118,52 @@ class MacrosanConfig(StorageBackendConfig): macrosan_fc_use_sp_port_nr: Annotated[ int | None, Field( - description="The use_sp_port_nr parameter is the number of online FC ports used by the single-ended memory when the FC connection is established in the switch non-all-pass mode. The maximum is 4" + description=( + "The use_sp_port_nr parameter is the number of online FC ports " + "used when FC connection is established in switch non-all-pass " + "mode. The maximum is 4." + ) ), ] = None macrosan_fc_keep_mapped_ports: Annotated[ bool | None, Field( - description="In the case of an FC connection, the configuration item associated with the port is maintained." + description=( + "In FC connections, keep the configuration item " + "associated with the port." + ) ), ] = None macrosan_client: Annotated[ str | None, Field( - description="Macrosan iscsi_clients list. You can configure multiple clients. You can configure it in this format: (host; client_name; sp1_iscsi_port; sp2_iscsi_port), (host; client_name; sp1_iscsi_port; sp2_iscsi_port) Important warning, Client_name has the following requirements: [a-zA-Z0-9.-_:], the maximum number of characters is 31 E.g: (controller1; device1; eth-1:0; eth-2:0), (controller2; device2; eth-1:0/eth-1:1; eth-2:0/eth-2:1)," + description=( + "MacroSAN iSCSI clients list. Configure one or more entries in " + "format: (host;client_name;sp1_iscsi_port;sp2_iscsi_port). " + "client_name supports [a-zA-Z0-9.-_:] up to 31 chars." + ) ), ] = None macrosan_client_default: Annotated[ str | None, Field( - description="This is the default connection ports' name for iscsi. This default configuration is used when no host related information is obtained.E.g: eth-1:0/eth-1:1; eth-2:0/eth-2:1" + description=( + "Default iSCSI connection port names used when " + "no host-specific information is available, e.g. " + "eth-1:0/eth-1:1;eth-2:0/eth-2:1." + ) ), ] = None class MacrosanBackend(StorageBackendBase): - """MacroSAN iSCSI backend implementation.""" + """MacroSAN backend implementation.""" backend_type = "macrosan" - display_name = "MacroSAN iSCSI" + display_name = "MacroSAN" generally_available = True @property From 580db2d2f35759a761e4845ab11a2fa3d8c6d3ce Mon Sep 17 00:00:00 2001 From: Ahmad Hassan Date: Tue, 21 Apr 2026 14:26:03 +0500 Subject: [PATCH 3/3] fix: test/lint issues resolve --- .../unit/sunbeam/storage/backends/conftest.py | 4 +++- .../sunbeam/storage/backends/test_common.py | 18 ++++++++++++++---- 2 files changed, 17 insertions(+), 5 deletions(-) diff --git a/sunbeam-python/tests/unit/sunbeam/storage/backends/conftest.py b/sunbeam-python/tests/unit/sunbeam/storage/backends/conftest.py index 1af452e5a..1f78dbc96 100644 --- a/sunbeam-python/tests/unit/sunbeam/storage/backends/conftest.py +++ b/sunbeam-python/tests/unit/sunbeam/storage/backends/conftest.py @@ -42,7 +42,9 @@ def dellpowerstore_backend(): return DellPowerstoreBackend() -@pytest.fixture(params=["hitachi", "purestorage", "dellsc", "macrosan", "dellpowerstore"]) +@pytest.fixture( + params=["hitachi", "purestorage", "dellsc", "macrosan", "dellpowerstore"] +) def any_backend(request): """Parametrized fixture that provides each backend type.""" backends = { 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 877f18a21..4ff29d4f1 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, macrosan_backend, dellpowerstore_backend + hitachi_backend, + purestorage_backend, + dellsc_backend, + macrosan_backend, + dellpowerstore_backend, ): """Test that all backends have unique type identifiers.""" backends = [ hitachi_backend, purestorage_backend, - dellsc_backend, macrosan_backend, + dellsc_backend, + macrosan_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, macrosan_backend, dellpowerstore_backend + hitachi_backend, + purestorage_backend, + dellsc_backend, + macrosan_backend, + dellpowerstore_backend, ): """Test that all backends have unique charm names.""" backends = [ hitachi_backend, purestorage_backend, - dellsc_backend, macrosan_backend, + dellsc_backend, + macrosan_backend, dellpowerstore_backend, ] charm_names = [b.charm_name for b in backends]