From 914e973d39391f02c57333ace68055c2335f9f0e Mon Sep 17 00:00:00 2001 From: Ahmad Hassan Date: Thu, 9 Apr 2026 16:35:38 +0500 Subject: [PATCH 1/3] feat: generate linstor charm --- .../storage/backends/linstor/__init__.py | 4 + .../storage/backends/linstor/backend.py | 105 ++++++++++++++++++ .../unit/sunbeam/storage/backends/conftest.py | 10 +- .../sunbeam/storage/backends/test_common.py | 10 +- .../sunbeam/storage/backends/test_linstor.py | 58 ++++++++++ 5 files changed, 182 insertions(+), 5 deletions(-) create mode 100644 sunbeam-python/sunbeam/storage/backends/linstor/__init__.py create mode 100644 sunbeam-python/sunbeam/storage/backends/linstor/backend.py create mode 100644 sunbeam-python/tests/unit/sunbeam/storage/backends/test_linstor.py diff --git a/sunbeam-python/sunbeam/storage/backends/linstor/__init__.py b/sunbeam-python/sunbeam/storage/backends/linstor/__init__.py new file mode 100644 index 000000000..20d74e88b --- /dev/null +++ b/sunbeam-python/sunbeam/storage/backends/linstor/__init__.py @@ -0,0 +1,4 @@ +# SPDX-FileCopyrightText: 2026 - Canonical Ltd +# SPDX-License-Identifier: Apache-2.0 + +"""LINSTOR backend for Sunbeam storage.""" diff --git a/sunbeam-python/sunbeam/storage/backends/linstor/backend.py b/sunbeam-python/sunbeam/storage/backends/linstor/backend.py new file mode 100644 index 000000000..de5d1ff9b --- /dev/null +++ b/sunbeam-python/sunbeam/storage/backends/linstor/backend.py @@ -0,0 +1,105 @@ +# SPDX-FileCopyrightText: 2026 - Canonical Ltd +# SPDX-License-Identifier: Apache-2.0 +# ruff: noqa: E501 + +"""LinstorIscsi backend implementation using base step classes.""" + +import logging +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 LinstorConfig(StorageBackendConfig): + """Configuration model for LinstorIscsi 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.") + ] + + # Optional backend configuration + protocol: Annotated[ + Literal["iscsi"] | None, + Field(description="Protocol selector: iscsi."), + ] = None + linstor_default_volume_group_name: Annotated[ + str | None, + Field(description="Default Volume Group name for LINSTOR. Not Cinder Volume."), + ] = None + linstor_default_uri: Annotated[ + str | None, + Field(description="Default storage URI for LINSTOR."), + ] = None + linstor_default_storage_pool_name: Annotated[ + str | None, + Field(description="Default Storage Pool name for LINSTOR."), + ] = None + linstor_volume_downsize_factor: Annotated[ + str | None, + Field(description="Default volume downscale size in KiB = 4 MiB."), + ] = None + linstor_default_blocksize: Annotated[ + int | None, + Field( + description="Default Block size for Image restoration. When using iSCSI transport, this option specifies the block size." + ), + ] = None + linstor_autoplace_count: Annotated[ + int | None, + Field( + description="Autoplace replication count on volume deployment. 0 = Full cluster replication without autoplace, 1 = Single node deployment without replication, 2 or greater = Replicated deployment with autoplace." + ), + ] = None + linstor_controller_diskless: Annotated[ + bool | None, + Field(description="True means Cinder node is a diskless LINSTOR node."), + ] = None + + +class LinstorBackend(StorageBackendBase): + """LinstorIscsi backend implementation.""" + + backend_type = "linstor" + display_name = "LinstorIscsi" + generally_available = True + + @property + def charm_name(self) -> str: + """Return the charm application name.""" + return "cinder-volume-linstor" + + @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 LinstorConfig diff --git a/sunbeam-python/tests/unit/sunbeam/storage/backends/conftest.py b/sunbeam-python/tests/unit/sunbeam/storage/backends/conftest.py index 80d3d8ef5..ba58a8ae3 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.linstor.backend import LinstorBackend from sunbeam.storage.backends.purestorage.backend import PureStorageBackend @@ -29,19 +30,26 @@ def dellsc_backend(): return DellSCBackend() +@pytest.fixture +def linstor_backend(): + """Provide a LINSTOR backend instance.""" + return LinstorBackend() + + @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", "linstor", "dellpowerstore"]) def any_backend(request): """Parametrized fixture that provides each backend type.""" backends = { "hitachi": HitachiBackend(), "purestorage": PureStorageBackend(), "dellsc": DellSCBackend(), + "linstor": LinstorBackend(), "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..1382082d7 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, linstor_backend, dellpowerstore_backend ): """Test that all backends have unique type identifiers.""" backends = [ hitachi_backend, purestorage_backend, - dellsc_backend, + dellsc_backend, linstor_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, linstor_backend, dellpowerstore_backend ): """Test that all backends have unique charm names.""" backends = [ hitachi_backend, purestorage_backend, - dellsc_backend, + dellsc_backend, linstor_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"), + ("linstor", "linstor"), ("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"), + ("linstor", "cinder-volume-linstor"), ("dellpowerstore", "cinder-volume-dellpowerstore"), ], ) diff --git a/sunbeam-python/tests/unit/sunbeam/storage/backends/test_linstor.py b/sunbeam-python/tests/unit/sunbeam/storage/backends/test_linstor.py new file mode 100644 index 000000000..f27091011 --- /dev/null +++ b/sunbeam-python/tests/unit/sunbeam/storage/backends/test_linstor.py @@ -0,0 +1,58 @@ +# SPDX-FileCopyrightText: 2026 - Canonical Ltd +# SPDX-License-Identifier: Apache-2.0 + +"""Tests for LINSTOR backend.""" + +import pytest +from pydantic import ValidationError + +from tests.unit.sunbeam.storage.backends.test_common import BaseBackendTests + + +class TestLinstorBackend(BaseBackendTests): + """Tests for LINSTOR backend.""" + + @pytest.fixture + def backend(self, linstor_backend): + """Provide LINSTOR backend instance.""" + return linstor_backend + + def test_backend_type_is_linstor(self, backend): + """Test that backend type is 'linstor'.""" + assert backend.backend_type == "linstor" + + def test_charm_name_is_linstor_charm(self, backend): + """Test that charm name is cinder-volume-linstor.""" + assert backend.charm_name == "cinder-volume-linstor" + + def test_config_has_required_fields(self, backend): + """Test that LINSTOR config has required fields.""" + fields = backend.config_type().model_fields + for field in ("san_ip", "protocol"): + assert field in fields, f"Required field {field} not found in config" + + +class TestLinstorConfigValidation: + """Test LINSTOR config validation behavior.""" + + def test_protocol_rejects_invalid_value(self, linstor_backend): + """Test that protocol rejects values other than iscsi.""" + config_class = linstor_backend.config_type() + with pytest.raises(ValidationError): + config_class.model_validate( + { + "san-ip": "192.168.1.1", + "protocol": "fc", + } + ) + + def test_protocol_accepts_iscsi(self, linstor_backend): + """Test that protocol accepts iscsi.""" + config_class = linstor_backend.config_type() + config = config_class.model_validate( + { + "san-ip": "192.168.1.1", + "protocol": "iscsi", + } + ) + assert config.protocol == "iscsi" From 9adbd58707e6280d5e9d01fedff99db0f44f46f9 Mon Sep 17 00:00:00 2001 From: Ahmad Hassan Date: Thu, 9 Apr 2026 17:09:44 +0500 Subject: [PATCH 2/3] fix: align naming, linting, and required-field test semantics --- .../storage/backends/linstor/backend.py | 30 +++++++++++-------- .../sunbeam/storage/backends/test_linstor.py | 7 ++--- 2 files changed, 20 insertions(+), 17 deletions(-) diff --git a/sunbeam-python/sunbeam/storage/backends/linstor/backend.py b/sunbeam-python/sunbeam/storage/backends/linstor/backend.py index de5d1ff9b..a01175952 100644 --- a/sunbeam-python/sunbeam/storage/backends/linstor/backend.py +++ b/sunbeam-python/sunbeam/storage/backends/linstor/backend.py @@ -1,24 +1,18 @@ # SPDX-FileCopyrightText: 2026 - Canonical Ltd # SPDX-License-Identifier: Apache-2.0 -# ruff: noqa: E501 -"""LinstorIscsi backend implementation using base step classes.""" +"""LINSTOR iSCSI backend implementation using base step classes.""" -import logging 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 LinstorConfig(StorageBackendConfig): - """Configuration model for LinstorIscsi backend. + """Configuration model for LINSTOR iSCSI backend. This model includes ALL configuration options for the backend. Additional configuration can be managed dynamically through the charm. @@ -36,7 +30,9 @@ class LinstorConfig(StorageBackendConfig): ] = None linstor_default_volume_group_name: Annotated[ str | None, - Field(description="Default Volume Group name for LINSTOR. Not Cinder Volume."), + Field( + description=("Default volume group name for LINSTOR (not Cinder volume).") + ), ] = None linstor_default_uri: Annotated[ str | None, @@ -53,13 +49,21 @@ class LinstorConfig(StorageBackendConfig): linstor_default_blocksize: Annotated[ int | None, Field( - description="Default Block size for Image restoration. When using iSCSI transport, this option specifies the block size." + description=( + "Default block size for image restoration. " + "When using iSCSI transport, this option specifies " + "the block size." + ) ), ] = None linstor_autoplace_count: Annotated[ int | None, Field( - description="Autoplace replication count on volume deployment. 0 = Full cluster replication without autoplace, 1 = Single node deployment without replication, 2 or greater = Replicated deployment with autoplace." + description=( + "Autoplace replication count on volume deployment: " + "0=full cluster without autoplace, 1=single-node " + "without replication, >=2=replicated with autoplace." + ) ), ] = None linstor_controller_diskless: Annotated[ @@ -69,10 +73,10 @@ class LinstorConfig(StorageBackendConfig): class LinstorBackend(StorageBackendBase): - """LinstorIscsi backend implementation.""" + """LINSTOR iSCSI backend implementation.""" backend_type = "linstor" - display_name = "LinstorIscsi" + display_name = "LINSTOR iSCSI" generally_available = True @property diff --git a/sunbeam-python/tests/unit/sunbeam/storage/backends/test_linstor.py b/sunbeam-python/tests/unit/sunbeam/storage/backends/test_linstor.py index f27091011..bccdf5538 100644 --- a/sunbeam-python/tests/unit/sunbeam/storage/backends/test_linstor.py +++ b/sunbeam-python/tests/unit/sunbeam/storage/backends/test_linstor.py @@ -25,11 +25,10 @@ def test_charm_name_is_linstor_charm(self, backend): """Test that charm name is cinder-volume-linstor.""" assert backend.charm_name == "cinder-volume-linstor" - def test_config_has_required_fields(self, backend): - """Test that LINSTOR config has required fields.""" + def test_config_has_required_field(self, backend): + """Test that LINSTOR config exposes its required field.""" fields = backend.config_type().model_fields - for field in ("san_ip", "protocol"): - assert field in fields, f"Required field {field} not found in config" + assert "san_ip" in fields, "Required field san_ip not found in config" class TestLinstorConfigValidation: From d85b746fd34aae4210bd1d867b96ed96bbef6a7f Mon Sep 17 00:00:00 2001 From: Ahmad Hassan Date: Tue, 21 Apr 2026 14:06:25 +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 ba58a8ae3..b44f96e49 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", "linstor", "dellpowerstore"]) +@pytest.fixture( + params=["hitachi", "purestorage", "dellsc", "linstor", "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 1382082d7..f498a3c83 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, linstor_backend, dellpowerstore_backend + hitachi_backend, + purestorage_backend, + dellsc_backend, + linstor_backend, + dellpowerstore_backend, ): """Test that all backends have unique type identifiers.""" backends = [ hitachi_backend, purestorage_backend, - dellsc_backend, linstor_backend, + dellsc_backend, + linstor_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, linstor_backend, dellpowerstore_backend + hitachi_backend, + purestorage_backend, + dellsc_backend, + linstor_backend, + dellpowerstore_backend, ): """Test that all backends have unique charm names.""" backends = [ hitachi_backend, purestorage_backend, - dellsc_backend, linstor_backend, + dellsc_backend, + linstor_backend, dellpowerstore_backend, ] charm_names = [b.charm_name for b in backends]