From ddb6405a3e0dcf02f15a84af3a12e54c5b74420a Mon Sep 17 00:00:00 2001 From: Ahmad Hassan Date: Tue, 14 Apr 2026 14:47:15 +0500 Subject: [PATCH 1/3] feat: generate synology charm --- .../storage/backends/synology/__init__.py | 4 + .../storage/backends/synology/backend.py | 123 ++++++++++++++++++ .../unit/sunbeam/storage/backends/conftest.py | 10 +- .../sunbeam/storage/backends/test_common.py | 10 +- .../sunbeam/storage/backends/test_synology.py | 78 +++++++++++ 5 files changed, 220 insertions(+), 5 deletions(-) create mode 100644 sunbeam-python/sunbeam/storage/backends/synology/__init__.py create mode 100644 sunbeam-python/sunbeam/storage/backends/synology/backend.py create mode 100644 sunbeam-python/tests/unit/sunbeam/storage/backends/test_synology.py diff --git a/sunbeam-python/sunbeam/storage/backends/synology/__init__.py b/sunbeam-python/sunbeam/storage/backends/synology/__init__.py new file mode 100644 index 000000000..8a9ecad28 --- /dev/null +++ b/sunbeam-python/sunbeam/storage/backends/synology/__init__.py @@ -0,0 +1,4 @@ +# SPDX-FileCopyrightText: 2026 - Canonical Ltd +# SPDX-License-Identifier: Apache-2.0 + +"""Synology backend for Sunbeam storage.""" diff --git a/sunbeam-python/sunbeam/storage/backends/synology/backend.py b/sunbeam-python/sunbeam/storage/backends/synology/backend.py new file mode 100644 index 000000000..1337eb17c --- /dev/null +++ b/sunbeam-python/sunbeam/storage/backends/synology/backend.py @@ -0,0 +1,123 @@ +# SPDX-FileCopyrightText: 2026 - Canonical Ltd +# SPDX-License-Identifier: Apache-2.0 +# ruff: noqa: E501 + +"""Syno 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" + + +class SynologyConfig(StorageBackendConfig): + """Configuration model for Syno 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.") + ] + protocol: Annotated[ + Protocol | None, + Field(description="Protocol selector: iscsi."), + ] = None + + synology_password: Annotated[ + str, + Field(description="Password of administrator for logging in Synology storage."), + SecretDictField(field="synology-password"), + ] + + synology_one_time_pass: Annotated[ + str, + Field( + description="One time password of administrator for logging in Synology storage if OTP is enabled." + ), + SecretDictField(field="synology-one-time-pass"), + ] + + # Optional backend configuration + synology_pool_name: Annotated[ + str | None, + Field(description="Volume on Synology storage to be used for creating lun."), + ] = None + + synology_admin_port: Annotated[ + int | None, + Field(description="Management port for Synology storage."), + ] = 5000 + + synology_username: Annotated[ + str | None, + Field(description="Administrator of Synology storage."), + ] = "admin" + + synology_ssl_verify: Annotated[ + bool | None, + Field( + description="Do certificate validation or not if $driver_use_ssl is True" + ), + ] = True + + synology_device_id: Annotated[ + str | None, + Field( + description="Device id for skip one time password check for logging in Synology storage if OTP is enabled." + ), + ] = None + + +class SynologyBackend(StorageBackendBase): + """Syno iSCSI backend implementation.""" + + backend_type = "synology" + display_name = "Syno iSCSI" + generally_available = True + + @property + def charm_name(self) -> str: + """Return the charm application name.""" + return "cinder-volume-synology" + + @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 SynologyConfig diff --git a/sunbeam-python/tests/unit/sunbeam/storage/backends/conftest.py b/sunbeam-python/tests/unit/sunbeam/storage/backends/conftest.py index 80d3d8ef5..41001e94d 100644 --- a/sunbeam-python/tests/unit/sunbeam/storage/backends/conftest.py +++ b/sunbeam-python/tests/unit/sunbeam/storage/backends/conftest.py @@ -9,6 +9,7 @@ from sunbeam.storage.backends.dellsc.backend import DellSCBackend from sunbeam.storage.backends.hitachi.backend import HitachiBackend from sunbeam.storage.backends.purestorage.backend import PureStorageBackend +from sunbeam.storage.backends.synology.backend import SynologyBackend @pytest.fixture @@ -29,19 +30,26 @@ def dellsc_backend(): return DellSCBackend() +@pytest.fixture +def synology_backend(): + """Provide a Synology backend instance.""" + return SynologyBackend() + + @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", "synology", "dellpowerstore"]) def any_backend(request): """Parametrized fixture that provides each backend type.""" backends = { "hitachi": HitachiBackend(), "purestorage": PureStorageBackend(), "dellsc": DellSCBackend(), + "synology": SynologyBackend(), "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..9baf3a28c 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, synology_backend, dellpowerstore_backend ): """Test that all backends have unique type identifiers.""" backends = [ hitachi_backend, purestorage_backend, - dellsc_backend, + dellsc_backend, synology_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, synology_backend, dellpowerstore_backend ): """Test that all backends have unique charm names.""" backends = [ hitachi_backend, purestorage_backend, - dellsc_backend, + dellsc_backend, synology_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"), + ("synology", "synology"), ("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"), + ("synology", "cinder-volume-synology"), ("dellpowerstore", "cinder-volume-dellpowerstore"), ], ) diff --git a/sunbeam-python/tests/unit/sunbeam/storage/backends/test_synology.py b/sunbeam-python/tests/unit/sunbeam/storage/backends/test_synology.py new file mode 100644 index 000000000..2cdc3c4e0 --- /dev/null +++ b/sunbeam-python/tests/unit/sunbeam/storage/backends/test_synology.py @@ -0,0 +1,78 @@ +# SPDX-FileCopyrightText: 2026 - Canonical Ltd +# SPDX-License-Identifier: Apache-2.0 + +"""Tests for Synology backend.""" + +import pytest +from pydantic import ValidationError + +from sunbeam.storage.models import SecretDictField +from tests.unit.sunbeam.storage.backends.test_common import BaseBackendTests + + +class TestSynologyBackend(BaseBackendTests): + """Tests for Synology backend.""" + + @pytest.fixture + def backend(self, synology_backend): + """Provide Synology backend instance.""" + return synology_backend + + def test_backend_type_is_synology(self, backend): + """Test that backend type is 'synology'.""" + assert backend.backend_type == "synology" + + def test_charm_name_is_synology_charm(self, backend): + """Test that charm name is cinder-volume-synology.""" + assert backend.charm_name == "cinder-volume-synology" + + def test_config_has_required_fields(self, backend): + """Test that Synology config has required fields.""" + fields = backend.config_type().model_fields + for field in ( + "san_ip", + "protocol", + "synology_password", + "synology_one_time_pass", + ): + assert field in fields, f"Required field {field} not found in config" + + def test_sensitive_fields_are_marked_secret(self, backend): + """Test Synology secret fields are marked as secret.""" + config_class = backend.config_type() + for field_name in ("synology_password", "synology_one_time_pass"): + 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 TestSynologyConfigValidation: + """Test Synology config validation behavior.""" + + def test_protocol_rejects_invalid_value(self, synology_backend): + """Test that protocol rejects values other than iscsi.""" + config_class = synology_backend.config_type() + with pytest.raises(ValidationError): + config_class.model_validate( + { + "san-ip": "192.168.1.1", + "synology-password": "secret", + "synology-one-time-pass": "otp", + "protocol": "fc", + } + ) + + def test_protocol_accepts_iscsi(self, synology_backend): + """Test that protocol accepts iscsi.""" + config_class = synology_backend.config_type() + config = config_class.model_validate( + { + "san-ip": "192.168.1.1", + "synology-password": "secret", + "synology-one-time-pass": "otp", + "protocol": "iscsi", + } + ) + assert config.protocol == "iscsi" From 4868c05717318010a6749c76ce2996972fff3fc8 Mon Sep 17 00:00:00 2001 From: Ahmad Hassan Date: Tue, 14 Apr 2026 18:04:28 +0500 Subject: [PATCH 2/3] fix: tighten Synology backend config validation and naming --- .../storage/backends/synology/backend.py | 34 +++++++++-------- .../sunbeam/storage/backends/test_synology.py | 38 +++++++++++++++++++ 2 files changed, 56 insertions(+), 16 deletions(-) diff --git a/sunbeam-python/sunbeam/storage/backends/synology/backend.py b/sunbeam-python/sunbeam/storage/backends/synology/backend.py index 1337eb17c..d091572c7 100644 --- a/sunbeam-python/sunbeam/storage/backends/synology/backend.py +++ b/sunbeam-python/sunbeam/storage/backends/synology/backend.py @@ -1,8 +1,6 @@ # SPDX-FileCopyrightText: 2026 - Canonical Ltd # SPDX-License-Identifier: Apache-2.0 -# ruff: noqa: E501 - -"""Syno iSCSI backend implementation using base step classes.""" +"""Synology iSCSI backend implementation using base step classes.""" import logging from enum import StrEnum @@ -26,7 +24,7 @@ class Protocol(StrEnum): class SynologyConfig(StorageBackendConfig): - """Configuration model for Syno iSCSI backend. + """Configuration model for Synology iSCSI backend. This model includes ALL configuration options for the backend. Additional configuration can be managed dynamically through the charm. @@ -48,12 +46,15 @@ class SynologyConfig(StorageBackendConfig): ] synology_one_time_pass: Annotated[ - str, + str | None, Field( - description="One time password of administrator for logging in Synology storage if OTP is enabled." + description=( + "One time password of administrator for logging in Synology " + "storage if OTP is enabled." + ) ), SecretDictField(field="synology-one-time-pass"), - ] + ] = None # Optional backend configuration synology_pool_name: Annotated[ @@ -62,35 +63,36 @@ class SynologyConfig(StorageBackendConfig): ] = None synology_admin_port: Annotated[ - int | None, + int, Field(description="Management port for Synology storage."), ] = 5000 synology_username: Annotated[ - str | None, + str, Field(description="Administrator of Synology storage."), ] = "admin" synology_ssl_verify: Annotated[ - bool | None, - Field( - description="Do certificate validation or not if $driver_use_ssl is True" - ), + bool, + Field(description="Verify SSL certificates when connecting over HTTPS."), ] = True synology_device_id: Annotated[ str | None, Field( - description="Device id for skip one time password check for logging in Synology storage if OTP is enabled." + description=( + "Device id for skip one time password check for logging in " + "Synology storage if OTP is enabled." + ) ), ] = None class SynologyBackend(StorageBackendBase): - """Syno iSCSI backend implementation.""" + """Synology iSCSI backend implementation.""" backend_type = "synology" - display_name = "Syno iSCSI" + display_name = "Synology iSCSI" generally_available = True @property diff --git a/sunbeam-python/tests/unit/sunbeam/storage/backends/test_synology.py b/sunbeam-python/tests/unit/sunbeam/storage/backends/test_synology.py index 2cdc3c4e0..e8e3d3b10 100644 --- a/sunbeam-python/tests/unit/sunbeam/storage/backends/test_synology.py +++ b/sunbeam-python/tests/unit/sunbeam/storage/backends/test_synology.py @@ -26,6 +26,10 @@ def test_charm_name_is_synology_charm(self, backend): """Test that charm name is cinder-volume-synology.""" assert backend.charm_name == "cinder-volume-synology" + def test_display_name_uses_synology_branding(self, backend): + """Test that display name consistently uses Synology.""" + assert backend.display_name == "Synology iSCSI" + def test_config_has_required_fields(self, backend): """Test that Synology config has required fields.""" fields = backend.config_type().model_fields @@ -76,3 +80,37 @@ def test_protocol_accepts_iscsi(self, synology_backend): } ) assert config.protocol == "iscsi" + + def test_one_time_password_is_optional(self, synology_backend): + """Test that one-time password is optional when OTP is not enabled.""" + config_class = synology_backend.config_type() + config = config_class.model_validate( + { + "san-ip": "192.168.1.1", + "synology-password": "secret", + "protocol": "iscsi", + } + ) + assert config.synology_one_time_pass is None + + @pytest.mark.parametrize( + "field_name,field_value", + [ + ("synology-admin-port", None), + ("synology-username", None), + ("synology-ssl-verify", None), + ], + ) + def test_optional_defaults_reject_none( + self, synology_backend, field_name, field_value + ): + """Test that non-nullable fields with defaults reject explicit None.""" + config_class = synology_backend.config_type() + payload = { + "san-ip": "192.168.1.1", + "synology-password": "secret", + "protocol": "iscsi", + field_name: field_value, + } + with pytest.raises(ValidationError): + config_class.model_validate(payload) From f2a6b4057943bc6f54020269dd433004c5ea2d35 Mon Sep 17 00:00:00 2001 From: Ahmad Hassan Date: Tue, 21 Apr 2026 16:38:37 +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 41001e94d..6750e34bf 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", "synology", "dellpowerstore"]) +@pytest.fixture( + params=["hitachi", "purestorage", "dellsc", "synology", "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 9baf3a28c..2742d6a9a 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, synology_backend, dellpowerstore_backend + hitachi_backend, + purestorage_backend, + dellsc_backend, + synology_backend, + dellpowerstore_backend, ): """Test that all backends have unique type identifiers.""" backends = [ hitachi_backend, purestorage_backend, - dellsc_backend, synology_backend, + dellsc_backend, + synology_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, synology_backend, dellpowerstore_backend + hitachi_backend, + purestorage_backend, + dellsc_backend, + synology_backend, + dellpowerstore_backend, ): """Test that all backends have unique charm names.""" backends = [ hitachi_backend, purestorage_backend, - dellsc_backend, synology_backend, + dellsc_backend, + synology_backend, dellpowerstore_backend, ] charm_names = [b.charm_name for b in backends]